"""The inference service. Brief §30. FastAPI, and deliberately small: submit a job, ask about a job, list what the service can actually do. The service's most important property is what it refuses. A capability with no validated artefact returns `unavailable` — not a plausible-looking result, not a placeholder, and not an error. `unavailable` is the honest answer for twenty-five of the twenty-eight capabilities today, and the API's job is to say so plainly enough that a client cannot mistake it for anything else. **`state` and `runnable` are different questions and the client must render both** (ADR 0021). `state` is the strongest claim a capability is entitled to make once its stack is wired; `runnable` is whether an artefact and an adapter exist today. Most capabilities are `experimental` and `runnable: false`, which means *"this is a real feature and it is not switched on yet"* — not *"this works."* A client that renders `state` alone will overclaim. Three run for real. `cattle_detection` and `poultry_count` execute a checksummed YOLOX artefact against the referenced media and return a result built from what the model actually produced (ADR 0018). `cattle_identity` embeds the capture with a checksummed DINOv3 artefact and ranks it against the enrolled animals the request carries (`app/identification.py`). **Twenty-five is not a backlog of twenty-five equal items**, and reading it as one is how this service gets misreported. Several of the unwired capabilities have complete benchmarks whose result is that the method does not work: `cattle_gait` separates lame from sound at AUC 0.3117 (p = 0.91) and `poultry_respiratory` at AUC 0.4141, both at or below chance. Those are measured negatives, not missing work, and registering either would wire a screen that is anti-correlated with the thing it screens for. Jobs run inline. A frame that settles at the first grid is well under a second on a CPU; a dense one that runs all three grids takes a few seconds (ADR 0019). A queue becomes worth its moving parts when a capability arrives that takes tens of seconds, and none does. **Two endpoints are open and two are not.** `/health` and `/capabilities` carry no farm data and the platform's probes need `/health`, so both are unauthenticated. `/jobs` runs a model against a farm's photographs, so it needs a bearer token whenever one is configured. """ from __future__ import annotations import hmac import logging import os from uuid import UUID, uuid4 from fastapi import Depends, FastAPI, Header, HTTPException from pydantic import BaseModel from app import dispositions from app.capabilities import ( REGISTRY, Capability, CapabilityState, Requirement, licence_exposure, ) from app.counting import RUNNERS as _COUNTING_RUNNERS from app.detectors import DetectorError from app.identification import RUNNERS as _IDENTITY_RUNNERS from app.media import MediaError, build_store from app.providers import ( CompositeProvider, HostedModelProvider, LocalArtefactProvider, ) from app.reasoning import RUNNERS as _REASONING_RUNNERS from app.schemas import InferenceRequest, JobState, JobStatus logger = logging.getLogger(__name__) app = FastAPI( title="Animap inference", version="0.1.0", summary="Runs validated livestock models, and refuses to invent results.", ) #: Both kinds of capability, asked in order. #: #: `LocalArtefactProvider` serves the three with a checksummed file on disk; #: `HostedModelProvider` serves the sixteen whose model is somebody else's and #: which can never have a file. The sets are disjoint and `CompositeProvider` #: says why that is a rule rather than a coincidence. provider = CompositeProvider(LocalArtefactProvider(), HostedModelProvider()) media = build_store() #: Every capability with an implemented adapter, from both modules that hold #: one. Merged here rather than in either, so neither has to import the other and #: the set `/health` counts is the set `/jobs` dispatches on. #: #: **A key in exactly one of them.** The two dicts are disjoint by construction — #: counting owns the two detector capabilities, identification owns #: `cattle_identity` — and a collision would silently take whichever was merged #: last, so it is refused rather than resolved. _MODULES = { "counting": _COUNTING_RUNNERS, "identification": _IDENTITY_RUNNERS, "reasoning": _REASONING_RUNNERS, } _SEEN: dict[str, str] = {} for _module, _runners in _MODULES.items(): for _key in _runners: if _key in _SEEN: raise RuntimeError( f"{_SEEN[_key]} and {_module} both claim {_key}. One capability " f"has one runner, and a silent winner here is a capability " f"answered by a model nobody chose." ) _SEEN[_key] = _module RUNNERS: dict[str, object] = { **_COUNTING_RUNNERS, **_IDENTITY_RUNNERS, **_REASONING_RUNNERS, } #: In-memory for now. Jobs are re-submittable and carry no record of their own — #: the durable state lives in the API's `model_runs`, and a lost job means a #: retry rather than lost data. A queue replaces this when a model exists to run. _jobs: dict[UUID, JobStatus] = {} def _configured_token() -> str: """Read at call time, not import time, so a test can set it and so a secret rotation takes effect on restart rather than needing a rebuild.""" return os.environ.get("ANIMAP_INFERENCE_TOKEN", "") def require_token(authorization: str = Header(default="")) -> None: """Bearer auth on the endpoints that touch a farm's media. Absent `ANIMAP_INFERENCE_TOKEN` this is a no-op, which is what lets the tests and a local uvicorn run without ceremony. That default is only safe because it is *visible*: `/health` reports `authenticated`, so a deployment that reached the internet without a token says so to anyone who asks, including the person checking it after a deploy. """ expected = _configured_token() if not expected: return scheme, _, presented = authorization.partition(" ") # Constant-time, because a token compared with `==` leaks its prefix to # anyone willing to time a few thousand requests. if scheme.lower() != "bearer" or not hmac.compare_digest(presented, expected): raise HTTPException(401, "A valid bearer token is required.") class HealthResponse(BaseModel): status: str capabilities_registered: int capabilities_runnable: int artefacts_loaded: int #: Capabilities that have both an artefact and an adapter. The gap between #: this and `capabilities_runnable` is the set that returns 501. adapters_implemented: int #: Where captures are read from. Reported because a service that is healthy #: and pointed at an empty local directory fails every job for a reason no #: probe would otherwise show. media_provider: str #: The licence of every artefact that is actually loaded. A deployment that #: has picked up a copyleft model is a deployment in breach, so it is visible #: from outside rather than only in a log (ADR 0017). artefact_licenses: list[str] #: Whether `/jobs` requires a bearer token. False on a public deployment is #: a misconfiguration, and this is what makes it findable without reading #: the Container App's environment. authenticated: bool class SupersededView(BaseModel): """The verdict a capability used to carry, and why it moved (ADR 0021). Served rather than kept internal so that a reframing nobody agrees with is arguable from outside this service. """ verdict: str summary: str reframed_because: str class DispositionView(BaseModel): """The evidence behind a capability, and what the product may say about it. Served alongside the state because a client that can only render `Coming soon` has nothing to say when a farmer asks *when*. `stated_uncertainty` is the line a result screen shows beside a number (directive §37). """ group: str summary: str blocker: str stated_uncertainty: str data_needed: str | None evidence: list[str] superseded: SupersededView | None class QuantityView(BaseModel): """What one claim's observation value may carry, and on whose authority. `basis` is published rather than kept internal because the three kinds are not equally strong: a `scale` bound is what the unit means, and a `guardrail` is an engineering judgement with no measurement behind it. A client that shows a farmer a refused reading should be able to say which. """ claim: str #: `null` when the claim carries no quantity at all. unit: str | None carries_number: bool minimum: float | None maximum: float | None step: float | None basis: str | None why: str class OutputView(BaseModel): type: str unit: str | None show_range: bool #: The range outside which a value is broken rather than merely surprising, #: and the granularity the rubric supports. All `null` for the twelve #: capabilities whose output carries no number. See `OutputSpec` for what #: these are and, more importantly, what they are not: they bound the #: impossible, and say nothing about where a real answer usually falls. #: #: **Enforced as well as published**, which they were not for one commit: #: `app/adapters/claims.py::schema_for` and `check_numeric_bounds` both read #: them, so a client that pre-checks against these gets the same answer the #: service does. plausible_min: float | None plausible_max: float | None step: float | None #: Which of `allowed_claims` report the quantity the three fields above #: bound. Empty for a categorical output. A client showing an observation #: knows from this whether the bounds apply to it — a footpad grade is 0 to #: 4, and the sampled prevalence beside it is a percentage. measured_claims: list[str] #: Every claim in the vocabulary and what number it may carry, which is what #: the three fields above could not say. They bound one quantity, so an #: observation on any other claim went unbounded — 73 of the registry's 93, #: and the route a lameness score took to a farm. #: #: `carries_number: false` means a figure beside this claim is the defect, #: not a finer reading of it, and the service refuses every number there. The #: claim still carries a word: `Observation.value` takes a string, and #: 'moderate' or 'left flank' is what such a claim is for. quantities: list[QuantityView] class AcquisitionView(BaseModel): """Directive §34. How this capability's signal is acquired, and what may be claimed from it. The Android capture flow reads this.""" protocol: str modality: list[str] output: OutputView minimum_capture_seconds: int | None preferred_capture_seconds: int | None minimum_distance_m: float | None minimum_samples: int | None preferred_samples: int | None required_views: list[str] optional_inputs: list[str] confirmation_options: list[str] escalation: str | None #: Workflows this capability must never hold up (§6.6). never_blocks: list[str] reject_if: list[str] #: What a model may emit. This is the closed vocabulary a reasoner's schema #: is built from, so a client rendering it is showing what Animap can say. allowed_claims: list[str] forbidden_claims: list[str] #: Quantities the result screen may show that no model produces — the app #: computes them from a capability output plus the farm's own records. #: `poultry_count` is the only holder, carrying §6.3's third quantity. derived_claims: list[str] #: **What a model may say in words a farmer reads** (ADR 0024). `evidence` #: used to be free text; it is a closed enum of these phrases now, per #: capability, exactly as `allowed_claims` is. The strings are reader-facing #: rather than identifiers, so a client renders them as they are — and a #: client that pre-checks a response against this list gets the same answer #: the service does. #: #: Empty for `poultry_uniformity`, which may claim nothing (ADR 0023). allowed_evidence: list[str] #: What a model may say about the **capture** rather than about the animal. #: A shared set plus one phrase per condition in `reject_if`, so the #: rejection a client already renders and the sentence a farmer reads for it #: cannot drift apart. allowed_limits: list[str] class CapabilityView(BaseModel): key: str species: str #: The single-word spelling the API's `capabilities` table stores. The full #: list is `acquisition.modality`. modality: str state: CapabilityState #: Any combination of `guided_capture`, `human_confirmation`, #: `hardware_required`, `fixed_installation` — or none. Sorted, so a client #: can compare two responses. requirements: list[str] #: What a farmer gets with no signal. Nineteen capabilities are `required` #: because their first stage is a hosted model — which is a hosting fact, #: never a reason for a lower `state`. connectivity: str #: Whether this could plausibly move to the phone later. An engineering #: property, not a promise and not a badge. on_device_candidate: bool output_kind: str depends_on: list[str] #: The runtimes this capability's declared stack leans on, named as #: `app/adapters/licences.py` names them. model_stack: list[str] #: Runtimes in that stack Animap may not currently serve, cannot fetch #: unattended, or has never checked the terms of. **Never a reason for a #: lower `state`** — a licence is an attribute of a model, not a property of #: a capability. Empty for most. licence_exposure: list[str] acquisition: AcquisitionView #: Whether a result can be produced **today**. Independent of `state`. runnable: bool reason: str disposition: DispositionView | None @app.get("/health", response_model=HealthResponse) def health() -> HealthResponse: runnable = [c for c in REGISTRY.values() if provider.can_run(c)] return HealthResponse( status="ok", capabilities_registered=len(REGISTRY), capabilities_runnable=len(runnable), artefacts_loaded=len(provider.artefacts), adapters_implemented=sum(1 for c in runnable if c.key in RUNNERS), media_provider=media.provider, artefact_licenses=sorted({a.license for a in provider.artefacts.values()}), authenticated=bool(_configured_token()), ) @app.get("/capabilities", response_model=list[CapabilityView]) def capabilities() -> list[CapabilityView]: """What the service can do, what it cannot, and what it may say either way. The single source of what a user is told about a capability. A client shows `state` and `runnable` together, `acquisition` to drive the capture, and `disposition.stated_uncertainty` beside any number. """ # Computed once for the whole response rather than per capability: it reads # the adapters' licence ledger, and 28 lookups of the same table is waste. exposure = licence_exposure() return [_view(c, exposure.get(c.key, [])) for c in REGISTRY.values()] @app.post("/jobs", response_model=JobStatus, status_code=202, dependencies=[Depends(require_token)]) def submit(request: InferenceRequest) -> JobStatus: capability = REGISTRY.get(request.capability_key) if capability is None: raise HTTPException(404, f"Unknown capability: {request.capability_key}") if not provider.can_run(capability): # Not an error. The capture is already saved on the device and in the # API; this only says no interpretation is available for it yet. status = JobStatus( job_id=uuid4(), state=JobState.UNAVAILABLE, capability_key=capability.key, detail=_unavailable_reason(capability), ) _jobs[status.job_id] = status return status runner = RUNNERS.get(capability.key) if runner is None: # An artefact without an adapter stays unimplemented rather than # stubbed: an adapter that returns something plausible is precisely the # failure ADR 0005 exists to prevent. raise HTTPException( 501, f"{capability.key} has a validated artefact but no adapter is " f"implemented for it yet.", ) artefact = provider.artefact_for(capability) job_id = uuid4() try: result = runner.run( request=request, capability=capability, artefact=artefact, store=media, request_id=job_id, ) except MediaError as exc: # The referenced media is missing or unreadable. The capture itself is # safe on the device, so this is a job to retry, not data to discard. status = JobStatus( job_id=job_id, state=JobState.FAILED, capability_key=capability.key, detail=str(exc), ) except (DetectorError, ValueError) as exc: logger.exception("%s failed on %s", capability.key, request.media_ids[0]) status = JobStatus( job_id=job_id, state=JobState.FAILED, capability_key=capability.key, detail=f"The model could not be run: {exc}", ) else: status = JobStatus( job_id=job_id, state=JobState.COMPLETE, capability_key=capability.key, result=result, ) _jobs[job_id] = status return status @app.get("/jobs/{job_id}", response_model=JobStatus, dependencies=[Depends(require_token)]) def job(job_id: UUID) -> JobStatus: status = _jobs.get(job_id) if status is None: raise HTTPException(404, "No such job.") return status def _view(c: Capability, exposure: list[str] | None = None) -> CapabilityView: runnable = provider.can_run(c) return CapabilityView( key=c.key, species=c.species, modality=c.modality, state=c.state, requirements=sorted(r.value for r in c.requirements), connectivity=c.connectivity.value, on_device_candidate=c.on_device_candidate, output_kind=c.output_kind, depends_on=list(c.depends_on), model_stack=list(c.model_stack), licence_exposure=list(exposure or []), acquisition=_acquisition_view(c), runnable=runnable, reason="" if runnable else _unavailable_reason(c), disposition=_disposition_view(c.key), ) def _acquisition_view(c: Capability) -> AcquisitionView: a = c.acquisition return AcquisitionView( protocol=a.protocol, modality=[m.value for m in a.modality], output=OutputView( type=a.output.type, unit=a.output.unit, show_range=a.output.show_range, plausible_min=a.output.plausible_min, plausible_max=a.output.plausible_max, step=a.output.step, measured_claims=list(a.output.measured_claims), quantities=[ QuantityView( claim=q.claim, unit=q.unit, carries_number=q.carries_number, minimum=q.minimum, maximum=q.maximum, step=q.step, basis=q.basis.value if q.basis else None, why=q.why, ) for q in a.output.quantities ], ), minimum_capture_seconds=a.minimum_capture_seconds, preferred_capture_seconds=a.preferred_capture_seconds, minimum_distance_m=a.minimum_distance_m, minimum_samples=a.minimum_samples, preferred_samples=a.preferred_samples, required_views=list(a.required_views), optional_inputs=list(a.optional_inputs), confirmation_options=list(a.confirmation_options), escalation=a.escalation, never_blocks=list(a.never_blocks), reject_if=list(a.reject_if), allowed_claims=list(a.allowed_claims), forbidden_claims=list(a.forbidden_claims), derived_claims=list(a.derived_claims), allowed_evidence=list(a.evidence_phrases), allowed_limits=list(a.limit_phrases), ) def _disposition_view(key: str) -> DispositionView | None: disposition = dispositions.get(key) if disposition is None: return None superseded = disposition.superseded return DispositionView( group=disposition.group.value, summary=disposition.summary, blocker=disposition.blocker, stated_uncertainty=disposition.stated_uncertainty, data_needed=disposition.data_needed, evidence=[f"{s.claim} — {s.url}" for s in disposition.evidence], superseded=None if superseded is None else SupersededView( verdict=superseded.verdict, summary=superseded.summary, reframed_because=superseded.reframed_because, ), ) def _unavailable_reason(c: Capability) -> str: """Why a capability cannot run today, in words a client can show a person. **The distinction this string used to draw has moved into `state`.** It once had to separate "late" from "never", because six capabilities were `coming_soon` in the registry and `not_viable` in the research, and a client rendering `status` alone showed the same `Coming soon` for both. After ADR 0021 nothing is `not_viable`: the claims that were rejected are rejected as claims, in `capabilities.REJECTED_CLAIMS`, and every capability survives in a corrected form. What is left to say is narrower and more useful — this feature is real, the model behind it is not installed on this deployment yet, and here is what it is waiting for. """ if c.state == CapabilityState.UNSUPPORTED_CLAIM: # **One capability holds this state**: `poultry_uniformity`, since # ADR 0023. The branch existed so that one arriving could not be # rendered as merely late, and the arrival showed it was half a branch. # # §39 says not to change a capability to "Not planned", and a flat # refusal here would do exactly that to a claim that survives in another # form. So the two cases are split. A capability whose corrected form is # something the app computes says what that is; only a claim with # nothing behind it at all reads as unplanned. if c.acquisition.derived_claims: return ( "The capture is saved. Animap will not work this out from the " "capture this capability declares — measured against the real " "answer it is wrong by more than the number is worth, and by " "more birds rather than fewer. What survives is computed from " "entered values instead: " + ", ".join(c.acquisition.derived_claims) + "." ) return ( "This is not planned. The claim behind it is not scientifically " "supportable in any capture protocol." ) # Checked before `state`, not inside a `coming_soon` branch. A capability # waiting on another capability is waiting whatever its own state says. # # **Nothing declares a dependency today.** `poultry_uniformity` was the only # one and ADR 0023 removed it, because the input it waited on is the input # that makes its answer wrong — a dependency that resolves into a refusal is # a worse pointer than none. if c.depends_on: return ( "The capture is saved. This needs " + ", ".join(c.depends_on) + " first — there is nothing to compute until those results exist." ) if c.state == CapabilityState.COMING_SOON: return "Not built yet. The engineering path exists and nobody has walked it." if Requirement.HARDWARE_REQUIRED in c.requirements: return "This capability needs compatible hardware." if Requirement.FIXED_INSTALLATION in c.requirements: return "This capability needs a permanently placed camera or microphone." artefact = provider.artefact_for(c) if artefact is None: return ( "The capture is saved. No validated model for this capability is " "installed on this deployment yet, so the analysis is still to come." ) # **These two were one string, and it named the wrong thing.** The fallback # read "The model artefact is present but has not been validated." for # `cattle_identity`, whose artefact is present *and* validated — it passes # its checksum, `discover()` loads it, and its licence is in # `/health.artefact_licenses`. What was missing was on the capability, not on # the model: the registry entry named no `model_provider`, so # `Capability.is_runnable` was False. A reason that sends somebody to inspect # a model card when the model card is fine costs an afternoon, and # `DEPLOY.md` had to carry a paragraph warning readers not to believe it. if not artefact.is_validated: return ( "The model artefact is installed but its card attests nothing about " "whether it works, so this deployment will not run it." ) return ( "The capture is saved. The model for this capability is installed and " "checksummed, and the capability is not switched on in this build yet." )