Class: Match::Otu::TaxonName
- Inherits:
-
Object
- Object
- Match::Otu::TaxonName
- 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
-
#candidates ⇒ Object
readonly
Returns the value of attribute candidates.
-
#levenshtein_distance ⇒ Object
readonly
Returns the value of attribute levenshtein_distance.
-
#match_original_combination ⇒ Object
readonly
Returns the value of attribute match_original_combination.
-
#names ⇒ Object
readonly
Returns the value of attribute names.
-
#project_id ⇒ Object
readonly
Returns the value of attribute project_id.
-
#resolve_synonyms ⇒ Object
readonly
Returns the value of attribute resolve_synonyms.
-
#taxon_name_id ⇒ Object
readonly
Returns the value of attribute taxon_name_id.
-
#taxon_name_query ⇒ Object
readonly
Returns the value of attribute taxon_name_query.
-
#trigram_prefilter ⇒ Object
readonly
Returns the value of attribute trigram_prefilter.
-
#try_without_subgenus ⇒ Object
readonly
Returns the value of attribute try_without_subgenus.
-
#use_author_year ⇒ Object
readonly
Returns the value of attribute use_author_year.
Instance Method Summary collapse
-
#base_scope ⇒ ActiveRecord::Relation
private
Build the base TaxonName scope, optionally constrained to a TaxonName query result or to descendants of taxon_name_id.
- #call ⇒ Array<Hash>
- #default_columns ⇒ Array<Symbol> private
-
#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.
-
#distance_sql(columns, name) ⇒ String
private
Sanitized SQL for the distance to the nearest of
columns. - #find_taxon_names(name, columns: default_columns) ⇒ Array<TaxonName> private
- #find_taxon_names_exact(name, columns:) ⇒ Array<TaxonName> private
- #find_taxon_names_fuzzy(name, columns:) ⇒ Array<TaxonName> private
-
#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.
-
#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
constructor
A new instance of TaxonName.
- #match_name(name) ⇒ Hash private
- #no_match ⇒ Hash private
-
#parsed_author_year(name) ⇒ Hash?
private
Parse an author/year off the name, when there is one to parse.
-
#rank_taxon_names(taxon_names) ⇒ Array<TaxonName>
private
Rank candidate TaxonNames: 1.
-
#taxon_name_query_scope ⇒ ActiveRecord::Relation
private
Memoized — the same subquery serves every name in the batch.
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.
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 = @trigram_prefilter = trigram_prefilter end |
Instance Attribute Details
#candidates ⇒ Object (readonly)
Returns the value of attribute candidates.
45 46 47 |
# File 'lib/match/otu/taxon_name.rb', line 45 def candidates @candidates end |
#levenshtein_distance ⇒ Object (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_combination ⇒ Object (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 |
#names ⇒ Object (readonly)
Returns the value of attribute names.
45 46 47 |
# File 'lib/match/otu/taxon_name.rb', line 45 def names @names end |
#project_id ⇒ Object (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_synonyms ⇒ Object (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_id ⇒ Object (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_query ⇒ Object (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_prefilter ⇒ Object (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_subgenus ⇒ Object (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_year ⇒ Object (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 end |
Instance Method Details
#base_scope ⇒ ActiveRecord::Relation (private)
Build the base TaxonName scope, optionally constrained to a TaxonName query result or to descendants of taxon_name_id.
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 |
#call ⇒ 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_columns ⇒ Array<Symbol> (private)
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.
282 283 284 285 286 287 288 289 290 |
# File 'lib/match/otu/taxon_name.rb', line 282 def (taxon_names, parsed) = parsed[:author_year] return taxon_names if .blank? alternate = .gsub(' & ', ' and ') matching = taxon_names.select { |tn| [, alternate].include?(tn.) } matching.presence || taxon_names end |
#distance_sql(columns, name) ⇒ String (private)
Returns 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)
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)
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)
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).
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)
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 = (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 = (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_match ⇒ Hash (private)
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.
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 (name) return nil unless @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. { name: result., author_year: result. } 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
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_scope ⇒ ActiveRecord::Relation (private)
Memoized — the same subquery serves every name in the batch.
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 |