"""Model adapters, and the governance around them. ADR 0005 and brief §30. A model file is only usable once it can account for itself: explicit source, licence, checksum, model card, version, and validation notes. Nothing is downloaded at runtime. This is not bureaucracy. A livestock record that informs a treatment or a sale has to be reconstructable months later — "which model said this animal weighed 386 kg, and had anyone checked it worked in Nigeria?" is a question the system must be able to answer, and it cannot if artefacts arrive anonymously. """ from __future__ import annotations import hashlib import json import logging import os from dataclasses import dataclass from pathlib import Path from app.adapters import fingerprints, licence_policy, licences from app.capabilities import UNVALIDATED_GEOGRAPHY, Capability logger = logging.getLogger(__name__) #: Read in blocks rather than whole. `yolox_x` is 396 MB and this runs on a #: 2 vCPU / 4 GiB container at start-up, next to onnxruntime's arenas. _DIGEST_CHUNK = 1 << 20 MODELS_DIR = Path(__file__).resolve().parent.parent / "models" #: Every field a model card must carry before its artefact may be loaded. REQUIRED_CARD_FIELDS = ( "model_id", "version", "capability_key", "source", "license", "sha256", "validation_notes", "geography_validated", ) class ArtefactError(RuntimeError): """An artefact is missing, unaccounted for, or does not match its card.""" #: Licences Animap may not serve from. Copyleft that reaches a network service, #: and non-commercial terms that a commercial product cannot satisfy at all. #: #: ADR 0017 records what AGPL-3.0 actually says, which is narrower than this #: service used to claim: §13's source-offer duty is conditioned on *modifying* #: the program, and §0 puts network interaction outside "convey". The reason #: these are refused anyway is not that breach is certain — it is that the #: vendor's published position asserts it, the question is unsettled, and the #: measured cost of avoiding it is roughly nil. #: #: The list is broader than AGPL because the failure it guards against is "an #: artefact whose terms this product cannot meet", and a Non-Commercial dataset #: licence fails that just as completely. The Bristol cattle sets are the live #: example (`app/dispositions.py`). DISALLOWED_LICENSES = frozenset({ "AGPL-3.0", "AGPL-3.0-only", "AGPL-3.0-or-later", "GPL-3.0", "GPL-3.0-only", "GPL-3.0-or-later", "GPL-2.0", "SSPL-1.0", "CC-BY-NC-4.0", "CC-BY-NC-SA-4.0", "CC BY-NC 4.0", "Non-Commercial Government Licence", }) #: Kept as the old name because `is_copyleft` reads better at the call site and #: the AGPL case is still the one this exists for. NETWORK_COPYLEFT_LICENSES = DISALLOWED_LICENSES #: Runtimes that can only load an artefact whose terms Animap may not serve #: under, whatever the card claims about it. #: #: **This used to be described as "the check that matters", and it was not.** A #: card declares its own licence, so the named threat — somebody editing a card #: — is exactly the case a self-declaration cannot catch: write #: `"license": "Apache-2.0"` over a path to `yolo11m.pt` and the licence gate #: waves it through. The fix was to read `runtime` instead. But `runtime` is #: also a card field, read eleven lines below a paragraph arguing that the card #: cannot be trusted, so editing `runtime` rather than `license` defeated it in #: one line and served AGPL weights as `cattle_identity` under Apache-2.0. #: #: **What actually binds a licence to an artefact is #: `app/adapters/fingerprints.py`**, which reads the file's own structure and #: says which model it holds. `load_card` runs it, `ModelArtefact.effective_runtime` #: prefers what the bytes say over what the card claims, and every licence #: decision below keys off that. This frozenset and the table in #: `app/adapters/licences.py` both still run, because a layered refusal costs #: nothing and one of the three may be edited wrongly. #: #: The adapter stays in the tree for `evaluation/`, which is the evidence behind #: ADR 0017. It is unreachable from `discover()`, which is the only path that #: answers a farmer's request. DISALLOWED_RUNTIMES = frozenset({"ultralytics"}) @dataclass(frozen=True) class ModelArtefact: model_id: str version: str capability_key: str source: str license: str sha256: str validation_notes: str geography_validated: list[str] path: Path #: Which adapter reads this file, **as the card claims**. Optional on the #: card because governance cares about provenance and the runtime is an #: implementation detail — but a card without it cannot be run, only #: inspected. Read `effective_runtime` for the answer that is not a claim. runtime: str | None = None #: What the artefact's own bytes say it is. `None` when `load_card` was #: given no file to read, which happens only in tests. identity: fingerprints.Identification | None = None #: How an embedding backbone's input is prepared, **read off the card rather #: than hard-coded at the call site**. #: #: `backbones.preprocess` writes out torchvision's eval transform, and its #: own docstring says getting it wrong "does not raise — it quietly returns #: worse vectors". A resize or a normalisation constant that disagrees with #: the export produces vectors of the right width and the wrong meaning, #: which is the same silent failure `IdentityIndex` pins the backbone id to #: catch. So the numbers travel with the artefact they belong to. #: #: `None` for every artefact that is not an embedding backbone — YOLOX cards #: carry none of these — and `OnnxEmbeddingAdapter` keeps its own defaults for #: that case. `load()` shape-checks the result against the graph either way, #: so a card that disagrees with its own `.onnx` fails loudly. input_size: int | None = None image_mean: tuple[float, float, float] | None = None image_std: tuple[float, float, float] | None = None embedding_dimensions: int | None = None @property def observed_runtime(self) -> str | None: """The runtime the bytes identify, or `None` if they identify none.""" return self.identity.runtime if self.identity else None @property def refuted_by(self) -> list[str]: """Which properties of its own claimed runtime this file fails. Empty unless the card names a runtime that **has** a fingerprint of the kind the file was read as, and the bytes fail it. See `fingerprints.Identification.refutes` for why that case had to be split out of "unidentified". """ if self.identity is None: return [] return self.identity.refutes(self.runtime) @property def effective_runtime(self) -> str | None: """Which loader really runs, preferring evidence over assertion. **This is the field every licence decision should read.** The card's `runtime` is a claim by whoever wrote the card; the fingerprint is a property of the bytes. When they disagree the bytes win, because the bytes are what onnxruntime will load and what a farmer's result will come out of. Falls back to the card when nothing recognised the file. That is not a weakening — an unfingerprinted artefact's identity rested on its card before this existed and still does, and `identity_conflict` reports the difference between *"checked and agreed"* and *"not checked"*. **It does not fall back to a claim the bytes refute.** That fallback was the hole: `observed_runtime or self.runtime` treats *"no fingerprint matched"* as *"nothing is known"*, so an Ultralytics export with its initializers renamed inherited its card's `yolox-onnx` and every licence decision below was answered about YOLOX. `None` here is what makes `licences.gate` refuse it, under both policies, as an integrity failure rather than a licence one — which is the distinction that module's own docstring draws and the reason the founder's "drop nothing for a licence right now" instruction does not reach this case. """ if self.observed_runtime: return self.observed_runtime return None if self.refuted_by else self.runtime @property def identity_conflict(self) -> str: """Why the bytes and the card disagree, or an empty string. Empty covers two different states on purpose — agreement, and nothing to compare — because a caller is deciding whether to refuse and both answers are "do not refuse on this". `identity.detail` is where the difference is legible. There are two ways to disagree and they read differently. Either the bytes matched some *other* fingerprint, and the report can name what the file actually is; or nothing matched at all but the card's own claim was testable and failed, and the report can only say what the file is not. The second is still a conflict, and it is the one that was missing. **Naming what the file is comes first**, because it is the stronger statement and the one a licence can be attached to. Only when nothing matched does the report fall back to saying what was disproved. """ observed = self.observed_runtime if observed is not None and self.runtime and observed != self.runtime: declared_licence = licences.RUNTIME_LICENCES.get(self.runtime) real_licence = licences.RUNTIME_LICENCES.get(observed) return ( f"The card says runtime {self.runtime!r} " f"({declared_licence.licence if declared_licence else 'unrecorded'}), " f"but {self.path.name} is structurally {observed!r} " f"({real_licence.licence if real_licence else 'unrecorded'}). " f"{self.identity.detail if self.identity else ''} A sha256 proves " f"these are the reviewed bytes; it does not prove what the bytes " f"are, and this does." ) refuted = self.refuted_by if observed is None and refuted: declared_licence = licences.RUNTIME_LICENCES.get(self.runtime or "") return ( f"The card says runtime {self.runtime!r} " f"({declared_licence.licence if declared_licence else 'unrecorded'}), " f"and {self.path.name} fails {len(refuted)} of that runtime's " f"structural properties: {'; '.join(refuted)}. No other " f"fingerprint matched either, so what this file *is* is unknown " f"— but the card's claim about it has been disproved, and an " f"artefact nobody can identify may not be served under a licence " f"borrowed from a model it is not." ) return "" @property def is_copyleft(self) -> bool: """Whether the card's *declared* licence is one Animap may not serve. A self-declaration, and therefore not a control on its own — see `effective_runtime` for the answer that does not depend on the card telling the truth. """ return self.license in DISALLOWED_LICENSES @property def uses_disallowed_runtime(self) -> bool: """Keyed to the observed runtime, so editing the card does not clear it. This is the check ADR 0017 added and the one the original exploit was aimed at. Reading `effective_runtime` rather than `runtime` is what makes it hold: a card claiming `dinov3-onnx` over `yolo11m.pt` is `ultralytics` here, because the file's pickle names `ultralytics.nn.tasks.DetectionModel` and nothing else can load it. """ return (self.effective_runtime or "") in DISALLOWED_RUNTIMES @property def is_validated(self) -> bool: """Validation notes must say something. An empty string is the tell that a card was filled in to get past the loader rather than because anyone checked the model. """ return bool(self.validation_notes.strip()) def load_card(card_path: Path) -> ModelArtefact: card = json.loads(card_path.read_text()) missing = [f for f in REQUIRED_CARD_FIELDS if not card.get(f)] if missing: raise ArtefactError( f"{card_path.name} is missing required fields: {', '.join(missing)}. " f"An artefact that cannot account for itself is not usable." ) artefact_path = (card_path.parent / card.get("artefact", "")).resolve() # Cards may point sideways — several capabilities share one detector, and # storing the file twice would mean two things to keep in step. They may not # point *out*, because a card is a reviewed artefact reference and not a way # to load whatever is on the box. root = card_path.parent.parent.resolve() if not artefact_path.is_relative_to(root): raise ArtefactError( f"{card_path.name} names an artefact outside {root.name}/." ) if not artefact_path.is_file(): raise ArtefactError(f"{card_path.name} names an artefact that is not present.") digest = _digest(artefact_path) if digest != card["sha256"]: raise ArtefactError( f"{artefact_path.name} does not match the checksum on its card. " f"Expected {card['sha256'][:12]}…, found {digest[:12]}…" ) # Read *after* the checksum, because there is no point asking what a file is # when it is not the file that was reviewed. A structure this does not # recognise is recorded as unidentified and is not an error — see # `fingerprints.identify`. identity = fingerprints.identify(artefact_path) return ModelArtefact( model_id=card["model_id"], version=card["version"], capability_key=card["capability_key"], source=card["source"], license=card["license"], sha256=card["sha256"], validation_notes=card["validation_notes"], geography_validated=list(card["geography_validated"]), path=artefact_path, runtime=card.get("runtime"), identity=identity, input_size=card.get("input_size"), image_mean=_triple(card.get("image_mean")), image_std=_triple(card.get("image_std")), embedding_dimensions=card.get("embedding_dimensions"), ) def _triple(value) -> tuple[float, float, float] | None: """A three-channel normalisation constant, or `None`. Refuses a malformed one rather than passing it through, because a mean of the wrong length reaches `backbones.preprocess` as a numpy broadcast error at request time — out of a farmer's capture — instead of at start-up where the card is being read. """ if value is None: return None if not isinstance(value, (list, tuple)) or len(value) != 3: raise ArtefactError( f"An image_mean or image_std must be three numbers, one per " f"channel, and this card carries {value!r}." ) return tuple(float(v) for v in value) def _digest(path: Path) -> str: sha = hashlib.sha256() with path.open("rb") as handle: while chunk := handle.read(_DIGEST_CHUNK): sha.update(chunk) return sha.hexdigest() def _enforcing() -> bool: """Whether a licence problem stops a load right now. Every licence refusal in `discover()` routes through this, so the posture is one setting — `ANIMAP_LICENCE_POLICY`, defined in `app/adapters/licence_policy.py` — and not three independent checks that have to be found and changed separately. """ return licence_policy.current() is licence_policy.LicencePolicy.ENFORCE def _true_licence(artefact: ModelArtefact) -> str: """The licence to write in the ledger, which is not always the card's. When the bytes identify a runtime, the runtime table's licence is the fact and the card's `license` is a claim about it. Recording the claim would put `DINOv3 License` beside a runtime of `ultralytics`, which reads as a contradiction a person has to unpick rather than as a finding. """ real = licences.RUNTIME_LICENCES.get(artefact.effective_runtime or "") return real.licence if real else artefact.license def _record_identity(artefact: ModelArtefact, what: str) -> bool: """Handle a disagreement between an artefact's bytes and its card. Returns True when the load should stop. Under the default policy it never does — the founder's instruction is that nothing is dropped on a governance question right now — and what the caller gets instead is a ledger entry naming the licence the *bytes* arrive under. **That substitution is the point of the whole check.** Before it, a card editing `runtime` from `ultralytics` to `dinov3-onnx` produced a load that was recorded, if it was recorded at all, as a bespoke Meta licence. It is AGPL-3.0. A ledger that answers *"what did we ship that we should not have?"* with the wrong licence is worse than no ledger, because somebody will believe it. """ conflict = artefact.identity_conflict if not conflict: if artefact.identity is not None and not artefact.identity.identified: # Not a failure and not a pass. Logged at INFO because a listing of # every unfingerprinted model is a to-do list, not an incident. logger.info( "%s: %s", what, artefact.identity.detail, ) return False if artefact.observed_runtime is None and artefact.refuted_by: # **Stops the load under both identity policies, and that is not the # founder's "drop nothing" instruction being ignored.** `record` mode # exists so a governance exception stays findable afterwards, and its # whole value is that the entry it writes is true. Here nothing matched, # so there is no licence to name: the only honest record would say # "loaded something we cannot identify under terms we cannot name", # which is not an exception anyone can act on. Refusing costs one # capability and a log line that says exactly what to fix. logger.error("Refusing %s: %s", what, conflict) return True real = licences.RUNTIME_LICENCES.get(artefact.observed_runtime or "") refuse = ( licence_policy.identity_policy() is licence_policy.IdentityPolicy.REFUSE ) if refuse: logger.error("Refusing %s: %s", what, conflict) return True licence_policy.record_exception( what=what, # Recorded under what the file *is*, never under what it claimed. runtime=artefact.observed_runtime or "", licence=real.licence if real else "UNRECORDED", reason=conflict, source_url=real.source_url if real else "", ) return False def discover() -> dict[str, ModelArtefact]: """Every validated artefact currently present, keyed by capability. A card that fails its checks is logged and skipped rather than raised — one bad artefact must not take the whole service down, and the capability it belongs to simply stays unavailable, which is the honest outcome. **Every licence decision below reads `effective_runtime`**, which prefers what the bytes are over what the card says they are. That is the difference between this and every previous version of the gate. """ found: dict[str, ModelArtefact] = {} if not MODELS_DIR.is_dir(): return found for card_path in sorted(MODELS_DIR.glob("*/model_card.json")): try: artefact = load_card(card_path) except (ArtefactError, json.JSONDecodeError) as exc: logger.error("Ignoring %s: %s", card_path.parent.name, exc) continue if not artefact.is_validated: logger.error( "Ignoring %s: validation_notes is empty, so nothing attests that " "this model works.", card_path.parent.name, ) continue if _record_identity( artefact, f"{card_path.parent.name}/{artefact.model_id}" ): continue if artefact.is_copyleft: # Refused under the default policy, recorded under `record`. The # capability going unavailable is noticed; a log line on a Tuesday # is not (ADR 0017) — which is why `record` mode logs at ERROR and # keeps the fact rather than merely warning. message = ( f"{artefact.model_id} declares licence {artefact.license}, " f"which Animap may not serve from." ) if _enforcing(): logger.error( "Refusing %s: %s Move the capability to a permissively " "licensed artefact.", card_path.parent.name, message, ) continue licence_policy.record_exception( what=f"{card_path.parent.name}/{artefact.model_id}", runtime=artefact.effective_runtime or "", licence=artefact.license, reason=message, ) if artefact.uses_disallowed_runtime and _enforcing(): # The check the licence field cannot make, keyed to what the bytes # are. A card that declares Apache-2.0 over a path to an AGPL # checkpoint passes the test above; a card that also rewrites # `runtime` used to pass this one, and now does not, because # `uses_disallowed_runtime` reads `effective_runtime`. logger.error( "Refusing %s: runtime %r may not be served, whatever the card " "declares about the licence (ADR 0017). It exists for " "evaluation/ only.", card_path.parent.name, artefact.effective_runtime, ) continue if artefact.uses_disallowed_runtime: licence_policy.record_exception( what=f"{card_path.parent.name}/{artefact.model_id}", runtime=artefact.effective_runtime or "", licence=_true_licence(artefact), reason="Runtime is on the hard-coded refusal list (ADR 0017).", ) # The same idea, generalised and applied to every runtime rather than to # one name. `licences.gate` refuses three things the checks above do not: # a runtime nobody has recorded a licence for, a runtime whose real # licence is one Animap may not serve, and — the new one — a card whose # declared licence *disagrees* with what that runtime actually loads. # # It is handed `effective_runtime`, so on a mislabelled artefact it is # asked about the model that is really there. That also means the # card-versus-runtime disagreement it raises on becomes a three-way # check: the card's licence, the card's runtime, and the bytes. try: licences.gate( artefact.effective_runtime, artefact.license, what=f"{card_path.parent.name}/{artefact.model_id}", ) except licences.LicenceRefused as refusal: logger.error("Refusing %s: %s", card_path.parent.name, refusal) continue found[artefact.capability_key] = artefact return found class InferenceProvider: """Runs a capability, or explains why it cannot. `run` is deliberately absent from the base class. A provider with a default implementation would be a provider that can return something without a model, and that is the one thing this service must not do. """ def can_run(self, capability: Capability) -> bool: raise NotImplementedError class LocalArtefactProvider(InferenceProvider): """Serves capabilities backed by a validated artefact on disk. Two capabilities have one as of ADR 0018 — `cattle_detection` and `poultry_count`, both YOLOX-m. The other nineteen have none, and `can_run` returning False for them is the correct state rather than a gap. """ def __init__(self, artefacts: dict[str, ModelArtefact] | None = None): self.artefacts = artefacts if artefacts is not None else discover() def can_run(self, capability: Capability) -> bool: artefact = self.artefacts.get(capability.key) return artefact is not None and artefact.is_validated and capability.is_runnable def artefact_for(self, capability: Capability) -> ModelArtefact | None: return self.artefacts.get(capability.key) class HostedModelProvider(InferenceProvider): """Serves capabilities whose model is somebody else's, reached over HTTP. ## Why this is a provider and not a special case in `LocalArtefactProvider` `can_run` there asks one question — is there a validated file on disk — and for these sixteen the answer is permanently no, and correctly so. There is no artefact to checksum because there are no bytes: directive §4 asks for a hosted visual reasoner precisely where no public dataset exists to train one. Bending the local provider to answer True without a file would make `is_validated` mean two different things depending on the capability, and that check is the thing standing between a card somebody filled in and a model somebody validated. So the two providers answer for disjoint sets and `main` asks both. ## The artefact this returns, and why it names the live model `InferenceResult` requires `model_id` and `model_version`, and the API refuses a result whose pair does not equal the run's — *"register a release for the artefact that actually ran"*. A hosted model has no sha256, so what identifies it is its name, and that is read **at call time** from `ANIMAP_MULTIMODAL_MODEL` rather than frozen at import. That has a consequence worth stating plainly: **rotating the model refuses every queued run of these capabilities until a release is registered for the new one.** That is the correct behaviour and not a hazard. A different model is different evidence, `evidence_correction` is a training set built on knowing which model said what, and a silent swap would put two models' answers in one column. Re-registering is a management command — neither a rebuild nor a redeploy — so a rotation still costs a restart and a `register_capability_release`, exactly as `multimodal.py` intends. `capability.model_provider` stays the stable string `hosted-multimodal`. It is what makes `is_runnable` true and what the Android app copies out of `capabilities.json`, and it must not change when a model does — a device would otherwise need a rebuild to keep a capability visible. """ #: What `sha256` says when there are no bytes to hash. #: #: Not an empty string and not a fake digest. A reader of a stored result #: asking "which file was this" gets an answer that says there was no file, #: rather than a blank that reads as a missing field or sixty-four zeroes #: that read as a checksum. NO_LOCAL_BYTES = "hosted:no-local-artefact" def __init__(self, keys: frozenset[str] | None = None): # Imported here rather than at module scope: `reasoning` imports # `providers` for `ModelArtefact`, and the pair would not load. if keys is None: from app.reasoning import RUBRICS keys = frozenset(RUBRICS) self.keys = keys def _configured_model(self) -> str | None: provider = os.environ.get("ANIMAP_MULTIMODAL_PROVIDER", "").strip() model = os.environ.get("ANIMAP_MULTIMODAL_MODEL", "").strip() if not provider or not model: return None return f"{provider}/{model}" def can_run(self, capability: Capability) -> bool: """True when this capability is hosted, declared runnable, and reachable. The third clause is the one that keeps `/health` honest. Without a key the adapter is not ready and every one of these answers `unavailable` with a reason, which is a deployment that says what it is missing rather than one that fails sixteen jobs to find out. """ from app.adapters.multimodal import HostedMultimodalAdapter from app.adapters.transports import transport_from_env from app.reasoning import HOSTED_PROVIDER if capability.key not in self.keys: return False if capability.model_provider != HOSTED_PROVIDER: return False if not capability.is_runnable: return False if self._configured_model() is None: return False # Built the way `adapters/registry.py` builds it, so `/health`'s # listing and this answer cannot disagree about whether the vendor is # reachable. A bare adapter has no transport and is never ready. return HostedMultimodalAdapter( transport=transport_from_env() ).availability().ready def artefact_for(self, capability: Capability) -> ModelArtefact | None: model = self._configured_model() if capability.key not in self.keys or model is None: return None return ModelArtefact( model_id=model, # The rubric's version, not the vendor's. A materially changed # question is different evidence from the same model, and this is # what a release row records it as. Bumped by hand in `reasoning`. version="1", capability_key=capability.key, source="hosted", license="vendor-terms", sha256=self.NO_LOCAL_BYTES, validation_notes=( "No local artefact and no measurement. This capability is " "answered by a hosted visual reasoner, is `experimental`, and " "requires human confirmation — see app/reasoning.py." ), geography_validated=[UNVALIDATED_GEOGRAPHY], path=Path("hosted"), runtime="http", ) class CompositeProvider(InferenceProvider): """Every provider, asked in order, first answer wins. The two sets are disjoint — one is capabilities with a file, the other is capabilities that can never have one — so "first answer wins" resolves nothing today and is a rule rather than a tiebreak. `_OVERLAP` in `main` refuses a collision on the runner side for the same reason. """ def __init__(self, *providers: InferenceProvider): self.providers = providers @property def artefacts(self) -> dict[str, ModelArtefact]: """Only the ones on disk. `/health` reports this as `artefacts_loaded`, and a hosted model is not an artefact anybody loaded.""" merged: dict[str, ModelArtefact] = {} for provider in self.providers: merged.update(getattr(provider, "artefacts", {})) return merged def can_run(self, capability: Capability) -> bool: return any(p.can_run(capability) for p in self.providers) def artefact_for(self, capability: Capability) -> ModelArtefact | None: for provider in self.providers: if provider.can_run(capability): return provider.artefact_for(capability) for provider in self.providers: found = provider.artefact_for(capability) if found is not None: return found return None