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, try_without_subgenus_after_exact_match: 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.
  • try_without_subgenus_after_exact_match (Boolean) (defaults to: false) —

    when true (and try_without_subgenus is true), also retry after the plain match succeeds, so that a different taxon matched without subgenus makes the result ambiguous. Costs an extra query per name, so it's off by default for large batches.

  • 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



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/match/otu/taxon_name.rb', line 106

def initialize(
  names:, project_id:, levenshtein_distance: 0, taxon_name_id: nil,
  taxon_name_query: nil, resolve_synonyms: false,
  try_without_subgenus: false,
  try_without_subgenus_after_exact_match: 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
  @try_without_subgenus_after_exact_match = try_without_subgenus_after_exact_match
  @candidates = candidates&.to_i
  @match_original_combination = match_original_combination
  @use_author_year = use_author_year
  @trigram_prefilter = trigram_prefilter
end

Instance Attribute Details

#candidates ⇒ Object (readonly)

Returns the value of attribute candidates.



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

def candidates
  @candidates
end

#levenshtein_distance ⇒ Object (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_combination ⇒ Object (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

#names ⇒ Object (readonly)

Returns the value of attribute names.



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

def names
  @names
end

#project_id ⇒ Object (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_synonyms ⇒ Object (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_id ⇒ Object (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_query ⇒ Object (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_prefilter ⇒ Object (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_subgenus ⇒ Object (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

#try_without_subgenus_after_exact_match ⇒ Object (readonly)

Returns the value of attribute try_without_subgenus_after_exact_match.



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

def try_without_subgenus_after_exact_match
  @try_without_subgenus_after_exact_match
end

#use_author_year ⇒ Object (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)


363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
# File 'lib/match/otu/taxon_name.rb', line 363

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_scope ⇒ ActiveRecord::Relation (private)

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

Returns:

  • (ActiveRecord::Relation)


516
517
518
519
520
521
522
523
524
525
526
527
528
# File 'lib/match/otu/taxon_name.rb', line 516

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_column ⇒ Array<Symbol> (private)

Returns:

  • (Array<Symbol>)


238
239
240
241
242
243
244
# File 'lib/match/otu/taxon_name.rb', line 238

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:



579
580
581
582
583
584
585
586
587
588
589
# File 'lib/match/otu/taxon_name.rb', line 579

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



503
504
505
506
507
508
509
510
511
# File 'lib/match/otu/taxon_name.rb', line 503

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)


388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'lib/match/otu/taxon_name.rb', line 388

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:



449
450
451
452
453
454
455
456
457
458
459
# File 'lib/match/otu/taxon_name.rb', line 449

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:



464
465
466
467
468
469
# File 'lib/match/otu/taxon_name.rb', line 464

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:



474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
# File 'lib/match/otu/taxon_name.rb', line 474

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:



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/match/otu/taxon_name.rb', line 257

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:



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

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:



314
315
316
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 314

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 are ambiguous when they're different names, e.g. true homonyms, or a junior homonym alongside the valid name it's invalidated by - the curator has to choose. A Combination alongside its own Protonym is one name, not two, and ranking picks correctly there. When resolving synonyms, the result is the valid name regardless, so only different valid taxa are ambiguous.

Parameters:

Returns:

  • (Boolean)


203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/match/otu/taxon_name.rb', line 203

def genuinely_ambiguous?(ranked)
  return false if ranked.size < 2

  keys =
    if resolve_synonyms
      ranked.map(&:cached_valid_taxon_name_id)
    else
      ranked.map { |tn| name_key(tn) }
    end

  keys.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)


409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
# File 'lib/match/otu/taxon_name.rb', line 409

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)


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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/match/otu/taxon_name.rb', line 132

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

  taxon_names = find_taxon_names(search_string)

  # There could be another match without subgenus even when
  # taxon_names.present?, which would signal ambiguity - only checked
  # when asked for, since it costs an extra query per name.
  if try_without_subgenus
    if taxon_names.empty?
      taxon_names = find_taxon_names_ignoring_subgenus(search_string)
    elsif try_without_subgenus_after_exact_match
      taxon_names = (
        taxon_names + find_taxon_names_ignoring_subgenus(search_string)
      ).uniq(&:id)
    end
  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

#name_key(taxon_name) ⇒ Integer (private)

Returns the id of the name taxon_name is a spelling of: the Protonym (for a Combination its finest one), or, when that's nominotypical, the ancestor it's nominotypical of - e.g. 'Aus bus bus' is a spelling of 'Aus bus', as they share name and type; ICN autonyms can chain ('Aus bus var. bus f. bus').

Parameters:

Returns:

  • (Integer) —

    the id of the name taxon_name is a spelling of: the Protonym (for a Combination its finest one), or, when that's nominotypical, the ancestor it's nominotypical of - e.g. 'Aus bus bus' is a spelling of 'Aus bus', as they share name and type; ICN autonyms can chain ('Aus bus var. bus f. bus')



222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/match/otu/taxon_name.rb', line 222

def name_key(taxon_name)
  protonym = taxon_name.is_combination? ?
    taxon_name.finest_protonym :
    taxon_name
  # Either a hybrid or a Combination with bad parts:
  return taxon_name.id if !protonym.is_a?(::Protonym)

  while (parent = protonym.parent) &&
      protonym.nominotypical_sub_of?(parent)
    protonym = parent
  end

  protonym.id
end

#no_match ⇒ Hash (private)

Returns:

  • (Hash)


183
184
185
186
187
188
189
190
191
192
193
# File 'lib/match/otu/taxon_name.rb', line 183

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.



547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
# File 'lib/match/otu/taxon_name.rb', line 547

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:



596
597
598
599
600
601
602
603
604
605
606
607
608
609
# File 'lib/match/otu/taxon_name.rb', line 596

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_scope ⇒ ActiveRecord::Relation (private)

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

Returns:

  • (ActiveRecord::Relation)


532
533
534
535
536
537
538
# File 'lib/match/otu/taxon_name.rb', line 532

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