Class: Match::Otu::TaxonName

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

Constant Summary collapse

MAX_NAMES =
3000
MATCHABLE_COLUMNS =

Columns that may be interpolated into the raw SQL below.

[
  :cached, :cached_original_combination, :cached_secondary_homonym, :cached_primary_homonym
].freeze
FUZZY_LIMIT =

Candidates gathered per name before ranking.

10

Instance Attribute Summary collapse

Instance Method Summary collapse

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 cached match fails, try cached_secondary_homonym then cached_primary_homonym

  • 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



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/match/otu/taxon_name.rb', line 63

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(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.



45
46
47
# File 'lib/match/otu/taxon_name.rb', line 45

def candidates
  @candidates
end

#levenshtein_distanceObject (readonly)

Returns the value of attribute levenshtein_distance.



45
46
47
# File 'lib/match/otu/taxon_name.rb', line 45

def levenshtein_distance
  @levenshtein_distance
end

#match_original_combinationObject (readonly)

Returns the value of attribute match_original_combination.



45
46
47
# File 'lib/match/otu/taxon_name.rb', line 45

def match_original_combination
  @match_original_combination
end

#namesObject (readonly)

Returns the value of attribute names.



45
46
47
# File 'lib/match/otu/taxon_name.rb', line 45

def names
  @names
end

#project_idObject (readonly)

Returns the value of attribute project_id.



45
46
47
# File 'lib/match/otu/taxon_name.rb', line 45

def project_id
  @project_id
end

#resolve_synonymsObject (readonly)

Returns the value of attribute resolve_synonyms.



45
46
47
# File 'lib/match/otu/taxon_name.rb', line 45

def resolve_synonyms
  @resolve_synonyms
end

#taxon_name_idObject (readonly)

Returns the value of attribute taxon_name_id.



45
46
47
# File 'lib/match/otu/taxon_name.rb', line 45

def taxon_name_id
  @taxon_name_id
end

#taxon_name_queryObject (readonly)

Returns the value of attribute taxon_name_query.



45
46
47
# File 'lib/match/otu/taxon_name.rb', line 45

def taxon_name_query
  @taxon_name_query
end

#trigram_prefilterObject (readonly)

Returns the value of attribute trigram_prefilter.



45
46
47
# File 'lib/match/otu/taxon_name.rb', line 45

def trigram_prefilter
  @trigram_prefilter
end

#try_without_subgenusObject (readonly)

Returns the value of attribute try_without_subgenus.



45
46
47
# File 'lib/match/otu/taxon_name.rb', line 45

def try_without_subgenus
  @try_without_subgenus
end

#use_author_yearObject (readonly)

Returns the value of attribute use_author_year.



45
46
47
# File 'lib/match/otu/taxon_name.rb', line 45

def use_author_year
  @use_author_year
end

Instance Method Details

#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)


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

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

#callArray<Hash>

Returns:

  • (Array<Hash>)


81
82
83
84
85
86
87
88
89
90
# File 'lib/match/otu/taxon_name.rb', line 81

def call
  unique_names = names.uniq
  match_cache = {}

  unique_names.each do |name|
    match_cache[name] = match_name(name)
  end

  names.map { |name| match_cache[name].merge(scientific_name: name) }
end

#default_columnsArray<Symbol> (private)

Returns:

  • (Array<Symbol>)


157
158
159
# File 'lib/match/otu/taxon_name.rb', line 157

def default_columns
  match_original_combination ? [:cached, :cached_original_combination] : [:cached]
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:



282
283
284
285
286
287
288
289
290
# File 'lib/match/otu/taxon_name.rb', line 282

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



212
213
214
215
216
217
218
219
220
# File 'lib/match/otu/taxon_name.rb', line 212

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

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

Parameters:

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

    subset of MATCHABLE_COLUMNS

Returns:



164
165
166
167
168
169
170
171
172
173
174
# File 'lib/match/otu/taxon_name.rb', line 164

def find_taxon_names(name, columns: default_columns)
  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:



179
180
181
182
# File 'lib/match/otu/taxon_name.rb', line 179

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:



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/match/otu/taxon_name.rb', line 187

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(FUZZY_LIMIT)
    .to_a
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)


152
153
154
# File 'lib/match/otu/taxon_name.rb', line 152

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

#match_name(name) ⇒ Hash (private)

Parameters:

  • name (String)

Returns:

  • (Hash)


96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/match/otu/taxon_name.rb', line 96

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

  taxon_names = find_taxon_names(search_string)

  if taxon_names.empty? && try_without_subgenus
    taxon_names = find_taxon_names(search_string, columns: [:cached_secondary_homonym])
    if taxon_names.empty?
      taxon_names = find_taxon_names(search_string, columns: [:cached_primary_homonym])
    end
  end

  # An unambiguous match needs no differentiating.
  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)


140
141
142
143
144
# File 'lib/match/otu/taxon_name.rb', line 140

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.



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/match/otu/taxon_name.rb', line 255

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:



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

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)


241
242
243
244
245
246
247
# File 'lib/match/otu/taxon_name.rb', line 241

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