animap-gpu / app /identification.py
bluman1's picture
Publish services/inference
4b98524 verified
Raw
History Blame Contribute Delete
33.4 kB
"""Matching a capture against a farm's enrolled animals, and refusing to name one.
Directive §6.4, and the counterpart to `app/counting.py`: that module turns boxes
into a claim, this one turns a nearest neighbour into a candidate. Both exist
because the hard part is not the model — it is being honest about the cases the
model cannot carry.
**The measurement this runner ships against is the open-set one, not the
closed-set one.** Run `8db9e0bd1b30`
(`experiments/cattle_identity/metrics.json`) enrolled 169 animals from the
muzzle268 database and measured, on 789 probes of animals that *were* enrolled:
top-1 0.9772, top-3 0.9937, MRR 0.9853, against a chance rate of 0.005917. That
is the number worth quoting and it is not the number that decides the product.
On 416 probes of animals nobody enrolled, **the unthresholded false-accept rate
is 1.000**. Every unenrolled animal comes back as somebody, because every query
has a nearest neighbour and nothing about a nearest neighbour knows the right
answer was absent. The two similarity distributions overlap badly — enrolled
probes median 0.9707, unenrolled median 0.9042 with an unenrolled *maximum* of
0.9776 — so the cutoff that admits no impostor at all sits at 0.98 and accepts
**24.08%** of the correct matches.
`MEASURED_POLICY` is that operating point, and choosing it is the whole design:
- Three quarters of the time an enrolled animal is photographed, this runner
claims `no_confident_match` **and still returns the ranked candidates**, so a
person sees the names and picks one. That is a worse headline and the same
information.
- The alternative — no threshold, always name the top candidate — is the one
that reads better and puts a neighbour's cow in a farm's records 100% of the
time an unenrolled animal is photographed.
`§6.4`'s confirm step is therefore load-bearing rather than decorative.
**What carries that requirement to a device, precisely.** This paragraph used to
say `IdentityResult.to_json` states `requires_confirmation: True` on every path.
It does — and this runner never calls it. The served result is
`schemas.InferenceResult`, which has no such field, so **the flag reaches
nobody** and citing it here described a guarantee that was not on the wire.
What actually crosses is the capability row. `Requirement.HUMAN_CONFIRMATION` is
on the registry entry, travels verbatim to the API, and is published as
`requirements: ["guided_capture", "human_confirmation"]` by `GET /capabilities`,
where a database constraint forces §6.4's four buttons to travel beside it. That
is a real mechanism and it is a **different endpoint from the result**: a client
that renders a result without having read `/capabilities` has nothing in the
payload telling it to ask. Closing that gap means a field on `InferenceResult`,
which is a contract change for every capability and is not made here.
What this runner does guarantee on every path is narrower and worth stating
exactly: no result carries an interpretation, an `observation_confidence`, or a
non-`None` confidence on any observation; `identity_candidate` is emitted only
when the measured policy accepted; and `_result` raises on
`identity_without_confirmation`. A result from here cannot *look* settled. It
relies on the client having read the capability to know it must ask.
**What is not measured here.** The database is US beef breeds — Angus, Angus x
Hereford, Continental x British crosses. No Nigerian and no zebu animal has been
through this, and a White Fulani is white all over, so the coat-pattern signal
the published re-identification literature leans on is absent for the herds this
product is for. The registry carries `UNVALIDATED_GEOGRAPHY`, which is what stops
`may_be_promoted_to_production` mechanically.
## The cold-gallery cost, which is a real limit and not a solved problem
Measured on 2026-08-23 over this service's own HTTP path by
`scripts/verify_identity_wiring.py`, on an Apple M-series laptop, single process,
enrolling five muzzle photographs per animal:
| Register | Enrolment photographs | Cold request | Per photograph | Warm median |
|---|---|---|---|---|
| 20 animals | 100 | **11.13 s** | 0.11 s | **0.059 s** |
| 60 animals | 300 | **60.35 s** | 0.20 s | **0.07 s** |
`_VECTOR_CACHE` is what separates the cold column from the warm one, and it only
helps the warm one. **A farm's first identification pays for embedding its whole
register.**
**Both per-photograph rates are recorded because they disagree**, and the honest
reading is that this is a loaded shared laptop rather than a controlled
measurement — the 0.20 s run had a test suite beside it. Extrapolate on the
slower one; a latency figure from a shared machine is not a latency figure, which
is a rule this repository already writes down in
`experiments/cattle_respiratory/README.md`. At 0.20 s a 169-animal register is
about **169 seconds** cold.
Neither rate is the 59 ms in `backbones.py`. That is the embed call alone on a
frame somebody already decoded; this path also reads the blob and decodes a JPEG.
An earlier version of this paragraph extrapolated on 59 ms and so put a
169-animal farm at "about a minute" — roughly three times better than measured,
in a paragraph whose entire purpose is to be honest about a limit.
**Even the small register does not fit the inline job model.** `app/main.py` runs
jobs synchronously, and this service declined to wire an eighteen-second
capability inline at all; eleven seconds for twenty animals is the same order and
a hundred and sixty-nine seconds is not close. So the honest statement is not
"wired for the farm sizes Animap has" — it is that **every** cold request is slow
and only the warm path is fast, so a deployment has to keep the cache warm rather
than treat the cold case as an edge.
The fix is not a bigger cache. It is for the API to store each enrolment vector
beside its `MediaAsset` and send vectors instead of media ids, which moves the
cost to enrolment time where a person is already waiting. That is a change to the
API's schema and is not made here.
**Where these numbers come from, since they cite no run record.** They are a
session measurement, not a benchmark, and the distinction matters in a repository
whose whole discipline is that a figure names a run id. The script is
`scripts/verify_identity_wiring.py`; it is reproducible and it has not been
through the experiment harness, so nothing here may be quoted as a measured
accuracy. What it does establish is that the wiring reproduces the benchmark
rather than merely citing it: over 122 held-out probes against a 60-animal
gallery, top-1 similarity ran min 0.8336, median 0.9764, max 0.9930 against the
run record's min 0.8336, median 0.9707, max 0.9930, and rank-1 was right 121
times out of 122. The accept rate was 41% against the benchmark's 24.08%, which
is the direction a 60-animal gallery moves it against 169.
"""
from __future__ import annotations
import threading
from collections import OrderedDict
from uuid import UUID, uuid4
import numpy as np
from PIL import Image
from app.adapters.embedding import DINOV3_SPEC, OnnxEmbeddingAdapter
from app.adapters.embedding.identity import (
ENROLMENT_VIEWS,
PRIMARY_VIEW,
Embedding,
IdentityIndex,
IndexMismatch,
OpenSetPolicy,
)
from app.capabilities import FORBIDDEN_CLAIMS, Capability
from app.media import MediaRef, MediaStore
from app.providers import ModelArtefact
from app.quality import assess
from app.schemas import (
EnrolledAnimal,
InferenceLocation,
InferenceRequest,
InferenceResult,
Observation,
QualityCheck,
)
#: The operating point measured in run `8db9e0bd1b30`, and the only policy this
#: runner ships.
#:
#: `accept_similarity` 0.98 is the lowest cutoff in the published sweep whose
#: false-accept rate is 0.0. `accept_margin` 0.0 is the measured companion and it
#: never rejects anything — a margin can only be non-negative — which is
#: deliberate rather than an oversight: `open_set_margin_sweep` in the same run
#: shows the margin rule buys nothing here. At margin 0.07 it holds the
#: false-accept rate to 0.0072 while accepting 23.45% of correct matches, which
#: is *worse on both axes* than the similarity rule alone. The rule stays wired
#: because a future backbone may separate the two distributions differently, and
#: it is set where the evidence puts it.
#:
#: **Both numbers cite the run that produced them and `OpenSetPolicy` will not
#: accept them otherwise.** Its `__post_init__` raises `UnmeasuredThreshold` on a
#: threshold with no run id, which is what stops a cutoff being tuned by whoever
#: is looking at a demo that morning.
MEASURED_POLICY = OpenSetPolicy.measured(
accept_similarity=0.98,
accept_margin=0.0,
run_id="8db9e0bd1b30",
measured_on="muzzle268-169enrolled",
)
#: Said on every run, whatever the result, in the shape `CountingProfile`
#: established. §6.4's product statement, cut to what a person reads.
STANDING_WARNING = (
"A suggestion, not a record. Animap proposes an animal and you confirm it — "
"an unconfirmed match is never written to this animal's history."
)
#: Said whenever candidates are returned at all, because the open-set result is
#: not something a reader can infer from a ranked list.
UNENROLLED_WARNING = (
"An animal that has never been enrolled will still produce a nearest match. "
"Measured on 416 photographs of unenrolled cattle, every one of them came "
"back as somebody. If this animal is new, register it rather than picking "
"the closest name."
)
#: How many candidates a result carries. §6.4 shows a ranked list rather than one
#: answer, and `evidence_correction.selected_interpretation` is the reason: a
#: farmer picking the second name records *"rank 2 was right"*, which its own
#: docstring calls the highest-value training signal in the table. Three, because
#: `closed_set_top3_accuracy` is 0.9937 and a fourth carries no measurement.
TOP_K = 3
#: Enrolment vectors held between requests, keyed by media id and artefact.
#:
#: **Without this the design does not fit the inline job model.** The gallery
#: travels on the request so the service holds no farm state
#: (`schemas.EnrolledAnimal` says why), and the cost of that is re-embedding
#: every enrolled photograph on every identification. At the **measured 0.20 s**
#: per enrolment photograph — end to end, including decode, not the 59 ms embed
#: call `backbones.py` reports on a frame somebody already decoded — a farm with
#: a hundred animals and the five-shot enrolment the accuracy was measured on is
#: 500 photographs, or **about a hundred seconds a request**. That is far past
#: the inline ceiling `app/main.py` describes and far worse than the
#: eighteen-second capability this service declined to wire inline.
#:
#: **A media id is immutable, which is what makes caching it correct rather than
#: merely fast.** `MediaAsset` rows are write-once and the blob behind one is
#: never rewritten, so the same id is the same bytes forever and its vector
#: cannot go stale. The artefact digest is in the key because a re-export with
#: different pooling produces a vector of the same width and a different meaning
#: — the case `IdentityIndex` pins its backbone id to catch — so vectors from two
#: artefacts must never collide here.
#:
#: **The farm id is in the key, and it was not for one commit.** The argument for
#: leaving it out was that nothing can be read from the cache without already
#: holding the media id. A watchdog showed that is not quite the property that
#: matters: `AzureBlobMediaStore` scopes an unpathed lookup to `farm/{farm_id}/`,
#: so *without* a cache a request naming another farm's media id fails to resolve
#: — and *with* one it would hit and score. What leaked was a similarity against
#: a foreign photograph rather than the photograph or the name, and it needed the
#: service token plus a known foreign UUID, so it was narrow. It was also free to
#: close, and a cache must not be the reason a farm boundary that the media store
#: enforces stops being enforced.
_VECTOR_CACHE: OrderedDict[tuple[str, str, str], np.ndarray] = OrderedDict()
#: Entries kept. 768 float32 values is 3 KB, so this is about 12 MB — small
#: against the container's 4 GiB, and enough for a few hundred animals at five
#: shots each. Least-recently-used is evicted, so the farms being worked today
#: stay warm.
_CACHE_LIMIT = 4096
#: `submit()` runs in FastAPI's threadpool, so two captures can be scored at
#: once and an `OrderedDict` is not safe under that on its own.
_CACHE_LOCK = threading.Lock()
def cache_clear() -> None:
"""Empty the vector cache. For tests, and for a deployment that swaps an
artefact without a restart."""
with _CACHE_LOCK:
_VECTOR_CACHE.clear()
def _cached_vector(key: tuple[str, str, str]) -> np.ndarray | None:
with _CACHE_LOCK:
vector = _VECTOR_CACHE.get(key)
if vector is not None:
_VECTOR_CACHE.move_to_end(key)
return vector
def _remember(key: tuple[str, str, str], vector: np.ndarray) -> None:
with _CACHE_LOCK:
_VECTOR_CACHE[key] = vector
_VECTOR_CACHE.move_to_end(key)
while len(_VECTOR_CACHE) > _CACHE_LIMIT:
_VECTOR_CACHE.popitem(last=False)
#: Muzzle photographs per animal in the protocol the accuracy was measured under.
#:
#: Run `8db9e0bd1b30` enrolled each animal from five, all under the `muzzle` view
#: name, and `IdentityIndex.candidates` scores an animal by its **best** view —
#: so the five are five chances to match rather than five samples averaged
#: together. An animal enrolled from one photograph is being asked a harder
#: question than the benchmark asked, and measured that way on twelve animals,
#: **0 of 23 held-out queries cleared the 0.98 cutoff**.
#:
#: Used only to report a thin enrolment, never to refuse one.
MEASURED_ENROLMENT_SHOTS = 5
class IdentityRunner:
"""Runs `cattle_identity` against one capture and one farm's register."""
#: The view a single capture is compared against. §6.4 leads with the muzzle
#: and the re-identification measurement is entirely muzzle-to-muzzle, so
#: comparing a muzzle print against a side-body vector would contribute
#: nothing but a chance of a spurious high score. Restricting the pool is
#: what keeps the runtime behaviour inside the measured one.
#:
#: **Nothing checks that the capture actually is a muzzle, and a watchdog was
#: right to call that a gap.** The registry declares `muzzle_not_visible` in
#: `reject_if`, but `app/quality.py` implements resolution, illumination and
#: motion blur and nothing that looks for an anatomical part — so the
#: condition is declared and never evaluated. A caller that sends the five
#: enrolment views in the order the registry lists them sends `front_face`
#: first, and it would be scored against a gallery of muzzles.
#:
#: What that costs is bounded in the safe direction: a front-face photograph
#: is *less* similar to every enrolled muzzle, so it drives the top score down
#: and towards `no_confident_match`. It degrades to a refusal rather than to a
#: wrong name. But it is a refusal for a reason nobody is told, and the fix is
#: a real one — either the capture flow states which view it took, or
#: something evaluates `muzzle_not_visible`. Neither exists today.
query_view = PRIMARY_VIEW
def run(
self,
*,
request: InferenceRequest,
capability: Capability,
artefact: ModelArtefact,
store: MediaStore,
request_id: UUID | None = None,
) -> InferenceResult:
request_id = request_id or uuid4()
warnings: list[str] = [STANDING_WARNING]
if len(request.media_ids) > capability.frames_required:
warnings.append(
f"{len(request.media_ids)} frames were supplied; this capability "
f"reads {capability.frames_required}."
)
image = store.open_image(self._ref(request, request.media_ids[0]))
verdict = assess(image)
checks = list(verdict.checks)
if verdict.blocked:
failure = verdict.first_failure
warnings.append(
failure.detail if failure and failure.detail
else "The capture was not usable."
)
return self._result(
request=request, capability=capability, artefact=artefact,
request_id=request_id, observations=[], checks=checks,
warnings=warnings, recapture=True,
)
adapter = OnnxEmbeddingAdapter(
artefact, DINOV3_SPEC,
input_size=artefact.input_size or 224,
mean=artefact.image_mean or (0.485, 0.456, 0.406),
std=artefact.image_std or (0.229, 0.224, 0.225),
dimensions=artefact.embedding_dimensions or 768,
).load()
index = IdentityIndex(
farm_id=str(request.farm_id),
backbone_id=DINOV3_SPEC.adapter_id,
dimensions=adapter.dimensions,
artefact_sha256=artefact.sha256,
)
names: dict[str, str] = {}
enrolled_views, skipped = self._enrol(
index, adapter, artefact, request, store, names,
)
warnings.extend(skipped)
query = self._embed(adapter, artefact, image)
result = index.match(
query,
policy=MEASURED_POLICY,
top_k=TOP_K,
# Compared against muzzles only. See `query_view`.
restrict_to_views=(self.query_view,),
names=names,
)
checks.append(self._enrolment_check(index, enrolled_views))
observations: list[Observation] = [
# Emitted on every path, refusals included, for the reason
# `counting.py` emits its grid: a threshold that has to be re-derived
# later is re-derived from stored results or not at all.
Observation(
type="enrolled_animals",
value=float(result.enrolled_animals),
unit="animals",
confidence=None,
),
Observation(
type="open_set_verified",
value=1.0 if result.open_set_verified else 0.0,
confidence=None,
),
]
top = result.top
if top is not None:
observations.append(Observation(
# **Uncalibrated, and named so.** Nothing mapped cosine
# similarity onto a probability that the animal is Kofi, so this
# is a diagnostic and the release declares no sentence for it —
# exactly as `counting_grid` carries none. §37's whole complaint
# is an uncalibrated model score reaching a person as a promise.
type="top_candidate_similarity",
value=round(top.similarity, 4),
confidence=None,
))
accepted = result.claim == "identity_candidate"
if not accepted:
observations.append(Observation(
type="no_confident_match", value=None, confidence=None,
))
# **The ranked list is emitted on both paths, under different names.**
# `IdentityResult` returns candidates whether or not the policy accepted
# the top one, and dropping them on the refusal path would throw away the
# thing §6.4 is shaped around: `evidence_correction.selected_
# interpretation` records *"rank 2 was right"*, and its own docstring
# calls that the highest-value training signal in the table. At the
# measured operating point three quarters of correct matches are refused,
# so the refusal path is where most of that signal lives — emitting
# nothing there would collect it almost nowhere.
#
# The type differs because the claim differs. `identity_candidate` is
# §6.4's *"This looks like Kofi"*; `closest_candidate` is *"not a
# confident match, and these are the nearest on your register"*. One
# observation type carrying both would leave the device deciding which
# sentence to show from a field that does not say, and the release
# declares a separate sentence for each.
#
# ## Exactly one settled name, ever
#
# An accepted run used to publish `identity_candidate` for **all three**
# ranked candidates, so `deploy/semantics` rendered *"This looks like
# Cow 0100"*, *"This looks like Cow 0200"* and *"This looks like Cow
# 0300"* — three settled names for one animal, on one `captured_at`,
# which no ordering on the device can pick between. The policy accepted
# **the top candidate**; it said nothing whatever about ranks 2 and 3.
#
# So rank 1 is the proposal and the rest are what §6.4's screen calls
# *"Also considered"* — the same list, under the type that does not
# assert. Picking one of them is `choose_another_animal`, which is a
# correction and the signal the ledger is built to keep.
for position, candidate in enumerate(result.candidates):
settled = accepted and position == 0
observations.append(Observation(
type="identity_candidate" if settled else "closest_candidate",
# The animal's own name. `identity_candidate` is one of three
# claims in the whole registry that carries free text, and
# `app/capabilities.py` says why: a registry cannot enumerate a
# farm's animals. The value comes from the farm's own register
# via the request, never from this service.
value=candidate.display_name,
# **`confidence` stays None on both paths.**
# `Candidate.confidence` is documented as never set by the
# matcher, and promoting a similarity into it here would defeat
# that in one line.
confidence=None,
))
if result.candidates:
warnings.append(UNENROLLED_WARNING)
warnings.extend(result.warnings)
if index.unverified_queries:
# Should be zero on this path: every vector is built as an
# `Embedding` below. Reported rather than asserted, because the count
# existing at all is the gap between what the index checks and what
# it would like to.
warnings.append(
f"{index.unverified_queries} vectors were compared without "
f"provenance. Every vector this service builds carries it, so "
f"this is a defect rather than a capture problem."
)
return self._result(
request=request, capability=capability, artefact=artefact,
request_id=request_id, observations=observations, checks=checks,
warnings=warnings,
# A refusal here is not a bad photograph. The animal may simply not
# be enrolled, and asking for a recapture would send a worker back to
# a pen to re-photograph a cow the system has never seen.
recapture=verdict.degraded,
)
def _ref(self, request: InferenceRequest, media_id: UUID,
object_path: str | None = None) -> MediaRef:
return MediaRef(
media_id=media_id,
farm_id=request.farm_id,
captured_at=request.captured_at,
object_path=object_path or request.path_for(media_id),
)
def _embed(
self, adapter: OnnxEmbeddingAdapter, artefact: ModelArtefact,
image: Image.Image,
) -> Embedding:
"""A vector that can prove where it came from.
Always an `Embedding`, never a bare array. `IdentityIndex._accept` can
only check the *width* of an array, and a watchdog scored a foreign
vector against a `dinov3-vits16` index at similarity 1.0 through exactly
that gap.
"""
return Embedding(
vector=adapter.embed(image),
backbone_id=DINOV3_SPEC.adapter_id,
artefact_sha256=artefact.sha256,
)
def _enrol(
self,
index: IdentityIndex,
adapter: OnnxEmbeddingAdapter,
artefact: ModelArtefact,
request: InferenceRequest,
store: MediaStore,
names: dict[str, str],
) -> tuple[int, list[str]]:
"""Build this farm's gallery from the request. Returns views enrolled.
A gallery entry that cannot be read is **skipped with a warning rather
than failing the job**: one unreadable enrolment photograph out of two
hundred should not stop a farmer identifying an animal, and the animals
that did load are still a gallery. The count that reaches the result is
the count that actually enrolled, so a farm cannot be told it was
compared against more animals than it was.
"""
enrolled_views = 0
skipped: list[str] = []
for entry in request.enrolled:
usable = self._views_for(entry, skipped)
if not usable:
continue
who = entry.display_name or entry.animal_id
stored = 0
for view, media_ids in usable.items():
for position, media_id in enumerate(media_ids):
key = (str(request.farm_id), str(media_id), artefact.sha256)
cached = _cached_vector(key)
if cached is not None:
vector = Embedding(
vector=cached,
backbone_id=DINOV3_SPEC.adapter_id,
artefact_sha256=artefact.sha256,
)
else:
try:
image = store.open_image(self._ref(
request, media_id,
entry.path_for_view(view, position),
))
except Exception as exc: # MediaError, or any store error
skipped.append(
f"An enrolled {view} photograph of {who} could "
f"not be read ({exc}), so it was left out of "
f"the comparison."
)
continue
vector = self._embed(adapter, artefact, image)
_remember(key, vector.vector)
try:
# **One call per photograph, under the one view name.**
# `enrol` takes a mapping of view to vector, so a single
# call can only hold one shot per view — and the measured
# protocol is five muzzles per animal, scored by maximum.
# Calling it per photograph is how those five reach the
# index, and it is what run 8db9e0bd1b30 did.
index.enrol(
entry.animal_id, {view: vector},
media_ids={view: str(media_id)},
)
except (IndexMismatch, ValueError) as exc:
skipped.append(
f"A {view} photograph of {who} was left out of the "
f"comparison: {exc}"
)
continue
stored += 1
if not stored:
continue
names[entry.animal_id] = who
enrolled_views += stored
return enrolled_views, skipped
def _views_for(
self, entry: EnrolledAnimal, skipped: list[str]
) -> dict[str, list[UUID]]:
"""The views of one animal this runner will compare against.
Unknown view names are dropped here with a warning rather than left for
`enrol` to raise on, because one typo in one animal's record should cost
that animal's view and not the whole request.
"""
usable: dict[str, list[UUID]] = {}
for view, media_ids in entry.views.items():
if view not in ENROLMENT_VIEWS:
skipped.append(
f"{entry.display_name or entry.animal_id} has a view called "
f"{view!r}, which is not one of the five §6.4 enrols "
f"({', '.join(ENROLMENT_VIEWS)}). It was ignored."
)
continue
if media_ids:
usable[view] = list(media_ids)
return usable
def _enrolment_check(
self, index: IdentityIndex, enrolled_views: int
) -> QualityCheck:
"""Whether this farm had anything to compare the capture against.
A quality check rather than a warning, because the answer is about the
*farm's register* rather than about the photograph, and a capture flow
that can distinguish the two can say *"register this animal"* instead of
*"take it again"*.
"""
muzzles = sum(1 for v in index.views if v.view == self.query_view)
animals = len(index.animal_ids)
if muzzles:
detail = (
f"{animals} animals enrolled, {enrolled_views} views, "
f"{muzzles} of them muzzles."
)
thin = [
a for a in index.animal_ids
if sum(1 for v in index.views
if v.animal_id == a and v.view == self.query_view)
< MEASURED_ENROLMENT_SHOTS
]
if thin:
# **Reported, never a refusal.** A thin enrolment still matches,
# and one good muzzle photograph is worth more than a refused
# enrolment — `IdentityIndex.enrol` takes that position and this
# agrees with it. What it is not is the protocol the accuracy was
# measured under, and a farm whose animals never quite match
# deserves to know the reason is its register rather than its
# camera.
detail += (
f" {len(thin)} of them carry fewer than "
f"{MEASURED_ENROLMENT_SHOTS} muzzle photographs, which is "
f"the enrolment the accuracy was measured on."
)
return QualityCheck(check="gallery", passed=True, detail=detail)
return QualityCheck(
check="gallery",
passed=False,
detail=(
f"No enrolled {self.query_view} to compare against. "
f"{len(index.animal_ids)} animals were sent and none carries "
f"the view this capability matches on."
),
)
def _result(
self, *, request, capability, artefact, request_id, observations,
checks, warnings, recapture,
) -> InferenceResult:
forbidden = [o.type for o in observations if o.type in FORBIDDEN_CLAIMS]
if forbidden:
raise ValueError(
f"{capability.key} tried to emit a forbidden claim: {forbidden}"
)
banned = set(capability.acquisition.forbidden_claims)
offending = [o.type for o in observations if o.type in banned]
if offending:
# `identity_without_confirmation` is the one thing §6.4 forbids, and
# it is checked here as well as in the registry because this runner
# is the only thing that could emit it.
raise ValueError(
f"{capability.key} tried to emit {offending}, which its own "
f"registry entry forbids by name."
)
return InferenceResult(
request_id=request_id,
capability_key=capability.key,
model_id=artefact.model_id,
model_version=artefact.version,
inference_location=InferenceLocation.REMOTE,
subject_type=request.subject_type,
subject_id=request.subject_id,
observations=observations,
# **No interpretation, and none is possible.** A name is a fact about
# the register, not a judgement about the animal, and there is
# nothing for a vet to review. `observation_confidence` is likewise
# None: the three-level label would be read as a confidence in the
# name, and nothing calibrated one.
interpretations=[],
observation_confidence=None,
interpretation_confidence=None,
quality_checks=checks,
warnings=warnings,
recommended_recapture=recapture,
)
#: Capabilities with an implemented adapter, in the shape `app/counting.py`
#: publishes. `app/main.py` merges the two: a capability with a validated
#: artefact and no entry in either returns 501 rather than a plausible result.
RUNNERS: dict[str, IdentityRunner] = {
"cattle_identity": IdentityRunner(),
}