Class: Match::Otu::TaxonName

Inherits:
Object
  • Object
show all
Includes:
NameBatchMatcher
Defined in:
lib/match/otu/taxon_name.rb

Constant Summary collapse

MATCHABLE_COLUMNS =

Columns that may be interpolated into the raw SQL below.

[:cached, :cached_original_combination].freeze
CANDIDATES_LIMIT =

Candidates gathered per name before ranking.

10
GENUS_RANK_CLASSES =

Genus, species, and subspecies ranks for those codes that have a subgenus rank.

CODES_WITH_SUBGENUS.map { |code|
  Ranks.lookup(code, :genus)
}.freeze
SPECIES_RANK_CLASSES =
CODES_WITH_SUBGENUS.map { |code|
  Ranks.lookup(code, :species)
}.freeze
SUBSPECIES_RANK_CLASSES =
CODES_WITH_SUBGENUS.map { |code|
  Ranks.lookup(code, :subspecies)
}.freeze
VARIETY_RANK_CLASSES =

Deeper ICN-only infraspecific ranks — ICZN/ICNP don't have these.

[Ranks.lookup(:icn, :variety)].freeze
SUBVARIETY_RANK_CLASSES =
[Ranks.lookup(:icn, :subvariety)].freeze
FORM_RANK_CLASSES =
[Ranks.lookup(:icn, :form)].freeze
SUBFORM_RANK_CLASSES =
[Ranks.lookup(:icn, :subform)].freeze
SPECIES_GROUP_MARKERS =
[
  ['subsp', SUBSPECIES_RANK_CLASSES],
  ['subvar', SUBVARIETY_RANK_CLASSES],
  ['var', VARIETY_RANK_CLASSES],
  ['subf', SUBFORM_RANK_CLASSES],
  ['f', FORM_RANK_CLASSES]
].freeze
SPECIES_GROUP_MARKER_RANKS =
SPECIES_GROUP_MARKERS.to_h.freeze
SPECIES_GROUP_MARKER_ALTERNATION =
SPECIES_GROUP_MARKERS.map(&:first).join('|')
SPECIES_GROUP_MARKER_SCAN_PATTERN =

Scans for markers, requiring something after them (the epithet); \b keeps var/f from matching inside subvar./subf..

/\b(#{SPECIES_GROUP_MARKER_ALTERNATION})\.\s+\S/i
SPECIES_GROUP_SPLIT_PATTERN =
/\s*\b(?:#{SPECIES_GROUP_MARKER_ALTERNATION})\.\s+/i
GENUS_GROUP_MARKER_PATTERN =
/\s*\b(?:subg|sgen|subsect|sect|subser|ser)\.\s+\S+/i

Constants included from NameBatchMatcher

NameBatchMatcher::MAX_NAMES

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from NameBatchMatcher

#call

Constructor Details

#initialize(names:, project_id:, levenshtein_distance: 0, taxon_name_id: nil, taxon_name_query: nil, resolve_synonyms: false, try_without_subgenus: false, candidates: nil, match_original_combination: false, use_author_year: false, trigram_prefilter: false) ⇒ TaxonName

Returns a new instance of TaxonName.

Parameters:

  • names (Array<String>)

    array of name strings to match

  • project_id (Integer)
  • levenshtein_distance (Integer) (defaults to: 0)

    0 for exact, 1-8 for fuzzy

  • taxon_name_id (Integer, nil) (defaults to: nil)

    scope matches to descendants of this TaxonName

  • taxon_name_query (Hash, nil) (defaults to: nil)

    scope matches to the result of a Queries::TaxonName::Filter. Takes precedence over taxon_name_id.

  • resolve_synonyms (Boolean) (defaults to: false)

    when true, resolve synonyms to valid names and return their OTUs

  • try_without_subgenus (Boolean) (defaults to: false)

    when true and the plain match fails, retry against other spellings within the same species description complex:

    * subgenus (and other genus-group ranks) ignored,
    * species-group epithets gender-tolerant,
    * genus matched as either current or original.
  • candidates (Integer, nil) (defaults to: nil)

    when set, include the ranked match set, capped at this many

  • match_original_combination (Boolean) (defaults to: false)

    when true, match cached_original_combination alongside cached

  • use_author_year (Boolean) (defaults to: false)

    when true, strip a parseable author/year from the name before matching and use it to differentiate when more than one candidate matches

  • trigram_prefilter (Boolean) (defaults to: false)

    when true, narrow fuzzy candidates with the pg_trgm similarity operator before computing levenshtein distance



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/match/otu/taxon_name.rb', line 101

def initialize(
  names:, project_id:, levenshtein_distance: 0, taxon_name_id: nil,
  taxon_name_query: nil, resolve_synonyms: false,
  try_without_subgenus: false, candidates: nil,
  match_original_combination: false, use_author_year: false,
  trigram_prefilter: false
)
  @names = names.first(NameBatchMatcher::MAX_NAMES)
  @project_id = project_id
  @levenshtein_distance = levenshtein_distance.to_i.clamp(0, 8)
  @taxon_name_id = taxon_name_id
  @taxon_name_query = taxon_name_query
  @resolve_synonyms = resolve_synonyms
  @try_without_subgenus = try_without_subgenus
  @candidates = candidates&.to_i
  @match_original_combination = match_original_combination
  @use_author_year = use_author_year
  @trigram_prefilter = trigram_prefilter
end

Instance Attribute Details

#candidatesObject (readonly)

Returns the value of attribute candidates.



71
72
73
# File 'lib/match/otu/taxon_name.rb', line 71

def candidates
  @candidates
end

#levenshtein_distanceObject (readonly)

Returns the value of attribute levenshtein_distance.



71
72
73
# File 'lib/match/otu/taxon_name.rb', line 71

def levenshtein_distance
  @levenshtein_distance
end

#match_original_combinationObject (readonly)

Returns the value of attribute match_original_combination.



71
72
73
# File 'lib/match/otu/taxon_name.rb', line 71

def match_original_combination
  @match_original_combination
end

#namesObject (readonly)

Returns the value of attribute names.



71
72
73
# File 'lib/match/otu/taxon_name.rb', line 71

def names
  @names
end

#project_idObject (readonly)

Returns the value of attribute project_id.



71
72
73
# File 'lib/match/otu/taxon_name.rb', line 71

def project_id
  @project_id
end

#resolve_synonymsObject (readonly)

Returns the value of attribute resolve_synonyms.



71
72
73
# File 'lib/match/otu/taxon_name.rb', line 71

def resolve_synonyms
  @resolve_synonyms
end

#taxon_name_idObject (readonly)

Returns the value of attribute taxon_name_id.



71
72
73
# File 'lib/match/otu/taxon_name.rb', line 71

def taxon_name_id
  @taxon_name_id
end

#taxon_name_queryObject (readonly)

Returns the value of attribute taxon_name_query.



71
72
73
# File 'lib/match/otu/taxon_name.rb', line 71

def taxon_name_query
  @taxon_name_query
end

#trigram_prefilterObject (readonly)

Returns the value of attribute trigram_prefilter.



71
72
73
# File 'lib/match/otu/taxon_name.rb', line 71

def trigram_prefilter
  @trigram_prefilter
end

#try_without_subgenusObject (readonly)

Returns the value of attribute try_without_subgenus.



71
72
73
# File 'lib/match/otu/taxon_name.rb', line 71

def try_without_subgenus
  @try_without_subgenus
end

#use_author_yearObject (readonly)

Returns the value of attribute use_author_year.



71
72
73
# File 'lib/match/otu/taxon_name.rb', line 71

def use_author_year
  @use_author_year
end

Instance Method Details

#anchor_chain_ids(genus_name, anchors) ⇒ ActiveRecord::Relation (private)

A relation of ids for the deepest anchor: walks anchors (highest rank first), each matched the same gender-tolerant way as the terminal, each required to be the parent of the next, ultimately rooted so genus_name is some match — current or original — for the highest rank one.

Parameters:

  • genus_name (String)
  • anchors (Array<Array(String, Array<String>)>)

Returns:

  • (ActiveRecord::Relation)


317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# File 'lib/match/otu/taxon_name.rb', line 317

def anchor_chain_ids(genus_name, anchors)
  ids = nil

  anchors.each_with_index do |(epithet, rank_classes), i|
    level = epithet_scope(base_scope, epithet, rank_classes)

    level = if i.zero?
        genus_match_scope(level, genus_name)
      else
        level.where(parent_id: ids)
      end

    ids = level.select(:id)
  end

  ids
end

#base_scopeActiveRecord::Relation (private)

Build the base TaxonName scope, optionally constrained to a TaxonName query result or to descendants of taxon_name_id.

Returns:

  • (ActiveRecord::Relation)


470
471
472
473
474
475
476
477
478
479
480
481
482
# File 'lib/match/otu/taxon_name.rb', line 470

def base_scope
  scope = ::TaxonName.where(project_id: project_id)

  if taxon_name_query.present?
    scope = scope.where(id: taxon_name_query_scope)
  elsif taxon_name_id.present?
    scope = scope
      .joins('JOIN taxon_name_hierarchies ON taxon_names.id = taxon_name_hierarchies.descendant_id')
      .where(taxon_name_hierarchies: { ancestor_id: taxon_name_id })
  end

  scope
end

#default_columnArray<Symbol> (private)

Returns:

  • (Array<Symbol>)


192
193
194
195
196
197
198
# File 'lib/match/otu/taxon_name.rb', line 192

def default_column
  if match_original_combination
    [:cached, :cached_original_combination]
  else
    [:cached]
  end
end

#differentiate_by_author_year(taxon_names, parsed) ⇒ Array<TaxonName> (private)

Mirrors Vendor::Biodiversity::Result#scope_to_author_year: when the author/year matches candidates, use only those; when it matches none, ignore it rather than discarding every candidate.

Parameters:

  • taxon_names (Array<TaxonName>)
  • parsed (Hash)

Returns:



533
534
535
536
537
538
539
540
541
542
543
# File 'lib/match/otu/taxon_name.rb', line 533

def differentiate_by_author_year(taxon_names, parsed)
  author_year = parsed[:author_year]
  return taxon_names if author_year.blank?

  alternate = author_year.gsub(' & ', ' and ')
  matching = taxon_names.select { |tn|
    [author_year, alternate].include?(tn.cached_author_year)
  }

  matching.presence || taxon_names
end

#distance_sql(columns, name) ⇒ String (private)

Returns sanitized SQL for the distance to the nearest of columns.

Parameters:

  • columns (Array<Symbol>)
  • name (String)

Returns:

  • (String)

    sanitized SQL for the distance to the nearest of columns



457
458
459
460
461
462
463
464
465
# File 'lib/match/otu/taxon_name.rb', line 457

def distance_sql(columns, name)
  parts = columns.collect do |column|
    ::TaxonName.sanitize_sql_array(
      ["levenshtein(left(taxon_names.#{column}, 255), ?)", name]
    )
  end

  parts.one? ? parts.first : "LEAST(#{parts.join(', ')})"
end

#epithet_scope(scope, epithet, rank_classes) ⇒ ActiveRecord::Relation (private)

A candidate matches either by being an exact hit, or by matching one of the predicted gender forms.

Parameters:

  • scope (ActiveRecord::Relation)
  • epithet (String)

    the raw epithet as typed

  • rank_classes (Array<String>)

    restricts scope to candidates of these ranks

Returns:

  • (ActiveRecord::Relation)


342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/match/otu/taxon_name.rb', line 342

def epithet_scope(scope, epithet, rank_classes)
  downcased = epithet.downcase
  forms = Utilities::Nomenclature.predict_three_forms(downcased).values.uniq
  scoped = scope.where(rank_class: rank_classes)

  if levenshtein_distance > 0
    scoped
      .where('levenshtein(left(taxon_names.name, 255), ?) <= ?',
        downcased, levenshtein_distance
      )
      .or(scoped.where(name: forms))
  else
    scoped.where(name: ([downcased] + forms).uniq)
  end
end

#find_taxon_names(name, columns: default_column) ⇒ Array<TaxonName> (private)

Parameters:

  • name (String)
  • columns (Array<Symbol>) (defaults to: default_column)

    subset of MATCHABLE_COLUMNS

Returns:



403
404
405
406
407
408
409
410
411
412
413
# File 'lib/match/otu/taxon_name.rb', line 403

def find_taxon_names(name, columns: default_column)
  columns.each do |column|
    raise ArgumentError, "Invalid column: #{column}" unless MATCHABLE_COLUMNS.include?(column)
  end

  if levenshtein_distance > 0
    find_taxon_names_fuzzy(name, columns:)
  else
    find_taxon_names_exact(name, columns:)
  end
end

#find_taxon_names_exact(name, columns:) ⇒ Array<TaxonName> (private)

Parameters:

  • name (String)
  • columns (Array<Symbol>)

Returns:



418
419
420
421
422
423
# File 'lib/match/otu/taxon_name.rb', line 418

def find_taxon_names_exact(name, columns:)
  clause = columns.collect { |column|
    "taxon_names.#{column} = ?"
  }.join(' OR ')
  base_scope.where(clause, *Array.new(columns.size, name)).to_a
end

#find_taxon_names_fuzzy(name, columns:) ⇒ Array<TaxonName> (private)

Parameters:

  • name (String)
  • columns (Array<Symbol>)

Returns:



428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
# File 'lib/match/otu/taxon_name.rb', line 428

def find_taxon_names_fuzzy(name, columns:)
  truncated_name = name[0..254]
  scope = base_scope

  # levenshtein() can not be indexed, so without this every name in the
  # batch scans taxon_names. The pg_trgm operator uses the GIN trigram
  # indexes on these columns to narrow the set first. Very short strings
  # can fall below the similarity threshold, so this trades some fuzzy
  # recall for a query that is viable at page scale.
  if trigram_prefilter
    similarity = columns.collect { |column|
      "taxon_names.#{column} % ?"
    }.join(' OR ')
    scope = scope.where(similarity, *Array.new(columns.size, truncated_name))
  end

  distance = distance_sql(columns, truncated_name)

  scope
    .where("#{distance} <= ?", levenshtein_distance)
    .order(Arel.sql(distance))
    .limit(CANDIDATES_LIMIT)
    .to_a
end

#find_taxon_names_ignoring_subgenus(search_string) ⇒ Array<TaxonName> (private)

Genus-group content (subgenus, section, series...) is always stripped first — it's never read. What's left is either explicitly marked with species-group rank abbreviations (subsp./var./f./...), in which case every rank present is matched gender-tolerantly, anchored in sequence; or it's bare, in which case word count (plus capitalization, to spot a bare subgenus) decides the shape. The genus itself may match either the current classification or the genus a name was originally described in.

Parameters:

  • search_string (String)

Returns:



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/match/otu/taxon_name.rb', line 211

def find_taxon_names_ignoring_subgenus(search_string)
  # Remove all name words explicitly marked by a genus group marker, like
  # 'subg. Bus'
  stripped = search_string.gsub(GENUS_GROUP_MARKER_PATTERN, ' ').squish
  # markers are rank indicator words, like ['subsp', 'var'] e.g.
  markers = stripped.scan(SPECIES_GROUP_MARKER_SCAN_PATTERN).flatten.map(&:downcase)

  # If the search string uses any marker words then we assume it uses
  # them consistently:
  return find_via_species_group_markers(stripped:, markers:) if markers.any?

  # If there are no marker words then we attempt to match by position:
  words = stripped.split(' ')

  case words.length
  when 2 # 'Aus bus'
    find_via_species_group_chain(
      genus_name: words.first,
      anchors: [],
      terminal_epithet: words.last,
      terminal_rank_classes: SPECIES_RANK_CLASSES
    )
  when 3
    if words[1].start_with?('(') || words[1] =~ /\A[[:upper:]]/
      # 'Aus (Bus) cus' or 'Aus Bus cus'
      find_via_species_group_chain(
        genus_name: words.first,
        anchors: [],
        terminal_epithet: words.last,
        terminal_rank_classes: SPECIES_RANK_CLASSES
      )
    else
      # Aus bus cus
      find_via_species_group_chain(
        genus_name: words.first,
        anchors: [[words[1], SPECIES_RANK_CLASSES]],
        terminal_epithet: words.last,
        terminal_rank_classes: SUBSPECIES_RANK_CLASSES
      )
    end
  when 4 # 'Aus (Bus) cus dus'
    find_via_species_group_chain(
      genus_name: words.first,
      anchors: [[words[-2], SPECIES_RANK_CLASSES]],
      terminal_epithet: words.last,
      terminal_rank_classes: SUBSPECIES_RANK_CLASSES
    )
  else # we could only guess: don't guess
    []
  end
end

#find_via_species_group_chain(genus_name:, anchors:, terminal_epithet:, terminal_rank_classes:) ⇒ Array<TaxonName> (private)

Parameters:

  • genus_name (String)

    exact genus name — current or original

  • anchors (Array<Array(String, Array<String>)>)

    ordered [epithet, rank_classes] pairs, shallowest first — each an ancestor the terminal must descend through, matched the same gender-tolerant way as the terminal (see #epithet_scope)

  • terminal_epithet (String)

    the terminal word as typed; matched against its predicted gender forms, not the raw string

  • terminal_rank_classes (Array<String>)

Returns:



298
299
300
301
302
303
304
305
306
307
308
# File 'lib/match/otu/taxon_name.rb', line 298

def find_via_species_group_chain(
  genus_name:, anchors:, terminal_epithet:, terminal_rank_classes:
)
  scope = epithet_scope(base_scope, terminal_epithet, terminal_rank_classes)

  if anchors.empty?
    genus_match_scope(scope, genus_name).to_a
  else
    scope.where(parent_id: anchor_chain_ids(genus_name, anchors)).to_a
  end
end

#find_via_species_group_markers(stripped:, markers:) ⇒ Array<TaxonName> (private)

Parameters:

  • stripped (String)

    the search string with genus-group markers already removed

  • markers (Array<String>)

    the species-group markers found, in appearance order (e.g. ['subsp', 'var'])

Returns:



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/match/otu/taxon_name.rb', line 268

def find_via_species_group_markers(stripped:, markers:)
  segments = stripped.split(SPECIES_GROUP_SPLIT_PATTERN)
  return [] unless segments.size == markers.size + 1
  return [] if segments[1..].any? { |segment| segment.include?(' ') }

  first_words = segments.first.split(' ') # 'Aus bus', genus + species
  return [] if first_words.size < 2

  anchors = [[first_words.last, SPECIES_RANK_CLASSES]]
  anchors += segments[1..-2].each_with_index.map do |epithet, i|
    [epithet, SPECIES_GROUP_MARKER_RANKS.fetch(markers[i])]
  end

  find_via_species_group_chain(
    genus_name: first_words.first,
    anchors:,
    terminal_epithet: segments.last,
    terminal_rank_classes: SPECIES_GROUP_MARKER_RANKS.fetch(markers.last)
  )
end

#genuinely_ambiguous?(ranked) ⇒ Boolean (private)

Multiple candidate rows aren't ambiguous if they all resolve to the same valid taxon (e.g. a Combination alongside its own Protonym) — ranking always picks correctly there. Only flag it when candidates point to genuinely different valid taxa (e.g. true homonyms).

Parameters:

Returns:

  • (Boolean)


187
188
189
# File 'lib/match/otu/taxon_name.rb', line 187

def genuinely_ambiguous?(ranked)
  ranked.map(&:cached_valid_taxon_name_id).uniq.length > 1
end

#genus_match_scope(scope, genus_name) ⇒ ActiveRecord::Relation (private)

Filters scope to rows associated with genus_name, either as the current classification or original combination.

Parameters:

  • scope (ActiveRecord::Relation)
  • genus_name (String)

Returns:

  • (ActiveRecord::Relation)


363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
# File 'lib/match/otu/taxon_name.rb', line 363

def genus_match_scope(scope, genus_name)
  genus_relation = ::TaxonName.where(project_id:, rank_class: GENUS_RANK_CLASSES)
  genus_relation = if levenshtein_distance > 0
    genus_relation.where(
      'levenshtein(left(taxon_names.name, 255), ?) <= ?', genus_name, levenshtein_distance
    )
  else
    genus_relation.where(name: genus_name)
  end

  genus_ids_sql = genus_relation.select(:id).to_sql

  # Correlated non-AR `EXISTS`, not an AR `.where.not(id: subquery)` or
  # similar — this can match against thousands of descendants of a
  # common genus, and a correlated per-candidate lookup stays cheap
  # where hashing that whole set would not.
  scope.where(
    <<~SQL.squish,
      (
        EXISTS (
          SELECT 1 FROM taxon_name_hierarchies tnh
          WHERE tnh.descendant_id = taxon_names.id
            AND tnh.generations >= 1
            AND tnh.ancestor_id IN (#{genus_ids_sql})
        )
        OR EXISTS (
          SELECT 1 FROM taxon_name_relationships tnr
          WHERE tnr.object_taxon_name_id = taxon_names.id
            AND tnr.type = ?
            AND tnr.subject_taxon_name_id IN (#{genus_ids_sql})
        )
      )
    SQL
    'TaxonNameRelationship::OriginalCombination::OriginalGenus'
  )
end

#match_name(name) ⇒ Hash (private)

Parameters:

  • name (String)

Returns:

  • (Hash)


125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/match/otu/taxon_name.rb', line 125

def match_name(name)
  parsed = parsed_author_year(name)
  search_string = parsed ? parsed[:name] : name

  taxon_names = find_taxon_names(search_string)

  # TODO: there could be another match without_subgenus even if
  # taxon_names.present?, which would signal ambiguity.
  if taxon_names.empty? && try_without_subgenus
    taxon_names = find_taxon_names_ignoring_subgenus(search_string)
  end

  if parsed && taxon_names.size > 1
    taxon_names = differentiate_by_author_year(taxon_names, parsed)
  end

  return no_match if taxon_names.empty?

  ranked = rank_taxon_names(taxon_names)
  matched = ranked.first
  resolved = matched

  if resolve_synonyms && matched.cached_valid_taxon_name_id != matched.id
    valid = ::TaxonName
      .where(project_id: project_id)
      .find_by(id: matched.cached_valid_taxon_name_id)
    resolved = valid if valid
  end

  otus = ::Otu.where(project_id: project_id, taxon_name_id: resolved.id).to_a

  result = {
    taxon_name_id: resolved.id,
    taxon_name: resolved,
    otus: otus,
    ambiguous: genuinely_ambiguous?(ranked),
    matched: true
  }

  result[:candidates] = ranked.first(candidates) if candidates
  result
end

#no_matchHash (private)

Returns:

  • (Hash)


169
170
171
172
173
174
175
176
177
178
179
# File 'lib/match/otu/taxon_name.rb', line 169

def no_match
  result = {
    taxon_name_id: nil,
    taxon_name: nil,
    otus: [],
    ambiguous: false,
    matched: false
  }
  result[:candidates] = [] if candidates
  result
end

#parsed_author_year(name) ⇒ Hash? (private)

Parse an author/year off the name, when there is one to parse. Memoized per unique string — the parser is comparatively expensive and names repeat.

Parameters:

  • name (String)

Returns:

  • (Hash, nil)

    {name: <name without the author/year>, author_year: <'Smith, 1920'>}, or nil when the string is unparseable or carries no author/year.



501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
# File 'lib/match/otu/taxon_name.rb', line 501

def parsed_author_year(name)
  return nil unless use_author_year

  @parsed_author_years ||= {}
  return @parsed_author_years[name] if @parsed_author_years.key?(name)

  @parsed_author_years[name] = begin
    result = ::Vendor::Biodiversity::Result.new(
      query_string: name, project_id: project_id
    )

    if result.parseable && result.is_authored?
      {
        name: result.name_without_author_year,
        author_year: result.author_year
      }
    else
      nil
    end
  rescue StandardError
    # Arbitrary curator-supplied strings reach the parser; an unparseable
    # one simply matches with its author/year left in place.
    nil
  end
end

#rank_taxon_names(taxon_names) ⇒ Array<TaxonName> (private)

Rank candidate TaxonNames:

1. Prefer those with OTUs
2. Prefer valid names

Parameters:

Returns:



550
551
552
553
554
555
556
557
558
559
560
561
562
563
# File 'lib/match/otu/taxon_name.rb', line 550

def rank_taxon_names(taxon_names)
  taxon_name_ids = taxon_names.map(&:id)
  ids_with_otus = ::Otu.where(
    project_id: project_id,
    taxon_name_id: taxon_name_ids
  ).distinct.pluck(:taxon_name_id).to_set

  taxon_names.sort_by do |tn|
    [
      ids_with_otus.include?(tn.id) ? 0 : 1,
      tn.cached_valid_taxon_name_id == tn.id ? 0 : 1
    ]
  end
end

#taxon_name_query_scopeActiveRecord::Relation (private)

Memoized — the same subquery serves every name in the batch.

Returns:

  • (ActiveRecord::Relation)


486
487
488
489
490
491
492
# File 'lib/match/otu/taxon_name.rb', line 486

def taxon_name_query_scope
  @taxon_name_query_scope ||= ::Queries::TaxonName::Filter
    .new(taxon_name_query.merge(project_id: project_id))
    .all
    .unscope(:order)
    .select(:id)
end