Spaces:
Running on Zero
Running on Zero
| """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 | |
| 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()), | |
| ) | |
| 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()] | |
| 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 | |
| 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." | |
| ) | |