Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
165ae9b
feat(occurrence-stats): add lca_rank_between helper
mihow May 14, 2026
7b1660c
feat(occurrence-stats): aggregate human-model agreement over filtered…
mihow May 14, 2026
3110418
feat(occurrence-stats): wire human-model-agreement action
mihow May 14, 2026
ba9c901
test(occurrence-stats): HTTP coverage for human-model-agreement action
mihow May 14, 2026
5b1bde7
feat(ui): useHumanModelAgreement hook for occurrence stats
mihow May 14, 2026
e050a1f
docs(prompts): handoff for PR #1307 rework — rename + SQL push-down +…
mihow May 14, 2026
f49c9ca
refactor(occurrence-stats): rename to model-agreement + push aggregat…
mihow May 15, 2026
da2a232
docs(plan): add text lang to fenced block (markdownlint MD040)
mihow May 15, 2026
7ba8689
perf(occurrence-stats): scope agreement subqueries to verified set
mihow May 15, 2026
6ad1885
feat(occurrence-stats): drop ORDER threshold; add coarsest_rank query…
May 21, 2026
6f51da5
feat(ui): align model-agreement hook with BE rename + multi-value que…
May 21, 2026
7c144b0
chore(docs): drop NEXT_SESSION_PROMPT.md from PR
May 21, 2026
34aace5
chore(docs): drop session-scratchpad planning docs from PR
May 21, 2026
36cc677
test(occurrence-stats): make any-rank bucket test deterministic
mihow May 22, 2026
b74b3cd
chore(occurrence-stats): move FE hook to UI PR #1308
mihow May 22, 2026
2c65cce
feat(occurrence-stats): add Wilson CI + Cohen's kappa to model-agreement
mihow May 26, 2026
336c1fe
refactor(stats): move wilson_interval + cohens_kappa to ami/utils/stats
mihow May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1751,3 +1751,114 @@ class TopIdentifiersResponseSerializer(serializers.Serializer):

project_id = serializers.IntegerField()
top_identifiers = UserIdentificationCountSerializer(many=True)


class ModelAgreementSerializer(serializers.Serializer):
"""Verified / agreement rates over the filtered Occurrence set.

`agreed_exact_count` is a subset of `agreed_any_rank_count` by
construction — an exact match implies the LCA is the taxon itself.
`*_pct` percentages are 0.0..1.0 (not 0..100).

Denominator note: `agreed_*_pct` divide by `verified_with_prediction_count`
(verified occurrences that *also* have a machine prediction), NOT by
`verified_count`. A verified occurrence with no machine prediction can't
agree or disagree — including it in the denominator would drag the rate
down without representing actual model disagreement. `no_prediction_count`
is surfaced so the consumer can see how many such occurrences exist.

Optional rank threshold: when the caller passes
`?agreement_coarsest_rank=FAMILY`, the response also includes
`agreed_coarser_rank_*` counting only LCAs at that rank or deeper. The
threshold rank is echoed in `agreement_coarsest_rank`. When the param is
absent, the coarser-rank fields are null and `agreement_coarsest_rank`
is null.
"""

project_id = serializers.IntegerField()
total_occurrences = serializers.IntegerField()
verified_count = serializers.IntegerField(help_text="Occurrences with at least one non-withdrawn identification.")
verified_pct = serializers.FloatField(
min_value=0.0,
max_value=1.0,
help_text="verified_count / total_occurrences",
)
verified_with_prediction_count = serializers.IntegerField(
help_text="Verified occurrences that also have a machine prediction (denominator for agreed_*_pct)."
)
no_prediction_count = serializers.IntegerField(
help_text="Verified occurrences with no machine prediction (excluded from agreement denominator)."
)
agreed_exact_count = serializers.IntegerField()
agreed_exact_pct = serializers.FloatField(
min_value=0.0,
max_value=1.0,
help_text="agreed_exact_count / verified_with_prediction_count",
)
agreed_exact_ci_low = serializers.FloatField(
min_value=0.0,
max_value=1.0,
allow_null=True,
required=False,
help_text="Wilson 95% CI lower bound for agreed_exact_pct. Null when verified_with_prediction_count is 0.",
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

How can we send the help text to the UI to use in tooltips? in the serializer? later in our share types maybe, but right now...?

)
agreed_exact_ci_high = serializers.FloatField(
min_value=0.0,
max_value=1.0,
allow_null=True,
required=False,
help_text="Wilson 95% CI upper bound for agreed_exact_pct. Null when verified_with_prediction_count is 0.",
)
agreed_any_rank_count = serializers.IntegerField(
help_text="Exact matches plus disagreements whose LCA is at any real rank (UNKNOWN excluded)."
)
agreed_any_rank_pct = serializers.FloatField(
min_value=0.0,
max_value=1.0,
help_text="agreed_any_rank_count / verified_with_prediction_count",
)
agreed_any_rank_ci_low = serializers.FloatField(
min_value=0.0,
max_value=1.0,
allow_null=True,
required=False,
help_text="Wilson 95% CI lower bound for agreed_any_rank_pct. Null when verified_with_prediction_count is 0.",
)
agreed_any_rank_ci_high = serializers.FloatField(
min_value=0.0,
max_value=1.0,
allow_null=True,
required=False,
help_text="Wilson 95% CI upper bound for agreed_any_rank_pct. Null when verified_with_prediction_count is 0.",
)
cohens_kappa = serializers.FloatField(
min_value=-1.0,
max_value=1.0,
allow_null=True,
required=False,
help_text=(
"Cohen's kappa (exact-taxon) — human↔model agreement beyond chance. "
"Range [-1, 1]; negative is worse than chance. Null when there are no "
"doubly-classified occurrences or expected agreement is 1.0."
),
)
agreement_coarsest_rank = serializers.CharField(
allow_null=True,
required=False,
help_text="Threshold rank from ?agreement_coarsest_rank query param. Null when the param is absent.",
)
agreed_coarser_rank_count = serializers.IntegerField(
allow_null=True,
required=False,
help_text=(
"Exact matches plus disagreements whose LCA is at `agreement_coarsest_rank` or deeper. "
"Null when no threshold was supplied."
),
)
agreed_coarser_rank_pct = serializers.FloatField(
min_value=0.0,
max_value=1.0,
allow_null=True,
required=False,
help_text="agreed_coarser_rank_count / verified_with_prediction_count. Null when no threshold supplied.",
)
87 changes: 70 additions & 17 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from ami.base.views import ProjectMixin
from ami.main.api.schemas import limit_doc_param, project_id_doc_param
from ami.main.api.serializers import TagSerializer
from ami.main.models_future.occurrence import top_identifiers_for_project
from ami.main.models_future.occurrence import model_agreement_for_project, top_identifiers_for_project
from ami.utils.requests import get_default_classification_threshold
from ami.utils.storages import ConnectionTestResult

Expand All @@ -55,6 +55,7 @@
Tag,
TaxaList,
Taxon,
TaxonRank,
User,
update_detection_counts,
)
Expand All @@ -71,6 +72,7 @@
EventSerializer,
EventTimelineSerializer,
IdentificationSerializer,
ModelAgreementSerializer,
OccurrenceListSerializer,
OccurrenceSerializer,
PageListSerializer,
Expand Down Expand Up @@ -1202,6 +1204,24 @@ def filter_queryset(self, request, queryset, view):
return queryset


OCCURRENCE_FILTER_BACKENDS = (
CustomOccurrenceDeterminationFilter,
OccurrenceCollectionFilter,
OccurrenceAlgorithmFilter,
OccurrenceDateFilter,
OccurrenceVerified,
OccurrenceVerifiedByMeFilter,
OccurrenceTaxaListFilter,
)

OCCURRENCE_FILTERSET_FIELDS = (
"event",
"deployment",
"determination__rank",
"detections__source_image",
)


class OccurrenceViewSet(DefaultViewSet, ProjectMixin):
"""
API endpoint that allows occurrences to be viewed or edited.
Expand All @@ -1211,22 +1231,8 @@ class OccurrenceViewSet(DefaultViewSet, ProjectMixin):
queryset = Occurrence.objects.all()

serializer_class = OccurrenceSerializer
# filter_backends = [CustomDeterminationFilter, DjangoFilterBackend, NullsLastOrderingFilter, SearchFilter]
filter_backends = DefaultViewSetMixin.filter_backends + [
CustomOccurrenceDeterminationFilter,
OccurrenceCollectionFilter,
OccurrenceAlgorithmFilter,
OccurrenceDateFilter,
OccurrenceVerified,
OccurrenceVerifiedByMeFilter,
OccurrenceTaxaListFilter,
]
filterset_fields = [
"event",
"deployment",
"determination__rank",
"detections__source_image",
]
filter_backends = DefaultViewSetMixin.filter_backends + list(OCCURRENCE_FILTER_BACKENDS)
filterset_fields = list(OCCURRENCE_FILTERSET_FIELDS)
ordering_fields = [
"created_at",
"updated_at",
Expand Down Expand Up @@ -1324,6 +1330,11 @@ class OccurrenceStatsViewSet(viewsets.GenericViewSet, ProjectMixin):

permission_classes = [IsActiveStaffOrReadOnly]
require_project = True
# Filter machinery for actions that opt into `self.filter_queryset(...)`.
# `top_identifiers` doesn't call it, so its behavior is unchanged.
queryset = Occurrence.objects.none()
filter_backends = [DjangoFilterBackend, *OCCURRENCE_FILTER_BACKENDS]
filterset_fields = list(OCCURRENCE_FILTERSET_FIELDS)

Comment thread
mihow marked this conversation as resolved.
@extend_schema(
parameters=[project_id_doc_param, limit_doc_param],
Expand Down Expand Up @@ -1354,6 +1365,48 @@ def top_identifiers(self, request):
)
return Response(serializer.data)

@extend_schema(
parameters=[project_id_doc_param],
responses=ModelAgreementSerializer,
)
@action(detail=False, methods=["get"], url_path="model-agreement")
def model_agreement(self, request):
"""Verified / human↔model agreement rates over the filtered occurrence set.

Accepts every query param the `/occurrences/` list endpoint accepts.
Reuses `apply_default_filters` so `apply_defaults=false` bypasses
project default taxa lists + score thresholds.

Optional ?agreement_coarsest_rank=<RANK> adds `agreed_coarser_rank_*`
counts — LCAs at the given rank or deeper. Valid values: any
TaxonRank name (FAMILY, GENUS, etc.); invalid → 400.
"""
project = self.get_active_project()
assert project is not None # require_project=True guarantees this
if not Project.objects.visible_for_user(request.user).filter(pk=project.pk).exists():
raise NotFound("Project not found.")

coarsest_rank_param = request.query_params.get("agreement_coarsest_rank")
coarsest_rank = None
if coarsest_rank_param:
try:
coarsest_rank = TaxonRank[coarsest_rank_param.upper()]
except KeyError:
valid = ", ".join(r.name for r in TaxonRank if r.name != "UNKNOWN")
raise api_exceptions.ValidationError(
{"agreement_coarsest_rank": f"Invalid rank '{coarsest_rank_param}'. Must be one of: {valid}."}
)
if coarsest_rank == TaxonRank.UNKNOWN:
raise api_exceptions.ValidationError(
{"agreement_coarsest_rank": "UNKNOWN is not a valid threshold rank."}
)
Comment on lines +1389 to +1402
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 26, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject blank agreement_coarsest_rank values.

Line 1406 only validates truthy input, so ?agreement_coarsest_rank= is treated the same as omission and returns null coarser-rank fields instead of the documented 400 for an invalid rank.

🛠️ Proposed fix
-        coarsest_rank_param = request.query_params.get("agreement_coarsest_rank")
         coarsest_rank = None
-        if coarsest_rank_param:
+        if "agreement_coarsest_rank" in request.query_params:
+            coarsest_rank_param = request.query_params.get("agreement_coarsest_rank")
+            if not coarsest_rank_param:
+                raise api_exceptions.ValidationError(
+                    {"agreement_coarsest_rank": "This parameter cannot be blank."}
+                )
             try:
                 coarsest_rank = TaxonRank[coarsest_rank_param.upper()]
             except KeyError:
                 valid = ", ".join(r.name for r in TaxonRank if r.name != "UNKNOWN")
                 raise api_exceptions.ValidationError(
🧰 Tools
🪛 Ruff (0.15.14)

[warning] 1411-1413: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🤖 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/views.py` around lines 1404 - 1417, The code treats an empty
query param as omission because it only branches when coarsest_rank_param is
truthy; change the logic in the agreement_coarsest_rank handling so that you
distinguish between None (missing) and empty string: if
request.query_params.get("agreement_coarsest_rank") is None keep coarsest_rank =
None, otherwise if coarsest_rank_param.strip() == "" raise
api_exceptions.ValidationError for "agreement_coarsest_rank" (reject blank), and
then continue to map via TaxonRank[...] and handle KeyError/TaxonRank.UNKNOWN as
before; refer to the coarsest_rank_param variable, TaxonRank enum, and the
existing ValidationError branches in ami/main/api/views.py.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

can we also use proper validation here? add Enum support to SingleParamSerializer if need be, or pydantic? Or a DRF serializer?

## Query parameters
Use `SingleParamSerializer[T].clean(...)` from `ami/base/serializers.py`.
It runs a DRF `serializers.IntegerField` / etc. through the standard
validation pipeline and raises `ValidationError` → DRF returns 400 with
the field-level error body the frontend expects.
```python
limit = SingleParamSerializer[int].clean(
param_name="limit",
field=serializers.IntegerField(required=False, min_value=1, max_value=50, default=5),
data=request.query_params,
)
```

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.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look at SingleParamSerializer implementation
ast-grep --pattern 'class SingleParamSerializer$$$'

echo "---"
rg -n "SingleParamSerializer" ami/base/serializers.py -A 30 | head -80

echo "=== api-stats-pattern.md ==="
cat docs/claude/reference/api-stats-pattern.md | head -200

echo "=== TaxonRank enum ==="
rg -n "class TaxonRank" -A 20

Length of output: 14976


🏁 Script executed:

# Confirm current imports in ami/base/serializers.py to ensure EnumField won't conflict
rg -n "^from|^import" ami/base/serializers.py | head -30
# Confirm EnumField is not already defined
rg -n "EnumField" ami/base/serializers.py

Length of output: 804



Good, no conflicts. Here's the full plan:

Task: Proper enum validation via SingleParamSerializer + EnumField

  • Add EnumField to ami/base/serializers.py
  • Update model_agreement in views.py to use it

🧬 Code Graph Analysis Results

Relevant snippets from ami/main/api/views.py

1) SourceImageCollectionViewSet._get_source_image uses SingleParamSerializer[int] for query/body param validation

ami/main/api/views.py (approx. L560-L575)

def _get_source_image(self):
    """
    Get source image from either GET query param or in the PUT/POST request body.
    """
    key = "source_image"
    source_image_id = SingleParamSerializer[int].clean(
        key,
        field=serializers.IntegerField(required=True, min_value=0),
        data=dict(self.request.data, **self.request.query_params),
    )

    try:
        return SourceImage.objects.get(id=source_image_id)
    except SourceImage.DoesNotExist:
        raise api_exceptions.NotFound(detail=f"SourceImage with id {source_image_id} not found")

2) SourceImageUploadViewSet.perform_create uses SingleParamSerializer[bool] for process_now

ami/main/api/views.py (approx. L620-L650)

def perform_create(self, serializer):
    """
    Save the SourceImageUpload with the current user and create the associated SourceImage.
    """
    from ami.base.serializers import get_current_user
    from ami.main.models import create_source_image_from_upload

    # Get current user from request
    user = get_current_user(self.request)

    # Create the SourceImageUpload object with the user
    obj = serializer.save(user=user)

    # Get process_now flag from project feature flags
    process_now = SingleParamSerializer[bool].clean(
        param_name="process_now",
        field=serializers.BooleanField(required=True),
        data=self.request.query_params,
    )

    # Create source image from the upload
    source_image = create_source_image_from_upload(
        image=obj.image,
        deployment=obj.deployment,
        request=self.request,
        process_now=process_now,
    )

    # Update the source_image reference and save
    obj.source_image = source_image
    obj.save()

3) OccurrenceStatsViewSet.top_identifiers uses SingleParamSerializer[int] for limit

ami/main/api/views.py (approx. L800-L830)

limit = SingleParamSerializer[int].clean(
    param_name="limit",
    field=serializers.IntegerField(required=False, min_value=1, max_value=50, default=5),
    data=request.query_params,
)
top_users = list(top_identifiers_for_project(project)[:limit])

4) OccurrenceStatsViewSet.model_agreement manually validates agreement_coarsest_rank via TaxonRank[...] (enum-style parsing)

ami/main/api/views.py (approx. L830-L880)

coarsest_rank_param = request.query_params.get("agreement_coarsest_rank")
coarsest_rank = None
if coarsest_rank_param:
    try:
        coarsest_rank = TaxonRank[coarsest_rank_param.upper()]
    except KeyError:
        valid = ", ".join(r.name for r in TaxonRank if r.name != "UNKNOWN")
        raise api_exceptions.ValidationError(
            {"agreement_coarsest_rank": f"Invalid rank '{coarsest_rank_param}'. Must be one of: {valid}."}
        )
    if coarsest_rank == TaxonRank.UNKNOWN:
        raise api_exceptions.ValidationError(
            {"agreement_coarsest_rank": "UNKNOWN is not a valid threshold rank."}
        )

5) EventViewSet.timeline uses Django form field cleaning for resolution_minutes

ami/main/api/views.py (approx. L230-L250)

event = self.get_object()
resolution_minutes = IntegerField(required=False, min_value=1).clean(
    request.query_params.get("resolution_minutes", 1)
)
resolution = datetime.timedelta(minutes=resolution_minutes)

6) Storage connection status uses a hardcoded list with @TODO use ENUM

ami/main/api/views.py (approx. L1000-L1025)

_STORAGE_CONNECTION_STATUS = [
    # These come from the ConnetionStatus react component
    # `@TODO` use ENUM
    "NOT_CONNECTED",
    "CONNECTING",
    "CONNECTED",
    "ERROR",
]

class StorageStatus(APIView):
    ...
    def post(self, request):
        """`@TODO` not totally sure how to use the serializer here yet."""
        ...
        data = {
            "data_source": data_source,
            "status": _STORAGE_CONNECTION_STATUS[1],
            ...
        }
        return Response(data)

🧠 Learnings used
Learnt from: mihow
Repo: RolnickLab/antenna PR: 1130
File: ami/ml/orchestration/nats_queue.py:217-217
Timestamp: 2026-02-13T23:11:30.276Z
Learning: In the RolnickLab/antenna project (Pydantic v1), use .dict() for serialization of Pydantic models instead of .model_dump(). Update code paths in Python files where model_dump() is used to ensure compatibility with Pydantic v1 behavior. If any files are later migrated to Pydantic v2, consider switching to .model_dump() or using model_dump(..., mode='json') as appropriate.

Learnt from: mihow
Repo: RolnickLab/antenna PR: 1222
File: ami/ml/orchestration/nats_queue.py:126-132
Timestamp: 2026-04-13T22:08:32.779Z
Learning: In code that uses the `nats-py` JetStream client, do not divide `ConsumerInfo.config.ack_wait` (or the `ack_wait` returned by `consumer_info()` / `add_consumer()`) by `1e9`. `nats-py` already deserializes the server’s raw nanosecond value by dividing by `_NANOSECOND` (1e9), so application-level code should treat `ack_wait` as already human-readable seconds (e.g., `30.0` means 30 seconds). Only apply nanosecond-to-second conversion if you are working with the raw server nanosecond field directly (not the `ack_wait` property exposed by `ConsumerInfo`/`ConsumerConfig`).

Learnt from: mihow
Repo: RolnickLab/antenna PR: 1214
File: ami/main/api/serializers.py:1396-1425
Timestamp: 2026-04-17T05:08:02.267Z
Learning: When serializers access related model fields (e.g., `prediction.taxon` / `prediction.algorithm` after `obj.best_prediction` in `get_best_machine_prediction`), ensure the underlying queryset uses `select_related` for those relations (e.g., `.select_related("taxon", "algorithm")`) so the serializer does not trigger per-row lazy loads (N+1 queries). If an N+1 remains due to calling `best_prediction` itself, treat it separately (it may be pre-existing and tracked elsewhere), and don’t conflate it with missing `select_related` coverage for the accessed relations.

Learnt from: mihow
Repo: RolnickLab/antenna PR: 1261
File: ami/jobs/tasks.py:162-171
Timestamp: 2026-04-21T07:40:49.264Z
Learning: In this repo, when using `cachalot_disabled` imported from `cachalot.api`, treat it as supported in both forms: `with cachalot_disabled():` and `cachalot_disabled()`. It is implemented as a `contextmanager` that also behaves as a `ContextDecorator` (Python 3.2+), so the decorator form is not a no-op and creates a fresh generator instance per invocation. During code review, do not raise false positives that claim the decorator usage is unsupported or ineffective.

Learnt from: mihow
Repo: RolnickLab/antenna PR: 1131
File: ami/exports/dwca/rows.py:96-146
Timestamp: 2026-04-22T22:40:24.069Z
Learning: For this repo, `Detection.bbox`/`BoundingBox` values use an absolute pixel coordinate space (not normalized [0–1] floats). When code constructs crops (e.g., `PIL.Image.crop((x1, y1, x2, y2))`), use integer pixel coordinates and pass them through directly—do not convert from normalized floats. When emitting DWCA bounding box measurements (e.g., in `iter_mof_rows`), ensure the bounding box row uses `measurementUnit="pixels"` to match the canonical schema in `ami/ml/schemas.py`.

The requested changes have been implemented and a pull request has been created: View PR


base_qs = Occurrence.objects.filter(project=project).valid().apply_default_filters(project, request)
filtered_qs = self.filter_queryset(base_qs)
payload = model_agreement_for_project(filtered_qs, coarsest_rank=coarsest_rank)
payload["project_id"] = project.pk
return Response(ModelAgreementSerializer(payload, context={"request": request}).data)


class TaxonTaxaListFilter(filters.BaseFilterBackend):
"""
Expand Down
Loading
Loading