diff --git "a/docs/architecture/03-request-lifecycle.md" "b/docs/architecture/03-request-lifecycle.md" new file mode 100644--- /dev/null +++ "b/docs/architecture/03-request-lifecycle.md" @@ -0,0 +1,2586 @@ +# 03 — Request Lifecycle + +**Parent:** [Architecture hub](../ARCHITECTURE.md) · **Previous:** [02 Deployment topology](02-deployment-topology.md) · +**Next:** [04 Router](04-router.md) + +**Status tags used in this document:** `IMPLEMENTED` · `VERIFIED` · `MEASURED` · `ATTEMPTED` · +`NOT RUN` · `BLOCKED` · `DEFERRED` · `REJECTED` · `OPEN` · `RESOLVED` · `CLOSED`. + +> **One-paragraph summary.** One analysis travels through a **nine-state controller** +> (`core/controller.py`) whose authority is deliberately narrow: it may **refuse** a plan that fails its own +> preconditions, and otherwise it **executes the plan it is given and assembles the result**. It does not +> decide what runs — that is `core/planner.py` alone — and it does not compute what a specialist computes. +> The path is `AnalysisRequest → router.route() → PolicyPlanner.plan() → dispatch → ResultEnvelope` +> (`core/controller.py:10`). Two properties dominate the design and explain almost every line of the file: +> **partial failure never erases evidence** (a failed step becomes a typed `STATISTIC` record and the run +> continues — there is no early exit on step failure), and **confidence is never averaged** (the run's +> confidence is the *primary step's* score, calibrated; a run whose primary specialist failed has **no** +> confidence rather than a laundered one). Three honest gaps are recorded in this document and none are +> papered over: the controller emits **eight** trace states while the config declares **nine** +> (`PREPROCESS` is never recorded); the deployed serving composition **attaches no router**, so +> `force_task` is de facto required on the live path; and the tiling / percentile / dB-clip policy that +> `configs/base.yaml` declares is **read by no code on the serving path**. + +--- + +## Part A — The controller and the nine states + +### 1. Where this subsystem sits + +#### 1.1 The five verbs + +`docs/ARCHITECTURE_FREEZE.md` §5 assigns each layer exactly one verb, and the controller is written to obey +that allocation rather than to be convenient: + +| Layer | Verb | Module | +|---|---|---| +| Router | **understands** | `router/classifier.py` | +| Policy engine | **decides** | `core/planner.py` | +| Specialists | **compute** | `specialists/**` | +| VLM | **explains** | `specialists/vqa/inference.py` | +| Evidence engine | **proves** | `evidence/engine.py` | + +The controller is the **dispatcher and assembler** that sits between them. `core/controller.py:5-14` states +its own authority verbatim: + +> `docs/ARCHITECTURE_FREEZE.md` section 5 gives the control tier two verbs that live here: the controller +> *dispatches* and *assembles*. It does not decide which specialists run — that is `core.planner`'s job +> alone — and it does not compute anything a specialist computes. + +#### 1.2 What the controller is, precisely + +`AnalysisController` (`core/controller.py:171`) is the only class in the control tier. Its constructor is: + +```python +def __init__( + self, + *, + registry: SpecialistRegistry, + planner: PolicyPlanner, + router: Any | None = None, + evidence: Any | None = None, + config: Any | None = None, + budget_seconds: float | None = None, +) -> None +``` + +Four constructor behaviours are load-bearing and each is documented in the source: + +1. **`evidence` is built here, and the calibration artifact is loaded here.** When `evidence` is omitted the + controller constructs `EvidenceEngine.from_config(config, calibration=load_calibration(config))` + (`core/controller.py:217-221`). Before 2026-09-22 this call passed **no** `calibration=`, so + `EvidenceEngine` fell back to its `None` default and every confidence the system emitted said + `method="uncalibrated"` even once a fitted artifact existed. The comment at `core/controller.py:203-216` + calls that "the single section 67 immutable-decision violation (`calibrated confidence`)" and records it + as **a wiring gap, not a missing capability**. +2. **`load_calibration` returns `None`, never a fabricated default** — so an artifact-less deployment still + degrades to the honest pass-through rather than failing to construct. +3. **`budget_seconds` defaults to `config.get("agent.timeout_seconds", DEFAULT_BUDGET_SECONDS)`**, where + `DEFAULT_BUDGET_SECONDS: float = 120.0` (`core/controller.py:122`) and + `configs/base.yaml:240` sets `agent.timeout_seconds: 120`. +4. **`specialists.base.SpecialistRequest` is imported lazily inside `_execute_one`, NOT at module scope.** + The reason is a real import cycle, spelled out at `core/controller.py:106-115`: + `specialists.base → core.errors → core/__init__ → core.controller → specialists.base (partially + initialised) → ImportError`. + +#### 1.3 The one public entry point + +```python +def run( + self, + request: AnalysisRequest, + *, + assets: Sequence[AssetMetadata] | None = None, + asset_modalities: Mapping[str, str] | None = None, +) -> ResultEnvelope +``` + +Its contract, verbatim from `core/controller.py:254-256`: + +> Never raises for a specialist-level failure: those are recorded in the result, the trace and the evidence. +> Raises only for a caller error the schema cannot catch. + +`health()` (`core/controller.py:396`) is the other public method and is **retired as a public API path** +(H-1/H-2, owner ruling 2026-09-22). It is retained only as an operator diagnostic. Its docstring is worth +quoting because it is the clearest statement of the health-shape defect in the codebase: + +> 1. **The shape is not the contract's.** … `GET /v1/health` [is] fixed to `HealthStatus` +> (`core/schemas.py:392`), which is `extra="forbid"` with the fields `status, schema_version, models, +> device, gpu_available`. This method returns `registry, degraded, unavailable, device` — so +> constructing a `HealthStatus` from its output raises `extra_forbidden` on three keys and reports +> `status`/`gpu_available` missing. That is finding H-1 … +> 2. **The vocabulary is the registry's, not the contract's.** … + +The replacement is `app.deployment.deployment_report()`, which **constructs nothing** — "requirement 4 of +`docs/DEPLOYMENT_ARCHITECTURE.md` section 3.3 forbids loading a model for a metadata request. Calling this +from `/v1/health` would spend the 5 GPU-minute daily budget on health probes." + +### 2. The nine states — declared versus emitted + +This is the single most important honesty point in this document, so it is stated first and precisely. + +#### 2.1 The enum has nine members + +`ControllerState` (`core/schemas.py:79-88`) — exact spellings, all upper-case: + +| # | Member | Value | +|---|---|---| +| 1 | `RECEIVE` | `"RECEIVE"` | +| 2 | `PARSE` | `"PARSE"` | +| 3 | `VALIDATE` | `"VALIDATE"` | +| 4 | `PLAN` | `"PLAN"` | +| 5 | `PREPROCESS` | `"PREPROCESS"` | +| 6 | `EXECUTE` | `"EXECUTE"` | +| 7 | `AGGREGATE` | `"AGGREGATE"` | +| 8 | `VERIFY` | `"VERIFY"` | +| 9 | `RESPOND` | `"RESPOND"` | + +`configs/base.yaml:242-251` repeats the same nine names under `agent.states`, and the frontend mirrors them +again in `frontend/assets/js/mission.js:361`: + +```javascript +var STATES = ['RECEIVE', 'PARSE', 'VALIDATE', 'PLAN', 'PREPROCESS', 'EXECUTE', 'AGGREGATE', 'VERIFY', 'RESPOND']; +``` + +So **three** places declare nine states: the schema, the frozen config, and the frontend. + +#### 2.2 The controller records eight + +`AnalysisController.run()` calls `self._record(trace, ControllerState.X, …)` for exactly **eight** of the +nine. The full set of `ControllerState` writes in `core/controller.py` is: + +| Line | State | Context | +|---|---|---| +| `:279` | `RECEIVE` | `{"asset_count": len(request.assets)}` | +| `:302` | `PARSE` | `{"inputs": […], "modalities": […], "query_length": …}` | +| `:316` | `VALIDATE` | `{"assets": …, "force_task": …, "router": …}` | +| `:342` | `PLAN` | `{"plan": plan.to_trace()}` | +| `:569` | `EXECUTE` | budget-skip outcome | +| `:575` | `EXECUTE` | per-step outcome | +| `:371` | `AGGREGATE` | `{"evidence": …, "degraded": …}` | +| `:781` | `AGGREGATE` | refusal path: `{"refused": True, "refusal": …}` | +| `:385` | `VERIFY` | `{"contradiction": …}` | +| `:784` | `VERIFY` | refusal path: `{"steps": 0}` | +| `:391` | `RESPOND` | `{"answer_length": …}` | +| `:787` | `RESPOND` | refusal path: `{"refused": True}` | + +**`PREPROCESS` appears nowhere in `core/controller.py`.** A repository-wide search for `PREPROCESS` +returns: `configs/base.yaml:247`, `core/schemas.py:84`, three frontend files, and training-script +identifiers (`PREPROCESSING_VERSION` in `training/change_vqa/dataset.py`) that are unrelated. There is no +`_record(trace, ControllerState.PREPROCESS, …)` anywhere. + +#### 2.3 A test pins the eight, and that is deliberate + +`tests/unit/test_controller.py:379-395` asserts the observed sequence: + +```python +def test_run_records_controller_states_in_order() -> None: + controller = _controller({"vqa": _stub_class("vqa")()}, task=Task.VQA) + envelope = controller.run(_request(1)) + states = [s.state for s in envelope.trace.steps] + assert states[0] is ControllerState.RECEIVE + assert states[-1] is ControllerState.RESPOND + for required in ( + ControllerState.RECEIVE, + ControllerState.PARSE, + ControllerState.VALIDATE, + ControllerState.PLAN, + ControllerState.EXECUTE, + ControllerState.AGGREGATE, + ControllerState.VERIFY, + ControllerState.RESPOND, + ): + assert required in states +``` + +Note the list: **eight** members. `PREPROCESS` is not in it, and the test does not fail on its absence. + +#### 2.4 Why `PREPROCESS` exists but is unrecorded — stated honestly + +Three things are true at once and none of them is a contradiction: + +1. The **policy** names a `PREPROCESS` phase: `configs/base.yaml:242` lists it among `agent.states`, and the + frontend's stage model expects it (`frontend/assets/js/mission.js:364` maps `SPECIALIST_STARTED → + 'PREPROCESS'`, and `:371` labels it `'awaiting backend'`). +2. **No code reads `agent.states`.** A repository-wide search for `agent.states` in Python returns + nothing. The list is declared and never consumed, so it cannot drive the controller. +3. **The controller's actual preprocessing happens inside the specialist**, not in a controller state: + `_execute_one` constructs a `SpecialistRequest`, calls `specialist.validate_request(...)`, then + `specialist.execute(...)` (`core/controller.py:621-630`). Anything that could be called "preprocessing" + — raster reading, percentile stretch, band mapping — happens inside `execute`, i.e. inside the `EXECUTE` + state, and is recorded there as one `EXECUTE` trace step per plan step. + +So the honest statement is: **`PREPROCESS` is a declared state with no emitter.** The frontend must render it +from its own client-side model, not from the backend trace. `frontend/assets/js/mission.js:371` already +labels it `'awaiting backend'`, which is consistent with this. Status: `OPEN` (a schema/config/frontend +declaration that the controller does not honour). + +```mermaid +flowchart TB + subgraph Declared["Declared: 9 states (core/schemas.py:79, configs/base.yaml:242, mission.js:361)"] + R1[RECEIVE] --> P1[PARSE] --> V1[VALIDATE] --> PL1[PLAN] --> PP1[PREPROCESS] --> E1[EXECUTE] --> A1[AGGREGATE] --> VE1[VERIFY] --> RS1[RESPOND] + end + subgraph Emitted["Emitted: 8 trace steps (core/controller.py)"] + R2[RECEIVE :279] --> P2[PARSE :302] --> V2[VALIDATE :316] --> PL2[PLAN :342] --> E2["EXECUTE :569/:575 (one per step)"] --> A2["AGGREGATE :371 / :781"] --> VE2["VERIFY :385 / :784"] --> RS2["RESPOND :391 / :787"] + end + PP1 -.->|"NO EMITTER
OPEN"| E2 +``` + +### 3. State-by-state reference + +For each state: what it does, what can fail, and exactly what it emits. + +#### 3.1 `RECEIVE` — `core/controller.py:258-279` + +**Does.** Allocates the run. `started = time.perf_counter()`; `run_id = request.run_id or self._new_run_id()` +where `_new_run_id()` returns `f"run_{uuid.uuid4().hex[:12]}"` (`core/controller.py:1211-1214`). Constructs +the `ExecutionTrace` with `run_id`, `query=request.query`, `inputs=[_asset_label(a) for a in request.assets]` +and `config_hash=self.config.hash if self.config is not None else None`. + +**The `inputs` field is a real disclosure fix, not a cosmetic one.** `core/controller.py:263-275` records +that `ExecutionTrace` is returned verbatim by `POST /v1/analyze`, and it previously echoed `request.assets` +*after* the caller had turned asset **handles** into filesystem **paths** — so the response named the +server-side location of every uploaded byte. `_asset_label` (`core/controller.py:1295-1323`) reduces each +entry to `Path(asset).name`, and it is deliberately the **same call** `_resolve_assets` makes when looking a +modality override up by basename, "so the label and the lookup cannot disagree about what an asset is +called." Recorded as **F-13**. + +**Fails.** Nothing. This is a pure allocation. + +**Emits.** +```json +{"asset_count": 2} +``` + +#### 3.2 `PARSE` — `core/controller.py:281-308` + +**Does.** Resolves the assets into `AssetMetadata`. If the caller passed `assets=` they are used verbatim +("A caller that has already validated rasters should pass them rather than make the controller inspect the +same files twice"); otherwise `_resolve_assets(request, asset_modalities=…)` inspects each one. Then sets +`trace.modalities = self._modalities(resolved)` — *distinct* modalities in first-seen order +(`core/controller.py:524-531`). + +**The inspection is header-only by design.** `_resolve_assets` delegates to +`preprocessing.raster.inspect_raster`, "which opens the raster header and reports band count, dtype, CRS, +transform, bounds and resolution. It does NOT decode pixel data — a 12-band Sentinel-2 tile is ~100 MB and a +four-specialist plan would decode it repeatedly" (`core/controller.py:486-489`). + +**The docstring records a real historical defect.** An earlier version built `AssetMetadata(path=path)`, +leaving `geo` and `modality` at their defaults, so every asset reached every specialist with +`band_count=None` and `modality=UNKNOWN`. The visible consequence, quoted at `core/controller.py:497-500`: + +> a valid 4-band optical + 2-band SAR pair was rejected by `OpticalSarSpecialist.validate_request` with +> "could not identify one optical and one SAR asset from modalities ['unknown', 'unknown']" — a +> user-visible refusal of a perfectly good request. + +**Fails.** A missing or unreadable asset raises `RasterReadError` (typed), which the controller surfaces +with a user message. `core/controller.py:503-506` is explicit that it does **not** degrade to an empty +description, "because 'we could not read this' and 'this had nothing to report' must stay distinguishable." + +**Also emits the F-14 fix.** The PARSE detail writes `[_asset_label(a) for a in request.assets]`, not +`list(request.assets)`. `core/controller.py:287-299` records the second instance of the same disclosure with +a **measured** example: "the client sent the handle `asset_d243f7f85d8c2f3c02981f0af9737f01` and received +back `C:\Users\anish\sq_scratch\...\assets\asset_d243...7f01.tif`." + +**Emits.** +```json +{ + "inputs": ["a.tif", "b.tif"], + "modalities": ["optical"], + "query_length": 47 +} +``` + +#### 3.3 `VALIDATE` — `core/controller.py:310-324` + +**Does.** Routes. `prediction = self._route(request)`; `trace.intent = prediction.intent if prediction is not +None else None`; `trace.task = prediction.intent.task if prediction is not None else request.force_task`. + +**What routing does, and what it does not.** `_route` (`core/controller.py:458-476`) has two branches: + +- **`force_task` is set** → the router is **not consulted at all**. It constructs + `Intent(task=request.force_task, confidence=1.0, source="forced")` and wraps it in + `RouterPrediction(intent=intent, above_threshold=True)`. The fact that the router's opinion was bypassed is + therefore *visible in the trace* (`source="forced"`). +- **`force_task` is `None`** → `if self.router is None: return None`, else + `return self.router.route(request.query)`. + +**`return None` is a hard stop downstream.** `core/controller.py:327-334`: + +```python +if prediction is None: + # No router and no forced task: nothing can be planned. This is a + # caller error, not a query the system declined. + raise UnsupportedQueryError( + "no router configured and no force_task supplied; there is no " + "way to choose a specialist", + recoverable=False, + ) +``` + +**This branch is reachable on the live deployment — see §38.** It is a `raise`, not a recorded failure, and +it is the only `raise` in the happy path. + +**Emits.** +```json +{ + "assets": 2, + "force_task": "change_vqa", + "router": {"task": "change_vqa", "modality": "unknown", "temporal": true, + "spatial_output": false, "language_output": true, + "confidence": 1.0, "source": "forced", "above_threshold": true, + "used_fallback": false, "fallback_rule": null} +} +``` + +(The `router` value is `RouterPrediction.to_trace()`, `router/classifier.py:93-106`. When routing is forced it +carries the synthesized prediction, so `source` reads `"forced"`.) + +#### 3.4 `PLAN` — `core/controller.py:326-345` + +**Does.** `plan = self.planner.plan(prediction, request, assets=resolved)`. Then populates +`trace.parameters` with three things: + +```python +trace.parameters = { + **plan.to_trace(), + "budget_seconds": self.budget_seconds, + "registry": self.registry.describe(), +} +``` + +**Fails.** Nothing here — the planner expresses refusal *in the plan*, never by raising +(`core/planner.py:75-81`: "Refusing is not an error. It returns a valid plan with `refused=True` and a typed +`PlanRefusal`"). If `plan.refused` is true the controller takes the early-return refusal path (§4) and +**never reaches EXECUTE**. + +**F-19 is visible in this state's neighbourhood.** `core/controller.py:350-365` records that +`registry.describe()` reports the registry's **memoised** entries, so the PLAN-time snapshot reports the +*previous* request's builds while `trace.selected_models` (populated inside `_execute`) reports *this* one. +Measured in pass 13 with two identical payloads on one controller: "request #1 body `built == []`, request #2 +body `built == ['optical_sar']`". The fix re-snapshots **after** execution +(`trace.parameters["registry"] = self.registry.describe()`, `core/controller.py:365`) rather than moving the +PLAN-time assignment, "so the PLAN record's own context stays intact and leaves the early-return refusal path +with a snapshot that is still correct — nothing is built on a refusal." + +**Emits.** +```json +{"plan": {"steps": [...], "mode": "sequential", "refused": false, "refusal": null, + "uncertain": false, "router_source": "forced", + "effective_confidence": 1.0, "notes": []}} +``` + +#### 3.5 `PREPROCESS` — **declared, never emitted** + +See §2.4. Recorded here as a state so the reference is complete, with its status stated: + +| Aspect | Value | +|---|---| +| Declared in | `core/schemas.py:84`, `configs/base.yaml:247`, `frontend/assets/js/mission.js:361` | +| Emitted by | **nothing** — no `_record(trace, ControllerState.PREPROCESS, …)` exists | +| Read from config by | **nothing** — `agent.states` is consumed by no Python code | +| Where the work actually happens | inside `EXECUTE`, inside `specialist.execute(...)` | +| Status | `OPEN` | + +#### 3.6 `EXECUTE` — `core/controller.py:347-348`, `:535-709` + +**Does.** `outcomes = self._execute(trace, plan, resolved, request, started)`. First sets +`trace.workflow = [step.step_id for step in plan.steps]` (`core/controller.py:545`) — the ordered list of step +ids, which is what makes "step_001 produced the grounding box" a reproducible claim. + +Then **for each step**, in plan order: + +1. **Budget check, between steps** (`core/controller.py:548-571`). `elapsed = time.perf_counter() - started`; + if `elapsed > self.budget_seconds` the step is **skipped**, not failed: + `StepOutcome(step=step, skipped_reason=f"budget_exceeded:{elapsed:.1f}s")`. A trace error is appended with + `"code": SpecialistTimeoutError.code` and `"message": f"skipped: run budget of {self.budget_seconds}s + exceeded"`. The comment is an explicit design admission: + + > Budget is checked BETWEEN steps (§5.2). A true per-step timeout needs a worker process or a signal + > handler, both of which conflict with the single-process monolith constraint more than they benefit. The + > honest position: v1 bounds total wall clock and records what it skipped. + +2. **Run the step** (`_execute_one`, `core/controller.py:599-709`): + - `entry = self.registry.build(step.capability)` — memoised construction. + - **If `entry.specialist is None`** the step is a recorded failure: + `error=self._unavailable_error(step, entry)`, which returns a `ModelUnavailableError` whose detail is + `f"{step.capability} was planned but could not be constructed"`, suffixed with `entry.detail` when + present (`core/controller.py:711-719`). + - Otherwise builds `SpecialistRequest(assets=list(assets), query=request.query, + params=dict(step.params), run_id=trace.run_id)` and calls `specialist.validate_request(...)` **then** + `specialist.execute(...)` (`core/controller.py:621-630`). + - `except SatQueryError as exc:` → recorded as the outcome's error, unchanged. + - `except Exception as exc:` → **wrapped**, never propagated. The exception's own text is *server-side + only*: it is logged (`_log.error(..., exc_info=exc)`) and preserved in `context`, while the client + receives a fixed message. `core/controller.py:687-700`: + ```python + error=SpecialistError( + "unhandled error", + user_message=( + "The step failed with an unhandled error. Internal " + "detail is withheld; see the server-side diagnostics." + ), + specialist=step.capability, + context={ + "step_id": step.step_id, + "code": "unhandled", + "exception_type": type(exc).__name__, + "exception_message": str(exc), + }, + ) + ``` + The **classification survives**: "the client still learns that this was an UNHANDLED failure rather than + a known typed error. Losing that would hide the difference between 'we predicted this failure mode' and + 'we did not', which is exactly the distinction `code: unhandled` exists to record" + (`core/controller.py:644-648`). + +3. **Record the outcome** — one `EXECUTE` trace step per plan step, via `outcome.to_trace()` + (`core/controller.py:154-165`): + ```json + {"step_id": "step_001", "capability": "change_vqa", "ok": true, + "skipped_reason": null, "error_code": null, + "duration_ms": 8421.377, "registry_state": "available"} + ``` + And `trace.timings[step.step_id] = round(outcome.duration_ms, 3)`. + +4. **Record a client-facing error when the step failed** (`core/controller.py:578-594`): + ```python + trace.errors.append({ + "step_id": step.step_id, + "capability": step.capability, + "code": outcome.error.code, + "message": outcome.error.user_message or "The step failed.", + "recoverable": outcome.error.recoverable, + }) + ``` + The comment records F-15: "the trace is CLIENT-FACING, so it carries the operator-safe message only. It + previously preferred `error.detail` — the technical text, which can name filesystem paths and library + internals — over `user_message`." + +**There is no early exit.** The loop body has no `break` and no `return`; the only `continue` is the +budget-skip. `core/controller.py:16-20` states the rule and its reason: + +> A failed step never removes itself from the result. It becomes a recorded failure with a typed code, and +> the run continues. Nothing aborts the run; there is no early exit on step failure. + +**After the loop.** `trace.selected_models = self._selected_models(outcomes)` (`core/controller.py:596`, +`:721-749`). That walks each outcome's *constructed* specialist and calls `specialist.model_refs()`, deduping +on `f"{name}:{role}"` and building explicit `ModelRef` objects rather than relying on pydantic coercion. + +**Fails.** Any `SatQueryError` from `validate_request` or `execute`; any unhandled `Exception` (wrapped); a +`ModelUnavailableError` when construction fails. **None of these aborts the run.** + +**Emits.** One `EXECUTE` step per plan step (or per skipped step), plus `trace.workflow`, `trace.timings`, +`trace.errors[]` entries, and `trace.selected_models`. + +#### 3.7 `AGGREGATE` — `core/controller.py:367-380`, `_aggregate` `:793-834` + +**Does.** `result = self._aggregate(trace, plan, outcomes, prediction, request)`. + +`_aggregate` merges N step outcomes into one `SpecialistResult`: + +```python +successful = [o for o in outcomes if o.ok] +results = [o.result for o in successful if o.result is not None] + +failure_evidence = [self._failure_evidence(o) for o in outcomes if not o.ok] + +collection = self.evidence.aggregate(results) if results else None +items: list[Evidence] = list(collection.items) if collection else [] +items.extend(self._synthesis_evidence(results, request)) +items.extend(failure_evidence) +items = self._renumber(items) +``` + +Three evidence sources are therefore concatenated in a fixed order: + +| Source | Producer | When present | +|---|---|---| +| Aggregated specialist evidence | `EvidenceEngine.aggregate(results)` | whenever any step succeeded | +| Synthesis record | `_synthesis_evidence` | only when **≥ 2** results | +| Failure records | `_failure_evidence` | one per non-ok outcome | + +**The failure record is the "absence must be distinguishable from a non-event" rule applied at the control +layer** (`core/controller.py:22-29`): + +> If a specialist fails and simply does not contribute evidence, a consumer cannot tell whether it ran and +> found nothing, never ran, or crashed. So a failed step adds one `STATISTIC` evidence item carrying its typed +> code. No schema change is needed: `STATISTIC` is already in the frozen `EvidenceType` vocabulary. + +`_failure_evidence` (`core/controller.py:865-887`) produces exactly: +```python +Evidence( + type=EvidenceType.STATISTIC, + source_specialist=outcome.step.capability, + score=0.0, + payload={ + "step_failed": True, + "step_id": outcome.step.step_id, + "code": code, + "message": message, + "skipped_reason": outcome.skipped_reason, + }, +) +``` +`score=0.0` is deliberate and is not a confidence claim: it is a "strength" slot filled with the only honest +value for "this produced nothing." + +`_synthesis_evidence` (`core/controller.py:889-916`) returns `[]` when `len(results) < 2`; otherwise one item +attributed to `"controller"`: +```python +Evidence( + type=EvidenceType.STATISTIC, + source_specialist="controller", + score=None, + payload={"synthesis": True, "contributors": capabilities, "query": request.query}, +) +``` +Its docstring names the two facts it exists to make visible: "that several specialists were merged into one +answer, and which query they were answering. Neither belongs to any single specialist, so neither can be +attributed to one." + +`_renumber` (`core/controller.py:918-929`) rewrites **every** id to `evidence_{i:03d}` starting at 1, because +"the engine's ids stop being authoritative once controller-authored items are appended." + +The state's `detail` re-runs aggregation purely to produce a summary — the aggregate call is cheap and pure: + +```python +{"evidence": {"returned": 9, "total_before_limit": 9, "dropped_duplicates": 0, + "dropped_over_limit": 0, "truncated": false, + "sources": ["change_vqa"], "types": ["change_map", "statistic"]}, + "degraded": false} +``` + +**Fails.** Nothing directly. A malformed specialist result would already have failed inside `EXECUTE`. + +**Emits.** The `AGGREGATE` trace step, and the assembled `SpecialistResult` (fields enumerated in §14). + +#### 3.8 `VERIFY` — `core/controller.py:382-385`, `_verify` `:1120-1147` + +**Does two things, both real checks.** + +**(a) The `sources` assertion.** `core/controller.py:31-41` explains why this is checkable rather than merely +intended: + +> `EvidenceEngine.aggregate` records `sources` BEFORE applying its item cap, so a specialist whose evidence was +> capped away still appears. That makes "no specialist ran and left no trace" *checkable* rather than merely +> intended: +> +> ``` +> set(collection.sources) == {capability for each successful step} +> ``` +> +> A mismatch means a specialist ran and left nothing behind — the exact failure this design exists to +> prevent. It is enforced in the `VERIFY` state as a real assertion, not a comment. + +The implementation (`core/controller.py:1124-1141`): +```python +successful = [o for o in outcomes if o.ok and o.result is not None] +if not successful: + return +collected = self.evidence.aggregate([o.result for o in successful]) +expected = {o.capability for o in successful} +observed = set(collected.sources) +missing = expected - observed +if missing: + trace.errors.append({ + "step_id": "*", + "code": "evidence_loss", + "message": f"specialists produced no evidence: {sorted(missing)}", + }) +``` + +**(b) A fallback note for an evidence-less result** (`core/controller.py:1143-1147`): for each successful +outcome whose result carries no evidence items, `trace.fallbacks.append(f"{outcome.capability} produced a +result with no evidence items")`. + +**Then contradiction detection** runs (`self._detect_contradiction(...)`) and the state's `detail` records the +flag: +```json +{"contradiction": false} +``` + +**Fails.** Nothing. It records. + +**Emits.** The `VERIFY` trace step; possibly `trace.errors[]` with `code: "evidence_loss"`; possibly +`trace.fallbacks[]`. + +#### 3.9 `RESPOND` — `core/controller.py:387-394` + +**Does.** Final assembly: +```python +result.execution_trace = trace +trace.outputs = [e.evidence_id for e in result.evidence] +trace.confidence = result.confidence +self._record(trace, ControllerState.RESPOND, {"answer_length": len(result.answer)}) +trace.finished_at = self._now() +return ResultEnvelope(run_id=run_id, result=result, trace=trace) +``` + +Three things worth naming: + +- **`result.execution_trace = trace`** means the trace is reachable **twice** in the serialized envelope — + once as `envelope.trace` and once as `envelope.result.execution_trace`. They are the same object. The + registry docstring at `core/registry.py:570-574` measured the consequence of that duplication for the F-15 + disclosure: a single construction failure "put the vendor directory path into **four** client-visible + fields — `result.warnings[]`, `result.evidence[].payload["message"]`, + `trace.parameters.registry.built[].detail`, and the same registry block again inside + `result.execution_trace` (the trace is the same object, serialized twice)." +- **`trace.outputs`** is a list of *evidence ids*, not a count and not the answer text. It is the citation + list a consumer follows to inspect what supported the answer. +- **`trace.finished_at`** is the last write. `TraceStep.started_at` defaults to `_utcnow()` at construction, + so per-state timings are *start* stamps; per-step durations live in `trace.timings`. + +**Fails.** Nothing. + +**Emits.** `{"answer_length": 218}`, plus the completed `ResultEnvelope`. + +### 4. The refusal path — `_finish_refusal` (`core/controller.py:753-789`) + +A refusal is **a successful run with no steps**. The docstring is one line: "A refusal is a successful run +with no steps, not an error." + +```python +result = SpecialistResult( + task=trace.task or Task.UNSUPPORTED, + answer=message, + confidence=ConfidenceBreakdown( + raw=0.0, + calibrated=None, + method="uncalibrated", + degraded=True, + degradation_reason=f"refused:{refusal.reason}" if refusal else "refused", + ), + warnings=list(plan.notes), + degraded=True, +) +trace.fallbacks.extend(plan.notes) +``` + +Note the exact state sequence on this path — **it skips `PREPROCESS` and `EXECUTE` entirely, and its +`AGGREGATE` detail is different**: + +| State | Detail | +|---|---| +| `RECEIVE` | `{"asset_count": n}` | +| `PARSE` | `{"inputs": […], "modalities": […], "query_length": …}` | +| `VALIDATE` | `{"assets": n, "force_task": …, "router": …}` | +| `PLAN` | `{"plan": {…, "refused": true, "refusal": {…}}}` | +| `AGGREGATE` | `{"refused": true, "refusal": {"code": …, "reason": …, "user_message": …}}` | +| `VERIFY` | `{"steps": 0}` | +| `RESPOND` | `{"refused": true}` | + +`trace.confidence = result.confidence` and `trace.finished_at` are set, so the refusal envelope is shaped +exactly like any other. `run_id` comes from `trace.run_id` (the one allocated in `RECEIVE`), so the client's +`run_id` is echoed. + +**`raw=0.0, calibrated=None, degraded=True`** is the only honest confidence for a refusal: no specialist ran, +so there is no measurement. `degradation_reason` names the refusal rule, e.g. `"refused:task_unsupported"`. + +### 5. The budget — total wall clock, checked between steps + +| Aspect | Value | Source | +|---|---|---| +| Key | `agent.timeout_seconds` | `configs/base.yaml:240` | +| Frozen value | `120` | `configs/base.yaml:240` | +| Fallback constant | `DEFAULT_BUDGET_SECONDS = 120.0` | `core/controller.py:122` | +| Read at | `core/controller.py:226` | constructor | +| Checked | between steps, `elapsed > self.budget_seconds` | `core/controller.py:553-554` | +| On exceed | step **skipped**, `skipped_reason="budget_exceeded:{elapsed:.1f}s"` | `core/controller.py:555-558` | +| Trace error code | `SpecialistTimeoutError.code` = `"specialist_timeout"` | `core/errors.py:237-239` | + +The design admits the limitation rather than hiding it. There is **no per-step timeout**, because a true one +"needs a worker process or a signal handler, both of which conflict with the single-process monolith +constraint more than they benefit" (`core/controller.py:548-552`). + +`SpecialistTimeoutError` is `recoverable=True` (`core/errors.py:256-258`) for two independently recorded +reasons: `API_CONTRACT.md` §5.1 maps `504` with `recoverable: true`, and the plan's Failure Matrix lists +Timeout with recovery "abort specialist" and fallback "partial result" — i.e. the controller continues rather +than failing the request. + +### 6. Partial failure never erases evidence + +The mechanism is three-part and each part is separately testable: + +1. **`StepOutcome` retains the failure** (`core/controller.py:135-165`): + ```python + @dataclass + class StepOutcome: + step: PlanStep + result: SpecialistResult | None = None + registry_entry: RegistryEntry | None = None + error: SatQueryError | None = None + duration_ms: float = 0.0 + skipped_reason: str | None = None + + @property + def ok(self) -> bool: + return self.result is not None and self.error is None + ``` + Note `ok` requires **both** a result and no error — a step that somehow produced both is not "ok". + +2. **`_failure_evidence` converts it to a `STATISTIC` record** (§3.7) so it survives into `result.evidence`. + +3. **`_warnings` converts it to a human-readable line** (`core/controller.py:1014-1052`): + ``` + "{capability} failed ({code}): {scrubbed reason}" + "{capability} skipped ({skipped_reason})" + ``` + The reason comes from `_client_error_reason` — **one** shared implementation, deliberately: + ```python + @staticmethod + def _client_error_reason(error: SatQueryError) -> str: + return scrub_paths(error.detail) or error.user_message + ``` + `core/controller.py:838-863` explains that three client-visible carriers report a failed step — + `result.warnings[]`, `evidence[].payload["message"]` and `trace.errors[].message` — and before F-20 two of + them published an internals string while the third promised the internals were withheld: "The response + contradicted itself." The repair has two halves: the **producer** stopped putting internals into `detail`, + and this function exists "so the rule is written ONCE. Two copies of `scrub_paths(detail) or user_message` + is how a rule drifts: the next fix lands on whichever copy the author happened to open." + + `detail` is preferred over `user_message` on purpose, because "F-15 established that a typed error's + `detail` is the actionable diagnostic ("... has no builder 'build_x'", "no GPU in this dimension") while + the base `user_message` is generic, and three tests pin exactly those diagnostics." + +### 7. Contradiction detection — represented, never resolved + +`_detect_contradiction` (`core/controller.py:1151-1184`) groups every successful outcome's boxes by +`box.coordinate_system.value`, then compares pairs **within one coordinate system**: + +```python +for system, boxes in claims.items(): + if len(boxes) < 2: + continue + for i in range(len(boxes)): + for j in range(i + 1, len(boxes)): + if _iou(boxes[i], boxes[j]) < CONTRADICTION_IOU_FLOOR: + trace.contradiction = True + message = ( + "specialists produced contradictory spatial claims " + f"in {system}; both are retained in evidence" + ) + if message not in result.warnings: + result.warnings.append(message) + return +``` + +| Constant | Value | Source | +|---|---|---| +| `CONTRADICTION_IOU_FLOOR` | `0.1` | `core/controller.py:119` | + +`_iou` (`core/controller.py:1281-1292`) is plain intersection-over-union returning `0.0` when disjoint. + +The design rationale (`core/controller.py:60-66`): + +> When two specialists make contradictory spatial claims, both are kept and `trace.contradiction` is set. +> Selecting a winner requires a precedence weight, and any such weight is a value judgment with no measurement +> behind it. It also produces a third box that *neither specialist predicted* — a fabricated coordinate. + +Two implementation details matter. **Cross-system comparison is skipped** because "IoU across coordinate +systems is meaningless" (`core/controller.py:1161-1162`). And the method **returns after the first +contradiction**, so at most one warning is added per run. + +### 8. Confidence is not averaged + +`core/controller.py:43-58` states the rule: + +> Per-specialist confidences are uncalibrated and are not probabilities. Averaging two uncalibrated +> hand-weighted sums produces a number that is not a measurement of anything. +> +> The aggregate confidence is therefore the confidence of the **primary step** — the step whose capability +> matches the router's predicted task — passed through the evidence engine's calibration, which degrades +> honestly to `method="uncalibrated"`, `calibrated=None` when no artifact is fitted. + +`_confidence` (`core/controller.py:1069-1116`): + +```python +primary_task = trace.task +primary = next( + (r for r in results if primary_task is not None and r.task is primary_task), + None, +) + +components: dict[str, float] = {} +for result in results: + components[f"{result.task.value}_confidence"] = float(result.confidence.raw) + +if primary is None: + return ConfidenceBreakdown( + raw=0.0, + calibrated=None, + method="uncalibrated", + components=components, + degraded=True, + degradation_reason="primary specialist produced no result", + ) + +breakdown = self.evidence.confidence_for( + primary, + extra_components=components, + degraded=primary.degraded, +) +if not breakdown.degraded and prediction is not None: + if not prediction.above_threshold: + breakdown = breakdown.model_copy( + update={ + "degraded": True, + "degradation_reason": "router confidence below threshold", + } + ) +return breakdown +``` + +| Aspect | Behaviour | +|---|---| +| The run's score | the primary step's score, calibrated through the engine's artifact | +| Secondary scores | kept, in `components`, **namespaced** `f"{task.value}_confidence"` | +| Primary absent | `raw=0.0`, `calibrated=None`, `degraded=True`, reason `"primary specialist produced no result"` | +| Specialist said degraded | preserved — `degraded=primary.degraded` is passed through | +| Router uncertain | forces `degraded=True` with reason `"router confidence below threshold"` | + +The reasoning for the primary-absent branch is stated flatly: "Reporting a secondary specialist's score as the +run's confidence would launder a failure into a number. Secondaries are not discarded — their raw scores +appear in `components`, namespaced by capability. That is measurable and invents no combination rule" +(`core/controller.py:54-58`). + +Note that the final router clause applies **only** when the breakdown is not already degraded, so a +specialist-sourced degradation reason is never overwritten by the router's. + +### 9. The answer priority list — a list, not a heuristic + +`_answer` (`core/controller.py:933-970`) documents itself as "A priority list, not a heuristic (design +§7.3)": + +| # | Rule | Produces | +|---|---|---| +| 1 | the first **ok** outcome whose `capability` is `"vqa"` or `"caption"` and whose `result.answer` is non-empty | that answer verbatim | +| 2 | else the non-empty-answer result with the highest `confidence.value` | `f"[{best.task.value}] {best.answer}"` | +| 3 | else any failures | `f"No result could be produced: {named}."` where `named` is `", ".join(f"{capability} ({error.code or 'skipped'})")` | +| 4 | else `plan.refusal.user_message` | the refusal text | +| 5 | else | `"No specialist produced an answer."` | + +Rule 1 exists because "The VLM explains, when it ran (freeze §5)" and rule 2 exists because "else the +highest-confidence specialist answer, **attributed**" — the `[task]` prefix is the attribution, and it is +added here, not by the specialist. The closing note is the constraint that keeps this a list: +"No template composes a narrative from evidence: that is the VLM's job, and only when it actually ran." + +### 10. Merging rules — four different operators for four different meanings + +`_aggregate` calls four distinct merge helpers, and the choice of helper per field is the design: + +| Helper | Semantics | Used for | +|---|---|---| +| `_merge_list` | union, order-preserving, deduped | `labels`, `masks` | +| `_concat` | concatenation in plan-step order, **duplicates preserved** | `regions`, `boxes` | +| `_first_non_none` | first non-`None` in result order | `change_map` | +| `_first_geospatial` | first non-empty geo block | `geospatial` | + +`_first_geospatial` (`core/controller.py:1000-1012`) is not merged, and the reason is a coordinate-integrity +argument: + +> Not merged: merging CRS or transform fields across assets would silently mix two coordinate reference +> systems into one field, which is a coordinate error the schema cannot catch (design §7.2). + +Its selection predicate is `geo.has_crs or geo.crs or geo.bounds`; when nothing qualifies it returns a bare +`GeoMetadata()`. + +### 11. `_degraded` — three independent triggers + +```python +def _degraded(plan, outcomes, results) -> bool: + if plan.uncertain: + return True + if any(not o.ok for o in outcomes): + return True + return any(r.degraded for r in results) +``` + +So a run is degraded when **any** of: the route was uncertain; any step failed or was skipped; any specialist +result was itself degraded. This is the run-level flag; the *capability-level* flag is a separate vocabulary +(§40). + +--- + +## Part B — The shapes + +Everything here is from `core/schemas.py`, which is `SCHEMA_VERSION = "1.0"` (`core/schemas.py:21`). The +module docstring states its status: "This module is the binding contract between every component. Per +docs/ARCHITECTURE_FREEZE.md section 3, no specialist may invent its own result shape." + +### 12. `AnalysisRequest` — the inbound shape + +`core/schemas.py:412-418`: + +```python +class AnalysisRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + assets: list[str] = Field(min_length=1) + query: str + force_task: Task | None = None + run_id: str | None = None +``` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `assets` | `list[str]` | **yes** | `min_length=1`. On the live path these are asset **handles**, rewritten to paths by `app/space_app.py::analyze` before `controller.run()` | +| `query` | `str` | **yes** | no minimum length; an empty string is schema-legal | +| `force_task` | `Task \| None` | no | bypasses the router entirely; recorded as `source="forced"` | +| `run_id` | `str \| None` | no | echoed back; generated as `run_{uuid4hex[:12]}` when omitted | + +`extra="forbid"` is why an unknown field is a **422**, not an ignored key. `app/space_app.py:682-703` wraps +`AnalysisRequest.model_validate(payload)` and, on failure, logs the real `ValidationError` server-side while +returning `invalid_request` with the fixed detail "The body was not a valid AnalysisRequest. See +docs/API_CONTRACT.md section 2 for the accepted shape." — F-15 applied at the schema boundary. + +### 13. `SpecialistResult` — the master contract + +`core/schemas.py:325-343`. Its docstring is one sentence: "Every specialist returns exactly this. No +exceptions." + +| Field | Type | Default | Meaning | +|---|---|---|---| +| `task` | `Task` | required | the task this result answers | +| `answer` | `str` | `""` | language output; empty for spatial-only tasks | +| `labels` | `list[str]` | `[]` | class labels | +| `regions` | `list[Region]` | `[]` | spatial results that may not be rectangles | +| `boxes` | `list[Box]` | `[]` | strict rectangles | +| `masks` | `list[str]` | `[]` | artifact refs | +| `change_map` | `str \| None` | `None` | artifact ref — **permanently `null` in v1** (F-16) | +| `evidence` | `list[Evidence]` | `[]` | the citable observations | +| `confidence` | `ConfidenceBreakdown` | required | never an LLM utterance | +| `geospatial` | `GeoMetadata` | `GeoMetadata()` | CRS / transform / bounds | +| `execution_trace` | `ExecutionTrace \| None` | `None` | set by the controller in `RESPOND` | +| `schema_version` | `str` | `"1.0"` | | +| `warnings` | `list[str]` | `[]` | operator-safe warnings | +| `degraded` | `bool` | `False` | | + +Two validators run on every construction, and both encode real findings: + +**(a) `_unique_evidence_ids`** (`core/schemas.py:345-351`) — `evidence_id` values must be unique within a +result. This is why `_renumber` in the controller exists. + +**(b) `_task_output_consistency`** (`core/schemas.py:353-406`) — task-specific degradation rules. Two clauses +are live: + +- **GROUNDING with no localisation is degraded, not a crash** (`core/schemas.py:355-361`): if + `task is Task.GROUNDING and not (self.boxes or self.regions)` and the result is not already degraded, a + warning is appended and `degraded` is set. +- **CHANGE_VQA with no answer text is degraded** (`core/schemas.py:396-405`): with the explicit caveat that + "an answer alone is NOT enough to be non-degraded: the specialist sets `degraded` itself when it answered + from an untrained head, and this validator must not clear that." + +A **third** clause was **removed**, and the removal is documented in a long comment at +`core/schemas.py:362-395`. It read: + +```python +if self.task is Task.CHANGE and self.change_map is None and not self.regions: + ... "change analysis produced no spatial output" ... degraded = True +``` + +The comment records why it died (F-16c, owner ruling 2026-09-23): + +> `change_map` was a PROXY for "a map was produced": before F-16 it held a filesystem path whenever a file had +> been written. F-16 made the ref permanently null — a client cannot retrieve it in v1 — so the proxy died, +> the clause collapsed to `not regions`, and a SUCCESSFUL no-change analysis began reporting `degraded: true`. +> A null, non-retrievable artifact ref was manufacturing a degradation. + +And the replacement policy: + +> No replacement clause is added, deliberately. A narrower "empty answer => degraded" rule was tried and +> backed out: it is not what the ruling asked for, it invented a semantic the specialist already owns, and it +> made a pre-existing, unrelated fixture (`test_change_with_regions_is_not_degraded`, a result with regions and +> no answer) fail. A fix that forces edits to tests it has nothing to do with is signalling over-reach, not +> diligence. **CHANGE is the one task whose `degraded` flag is now set entirely by its specialist.** + +That last sentence is a precise, checkable claim about where responsibility sits. + +### 14. `ResultEnvelope` — the outbound shape + +`core/schemas.py:421-427`: + +```python +class ResultEnvelope(BaseModel): + model_config = ConfigDict(extra="forbid") + + run_id: str + result: SpecialistResult + trace: ExecutionTrace + schema_version: str = SCHEMA_VERSION +``` + +Three top-level keys plus the version. `extra="forbid"` means the served body has exactly this shape. +`app/space_app.py:728` serializes it with `envelope.model_dump(mode="json")`. + +### 15. `ExecutionTrace`, `TraceStep`, `ModelRef` + +#### 15.1 `ExecutionTrace` (`core/schemas.py:296-319`) + +Its docstring comment above the class reads: "Execution trace (observable facts only — never +chain-of-thought)". + +| Field | Type | Default | Written by | +|---|---|---|---| +| `run_id` | `str` | `run_{uuid4hex[:12]}` | `RECEIVE` | +| `schema_version` | `str` | `"1.0"` | schema | +| `task` | `Task \| None` | `None` | `VALIDATE` | +| `query` | `str \| None` | `None` | `RECEIVE` | +| `inputs` | `list[str]` | `[]` | `RECEIVE` (basenames) | +| `modalities` | `list[Modality]` | `[]` | `PARSE` | +| `intent` | `Intent \| None` | `None` | `VALIDATE` | +| `validation` | `dict[str, Any]` | `{}` | **nothing** — see §15.4 | +| `workflow` | `list[str]` | `[]` | `EXECUTE` (step ids) | +| `steps` | `list[TraceStep]` | `[]` | every state | +| `selected_models` | `list[ModelRef]` | `[]` | `EXECUTE` (post-loop) | +| `parameters` | `dict[str, Any]` | `{}` | `PLAN`, then re-snapshotted after `EXECUTE` | +| `outputs` | `list[str]` | `[]` | `RESPOND` (evidence ids) | +| `confidence` | `ConfidenceBreakdown \| None` | `None` | `RESPOND` | +| `timings` | `dict[str, float]` | `{}` | `EXECUTE` (per step) | +| `fallbacks` | `list[str]` | `[]` | `VERIFY`, refusal path | +| `errors` | `list[dict[str, Any]]` | `[]` | `EXECUTE`, `VERIFY` | +| `contradiction` | `bool` | `False` | `VERIFY` | +| `config_hash` | `str \| None` | `None` | `RECEIVE` | +| `started_at` | `str` | `_utcnow()` | construction | +| `finished_at` | `str \| None` | `None` | `RESPOND` | + +#### 15.2 `TraceStep` (`core/schemas.py:279-285`) + +```python +class TraceStep(BaseModel): + model_config = ConfigDict(extra="forbid") + + state: ControllerState + started_at: str = Field(default_factory=_utcnow) + duration_ms: float | None = None + detail: dict[str, Any] = Field(default_factory=dict) +``` + +`_record` (`core/controller.py:1188-1197`) appends `TraceStep(state=state, detail=self._jsonable(detail or +{}))`. `duration_ms` is **left at `None`** by `_record` — the controller never sets it; per-step durations go +to `trace.timings` instead. + +`_jsonable` (`core/controller.py:1265-1278`) recursively stringifies anything exotic, because "`ExecutionTrace. +steps[].detail` is a free-form dict, and a plan carries tuples and enums that `json.dumps` would reject. +Stringifying the leaves preserves the fact without inventing structure." + +#### 15.3 `ModelRef` (`core/schemas.py:288-293`) + +```python +class ModelRef(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str + revision: str | None = None + role: str | None = None +``` + +Built explicitly in `_selected_models` rather than relying on dict coercion: "pydantic would coerce a dict +only by emitting a serialization warning. Constructing the type explicitly keeps the contract honest instead +of relying on coercion" (`core/controller.py:724-727`). + +#### 15.4 `validation` is always `{}` — an honest gap + +`ExecutionTrace.validation` is typed `dict[str, Any]` with a `default_factory=dict`, and a repository-wide +search finds **no writer**: no `trace.validation = …`, no `validation=` construction, no `trace.validation[…]` +subscript anywhere in `core/`, `app/`, `deploy/` or the frontend. So on the live path the field serializes as +`{}` in every response. + +The validation facts that *are* observable live elsewhere and are not lost: + +| Fact | Where it is observable | +|---|---| +| router decision + provenance | `trace.intent` and the `VALIDATE` step's `detail["router"]` | +| plan steps, mode, refusal, notes | `trace.parameters` and the `PLAN` step's `detail["plan"]` | +| per-step success/failure/skip | each `EXECUTE` step's `detail` | +| evidence-loss check | `trace.errors[]` with `code: "evidence_loss"` | + +Status: `OPEN` — a schema field with no producer. It is recorded rather than removed because removing it would +be a schema change to the frozen `SCHEMA_VERSION = "1.0"` contract. + +### 16. `ConfidenceBreakdown` (`core/schemas.py:259-273`) + +Docstring: "Never an LLM utterance. Always measurable signals (plan section 26)." + +| Field | Type | Default | Notes | +|---|---|---|---| +| `raw` | `float` | required | `ge=0.0, le=1.0` | +| `calibrated` | `float \| None` | `None` | `ge=0.0, le=1.0` when present | +| `method` | `str` | `"uncalibrated"` | `"temperature_scaling"` when a fit was applied | +| `components` | `dict[str, float]` | `{}` | diagnostic only | +| `degraded` | `bool` | `False` | | +| `degradation_reason` | `str \| None` | `None` | | + +And the one property (`core/schemas.py:271-273`): + +```python +@property +def value(self) -> float: + return self.calibrated if self.calibrated is not None else self.raw +``` + +`value` is what `_answer`'s rule 2 ranks on. The deeper treatment of calibration — the formula, `_is_effective`, +`_EPS`, `_sigmoid`, the artifact object, and the **measured** result that ECE went 0.013755 → 0.014929 +(**worse**) — is in [06 Evidence and confidence](06-evidence-and-confidence.md) §7–§10 and is not repeated here. + +### 17. Supporting shapes + +#### 17.1 `AssetMetadata` (`core/schemas.py:153-165`) + +```python +class AssetMetadata(BaseModel): + model_config = ConfigDict(extra="forbid") + + asset_id: str = Field(default_factory=lambda: _new_id("asset")) + path: str + modality: Modality = Modality.UNKNOWN + sha256: str | None = None + geo: GeoMetadata = Field(default_factory=GeoMetadata) + sensor: SensorDescriptor | None = None + acquisition_date: str | None = None + scene_id: str | None = None + dataset_id: str | None = None + split: str | None = None +``` + +`_new_id` is `f"{prefix}_{uuid.uuid4().hex[:12]}"` (`core/schemas.py:28-29`). Note `path` is required and is +the **resolved** path (`inspect_raster` sets `path=str(p.resolve())`). + +#### 17.2 `GeoMetadata` (`core/schemas.py:120-135`) + +`extra="allow"` — deliberately the one open shape in the module. Fields: `crs`, `transform` (6 affine +coefficients, row-major), `bounds` (`[minx, miny, maxx, maxy]`), `width`, `height`, `band_count`, `dtype`, +`nodata`, `resolution`, `has_crs: bool = False`, `is_georeferenced: bool = False`. `inspect_raster` also +passes `driver` and `is_tiled` (`preprocessing/raster.py:173-174`), which only work because `extra="allow"`. + +#### 17.3 `SensorDescriptor` (`core/schemas.py:138-150`) + +Frozen sensor-adapter contract (plan §18), `extra="forbid"`: `sensor`, `available_bands`, `band_map`, +`normalization: str = "percentile"`, `availability_mask: list[bool]`, `resolution`, `canonical_channels: int = +0`, `missing_channels_zero_filled: bool = True`. The `availability_mask` is the **C-1** finding's carrier: the +channel-availability mask is a first-class fusion input, never a CROMA input. + +#### 17.4 Spatial shapes + +| Shape | Line | Fields | +|---|---|---| +| `Box` | `core/schemas.py:171-186` | `x1, y1, x2, y2`, `score ∈ [0,1]`, `label`, `coordinate_system` (default `normalized_0_1`) | +| `Region` | `:189-199` | `region_id`, `box: Box \| None`, `mask_ref: str \| None`, `label`, `score`, `coordinate_system` | +| `ChangeRegion` | `:202-210` | `region_id`, `box: Box`, `area_pixels: int (ge=0)`, `mean_probability ∈ [0,1]`, `stability ∈ [0,1] \| None`, `coordinate_system` | + +`Box._ordered` (`core/schemas.py:182-186`) raises unless `x2 >= x1 and y2 >= y1` — a self-intersecting box is +not representable. + +`Evidence` and the eleven-member `EvidenceType` vocabulary are the subject of +[06](06-evidence-and-confidence.md) §2–§4 and are cross-referenced rather than restated. The one thing worth +repeating here because it is a **validator on the request lifecycle's output**: +`Evidence._spatial_needs_crs` (`core/schemas.py:238-253`) refuses coordinates without a `coordinate_system` +for the six spatial types (`BOUNDING_BOX`, `MASK`, `CHANGE_MAP`, `TILE`, `IMAGE_CROP`, +`JOINT_FEATURE_REGION`). + +### 18. The enums + +#### 18.1 `Task` (`core/schemas.py:35-47`) — seven members + +| Value | Note from the source | +|---|---| +| `vqa` | question answering about **one** asset | +| `caption` | description of one asset | +| `grounding` | localisation on one asset | +| `change` | the change **detector**; returns a spatial change map with **no language output** | +| `optical_sar` | optical + SAR fusion | +| `change_vqa` | R-02. two temporally corresponding assets + a change-oriented question in, a short answer out | +| `unsupported` | the refusal label | + +The `change_vqa` comment is a precise disambiguation and worth quoting: "Distinct from CHANGE, which is the +change *detector* and returns a spatial change map with no language output, and distinct from VQA, which +answers about ONE asset. **The three are not interchangeable and the planner must not substitute one for +another.**" + +#### 18.2 `Modality` (`core/schemas.py:50-54`) — four members + +`optical` · `sar` · `optical_sar` · `unknown`. + +#### 18.3 `CoordinateSystem` (`core/schemas.py:57-62`) — three members + +`normalized_0_1` · `pixel` · `geo`. The docstring is the whole point: "Never omit this. A bare box is +meaningless without it. (C-5)". + +#### 18.4 `EvidenceType` (`core/schemas.py:65-76`) — eleven members + +`image_crop` · `tile` · `bounding_box` · `mask` · `change_map` · `optical_view` · `sar_view` · +`joint_feature_region` · `statistic` · `geolocation` · `availability_mask`. + +`statistic` is the member the controller depends on for both failure records and synthesis records; +`availability_mask` carries the C-1 comment inline: "C-1: modality trust evidence". + +#### 18.5 `ControllerState` — see §2.1. + +### 19. `Intent` — advisory only + +`core/schemas.py:94-114`: + +```python +class Intent(BaseModel): + """Output of the learned router. Advisory only — the controller decides.""" + model_config = ConfigDict(extra="forbid") + + task: Task + modality: Modality = Modality.UNKNOWN + temporal: bool = False + spatial_output: bool = False + language_output: bool = True + confidence: float = Field(ge=0.0, le=1.0) + source: Literal["learned", "lexical_fallback", "forced"] = "learned" + + @model_validator(mode="after") + def _consistency(self) -> "Intent": + if self.task in (Task.CHANGE, Task.CHANGE_VQA) and not self.temporal: + self.temporal = True + if self.task is Task.OPTICAL_SAR and self.modality is Modality.UNKNOWN: + self.modality = Modality.OPTICAL_SAR + return self +``` + +Two coherence repairs are encoded in the validator: a change-family task is forced `temporal=True`, and an +`optical_sar` task with an unknown modality is forced to `optical_sar`. The comment notes the asymmetry that +is *not* repaired: "A temporal task without spatial output is legal; the reverse is not implied." + +`Intent.source` is the **provenance** field, and it has exactly three legal values. It is what the planner's +provenance discount keys on (§28.3). + +`RouterPrediction` (`router/classifier.py:80-106`) wraps an `Intent` with the evidence needed to explain it: + +| Field | Type | Default | +|---|---|---| +| `intent` | `Intent` | required | +| `task_probs` | `dict[str, float]` | `{}` | +| `modality_probs` | `dict[str, float]` | `{}` | +| `binary_probs` | `dict[str, float]` | `{}` | +| `used_fallback` | `bool` | `False` | +| `fallback_rule` | `str \| None` | `None` | +| `matched_terms` | `tuple[str, ...]` | `()` | +| `above_threshold` | `bool` | `True` | + +`to_trace()` publishes ten keys and, like every other trace writer in the codebase, is commented "Observable +facts only. No chain-of-thought." + +### 20. `StepOutcome` — the internal outcome record + +`core/controller.py:135-165`. Internal (not in `__all__`'s served surface, though it *is* exported). Fields: +`step: PlanStep`, `result: SpecialistResult | None`, `registry_entry: RegistryEntry | None`, `error: +SatQueryError | None`, `duration_ms: float`, `skipped_reason: str | None`. Properties `ok` and `capability`; +method `to_trace()` (the shape shown in §3.6). + +--- + +## Part C — Validation + +### 21. The raster contract + +`preprocessing/raster.py:1-14` states the chain it implements and the three rules it obeys: + +> ``` +> file -> dimensions -> bands -> dtype -> CRS -> transform -> bounds -> nodata +> -> modality -> temporal metadata +> ``` +> +> Design rules: +> * Never raise a bare exception. Every failure is a typed SatQueryError. +> * Never silently drop geospatial metadata. If the source had a CRS, the returned AssetMetadata says so. +> * A missing CRS degrades to non-geospatial mode; it does not abort. + +`inspect_raster` (`preprocessing/raster.py:96-185`) enforces, in order: + +| Check | Failure | Error code | Recoverable | +|---|---|---|---| +| path exists | `not p.exists()` | `raster_read_error` | no | +| is a regular file | `not p.is_file()` | `raster_read_error` | no | +| rasterio importable | `ImportError` | `raster_read_error` | no | +| openable | any rasterio exception | `raster_read_error` | no | +| non-degenerate dims | `width <= 0 or height <= 0` | `raster_read_error` | no | +| pixel budget | `width * height > max_pixels` | `oversized_image` | **yes** | +| band count | `band_count <= 0` | `unsupported_bands` | no | + +`OversizedImageError` and `MissingCRSError` both default `recoverable=True` in their own constructors +(`core/errors.py:137-139`, `:152-154`), so "the caller may downscale" is a machine-readable statement rather +than prose. + +**CRS absence does not abort.** `GeoMetadata.has_crs` and `is_georeferenced` record the fact, and +`inspect_raster` returns normally. `MissingCRSError` exists in the taxonomy but `inspect_raster` does not +raise it — the degradable path is taken instead. + +**Hash is opt-in.** `compute_hash: bool = False`; when true, `file_sha256` streams in 1 MiB chunks +(`preprocessing/raster.py:82-88`). Change detection needs it (see §26). + +### 22. Modality inference — band-count heuristic + +`preprocessing/raster.py:35-74`. The two sets are exact: + +```python +_OPTICAL_BAND_COUNTS = {3, 4, 8, 11, 12, 13} +_SAR_BAND_COUNTS = {1, 2} +``` + +```python +def infer_modality(band_count: int, explicit: str | None = None) -> Modality: + if explicit: + try: + return Modality(explicit.lower()) + except ValueError: + pass + + if band_count in _SAR_BAND_COUNTS: + return Modality.SAR + if band_count in _OPTICAL_BAND_COUNTS: + return Modality.OPTICAL + if band_count >= 4: + return Modality.OPTICAL + return Modality.UNKNOWN +``` + +Resolution order, exhaustively: + +| `band_count` | Result | Rule | +|---|---|---| +| 1 | `sar` | member of `{1, 2}` | +| 2 | `sar` | member of `{1, 2}` | +| 3 | `optical` | member of `{3, 4, 8, 11, 12, 13}` | +| 4 | `optical` | member of `{3, 4, 8, 11, 12, 13}` | +| 5–7 | `optical` | `>= 4` fallback | +| 8 | `optical` | member of the set | +| 9, 10 | `optical` | `>= 4` fallback | +| 11 | `optical` | member of the set | +| 12 | `optical` | member of the set | +| 13 | `optical` | member of the set | +| 14+ | `optical` | `>= 4` fallback | +| 0 or negative | `unsupported_bands` raised earlier | `band_count <= 0` check | + +**The heuristic is declared as a heuristic.** `preprocessing/raster.py:35-37`: "These are heuristics, not +ground truth — the sensor adapter is authoritative when a sensor descriptor is supplied." + +**The genuinely ambiguous case is 1 band, and the code says so.** A single-band GeoTIFF is a perfectly +ordinary SAR product *and* a perfectly ordinary panchromatic optical product; the band count cannot tell them +apart. `_normalise_modality_overrides` (`core/controller.py:1217-1250`) records that "Rather than guess, the +caller may declare the modality" and that the override "is genuinely ambiguous for a 1-band scene". +`infer_modality`'s own comment at `preprocessing/raster.py:70-71` repeats it: "12-band optical and 2-band SAR +are both plausible; default to optical only when the count clearly favours it." + +**An unparsable explicit label is silently ignored by `infer_modality`** — the `except ValueError: pass` falls +through to the count heuristic. That is safe only because the caller-side validator is strict (§23). + +### 23. Explicit modality overrides — validated at the boundary + +`core/controller.py:1217-1250`: + +```python +def _normalise_modality_overrides(overrides): + if not overrides: + return {} + valid = {m.value for m in Modality} + normalised: dict[str, str] = {} + for key, value in overrides.items(): + text = str(value).strip().lower() + if text not in valid: + raise ValueError( + f"asset_modalities[{key!r}] = {value!r} is not a valid modality; " + f"expected one of {sorted(valid)}" + ) + normalised[str(key)] = text + return normalised +``` + +| Aspect | Behaviour | +|---|---| +| Values | `.strip().lower()` then membership in `{optical, sar, optical_sar, unknown}` | +| Invalid value | **raises** `ValueError` — never silently ignored | +| Keys | **not** normalised; looked up against the exact asset string *and* its basename | +| Lookup | `overrides.get(str(path)) or overrides.get(Path(path).name)` (`core/controller.py:519`) | +| Authoritative over | the band-count heuristic (`inspect_raster(explicit_modality=…)`) | + +The rationale is stated as a principle about boundaries: "Values are normalised and validated here, at the +boundary where they enter the system, so a typo fails immediately and loudly instead of silently degrading to +'unknown' three layers down. A declaration that is ignored without complaint would be worse than a loud +error: the caller would believe an override took effect when it did not" (`core/controller.py:1228-1233`). + +**The serving path does not currently pass overrides.** `app/space_app.py::analyze` calls +`controller.run(request)` with no `asset_modalities=`, and `AnalysisRequest` has no field for them. So on the +live path the override channel is reachable only from an in-process caller, and a 1-band ambiguous asset is +resolved by the heuristic. Status: `IMPLEMENTED (not reachable from the HTTP surface)`. + +### 24. The per-file cap — 4,194,304 bytes, enforced at two layers + +| Layer | Value | Source | Enforcement | +|---|---|---|---| +| Gateway | `max_file_bytes: int = 4 * 1024 * 1024` | `gateway/policy.py:221` | declared `Content-Length` **and** while reading (`gateway/app.py::_read_body_bounded`, F-6) | +| Space | `_asset_max_file_bytes()` default `4 * 1024 * 1024` | `app/space_app.py:359` | while reading via the shared `gateway/assets.py::read_body_bounded` (F-9) | + +`4 * 1024 * 1024 = 4,194,304` bytes = 4 MiB. Both layers read the **same** variable, +`SATQUERY_MAX_FILE_BYTES`, and both **refuse** an unparsable or non-positive value rather than defaulting — +F-7. `app/space_app.py:318-378` documents the measured divergence before the fix: + +> `SATQUERY_MAX_FILE_BYTES='abc'` or `'4e6'` → this function silently returned the 4 MiB DEFAULT while the +> gateway RAISED AT STARTUP for the same value. +> +> `SATQUERY_MAX_FILE_BYTES='0'` or `'-1'` → this function RETURNED THE VALUE ('0' / -1) while the gateway, +> once its own non-positive guard was added, REFUSED it. + +The Space's refusal names the variable and the offending text, because "this is read while building the store +and an operator can only see it in a Space's build or start log." + +**The cap is a request-side limit, not a raster limit.** A 4 MiB GeoTIFF is comfortably within the cap; the +*pixel* budget is separate (§25). + +### 25. The pixel budget — 25,000,000 + +| Key | Value | Source | +|---|---|---| +| `image.max_pixels` | `25000000` | `configs/base.yaml:17` | +| Enforced at | `inspect_raster(max_pixels=…)` | `preprocessing/raster.py:152-156` | +| Error | `OversizedImageError` (`oversized_image`), `recoverable=True` | `core/errors.py:147-154` | + +25,000,000 pixels is 25 MP. The error carries structured context: +`{"width": …, "height": …, "max_pixels": …}`. + +**Who passes `max_pixels` on the live path is not established from the available evidence.** The controller +calls `_inspect_asset(path, explicit_modality)`, which calls `inspect_raster(path, +explicit_modality=explicit_modality)` with **no** `max_pixels` (`core/controller.py:1253-1262`). So unless +the deployed build differs, the live path does **not** enforce the 25 MP budget at inspection time. +Status: `UNKNOWN — not established from the available evidence` for whether the deployed inference build +passes the value. The key is declared, and `tests/geospatial/test_transform.py:381` exercises +`inspect_raster(geotiff, max_pixels=10)`, so the mechanism is tested; the live wiring is not evidenced. + +### 26. Equal-dimension pairs for change tasks + +The change detector requires T1 and T2 to be the same shape. `specialists/change/stanet.py:417`: + +```python +f"T1 and T2 must have the same shape; got {tuple(t1.shape)} " +``` + +and again at `:580` (`"must have the same shape"`). The comparison is on the **decoded tensor shape**, not on +the raster header, so it is enforced inside the detector forward pass rather than at validation. + +**The pair checks that *are* at validation time** are in `specialists/change/specialist.py:225-272` +(`_assess_pair`), and they run in this order: + +| # | Check | Error | Code | +|---|---|---|---| +| 1 | `t1.path == t2.path` | `TemporalPairError` | `temporal_pair_invalid` | +| 2 | `t1.sha256 == t2.sha256` (when both present) | `TemporalPairError` | `temporal_pair_invalid` | +| 3 | CRS compatibility (`geospatial.crs.compare_crs`) | **warning**, not an error | — | +| 4 | registration quality (`measure_registration`) | degrades to `is_usable=False` | — | + +Check 1's comment names both mistakes it catches: "ONE asset is the common mistake: the caller treated this as +a single-image task. THREE is the other: they attached a time series." Check 2's message is the rule: +"T1 and T2 have identical sha256; a repeated acquisition is not a temporal pair." + +Check 3 is deliberately a warning. `specialists/change/specialist.py:255-262`: + +> No CRS on either side is NOT fatal for pixel-domain change detection — the two rasters still tile to the +> same grid. It IS fatal for any claim about ground coordinates, so the warning is recorded and the geospatial +> block is withheld downstream. + +Check 4 is where the "measurement of failure" distinction lives (`specialists/change/specialist.py:274-307`): +"A failure to MEASURE is not the same as a measurement of failure, but both mean the pair cannot be trusted +spatially, so both produce an unusable verdict. The distinction is recorded in the warning." A measurement +failure returns `RegistrationQuality(shift_x=0.0, shift_y=0.0, response=0.0, max_shift_px=…, is_usable=False, +reason="registration measurement failed")` — and the non-zero `reason` is what makes "we could not measure" +distinguishable from "we measured zero shift". + +`execute` reuses the same assessment rather than measuring twice: "Split out so `execute` can reuse the SAME +assessment the validator made, rather than measuring registration twice and reporting two numbers that could +disagree" (`specialists/change/specialist.py:228-230`). The consequence when registration is unusable +(`:321-326`) is a **suppression**, not a failure: + +> poor co-registration: {reason}. Spatial change claims are suppressed; confidence reflects the measurement, +> not the change map. + +### 27. GeoTIFF acceptance for optical-SAR + +The optical-SAR specialist's validation is exactly two assets, both present, one optical and one SAR +(`specialists/optical_sar/specialist.py:192-293`): + +| # | Check | Error | +|---|---|---| +| 1 | `request.asset_count != 2` | `InvalidRequestError` | +| 2 | each `Path(asset.path).exists()` | `InvalidRequestError` | +| 3 | `_assess_pair(...)` | `InvalidRequestError` (three distinct messages) | + +The user-facing message for check 1 is the clearest statement of the requirement in the codebase: + +> "Optical-SAR fusion needs exactly two images: one optical and one radar (SAR)." + +`_assess_pair` (`specialists/optical_sar/specialist.py:223-293`) resolves each asset's modality by **declared +value first, band-count inference second**: + +> Modality comes from the asset's declared `modality`, falling back to a band-count inference through +> `preprocessing.raster.infer_modality`. The declared value wins when present, because the caller may know +> something the band count does not — a 2-band Cartosat stack, or a 12-band decomposition product. + +It then classifies and refuses on three distinct conditions, each with its own `reason` in the error context: + +| Condition | `reason` | User message | +|---|---|---| +| two optical | `two_optical` | "Both uploaded images look like optical imagery. This workflow needs one optical image and one radar (SAR) image." | +| two SAR | `two_sar` | "Both uploaded images look like radar (SAR) imagery. This workflow needs one optical image and one radar (SAR) image." | +| neither clean | `indeterminate` | "Could not tell which image is optical and which is radar. Please label the images so one is optical and one is SAR." | + +Only the third case — one `optical` and one `sar` — returns a `PairAssessment`. The `indeterminate` branch is +the one the F-13-era `UNKNOWN`/`UNKNOWN` defect produced, and its message is the one quoted in +`core/controller.py:497-500`. + +**Band-count inference used for pairing produces a warning, not a silent decision.** +`specialists/optical_sar/specialist.py:309-315` appends: + +> asset {name} had no declared modality; inferred '{modality}' from {band_count} band(s). A band count is a +> heuristic, not a sensor declaration -- label the asset explicitly if this is wrong. + +So "we guessed" is always visible in `result.warnings[]`. + +**A GeoTIFF is required, not merely accepted.** The format gate is upstream: `POST /v1/assets` accepts a +closed list of five content types (`image/tiff`, `image/geotiff`, `image/png`, `image/jpeg`, +`application/octet-stream`), and `API_CONTRACT.md` §2.5 records the consequence in the row itself: "**`image/ +tiff` is the type the geospatial specialists need** — a client that uploads only PNG/JPEG can serve the VQA, +caption and grounding tasks but not the change or optical/SAR ones." A PNG therefore passes the upload gate +and fails later at `inspect_raster` with `raster_read_error`. + +### 28. The planner's decision rules + +The planner is pure: "No models, no torch, no filesystem, no clock — every rule below is unit-testable with a +hand-built `RouterPrediction`" (`core/planner.py:256-258`). + +#### 28.1 Refusals — a closed list + +`_refusal_for` (`core/planner.py:412-426`) is four lines and its comment is "The closed refusal list from §8. +Order matters: query first." + +| # | Condition | `code` | `reason` | `user_message` | +|---|---|---|---|---| +| 1 | `task is Task.UNSUPPORTED` | `unsupported_query` | `task_unsupported` | `UnsupportedQueryError.user_message` = "No specialist supports this request." | +| 2 | `asset_count < 1` | `invalid_request` | `zero_assets` | `InvalidRequestError.user_message` = "The uploaded inputs do not support the requested task." | + +A third refusal is produced **after** the availability gate (`core/planner.py:358-372`): + +| # | Condition | `code` | `reason` | `user_message` | +|---|---|---|---|---| +| 3 | every step dropped as unavailable | `model_unavailable` | `no_available_specialist` | "No specialist is available for this request in this environment." | + +Note that refusal #3 uses `_refusal(...)` with `code="model_unavailable"` — a **string**, not +`ModelUnavailableError.code`. Both happen to be `"model_unavailable"`, so they agree, but they are not the +same source. + +`asset_count < 1` is unreachable through the HTTP surface because `AnalysisRequest.assets` is +`Field(min_length=1)`. It is reachable from an in-process caller, and it is kept for that reason. + +#### 28.2 Step construction and `§3.5` widening + +The primary step is built from `TASK_CAPABILITY[task]` with `reason=f"task:{task.value}"` and +`required=True` (`core/planner.py:325-333`). + +Then `_widening_steps` (`core/planner.py:434-508`) may add up to two more, from a closed set of rules: + +| Rule | Condition | Step added | `reason` | `required` | +|---|---|---|---|---| +| change + language → answer | `task is CHANGE and asset_count >= 2 and intent.language_output` **and** `change_vqa` is known | `change_vqa` | `task:change+language_output:answer` | `False` | +| change + language → caption | same, **but** `change_vqa` unknown | `caption`, index `asset_count - 1` | `task:change+language_output` | `False` | +| spatial + language | `intent.spatial_output and intent.language_output and asset_count >= 1` and `task is not GROUNDING` | `grounding`, index `0` | `aspect:spatial_output` | `False` | + +The first two are **alternatives, not both**, and the comment says why (`core/planner.py:460-469`): "Which +language output … depends on what the deployment has. With a change-VQA capability registered (R-02), the +request is satisfied by an ANSWER to the change question — which is the specific thing that was asked. +Without it, the fallback is to caption the later acquisition, which is the one a 'what changed' answer +describes. These are alternatives, not two things to do … Adding both would spend a step on a strictly weaker +output." + +Note the caption fallback indexes `asset_count - 1` — the **later** acquisition — while every other one-asset +capability indexes `0`. + +**Availability is deliberately not gated here.** `core/planner.py:450-454`: + +> Availability is NOT gated here. The step is added whenever its rule fires, and `_drop_unavailable` removes it +> with a recorded note if the capability is unregistered. Gating here as well would create a second, silent +> availability check whose omission leaves no trace — exactly the "absence indistinguishable from a +> non-event" failure that §4.3 forbids. + +Why widening exists at all is stated concretely (`core/planner.py:23-31`): "A query needing two specialists +can never get both. The router returns ONE task; *'what changed between these two images, and describe the +scene'* routes to `change` alone, and the caption is lost. Section §3.5 is what recovers it." + +#### 28.3 The provenance discount — a reading, not a gate + +| Constant | Value | Source | +|---|---|---| +| `LEXICAL_FALLBACK_DISCOUNT` | `0.75` | `core/planner.py:110` | +| `SETTLED_CONFIDENCE` | `0.60` | `core/planner.py:116` | + +```python +@staticmethod +def _effective_confidence(prediction) -> float: + raw = float(prediction.intent.confidence) + if prediction.intent.source == "lexical_fallback": + return raw * LEXICAL_FALLBACK_DISCOUNT + return raw +``` + +The distinction is stated as an evidence argument (`core/planner.py:57-62`): + +> A lexical fallback at 0.9 is not the same evidence as a learned model at 0.9: one is a regex that matched, +> the other is a learned distribution. Treating them identically would let a matched keyword outrank the model +> it fell back from. So the planner applies a **provenance discount** — not a second numeric gate — to its own +> reading of the confidence, and never edits `Intent.confidence` itself. + +The measured fact that makes the discount necessary is in the same comment: "on the spec section 29 examples +the fallback returns 0.850-0.920 against the trained model's 0.780-1.000 — **the fallback can be MORE +confident**." + +**`SETTLED_CONFIDENCE = 0.60` is declared but not used as a gate.** A repository search finds it exported in +`__all__` and documented at `core/planner.py:112-116`, but no comparison against it appears in `plan()`. The +uncertainty test that actually runs is `not prediction.above_threshold` +(`core/planner.py:339-344`, `:315`, `:368`, `:389`). The threshold itself is applied **once**, in the router +(`router/classifier.py:289`, `:308`), and the planner's docstring is explicit that re-reading it here would be +a second independent gate over the same quantity: + +> So the planner consumes `above_threshold` (a bool), never the numeric threshold. Re-reading +> `router.confidence_threshold` here would be a second, independent gate over the same quantity — two places +> bound to one knob, which is how a threshold ends up meaning two different things. + +Status of `SETTLED_CONFIDENCE`: `IMPLEMENTED (declared and exported, not read)`. This is an honest gap of the +same class as `PREPROCESS`. + +#### 28.4 The discretionary explanation step + +`core/planner.py:339-352`: + +```python +if ( + not prediction.above_threshold + and prediction.intent.language_output + and "vqa" not in {s.capability for s in steps} + and self._capability_known("vqa") +): + self._append( + steps, + capability="vqa", + indices=(0,), + reason="uncertain_route:explain", + required=False, + ) + notes.append("uncertain route: added an explanation step") +``` + +Four conditions, all necessary: the route must be **uncertain**, the request must want **language**, the plan +must not already contain `vqa`, and the capability must be **known**. `required=False`, so a failure degrades +rather than invalidating. + +#### 28.5 The availability gate + +`_drop_unavailable` (`core/planner.py:572-602`) removes steps whose capability is not in +`registry.available()`, appending `f"dropped step {step_id} ({capability}): capability not registered"` to +`notes` for each. The docstring distinguishes declaration from construction, which is the boundary the +planner must not cross: + +> Note this filters only on *declaration*. Whether a declared capability can actually be CONSTRUCTED is +> discovered by the controller, which records an `UNAVAILABLE` registry entry — the planner must not attempt +> construction, which would defeat the lazy-load design. + +If `registry.available()` raises, `_registry_ok()` returns `False` and the filter is **skipped entirely**, +returning the steps unchanged — "a broken registry is not a plan error" (`core/planner.py:569`). + +#### 28.6 The step cap, and the honest gap + +```python +if self.max_steps is not None and len(steps) > self.max_steps: + dropped = [s.capability for s in steps[self.max_steps:]] + notes.append( + f"plan truncated at {self.max_steps} steps; dropped {dropped}" + ) + steps = steps[: self.max_steps] +``` + +| Aspect | Value | +|---|---| +| Constructor parameter | `max_steps: int | None = None` (`core/planner.py:270`) | +| Documented as mirroring | `agent.max_specialists` (`core/planner.py:265-266`) | +| `agent.max_specialists` value | `4` (`configs/base.yaml:239`) | +| **Read by any code** | **no** — a repository search for `agent.max_specialists` in Python returns only the planner's own docstring | +| What the serving composition passes | `PolicyPlanner(registry)` — **no `max_steps`** (`app/serving.py:263`) | +| Consequence on the live path | `max_steps is None`, so **no truncation is applied** | + +Status: `OPEN` — the cap exists, is tested (`tests/unit/test_planner.py:454 +test_max_steps_truncates_and_records`), and is not wired to the frozen config key that names it. In practice +the reachable plan length is small (primary + at most two widening steps = 3), so the unwired cap is not +currently load-bearing; that is an observation about today's rule set, not a guarantee. + +#### 28.7 `PlanMode` + +`_mode_for` (`core/planner.py:613-632`) returns `PARALLEL_SAFE` only when there are at least two steps **and** +the asset index sets are pairwise disjoint. The docstring states the outcome plainly: + +> With today's four specialists asset indices commonly overlap — a change plan and a caption plan can both +> read asset 1 — so this legitimately returns SEQUENTIAL in normal operation. That is correct, not a +> limitation (§3.4). + +And the enum's own docstring (`core/planner.py:148-158`) is emphatic that the marker is a **declaration**, not +a directive: "v1 executes both modes sequentially — freeze section 5 forbids worker pools, queues and async +frameworks. The marker records that a future `ThreadPoolExecutor` would be sound." + +### 29. `CAPABILITY_ASSETS` — the asset-count-aware dispatch rule + +`core/planner.py:133-142`: + +| Capability | Assets | Note | +|---|---|---| +| `vqa` | 1 | | +| `caption` | 1 | | +| `grounding` | 1 | | +| `change` | 2 | | +| `optical_sar` | 2 | | +| `change_vqa` | 2 | "Two assets, like `change` — the difference is the output, not the input: a short language answer rather than a change map." | + +`TASK_CAPABILITY` (`core/planner.py:121-128`) maps the six routable tasks onto those keys, with +`UNSUPPORTED` **deliberately absent**: "it is a refusal, not a capability." + +```python +TASK_CAPABILITY: Mapping[Task, str] = { + Task.VQA: "vqa", + Task.CAPTION: "caption", + Task.GROUNDING: "grounding", + Task.CHANGE: "change", + Task.OPTICAL_SAR: "optical_sar", + Task.CHANGE_VQA: "change_vqa", +} +``` + +**`_indices_for`** (`core/planner.py:527-537`) is the dispatch rule: + +```python +needed = CAPABILITY_ASSETS.get(capability, 1) +return tuple(range(min(needed, asset_count))) +``` + +So a two-asset capability on a two-asset request takes `(0, 1)`; on a one-asset request it takes `(0,)` — a +**partial** index set, which the specialist's own `validate_request` then refuses with +`InvalidRequestError`. The planner's comment explains the division of labour: "the specialists validate their +own asset count and pairing rules, and duplicating that logic here would give two places to disagree." + +**The registry's spec table independently declares the same counts** +(`core/registry.py:192-259`: `requires_assets=1` for grounding, `2` for change / change_vqa / optical_sar, +`None` for vqa and caption), and `app/deployment.py:199-203` notes that `CapabilityRequirement` carries "**no +new numbers** — it is a view, not a third copy", with a test +(`test_the_adapter_artifact_table_matches_the_registry`) that keeps the two in agreement. + +**`change_vqa` is deliberately not keyed `vqa`.** `core/registry.py:221-226`: + +> The capability key is `change_vqa`, which is deliberately NOT `vqa`: a `vqa`-keyed row would make the +> planner route a two-asset change question to the one-asset VLM and silently answer about only one of the +> two acquisitions. + +### 30. What the router's ontology does and does not contain + +`configs/base.yaml:59-66` sets `router.num_tasks: 6` and lists six tasks: `vqa`, `caption`, `grounding`, +`change`, `optical_sar`, `unsupported`. + +**`change_vqa` is not in the router's label space.** `router/classifier.py:45-52` maps six labels to schema +tasks and `_assert_schema_alignment()` (`router/classifier.py:62-77`) fails loudly if `label_space.TASK_CLASSES` +contains a label with no schema mapping — but the direction checked is *label → schema*, not *schema → label*. +`Task.CHANGE_VQA` has no router label. + +`core/config.py:198-206` validates the two router invariants that *are* checked: + +```python +tasks = self.get("router.tasks") or [] +if "unsupported" not in tasks: + errors.append("router.tasks must include 'unsupported'") +if self.get("router.num_tasks") != len(tasks): + errors.append( + f"router.num_tasks={self.get('router.num_tasks')} does not match " + f"router.tasks length ({len(tasks)})" + ) +``` + +Consequence for the lifecycle: a `change_vqa` result is reachable on the live path only via `force_task`, or +via the planner's widening rule (§28.2) when the router returns `change` **with** `language_output` set. The +widening path is the designed route to it; `force_task` is the direct one. Note that the widening path +requires a **router** to have produced the `change` intent with `language_output=True` — see §38. + +--- + +## Part D — Preprocessing + +This part documents the preprocessing policy `configs/base.yaml` declares, and states precisely which parts +are executed. The distinction matters more here than anywhere else in the document, because **the config +declares more than the serving path implements**. + +### 31. The tiling policy — declared, validated, and not implemented in serving + +#### 31.1 The declared values + +`configs/base.yaml:16-24`: + +```yaml +image: + max_pixels: 25000000 + tile_size: 512 + tile_overlap: 128 + max_tiles: 64 + # plan section 9.1 tile policy: whole-image thumbnail first, then top-K tiles. + # max_tiles is the hard ceiling on tiles *examined*; top_k_tiles is how many + # are actually sent through a specialist. + top_k_tiles: 4 +``` + +| Key | Value | Meaning (from the config's own comment) | +|---|---|---| +| `image.tile_size` | `512` | the tile edge | +| `image.tile_overlap` | `128` | overlap between adjacent tiles | +| `image.max_tiles` | `64` | **hard ceiling on tiles *examined*** | +| `image.top_k_tiles` | `4` | how many tiles are **actually sent through a specialist** | +| `image.max_pixels` | `25000000` | see §25 | + +#### 31.2 The "whole-image thumbnail first, then top-K tiles" rule + +The rule is stated in the config comment above (`plan section 9.1 tile policy: whole-image thumbnail first, +then top-K tiles`), and the two-tier structure is what `max_tiles` versus `top_k_tiles` encodes: 64 is the +examination budget, 4 is the specialist budget. `preprocessing/imagery.py:10-15` names the same stage from +the other direction: + +> What this does NOT do +> --------------------- +> It does not resample, crop, or reproject. Those change the pixel grid, and the grounding specialist converts +> normalized boxes to pixel coordinates using the ORIGINAL raster's dimensions — a silent resize here would +> put every box in the wrong place. **Size changes belong in the tiling stage, which records what it did.** + +#### 31.3 The invariants that *are* enforced + +`core/config.py` validates two cross-keys, and both are enforced at load time: + +| Invariant | Check | Line | +|---|---|---| +| `top_k_tiles <= max_tiles` | `errors.append("image.top_k_tiles cannot exceed image.max_tiles")` | `core/config.py:215-216` | +| `processor_longest_edge <= tile_size` | raises if `proc_edge > tile_size`, naming F5-2 | `core/config.py:137-142` | +| `tile_overlap < tile_size` (both image and change) | asserted by test | `tests/test_config.py:219-222` | + +The second is the load-bearing one for cost, and its comment records the **measured** figures: the +processor's default `longest_edge` of 2048 upscales a 512 px tile 4× and then splits it into 17 sub-images +with 1142 prompt tokens, versus 1 image when pinned. "The plan estimated a 4x overrun; the real figure is +~17x." + +`tests/test_config.py:159-162` pins the pin itself: `test_processor_pin_is_tied_to_tile_size` asserts +`cfg.get("vlm.processor_longest_edge") == cfg.get("image.tile_size")`. So the two values are held equal by +test, not by comment. + +#### 31.4 The honest gap: no serving-path tiler exists + +A repository search for a tiling implementation — `def tile`, `class Tiler`, `iter_tiles`, `select_tiles` — +returns **nothing** outside `.scratch/`. `preprocessing/` contains exactly four modules: +`__init__.py`, `imagery.py`, `quality.py`, `raster.py`. There is no `preprocessing/tiling.py`. + +What the search *does* return for the `image.*` tiling keys is: `core/config.py` (validation), +`scripts/check_env.py:170-172` (a diagnostic print), and `tests/test_config.py` (assertions). The +`change.tile_size` key is a **different** key and is genuinely consumed — by +`training/change/train.py` and `scripts/{eval_change,sweep_change_threshold,train_change,verify_levir_real}.py` +via a `_fit_to_tile` helper that "Centre-crop[s] or zero-pad[s] every array to a `tile_size` square." + +So the precise statement is: + +| Item | Status | +|---|---| +| The tiling **policy** is declared in config | `IMPLEMENTED` | +| The policy's internal invariants are validated at load | `IMPLEMENTED` | +| The policy's invariants are pinned by tests | `VERIFIED` | +| `change.tile_size` (256) is consumed by training/eval scripts | `IMPLEMENTED` | +| A serving-path tiler that examines `max_tiles` and forwards `top_k_tiles` | **NOT FOUND — not established from the available evidence** | +| `image.tile_size` / `tile_overlap` / `max_tiles` / `top_k_tiles` read by any runtime code path | **NO** | + +The `image.*` keys are therefore *frozen declarations awaiting a consumer*. They are not dead values — they +are validated, hashed into `Config.hash`, and they constrain `vlm.processor_longest_edge` — but the specific +behaviour named in the config comment is not executed by the code in this repository. Status: `OPEN`. + +### 32. Percentile normalisation 2/98 for optical + +#### 32.1 The declared values + +`configs/base.yaml:26-31`: + +```yaml +optical: + normalization: percentile + lower_percentile: 2 + upper_percentile: 98 + canonical_channels: 12 +``` + +#### 32.2 What *is* implemented: a hardcoded 2/98 display stretch + +`preprocessing/imagery.py:60-67` — the shared raster-to-displayable conversion: + +```python +arr = array.astype(np.float32) +finite = arr[np.isfinite(arr)] +if finite.size: + lo, hi = np.percentile(finite, (2, 98)) + if hi > lo: + arr = (arr - lo) / (hi - lo) +arr = np.clip(arr, 0.0, 1.0) +return (arr * 255).astype(np.uint8) +``` + +The **same** 2/98 pair appears, independently hardcoded, in two more places: + +| Site | Line | +|---|---| +| `specialists/vqa/inference.py` | `:87` — `lo, hi = np.percentile(finite, (2, 98))` | +| `specialists/change/postprocess.py` | `:117` — `lo, hi = np.percentile(finite, (2, 98))` | + +Three implementations, one numeric pair, and **none of them reads `optical.lower_percentile` / +`optical.upper_percentile`**. The values coincide with the config; the config is not the source. + +Two properties of the implemented stretch are worth naming: + +- **It is deterministic**, and `preprocessing/imagery.py:7-8` makes that a stated guarantee: "The percentile + stretch is deterministic: the same file always yields the same array, so a grounding box and a VQA answer + describe identical pixels." That is why one shared implementation exists rather than two: "duplicating that + logic in two modules is how the two drift apart." +- **It uses only finite values** (`finite = arr[np.isfinite(arr)]`), so "a nodata sentinel does not crush the + dynamic range" (`preprocessing/imagery.py:29-30`). + +The stretch maps to a `(H, W, 3)` uint8 array. The band selection is the first three bands, with two +special cases (`preprocessing/imagery.py:50-58`): a >3-band raster is truncated to its first three, and a +1-band raster is repeated three times. + +#### 32.3 What is *not* implemented: the config-driven conditioning stage + +`specialists/optical_sar/radiometry.py:5-14` states the position without hedging: + +> `configs/base.yaml` declares two radiometric transforms: +> +> ``` +> optical: {normalization: percentile, lower_percentile: 2, upper_percentile: 98} +> sar: {representation: db, clip_min_db: -30, clip_max_db: 5} +> ``` +> +> **Neither is read by any code.** + +And `:29-37`: + +> NOT implemented: the percentile / dB **conditioning** stage that `base.yaml` names. The ruling +> (`PHASE14_CROMA_NORMALISATION_CHANGE.md` section 2.2) states the two are ordered stages, not alternatives, +> and that both must run. The second is specified here; the first remains unspecified and unsourced — no +> source examined in `docs/CROMA_NORMALISATION_UPSTREAM_EVIDENCE.md` uses or endorses a percentile stretch or +> a dB clip. Implementing a guess for it is exactly the fabrication the DEV-2 ruling exists to prevent, so it +> is left visibly absent rather than silently approximated. **Consequence: the two `optical.*` and three +> `sar.*` config keys are still read by no code.** That is recorded, not fixed. + +The pipeline diagram in the same module (`:39-44`) is the clearest statement of where the gap sits: + +``` +GeoTIFF -> band map + zero-fill + availability mask (sensor_adapter) + -> [percentile / dB conditioning] (NOT IMPLEMENTED) + -> per-channel mean +/- 2*std -> [0, 1] <- THIS MODULE + -> CROMA.encode +``` + +So: for the **display path** (VQA, caption, grounding, change postprocessing) a 2/98 percentile stretch +exists and is exercised. For the **CROMA fusion path** the percentile stage does not exist, and the module +that would own it says so. + +### 33. dB clip −30…+5 for SAR + +`configs/base.yaml:33-38`: + +```yaml +sar: + representation: db + clip_min_db: -30 + clip_max_db: 5 + canonical_channels: 2 +``` + +| Key | Value | Consumed by | +|---|---|---| +| `sar.representation` | `"db"` | **nothing** | +| `sar.clip_min_db` | `-30` | **nothing** | +| `sar.clip_max_db` | `5` | **nothing** | +| `sar.canonical_channels` | `2` | validation only, via `croma.sar_channels` | + +`croma.sar_channels: 2` **is** validated: `core/config.py:167-168` raises unless +`self.get("croma.sar_channels") == 2`, with the message "croma.sar_channels must be 2 (CROMA s1_channels is +fixed)". The comment above the check explains the direction: "CROMA expects exactly 2 SAR channels (VV, VH)." + +The three `sar.*` conditioning keys (`representation`, `clip_min_db`, `clip_max_db`) are read by no code, as +`specialists/optical_sar/radiometry.py:36-37` states explicitly. + +### 34. What *is* implemented on the CROMA input path — mean ± 2σ + +`specialists/optical_sar/radiometry.py:21-27`: + +> Implemented: the **encoder-input** stage — per-channel `mean +/- 2*std` -> `[0, 1]`, the transform the CROMA +> authors' own README instructs users to apply … (corroborated by the authors' instruction for their released +> benchmark tensors: *"convert tensors to floats and divide by 255"*). + +The transform, quoted verbatim from the module (`:48-55`): + +```python +min_value = x[:, c].mean() - 2 * x[:, c].std() +max_value = x[:, c].mean() + 2 * x[:, c].std() +img = (x[:, c] - min_value) / (max_value - min_value) * 255.0 +img = clip(img, 0, 255).to(uint8) +# before the forward pass: +x = x.float() / 255 +``` + +**A deliberate deviation is documented and justified.** The README computes the mean over the **whole batch**, +which makes one image's encoding depend on its neighbours. `:57-69`: + +> That makes a single image's encoding a function of its neighbours, which is unacceptable for a serving +> system whose whole point is reproducibility: the same query must not produce a different answer because +> another request was batched alongside it. +> +> This module computes the window **per sample**, over that sample's channel only. + +This is the same reproducibility principle that makes `EvidenceEngine.aggregate` pure and +`evidence_digest()` exist. + +### 35. `change.tile_size` — a different key, genuinely consumed + +`configs/base.yaml:182-197`: + +```yaml +change: + tile_size: 256 + tile_overlap: 0 + threshold: 0.50 + min_component_pixels: 32 + encoder: resnet18 + sa_mode: PAM + pretrained: true + ... +``` + +| Key | Value | Consumed by | Notes | +|---|---|---|---| +| `change.tile_size` | `256` | `training/change/train.py` + 4 scripts | the detector's input tensor; **not** `image.tile_size` (512) | +| `change.tile_overlap` | `0` | test assertion only | `tests/test_config.py:222` | +| `change.threshold` | `0.50` | `specialists/change/specialist.py:900` | `threshold=float(config.get("change.threshold", 0.5))` | +| `change.min_component_pixels` | `32` | `specialists/change/specialist.py:901` | `min_component_pixels=int(config.get("change.min_component_pixels", 32))` | +| `change.encoder` | `resnet18` | validated required | `core/config.py:209-210` | +| `change.sa_mode` | `PAM` | validated ∈ `{BAM, PAM}` | `core/config.py:211-212` | + +Note the two tile sizes are different **and both real**: the VLM/fusion path is pinned to 512 via +`vlm.processor_longest_edge`, and the change detector is pinned to 256. `scripts/train_change.py:159` prints +"tile size : 256px (encoder stride 8)", which is the reason 256 is the value and not something else. + +`change.threshold` and `change.min_component_pixels` are the two keys that shape the change output, and both +are read with the config value as the primary and the same number as the fallback — so a config load failure +cannot silently change detector behaviour. + +--- + +## Part E — The execution events, the trace, and the live path + +### 36. The eight execution events + +`frontend/assets/js/core.js:616-620`: + +```javascript +SQ.EVENT_NAMES = [ + 'QUERY_RECEIVED', 'QUERY_UNDERSTOOD', 'ROUTE_SELECTED', + 'SPECIALIST_STARTED', 'SPECIALIST_COMPLETED', + 'EVIDENCE_GENERATED', 'CONFIDENCE_COMPUTED', 'RESULT_ASSEMBLED' +]; +``` + +And the eight stages they drive (`frontend/assets/js/core.js:605-614`): + +| Stage id | Label | Driven by event | +|---|---|---| +| `QUERY` | QUERY | `QUERY_RECEIVED` | +| `UNDERSTAND` | UNDERSTAND | `QUERY_UNDERSTOOD` | +| `ROUTE` | ROUTE | `ROUTE_SELECTED` | +| `ANALYZE` | ANALYZE | `SPECIALIST_STARTED` | +| `GROUND` | GROUND | `SPECIALIST_COMPLETED` | +| `EVIDENCE` | EVIDENCE | `EVIDENCE_GENERATED` | +| `CONFIDENCE` | CONFIDENCE | `CONFIDENCE_COMPUTED` | +| `ANSWER` | ANSWER | `RESULT_ASSEMBLED` | + +The event protocol and its measured trace fill (94.4444 %) are the subject of +[06](06-evidence-and-confidence.md) §13 and are cross-referenced rather than restated here. What matters for +*this* document is the mapping boundary: the eight events are a **frontend** protocol emitted by +`SQ.run`/`SQ.ingest` (`frontend/assets/js/core.js:742`), not a backend protocol. The backend emits +`ControllerState` trace steps (§2.2). `frontend/assets/js/mission.js:361-371` is where the two vocabularies are +reconciled, and it does so by mapping `SPECIALIST_STARTED → 'PREPROCESS'` and `SPECIALIST_COMPLETED → +'EXECUTE'` — which is how the frontend renders the state the backend never emits. + +### 37. The client-side policy is not the router + +`frontend/assets/js/core.js:623-675` defines `SQ.policy(query)` — a deterministic regex policy that returns +`{task, specialists, route}`. It is explicitly labelled `deterministic: true` and its rule list is returned in +`route.rules` so a UI can display which rules fired. `frontend/assets/js/mission.js` has a parallel +`interpret()`. + +**This is not `router/classifier.py`.** It is a client-side stand-in, and the distinction matters because the +task names differ: `SQ.policy` produces `'CHANGE_ANALYSIS'`, `'CHANGE_VQA'`, `'SAR_ANALYSIS'`, `'GROUNDING'`, +`'VLM_CAPTION'`, `'VLM_QA'`, and specialist names like `'CHANGE_DETECTOR'`, `'EVIDENCE_EXTRACTOR'`, +`'VLM_REASONER'`, `'GROUNDING_HEAD'`, `'IMAGE_REGISTRATION'`. The backend's `Task` enum values are lowercase +and are `vqa`, `caption`, `grounding`, `change`, `optical_sar`, `change_vqa`, `unsupported`. The mapping +between the two happens in `mission.js` when it builds `force_task`. + +Two comments in `SQ.policy` record real routing defects it fixes, and they are worth quoting because they are +the same class of bug the backend planner's widening rules address: + +> `built` was removed and `new` counts only outside a `where` question. "Where is the built-up area?" — the +> architecture page's OWN sample — previously fired `intent.change` on "built", then `intent.quantify` on +> "area", and was answered as CHANGE_VQA with a CHANGE_DETECTOR specialist: a location question routed to a +> change question. + +The fix is ordering plus a guard: `where` is evaluated **first**, and `newAsChange = /\bnew\b/.test(q) && +!where`. + +### 38. The live path attaches no router — `force_task` is de facto required + +This is the most consequential lifecycle fact about the deployed system, and it is established by three +independent pieces of evidence. + +#### 38.1 `build_serving_controller` passes no router + +`app/serving.py:247-265`: + +```python +def build_serving_controller(config=None, *, device=None) -> AnalysisController: + cfg = load_config() if config is None else config + registry = build_serving_registry(cfg, device=device) + return AnalysisController( + registry=registry, + planner=PolicyPlanner(registry), + config=cfg, + ) +``` + +Its own docstring says so explicitly (`app/serving.py:254-257`): + +> Constructed with `registry=`, `planner=` and `config=` only. **No router is attached**, so a caller drives +> it with `AnalysisRequest(..., force_task=...)`; a natural-language router can be supplied by the caller's +> own composition if the router weights are available. + +#### 38.2 `IntentRouter` is never constructed outside tests + +A repository-wide search for `IntentRouter(` and `router=` in Python — excluding `.scratch/`, `.venv/` and +`tests/` — returns **nothing**. `app/space_app.py:184` calls `build_serving_controller()`, and +`app/serving.py` is the only constructor. So the router is *implemented and trained* but **not composed into +the serving process**. + +#### 38.3 The consequence, and how the system compensates + +Because `self.router is None`, `_route` returns `None` for any request without `force_task`, and +`core/controller.py:327-334` raises `UnsupportedQueryError(..., recoverable=False)`. So on the live +deployment: + +| Request | Outcome | +|---|---| +| `force_task` present | routed as `source="forced"`, `confidence=1.0`, `above_threshold=True`; the plan runs | +| `force_task` absent | **`unsupported_query`**, `recoverable=false` — no plan is built | + +The frontend compensates by always sending it. `frontend/assets/js/live.js:302`: +`if (opts.forceTask) body.force_task = opts.forceTask;` — and `:288` records that "`force_task` is omitted +rather than sent as null" because the schema is `extra="forbid"`-strict about unknown values while a `null` +for a declared optional field is legal. `frontend/assets/js/mission.js:661` sets +`force_task: forced` from the page's own lexical `interpret()`. + +`docs/FRONTEND_INTEGRATION.md:94` states the operational rule: "**Always send `force_task` when the UI knows +the intent.** The user picked …" — and `:89` shows the field as `force_task: "change_vqa", // optional; omit +to let the router decide`. + +#### 38.4 What follows from this, stated precisely + +| Claim | Status | +|---|---| +| The router is `IMPLEMENTED` and trained | `VERIFIED` (`router/classifier.py`, `router/adapter.py`, `router/encoder.py`, `router/fallback.py`, `router/label_space.py`) | +| The router has a measured validation number | `MEASURED` — 0.965116, **validation, ungated, n = 86**; the **test split was NOT RUN** | +| The router is attached to the deployed controller | **NO** — no `router=` argument exists outside tests | +| `force_task` is required on the live path | **YES**, in effect | +| `Intent.source` on the live path | always `"forced"` | +| The planner's `LEXICAL_FALLBACK_DISCOUNT` is exercised on the live path | **NO** — it keys on `source == "lexical_fallback"`, which cannot occur when routing is forced | +| The planner's uncertainty clause (`not above_threshold`) is exercised on the live path | **NO** — a forced prediction is constructed with `above_threshold=True` | +| The planner's widening rules fire on the live path | **only** the `spatial_output + language_output` rule, and only when the caller's own `force_task`/`Intent` says so; the change+language rule requires `intent.language_output`, which a forced `Intent` leaves at its default `True` (`core/schemas.py:102`) | + +That last row is worth being careful about. A forced `Intent` is constructed as +`Intent(task=request.force_task, confidence=1.0, source="forced")` (`core/controller.py:468-472`), so +`language_output` takes its **default of `True`** and `temporal`/`spatial_output` take their defaults of +`False` — except that `Intent._consistency` forces `temporal=True` for a change-family task. Therefore a +`force_task="change"` request on a two-asset plan **does** satisfy the widening rule's `intent.language_output` +condition, and if `change_vqa` is registered the planner will add a change-VQA step. So the widening path is +reachable on the live deployment through `force_task` — but the *uncertainty* path is not. + +Status: `OPEN` for "the router is not composed into the serving path". This is a composition gap, not a +missing capability, and it is the same shape of defect as the calibration wiring gap documented at +`core/controller.py:203-216`. + +```mermaid +sequenceDiagram + participant FE as Browser (mission.js) + participant GW as Gateway (Render) + participant SP as Space (build_space_app) + participant CO as AnalysisController + participant PL as PolicyPlanner + participant RG as SpecialistRegistry + participant SPEC as Specialist + + FE->>FE: SQ.policy(query) / interpret(query) + FE->>FE: choose force_task (de facto required) + FE->>GW: POST /api/v1/analyze {assets:[handle], query, force_task} + GW->>GW: validate · CORS · size · rate limit · request_id + GW->>SP: forward to /v1/analyze + SP->>SP: AnalysisRequest.model_validate(payload) + SP->>SP: handle -> AssetStore.get() -> path + SP->>CO: controller.run(request) + CO->>CO: RECEIVE + CO->>CO: PARSE (inspect_raster headers only) + CO->>CO: VALIDATE (_route -> forced Intent) + Note over CO: router is None on the live path
force_task is therefore required + CO->>PL: planner.plan(prediction, request, assets) + PL-->>CO: ExecutionPlan (steps, mode, notes) + CO->>CO: PLAN + loop each plan step + CO->>RG: registry.build(capability) (memoised) + RG-->>CO: RegistryEntry (AVAILABLE / DEGRADED / UNAVAILABLE) + CO->>SPEC: validate_request(SpecialistRequest) + CO->>SPEC: execute(SpecialistRequest) + SPEC-->>CO: SpecialistResult | typed error + CO->>CO: EXECUTE (record outcome, timing, error) + end + CO->>CO: AGGREGATE (evidence + failures + synthesis, renumbered) + CO->>CO: VERIFY (sources assertion, contradiction) + CO->>CO: RESPOND (outputs, confidence, finished_at) + CO-->>SP: ResultEnvelope + SP-->>GW: 200 envelope (JSON) + GW-->>FE: envelope + error translation +``` + +### 39. Degraded versus error — the distinction, stated once + +The codebase applies the same rule in five places, and it is worth collecting them because the lifecycle +depends on all five agreeing. + +| Situation | Behaviour | Why | +|---|---|---| +| An **optional artifact is absent** (no change checkpoint, no fusion head, no calibration) | **degrade** — construct, mark degraded, answer honestly | `app/space_app.py:24-26`: "Absent artifacts degrade; *corrupt* artifacts raise `ModelLoadError`. A Space that refuses to boot because an optional artifact is missing is worse than one serving a reduced capability set." | +| An artifact is **present but corrupt** | **raise** `ModelLoadError` | `core/registry.py:63-75`: "a `ModelLoadError` / `ModelUnavailableError` raised during construction maps to `UNAVAILABLE` (a defect, surfaced loudly), NOT retried as `DEGRADED`. The registry is not the place to helpfully re-add a fallback the builder refused." | +| A **specialist step fails** | **record and continue** | `core/controller.py:16-20`: partial failure never erases evidence; no early exit | +| A **plan cannot run at all** | **refuse** — a successful run with no steps | `core/planner.py:75-81` | +| The **primary specialist failed** | **no confidence** — `raw=0.0`, `degraded=True` | `core/controller.py:54-58`: reporting a secondary's score "would launder a failure into a number" | + +The change specialist encodes the absent-vs-corrupt distinction in its builder, and the registry's docstring +quotes it as the canonical statement (`core/registry.py:68-71`, quoting +`specialists/change/specialist.py:834-838`): + +> silently running an untrained model because a real checkpoint failed to load would be the worst outcome. + +Two further distinctions of the same family, both stated in the source: + +- **"we could not measure" ≠ "we measured failure."** `specialists/change/specialist.py:279-281`, with the + distinction recorded in a non-zero `reason` string. +- **"could not be built" ≠ "was never asked for."** `core/registry.py:49-61` — `build()` returns an + `UNAVAILABLE` entry rather than raising (except for an unknown capability) "so the controller can say + 'optical_sar was planned but could not be constructed: CROMA could not load' instead of either crashing or + pretending the step never existed." + +### 40. The three registry states, and how they reach the client + +`RegistryState` (`core/registry.py:102-107`): `AVAILABLE` (`"available"`), `DEGRADED` (`"degraded"`), +`UNAVAILABLE` (`"unavailable"`). + +`PLANABLE_STATES = frozenset({AVAILABLE, DEGRADED})` (`core/registry.py:113-115`) — DEGRADED is planable +deliberately: "a degraded specialist still runs and returns an honest, degraded result, so a local install with +no trained weights exercises real code paths." + +`_degradation_of` (`core/registry.py:527-548`) is duck-typed and conservative: it checks `has_checkpoint`, +`has_head`, `has_encoder` for `False`, and `model is None`; "a specialist that does not expose any recognised +flag is reported AVAILABLE, because inventing a degradation the specialist did not declare would be worse than +reporting a clean build." + +**The registry vocabulary never reaches a client.** `app/deployment.py:185-189`: + +```python +REGISTRY_TO_CONTRACT: Mapping[str, str] = { + "available": "loaded", + "degraded": "loaded", + "unavailable": "absent", +} +``` + +and `app/deployment.py:179-184` explains why `unavailable → "absent"` is the right default: + +> `UNAVAILABLE` is deliberately mapped to `"absent"` here and refined to `"unavailable"` only when the +> artifacts are demonstrably present. Mapping it straight to `"unavailable"` would be the single most damaging +> mistranslation available in this module: it would label every missing artifact a defect. + +`CONTRACT_STATES = frozenset({"loaded", "absent", "unavailable", "not_requested", "evicted"})` +(`app/deployment.py:175-177`) is listed as data "so a test can assert the adapter never emits anything outside +it — the whole point of an adapter is that its output range is checked." + +The **run-level** `SpecialistResult.degraded` and the **capability-level** `RegistryState` are therefore two +different vocabularies answering two different questions, and `DeploymentReport.status` +(`app/deployment.py:704-725`) is a third: + +> `degraded` = *"the service is up but at least one expected artifact is absent"*. Two things follow … a +> capability that is merely `not_requested` does **not** degrade the service — under `lazy_load: true` that is +> every capability on a fresh process, and reporting `degraded` for a healthy idle service would make the +> field useless as a probe; `unavailable` (present but broken) is a defect. It is reported as `error`, not +> `degraded`, because the two have different remedies. + +### 41. `_gpu_durations` — declared, never executed + +`app/space_app.py:109-116` transcribes the frozen ZeroGPU durations: + +| Task | Duration (seconds) | +|---|---| +| `vqa` | 20 | +| `caption` | 20 | +| `grounding` | 45 | +| `change` | 30 | +| `optical_sar` | 45 | +| `change_vqa` | 30 | + +`change_vqa` "has no key of its own and reuses `change`, because adding a key would move `Config.hash` off +`78f1e3700da15aa1`" (`app/space_app.py:106-108`). + +`decorate_gpu` (`app/space_app.py:144-165`) applies `spaces.GPU(duration=duration)` when the `spaces` package +is importable, and an **identity decorator** when it is not. The module docstring records the consequence +without softening it (`app/space_app.py:40-42`): + +> The consequence is recorded in `docs/PHASE19_FINAL_HARDENING.md`: the ZeroGPU decoration has **never +> executed** here. It is specified from finding C-8 and the frozen `gpu_duration_*` values, and that is all it +> is. + +`decorate_gpu` raises `KeyError` for a task with no declared duration — "A task with no declared duration is a +programming error, not a default: silently picking a duration would reserve the wrong amount of the 5 +GPU-minute daily budget." + +--- + +## Part F — Worked examples + +These examples are constructed from the code paths documented above. The trace shapes are exact; the +`duration_ms` and id values are illustrative. + +### 42. Worked example — a change-VQA request end to end + +**Request.** +```json +{ + "assets": ["asset_7c6f64a4a4c821e25d518467a1cc5d47", "asset_b1f0c2d3e4a59687766554433221100f"], + "query": "How much new construction appeared between these two dates?", + "force_task": "change_vqa" +} +``` + +**Stage 1 — the Space rewrites handles to paths.** `app/space_app.py:705-724` resolves both handles through +`store.resolve_many`, raising `input_error` with "One or more asset handles are unknown or have expired." on +failure, then rebuilds the request with `model_copy(update={"assets": [str(handle.path) for handle in +handles]})`. Note `model_copy` rather than mutation, "because `AnalysisRequest` is the contract's model and a +handler must not rewrite a validated request in place." + +**Stage 2 — `RECEIVE`.** `run_id = "run_9c1f4e7a2b30"` (generated; none was supplied). +`trace.inputs = ["t1.tif", "t2.tif"]` — basenames, not the resolved paths. +```json +{"asset_count": 2} +``` + +**Stage 3 — `PARSE`.** Both rasters are inspected header-only. Both are 4-band → `infer_modality(4)` → +`optical`. +```json +{"inputs": ["t1.tif", "t2.tif"], "modalities": ["optical"], "query_length": 54} +``` + +**Stage 4 — `VALIDATE`.** `force_task` is set, so the router is bypassed and a synthetic prediction is built. +`Intent._consistency` forces `temporal=True` because the task is in the change family. `trace.task = +Task.CHANGE_VQA`. +```json +{"assets": 2, "force_task": "change_vqa", + "router": {"task": "change_vqa", "modality": "unknown", "temporal": true, + "spatial_output": false, "language_output": true, "confidence": 1.0, + "source": "forced", "above_threshold": true, "used_fallback": false, + "fallback_rule": null}} +``` + +**Stage 5 — `PLAN`.** `_refusal_for(CHANGE_VQA, 2)` returns `None`. The primary step is `change_vqa` with +`reason="task:change_vqa"`, `required=True`, `params={}`, `asset_indices=(0, 1)`. `_widening_steps`: +`task is Task.CHANGE` is **false** (it is `CHANGE_VQA`), so the change+language rule does not fire; +`intent.spatial_output` is `False`, so the spatial rule does not fire. `prediction.above_threshold` is `True`, +so no explanation step. `_drop_unavailable` keeps the step because `change_vqa` is registered. +`_mode_for` returns `SEQUENTIAL` (one step). +```json +{"plan": {"steps": [{"step_id": "step_001", "capability": "change_vqa", + "specialist": "change_vqa", "asset_indices": [0, 1], + "reason": "task:change_vqa", "required": true}], + "mode": "sequential", "refused": false, "refusal": null, "uncertain": false, + "router_source": "forced", "effective_confidence": 1.0, "notes": []}} +``` + +**Stage 6 — `EXECUTE`.** `registry.build("change_vqa")` runs `_wired_change_vqa_builder`, which passes the +shared STANet checkpoint when it exists and the change-VQA head when it exists, `None` otherwise. Suppose both +exist: the entry is `AVAILABLE`. `validate_request` requires exactly two assets and calls `_assess_pair` +(temporal distinctness, CRS compatibility, registration). `execute` runs. +```json +{"step_id": "step_001", "capability": "change_vqa", "ok": true, "skipped_reason": null, + "error_code": null, "duration_ms": 14203.882, "registry_state": "available"} +``` + +**Stage 7 — `AGGREGATE`.** One result, so `_synthesis_evidence` returns `[]` (fewer than two results). No +failures. The engine's collection is renumbered to `evidence_001…`. +```json +{"evidence": {"returned": 3, "total_before_limit": 3, "dropped_duplicates": 0, + "dropped_over_limit": 0, "truncated": false, + "sources": ["change_vqa"], "types": ["change_map", "statistic"]}, + "degraded": false} +``` + +**Stage 8 — `VERIFY`.** `expected = {"change_vqa"}`, `observed = {"change_vqa"}` → no `evidence_loss`. One +result with boxes in one coordinate system → no contradiction (a single box cannot contradict itself, and the +pairwise loop needs at least two). +```json +{"contradiction": false} +``` + +**Stage 9 — `RESPOND`.** `trace.outputs = ["evidence_001", "evidence_002", "evidence_003"]`; +`trace.confidence = result.confidence`. +```json +{"answer_length": 187} +``` + +### 43. Worked example — a refusal + +**Request.** `{"assets": ["a.tif"], "query": "What is the GDP of France?"}` — routed (or forced) to +`unsupported`. + +`_refusal_for` returns refusal #1 immediately, before any step is built: + +```json +{"plan": {"steps": [], "mode": "sequential", "refused": true, + "refusal": {"code": "unsupported_query", "reason": "task_unsupported", + "user_message": "No specialist supports this request."}, + "uncertain": false, "router_source": "forced", + "effective_confidence": 1.0, "notes": ["refused:task_unsupported"]}} +``` + +The envelope carries `result.answer == "No specialist supports this request."`, +`result.confidence.raw == 0.0`, `calibrated == null`, `degraded == true`, +`degradation_reason == "refused:task_unsupported"`, `result.warnings == +["refused:task_unsupported"]`, and `trace.fallbacks == ["refused:task_unsupported"]`. The state sequence is +RECEIVE → PARSE → VALIDATE → PLAN → AGGREGATE(`{"refused": true, …}`) → VERIFY(`{"steps": 0}`) → +RESPOND(`{"refused": true}`). + +### 44. Worked example — partial failure producing a degraded envelope + +**Request.** `{"assets": ["a.tif", "b.tif"], "query": "What changed, and describe the later scene?", +"force_task": "change"}`. + +**PLAN.** Primary step `change` (`reason="task:change"`, `required=True`, `indices=(0,1)`). `_widening_steps`: +`task is CHANGE` and `asset_count >= 2` and `intent.language_output` is `True` (the forced `Intent`'s +default). If `change_vqa` is **known**, a `change_vqa` step is added with +`reason="task:change+language_output:answer"`, `required=False`; the note +`"change + language request: added a change-VQA step"` is appended. If `change_vqa` is **not** known, a +`caption` step is added at index `asset_count - 1` with `reason="task:change+language_output"` and the note +`"change + language request: added a caption step"`. + +**EXECUTE.** Suppose the change step succeeds but the change-VQA step fails with an unhandled exception. +`_execute_one` logs the traceback and returns a `StepOutcome` with the fixed-message `SpecialistError`. Two +`EXECUTE` steps are recorded: `step_001 ok=true`, `step_002 ok=false, error_code="specialist_error"`. + +`trace.errors` gains: +```json +{"step_id": "step_002", "capability": "change_vqa", "code": "specialist_error", + "message": "The step failed with an unhandled error. Internal detail is withheld; see the server-side diagnostics.", + "recoverable": false} +``` + +**AGGREGATE.** `results = [change_result]`. `failure_evidence` produces one `STATISTIC` item for +`change_vqa` with `payload.step_failed == true` and `payload.code == "specialist_error"`. +`_synthesis_evidence` returns `[]` because `len(results) < 2` — note that a *failure* does not count as a +result, so the synthesis record does **not** appear here. `_warnings` appends +`"change_vqa failed (specialist_error): "`. + +**Confidence.** `primary_task = trace.task = Task.CHANGE`. `primary = next((r for r in results if r.task is +Task.CHANGE), None)` → the change result **is** found. So the run's confidence is the change specialist's, +calibrated, with `components == {"change_confidence": }`. The failure does **not** zero the confidence — +but `_degraded` returns `True` because `any(not o.ok for o in outcomes)`. + +**VERIFY.** `expected = {"change"}`, `observed = {"change"}` → no `evidence_loss`. + +**Result.** `answer` comes from rule 1 (no vqa/caption succeeded), then rule 2 (the change result has an +answer), giving `"[change] No change detected above threshold 0.50. …"` or the region-bearing text. +`result.degraded == True`; `result.warnings` carries the failure line; `result.evidence` carries the +`STATISTIC` failure record. The envelope is a **200**, not a 5xx. + +--- + +## Part G — Boundaries + +### 45. What is NOT RUN, OPEN, BLOCKED or REJECTED for this topic + +| # | Item | Status | Evidence | +|---|---|---|---| +| 1 | `PREPROCESS` emitted as a trace state | `OPEN` | declared in `core/schemas.py:84`, `configs/base.yaml:247`, `mission.js:361`; no emitter in `core/controller.py`; test at `tests/unit/test_controller.py:385-393` lists eight | +| 2 | `agent.states` read by any code | `OPEN` | no Python reader | +| 3 | `agent.max_specialists` wired to `PolicyPlanner(max_steps=…)` | `OPEN` | key at `configs/base.yaml:239`; `PolicyPlanner(registry)` at `app/serving.py:263`; no reader of the key | +| 4 | `agent.unload_after_workflow` read by any code | `OPEN` | no Python reader | +| 5 | `SETTLED_CONFIDENCE` used as a gate | `OPEN` | declared/exported at `core/planner.py:116`; no comparison in `plan()` | +| 6 | `ExecutionTrace.validation` populated | `OPEN` | no writer anywhere; always `{}` | +| 7 | Router composed into the serving controller | `OPEN` | `app/serving.py:247-265` passes no `router=`; `IntentRouter(` never constructed outside tests | +| 8 | Serving-path tiler honouring `image.max_tiles` / `top_k_tiles` | `OPEN` | no tiling module in `preprocessing/`; keys read only by config validation, `scripts/check_env.py` and tests | +| 9 | `optical.lower_percentile` / `upper_percentile` read by code | `OPEN` | `specialists/optical_sar/radiometry.py:36-37` states it; the implemented 2/98 is hardcoded in three places | +| 10 | `sar.representation` / `clip_min_db` / `clip_max_db` read by code | `OPEN` | `specialists/optical_sar/radiometry.py:36-37` | +| 11 | `image.max_pixels` passed to `inspect_raster` on the live path | `UNKNOWN` | `core/controller.py:1253-1262` passes no `max_pixels`; whether the deployed build differs is `UNKNOWN — not established from the available evidence` | +| 12 | Asset modality overrides reachable from the HTTP surface | `IMPLEMENTED (not reachable)` | `_normalise_modality_overrides` exists; `AnalysisRequest` has no field for it | +| 13 | A per-step timeout | `REJECTED` | `core/controller.py:548-552` — needs a worker process or signal handler, which conflicts with the single-process monolith constraint | +| 14 | Any narrative composed from evidence by a template | `REJECTED` | `core/controller.py:946-948` — "that is the VLM's job, and only when it actually ran" | +| 15 | Averaging specialist confidences | `REJECTED` | `core/controller.py:43-47` | +| 16 | Resolving contradictory spatial claims by precedence weight | `REJECTED` | `core/controller.py:60-66` — "any such weight is a value judgment with no measurement behind it" | +| 17 | Parallel execution of plan steps | `NOT RUN` | `PlanMode.PARALLEL_SAFE` is a declaration; "v1 executes both modes sequentially" | +| 18 | ZeroGPU decoration executed | `NOT RUN` | `app/space_app.py:40-42` — "has **never executed** here" | +| 19 | An end-to-end benchmark of the request lifecycle | **does not exist** | no system-level accuracy is claimed | +| 20 | Router test split | `NOT RUN` | 0.965116 is **validation, ungated, n = 86**; the test split was not run | +| 21 | B-07 (tunnel gap; the `transport_mode: auto` fallthrough) | `OPEN` | patch prepared, **NOT deployed** — see [02](02-deployment-topology.md) §6 | +| 22 | B-02 (`codespace_name` trailing `\n`) | `OPEN` (cosmetic) | see [02](02-deployment-topology.md) §7 | + +### 46. Where the evidence lives + +| Claim area | File(s) | +|---|---| +| The controller, the FSM, `StepOutcome` | `core/controller.py` (1331 lines) | +| All shapes and enums | `core/schemas.py` (462 lines) | +| The planner, refusals, widening, `CAPABILITY_ASSETS` | `core/planner.py` (661 lines) | +| The registry, three states, `PLANABLE_STATES` | `core/registry.py` (639 lines) | +| The error taxonomy and `scrub_paths` | `core/errors.py` | +| Config load, validation, `Config.hash` | `core/config.py` (275 lines) | +| The frozen values | `configs/base.yaml` (295 lines) | +| Raster contract, modality inference | `preprocessing/raster.py` | +| The implemented 2/98 display stretch | `preprocessing/imagery.py` | +| The CROMA input stage and the not-implemented conditioning | `specialists/optical_sar/radiometry.py` | +| Change pair checks and degraded sites | `specialists/change/specialist.py`, `specialists/change/stanet.py` | +| Optical-SAR pair assessment | `specialists/optical_sar/specialist.py` | +| The specialist interface | `specialists/base.py` | +| Evidence aggregation and `confidence_for` | `evidence/engine.py` | +| The serving composition root | `app/serving.py` | +| The four HTTP routes, asset store, GPU decoration | `app/space_app.py` (735 lines) | +| Health / capability payloads, contract vocabulary | `app/deployment.py` (1232 lines) | +| Router inference and the lexical fallback | `router/classifier.py` (474 lines) | +| The eight execution events and the run engine | `frontend/assets/js/core.js`, `frontend/assets/js/mission.js` | +| The state-order test | `tests/unit/test_controller.py:379-395` | +| The tiling invariants | `tests/test_config.py:159-162`, `:214-222` | +| The endpoint contract | `docs/API_CONTRACT.md` §2.4, §4, §5 | +| The frontend integration rule for `force_task` | `docs/FRONTEND_INTEGRATION.md:89-94` | + +### 47. Cross-references + +| For | See | +|---|---| +| The evidence schema, `EvidenceType`, the aggregation pipeline | [06 Evidence and confidence](06-evidence-and-confidence.md) §2–§6 | +| The confidence system, temperature scaling, the measured ECE result | [06](06-evidence-and-confidence.md) §7–§11 | +| `ExecutionTrace` and the eight events in depth | [06](06-evidence-and-confidence.md) §12–§14 | +| The router, the five-head adapter, the label space, `interpret()` vs `chooseTask()` | [04 Router](04-router.md) | +| Per-specialist entry points, preprocessing, postprocessing, outputs | [05 Specialists](05-specialists.md) | +| The frozen config registry and its invariants | [07 Configuration freeze](07-configuration-freeze.md) | +| The four endpoints and the error envelopes | [08 API contract](08-api-contract.md) | +| The four tiers, the tunnel, the wake flow | [02 Deployment topology](02-deployment-topology.md) |