# 03 — Request Lifecycle **Parent:** [Architecture hub](README.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) |