Skip to content

feat: reduce false positive detections#2038

Open
jeremyames-gmail wants to merge 1 commit intotphakala:mainfrom
festion:feat/false-positive-reduction
Open

feat: reduce false positive detections#2038
jeremyames-gmail wants to merge 1 commit intotphakala:mainfrom
festion:feat/false-positive-reduction

Conversation

@jeremyames-gmail
Copy link
Copy Markdown

@jeremyames-gmail jeremyames-gmail commented Feb 24, 2026

Summary

  • Raise dynamic threshold minimum from 0.20 to 0.40 — prevents near-random detections at lowest threshold level
  • Raise range filter threshold from 0.01 to 0.03 — removes species with <3% occurrence probability
  • Slow dynamic threshold escalation from 1/2/3 to 2/4/6 detections per level — prevents 3 lucky hits from maxing out reduction
  • Fix species config zero-threshold bug — species with threshold: 0 (actions-only) now fall through to global threshold instead of accepting everything
  • Enable false positive filter at level 2 (Moderate) by default — requires 3 confirmations in a 6-second window

All changes are to default values only — existing user configs are unaffected. The escalation logic and zero-threshold bug fix are code changes that improve behavior for all users.

Test plan

  • All dynamic threshold tests updated and passing with -race flag
  • New base_threshold_test.go with 3 tests covering zero-threshold bug
  • Full processor test suite passing
  • Conf package tests passing
  • CI linter validation
  • Manual testing on RPi 4 with real audio

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation

    • Threshold docs and examples updated to show new defaults, clarified wording, and unified range-filter/CLI wording; tip added for per-species overrides.
  • Changes

    • Default range filter threshold raised to 0.03.
    • Dynamic threshold minimum raised to 0.40; learning cadence requires more detections and includes cooldowns.
    • Default false‑positive filter level set to Moderate.
    • Per-species custom thresholds of 0 now fall back to the global threshold.
  • Tests

    • New and updated unit tests covering threshold fallback and dynamic-threshold progression.

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @jeremyames-gmail, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request focuses on enhancing the accuracy and reliability of bird detection by significantly reducing false positives. It achieves this by adjusting several default threshold values, refining the logic for dynamic threshold escalation, and correcting a specific bug related to species-level zero thresholds. These changes are applied to default configurations, aiming to improve out-of-the-box performance for new installations without altering existing user settings.

Highlights

  • Dynamic Threshold Minimum Raised: The minimum allowed dynamic threshold has been increased from 0.20 to 0.40, preventing near-random detections at the lowest threshold level.
  • Range Filter Threshold Increased: The range filter threshold has been raised from 0.01 to 0.03, which removes species with less than 3% occurrence probability from consideration.
  • Dynamic Threshold Escalation Slowed: The dynamic threshold escalation now requires 2/4/6 approved detections to reach levels 1/2/3 respectively, slowing down the reduction and requiring more sustained evidence.
  • Species Config Zero-Threshold Bug Fixed: A bug was fixed where species configured with a threshold: 0 (intended for actions-only) would incorrectly accept all detections; they now correctly fall through to the global threshold.
  • False Positive Filter Enabled by Default: The false positive filter is now enabled by default at level 2 (Moderate), requiring 3 confirmations in a 6-second window.
Changelog
  • doc/wiki/guide.md
    • Updated the default rangefilter.threshold from 0.01 to 0.03 in documentation.
    • Updated the default dynamicthreshold.min from 0.20 to 0.40 in documentation.
    • Revised descriptions for threshold parameters to reflect new default values and their implications.
  • internal/analysis/processor/base_threshold_test.go
    • Added new test cases to verify correct behavior of getBaseConfidenceThreshold for species with zero, explicit, or no custom thresholds.
  • internal/analysis/processor/dynamic_threshold.go
    • Modified the dynamic threshold escalation logic to require more high-confidence detections (2/4/6) to reach levels 1/2/3, respectively.
  • internal/analysis/processor/dynamic_threshold_test.go
    • Updated dynamic threshold test cases to align with the new 2/4/6 detection requirement.
    • Adjusted the minimum dynamic threshold value in test setup from 0.20 to 0.40.
  • internal/analysis/processor/processor.go
    • Modified getBaseConfidenceThreshold to ensure that species with a configured threshold of 0 (actions-only) correctly use the global threshold instead of accepting all detections.
  • internal/conf/config.yaml
    • Updated the default birdnet.rangefilter.threshold from 0.01 to 0.03.
    • Updated the default realtime.dynamicthreshold.min from 0.20 to 0.40.
  • internal/conf/defaults.go
    • Updated the default birdnet.rangefilter.threshold from 0.01 to 0.03.
    • Updated the default realtime.dynamicthreshold.min from 0.20 to 0.40.
    • Set the default realtime.falsepositivefilter.level from 0 to 2.
Activity
  • All dynamic threshold tests were updated and passed with the -race flag.
  • A new base_threshold_test.go file was added with 3 tests covering the zero-threshold bug.
  • The full processor test suite passed.
  • Conf package tests passed.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 24, 2026

Walkthrough

Bumps defaults (range filter 0.01→0.03, dynamic threshold min 0.20→0.40), changes dynamic-threshold learning to multi-level progression with detection-count gates, treats per-species threshold 0 as fallback to global, and updates tests and docs accordingly.

Changes

Cohort / File(s) Summary
Configuration & Defaults
internal/conf/config.yaml, internal/conf/defaults.go
Increase birdnet.rangefilter.threshold default to 0.03, raise realtime.dynamicthreshold.min to 0.40, change default realtime.falsepositivefilter.level to 2, and update related inline comments.
Dynamic Threshold Logic
internal/analysis/processor/dynamic_threshold.go
Replace fixed-step learning with level-based progression and explicit min-detection gates; introduce level constants, multipliers, cooldown/timer changes, record threshold-change events, and enforce a minimum clamp.
Dynamic Threshold Tests
internal/analysis/processor/dynamic_threshold_test.go
Update tests to reflect new min (0.40), revised learning cadence and min-detection gates (e.g., 2/4/6), cooldown/timer semantics, clamping behavior, and initialization/readiness expectations.
Base Threshold Tests
internal/analysis/processor/base_threshold_test.go
Add tests for getBaseConfidenceThreshold covering per-species explicit thresholds, zero-valued per-species fallback to global, and missing-species fallback.
Threshold Fallback Logic
internal/analysis/processor/processor.go
getBaseConfidenceThreshold now only applies a per-species custom threshold when config.Threshold > 0; otherwise it falls back to the global BirdNET threshold; logging preserved when custom threshold is applied.
Documentation
doc/wiki/guide.md
Update examples, YAML blocks, headings, and explanatory text to use new defaults (0.03), clarify that range filter YAML and CLI range refer to the same functionality, and add per-species override tip.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

enhancement, test

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: reduce false positive detections' directly and accurately summarizes the primary objective of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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 and usage tips.

@jeremyames-gmail
Copy link
Copy Markdown
Author

/gemini review

@coderabbitai coderabbitai Bot added bug Something isn't working enhancement New feature or request labels Feb 24, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces several changes to reduce false positive detections, including raising the minimum dynamic threshold and range filter threshold, and slowing down the dynamic threshold escalation. It also includes a bug fix for species-specific thresholds set to zero and enables the false positive filter by default. The changes are well-supported by updated tests and documentation. My review identifies a minor improvement opportunity to replace magic numbers with constants for better code clarity and maintainability.

Comment on lines +209 to 225
switch {
case dt.HighConfCount >= 6:
// Level 3: maximum reduction (75% off base) — requires 6+ approved detections
dt.Level = 3
dt.CurrentValue = float64(baseThreshold * thresholdLevel3Multiplier)
case dt.HighConfCount >= 4:
// Level 2: 50% reduction — requires 4+ approved detections
dt.Level = 2
dt.CurrentValue = float64(baseThreshold * thresholdLevel2Multiplier)
case dt.HighConfCount >= 2:
// Level 1: 25% reduction — requires 2+ approved detections
dt.Level = 1
dt.CurrentValue = float64(baseThreshold * thresholdLevel1Multiplier)
default:
// Level 3 is the maximum reduction; any count >= 3 stays at this level
dt.Level = 3
dt.CurrentValue = float64(baseThreshold * thresholdLevel3Multiplier)
// Not enough evidence yet — stay at base threshold
dt.CurrentValue = float64(baseThreshold)
}
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.

medium

The detection counts 6, 4, and 2 for triggering different threshold levels are magic numbers. To improve readability and maintainability, consider defining them as named constants at the package level. For example:

const (
    detectionsForLevel1 = 2
    detectionsForLevel2 = 4
    detectionsForLevel3 = 6
)

Then, these constants can be used in the switch statement.

// Trigger Level 3 (25% of 0.80 = 0.20, which is below min of 0.30)
for i := range 3 {
// Now requires 6 approved detections for Level 3
for i := range 6 {
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.

medium

This test uses the magic number 6. If you define constants for the detection counts as suggested in dynamic_threshold.go (e.g., detectionsForLevel3), please use that constant here as well to keep the test synchronized with the implementation and improve clarity.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
doc/wiki/guide.md (2)

857-908: ⚠️ Potential issue | 🟡 Minor

Dynamic threshold docs still mention the old 0.20 minimum.

The config snippet now shows min: 0.40, but the “Key Parameters” explanation and the practical example later in this section still cite 0.20 / 0.2. Please update those to 0.40 for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@doc/wiki/guide.md` around lines 857 - 908, Update the docs to reflect the new
default minimum of 0.40: change the "min" description under "Key Parameters
Explained" (currently showing 0.20) to 0.40, update the Threshold Adjustment
Levels table's example for level 3+ from 0.20 (min limit) to 0.40 (min limit),
and change the Practical Example block where it lists min: 0.2 to min: 0.4 so
that all references to the `min` parameter and examples match the config
snippet's `min: 0.40`.

770-781: ⚠️ Potential issue | 🟡 Minor

Update remaining references to the old 0.01 default.

These edits set the range filter default to 0.03, but other sections still describe 0.01 as the default (e.g., earlier config examples and the Stage 1 narrative). Please align all references to avoid conflicting guidance.

Also applies to: 1149-1176

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@doc/wiki/guide.md` around lines 770 - 781, Update all remaining references to
the old 0.01 default so the docs consistently state 0.03 for the range filter;
search for occurrences of "0.01", "range filter default", and the config key
birdnet.rangefilter.threshold (including the earlier config examples and the
Stage 1 narrative noted around the other section) and change them to 0.03,
adjusting YAML examples, prose, and any explanatory text so the default value
and its explanation match the new 0.03 setting.
internal/conf/defaults.go (1)

76-170: 🛠️ Refactor suggestion | 🟠 Major

Replace new literal defaults with named constants.

The new 0.03 / 0.40 / 2 values are magic numbers in Go code. Please define descriptive constants and reference them in SetDefault calls to align with project standards.

♻️ Suggested refactor
+const (
+	defaultRangeFilterThreshold   = 0.03
+	defaultDynamicThresholdMin    = 0.40
+	defaultFalsePositiveFilterLvl = 2
+)
...
-	viper.SetDefault("birdnet.rangefilter.threshold", 0.03)
+	viper.SetDefault("birdnet.rangefilter.threshold", defaultRangeFilterThreshold)
...
-	viper.SetDefault("realtime.dynamicthreshold.min", 0.40)
+	viper.SetDefault("realtime.dynamicthreshold.min", defaultDynamicThresholdMin)
...
-	viper.SetDefault("realtime.falsepositivefilter.level", 2)
+	viper.SetDefault("realtime.falsepositivefilter.level", defaultFalsePositiveFilterLvl)

As per coding guidelines, "No magic numbers or strings - use named constants with descriptive names in Go code."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/conf/defaults.go` around lines 76 - 170, Replace the magic literals
by defining descriptive package-level constants (e.g. const
DefaultRangeFilterThreshold = 0.03, DefaultDynamicThresholdMin = 0.40,
DefaultFalsePositiveFilterLevel = 2) in the same defaults.go (or package scope)
and use those constants in the viper.SetDefault calls for
"birdnet.rangefilter.threshold", "realtime.dynamicthreshold.min" and
"realtime.falsepositivefilter.level" respectively; ensure the constant names are
clear and referenced in place of the numeric literals so the functions/keys
BirdNet range filter threshold, DynamicThreshold.min, and
FalsePositiveFilter.level use the named constants.
internal/analysis/processor/dynamic_threshold_test.go (2)

241-242: ⚠️ Potential issue | 🟡 Minor

Stale comment: // Higher minimum is now incorrect.

newTestProcessor() was updated in this PR to set Min: 0.40; the test overrides it to 0.30, which is lower than the new default. The comment "Higher minimum" no longer applies.

✏️ Proposed fix
-	p.Settings.Realtime.DynamicThreshold.Min = 0.30 // Higher minimum
+	p.Settings.Realtime.DynamicThreshold.Min = 0.30 // Lower custom minimum (Level 3 computes 0.20 < 0.30, so value clamps to 0.30)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/analysis/processor/dynamic_threshold_test.go` around lines 241 -
242, The inline comment next to the test override is now incorrect: update the
comment on the line that sets p.Settings.Realtime.DynamicThreshold.Min in the
test using newTestProcessor() to reflect that the override sets a lower value
than the new default (newTestProcessor now sets Min to 0.40), e.g. replace "//
Higher minimum" with a correct note such as "// Lower minimum than default
(default 0.40)" or remove the comment entirely; target the newTestProcessor
usage and the p.Settings.Realtime.DynamicThreshold.Min assignment when making
the change.

263-273: ⚠️ Potential issue | 🟡 Minor

Use require.NotNil to prevent nil-pointer panic on test failure.

assert.NotNil on line 270 does not stop the test on failure, so lines 271–273 will dereference a nil *DynamicThreshold and panic instead of producing a clean failure message if LearnFromApprovedDetection doesn't create the entry.

🛡️ Proposed fix
-import "github.com/stretchr/testify/assert"
+import (
+    "github.com/stretchr/testify/assert"
+    "github.com/stretchr/testify/require"
+)
-	assert.NotNil(t, p.DynamicThresholds["new species"], "Should create threshold entry")
+	require.NotNil(t, p.DynamicThresholds["new species"], "Should create threshold entry")
 	assert.Equal(t, 0, p.DynamicThresholds["new species"].Level, "Level should be 0 (need 2 for Level 1)")
 	assert.Equal(t, 1, p.DynamicThresholds["new species"].HighConfCount, "HighConfCount should be 1")
 	assert.Equal(t, "Newus speciesus", p.DynamicThresholds["new species"].ScientificName, "ScientificName should be set")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/analysis/processor/dynamic_threshold_test.go` around lines 263 -
273, Replace the non-fatal assertion with a fatal one in
TestLearnFromApprovedDetectionInitializesIfMissing: change assert.NotNil(t,
p.DynamicThresholds["new species"], ...) to require.NotNil(t,
p.DynamicThresholds["new species"], ...), and add the require package import
("github.com/stretchr/testify/require") at the top of the test file so the test
stops immediately and avoids nil-pointer dereference when
p.DynamicThresholds["new species"] is missing.
🧹 Nitpick comments (1)
internal/analysis/processor/dynamic_threshold_test.go (1)

44-55: Consider adding t.Parallel() to independent test functions.

Every test here creates its own Processor instance with no shared state, making them safe to run in parallel. Adding t.Parallel() to each top-level test function would reduce total test-suite wall time.

♻️ Example (apply to all top-level `TestXxx` functions)
 func TestCustomThresholdRespected(t *testing.T) {
+	t.Parallel()
 	p := newTestProcessor()

As per coding guidelines, "Use t.Parallel() for top-level test functions where safe" and "Use t.Parallel() only for independent tests that don't mutate global state."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/analysis/processor/dynamic_threshold_test.go` around lines 44 - 55,
This test is independent and can run concurrently—add t.Parallel() at the start
of the top-level test function TestCustomThresholdRespected so it runs in
parallel with other independent tests; locate the TestCustomThresholdRespected
function (which constructs a Processor via newTestProcessor and calls
p.getAdjustedConfidenceThreshold) and insert t.Parallel() as the first statement
in the function body.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/analysis/processor/base_threshold_test.go`:
- Around line 10-57: The three nearly-identical tests
(TestGetBaseConfidenceThreshold_ZeroThresholdFallsThrough,
TestGetBaseConfidenceThreshold_ExplicitThresholdUsed,
TestGetBaseConfidenceThreshold_NotInConfigUsesGlobal) should be collapsed into a
single table-driven test that iterates a slice of cases and uses t.Run for each
case; for each case create a fresh processor via newTestProcessor(), set
p.Settings.BirdNET.Threshold and p.Settings.Realtime.Species per the case, call
p.getBaseConfidenceThreshold(...) and assert.InDelta against the expected value,
and call t.Parallel() inside each subtest if running them concurrently is safe.

In `@internal/analysis/processor/dynamic_threshold.go`:
- Around line 206-224: The code uses magic literals 2, 4, and 6 to decide
progression levels; define descriptive constants (e.g., level1MinDetections = 2,
level2MinDetections = 4, level3MinDetections = 6) and replace the numeric
literals in the switch condition (dt.HighConfCount >= ...) with those constants,
update the comments on the cases to reference the named constants and the
intended cadence, and keep existing assignments to dt.Level and dt.CurrentValue
(which use baseThreshold and thresholdLevel1Multiplier/2/3) unchanged so
behavior remains identical.

---

Outside diff comments:
In `@doc/wiki/guide.md`:
- Around line 857-908: Update the docs to reflect the new default minimum of
0.40: change the "min" description under "Key Parameters Explained" (currently
showing 0.20) to 0.40, update the Threshold Adjustment Levels table's example
for level 3+ from 0.20 (min limit) to 0.40 (min limit), and change the Practical
Example block where it lists min: 0.2 to min: 0.4 so that all references to the
`min` parameter and examples match the config snippet's `min: 0.40`.
- Around line 770-781: Update all remaining references to the old 0.01 default
so the docs consistently state 0.03 for the range filter; search for occurrences
of "0.01", "range filter default", and the config key
birdnet.rangefilter.threshold (including the earlier config examples and the
Stage 1 narrative noted around the other section) and change them to 0.03,
adjusting YAML examples, prose, and any explanatory text so the default value
and its explanation match the new 0.03 setting.

In `@internal/analysis/processor/dynamic_threshold_test.go`:
- Around line 241-242: The inline comment next to the test override is now
incorrect: update the comment on the line that sets
p.Settings.Realtime.DynamicThreshold.Min in the test using newTestProcessor() to
reflect that the override sets a lower value than the new default
(newTestProcessor now sets Min to 0.40), e.g. replace "// Higher minimum" with a
correct note such as "// Lower minimum than default (default 0.40)" or remove
the comment entirely; target the newTestProcessor usage and the
p.Settings.Realtime.DynamicThreshold.Min assignment when making the change.
- Around line 263-273: Replace the non-fatal assertion with a fatal one in
TestLearnFromApprovedDetectionInitializesIfMissing: change assert.NotNil(t,
p.DynamicThresholds["new species"], ...) to require.NotNil(t,
p.DynamicThresholds["new species"], ...), and add the require package import
("github.com/stretchr/testify/require") at the top of the test file so the test
stops immediately and avoids nil-pointer dereference when
p.DynamicThresholds["new species"] is missing.

In `@internal/conf/defaults.go`:
- Around line 76-170: Replace the magic literals by defining descriptive
package-level constants (e.g. const DefaultRangeFilterThreshold = 0.03,
DefaultDynamicThresholdMin = 0.40, DefaultFalsePositiveFilterLevel = 2) in the
same defaults.go (or package scope) and use those constants in the
viper.SetDefault calls for "birdnet.rangefilter.threshold",
"realtime.dynamicthreshold.min" and "realtime.falsepositivefilter.level"
respectively; ensure the constant names are clear and referenced in place of the
numeric literals so the functions/keys BirdNet range filter threshold,
DynamicThreshold.min, and FalsePositiveFilter.level use the named constants.

---

Nitpick comments:
In `@internal/analysis/processor/dynamic_threshold_test.go`:
- Around line 44-55: This test is independent and can run concurrently—add
t.Parallel() at the start of the top-level test function
TestCustomThresholdRespected so it runs in parallel with other independent
tests; locate the TestCustomThresholdRespected function (which constructs a
Processor via newTestProcessor and calls p.getAdjustedConfidenceThreshold) and
insert t.Parallel() as the first statement in the function body.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d739f3f and debf2a2.

📒 Files selected for processing (7)
  • doc/wiki/guide.md
  • internal/analysis/processor/base_threshold_test.go
  • internal/analysis/processor/dynamic_threshold.go
  • internal/analysis/processor/dynamic_threshold_test.go
  • internal/analysis/processor/processor.go
  • internal/conf/config.yaml
  • internal/conf/defaults.go

Comment thread internal/analysis/processor/base_threshold_test.go Outdated
Comment thread internal/analysis/processor/dynamic_threshold.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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 the current code and only fix it if needed.

Inline comments:
In `@internal/analysis/processor/dynamic_threshold.go`:
- Around line 214-229: The switch assigns dt.Level using numeric literals
(1/2/3); define descriptive constants (e.g., const LevelBase = 0, LevelOne = 1,
LevelTwo = 2, LevelThree = 3) near the top of dynamic_threshold.go (or alongside
existing threshold constants) and replace the numeric assignments in the switch
(dt.Level = 3 / 2 / 1) with those named constants (dt.Level = LevelThree /
LevelTwo / LevelOne) so there are no magic numbers in dt.Level usage.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between debf2a2 and b5ec3f9.

📒 Files selected for processing (1)
  • internal/analysis/processor/dynamic_threshold.go

Comment thread internal/analysis/processor/dynamic_threshold.go
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements several measures to reduce false positive detections, including raising default thresholds, slowing dynamic threshold escalation, and fixing a bug in species-specific threshold fallbacks. No security vulnerabilities were found within the scope of the reviewed changes, with database operations using proper parameterization and no hardcoded secrets identified. The changes are well-documented, accompanied by comprehensive unit tests, and the code adheres to established patterns, correctly handling edge cases related to Go's zero-value unmarshaling.

Comment on lines +223 to +224
case dt.HighConfCount >= thresholdLevel1MinDetections:
dt.Level = 1
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.

medium

In the default case of the switch statement, dt.Level should be explicitly set to 0 to ensure state consistency. While dt.Level is initialized to 0, being explicit here prevents potential issues if the state was modified elsewhere or if HighConfCount was reset without a corresponding Level reset. This aligns with the principle of ensuring all fields are set to a valid default state, even if some are re-initialized later, which is crucial for maintaining predictable behavior, especially in testing scenarios.

	default:
		// Not enough evidence yet — stay at base threshold
		dt.Level = 0
		dt.CurrentValue = float64(baseThreshold)
References
  1. When a struct is initialized in a constructor, ensure all fields are set to a valid default state, even if some are re-initialized later. This is crucial for tests that may use the constructor directly without the full application initialization.

if config, exists := lookupSpeciesConfig(p.Settings.Realtime.Species.Config, commonName, scientificName); exists {
// Check if species has a custom threshold using both common and scientific name lookup.
// Species with threshold: 0 (actions-only config) fall through to global threshold.
if config, exists := lookupSpeciesConfig(p.Settings.Realtime.Species.Config, commonName, scientificName); exists && config.Threshold > 0 {
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.

medium

The addition of config.Threshold > 0 correctly addresses the bug where species with an unconfigured (zero) threshold would accept all detections. This ensures they correctly fall through to the global threshold as intended for 'actions-only' configurations.

@jeremyames-gmail jeremyames-gmail force-pushed the feat/false-positive-reduction branch from b5ec3f9 to 37f2e4c Compare February 24, 2026 01:23
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
internal/analysis/processor/base_threshold_test.go (1)

10-57: Consolidate these three tests into a single table-driven suite.

Same setup and only the inputs/expected values change; a case table keeps the tests easier to maintain.

As per coding guidelines, “Write table-driven tests for all packages”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/analysis/processor/base_threshold_test.go` around lines 10 - 57,
Consolidate the three tests into one table-driven test that iterates cases and
calls p.getBaseConfidenceThreshold; create a test function (e.g.,
TestGetBaseConfidenceThreshold_Table) that for each case (name string) sets up p
:= newTestProcessor(), p.Settings.BirdNET.Threshold = 0.75 and
p.Settings.Realtime.Species = conf.SpeciesSettings{Config: ...} as needed per
case, define fields like inputCommonName, inputScientificName, and
expectedThreshold, then loop over cases with t.Run(case.name, func(t
*testing.T){ threshold := p.getBaseConfidenceThreshold(case.inputCommonName,
case.inputScientificName); assert.InDelta(t, case.expectedThreshold,
float64(threshold), 0.001) }); keep unique symbols (newTestProcessor,
p.getBaseConfidenceThreshold, p.Settings.BirdNET.Threshold,
p.Settings.Realtime.Species, conf.SpeciesSettings) to locate and replace the
existing three tests.
🧹 Nitpick comments (2)
internal/analysis/processor/dynamic_threshold_test.go (1)

136-175: Use named constants for the 2/4/6 detection tiers and base thresholds in this test.

These values represent core behavior; extracting them to constants (or reusing production constants if available) avoids future drift.

As per coding guidelines, “No magic numbers or strings - use named constants with descriptive names in Go code”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/analysis/processor/dynamic_threshold_test.go` around lines 136 -
175, The test TestLearnFromApprovedDetectionLevels uses magic numbers for the
base threshold (0.80), the learning cooldown spacing (15s), the tier counts (2,
4, 6) and the minimum clamp (0.40); replace those literals with named constants
(e.g., baseThreshold := baseDynamicThreshold, learningCooldown :=
dynamicThresholdLearningCooldown, tierLevel1Count := dynamicTierLevel1Count,
tierLevel2Count := dynamicTierLevel2Count, tierLevel3Count :=
dynamicTierLevel3Count, minThreshold := dynamicThresholdMin) and use those
constants when calling p.addSpeciesToDynamicThresholds, setting LastLearnedAt
spacing, and asserting p.DynamicThresholds["test species"].CurrentValue and
Level so the test mirrors production constants and eliminates magic numbers.
internal/analysis/processor/base_threshold_test.go (1)

13-55: Extract repeated threshold and delta literals into named constants.

This removes magic numbers and makes intent clearer across the three tests.

♻️ Example refactor
+const (
+	globalThreshold   = 0.75
+	explicitThreshold = 0.90
+	delta             = 0.001
+)
+
 func TestGetBaseConfidenceThreshold_ZeroThresholdFallsThrough(t *testing.T) {
 	p := newTestProcessor()
-	p.Settings.BirdNET.Threshold = 0.75 // global threshold
+	p.Settings.BirdNET.Threshold = globalThreshold // global threshold
 	...
-	assert.InDelta(t, 0.75, float64(threshold), 0.001,
+	assert.InDelta(t, globalThreshold, float64(threshold), delta,
 		"Species with threshold:0 should fall through to global threshold, not return 0.0")
 }
 ...
-	assert.InDelta(t, 0.90, float64(threshold), 0.001,
+	assert.InDelta(t, explicitThreshold, float64(threshold), delta,
 		"Species with explicit threshold should use that value")

As per coding guidelines, “No magic numbers or strings - use named constants with descriptive names in Go code”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/analysis/processor/base_threshold_test.go` around lines 13 - 55,
Replace the magic numeric literals used across the tests with named constants:
declare descriptive constants (e.g., globalThreshold = 0.75, explicitThreshold =
0.90, actionsOnlyThreshold = 0.0, and thresholdDelta = 0.001) at the top of the
test file and update TestGetBaseConfidenceThreshold_ActionsOnlyFallsThrough,
TestGetBaseConfidenceThreshold_ExplicitThresholdUsed, and
TestGetBaseConfidenceThreshold_NotInConfigUsesGlobal to use those constants when
setting p.Settings.BirdNET.Threshold, the species config Threshold values, and
the assert.InDelta expected/delta parameters; keep calls to
p.getBaseConfidenceThreshold unchanged. Ensure constant names are clear and used
in all three tests to remove the magic numbers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@internal/analysis/processor/base_threshold_test.go`:
- Around line 10-57: Consolidate the three tests into one table-driven test that
iterates cases and calls p.getBaseConfidenceThreshold; create a test function
(e.g., TestGetBaseConfidenceThreshold_Table) that for each case (name string)
sets up p := newTestProcessor(), p.Settings.BirdNET.Threshold = 0.75 and
p.Settings.Realtime.Species = conf.SpeciesSettings{Config: ...} as needed per
case, define fields like inputCommonName, inputScientificName, and
expectedThreshold, then loop over cases with t.Run(case.name, func(t
*testing.T){ threshold := p.getBaseConfidenceThreshold(case.inputCommonName,
case.inputScientificName); assert.InDelta(t, case.expectedThreshold,
float64(threshold), 0.001) }); keep unique symbols (newTestProcessor,
p.getBaseConfidenceThreshold, p.Settings.BirdNET.Threshold,
p.Settings.Realtime.Species, conf.SpeciesSettings) to locate and replace the
existing three tests.

---

Nitpick comments:
In `@internal/analysis/processor/base_threshold_test.go`:
- Around line 13-55: Replace the magic numeric literals used across the tests
with named constants: declare descriptive constants (e.g., globalThreshold =
0.75, explicitThreshold = 0.90, actionsOnlyThreshold = 0.0, and thresholdDelta =
0.001) at the top of the test file and update
TestGetBaseConfidenceThreshold_ActionsOnlyFallsThrough,
TestGetBaseConfidenceThreshold_ExplicitThresholdUsed, and
TestGetBaseConfidenceThreshold_NotInConfigUsesGlobal to use those constants when
setting p.Settings.BirdNET.Threshold, the species config Threshold values, and
the assert.InDelta expected/delta parameters; keep calls to
p.getBaseConfidenceThreshold unchanged. Ensure constant names are clear and used
in all three tests to remove the magic numbers.

In `@internal/analysis/processor/dynamic_threshold_test.go`:
- Around line 136-175: The test TestLearnFromApprovedDetectionLevels uses magic
numbers for the base threshold (0.80), the learning cooldown spacing (15s), the
tier counts (2, 4, 6) and the minimum clamp (0.40); replace those literals with
named constants (e.g., baseThreshold := baseDynamicThreshold, learningCooldown
:= dynamicThresholdLearningCooldown, tierLevel1Count := dynamicTierLevel1Count,
tierLevel2Count := dynamicTierLevel2Count, tierLevel3Count :=
dynamicTierLevel3Count, minThreshold := dynamicThresholdMin) and use those
constants when calling p.addSpeciesToDynamicThresholds, setting LastLearnedAt
spacing, and asserting p.DynamicThresholds["test species"].CurrentValue and
Level so the test mirrors production constants and eliminates magic numbers.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b5ec3f9 and 37f2e4c.

📒 Files selected for processing (7)
  • doc/wiki/guide.md
  • internal/analysis/processor/base_threshold_test.go
  • internal/analysis/processor/dynamic_threshold.go
  • internal/analysis/processor/dynamic_threshold_test.go
  • internal/analysis/processor/processor.go
  • internal/conf/config.yaml
  • internal/conf/defaults.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • doc/wiki/guide.md
  • internal/analysis/processor/processor.go
  • internal/analysis/processor/dynamic_threshold.go
  • internal/conf/defaults.go

Tune default thresholds and fix bugs to reduce incorrect species identifications:

- Raise dynamic threshold minimum from 0.20 to 0.40
- Raise range filter threshold from 0.01 to 0.03
- Enable false positive filter by default at level 2 (Moderate)
- Slow dynamic threshold escalation to require 2/4/6 detections per level
- Fix zero-threshold bug where species with threshold:0 accepted all detections
- Add named constants for threshold levels and detection counts
- Convert base threshold tests to table-driven subtests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jeremyames-gmail jeremyames-gmail force-pushed the feat/false-positive-reduction branch from d599d64 to 67c1fd0 Compare February 24, 2026 02:27
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
internal/analysis/processor/base_threshold_test.go (1)

10-65: Consider parallelizing the table-driven subtests.

These cases are independent, so adding t.Parallel() at the test and subtest level should be safe and speeds up the suite.

♻️ Proposed refactor
 func TestGetBaseConfidenceThreshold(t *testing.T) {
+	t.Parallel()
 	tests := []struct {
 		name           string
 		globalThresh   float64
 		speciesConfig  map[string]conf.SpeciesConfig
@@
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
 			p := newTestProcessor()
 			p.Settings.BirdNET.Threshold = tt.globalThresh
 			p.Settings.Realtime.Species = conf.SpeciesSettings{
 				Config: tt.speciesConfig,

As per coding guidelines, "Use t.Parallel() when tests can run concurrently" and "Always call t.Parallel() within subtests if test cases are independent."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/analysis/processor/base_threshold_test.go` around lines 10 - 65, The
table-driven test TestGetBaseConfidenceThreshold can be parallelized: call
t.Parallel() at the top of TestGetBaseConfidenceThreshold and also inside each
subtest (the anonymous func passed to t.Run) so the independent cases run
concurrently; ensure the subtests still construct their own processor via
newTestProcessor and set p.Settings.BirdNET.Threshold and
p.Settings.Realtime.Species before calling p.getBaseConfidenceThreshold to avoid
shared-state races.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/analysis/processor/base_threshold_test.go`:
- Around line 10-65: The table-driven test TestGetBaseConfidenceThreshold can be
parallelized: call t.Parallel() at the top of TestGetBaseConfidenceThreshold and
also inside each subtest (the anonymous func passed to t.Run) so the independent
cases run concurrently; ensure the subtests still construct their own processor
via newTestProcessor and set p.Settings.BirdNET.Threshold and
p.Settings.Realtime.Species before calling p.getBaseConfidenceThreshold to avoid
shared-state races.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d599d64 and 67c1fd0.

📒 Files selected for processing (7)
  • doc/wiki/guide.md
  • internal/analysis/processor/base_threshold_test.go
  • internal/analysis/processor/dynamic_threshold.go
  • internal/analysis/processor/dynamic_threshold_test.go
  • internal/analysis/processor/processor.go
  • internal/conf/config.yaml
  • internal/conf/defaults.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/analysis/processor/processor.go
  • doc/wiki/guide.md

@jeremyames-gmail
Copy link
Copy Markdown
Author

@coderabbitai resume

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 24, 2026

✅ Actions performed

Reviews resumed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant