Skip to content

Let identifiers apply identifications to many occurrences in one request#1371

Draft
mihow wants to merge 2 commits into
mainfrom
feat/bulk-identifications
Draft

Let identifiers apply identifications to many occurrences in one request#1371
mihow wants to merge 2 commits into
mainfrom
feat/bulk-identifications

Conversation

@mihow

@mihow mihow commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

When an identifier confirms or re-identifies a page of occurrences, the site currently sends one request per occurrence. Selecting fifty occurrences and pressing Confirm fires fifty separate POSTs, and if some of them fail the interface asks the person to press the button again to retry the ones that did not land. This is slow on a large page, and it gets slower the more occurrences someone selects — which is exactly when they are working fastest.

This adds a single endpoint that accepts a whole batch of identifications in one request. The visible effect for an identifier is that a mass identification is one round trip instead of fifty, and the result comes back per occurrence: if one occurrence in the batch cannot be identified — because someone else deleted it since the page was loaded, say — that one is reported as an error and the other forty-nine are still saved. Nothing about how an identification behaves changes; this is a faster way to do what the site already does, deliberately not a different thing.

The frontend is not switched over in this PR. The endpoint is additive and the existing per-occurrence path still works, so the interface can move to it separately.

List of Changes

# Change (what it does) How
1 An identifier can submit many identifications in one request instead of one request per occurrence. New POST /api/v2/identifications/bulk/ action on IdentificationViewSet.
2 One bad occurrence in a batch no longer discards the good ones — each is reported on its own. Each item is saved in its own savepoint; the response lists an outcome per item, in request order, with created_count / error_count.
3 Identifying in bulk behaves exactly like identifying one at a time. Each item goes through the existing Identification.save(), so withdrawing the user's previous identification and recomputing the occurrence's determination are unchanged rather than reimplemented.
4 A person can only bulk-identify in a project they have permission to identify in. The batch is authorized against the single project it belongs to, via the same Identification.check_permission(user, "create") call the single-item endpoint makes.
5 Sending a bad batch gets a clear rejection instead of a server error. Batch-level problems (empty, over the cap, repeated occurrence, occurrences from two projects) return 400; everything else, including occurrences that no longer exist, is reported per item.
6 The endpoint does not get slower per occurrence as batches grow. Occurrences and taxa for the whole batch are each resolved in one query, rather than one lookup per item.

Detailed Description

Why a list of items, and not one taxon plus a list of occurrence IDs

The shape was decided by what the interface actually sends. There are three bulk callers today, all of which already build a list of per-item values (ui/src/data-services/hooks/identifications/useCreateIdentifications.ts):

  • Agree / Confirm (ui/src/pages/occurrences/occurrences-actions.tsx) sends, for each occurrence, that occurrence's own current determination as the taxon and that occurrence's own identification or prediction as the agreement target.
  • Suggest ID and the quick ID buttons send one taxon applied to many occurrences.

The Agree flow is the common mass action and it is irreducibly heterogeneous, so a {taxon_id, occurrence_ids: []} request could not express it. A list of per-item objects covers all three and maps onto the payload the frontend already constructs, which keeps the eventual frontend change small.

Request and response

POST /api/v2/identifications/bulk/
{
  "identifications": [
    {"occurrence_id": 1, "taxon_id": 5, "comment": "optional",
     "agreed_with_identification_id": null, "agreed_with_prediction_id": null}
  ]
}
200 OK
{
  "created_count": 49,
  "error_count": 1,
  "results": [
    {"index": 0, "occurrence_id": 1, "status": "created", "id": 991},
    {"index": 1, "occurrence_id": 7, "status": "error",
     "errors": {"occurrence_id": ["Occurrence 7 does not exist."]}}
  ]
}

Results carry the submitted index, so a client matches them back to its request without relying on ordering alone.

All-or-nothing was considered and rejected. There is no invariant that spans the items — each identification is independent — so a transaction around the whole batch would buy no consistency, only a simpler error contract. It would also mean a batch containing one occurrence deleted mid-session retries into the identical failure forever unless the client prunes the offending index first. Saving the valid rows matches what someone doing a mass identification actually wants.

How partial success is actually achieved is worth a look during review. ATOMIC_REQUESTS is on (config/settings/base.py), so the whole request already runs in one transaction and a per-item transaction.atomic() is a savepoint rather than an independent commit. That means partial success does not come for free from wrapping each item: an exception escaping save() would abort the request's transaction and discard every identification already made in it. Each item is therefore saved inside a savepoint and its failure is caught, so rolling back to the savepoint leaves the connection usable and the loop continues. This is what makes the promise in the summary true rather than aspirational, and it is pinned by a test that forces a write to fail part-way through a batch and asserts the surviving items are really committed.

A batch may not span projects (400). All three call sites work within one project page, and restricting it keeps authorization unambiguous. This can be relaxed later without breaking clients.

Repeated occurrence IDs in one batch are rejected (400). Two identifications for one occurrence in a single request have no defined winner — the outcome would depend on insert-order tiebreaks rather than on anything the client asked for.

Two places where this is deliberately stricter than the single-item endpoint

Both are intentional, and both are asymmetries a reviewer should agree with rather than discover:

  1. withdrawn is not accepted from clients. The single-item serializer exposes it as writable, so a client can currently create an already-withdrawn identification. The model maintains this field, and letting a client set it breaks the "one active identification per user per occurrence" invariant. Probably worth a separate issue against the single-item endpoint.
  2. agreed_with_identification_id / agreed_with_prediction_id must belong to the occurrence being identified. The single-item endpoint accepts any identification or classification in the system as an agreement target, with no such check. The bulk endpoint validates it per item. The single-item path is left alone to keep this PR to one change.

Permissions

A detail=False action is never routed through has_object_permission, so the endpoint performs the check itself, following the transient-instance pattern already used by ProjectPipelineConfigPermission and UserMembershipPermission in ami/base/permissions.py.

Two details are worth flagging for review, because both are the kind of thing that is invisible in a diff:

  1. The action maps to "create", not to its own name. BaseModel.check_permission looks the action up in a CRUD map; an action name of "bulk" would miss that map and fall through to check_custom_permission, asking for a bulk_identification permission that no role grants. That fails closed, but only for non-superusers — a test written as a superuser would pass while every identifier got a 403.
  2. One check per batch rather than one per occurrence. This is equivalent because the "create" branch of Identification.check_permission depends only on the occurrence's project. Since that equivalence is an invariant a future edit could quietly break, this PR adds a note at Identification.check_permission itself — the line someone would actually be editing — rather than only at the endpoint.

Performance

The win here is round trips, not queries: N HTTP requests become one, with one authorization instead of N. Resolving occurrences and taxa is batched (one query each for the whole request), so validation cost does not grow per item.

The write path deliberately remains a loop over the existing Identification.save(). Measured at 8 queries per item, all of them inside save() and update_occurrence_determination. Replacing that with bulk_create would mean reimplementing withdraw-previous and the determination recompute, neither of which has test coverage today to compare against — a large correctness risk for a saving that does not yet matter, since the interface can only select occurrences on the page currently displayed (ui/src/pages/occurrences/occurrences.tsx filters the selection to the visible rows). MAX_BULK_IDENTIFICATIONS = 200 bounds the worst case. A batched write is a sensible follow-up once the determination logic has tests, and the request contract would not change.

Testing

34 tests in ami/main/test_bulk_identifications.py, written before the implementation:

  • Permission matrix — identifier, project manager, superuser, basic member (403), non-member (403), anonymous. Deliberately not written as a superuser, since that is what would hide the action-name problem described above.
  • Equivalence with the single-item endpoint — the same identification made via POST /identifications/ and via the bulk endpoint must leave the occurrence in an identical state (determination, score, and the set of identifications with their withdrawn flags), starting from an occurrence that already has an earlier identification so that withdraw-previous is part of what the two paths have to agree on. This is the test that catches anything the bulk path skips or reimplements, without having to enumerate each rule.
  • Side effects of save() — the user's previous identification is withdrawn; another user's identification is not.
  • Partial failure — a rejected item does not undo earlier successes, and a write that fails inside save() is reported against its own index while the other items stay committed. The latter fails without the savepoint handling described above, so it pins the behaviour rather than assuming it.
  • Validation — empty batch, missing key, non-integer ID (400 rather than 500), over the cap and exactly at the cap, repeated occurrence, cross-project batch, withdrawn ignored, unknown and cross-occurrence agreement targets, an all-missing batch.
  • Query cost — measured at two batch sizes and compared, since a single exact count cannot separate fixed cost from per-item cost and so cannot catch an N+1 in validation or in the response. The budget is the measured per-item cost, so any new per-item query fails it.

Notes for reviewers

Two pre-existing issues turned up while reading the surrounding code. Neither is touched here, and both are stated as observations rather than as things this PR fixes:

  • update_occurrence_determination compares an ID to a Taxon object. current_determination is read via .values("determination"), which yields the raw ID, and is then compared against top_identification.taxon, a model instance (ami/main/models.py). The comparison is therefore always unequal, so the "no update needed" branch is effectively unreachable and every identification rewrites the determination and score. This is verified, not inferred: a single POST agreeing with an existing determination moves determination_score from the machine score to 1.0. That may well be the desired behaviour — a human-confirmed occurrence arguably should score 1.0 — but it is currently reached by accident rather than by intent, and the surrounding code reads as though it were meant to no-op. Worth a ticket to decide which behaviour is wanted.
  • user_agrees_with_identification (ami/main/models.py) has no callers. It is also decorated with an unbounded functools.cache keyed on model instances, which would return stale results and grow without limit per worker if it were ever wired up. Worth deleting or fixing before anyone reaches for it.

A third, smaller one: Classification.detection is nullable, so a classification can outlive the detection that linked it to an occurrence. Agreeing with such a prediction is reported as an item error rather than crashing.

Applying determinations to a page of occurrences currently costs one HTTP
request per occurrence: the identification UI fans out N POSTs with
Promise.allSettled and retries whichever ones were rejected. Add
POST /api/v2/identifications/bulk/ so the same work is one request.

The request is a list of per-item objects rather than one taxon applied to a
list of occurrence IDs, because the "Agree" action sends a different taxon and
a different agreement target for every occurrence. This matches the payload
the frontend already builds.

Each item is saved through the existing Identification.save(), so withdrawing
the user's previous identification and recomputing the occurrence
determination keep working exactly as they do for a single POST. Items are
saved in their own savepoints and reported individually, so one stale
occurrence does not discard the rest of the batch.

Occurrences and taxa are resolved in one query each, so validation does not
scale with the size of the batch. A batch is authorized once against the
single project it belongs to; permission to create an identification depends
only on the project, and the check is routed through the same
Identification.check_permission call the single-item endpoint makes.

Co-Authored-By: Claude <noreply@anthropic.com>
@netlify

netlify Bot commented Jul 17, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-ssec canceled.

Name Link
🔨 Latest commit 4927e14
🔍 Latest deploy log https://app.netlify.com/projects/antenna-ssec/deploys/6a5a16d65ba1ec00080b30b9

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 06182d53-fb27-48b7-8e03-fd9a05f23738

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/bulk-identifications

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.

@netlify

netlify Bot commented Jul 17, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-preview canceled.

Name Link
🔨 Latest commit 4927e14
🔍 Latest deploy log https://app.netlify.com/projects/antenna-preview/deploys/6a5a16d6f8042b0008c9e9e4

The endpoint promised that one bad item would not discard the rest of the
batch, but ATOMIC_REQUESTS wraps the whole request in a transaction, so the
per-item atomic() block was a savepoint rather than a separate transaction and
nothing committed on its own. An error raised inside save() propagated, aborted
the request transaction, and rolled back every identification already made in
it, returning a 500 instead of a per-item error. Catch the failure so the
savepoint rolls back that item alone and the loop continues, and correct the
comment that claimed otherwise.

Also fixed while reviewing:

- Agreeing with a prediction whose detection had been deleted raised an
  AttributeError and returned a 500, because Classification.detection is
  nullable. It is now reported as an item error.
- A batch in which no occurrence existed returned a batch-level 400 while a
  batch in which only some were missing reported per-item errors. The same
  failure now answers the same way in both cases.
- MAX_BULK_IDENTIFICATIONS moved to serializers, where it is enforced, which
  removes an import of views from inside a serializer method.

Tests: the partial-success guarantee is now pinned by forcing a write to fail
mid-batch, which fails without the fix above. The cap test used repeated IDs, so
the duplicate check rejected the batch and the test passed with the cap removed;
it now uses distinct IDs and asserts the message. The equivalence test seeded no
prior identification, so it never exercised withdraw-previous. Added coverage
for unknown and cross-occurrence agreement targets and for an all-missing batch,
and tightened the per-item query budget to the measured cost.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to 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.

1 participant