Skip to content

Endpoint for stats about verified occurrences - #1307

Merged
mihow merged 21 commits into
mainfrom
feat/human-model-agreement-endpoint
May 29, 2026
Merged

Endpoint for stats about verified occurrences#1307
mihow merged 21 commits into
mainfrom
feat/human-model-agreement-endpoint

Conversation

@mihow

@mihowmihow commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds GET /api/v2/occurrences/stats/model-agreement/ — verified-occurrence rate + human↔model agreement rates (with Wilson 95% confidence intervals and Cohen's kappa) over the same filter set the /occurrences/ list view accepts. Designed for the project-overview dashboard widget and occurrence-list sidebar panel (consumed by #1308).

Stats viewset convention established in #1296 (see docs/claude/reference/api-stats-pattern.md): scalar response under the entity it's computed over, namespaced under /stats/.

Filter parity

Stats endpoint accepts every query param the /occurrences/ list endpoint accepts, minus ordering/search (don't apply to scalars).

ParamSourceNotes
project_id=<int>ProjectMixinrequired
apply_defaults=true/falseapply_default_filtersDefaults to true. Bypass = ignore project default taxa lists + score thresholds.
taxon=<id> or determination=<id>CustomOccurrenceDeterminationFilterHierarchical via parents_json — matches the taxon and all descendants.
event=<id>DjangoFilterBackend
deployment=<id>DjangoFilterBackend
determination__rank=<RANK>DjangoFilterBackende.g. SPECIES, GENUS, FAMILY.
detections__source_image=<id>DjangoFilterBackend
collection=<id> or collection_id=<id>OccurrenceCollectionFilterCapture collection containing the occurrence's detections.
algorithm=<id> (repeatable)OccurrenceAlgorithmFilterInclusive list of detection-algorithm ids.
not_algorithm=<id> (repeatable)OccurrenceAlgorithmFilterExclusive list.
date_start=<YYYY-MM-DD>OccurrenceDateFilterISO date.
date_end=<YYYY-MM-DD>OccurrenceDateFilterISO date.
verified=true/falseOccurrenceVerifiedHas-any-ident filter (independent of identifier).
verified_by_me=true/falseOccurrenceVerifiedByMeFilterAuth-gated — short-circuits to no-op for anon.
taxa_list_id=<id>OccurrenceTaxaListFilter
not_taxa_list_id=<id>OccurrenceTaxaListFilter

Backed by the same OCCURRENCE_FILTER_BACKENDS + OCCURRENCE_FILTERSET_FIELDS tuples wired into OccurrenceViewSet, so the two endpoints stay in lock-step.

Endpoint-specific param

ParamDefaultNotes
agreement_coarsest_rank=<RANK>absent (no threshold)Optional. When supplied, response includes agreed_coarser_rank_* counting only LCAs at or deeper than the given rank. Accepts any TaxonRank name (case-insensitive); UNKNOWN and unknown strings → 400.

Response shape

{
"project_id": 18,
"total_occurrences": 43149,
"verified_count": 45,
"verified_pct": 0.001,
"verified_with_prediction_count": 24,
"no_prediction_count": 21,
"verified_without_taxon_count": 0,
"comparable_count": 24,
"agreed_exact_count": 12,
"agreed_exact_pct": 0.5,
"agreed_exact_ci_low": 0.3146,
"agreed_exact_ci_high": 0.6854,
"agreed_any_rank_count": 17,
"agreed_any_rank_pct": 0.7083,
"agreed_any_rank_ci_low": 0.5023,
"agreed_any_rank_ci_high": 0.8521,
"cohens_kappa": 0.41,
"agreement_coarsest_rank": null,
"agreed_coarser_rank_count": null,
"agreed_coarser_rank_pct": null
}

With ?agreement_coarsest_rank=FAMILY, the bottom three fields populate:

{
"agreement_coarsest_rank": "FAMILY",
"agreed_coarser_rank_count": 14,
"agreed_coarser_rank_pct": 0.5833
}

Field semantics

  • verified_* = at least one non-withdrawn Identification.
  • verified_with_prediction_count = verified AND has a machine prediction.
  • no_prediction_count = verified but no machine prediction (surfaced so consumers can see why the comparable denominator differs from verified_count).
  • verified_without_taxon_count = verified AND has a machine prediction but the human identification has no taxon (e.g. a comment-only verification, since Identification.taxon is nullable). Excluded from the agreement denominator since there is no human label to compare.
  • comparable_count = verified occurrences with BOTH a machine prediction and a human taxon. This is the denominator for every agreed_*_pct and the Wilson CIs. Equals verified_with_prediction_count − verified_without_taxon_count. Using it (rather than verified_with_prediction_count) keeps the numerator and denominator describing the same set — a verified row that can't agree or disagree is excluded from both.
  • agreed_exact_* = user's best identification taxon equals the model's best prediction.
  • agreed_any_rank_* = exact matches plus disagreements whose LCA is at any real taxonomic rank (UNKNOWN excluded, since it sorts after SPECIES in TaxonRank.OrderedEnum). The upstream filter (e.g. a Lepidoptera include list) is what bounds the meaningful scope, not a hardcoded threshold in this function.
  • agreed_*_ci_low / agreed_*_ci_high = Wilson score 95% confidence interval bounds for the corresponding agreed_*_pct. Wilson stays inside [0, 1] and is honest at the small n typical of verified sets, where the normal approximation breaks down. null when comparable_count is 0. Reads as "94%, somewhere between 87% and 97%" — wide when few comparable, tight when many.
  • cohens_kappa = exact-taxon Cohen's kappa: human↔model agreement beyond chance, range [-1, 1] (negative = worse than chance). Plain agreement % rewards luck — in a project dominated by one common species, human and model agree most of the time just by both naming the common one. Kappa subtracts that expected-by-chance agreement. null when there are no comparable pairs or expected agreement is exactly 1.0 (a single taxon category, undefined). Computed from the same comparable pairs already in memory — no extra query.
  • agreed_coarser_rank_* = exact matches plus disagreements whose LCA is at the supplied agreement_coarsest_rank or deeper. null when no threshold supplied.
  • agreement_coarsest_rank = the threshold rank that was applied (echoed back to the caller). null when the param was absent.

Disagreement counts are not surfaced explicitly — derivable as comparable_count − agreed_*_count.

Usage examples

# Whole project, project defaults applied
curl '.../api/v2/occurrences/stats/model-agreement/?project_id=18'# Bypass project default filters (broader denominator)
curl '.../api/v2/occurrences/stats/model-agreement/?project_id=18&apply_defaults=false'# One deployment
curl '.../api/v2/occurrences/stats/model-agreement/?project_id=18&deployment=42'# Apply a coarser-rank threshold (also count LCAs at FAMILY or deeper)
curl '.../api/v2/occurrences/stats/model-agreement/?project_id=18&agreement_coarsest_rank=FAMILY'# One taxon and its descendants (hierarchical match)
curl '.../api/v2/occurrences/stats/model-agreement/?project_id=18&taxon=567'# Multiple algorithms (repeated param)
curl '.../api/v2/occurrences/stats/model-agreement/?project_id=18&algorithm=3&algorithm=7'# Combine: deployment + collection + bypass project defaults
curl '.../api/v2/occurrences/stats/model-agreement/?project_id=18&deployment=42&collection=99&apply_defaults=false'

The frontend consumer (#1308) wraps this in useModelAgreement(projectId, filters), which accepts an arbitrary filter map (including arrays for repeated params), so the occurrence list page's filter state can be threaded through unchanged.

Discovering the response shape (OPTIONS)

The viewset sets a custom metadata_class so an OPTIONS request returns the response serializer's field schema — including each field's help_text — under actions.GET. Frontends can fetch this once and key tooltips / labels off the field name instead of hardcoding stat descriptions in the UI.

curl -X OPTIONS '.../api/v2/occurrences/stats/model-agreement/?project_id=18'
{
"name": "Model agreement",
"description": "Verified / human↔model agreement rates over the filtered occurrence set. ...",
"actions": {
"GET": {
"verified_pct": {
"type": "float",
"label": "Verified pct",
"help_text": "verified_count / total_occurrences",
"min_value": 0.0,
"max_value": 1.0
},
"comparable_count": {
"type": "integer",
"label": "Comparable count",
"help_text": "Verified occurrences with BOTH a machine prediction and a human taxon — the denominator for all agreed_*_pct and the Wilson CIs. ..."
}
// ... every response field, with type / label / help_text / bounds
}
}
}

DRF's default SimpleMetadata only emits field schema for write methods (POST / PUT), so this is provided by ResponseSchemaMetadata (ami/base/metadata.py). The convention is documented in docs/claude/reference/api-stats-pattern.md for future stats endpoints. Interpretation copy (e.g. "a wide confidence interval means the rate is based on few verifications") stays in the frontend next to the visualization — help_text describes what a field is, not how to read it as a human.

Implementation notes

  • Base queryset is deduplicated (queryset.distinct()) before counting so the join chain from apply_default_filters (e.g. verified_by_meIdentification, taxa_list_idparents_json) can't inflate total_occurrences vs the verified branch.
  • Aggregation is scoped to the verified set (occurrences with at least one non-withdrawn identification), which is typically a small fraction of total. The expensive correlated subqueries — best user identification (over Identification) and best machine prediction (over Classification) — evaluate only on verified rows, not on the full filtered queryset.
  • LCA on disagreements is deduplicated to distinct (user_taxon, machine_taxon) pairs before computation.
  • The agreement denominator is the comparable cohort (both a machine prediction and a human taxon), not all verified-with-prediction rows. A comment-only verification (Identification.taxon is nullable) has a prediction but no human label, so it can neither agree nor disagree and is excluded from both numerator and denominator.
  • Reuses apply_default_filters so apply_defaults=false bypasses project default taxa lists + score thresholds.
  • agreement_coarsest_rank is validated with a DRF ChoiceField through SingleParamSerializer: blank, unknown, and UNKNOWN values all 400 at the boundary, and the choices propagate to the OpenAPI schema as an enum.
  • *_pct fields are bounded to [0.0, 1.0] in the serializer; cohens_kappa to [-1.0, 1.0].
  • Wilson CI + Cohen's kappa are pure-Python over the verified_rows list already materialized for the agreement counts — one extra pass, zero extra DB queries.

Bench

Project 18 (43,149 occurrences, 45 verified): 928ms → 350ms cold / 146ms warm after scoping subqueries to the verified set.

Across all production projects with non-zero identifications:

ProjectTotalVerifiedColdWarm
P#85 SEC-SEQ AI Symposium36,25313,1401.18s343ms
P#20 Barro Colorado Island40,9581,3510.92s153ms
P#84 Pennsylvania Tebufenozide18,4072510.56s139ms
P#24 Atlantic Forestry Centre2,7972740.50s203ms
P#46 AMBER - Panama10,248480.44s125ms
P#23 Insectarium de Montréal20,393740.43s190ms
P#16 Aarhus Ecoscience64480.38s128ms
P#18 Vermont Atlas of Life43,149450.35s146ms
P#49 Marc Bélisle's Lab439710.32s128ms
P#38 MothBox - Gamboa21100.31s122ms

Pre-rework state on project 18 with apply_defaults=false: 159s curl timeout.

Test plan

  • ami.main.tests.TestLcaRankBetween — 7 unit tests including UNKNOWN-rank regression.
  • ami.main.tests.TestModelAgreementForProject — empty-project + 4-bucket canonical case + coarsest_rank threshold filtering + taxon-less verification excluded from the comparable denominator.
  • ami.main.tests.TestOccurrenceStatsViewSet — HTTP coverage for envelope shape, draft-project 404, filter passthrough, apply_defaults=false bypass, exact-match happy path, sister-species any-rank bucket, invalid rank → 400, UNKNOWN rejection, blank rank → 400, threshold echo, CI/kappa field presence + null-on-empty, and OPTIONS returning the response field schema with help_text.
  • ami.main.tests.TestWilsonInterval + ami.main.tests.TestCohensKappa — 10 pure-Python unit tests (textbook 8/10 CI value, unit-interval clamping, interval-tightens-with-n, out-of-range successes raises; kappa known 2×2 value, perfect agreement, single-category-undefined, negative kappa, empty).
  • Full model-agreement suite green locally: 41/41 (TestLcaRankBetween, TestModelAgreementForProject, TestOccurrenceStatsViewSet, TestWilsonInterval, TestCohensKappa).
  • Live smoke against project 18 + 9 other production projects, including the heaviest verified-set (P#85, 13,140 idents).

This PR is backend-only. The frontend consumer — the useModelAgreement hook + the occurrence-list stats panel — lives in #1308.

Follow-ups (out of scope, calling out for next rounds)

apply_default_filters is the dominant cost on hot stats paths

Update:#1317 (taxa verification counts) addresses part of this — its perf rewrite (precompute + CASE aggregation) eliminated a 30s timeout on the per-taxon stats path that has the same root cause (the apply_default_filters + valid() stack being applied per-row). The compound index + GIN index below are still outstanding follow-ups.

For the heaviest project (P#85, 36k post-filter occurrences) the agreement subqueries on the verified set run in <50ms. The rest of the response time is the apply_default_filters + valid() filter stack on Occurrence. EXPLAIN ANALYZE on P#85 reveals:

  1. No compound index on (project_id, determination_score) — Postgres does a Parallel Seq Scan on main_occurrence and discards 195,549 rows by filter to find 36,253 matching the project-default score threshold. Hot path: ~60ms.
  2. No GIN index on Taxon.parents_json — for projects with default include/exclude taxa lists, the parents_json__contains JSONB containment check is a row-by-row evaluation. P#85 has no taxa lists so this didn't show up here, but it would dominate for projects that do (e.g. via OccurrenceFilter's recursive taxa filter).
  3. valid()'s anti-join to main_detection is fine (index scan, 36k loops on hot cache, <60ms).

This affects every endpoint that calls apply_default_filters() or Occurrence.objects.valid()/occurrences/, /captures/, /events/, and the other stats actions on this viewset. Anywhere a project default threshold is non-zero, the same seq-scan is happening.

Note: the entire bench table above was measured without these indexes (they don't exist yet), so those numbers are the no-index baseline — worst case 1.18s cold / 343ms warm. That's acceptable for a cached dashboard widget, so the indexes are a follow-up, not a blocker for this PR.

Likely cheap wins:

  • CREATE INDEX CONCURRENTLY main_occurrence_project_score_idx ON main_occurrence (project_id, determination_score) — index range scan instead of seq-scan-then-filter.
  • CREATE INDEX CONCURRENTLY main_taxon_parents_json_gin_idx ON main_taxon USING gin (parents_json jsonb_path_ops) — index lookup for parents_json__contains instead of full-row JSONB eval.

Filter-driven occurrence exports

This PR's filter parity wiring (OCCURRENCE_FILTER_BACKENDS + OCCURRENCE_FILTERSET_FIELDS) sets up a natural follow-up: let users click "Export" on /occurrences/ with the current filters applied and get a job whose output matches that filtered set, without first materializing a SourceImageCollection. The export infra already has a "filters JSON → re-run backends in worker" pattern (ami/exports/utils.py:13-72generate_fake_request() + apply_filters()) but is hardwired to OccurrenceCollectionFilter. Wiring the same shared backend tuple into the exporter would close the gap.

Explicit auth gate on stats viewset

OccurrenceStatsViewSet uses IsActiveStaffOrReadOnly. verified_by_me=true from an anon caller is safe today only because OccurrenceVerifiedByMeFilter.filter_queryset short-circuits on is_authenticated. Worth an explicit gate at the viewset level rather than relying on the filter's internal short-circuit.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • Added a new API endpoint to retrieve agreement metrics between human and machine predictions, including agreement rates, confidence intervals, and statistical measures like Cohen's kappa. Optional filtering by coarser taxonomic ranks is now supported.

Review Change Stack

CopilotAI review requested due to automatic review settings May 14, 2026 17:44
@netlify

netlifyBot commented May 14, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-preview canceled.

NameLink
🔨 Latest commitb65100f
🔍 Latest deploy loghttps://app.netlify.com/projects/antenna-preview/deploys/6a18c5355986f30008ecf677

@netlify

netlifyBot commented May 14, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-ssec canceled.

NameLink
🔨 Latest commitb65100f
🔍 Latest deploy loghttps://app.netlify.com/projects/antenna-ssec/deploys/6a18c535070aa90008719159

@coderabbitai

coderabbitaiBot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

Failed to post review comments

📝 Walkthrough

Walkthrough

This PR adds a stats endpoint /occurrences/stats/model-agreement/ that computes verified-occurrence counts and human–model agreement metrics (exact, any-rank, optional coarser-rank) over filtered occurrences, plus Wilson 95% CIs and Cohen's kappa, with supporting utilities, serializer, tests, and docs update.

Changes

Model Agreement Stats Endpoint

Layer / File(s)Summary
Statistical utility functions
ami/utils/stats.py
New module adds WILSON_Z_95, wilson_interval() for 95% Wilson confidence bounds on proportions, and cohens_kappa() for inter-rater agreement; both return rounded, bounded results or None when undefined.
Taxonomic LCA and agreement computation
ami/main/models_future/occurrence.py
Adds TaxonTuple type, lca_rank_between() to find deepest shared non-UNKNOWN rank, and model_agreement_for_project() to compute deduplicated totals, verified subsets, exact/any/coarser-rank agreement counts/percents, Wilson CIs, and Cohen's kappa.
API response serializer
ami/main/api/serializers.py
Adds ModelAgreementSerializer describing project-scoped totals, verified counts/pcts, exact and any-rank agreement counts/pcts with nullable Wilson CI fields, nullable Cohen's kappa, and optional coarser-rank agreement fields keyed to agreement_coarsest_rank.
API endpoint wiring and exposure
ami/main/api/views.py
Centralizes occurrence filter settings, adds filter plumbing to OccurrenceStatsViewSet, and implements a non-paginated model_agreement action that validates project visibility and agreement_coarsest_rank, applies default and DRF filters, computes agreement via model_agreement_for_project, and returns serialized results.
OPTIONS response metadata
ami/base/metadata.py
Adds ResponseSchemaMetadata to include the response serializer's field schema under actions["GET"] in OPTIONS when GET is allowed and a serializer can be instantiated.
Tests
ami/main/tests.py
Adds unit tests for LCA, Wilson interval, Cohen's kappa, model_agreement_for_project, and comprehensive endpoint tests for /api/v2/occurrences/stats/model-agreement/ (validation, filtering, coarsest-rank handling, CI/kappa presence, and OPTIONS schema).
Docs
docs/claude/reference/api-stats-pattern.md
Documents the response-field-via-OPTIONS pattern and updates the example endpoint path to GET /occurrences/stats/model-agreement/.

Sequence Diagram

sequenceDiagram
participant Client
participant ViewSet as OccurrenceStatsViewSet
participant DRFFilter as DRF Filters
participant Logic as model_agreement_for_project
participant Serializer as ModelAgreementSerializer
Client->>ViewSet: GET /stats/model-agreement?project_id=X
ViewSet->>ViewSet: Validate project visibility
ViewSet->>ViewSet: Parse & validate agreement_coarsest_rank
ViewSet->>ViewSet: Build base queryset with defaults
ViewSet->>DRFFilter: filter_queryset()
DRFFilter->>ViewSet: Filtered QuerySet
ViewSet->>Logic: Compute agreement metrics
Logic->>Logic: Deduplicate, count verified/unverified
Logic->>Logic: Compute exact & any-rank agreement
Logic->>Logic: Calculate rates, Wilson CIs, Cohen's kappa
Logic->>ViewSet: Agreement dict
ViewSet->>Serializer: Serialize result
Serializer->>Client: JSON response (counts, rates, CIs, kappa)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Poem

🐰 I nudge through taxon trees with nimble feet,

Counting matches where human and model meet.
Wilson bounds hush the noisy debate,
Kappa whispers chance we should negate —
A hop, a stat, agreement looks sweet!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title 'Endpoint for stats about verified occurrences' is clear and directly describes the main change—a new stats endpoint for agreement metrics over verified occurrences.
Description check✅ PassedThe PR description comprehensively covers all required sections: summary, list of changes, detailed implementation notes, comprehensive test plan, benchmarks, deployment notes, usage examples, and documented follow-ups.
Docstring Coverage✅ PassedDocstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

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

✨ 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 feat/human-model-agreement-endpoint

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.

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

Pull request overview

Adds a new scalar stats endpoint GET /occurrences/stats/human-model-agreement/ that reports verified-occurrence and human-vs-model agreement rates (exact and "under-order") for an occurrence queryset, reusing the existing /occurrences/ filter stack and apply_default_filters.

Changes:

  • New aggregation helper human_model_agreement_for_project plus pure-Python lca_rank_between over Taxon.parents_json in ami/main/models_future/occurrence.py.
  • New action on OccurrenceStatsViewSet plus HumanModelAgreementSerializer; extracts OccurrenceViewSet filter backends/fields into module-level tuples (OCCURRENCE_FILTER_BACKENDS, OCCURRENCE_FILTERSET_FIELDS) shared by both viewsets.
  • React Query hook useHumanModelAgreement and supporting planning/scoping docs.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.

Show a summary per file
FileDescription
ami/main/models_future/occurrence.pyNew LCA helper + Python-side aggregation function over a pre-filtered Occurrence queryset.
ami/main/api/views.pyExtracts shared occurrence filter config and adds human_model_agreement action on OccurrenceStatsViewSet.
ami/main/api/serializers.pyNew HumanModelAgreementSerializer describing the response shape.
ami/main/tests.pyUnit tests for lca_rank_between, aggregation tests, and HTTP-level tests for the new action.
ui/src/data-services/hooks/occurrences/stats/useHumanModelAgreement.tsNew typed React Query hook for the endpoint.
docs/claude/planning/2026-05-14-human-model-agreement-endpoint.mdImplementation plan document for the feature.
docs/claude/planning/occurrence-filter-driven-exports.mdSide-research scoping stub for filter-driven exports (out of scope of this PR).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadami/main/models_future/occurrence.py Outdated
Comment threadami/main/models_future/occurrence.py Outdated
Comment threadami/main/models_future/occurrence.py Outdated
Comment threadami/main/models_future/occurrence.py Outdated
Comment threadami/main/api/views.py
Comment threadami/main/tests.py Outdated
Comment threadui/src/data-services/hooks/occurrences/stats/useHumanModelAgreement.ts Outdated

@coderabbitaicoderabbitaiBot 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: 2

🧹 Nitpick comments (3)
ami/main/api/serializers.py (1)

1765-1769: ⚡ Quick win

Constrain percentage fields to the documented 0.0..1.0 range

These fields are contractually bounded; adding serializer bounds gives fast failure on accidental regressions and keeps response validation self-documenting.

Proposed diff
- verified_pct = serializers.FloatField(help_text="verified_count / total_occurrences")+ verified_pct = serializers.FloatField(+ min_value=0.0,+ max_value=1.0,+ help_text="verified_count / total_occurrences",+ )
@@
- agreed_exact_pct = serializers.FloatField(help_text="agreed_exact_count / verified_count")+ agreed_exact_pct = serializers.FloatField(+ min_value=0.0,+ max_value=1.0,+ help_text="agreed_exact_count / verified_count",+ )
@@
- agreed_under_order_pct = serializers.FloatField(help_text="agreed_under_order_count / verified_count")+ agreed_under_order_pct = serializers.FloatField(+ min_value=0.0,+ max_value=1.0,+ help_text="agreed_under_order_count / verified_count",+ )
🤖 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 `@ami/main/api/serializers.py` around lines 1765 - 1769, The percentage fields
verified_pct, agreed_exact_pct, and agreed_under_order_pct are currently
unbounded; update their declarations to add validation bounds (min_value=0.0,
max_value=1.0) on the serializers.FloatField instances so the serializer
enforces the documented 0.0..1.0 contract and fails fast on invalid values.
ui/src/data-services/hooks/occurrences/stats/useHumanModelAgreement.ts (2)

4-13: 💤 Low value

Consider renaming Response to avoid shadowing the global DOM type.

Response is the name of the global fetch response type. Shadowing it at module scope is harmless today but creates a foot-gun if anyone later references the DOM Response in this file. A domain-prefixed name (e.g., HumanModelAgreementResponse) is clearer at call sites too.

♻️ Proposed rename
-interface Response {+interface HumanModelAgreementResponse {
project_id: number
total_occurrences: number
verified_count: number
verified_pct: number
agreed_exact_count: number
agreed_exact_pct: number
agreed_under_order_count: number
agreed_under_order_pct: number
}
@@
- const { data, isLoading, isFetching, error } = useAuthorizedQuery<Response>({+ const { data, isLoading, isFetching, error } = useAuthorizedQuery<HumanModelAgreementResponse>({
🤖 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 `@ui/src/data-services/hooks/occurrences/stats/useHumanModelAgreement.ts`
around lines 4 - 13, Rename the module-scoped interface named Response to a
domain-specific name (e.g., HumanModelAgreementResponse) to avoid shadowing the
global DOM Response type; update the interface declaration and all references to
it within useHumanModelAgreement.ts (and any exported types/imports) so code
that needs the DOM Response can still reference it unambiguously and call sites
use the new HumanModelAgreementResponse identifier.

20-32: ⚡ Quick win

Single-value filter map drops multi-value query params (e.g., algorithm).

OccurrenceAlgorithmFilter reads algorithm and not_algorithm via request.query_params.getlist(...) on the backend, so callers can legitimately pass multiple algorithm IDs. The current Record<string, string | number | boolean | undefined> plus params.set(...) collapses any such filter to a single value, so this hook can't fully reproduce the /occurrences/ filter set the PR objectives describe.

Consider widening the value type and switching to append per item:

♻️ Proposed change
 export const useHumanModelAgreement = (
projectId?: string,
- filters?: Record<string, string | number | boolean | undefined>+ filters?: Record<+ string,+ string | number | boolean | Array<string | number> | undefined+ >
) => {
const url = `${API_URL}/${API_ROUTES.OCCURRENCES}/stats/human-model-agreement/`
const params = new URLSearchParams()
if (projectId) params.set('project_id', projectId)
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
- if (value !== undefined && value !== '' && value !== null) {- params.set(key, String(value))- }+ if (value === undefined || value === null || value === '') return+ if (Array.isArray(value)) {+ value.forEach((v) => {+ if (v !== undefined && v !== null && v !== '') {+ params.append(key, String(v))+ }+ })+ } else {+ params.set(key, String(value))+ }
})
}
🤖 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 `@ui/src/data-services/hooks/occurrences/stats/useHumanModelAgreement.ts`
around lines 20 - 32, The hook useHumanModelAgreement currently types filters as
Record<string, string | number | boolean | undefined> and calls params.set(...),
which collapses multi-value query params; update the filters param type to allow
string[] (e.g., Record<string, string | number | boolean | string[] |
undefined>) and when iterating Object.entries(filters) detect arrays and call
params.append(key, String(item)) for each element (fall back to params.set for
single values), ensuring multi-value keys like "algorithm" and "not_algorithm"
are preserved in the generated URL.
🤖 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 `@ami/main/models_future/occurrence.py`:
- Line 187: The code eagerly materializes the entire QuerySet into memory by
doing occurrences = list(qs); change this to a memory-safe iteration or paging
approach: replace the full list() with chunked processing using
qs.iterator(chunk_size=1000) or loop over qs in paginated batches (e.g.,
Paginator or manual offset/limit) and aggregate/write results per-chunk, and
avoid prefetching everything at once (adjust or remove the prefetch_related on
identifications or use values()/only()/defer() to limit fetched fields) so
memory usage stays bounded; update any downstream logic that expects a full list
to work with incremental processing or collect results into a streaming response
instead.
In `@docs/claude/planning/2026-05-14-human-model-agreement-endpoint.md`:
- Around line 26-43: The fenced code block listing project files lacks a
language tag (triggering markdownlint MD040); update the opening fence for the
block that contains entries like "ami/ ... occurrence.py # ADD:
human_model_agreement_for_project(), _lca_rank_of() helper", "serializers.py #
ADD: HumanModelAgreementSerializer", and "useHumanModelAgreement.ts # ADD: typed
React Query hook" to include a language identifier (e.g., ```text) so the block
is explicitly labeled; keep the same block content and closing fence unchanged.
---
Nitpick comments:
In `@ami/main/api/serializers.py`:
- Around line 1765-1769: The percentage fields verified_pct, agreed_exact_pct,
and agreed_under_order_pct are currently unbounded; update their declarations to
add validation bounds (min_value=0.0, max_value=1.0) on the
serializers.FloatField instances so the serializer enforces the documented
0.0..1.0 contract and fails fast on invalid values.
In `@ui/src/data-services/hooks/occurrences/stats/useHumanModelAgreement.ts`:
- Around line 4-13: Rename the module-scoped interface named Response to a
domain-specific name (e.g., HumanModelAgreementResponse) to avoid shadowing the
global DOM Response type; update the interface declaration and all references to
it within useHumanModelAgreement.ts (and any exported types/imports) so code
that needs the DOM Response can still reference it unambiguously and call sites
use the new HumanModelAgreementResponse identifier.
- Around line 20-32: The hook useHumanModelAgreement currently types filters as
Record<string, string | number | boolean | undefined> and calls params.set(...),
which collapses multi-value query params; update the filters param type to allow
string[] (e.g., Record<string, string | number | boolean | string[] |
undefined>) and when iterating Object.entries(filters) detect arrays and call
params.append(key, String(item)) for each element (fall back to params.set for
single values), ensuring multi-value keys like "algorithm" and "not_algorithm"
are preserved in the generated URL.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: a97a61f4-518e-4bf7-b6b5-fb325dc4e97d

📥 Commits

Reviewing files that changed from the base of the PR and between aeb57c1 and b81a987.

📒 Files selected for processing (7)
  • ami/main/api/serializers.py
  • ami/main/api/views.py
  • ami/main/models_future/occurrence.py
  • ami/main/tests.py
  • docs/claude/planning/2026-05-14-human-model-agreement-endpoint.md
  • docs/claude/planning/occurrence-filter-driven-exports.md
  • ui/src/data-services/hooks/occurrences/stats/useHumanModelAgreement.ts

Comment threadami/main/models_future/occurrence.py Outdated
Comment threaddocs/claude/planning/2026-05-14-human-model-agreement-endpoint.md Outdated
@mihow
mihow marked this pull request as draft May 14, 2026 20:01
@mihowmihow changed the title feat(occurrence-stats): add /occurrences/stats/human-model-agreement/ endpointEndpoint for stats about verified occurrencesMay 14, 2026
@annavik

Copy link
Copy Markdown
Member

Oh yes!!

mihow added a commit that referenced this pull request May 15, 2026
… review fixes
Captures: review findings from Copilot + CodeRabbit, perf bench evidence
(43k rows → 159s timeout on apply_defaults=false), and the planned changes
for the next session (rename to model-agreement, push aggregation into
SQL/ORM, fix UNKNOWN rank LCA + denominator + verified_by_me anon gap +
test gaps).
Co-Authored-By: Claude <noreply@anthropic.com>
mihow added a commit that referenced this pull request May 15, 2026
…ion to SQL
Addresses review feedback on PR #1307:
Rename (drop "human"):
- URL: /occurrences/stats/human-model-agreement/ -> /model-agreement/
- Function: human_model_agreement_for_project -> model_agreement_for_project
- Serializer: HumanModelAgreementSerializer -> ModelAgreementSerializer
- Viewset action + url_path: human_model_agreement -> model_agreement
- FE hook: useHumanModelAgreement -> useModelAgreement (file + symbol)
- FE type: Response -> ModelAgreementResponse (fixes DOM Response shadow)
- Test class: TestHumanModelAgreementForProject -> TestModelAgreementForProject
SQL push-down (Copilot+CodeRabbit perf flag):
- Replace list(qs) full-row materialization with annotated aggregate().
- Annotate best_user_taxon_id via Subquery over Identification
(BEST_IDENTIFICATION_ORDER). Drop the prefetch + select_related("taxon")
on identifications since only taxon_id is read.
- aggregate() Count(filter=Q(...)) for total/verified/exact/no-prediction.
- For under-order disagreement: group disagreement set by distinct
(user_taxon, machine_taxon) pair before LCA. Each pair's LCA runs once.
- Bench against project 18 (43,149 occurrences): pre-rework apply_defaults=false
curl timed out at 159s; post-rework 1.96s unfiltered / 3.4s with bypass
(93,019 occurrences post-filter).
Denominator fix (Copilot):
- agreed_*_pct now divides by verified_with_prediction_count instead of
verified_count. A verified occurrence with no machine prediction can't
agree or disagree; including it in the denominator drags the rate down
without representing actual model disagreement.
- Surface no_prediction_count + verified_with_prediction_count as sibling
fields so consumers can see how many such occurrences exist.
UNKNOWN rank bug (Copilot):
- TaxonRank.UNKNOWN sorts after SPECIES in OrderedEnum definition order,
so without explicit exclusion UNKNOWN >= ORDER is True and a shared
UNKNOWN ancestor would wrongly count as under-order agreement. Filter
UNKNOWN out of lca_rank_between's candidate ranks. Add regression test.
Tests:
- New: test_unknown_rank_excluded_from_lca (LCA regression)
- New: test_agreement_under_order_bucket (HTTP coverage for sister-species
case, previously only exact-match shortcut was exercised)
- Updated: happy-path asserts verified_with_prediction_count and
no_prediction_count.
22/22 backend tests green:
docker compose exec django python manage.py test
ami.main.tests.TestLcaRankBetween
ami.main.tests.TestModelAgreementForProject
ami.main.tests.TestOccurrenceStatsViewSet
Co-Authored-By: Claude <noreply@anthropic.com>
@mihowmihow changed the title Endpoint for stats about verified occurrencesfeat(occurrence-stats): /occurrences/stats/model-agreement/ endpointMay 15, 2026
@mihowmihow changed the title feat(occurrence-stats): /occurrences/stats/model-agreement/ endpointEndpoint for stats about verified occurrencesMay 15, 2026
@mihow
mihow marked this pull request as ready for review May 21, 2026 00:20

@coderabbitaicoderabbitaiBot 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: 2

Caution

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

⚠️ Outside diff range comments (1)
ui/src/data-services/hooks/occurrences/stats/useModelAgreement.ts (1)

22-33: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Support repeated query params for multi-select filters.

Record<string, primitive> + params.set(...) drops repeated keys, so multi-value filters (e.g., repeated algorithm / not_algorithm) can’t be forwarded faithfully from occurrence filters.

💡 Proposed fix
-export const useModelAgreement = (- projectId?: string,- filters?: Record<string, string | number | boolean | undefined>-) => {+type FilterPrimitive = string | number | boolean+type FilterValue = FilterPrimitive | FilterPrimitive[] | null | undefined++export const useModelAgreement = (+ projectId?: string,+ filters?: Record<string, FilterValue>+) => {
const url = `${API_URL}/${API_ROUTES.OCCURRENCES}/stats/model-agreement/`
const params = new URLSearchParams()
if (projectId) params.set('project_id', projectId)
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
- if (value !== undefined && value !== '' && value !== null) {- params.set(key, String(value))- }+ if (Array.isArray(value)) {+ value.forEach((item) => {+ if (item !== undefined && item !== '' && item !== null) {+ params.append(key, String(item))+ }+ })+ return+ }+ if (value !== undefined && value !== '' && value !== null) {+ params.set(key, String(value))+ }
})
}
+ const queryString = params.toString()
const { data, isLoading, isFetching, error } =
useAuthorizedQuery<ModelAgreementResponse>({
queryKey: [
API_ROUTES.OCCURRENCES,
'stats',
'model-agreement',
projectId,
- filters,+ queryString,
],
- url: `${url}?${params.toString()}`,+ url: `${url}?${queryString}`,
})

Also applies to: 38-46

🤖 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 `@ui/src/data-services/hooks/occurrences/stats/useModelAgreement.ts` around
lines 22 - 33, The current implementation converts multi-value filters into a
Record<string, primitive> and uses params.set(...), which overwrites duplicate
query keys and loses multi-select filters; update the filters type to allow
arrays (e.g., Record<string, string | number | boolean | string[] | undefined>),
and when building URLSearchParams switch to using params.append(...) for
repeated values: if the filter value is an array loop and params.append(key,
String(v)) for each entry, otherwise call params.append(key, String(value));
ensure the same change is applied in the other occurrence mentioned (the block
around the second params handling at lines ~38-46). Use the existing params and
filters identifiers so the change is localized.
🧹 Nitpick comments (1)
docs/claude/prompts/NEXT_SESSION_PROMPT.md (1)

1-86: ⚡ Quick win

Planning document appears stale and may confuse future readers.

This file is titled "Next session" and describes tasks "for this session" (lines 7-68), but according to the PR objectives summary, the work described here has already been completed:

  • Renaming from "human-model-agreement" to "model-agreement" ✓
  • SQL aggregation push ✓
  • UNKNOWN rank bug fix ✓
  • Denominator fix (verified_with_prediction_count) ✓

Including a "NEXT_SESSION_PROMPT" document that describes already-completed work as if it's pending creates confusion for future developers who might try to execute these tasks again or wonder what state the codebase is in.

Additionally, line 5 references the old endpoint URL that will 404 after the renaming.

Consider one of:

  1. Archive/rename this to docs/claude/planning/2026-05-14-session-notes-pr-1307.md (historical record, past tense)
  2. Remove it if the other planning doc at line 18 already serves as the historical record
  3. Add a header clearly stating "Historical planning document - work completed in commits X-Y"
🤖 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 `@docs/claude/prompts/NEXT_SESSION_PROMPT.md` around lines 1 - 86, The
"NEXT_SESSION_PROMPT.md" planning doc is stale and misleading; update it by
either (a) renaming/archiving it (e.g., to
docs/claude/planning/2026-05-14-session-notes-pr-1307.md) and leaving as
historical record, (b) deleting it if redundant, or (c) editing the top of
NEXT_SESSION_PROMPT.md to a clear "Historical planning document — work completed
in commits <sha-range>" header and update/remove the old endpoint URL reference;
ensure you touch the file named NEXT_SESSION_PROMPT.md and fix the line that
references the old endpoint URL
(http://localhost:8000/api/v2/occurrences/stats/human-model-agreement/?) so it
no longer points to a non-existent route and include the completed-commits SHAs
or a pointer to the merged PR in the header.
🤖 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 `@ami/main/models_future/occurrence.py`:
- Around line 201-213: The count is taken from the raw queryset (total =
queryset.count()) but the verified branch uses a deduped queryset (.distinct()),
so duplicates in the incoming queryset can inflate total; change to operate on a
deduplicated base queryset (e.g., replace/count using queryset.distinct() or
assign deduped = queryset.distinct() and use deduped for total and downstream
operations like the block that builds verified_rows and any other aggregations)
so that total, verified_rows and agreement numerators use the same deduplicated
set (refer to total, verified_rows and the use of .distinct() in this
file/function).
In `@docs/claude/prompts/NEXT_SESSION_PROMPT.md`:
- Line 86: The TODO about updating MEMORY.md is incomplete—either perform the
update or remove/clarify the note: add a new entry named
project_pr_1307_human_model_agreement.md into MEMORY.md summarizing the current
PR state (references to PR `#1307`, the plan doc at
docs/claude/planning/occurrence-filter-driven-exports.md, and the exported
stub), or delete the parenthetical “(TODO this session start)” and replace it
with a clear status line (e.g., “updated” or “needs follow-up”) so the commit
message and NEXT_SESSION_PROMPT.md reflect an accurate, actionable state.
---
Outside diff comments:
In `@ui/src/data-services/hooks/occurrences/stats/useModelAgreement.ts`:
- Around line 22-33: The current implementation converts multi-value filters
into a Record<string, primitive> and uses params.set(...), which overwrites
duplicate query keys and loses multi-select filters; update the filters type to
allow arrays (e.g., Record<string, string | number | boolean | string[] |
undefined>), and when building URLSearchParams switch to using
params.append(...) for repeated values: if the filter value is an array loop and
params.append(key, String(v)) for each entry, otherwise call params.append(key,
String(value)); ensure the same change is applied in the other occurrence
mentioned (the block around the second params handling at lines ~38-46). Use the
existing params and filters identifiers so the change is localized.
---
Nitpick comments:
In `@docs/claude/prompts/NEXT_SESSION_PROMPT.md`:
- Around line 1-86: The "NEXT_SESSION_PROMPT.md" planning doc is stale and
misleading; update it by either (a) renaming/archiving it (e.g., to
docs/claude/planning/2026-05-14-session-notes-pr-1307.md) and leaving as
historical record, (b) deleting it if redundant, or (c) editing the top of
NEXT_SESSION_PROMPT.md to a clear "Historical planning document — work completed
in commits <sha-range>" header and update/remove the old endpoint URL reference;
ensure you touch the file named NEXT_SESSION_PROMPT.md and fix the line that
references the old endpoint URL
(http://localhost:8000/api/v2/occurrences/stats/human-model-agreement/?) so it
no longer points to a non-existent route and include the completed-commits SHAs
or a pointer to the merged PR in the header.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: c229eb53-6f25-4d87-bd0b-622992ee75eb

📥 Commits

Reviewing files that changed from the base of the PR and between b81a987 and 0924027.

📒 Files selected for processing (8)
  • ami/main/api/serializers.py
  • ami/main/api/views.py
  • ami/main/models_future/occurrence.py
  • ami/main/tests.py
  • docs/claude/planning/2026-05-14-human-model-agreement-endpoint.md
  • docs/claude/prompts/NEXT_SESSION_PROMPT.md
  • docs/claude/reference/api-stats-pattern.md
  • ui/src/data-services/hooks/occurrences/stats/useModelAgreement.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/claude/planning/2026-05-14-human-model-agreement-endpoint.md

Comment threadami/main/models_future/occurrence.py
Comment threaddocs/claude/prompts/NEXT_SESSION_PROMPT.md Outdated
mihow pushed a commit that referenced this pull request May 21, 2026
…pport
Renames `agreed_under_order_*` → `agreed_any_rank_*` to match the
backend's dropped ORDER threshold. Adds optional `agreement_coarsest_rank` +
`agreed_coarser_rank_*` fields to the response type (not consumed by the UI
yet — the stats panel still renders `verified_pct` + `agreed_any_rank_pct`).
Also widens `filters` to accept arrays and appends repeated query params so
multi-value filters (e.g. `algorithm`, `not_algorithm` — backend reads via
`request.query_params.getlist(...)`) survive. Same fix CodeRabbit flagged
in PR #1307 review.
Co-Authored-By: Claude <noreply@anthropic.com>
mihow pushed a commit that referenced this pull request May 21, 2026
…pport
Renames `agreed_under_order_*` → `agreed_any_rank_*` to match the
backend's dropped ORDER threshold. Adds optional `agreement_coarsest_rank` +
`agreed_coarser_rank_*` fields to the response type (not consumed by the UI
yet — the stats panel still renders `verified_pct` + `agreed_any_rank_pct`).
Also widens `filters` to accept arrays and appends repeated query params so
multi-value filters (e.g. `algorithm`, `not_algorithm` — backend reads via
`request.query_params.getlist(...)`) survive. Same fix CodeRabbit flagged
in PR #1307 review.
Co-Authored-By: Claude <noreply@anthropic.com>
mihow pushed a commit that referenced this pull request May 21, 2026
One-line field rename in the occurrence stats panel to match the backend's
dropped ORDER threshold. Hook type rename + multi-value filter support
landed on the base branch (4a92c0b on #1307).
Co-Authored-By: Claude <noreply@anthropic.com>
@mihow

Copy link
Copy Markdown
CollaboratorAuthor

Claude says: Pushed two commits addressing this round of CodeRabbit feedback + a follow-on design extension the user signed off on.

Code changes (0565f06)feat(occurrence-stats): drop ORDER threshold; add coarsest_rank query param

  • Drops the hardcoded lca >= TaxonRank.ORDER agreement gate. Replaces agreed_under_order_* with agreed_any_rank_* (exact + any non-null LCA at a real rank; UNKNOWN still excluded). Idea: the upstream filter (e.g. a Lepidoptera include list) already bounds the meaningful scope, so a fixed threshold in this function is unnecessary noise.
  • Adds optional ?agreement_coarsest_rank=<RANK> for callers who do want an explicit floor. When supplied, response also includes agreed_coarser_rank_* (exact + LCAs at-or-deeper-than the threshold). Always echoes the applied rank in agreement_coarsest_rank so consumers can disambiguate. Invalid rank or UNKNOWN → 400.
  • Addresses CodeRabbit:
    • Dedupes base queryset (queryset.distinct()) so default-filter joins (verified_by_me, taxa_list_id) can't inflate total_occurrences vs the verified branch.
    • Bounds the *_pct FloatField serializers to [0.0, 1.0].
  • Tests: existing buckets renamed; new cases for the coarsest-rank threshold filtering shallow LCAs, invalid rank → 400, UNKNOWN rejection, and the threshold echo.

FE follow-up (4a92c0b)feat(ui): align model-agreement hook with BE rename + multi-value query params

  • Renames agreed_under_order_*agreed_any_rank_* in the hook's response type; adds the optional coarser-rank fields.
  • Widens filters to accept arrays and uses params.append(...) so multi-value filters (algorithm, not_algorithm — backend reads via request.query_params.getlist(...)) survive instead of getting collapsed by params.set.

Tests: 19/19 in TestModelAgreementForProject + TestOccurrenceStatsViewSet pass (--keepdb). Live render on localhost:4000 still shows VERIFIED 0% / AGREEMENT 71% on P18.

Stale NEXT_SESSION_PROMPT.md flagged in the latest review is deferred — useful as narrative for this rename and not on the production path.

🤖 Generated with Claude Code

mihow pushed a commit that referenced this pull request May 21, 2026
One-line field rename in the occurrence stats panel to match the backend's
dropped ORDER threshold. Hook type rename + multi-value filter support
landed on the base branch (4a92c0b on #1307).
Co-Authored-By: Claude <noreply@anthropic.com>
mihow pushed a commit that referenced this pull request May 21, 2026
One-line field rename in the occurrence stats panel to match the backend's
dropped ORDER threshold. Hook type rename + multi-value filter support
landed on the base branch (4a92c0b on #1307).
Co-Authored-By: Claude <noreply@anthropic.com>
mihow added a commit that referenced this pull request May 22, 2026
useModelAgreement.ts belongs with the frontend consumer (#1308), not the
backend endpoint PR. Keeps #1307 backend-only.
Co-Authored-By: Claude <noreply@anthropic.com>
mihow pushed a commit that referenced this pull request May 22, 2026
One-line field rename in the occurrence stats panel to match the backend's
dropped ORDER threshold. Hook type rename + multi-value filter support
landed on the base branch (4a92c0b on #1307).
Co-Authored-By: Claude <noreply@anthropic.com>
mihow added a commit that referenced this pull request May 22, 2026
Typed React Query wrapper for /occurrences/stats/model-agreement/.
Owned by this UI PR (#1308); the backend PR (#1307) is now backend-only.
Co-Authored-By: Claude <noreply@anthropic.com>
@mihowmihow mentioned this pull request May 22, 2026
4 tasks
@mihow

Copy link
Copy Markdown
CollaboratorAuthor

Claude says: Follow-up from #1316 / #1317, which builds the per-taxon version of this. Two findings here that are relevant to this PR:

1. The parents_json GIN index follow-up — shipped, but it isn't the fix.

#1316 carried over the suggestion to add a GIN index on Taxon.parents_json (jsonb_path_ops) as the cure for slow higher-rank (Family/Order) rollups. We shipped it (migration 0085). Measured outcome: with the index in place, the per-taxon rollup still timed out (30s statement limit) on a production-scale project (~1k observed taxa, ~17k occurrences).

Reason: the rollup ran as a correlated subquery with parents_json @> [{"id": OuterRef(...)}], and a GIN index can't serve a containment whose right-hand side is an OuterRef — it's a per-row parameter, not a constant. So it degraded to a per-row scan, run once per page row plus once per taxon in the pagination COUNT. The index is still worth keeping (it serves the literal-RHS containment filters: occurrence-list taxon=<id>, the default-taxa filter), but it does not fix correlated rollups.

2. The pattern that did fix it — relevant to this endpoint's in-memory concern.

The reviews here flagged model_agreement_for_project materializing the full filtered queryset into Python on large projects. The approach we landed in #1317 sidesteps both problems at once: the counts only concern verified occurrences (those with a non-withdrawn Identification), which are sparse — bounded by human review effort, not total occurrences. Compute the rollup in a single pass over just that small set → constant-time CASE annotations (and id__in for the membership filter). Same project went from 30s timeout to ~0.3s across the default list / verified=true|false / ordering=verified_count. Cost is O(verified occurrences × ancestor depth), not O(all occurrences) — might be a cheaper path for this endpoint's large-project case too.

Writeup with the anti-pattern vs pattern and the gotchas (pagination COUNT annotation stripping, cachalot polluting repeated benchmarks, django-pydantic-field.values() returning pydantic objects rather than dicts): docs/claude/reference/hierarchical-rollup-query-performance.md (added in #1317).

mihow added a commit that referenced this pull request May 26, 2026
… review fixes
Captures: review findings from Copilot + CodeRabbit, perf bench evidence
(43k rows → 159s timeout on apply_defaults=false), and the planned changes
for the next session (rename to model-agreement, push aggregation into
SQL/ORM, fix UNKNOWN rank LCA + denominator + verified_by_me anon gap +
test gaps).
Co-Authored-By: Claude <noreply@anthropic.com>
mihowand others added 14 commits May 27, 2026 06:27
Adds 6 HTTP-level tests: missing project_id 400, draft 404, empty zeros,
happy-path exact match, deployment filter pass-through, apply_defaults=false
score-threshold bypass.
Also adds DjangoFilterBackend to OccurrenceStatsViewSet.filter_backends so
filterset_fields (event, deployment, determination__rank, ...) actually take
effect. Without DjangoFilterBackend, filterset_fields are silently ignored
and ?deployment=N returns the unfiltered set.
Co-Authored-By: Claude <noreply@anthropic.com>
Mirrors useTopIdentifiers's useAuthorizedQuery pattern. Accepts an
arbitrary filter map so the occurrence list page can thread its filter
state through unchanged (deployment, event, taxon, score thresholds,
apply_defaults).
Co-Authored-By: Claude <noreply@anthropic.com>
… review fixes
Captures: review findings from Copilot + CodeRabbit, perf bench evidence
(43k rows → 159s timeout on apply_defaults=false), and the planned changes
for the next session (rename to model-agreement, push aggregation into
SQL/ORM, fix UNKNOWN rank LCA + denominator + verified_by_me anon gap +
test gaps).
Co-Authored-By: Claude <noreply@anthropic.com>
…ion to SQL
Addresses review feedback on PR #1307:
Rename (drop "human"):
- URL: /occurrences/stats/human-model-agreement/ -> /model-agreement/
- Function: human_model_agreement_for_project -> model_agreement_for_project
- Serializer: HumanModelAgreementSerializer -> ModelAgreementSerializer
- Viewset action + url_path: human_model_agreement -> model_agreement
- FE hook: useHumanModelAgreement -> useModelAgreement (file + symbol)
- FE type: Response -> ModelAgreementResponse (fixes DOM Response shadow)
- Test class: TestHumanModelAgreementForProject -> TestModelAgreementForProject
SQL push-down (Copilot+CodeRabbit perf flag):
- Replace list(qs) full-row materialization with annotated aggregate().
- Annotate best_user_taxon_id via Subquery over Identification
(BEST_IDENTIFICATION_ORDER). Drop the prefetch + select_related("taxon")
on identifications since only taxon_id is read.
- aggregate() Count(filter=Q(...)) for total/verified/exact/no-prediction.
- For under-order disagreement: group disagreement set by distinct
(user_taxon, machine_taxon) pair before LCA. Each pair's LCA runs once.
- Bench against project 18 (43,149 occurrences): pre-rework apply_defaults=false
curl timed out at 159s; post-rework 1.96s unfiltered / 3.4s with bypass
(93,019 occurrences post-filter).
Denominator fix (Copilot):
- agreed_*_pct now divides by verified_with_prediction_count instead of
verified_count. A verified occurrence with no machine prediction can't
agree or disagree; including it in the denominator drags the rate down
without representing actual model disagreement.
- Surface no_prediction_count + verified_with_prediction_count as sibling
fields so consumers can see how many such occurrences exist.
UNKNOWN rank bug (Copilot):
- TaxonRank.UNKNOWN sorts after SPECIES in OrderedEnum definition order,
so without explicit exclusion UNKNOWN >= ORDER is True and a shared
UNKNOWN ancestor would wrongly count as under-order agreement. Filter
UNKNOWN out of lca_rank_between's candidate ranks. Add regression test.
Tests:
- New: test_unknown_rank_excluded_from_lca (LCA regression)
- New: test_agreement_under_order_bucket (HTTP coverage for sister-species
case, previously only exact-match shortcut was exercised)
- Updated: happy-path asserts verified_with_prediction_count and
no_prediction_count.
22/22 backend tests green:
docker compose exec django python manage.py test
ami.main.tests.TestLcaRankBetween
ami.main.tests.TestModelAgreementForProject
ami.main.tests.TestOccurrenceStatsViewSet
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Replace the .aggregate() over the full filtered queryset with a two-step
approach:
1. SQL Count('pk') for total_occurrences (no joins, no subqueries).
2. Fetch the verified set (occurrences with at least one non-withdrawn
ident) with both best_user_taxon_id and best_machine_prediction_taxon_id
annotated, then bucket counts + LCA in Python.
Why: the previous version evaluated two correlated subqueries (best user
identification + best machine prediction) on every row of the filtered
queryset. For typical projects, >95% of occurrences have no identification
— those rows ran the user-ident subquery only to discover NULL, then ran
the (much more expensive) machine-prediction subquery on detections that
won't contribute to any agreement bucket. Scoping the subqueries to the
verified set avoids that waste.
Bench (cold, cache invalidated):
Project Total Verified Pre Post
P#85 SEC-SEQ 36,253 13,140 — 1.18s
P#20 BCI 40,958 1,351 — 0.92s
P#84 Pennsylvania 18,407 251 — 0.56s
P#24 Atlantic Forestry 2,797 274 — 0.50s
P#18 Vermont 43,149 45 ~928ms 0.35s
P#23 Insectarium Montreal 20,393 74 — 0.43s
Warm via django-cachalot: 122–343ms across all projects.
For P#85 (highest absolute identification count in the system), the cost
is dominated by apply_default_filters' score-threshold join, not the
subqueries. apply_defaults=false actually runs faster (0.69s cold,
179,466 total / 13,140 verified) because the classification join is
skipped.
Co-Authored-By: Claude <noreply@anthropic.com>
… param
Replaces hardcoded `lca >= TaxonRank.ORDER` agreement gate with two layers:
- Always returned: `agreed_any_rank_*` — exact matches plus any non-null LCA
at a real rank (UNKNOWN excluded). The upstream filter (e.g. a Lepidoptera
include list) is what bounds the meaningful scope, not a hardcoded
threshold in this function.
- Optional `?agreement_coarsest_rank=FAMILY`: when supplied, response also
includes `agreed_coarser_rank_*` (exact + LCAs at or below the threshold).
The applied rank is echoed in `agreement_coarsest_rank`; null when absent.
Also addresses CodeRabbit feedback on the existing branch:
- Dedupe base queryset before counting (joins from default-filter chain can
inflate Occurrence rows).
- Bound `*_pct` FloatFields to [0.0, 1.0] in the serializer.
Param validation: invalid rank → 400; UNKNOWN rejected as not meaningful.
Tests cover any-rank fallback, threshold filtering, invalid + UNKNOWN
rejection, and threshold echo.
Co-Authored-By: Claude <noreply@anthropic.com>
…ry params
- Rename `agreed_under_order_*` → `agreed_any_rank_*` to match the endpoint's
dropped ORDER threshold (0565f06).
- Add optional `agreement_coarsest_rank` + `agreed_coarser_rank_*` fields to
the response type (not consumed yet — UI follows in #1308).
- Widen `filters` to accept arrays and append repeated query params so
multi-value filters (e.g. `algorithm`, `not_algorithm` — backend reads via
`request.query_params.getlist(...)`) survive. Per CodeRabbit review.
Co-Authored-By: Claude <noreply@anthropic.com>
Session-scratchpad doc — belongs in local notes, not the merged branch.
Co-Authored-By: Claude <noreply@anthropic.com>
- 2026-05-14-human-model-agreement-endpoint.md — design narrative; superseded
by code + PR description.
- occurrence-filter-driven-exports.md — side-research stub Copilot flagged as
out-of-scope. Promoted to a PR-description follow-up item.
Co-Authored-By: Claude <noreply@anthropic.com>
create_detections assigns the classification taxon via .order_by("?"),
so the previous test picked a random machine taxon and then required a
sister species under the same genus. Random non-species picks (ORDER /
FAMILY / GENUS) have no sister, flaking ~50% of runs.
Pin both the machine prediction and the human ID to two fixed Vanessa
species, so the LCA is always GENUS (any-rank bucket, not exact) and the
test is deterministic.
Co-Authored-By: Claude <noreply@anthropic.com>
useModelAgreement.ts belongs with the frontend consumer (#1308), not the
backend endpoint PR. Keeps #1307 backend-only.
Co-Authored-By: Claude <noreply@anthropic.com>
Both derive from the verified_rows already in memory — no extra query.
- wilson_interval(): 95% Wilson score CI on agreed_exact_pct and
agreed_any_rank_pct (agreed_*_ci_low / _ci_high). Wilson stays inside
[0,1] and is honest at the small n typical of verified sets, where the
normal approximation breaks down.
- cohens_kappa(): exact-taxon agreement beyond chance (cohens_kappa
field, range [-1, 1]). Null when no doubly-classified occurrences or
expected agreement is 1.0. Discounts the agreement you'd get for free
in a project dominated by one common species.
Adds 5 nullable response fields. Backwards-compatible (additive only).
9 pure-Python unit tests + 2 HTTP field-presence tests.
Co-Authored-By: Claude <noreply@anthropic.com>
Both are generic statistical helpers — they don't depend on Django or any
domain model. Lifting them out of ami/main/models_future/occurrence.py so
other endpoints/jobs that need binomial CIs or chance-corrected agreement
can import them without dragging in the occurrence module.
Same implementations, just relocated. Renamed parameter names on
cohens_kappa from (human, model) to (rater_a, rater_b) so the helper
reads as generic rather than human-vs-model specific.
Tests already use isolated `from ami.utils.stats import …` imports
(updated all 9 sites in ami/main/tests.py).
Co-Authored-By: Claude <noreply@anthropic.com>
@mihow
mihowforce-pushed the feat/human-model-agreement-endpoint branch from e476333 to 336c1feCompareMay 27, 2026 13:29
mihow pushed a commit that referenced this pull request May 27, 2026
One-line field rename in the occurrence stats panel to match the backend's
dropped ORDER threshold. Hook type rename + multi-value filter support
landed on the base branch (4a92c0b on #1307).
Co-Authored-By: Claude <noreply@anthropic.com>
mihow added a commit that referenced this pull request May 27, 2026
Typed React Query wrapper for /occurrences/stats/model-agreement/.
Owned by this UI PR (#1308); the backend PR (#1307) is now backend-only.
Co-Authored-By: Claude <noreply@anthropic.com>
Comment threadami/main/api/serializers.py
mihowand others added 4 commits May 28, 2026 15:35
Adds ResponseSchemaMetadata (ami/base/metadata.py) — a SimpleMetadata
subclass that emits the response serializer's field schema (type, label,
help_text, bounds) under actions.GET. DRF's default SimpleMetadata only
emits field schema for write methods (POST / PUT), so read-only stats
endpoints previously returned only name + description on OPTIONS.
Wires it into OccurrenceStatsViewSet and passes serializer_class= to
each @action decorator so view.get_serializer() resolves to the
per-action response serializer during OPTIONS resolution.
Result: frontends can fetch OPTIONS once per stats endpoint and key
tooltips / labels by field name. Stat copy lives next to the serializer
definition; interpretation copy stays in the FE bundle next to the
visualization.
Documented in docs/claude/reference/api-stats-pattern.md.
Co-Authored-By: Claude <noreply@anthropic.com>
Identification.taxon is nullable — a comment-only verification has a
machine prediction but no human label to compare. Previously such rows
landed in the agreement denominator (verified_with_prediction_count)
but never in any numerator, silently dragging agreed_*_pct down.
Adds a comparable cohort: verified occurrences with BOTH a machine
prediction and a human taxon. All agreed_*_pct and the Wilson CIs now
divide by comparable_count instead of verified_with_prediction_count,
so numerator and denominator describe the same set. Cohen's kappa
already used this cohort (both_present_pairs), so it is unchanged.
Surfaces two new fields so consumers can see why comparable_count
differs from verified_count:
- comparable_count — denominator for agreed_*_pct
- verified_without_taxon_count — verified, has prediction, no human taxon
Co-Authored-By: Claude <noreply@anthropic.com>
Replaces the manual try/except rank parsing with a ChoiceField run
through SingleParamSerializer, matching the project's standard
boundary-validation pattern.
Closes a gap where ?agreement_coarsest_rank= (blank) silently no-opped
instead of returning the documented 400 for an invalid rank. DRF treats
blank fields in QueryDict (HTML) input as absent, so the value is passed
in a plain dict to force "" through validation. Unknown ranks and
UNKNOWN (absent from the choice list) also 400 at the boundary, and the
param stays case-insensitive via an explicit uppercase.
drf-spectacular reads the ChoiceField choices into the OpenAPI schema as
an enum, so /api/v2/docs/ now lists the valid rank values.
Co-Authored-By: Claude <noreply@anthropic.com>
successes > total (or negative) makes the variance term negative and
crashes deeper in math.sqrt with an opaque domain error. Since
wilson_interval is a public helper in ami/utils/stats, guard the inputs
and raise a clear ValueError at the boundary instead. No production
caller can currently hit this — agreed_* counts are always a subset of
the comparable denominator — but the helper shouldn't depend on that.
Co-Authored-By: Claude <noreply@anthropic.com>
mihow pushed a commit that referenced this pull request May 28, 2026
One-line field rename in the occurrence stats panel to match the backend's
dropped ORDER threshold. Hook type rename + multi-value filter support
landed on the base branch (4a92c0b on #1307).
Co-Authored-By: Claude <noreply@anthropic.com>
mihow added a commit that referenced this pull request May 28, 2026
Typed React Query wrapper for /occurrences/stats/model-agreement/.
Owned by this UI PR (#1308); the backend PR (#1307) is now backend-only.
Co-Authored-By: Claude <noreply@anthropic.com>
@mihow
mihow merged commit c9b417d into mainMay 29, 2026
7 checks passed
@mihow
mihow deleted the feat/human-model-agreement-endpoint branch May 29, 2026 03:54
@mihow

mihow commented Jun 4, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Claude says: Nice-to-have follow-up from the closed CodeRabbit PR #1325: extract an EnumChoiceField helper into ami/base/serializers.py — a reusable enum-backed ChoiceField that accepts member names case-insensitively and supports excluding sentinel values like UNKNOWN.

Right now the agreement_coarsest_rank param is validated inline in model_agreement (ami/main/api/views.py, ~line 1463). That works and gives strict 400s for blank/unknown/UNKNOWN, but EnumChoiceField would DRY it up and become worthwhile once a second enum-backed query param shows up. Good first issue — small scope, easy to test (case-insensitive match, exclude rejection, blank-value 400) and review. Not blocking anything; parking it here so it doesn't get lost.

mihow added a commit that referenced this pull request Sep 2, 2026
* feat(occurrence-stats): add lca_rank_between helper
Pure-Python LCA over (taxon_id, rank, parents_json) tuples. Returns
the deepest shared TaxonRank or None. Used by the upcoming
human-model-agreement stat to bucket agreement at-or-finer-than ORDER.
Plan: docs/claude/planning/2026-05-14-human-model-agreement-endpoint.md
Side-research: docs/claude/planning/occurrence-filter-driven-exports.md
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(occurrence-stats): aggregate human-model agreement over filtered queryset
Pure aggregation; caller wires apply_default_filters + OccurrenceFilter.
Annotates best machine prediction, prefetches non-withdrawn identifications,
batches Taxon fetch for parents_json, buckets exact / under-order / above-order.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(occurrence-stats): wire human-model-agreement action
Adds HumanModelAgreementSerializer and the human_model_agreement action
on OccurrenceStatsViewSet. Extracts OccurrenceViewSet's filter backends +
filterset_fields into a module-level tuple so OccurrenceStatsViewSet can
reuse the same OccurrenceFilter pass-through (deployment, event, taxa lists,
verified, score thresholds, apply_defaults=false, etc).
The top_identifiers action keeps its current behavior — filter_queryset
is only invoked by actions that opt in.
Co-Authored-By: Claude <noreply@anthropic.com>
* test(occurrence-stats): HTTP coverage for human-model-agreement action
Adds 6 HTTP-level tests: missing project_id 400, draft 404, empty zeros,
happy-path exact match, deployment filter pass-through, apply_defaults=false
score-threshold bypass.
Also adds DjangoFilterBackend to OccurrenceStatsViewSet.filter_backends so
filterset_fields (event, deployment, determination__rank, ...) actually take
effect. Without DjangoFilterBackend, filterset_fields are silently ignored
and ?deployment=N returns the unfiltered set.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(ui): useHumanModelAgreement hook for occurrence stats
Mirrors useTopIdentifiers's useAuthorizedQuery pattern. Accepts an
arbitrary filter map so the occurrence list page can thread its filter
state through unchanged (deployment, event, taxon, score thresholds,
apply_defaults).
Co-Authored-By: Claude <noreply@anthropic.com>
* docs(prompts): handoff for PR #1307 rework — rename + SQL push-down + review fixes
Captures: review findings from Copilot + CodeRabbit, perf bench evidence
(43k rows → 159s timeout on apply_defaults=false), and the planned changes
for the next session (rename to model-agreement, push aggregation into
SQL/ORM, fix UNKNOWN rank LCA + denominator + verified_by_me anon gap +
test gaps).
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(occurrence-stats): rename to model-agreement + push aggregation to SQL
Addresses review feedback on PR #1307:
Rename (drop "human"):
- URL: /occurrences/stats/human-model-agreement/ -> /model-agreement/
- Function: human_model_agreement_for_project -> model_agreement_for_project
- Serializer: HumanModelAgreementSerializer -> ModelAgreementSerializer
- Viewset action + url_path: human_model_agreement -> model_agreement
- FE hook: useHumanModelAgreement -> useModelAgreement (file + symbol)
- FE type: Response -> ModelAgreementResponse (fixes DOM Response shadow)
- Test class: TestHumanModelAgreementForProject -> TestModelAgreementForProject
SQL push-down (Copilot+CodeRabbit perf flag):
- Replace list(qs) full-row materialization with annotated aggregate().
- Annotate best_user_taxon_id via Subquery over Identification
(BEST_IDENTIFICATION_ORDER). Drop the prefetch + select_related("taxon")
on identifications since only taxon_id is read.
- aggregate() Count(filter=Q(...)) for total/verified/exact/no-prediction.
- For under-order disagreement: group disagreement set by distinct
(user_taxon, machine_taxon) pair before LCA. Each pair's LCA runs once.
- Bench against project 18 (43,149 occurrences): pre-rework apply_defaults=false
curl timed out at 159s; post-rework 1.96s unfiltered / 3.4s with bypass
(93,019 occurrences post-filter).
Denominator fix (Copilot):
- agreed_*_pct now divides by verified_with_prediction_count instead of
verified_count. A verified occurrence with no machine prediction can't
agree or disagree; including it in the denominator drags the rate down
without representing actual model disagreement.
- Surface no_prediction_count + verified_with_prediction_count as sibling
fields so consumers can see how many such occurrences exist.
UNKNOWN rank bug (Copilot):
- TaxonRank.UNKNOWN sorts after SPECIES in OrderedEnum definition order,
so without explicit exclusion UNKNOWN >= ORDER is True and a shared
UNKNOWN ancestor would wrongly count as under-order agreement. Filter
UNKNOWN out of lca_rank_between's candidate ranks. Add regression test.
Tests:
- New: test_unknown_rank_excluded_from_lca (LCA regression)
- New: test_agreement_under_order_bucket (HTTP coverage for sister-species
case, previously only exact-match shortcut was exercised)
- Updated: happy-path asserts verified_with_prediction_count and
no_prediction_count.
22/22 backend tests green:
docker compose exec django python manage.py test
ami.main.tests.TestLcaRankBetween
ami.main.tests.TestModelAgreementForProject
ami.main.tests.TestOccurrenceStatsViewSet
Co-Authored-By: Claude <noreply@anthropic.com>
* docs(plan): add text lang to fenced block (markdownlint MD040)
Co-Authored-By: Claude <noreply@anthropic.com>
* perf(occurrence-stats): scope agreement subqueries to verified set
Replace the .aggregate() over the full filtered queryset with a two-step
approach:
1. SQL Count('pk') for total_occurrences (no joins, no subqueries).
2. Fetch the verified set (occurrences with at least one non-withdrawn
ident) with both best_user_taxon_id and best_machine_prediction_taxon_id
annotated, then bucket counts + LCA in Python.
Why: the previous version evaluated two correlated subqueries (best user
identification + best machine prediction) on every row of the filtered
queryset. For typical projects, >95% of occurrences have no identification
— those rows ran the user-ident subquery only to discover NULL, then ran
the (much more expensive) machine-prediction subquery on detections that
won't contribute to any agreement bucket. Scoping the subqueries to the
verified set avoids that waste.
Bench (cold, cache invalidated):
Project Total Verified Pre Post
P#85 SEC-SEQ 36,253 13,140 — 1.18s
P#20 BCI 40,958 1,351 — 0.92s
P#84 Pennsylvania 18,407 251 — 0.56s
P#24 Atlantic Forestry 2,797 274 — 0.50s
P#18 Vermont 43,149 45 ~928ms 0.35s
P#23 Insectarium Montreal 20,393 74 — 0.43s
Warm via django-cachalot: 122–343ms across all projects.
For P#85 (highest absolute identification count in the system), the cost
is dominated by apply_default_filters' score-threshold join, not the
subqueries. apply_defaults=false actually runs faster (0.69s cold,
179,466 total / 13,140 verified) because the classification join is
skipped.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(occurrence-stats): drop ORDER threshold; add coarsest_rank query param
Replaces hardcoded `lca >= TaxonRank.ORDER` agreement gate with two layers:
- Always returned: `agreed_any_rank_*` — exact matches plus any non-null LCA
at a real rank (UNKNOWN excluded). The upstream filter (e.g. a Lepidoptera
include list) is what bounds the meaningful scope, not a hardcoded
threshold in this function.
- Optional `?agreement_coarsest_rank=FAMILY`: when supplied, response also
includes `agreed_coarser_rank_*` (exact + LCAs at or below the threshold).
The applied rank is echoed in `agreement_coarsest_rank`; null when absent.
Also addresses CodeRabbit feedback on the existing branch:
- Dedupe base queryset before counting (joins from default-filter chain can
inflate Occurrence rows).
- Bound `*_pct` FloatFields to [0.0, 1.0] in the serializer.
Param validation: invalid rank → 400; UNKNOWN rejected as not meaningful.
Tests cover any-rank fallback, threshold filtering, invalid + UNKNOWN
rejection, and threshold echo.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(ui): align model-agreement hook with BE rename + multi-value query params
- Rename `agreed_under_order_*` → `agreed_any_rank_*` to match the endpoint's
dropped ORDER threshold (0565f06).
- Add optional `agreement_coarsest_rank` + `agreed_coarser_rank_*` fields to
the response type (not consumed yet — UI follows in #1308).
- Widen `filters` to accept arrays and append repeated query params so
multi-value filters (e.g. `algorithm`, `not_algorithm` — backend reads via
`request.query_params.getlist(...)`) survive. Per CodeRabbit review.
Co-Authored-By: Claude <noreply@anthropic.com>
* chore(docs): drop NEXT_SESSION_PROMPT.md from PR
Session-scratchpad doc — belongs in local notes, not the merged branch.
Co-Authored-By: Claude <noreply@anthropic.com>
* chore(docs): drop session-scratchpad planning docs from PR
- 2026-05-14-human-model-agreement-endpoint.md — design narrative; superseded
by code + PR description.
- occurrence-filter-driven-exports.md — side-research stub Copilot flagged as
out-of-scope. Promoted to a PR-description follow-up item.
Co-Authored-By: Claude <noreply@anthropic.com>
* test(occurrence-stats): make any-rank bucket test deterministic
create_detections assigns the classification taxon via .order_by("?"),
so the previous test picked a random machine taxon and then required a
sister species under the same genus. Random non-species picks (ORDER /
FAMILY / GENUS) have no sister, flaking ~50% of runs.
Pin both the machine prediction and the human ID to two fixed Vanessa
species, so the LCA is always GENUS (any-rank bucket, not exact) and the
test is deterministic.
Co-Authored-By: Claude <noreply@anthropic.com>
* chore(occurrence-stats): move FE hook to UI PR #1308
useModelAgreement.ts belongs with the frontend consumer (#1308), not the
backend endpoint PR. Keeps #1307 backend-only.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(occurrence-stats): add Wilson CI + Cohen's kappa to model-agreement
Both derive from the verified_rows already in memory — no extra query.
- wilson_interval(): 95% Wilson score CI on agreed_exact_pct and
agreed_any_rank_pct (agreed_*_ci_low / _ci_high). Wilson stays inside
[0,1] and is honest at the small n typical of verified sets, where the
normal approximation breaks down.
- cohens_kappa(): exact-taxon agreement beyond chance (cohens_kappa
field, range [-1, 1]). Null when no doubly-classified occurrences or
expected agreement is 1.0. Discounts the agreement you'd get for free
in a project dominated by one common species.
Adds 5 nullable response fields. Backwards-compatible (additive only).
9 pure-Python unit tests + 2 HTTP field-presence tests.
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(stats): move wilson_interval + cohens_kappa to ami/utils/stats
Both are generic statistical helpers — they don't depend on Django or any
domain model. Lifting them out of ami/main/models_future/occurrence.py so
other endpoints/jobs that need binomial CIs or chance-corrected agreement
can import them without dragging in the occurrence module.
Same implementations, just relocated. Renamed parameter names on
cohens_kappa from (human, model) to (rater_a, rater_b) so the helper
reads as generic rather than human-vs-model specific.
Tests already use isolated `from ami.utils.stats import …` imports
(updated all 9 sites in ami/main/tests.py).
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(stats): expose response schema via OPTIONS metadata
Adds ResponseSchemaMetadata (ami/base/metadata.py) — a SimpleMetadata
subclass that emits the response serializer's field schema (type, label,
help_text, bounds) under actions.GET. DRF's default SimpleMetadata only
emits field schema for write methods (POST / PUT), so read-only stats
endpoints previously returned only name + description on OPTIONS.
Wires it into OccurrenceStatsViewSet and passes serializer_class= to
each @action decorator so view.get_serializer() resolves to the
per-action response serializer during OPTIONS resolution.
Result: frontends can fetch OPTIONS once per stats endpoint and key
tooltips / labels by field name. Stat copy lives next to the serializer
definition; interpretation copy stays in the FE bundle next to the
visualization.
Documented in docs/claude/reference/api-stats-pattern.md.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(stats): exclude taxon-less verifications from agreement denominator
Identification.taxon is nullable — a comment-only verification has a
machine prediction but no human label to compare. Previously such rows
landed in the agreement denominator (verified_with_prediction_count)
but never in any numerator, silently dragging agreed_*_pct down.
Adds a comparable cohort: verified occurrences with BOTH a machine
prediction and a human taxon. All agreed_*_pct and the Wilson CIs now
divide by comparable_count instead of verified_with_prediction_count,
so numerator and denominator describe the same set. Cohen's kappa
already used this cohort (both_present_pairs), so it is unchanged.
Surfaces two new fields so consumers can see why comparable_count
differs from verified_count:
- comparable_count — denominator for agreed_*_pct
- verified_without_taxon_count — verified, has prediction, no human taxon
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(stats): validate agreement_coarsest_rank via ChoiceField
Replaces the manual try/except rank parsing with a ChoiceField run
through SingleParamSerializer, matching the project's standard
boundary-validation pattern.
Closes a gap where ?agreement_coarsest_rank= (blank) silently no-opped
instead of returning the documented 400 for an invalid rank. DRF treats
blank fields in QueryDict (HTML) input as absent, so the value is passed
in a plain dict to force "" through validation. Unknown ranks and
UNKNOWN (absent from the choice list) also 400 at the boundary, and the
param stays case-insensitive via an explicit uppercase.
drf-spectacular reads the ChoiceField choices into the OpenAPI schema as
an enum, so /api/v2/docs/ now lists the valid rank values.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(stats): wilson_interval rejects successes outside [0, total]
successes > total (or negative) makes the variance term negative and
crashes deeper in math.sqrt with an opaque domain error. Since
wilson_interval is a public helper in ami/utils/stats, guard the inputs
and raise a clear ValueError at the boundary instead. No production
caller can currently hit this — agreed_* counts are always a subset of
the comparable denominator — but the helper shouldn't depend on that.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(ui): live stats panel in occurrence list sidebar
Adds an OccurrenceStats panel above the filter sections on the
occurrence list page. Consumes the /occurrences/stats/model-agreement/
endpoint, threading the same active filter array the list view sends so
the numbers always reflect the current result set.
Shows two metrics: verified occurrences % and human-model agreement
rate % (rank-level / under-order agreement).
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(ui): switch stats panel to agreed_any_rank_pct (BE rename)
One-line field rename in the occurrence stats panel to match the backend's
dropped ORDER threshold. Hook type rename + multi-value filter support
landed on the base branch (4a92c0b on #1307).
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(ui): show raw verified count next to percentage
`StatBar` takes an optional `count` rendered as "0% (121)". Wired into the
Verified occurrences bar so a small-but-nonzero verified set that rounds to
0% still surfaces the underlying count.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(ui): add useModelAgreement hook for occurrence stats
Typed React Query wrapper for /occurrences/stats/model-agreement/.
Owned by this UI PR (#1308); the backend PR (#1307) is now backend-only.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(ui): add Wilson CI + Cohen's kappa bars to stats panel
Two new horizontal bars below the existing verified / agreement-rate bars:
- 'Agreement 95% CI (Wilson)' — RangeBar showing the Wilson CI as a
filled segment between low and high (wide bar = shaky number, narrow
bar = tight). Value reads '87–97%'. '—' when no verified-with-pred set.
- 'Cohen's κ (beyond chance)' — SignedBar over [-1, 1] with the zero
midpoint marked. Positive fills right, negative fills left. Value
reads '0.41'. '—' when undefined (empty or single-category set).
Hook type extended with the five new fields (agreed_*_ci_low/high +
cohens_kappa). Loading skeleton bumped to 4 placeholders.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(ui): split agreement bars by match scope + integrate Wilson CI inline
Stats panel now renders three agreement bars side-by-side instead of one
generic agreement row plus a separate CI range bar:
- Agreement (exact taxon) — agreed_exact_*
- Agreement (any rank) — agreed_any_rank_* (LCA at any real rank)
- Agreement (≥ <rank>) — agreed_coarser_rank_* (only when the caller passes
?agreement_coarsest_rank=<RANK>; otherwise hidden)
Wilson 95% CI is folded into each agreement bar instead of sitting on its
own row. The bar is a single 0–100% track with:
- a translucent CI band (bg-primary/40) from low to high
- 2px-wide CI bound caps (whiskers) at low/high
- a 3px tall dark vertical marker for the point estimate
This puts the uncertainty visually adjacent to the number it qualifies —
the bar IS the CI, the marker IS the point — so the CI is no longer easy
to overlook. Each agreement row also surfaces raw counts ("90 of 100").
Cohen's κ keeps its existing signed bar.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(ui): address stats panel review feedback from Anna
- Fix missing gray track background on the bars (bg-muted rendered
near-invisible; switch to bg-border to match the slider component)
- Shrink metric labels to body-overline-small with an InfoTooltip beside
each, aligning the Stats panel with the filter controls
- Collapse the detailed metrics (exact taxon, coarser rank, Cohen's kappa)
behind a "More detail" toggle, closed by default; keep Verified
occurrences and Agreement (any rank) always visible
- Clarify the verified-vs-denominator gap: show "N of M have a model
prediction to compare against" under Verified occurrences, and explain
the agreement denominator in each tooltip
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(ui): simplify stats panel bars and tooltips
Iterate on the occurrence stats panel per review feedback:
- Use one simple bar shape (gray track + primary fill) for every metric;
drop the separate CI whisker visualization
- Layer a translucent diagonal-hatch band over the 95% CI range on the
agreement bars so the uncertainty reads as "fuzzy" without a second chart
- Show the CI range as the agreement headline (e.g. "83-94%"); move the
point estimate and exact counts into the info tooltip
- Make all tooltips dynamic and route them through the (i) icon, including
the verified-vs-prediction denominator note
- Reorder: exact-taxon agreement above the fold, any-rank under "More"
- Show "<1%" instead of "0%" when the count is non-zero but rounds down
Co-Authored-By: Claude <noreply@anthropic.com>
* style(ui): prettier-format occurrence-stats panel
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(ui): keep CI hatch visible at any point estimate
The agreement bar drew a solid fill to the point estimate and layered the
hatch on top. When the point estimate sat near the upper CI bound (e.g.
21-100%), the solid fill covered the whole CI band and the blue-on-blue
hatch was invisible. Now the solid fill stops at the lower CI bound and the
hatch covers the full CI range over the gray track, so it reads as 'fuzzy'
regardless of where the estimate lands.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(ui): move stats panel copy into the translation layer
The occurrence stats panel had its labels and help text hardcoded in the
component. Add them to the STRING enum so the panel follows the frontend
i18n convention, and rewrite the help text in the process.
The panel-level tooltip now explains how to read the numbers and names the
two effects that push agreement upward: confirmations made by clicking
Agree on the model's own suggestion match the model by definition, and
people tend to verify the striking or unusual detections first, so the
verified set is not a random sample of the project. The per-metric
tooltips define the metric and carry the exact counts through
interpolation.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(ui): collapse the stats panel by default and query only while open
The stats panel now starts collapsed and its request runs only while it is
open, so a reader who never opens it costs no query. This keeps the panel a
soft feature flag while we validate whether the evaluation stats are
actually useful to reviewers.
Also in this change:
- The agreement tooltips now quote comparable_count, which is the
denominator the endpoint divides by. They previously quoted
verified_with_prediction_count, which is the larger number whenever a
verification carries no taxon, so the "K of N" in the tooltip could
disagree with the percentage beside it.
- When nothing in the filtered set can be compared, the panel says so
instead of drawing agreement bars at zero.
- Each bar exposes role="progressbar" with aria-valuetext.
- Labels and help text come from the translation layer.
- Comment density is brought into line with the sibling components.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(ui): drop the unreachable coarser-rank bar and tighten the stats query
The occurrence list never sends `agreement_coarsest_rank`, because the
parameter is not one of the fields in `AVAILABLE_FILTERS`. The endpoint
therefore always returns null for the coarser-rank fields and the bar could
never render, so the panel now stops trying to draw it. That also removes two
translation strings whose wording was inverted: the endpoint counts matches
whose common ancestor sits at the given rank or deeper, not coarser. The
response type keeps the three fields, since they still document what the
endpoint can return.
Three smaller corrections in the same pass:
- The stats query now runs only when a project id is present, and treats the
`enabled` argument as opt-out, so a caller that omits it cannot fire a
request that is missing its required `project_id`.
- Query parameters are sorted before the query string is built, so two
equivalent filter maps share one react-query cache key regardless of the
order they were assembled in.
- The verified-occurrences tooltip says "matching the current filters" rather
than "in the current filter", which read as a typo and understated that the
count reflects the whole active filter set.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(ui): say what "nothing to compare" actually means in the stats panel
The empty state claimed no verified occurrence matching the filters had a model
prediction. That is only one of the two ways the comparable set empties out: a
verification whose identification carries no taxon has nothing to compare
against either, so a filter set can hold verified occurrences that all have
predictions and still show this message. The wording now names both halves of
the condition, matching the denominator the agreement numbers actually use.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Michael Bunsen <michael@mixedneeds.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@mihow@annavik