File size: 25,597 Bytes
4b98524
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
"""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."
    )