Skip to content

Fix: Species rarity lookup does not use the active geomodel taxonomy (#3973) - #3978

Merged
tphakala merged 5 commits into
mainfrom
fix/issue-3973-geomodel-taxonomy
Jul 23, 2026
Merged

tphakala merged 5 commits into
mainfrom
fix/issue-3973-geomodel-taxonomy

Conversation

@birdnet-go-bot

@birdnet-go-bot birdnet-go-bot commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #3973.

Species rarity was answered against the wrong vocabulary and without taxonomic alias resolution, so a species the geomodel can rank reported Unknown whenever the primary classifier could not name it, and a species named by a legacy synonym missed its score entirely.

What changed

Rarity coverage is decided against the geomodel's vocabulary, not the primary classifier's. speciesHasGeomodelCoverage now takes the label sets rather than the orchestrator, and Orchestrator.GetRarityContext supplies the scores and both vocabularies from one snapshot. With the universal geomodel active this is a ~12K-species vocabulary spanning birds, bats, other mammals and insects, so a below-threshold species the classifier cannot name now correctly reads Very Rare instead of Unknown. When no universal geomodel produced the scores the geomodel vocabulary is empty and coverage falls back to the classifier's labels, which is the correct vocabulary for the TFLite meta model and the plain ONNX range filter rather than a degraded approximation.

Scientific-name matching resolves taxonomic aliases, with the exact name always tried first. Every match tries the exact name across the whole vocabulary before consulting openfauna.CanonicalName. That ordering is load-bearing: the alias map merges two pairs BirdNET v2.4 ships as separate species (Dicrurus divaricatus onto Dicrurus adsimilis, Mirafra cantillans onto Mirafra javanica), and canonical-first matching answered a request for one of them with the other's label, common name, rarity and occurrence probability. Separation holds while both members are in the list; when only one is, the alias fallback still answers for the absent one, which is ordinary alias behaviour rather than a guarantee.

The occurrence-score cache resolves synonyms. With the universal geomodel the scores carry geomodel labels while callers pass classifier labels, so for the 236 aliased v2.4 species every lookup missed the cache, fell through to a full probable-species recomputation per detection, and then returned 0.0 anyway because the fallback compared raw names too. Cache and fallback now share one index built by buildOccurrenceIndex, so they cannot diverge. Measured on an aliased species at 6000 probable species: 12,050 allocations and 1.42 MiB per detection before, 26 allocations and 214 KiB after.

A synthetic score is no longer read as a rarity. PassUnmappedSpecies injects unmapped species at 0.0 purely so they survive the filter. That was being read as Very Rare, which made the badge depend on an unrelated toggle. Coverage now decides first, so those species report Unknown. This does not cover force-includes: resolveOverrideLabels resolves an override against the geomodel labels first, so a force-included species the geomodel knows stays inside the coverage vocabulary and still reads as Very Common off its injected 1.0. Distinguishing a real score from an injected one needs the range filter to tag synthetic entries, which is left for a follow-up.

The species endpoint answers alias requests properly. The name resolver and the taxonomy backends are tried under both the requested name and the matched label's name. Neither is reliably the indexed form: requests carry the current name because detections are canonicalized on write, v2.4's labels carry the legacy one, and the embedded taxonomy snapshot holds 153 of the 236 pairs under both, 56 only under the current name and 27 only under the legacy one.

Response structs move from pointer fields with omitempty to value fields with omitzero. The emitted JSON is unchanged in every reachable state; omitzero has been supported since Go 1.24 and the repo already uses it.

User-visible behaviour changes

  • A classifier species with no geomodel match reports Unknown where it previously reported Very Rare. The count is the WithoutRangeData figure already shown in the range filter status.
  • A geomodel-covered species the primary classifier cannot name reports a real score or Very Rare where it previously reported Unknown. This is the reported bug.
  • The occurrence value for the 236 aliased species changes from 0.0 to its real score. It is not persisted, but it does reach the MQTT payload and the Occurrence action-template placeholder.

Testing

Regression coverage for both collision pairs, the label-side alias direction, synthetic scores reporting Unknown, the uncached fallback, and the omitzero fields being omitted when unset. Every new test was verified to fail against the pre-fix code and pass after.

gofmt clean, golangci-lint run reports 0 issues across the project, and go test -race passes on the species, classifier and processor packages.

Summary by CodeRabbit

  • Improvements

    • Species names now resolve more reliably across aliases, capitalization, and legacy model labels.
    • Rarity scores and statuses are computed consistently, including correct handling of geomodel vs classifier coverage.
    • Species occurrence lookups now better support canonical and alternate names while preserving distinct species with similar labels.
    • API responses omit unset rarity and taxonomy fields for cleaner JSON output.
  • Tests

    • Added extensive coverage for alias/collision label resolution, rarity classification, occurrence caching/indexing, and rarity/taxonomy JSON omission behavior.

- Implements GetRarityContext for atomicity under a single read lock.
- Uses openfauna.CanonicalName to properly handle taxonomic aliases in species score mapping.
- Removes JSON pointer assignments in API to use omitzero over omitempty.
@coderabbitai

coderabbitai Bot commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 862a7b4b-e8e2-45c5-ac50-223ba16b9845

📥 Commits

Reviewing files that changed from the base of the PR and between 09a93da and ae5e3d5.

📒 Files selected for processing (1)
  • internal/classifier/birdnet.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/classifier/birdnet.go

Walkthrough

Species matching, rarity computation, and taxonomy responses now use canonical scientific names, active geomodel context, and value-based JSON fields. Classifier orchestration exposes rarity context, while tests cover aliases, geomodel-only species, rarity statuses, and serialization.

Changes

Species rarity and taxonomy

Layer / File(s) Summary
Canonical species matching
internal/api/v2/species/species.go, internal/classifier/birdnet.go, internal/classifier/orchestrator.go, internal/api/v2/species/species_test.go, internal/classifier/rarity_context_test.go
Species labels, occurrence lookups, classifier deduplication, and geomodel coverage use canonical scientific names, with tests for aliases, case normalization, and colliding species.
Active rarity context and computation
internal/api/v2/species/species.go, internal/classifier/orchestrator.go, internal/classifier/rarity_context_test.go, internal/api/v2/species/species_test.go, internal/classifier/mapped_range_filter_test.go
Rarity lookup combines probable-species scores with active geomodel and classifier vocabularies, then derives score and status through canonical coverage checks.
Value-based response and taxonomy wiring
internal/api/v2/species/species.go, internal/api/v2/species/species_test.go
Rarity and taxonomy response fields use value types with omitzero; taxonomy conversion, enrichment, fixtures, and omission assertions are updated.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SpeciesAPI
  participant Orchestrator
  participant PrimaryModel
  SpeciesAPI->>Orchestrator: request rarity context
  Orchestrator->>PrimaryModel: read active geomodel and classifier labels
  Orchestrator->>PrimaryModel: fetch probable-species scores
  Orchestrator-->>SpeciesAPI: return scores and label context
  SpeciesAPI->>SpeciesAPI: compute rarity score and status
Loading

Suggested labels: bug

Suggested reviewers: tphakala

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: fixing species rarity lookup to use the active geomodel taxonomy.
Linked Issues check ✅ Passed The PR implements active-geomodel coverage, canonical alias matching, and the requested regression tests for universal-geomodel and alias cases.
Out of Scope Changes check ✅ Passed The changes stay focused on species rarity and taxonomy lookup behavior plus tests, with no clear unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-3973-geomodel-taxonomy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai
coderabbitai Bot requested a review from tphakala July 19, 2026 17:59
@coderabbitai coderabbitai Bot added the bug Something isn't working label Jul 19, 2026
Comment thread internal/api/v2/species/species.go Outdated
Comment on lines +108 to +109
Rarity SpeciesRarityInfo `json:"rarity,omitzero"`
Taxonomy ebird.TaxonomyTree `json:"taxonomy,omitzero"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The omitzero struct tag is used for API response structs, but the standard encoding/json library does not support it, causing zero-value fields to be included in responses.
Severity: MEDIUM

Suggested Fix

Revert the use of omitzero for API response structs. Either continue using pointer types with the omitempty tag to omit nil values, or implement a custom JSON marshaler for the API that correctly handles the omitzero logic for value types.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: internal/api/v2/species/species.go#L108-L109

Potential issue: The pull request replaces pointer fields with `omitempty` tags with
value-type fields using `omitzero` tags in API response structs. However, the project's
API serialization relies on the standard `encoding/json` library, which does not support
the `omitzero` tag. As a result, the tag is ignored, and fields intended to be omitted
when they are zero-value will now always be included in the JSON response as empty
objects. This changes the API contract and causes a regression from the previous
behavior where `nil` pointer fields were correctly omitted from the response.

Also affects:

  • internal/api/v2/species/species.go:38~38

Did we get this right? 👍 / 👎 to inform future reviews.

@tphakala tphakala Jul 23, 2026 •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applying this one. encoding/json has supported omitzero since Go 1.24, and go.mod declares go 1.26.5, so the tag is honoured rather than ignored. The repo already relies on it deliberately elsewhere, e.g. internal/notification/push_webhook.go, which documents it as Go 1.24+. No custom JSON serializer is configured, so ctx.JSON goes through the standard library.

I checked the wire format rather than assuming: I could not construct a reachable input where the emitted JSON differs from the pointer + omitempty form. SpeciesRarityInfo always has Date set on the success path so it is never the zero struct and is always emitted; on the error path the field is never assigned, stays zero, and is omitted, matching the old nil pointer exactly. Both taxonomy backends unconditionally set Kingdom: "Animalia", so no tree is ever all-zero either. There is a test pinning the omission direction now (TestSpeciesInfoJSONOmitsUnsetBlocks), which is what was actually missing.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/classifier/birdnet.go`:
- Around line 1490-1492: Update getCachedSpeciesScores and its cache lookup to
use openfauna.CanonicalName(detection.ExtractScientificName(...)) consistently
instead of strings.ToLower(...). Ensure both cache construction and retrieval
use the same canonical scientific-name key, matching the targetSci comparison in
the fallback.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e12b3858-4051-439c-acf9-e1441e51d949

📥 Commits

Reviewing files that changed from the base of the PR and between d40501d and 9f80f64.

📒 Files selected for processing (4)
  • internal/api/v2/species/species.go
  • internal/api/v2/species/species_test.go
  • internal/classifier/birdnet.go
  • internal/classifier/orchestrator.go

Comment thread internal/classifier/birdnet.go Outdated
@tphakala

tphakala commented Jul 19, 2026 •

Copy link
Copy Markdown
Owner

I think this whole PR is wrong as taxonomy lookups should be handled by OpenFauna, not by Geomodel

@tphakala tphakala added pkg:api API and web interface components pkg:classifier Classifier, range filter and model orchestration labels Jul 23, 2026
tphakala added 3 commits July 23, 2026 13:12
Follow-up fixes on the species rarity lookup work.

Occurrence score cache: the universal geomodel labels its scores with geomodel
names, so a cache keyed on the raw scientific name never matched a caller
passing the classifier's legacy synonym. Every such lookup missed and fell
through to a full probable-species recomputation, on the per-detection path.
Cache writes, cache reads and the fallback scan now share one canonicalScoreKey
helper, so both paths resolve a synonym to the same entry.

GetRarityContext: it previously took two separate label snapshots and then
re-resolved o.primary through Orchestrator.GetProbableSpecies, so nothing was
actually consistent despite the comment claiming otherwise. It now drives every
read from one primary snapshot and one settings snapshot, and takes the geomodel
labels from getProbableSpecies, which already captures them under the same
range-filter lock that produced the scores.

Also guards the taxonomy tree dereference, corrects the speciesHasGeomodelCoverage
doc comment (it reads the geomodel vocabulary now, not the primary classifier's),
extracts the repeated canonical label comparison, and gofmt.

Tests: cache/fallback key agreement including the legacy synonym pair, the
geomodel-vocabulary precedence that is the point of the fix, the empty-geomodel
fallback for the TFLite and plain ONNX backends, and GetRarityContext across
universal, non-geomodel and nil-primary cases.
…nthetic scores as rarity

Review of the previous commit turned up a regression it introduced, plus three
gaps in the original change.

Canonical-name matching merged two species pairs that BirdNET v2.4 ships
separately: OpenFauna maps Dicrurus divaricatus onto Dicrurus adsimilis and
Mirafra cantillans onto Mirafra javanica, so keying on the canonical name alone
collapsed each pair onto one entry. The occurrence cache then reported one bird's
probability for the other and disagreed with its own uncached fallback, and the
species endpoint answered a request for one of them with the other's label,
common name and rarity. Every match now tries the exact scientific name across
the whole vocabulary before any alias, so a species that exists under its own
name is never shadowed, while a legacy synonym still resolves. The occurrence
cache and its fallback share one index built by buildOccurrenceIndex, so they
cannot diverge again.

Rarity no longer reads a synthetic score as an occurrence probability. Coverage
decides first: PassUnmappedSpecies injects unmapped species at 0.0 so they
survive the filter and force-includes arrive at 1.0 so they read as always
active, and neither is rarity data. Previously the same species reported
very_rare or unknown depending on the PassUnmappedSpecies toggle.

The species endpoint now threads the matched label's scientific name to the name
resolver and the taxonomy lookup instead of the raw request string. Both key on
current names, so a request naming a species by a legacy synonym returned 200
with the common name unresolved and the taxonomy block silently missing.

Also: drops canonicalScoreKey, which duplicated canonicalSpeciesKey in the same
package, and routes the four hand-inlined copies of that expression through it;
corrects the GetRarityContext doc, which claimed a reload cannot desync scores
from vocabularies when only the scores and geomodel labels are lock-consistent;
corrects the speciesHasGeomodelCoverage doc, which said bats are absent from the
geomodel when it carries 66 bat and 42 other mammal labels.

Tests: the new classifier tests published their fixture only on the instance
while currentSettings resolves through the global snapshot, so they failed
whenever a sibling test left settings published. They now publish globally and
do not run in parallel, matching the package. Added regression coverage for both
collision pairs, the label-side alias direction, synthetic scores reporting
unknown, the uncached fallback, and the omitzero fields being omitted when
unset. Each new test was verified to fail against the pre-fix code.
… what the fixes cover

A fresh review of the previous commit found it regressed the thing it touched and
that three of its comments claimed more than the code delivers.

Threading the matched label's scientific name into the taxonomy and name lookups
was the wrong direction. Detections are canonicalized at ingestion, so a request
carries the current name while BirdNET v2.4's labels carry the legacy one, and
the embedded taxonomy database is a frozen snapshot holding some species under
each. Of the 236 alias pairs v2.4 ships under the legacy name, 153 are indexed
under both, 56 only under the current name and 27 only under the legacy one, so
preferring the label's name dropped the taxonomy block for 56 species to recover
27. Both lookups now try both names. The response also reports the requested name
again rather than the matched label's, which was an unasked-for change to a public
endpoint.

Comment corrections, each verified against the code rather than reasoned about:

- computeRarity claimed it stops both synthetic-score mechanisms being read as
  rarity. It stops one. resolveOverrideLabels resolves a force-include against the
  geomodel labels first, so a force-included species the geomodel knows is inside
  the coverage vocabulary and still reads as very common off its injected 1.0.
  Only an override outside that vocabulary reaches the unknown path. Separating a
  real score from an injected one needs the range filter to tag synthetic entries;
  vocabulary membership cannot express it. The test's second case was renamed to
  say what it actually exercises.
- speciesHasGeomodelCoverage told callers to gate on LocationBased. That flag is
  false only for an unconfigured location; a range filter that failed to load
  leaves it true while every species reports very rare at score 0, so the guard it
  advertised does not exist.
- The exact-name-first ordering separates an alias-merged pair only while both
  members are in the list. The probable-species list carries only species above
  today's threshold, so one member being absent is the common case, and the alias
  fallback then still answers for the absent one with the present one's score.

Also guards the empty-key case in the occurrence index's first pass, matching the
second, and makes the fallback test assert it actually reached the fallback rather
than only that the result was zero.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
internal/classifier/birdnet.go (1)

1533-1543: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated clamp logic into a helper.

The cache-hit path (Lines 1511-1518) and this fallback path now duplicate the same "clamp to [0,1]" block verbatim. As per coding guidelines, internal/**/*.go code should "Extract common code to eliminate duplication (dupl)".

♻️ Proposed extraction
+// clampOccurrence clamps an occurrence score to the valid [0.0, 1.0] range.
+func clampOccurrence(v float64) float64 {
+	switch {
+	case v < 0.0:
+		return 0.0
+	case v > 1.0:
+		return 1.0
+	default:
+		return v
+	}
+}
+
 func (bn *BirdNET) GetSpeciesOccurrenceAtTime(species string, detectionTime time.Time) float64 {
 	...
 	if err == nil && len(cachedScores) > 0 {
 		if occurrence, found := lookupOccurrence(cachedScores, species); found {
-			// Clamp the score to [0.0, 1.0] range
-			if occurrence < 0.0 {
-				return 0.0
-			}
-			if occurrence > 1.0 {
-				return 1.0
-			}
-			return occurrence
+			return clampOccurrence(occurrence)
 		}
 	}
 	...
 	if occurrence, found := lookupOccurrence(buildOccurrenceIndex(speciesScores), species); found {
-		// Clamp the score to [0.0, 1.0] range
-		if occurrence < 0.0 {
-			return 0.0
-		}
-		if occurrence > 1.0 {
-			return 1.0
-		}
-		return occurrence
+		return clampOccurrence(occurrence)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/classifier/birdnet.go` around lines 1533 - 1543, Extract the
duplicated [0.0, 1.0] score clamping logic into a shared helper in
internal/classifier/birdnet.go, then replace the inline blocks in both the
cache-hit path and the fallback path around lookupOccurrence with calls to that
helper. Preserve the existing behavior for values below, within, and above the
range.

Source: Coding guidelines

internal/api/v2/species/species_test.go (1)

718-739: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reuse the shared occurrence index in rarity lookup.

species.go still re-implements exact-name-before-canonical-alias lookup with findSpeciesScore/speciesHasGeomodelCoverage, while classifier/birdnet.go centralizes the same invariant in buildOccurrenceIndex/lookupOccurrence. Use the same index/called path from computeRarity so a future change to alias collision handling can’t leave rarity behind another copy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/v2/species/species_test.go` around lines 718 - 739, Update
computeRarity to use the shared occurrence index and lookup path from
classifier/birdnet.go, specifically buildOccurrenceIndex and lookupOccurrence,
instead of the local findSpeciesScore and speciesHasGeomodelCoverage logic.
Remove or bypass the duplicated rarity lookup while preserving exact-name
precedence over canonical aliases and unknown status for species without
geomodel coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/api/v2/species/species_test.go`:
- Around line 718-739: Update computeRarity to use the shared occurrence index
and lookup path from classifier/birdnet.go, specifically buildOccurrenceIndex
and lookupOccurrence, instead of the local findSpeciesScore and
speciesHasGeomodelCoverage logic. Remove or bypass the duplicated rarity lookup
while preserving exact-name precedence over canonical aliases and unknown status
for species without geomodel coverage.

In `@internal/classifier/birdnet.go`:
- Around line 1533-1543: Extract the duplicated [0.0, 1.0] score clamping logic
into a shared helper in internal/classifier/birdnet.go, then replace the inline
blocks in both the cache-hit path and the fallback path around lookupOccurrence
with calls to that helper. Preserve the existing behavior for values below,
within, and above the range.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 13656314-f73e-4329-8438-bb684058eb3c

📥 Commits

Reviewing files that changed from the base of the PR and between 9f80f64 and 09a93da.

📒 Files selected for processing (6)
  • internal/api/v2/species/species.go
  • internal/api/v2/species/species_test.go
  • internal/classifier/birdnet.go
  • internal/classifier/mapped_range_filter_test.go
  • internal/classifier/orchestrator.go
  • internal/classifier/rarity_context_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/classifier/orchestrator.go
  • internal/api/v2/species/species.go

The cache-hit and fallback paths of GetSpeciesOccurrenceAtTime ended up with the
same clamp block verbatim once both were routed through lookupOccurrence.
Behaviour is unchanged, including for NaN, which neither comparison matches and
which is therefore still returned as-is.
@tphakala

Copy link
Copy Markdown
Owner

@coderabbitai Thanks, both looked at.

Clamp duplication (birdnet.go): valid, applied in ae5e3d5. The duplication was mine, introduced when I routed the fallback through lookupOccurrence so it would match the cache path. Extracted as clampOccurrence. NaN behaviour is preserved, since neither comparison matches it and it falls through unchanged, same as the chain it replaced.

Reusing buildOccurrenceIndex/lookupOccurrence in computeRarity: not applying, for two reasons.

It isn't reachable: both are unexported in internal/classifier, and computeRarity lives in internal/api/v2/species. Exporting them to share would widen the classifier's public surface for one caller.

More importantly the two need different answers from the same data. lookupOccurrence answers "what is this species' score", collapsing absent-from-the-list and no-such-species into one miss. computeRarity has to tell those apart: a species the range filter can rank but that is below today's threshold is Very Rare, while one outside the vocabulary entirely is Unknown. That distinction is the fix for this issue, so it needs vocabulary membership (speciesHasGeomodelCoverage) as a separate question from score lookup (findSpeciesScore). A shared index conflates them.

The invariant you're pointing at is real though, so both sites now document the same rule and cite each other, and both have regression tests using the actual colliding pairs from the vendored alias map (Dicrurus adsimilis/D. divaricatus, Mirafra javanica/M. cantillans). If alias-collision handling changes, those tests fail on both sides.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@tphakala Thanks for the detailed follow-up.

The clampOccurrence extraction and preserved NaN behavior address the duplication finding.

Your reasoning for keeping computeRarity separate is sound: it must independently distinguish vocabulary coverage from an absent score to preserve the required Very Rare versus Unknown behavior. Avoiding an otherwise unnecessary exported classifier API is also appropriate. The paired collision regressions provide a good safeguard for the shared alias-handling invariant.

🐇 ✅


✏️ Learnings added
Learnt from: tphakala
URL: https://github.com/tphakala/birdnet-go/pull/3978

Timestamp: 2026-07-23T11:11:01.459Z
Learning: In this Go codebase, `internal/api/v2/species.computeRarity` must keep species-vocabulary coverage (`speciesHasGeomodelCoverage`) separate from score lookup (`findSpeciesScore`): coverage with no score means `Very Rare`, while no coverage means `Unknown`. It should not reuse `internal/classifier.lookupOccurrence`, which intentionally collapses those cases for occurrence lookup.

Learnt from: tphakala
URL: https://github.com/tphakala/birdnet-go/pull/3978

Timestamp: 2026-07-23T11:11:01.459Z
Learning: Alias-collision behavior is protected by regression tests using the vendored OpenFauna alias pairs `Dicrurus adsimilis`/`D. divaricatus` and `Mirafra javanica`/`M. cantillans`; exact-name matches must remain distinct even where canonical aliases collide.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@tphakala
tphakala merged commit a4ae2c7 into main Jul 23, 2026
23 checks passed
@tphakala
tphakala deleted the fix/issue-3973-geomodel-taxonomy branch July 23, 2026 11:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working pkg:api API and web interface components pkg:classifier Classifier, range filter and model orchestration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Species rarity lookup does not use the active geomodel taxonomy

2 participants