SatQuery / docs /DEVELOPMENT.md
thundercode's picture
release: add docs/DEVELOPMENT.md
c049289 verified
|
Raw
History Blame Contribute Delete
69.3 kB
# Development Guide
**Status tags:** `IMPLEMENTED` Β· `VERIFIED` Β· `MEASURED` Β· `ATTEMPTED` Β· `NOT RUN` Β· `BLOCKED` Β·
`DEFERRED` Β· `OPEN` Β· `RESOLVED` Β· `BY DESIGN`.
This is the contributor-facing guide to the SatQuery AI codebase. It answers: *what do I need to
install*; *how do I run it*; *what is each directory for*; *what rules will break the build if I break
them*; *how do I add a component*; *how do I test*; and *which environment traps will cost me an hour if
nobody tells me about them*.
Every command, path, timeout and rule below comes from a file that was read. Where the evidence does not
settle a question, the text says `UNKNOWN β€” not established from the available evidence` rather than
guessing.
> **Read this first.** `configs/base.yaml` is the single registry, and its hash β€” **`78f1e3700da15aa1`**
> β€” is frozen. **Editing the config moves the hash and invalidates every artifact keyed to it.** This is
> the one rule in the repository whose violation is not locally reversible (Β§7).
---
## Table of contents
**Part I β€” Prerequisites and setup**
1. Prerequisites
2. Local setup
3. Running the service locally
**Part II β€” Repository layout**
4. The top-level directory map
5. Where the contracts live
6. The package layout inside `core/`, `specialists/`, `app/`
**Part III β€” The configuration discipline**
7. "No magic numbers in Python"
8. What happens if you break the rule
9. The hash-exempt path: environment overrides
10. The invariants the loader enforces
**Part IV β€” Contract-first development**
11. `core/schemas.py` is the binding contract
12. The specialist interface
13. How to add a new specialist
14. How evidence is emitted
**Part V β€” The test workflow**
15. Running tests
16. The evidence-class markers
17. The known failures, and why they are not regressions
**Part VI β€” Scripts and notebooks**
18. The class of scripts under `scripts/`
19. `notebooks/`
**Part VII β€” Training entry points**
20. Local training (router, grounding, change, optical-SAR)
21. External GPU training (change-VQA, VLM LoRA)
22. Calibration
**Part VIII β€” Coding conventions**
23. What the code actually does
**Part IX β€” Known development traps**
24. The traps, in one table
25. Trap 1 β€” the stale untracked `deploy/`
26. Trap 2 β€” the dead sandbox proxy
27. Trap 3 β€” pytest only in the venv
28. Trap 4 β€” Chrome drops synthetic CDP key events
29. Trap 5 β€” Cloudflare `_headers` concatenate
30. Trap 6 β€” the annotation-scope trap
31. Trap 7 β€” the full-suite bulk-delete guard
32. Trap 8 β€” the stale serve process
**Part X β€” Status and evidence**
33. `NOT RUN` / `OPEN` / `BLOCKED` / `UNKNOWN` for development
34. Where the evidence lives
---
# Part I β€” Prerequisites and setup
## 1. Prerequisites
| Requirement | Value | Source |
|---|---|---|
| Python | **3.11+** | `release/repo/README.md` Β§Installation |
| Device | **a CPU is sufficient**; no CUDA requirement | `release/repo/README.md` Β§Installation |
| GPU | not required | `docs/DEPLOYMENT_DECISION.md` Β§5 |
| Git | needed to clone | `release/repo/README.md` Β§Installation |
The codebase is CPU-first and the deployment is CPU-only. This is not a fallback β€” it is the design:
- `core/config.py:86-91` β€” `device_preference` honours the `SATQUERY_DEVICE` env override, else
`"cuda" if torch.cuda.is_available() else "cpu"`.
- Every specialist defaults to `device="cpu"` (`docs/DEPLOYMENT_DECISION.md` Β§5).
- **No `.cuda()` call exists anywhere**; all placement is `.to(device)` (`docs/DEPLOYMENT_DECISION.md`
Β§5).
- `configs/base.yaml:293` β€” `cpu_mode_required: true`.
The development container that runs the inference tier pins Python 3.12
(`.devcontainer/devcontainer.json:3`, `image: mcr.microsoft.com/devcontainers/python:3.12`). The
repository's own guidance is 3.11+; a 3.11 or 3.12 interpreter both work.
**Not implemented** (noted, not needed): no thread capping (`torch.set_num_threads`) and no
quantisation. `lazy_load` and `cache_max_models` are only *reported* (`app/deployment.py:609-610`,
`:896-897`); **UNVERIFIED** whether any code enforces a one-model cache (`docs/DEPLOYMENT_DECISION.md`
Β§5).
## 2. Local setup
```bash
git clone https://github.com/Anish-lab-blip/SatQuery-AI
cd SatQuery-AI
python -m venv .venv
source .venv/Scripts/activate # Windows git-bash; use .venv/bin/activate on Linux/macOS
pip install -r requirements.txt
```
(`release/repo/README.md` Β§Installation)
### 2.1 The dependency manifest and its two profiles
`requirements.txt` is **frozen at architecture v1.0** and declares two install profiles in its header
comment (`requirements.txt:1-8`):
```
# Two install profiles:
# CPU (local dev / schema / geo / unit tests):
# pip install -r requirements.txt
# GPU (Kaggle T4x2 / HF ZeroGPU): torch is preinstalled on both.
# Do NOT pin torch here β€” Kaggle and HF ship their own builds.
```
**torch is deliberately not pinned** (`requirements.txt:21`). The platform supplies it. This is why the
local CPU install works on a machine with no CUDA and why the Kaggle/ZeroGPU targets get their own
builds.
The manifest's sections, with the contract notes the file itself carries:
| Section | Packages | Note |
|---|---|---|
| core | `numpy`, `pyyaml`, `pydantic` | β€” |
| geospatial | `rasterio`, `pyproj`, `opencv-python-headless` | β€” |
| models | `transformers>=4.52`, `open-clip-torch>=2.24`, `sentence-transformers>=2.7`, `peft>=0.10`, `huggingface_hub>=0.23`, `safetensors>=0.4`, `einops>=0.7` | see below |
| ui + reporting | `gradio>=4.44`, `reportlab>=4.1` | β€” |
| dev | `pytest>=8.0`, `pytest-cov>=5.0` | β€” |
Three contract notes in the file are load-bearing (`requirements.txt:43-53`):
- `transformers>=4.52` β€” SmolVLM via `AutoModelForImageTextToText`; `AutoModelForVision2Seq` is
deprecated (finding C-2).
- `open-clip-torch` β€” the RemoteCLIP checkpoint is loaded via `pretrained=<path>` so
`load_checkpoint()` runs its state-dict fixups (C-4).
- `huggingface_hub` β€” used to pin the RemoteCLIP / SmolVLM / CROMA revisions.
> **The `einops` lesson.** `einops>=0.7` is **not optional**. It is required by the vendored
> `specialists/optical_sar/vendor/use_croma.py` (`from einops import rearrange`). It was found missing on
> 2026-09-18 by executing the vendored module: it raised `ModuleNotFoundError`, so the CROMA path was
> blocked on a dependency that no document declared (`requirements.txt:28-33`).
### 2.2 Device selection
Set `SATQUERY_DEVICE` to force a device:
```bash
export SATQUERY_DEVICE=cpu # cpu | cuda | mps | null
```
`device_preference` reads the env var first and only falls back to a torch probe if it is unset
(`core/config.py:87-91`). The value is read **without importing torch** on the metadata path, which is
what keeps the health route cheap (`docs/DEPLOYMENT_TOPOLOGY.md` Β§3.3).
## 3. Running the service locally
The inference service is served by the same launcher the Codespace runs
(`release/repo/README.md` Β§Local development):
```bash
# Inference service, CPU (this is the launcher the Codespace runs)
PORT=8000 python deploy/codespace/serve.py
# Health
curl localhost:8000/v1/health
```
`serve.py` builds the app through `build_space_app()` and binds it with uvicorn on `$PORT` (default 8000)
(`deploy/codespace/serve.py:16-25`). It imports cheaply β€” FastAPI is imported inside `build_space_app()`
and no model is loaded at module scope β€” so it stays import-safe on a CPU host with no GPU and no weights
present (`deploy/codespace/serve.py:1-13`).
The server answers four routes (`app/space_app.py`):
| Route | Line | Purpose |
|---|---|---|
| `GET /v1/health` | `app/space_app.py:521` | liveness, derived status, torch-free device probe |
| `GET /v1/capabilities` | `app/space_app.py:549` | the six declared capabilities |
| `POST /v1/assets` | `app/space_app.py:555` | handle-based upload (off unless enabled) |
| `POST /v1/analyze` | `app/space_app.py:661` | the analysis path |
`POST /v1/assets` is **off unless explicitly enabled** (`SATQUERY_ASSET_ENABLED` **and**
`SATQUERY_ASSET_DIR` must both be set; otherwise it answers `503` rather than defaulting to a temp
directory) (`docs/BACKEND_DEPLOYMENT_RUNBOOK.md` Β§3.1.1).
### 3.1 Serving the frontend locally
The frontend is fully static and needs no build step (`release/repo/README.md` Β§Local development):
```bash
python -m http.server 5500 --directory frontend
```
The gateway's dev-origin allowlist includes `localhost` and `127.0.0.1` on ports 3000, 5500, 5173, 8000
and 8080 (`deploy/render/main.py:139-143`), so a local dev server on any of those ports is accepted
without an env-var change.
### 3.2 Running the gateway locally
```bash
PORT=10000 \
GITHUB_TOKEN=<token> \
CODESPACE_NAME=<codespace> \
SATQUERY_ALLOWED_ORIGINS="https://satquery.pages.dev" \
uvicorn deploy.render.main:app --port 10000
```
(`deploy/render/README.md` Β§Running locally β€” the placeholder is the repository's own.)
Then exercise it:
```bash
curl http://localhost:10000/api/health
curl -X POST http://localhost:10000/api/infer -H 'content-type: application/json' -d '{"query":"..."}'
curl http://localhost:10000/api/capabilities
```
> **The monorepo `deploy/` is stale** (Β§25). Use it for local experimentation only; it is **not** the
> deployed source and it lacks the tunnel code the live service runs.
---
# Part II β€” Repository layout
## 4. The top-level directory map
| Directory / file | Purpose | Source |
|---|---|---|
| `app/` | the serving composition root and the FastAPI entrypoint | `app/serving.py`, `app/space_app.py`, `app/deployment.py` |
| `core/` | the config loader, the typed schemas, the controller, the planner, the registry, the error taxonomy | `core/config.py`, `core/schemas.py`, `core/controller.py`, `core/planner.py`, `core/registry.py`, `core/errors.py`, `core/code_revision.py` |
| `router/` | the MiniLM intent router: encoder, classifier, adapter, training, label space, lexical fallback | `router/encoder.py`, `router/classifier.py`, `router/adapter.py`, `router/train.py`, `router/dataset.py`, `router/fallback.py`, `router/label_space.py` |
| `specialists/` | the six specialist implementations behind one interface | `specialists/base.py`, `specialists/vqa/`, `specialists/grounding/`, `specialists/change/`, `specialists/optical_sar/` |
| `preprocessing/` | raster loading, imagery, quality checks | `preprocessing/raster.py`, `preprocessing/imagery.py`, `preprocessing/quality.py` |
| `geospatial/` | CRS handling and transforms | `geospatial/crs.py`, `geospatial/transform.py` |
| `evidence/` | the evidence engine and confidence | `evidence/engine.py`, `evidence/confidence.py` |
| `evaluation/` | manifests, leakage, metrics, normalisation, the runner, benchmark adapters, the frozen prompt set | `evaluation/manifests.py`, `evaluation/leakage.py`, `evaluation/metrics/`, `evaluation/normalize.py`, `evaluation/runner.py`, `evaluation/benchmark_adapters/`, `evaluation/prompt_freeze.json`, `evaluation/manifest_freeze.json` |
| `training/` | the training loops and data adapters, split by task | `training/router/`, `training/grounding/`, `training/change/`, `training/change_vqa/`, `training/fusion/`, `training/vlm/`, `training/calibration/`, `training/data/` |
| `configs/` | the frozen registry and the frozen deploy manifest | `configs/base.yaml`, `configs/deploy.yaml` |
| `gateway/` | the standalone gateway: policy (decisions) + app (HTTP plumbing) | `gateway/policy.py`, `gateway/app.py`, `gateway/assets.py` |
| `deploy/` | the deployment launchers β€” **stale and untracked in the monorepo** | `deploy/codespace/`, `deploy/render/` (Β§25) |
| `tests/` | the suites, split by concern and evidence class | `tests/unit/`, `tests/integration/`, `tests/routing/`, `tests/geospatial/`, `tests/leakage/`, `tests/model/`, `tests/e2e/` |
| `scripts/` | the flat set of executable helpers | Β§18 |
| `notebooks/` | the Kaggle notebooks | Β§19 |
| `artifacts/` | trained heads, checkpoints, caches, evidence archives | ~3.7 GB total (`release/CURRENT_RELEASE_STATE.md` Β§3) |
| `frontend/` | the static site | staged by `scripts/stage_pages.mjs` |
| `docs/` | the project's own engineering records | ~60 files |
| `hf/` | the Hugging Face project card / model cards | β€” |
| `demo/`, `benchmark/`, `reports/`, `data/`, `logs/` | supporting material | β€” |
> **`training/router/` is empty** in the working copy; the router's training lives in `router/train.py`
> and `router/dataset.py` and is driven by `scripts/train_router.py` (Β§20).
> **The repository has no `pyproject.toml` and no `setup.py`.** `app` is a plain package, so the repo root
> must be on `sys.path` for `from app.space_app import ...` to resolve
> (`deploy/codespace/launch.sh:31-34`; `pytest.ini` sets `pythonpath = .`).
## 5. Where the contracts live
Three files are authoritative, and the code β€” not a document β€” wins when they disagree:
| Contract | File | What it fixes |
|---|---|---|
| the config registry | `configs/base.yaml` | every tunable value; hashed (Β§7) |
| the wire/data schemas | `core/schemas.py` | the request, result, evidence, trace and health shapes |
| the specialist interface | `specialists/base.py` | the four-method contract every specialist implements |
| the capability table | `core/registry.py` | which capabilities exist and how to build them |
| the planner's mapping | `core/planner.py` | `TASK_CAPABILITY` and `CAPABILITY_ASSETS` |
When `docs/API_CONTRACT.md` and `core/schemas.py` disagree, the contract document's own authority clause
resolves it: *"the request/response shapes are not invented here. They are the existing, tested Pydantic
models in `core/schemas.py`. This document describes them; it does not declare new ones."*
(`docs/STEP7_BACKEND_CHAIN_REPORT.md` Β§15). The known instance of this β€” the forward-compatibility
promise versus `extra="forbid"` β€” is recorded as **C-2** and left as a documented contradiction rather
than silently resolved (`docs/STEP7_BACKEND_CHAIN_REPORT.md` Β§15).
## 6. The package layout inside `core/`, `specialists/`, `app/`
### 6.1 `core/`
| File | Role |
|---|---|
| `core/config.py` | the loader, the validator, the hash (Β§7, Β§10) |
| `core/schemas.py` | every Pydantic model (Β§11) |
| `core/controller.py` | the nine-state controller that runs a plan and produces the trace |
| `core/planner.py` | the policy layer: `TASK_CAPABILITY`, `CAPABILITY_ASSETS`, `plan()` |
| `core/registry.py` | the capability registry: spec table, lazy construction, degradation states |
| `core/errors.py` | the typed error taxonomy and `scrub_paths` |
| `core/code_revision.py` | the code revision recorded in a run |
The three-tier control split is deliberate: the **registry** knows *which* specialists exist and *how* to
construct them; the **planner** *decides* what runs; the **controller** executes. The registry's own
docstring states it: *"It never decides what runs β€” that is `core.planner`'s job alone"*
(`core/registry.py:6-8`).
### 6.2 `specialists/`
| Package | Files | Capability |
|---|---|---|
| `specialists/vqa/` | `inference.py`, `model.py`, `prompts.py` | `vqa`, `caption` |
| `specialists/grounding/` | `specialist.py`, `remoteclip.py`, `head.py`, `inference.py` | `grounding` |
| `specialists/change/` | `specialist.py`, `stanet.py`, `vqa_specialist.py`, `postprocess.py` | `change`, `change_vqa` |
| `specialists/optical_sar/` | `specialist.py`, `croma.py`, `fusion_head.py`, `sensor_adapter.py`, `radiometry.py`, `inference.py`, `prompts.py`, `vendor/` | `optical_sar` |
### 6.3 `app/`
| File | Role |
|---|---|
| `app/serving.py` | the composition root β€” wires checkpoints through the registry's `builders=` override |
| `app/space_app.py` | `build_space_app()` β€” the FastAPI app and the four routes |
| `app/deployment.py` | the deployment description and device resolution |
---
# Part III β€” The configuration discipline
## 7. "No magic numbers in Python"
`configs/base.yaml` opens with the rule, in the file itself:
```yaml
# RULE: no magic numbers anywhere in Python. Everything tunable lives here.
# Every value below is loaded, validated and hashed by core/config.py.
```
(`configs/base.yaml:4-5`)
The loader reinforces it: *"One config system. No duplicated constants. Every value in configs/base.yaml
is loaded, validated against the frozen architecture, and hashed so evaluation runs are reproducible."*
(`core/config.py:1-5`).
**Access pattern.** Never read the YAML directly; import the singleton:
```python
from core.config import get_config
cfg = get_config()
cfg.get("croma.image_resolution") # dotted-path access
cfg.require("change.encoder") # raises ConfigError if missing
cfg.seed # project.seed, default 42
```
`get_config()` is an `lru_cache(maxsize=1)` singleton β€” *"Import this, do not re-read YAML"*
(`core/config.py:270-273`).
## 8. What happens if you break the rule
Two distinct failures, and both are loud.
### 8.1 The loader fails startup
`Config.__init__` calls `_validate()`, which collects **every** violation and raises a single
`ConfigError` naming them all (`core/config.py:46-49,94-222`):
```python
if errors:
raise ConfigError(
"configuration failed frozen-architecture validation:\n - "
+ "\n - ".join(errors)
)
```
The header states the intent: *"if a config tries to violate a frozen decision (e.g. bf16 on T4,
torch.compile on ZeroGPU, a CROMA image_resolution that is not a multiple of 8), it fails loudly rather
than at runtime"* (`core/config.py:6-8`).
### 8.2 Editing the config MOVES the hash
The hash is a sha256 over the whole registry, truncated to 16 hex characters
(`core/config.py:76-80`):
```python
@property
def hash(self) -> str:
"""Stable hash of the whole registry. Recorded in every evaluation run."""
blob = json.dumps(self._data, sort_keys=True, default=str).encode()
return hashlib.sha256(blob).hexdigest()[:16]
```
Because it is computed over the entire registry, **any** edit to `configs/base.yaml` changes it. The
frozen value is **`78f1e3700da15aa1`** (`release/repo/README.md` Β§Reproducibility;
`docs/PHASE18_DEPLOYMENT_PACKAGING.md` Β§3). Every artifact records the hash it was produced against, so a
hash change **invalidates every artifact keyed to it**.
> **This is the one non-reversible action in the repository.** `docs/BACKEND_DEPLOYMENT_RUNBOOK.md` Β§6.1
> lists it as the single row in the rollback table whose answer to "Reversible?" is **no**: *"The recorded
> benchmark hash is gone; the frozen benchmark no longer matches."*
**Verify the hash after any config-adjacent change:**
```bash
$PY -c "from core.config import get_config; print(get_config().hash)"
# expected: 78f1e3700da15aa1
```
(`docs/BACKEND_DEPLOYMENT_RUNBOOK.md` Β§1.1, where `$PY` is `$REPO/.venv/Scripts/python.exe` on Windows.)
**Two things that do *not* move the hash** β€” both verified:
1. **`configs/deploy.yaml` is inert.** It carries a `registry: false` marker and is never merged into the
registry, so editing it cannot move the hash. Verify with `$PY scripts/validate_deploy_config.py` β†’
exit 0, `"deploy.yaml is inert (not in the registry) and C-8-consistent."`
(`docs/BACKEND_DEPLOYMENT_RUNBOOK.md` Β§2.4). **But** `scripts/validate_deploy_config.py` hard-fails if
the `deployment:` block in `deploy.yaml` differs key-for-key from `base.yaml`'s, so a change to one
alone fails the validator (`docs/DEPLOYMENT_DECISION.md` Β§4).
2. **The serving wiring uses `builders=`.** `app/serving.py` wires the change and change-VQA heads through
the registry's `builders=` override instead of config, which is *"the seam that keeps `Config.hash`
unchanged while still pointing serving at the trained artifacts"* (`docs/PHASE19_FINAL_HARDENING.md`
Β§3.4).
## 9. The hash-exempt path: environment overrides
Two registry values can be overridden from the environment without editing the YAML
(`core/config.py:261-265`):
| Variable | Effect |
|---|---|
| `SATQUERY_PRECISION` | overrides `training.precision` |
| `SATQUERY_TORCH_COMPILE` | overrides `deployment.torch_compile` (`"true"` β†’ `True`) |
**Both are still validated.** Setting `SATQUERY_TORCH_COMPILE=true` **fails startup** because finding C-8
forbids `torch.compile` (`release/repo/docs/DEPLOYMENT.md` Β§6.3; `core/config.py:114-119`).
This is the general pattern for deployment state that must not move the hash: read it from the
environment. The asset-store capacity, TTL and per-file cap follow the same rule
(`release/repo/README.md` Β§Installation).
## 10. The invariants the loader enforces
`core/config.py::_validate` is not documentation β€” it is a check that raises `ConfigError`. Each invariant
exists because a specific finding proved the failure mode.
| Invariant | Why it exists | Finding |
|---|---|---|
| `croma.image_resolution % 8 == 0` | CROMA asserts this; native 120 β†’ 225 patches | C-7 |
| `training.precision ∈ {fp16, bf16, fp32}` | the T4 is SM 7.5, so bf16 is unavailable | C-6 |
| `deployment.torch_compile is not true` | ZeroGPU does not support `torch.compile` | C-8 |
| `vlm.processor_longest_edge ≀ image.tile_size` | the processor's default `longest_edge` is 2048, which upscales a 512 px tile 4Γ— and then splits it into **17** sub-images β€” a ~17Γ— overrun, not the 4Γ— the plan estimated | F5-2 |
| `vlm.prompt_must_use_chat_template is true` | SmolVLM raises `ValueError` on prompts lacking one `<image>` token per image | F5-3 |
| `fusion.input_dim == 3*encoder_dim + optical_channels + sar_channels` (= 2318) | CROMA emits optical/SAR/joint GAP vectors; the availability mask is consumed by the head | C-1 |
| `croma.optical_channels == 12` and `croma.sar_channels == 2` | CROMA's `s2_channels` / `s1_channels` are fixed | β€” |
| `grounding_head.feature_dim == 4 * grounding.encoder_projected_dim` (= 2048) | a mismatch is a **silent** shape error β€” torch raises only at the similarity step, after patch features are already cached | P7-1 |
| `router.tasks` includes `unsupported` and `router.num_tasks == len(router.tasks)` | the ontology and its declared size cannot drift apart | β€” |
| `change.sa_mode ∈ {BAM, PAM}` and `change.encoder` is set | the change architecture is not implicit | C-9 |
| `image.top_k_tiles ≀ image.max_tiles` | the dispatch ceiling cannot exceed the examination ceiling | β€” |
(`core/config.py:94-222`; `release/repo/README.md` Β§Installation.)
> **Why the grounding-head guard matters most.** A mismatch there is a *silent* shape error: torch raises
> only at the similarity step, by which point the patch features have already been computed and cached β€”
> *"the failure surfaces far from its cause"* (`core/config.py:170-196`).
---
# Part IV β€” Contract-first development
## 11. `core/schemas.py` is the binding contract
`core/schemas.py` (462 lines) holds every model the system exchanges. The rule is simple: **no specialist
may invent its own result shape.** Every specialist returns exactly `SpecialistResult`
(`core/schemas.py:325-406`), whose docstring says so: *"Every specialist returns exactly this. No
exceptions."*
The models, in order:
| Model | Line | Role |
|---|---|---|
| `Task` (enum) | 35 | the six-task ontology (`vqa`, `caption`, `grounding`, `change`, `optical_sar`, `change_vqa`) + `unsupported` |
| `Modality` (enum) | 50 | `optical` / `sar` / `joint` |
| `CoordinateSystem` (enum) | 57 | `normalized_0_1` / `geo` |
| `EvidenceType` (enum) | 65 | the evidence classes |
| `ControllerState` (enum) | 79 | the nine-state spine |
| `Intent` | 94 | the router's output |
| `GeoMetadata` | 120 | CRS / transform metadata |
| `SensorDescriptor` | 138 | sensor identity |
| `AssetMetadata` | 153 | one input asset |
| `Box` | 171 | a flat box (see the flat-vs-nested trap below) |
| `Region` / `ChangeRegion` | 189 / 202 | spatial outputs |
| `Evidence` | 216 | one observable artefact |
| `ConfidenceBreakdown` | 259 | measurable confidence |
| `TraceStep` / `ModelRef` / `ExecutionTrace` | 279 / 288 / 296 | the trace |
| `SpecialistResult` | 325 | the master contract |
| `AnalysisRequest` | 412 | the request |
| `ResultEnvelope` | 421 | the response wrapper |
| `HealthStatus` | 430 | the health shape |
**Every model sets `model_config = ConfigDict(extra="forbid")`.** This is deliberate and has a documented
consequence (C-2, Β§5): the models reject a body carrying an unknown key, which contradicts
`API_CONTRACT.md` Β§1.1's forward-compatibility promise. The code is authoritative; the document is the
inaccurate half (`docs/STEP7_BACKEND_CHAIN_REPORT.md` Β§15).
> **The flat-vs-nested trap.** `Box` is **flat**, not nested. An earlier documentation draft described it
> with nested geometry, and *"every box the frontend drew would have been at the origin"*
> (`docs/PHASE19_FINAL_HARDENING.md` Β§3.6). Documentation is validated against the real models by tests
> (`test_api_contract_doc.py`, `test_frontend_guide_doc.py`), which is what caught it.
### 11.1 The two cross-field validators you must not break
`SpecialistResult` carries a `_task_output_consistency` validator (`core/schemas.py:353-406`) with two
rules:
- **Grounding with no localisation is degraded, not a crash.** If `task == GROUNDING` and there are no
boxes or regions, the validator appends a warning and sets `degraded = True`.
- **Change-VQA with no answer text is degraded.** If `task == CHANGE_VQA` and the answer is blank, the
validator marks it degraded β€” but only if the specialist has not already set `degraded` itself.
The `CHANGE` clause that once lived there was **removed** (F-16c): it had collapsed to `not regions`, and
a *successful* no-change analysis began reporting `degraded: true`. **CHANGE is the one task whose
`degraded` flag is now set entirely by its specialist** (`core/schemas.py:362-395`).
## 12. The specialist interface
Every specialist implements exactly one abstract base class, `Specialist`, in `specialists/base.py`. The
interface is **four methods**, and the split is deliberate (`specialists/base.py:1-17`):
```
validate_request -> can this specialist serve this request at all?
execute -> do the work, return a SpecialistResult
produce_evidence -> what observable artefacts support the result?
estimate_confidence -> what measurable signals support the score?
```
The class attributes a specialist must set (`specialists/base.py:48-58`):
| Attribute | Meaning |
|---|---|
| `name` | stable machine-readable name, used in traces and evidence sources |
| `version` | semantic version; *"bump when behaviour changes, not when code moves"* |
| `capabilities` | the capability strings this specialist serves, e.g. `("vqa", "caption")` |
The four abstract methods (`specialists/base.py:62-96`):
| Method | Contract |
|---|---|
| `validate_request(request)` | raise the **most specific** typed error available (`InvalidRequestError`, `PairMisalignmentError`, …), never a bare `Exception` |
| `execute(request)` | return a normalised `SpecialistResult`; *"Never returns None."* |
| `produce_evidence(result)` | return `list[Evidence]`; *"Must not invent anything the specialist did not actually compute."* |
| `estimate_confidence(result)` | return a `ConfidenceBreakdown`; *"Never an LLM utterance."* |
The base class also provides helpers a specialist should use rather than re-implement
(`specialists/base.py:98-134`): `supports()`, `require_assets()` (asserts an exact asset count and raises
a typed error), `require_capability()`, `model_refs()` (for the trace), and `describe()`.
`SpecialistRequest` (`specialists/base.py:34-45`) is the input: `assets`, `query`, `params`, `run_id`.
Its docstring states the isolation rule: *"Everything a specialist is given. No specialist reads global
state."*
## 13. How to add a new specialist
This is the verified procedure, read from the code. There are **five** steps, and skipping any one fails
loudly (which is the design).
### Step 1 β€” implement the `Specialist` ABC
Subclass `specialists.base.Specialist`, set `name`, `version` and `capabilities`, and implement the four
methods. The builder for the class is a module-level function
`build_<x>_specialist(config, *, device, **kwargs)` β€” the same shape as the four existing builders
(`core/registry.py:185-259` names them: `build_vqa_specialist`, `build_grounding_specialist`,
`build_change_specialist`, `build_change_vqa_specialist`, `build_optical_sar_specialist`).
### Step 2 β€” add a row to the spec table
Add a `SpecialistSpec` to `default_specs()` in `core/registry.py:171-260`. The spec's fields
(`core/registry.py:121-165`):
| Field | Meaning |
|---|---|
| `name` | the registry key β€” **the capability string**, never the specialist's own `name` |
| `capabilities` | the tuple the built object must declare; **asserted** after construction |
| `module` | dotted module path containing the builder |
| `builder` | builder function name inside `module` |
| `requires_assets` | exact asset count, or `None` for "any" |
| `config_keys` | `{builder_kwarg: dotted.config.key}` β€” only keys that resolve are passed |
| `optional_config_keys` | as above, but a missing key contributes nothing |
| `failure_states` | registry state to use when construction raises, keyed by exception class name |
The `SpecialistSpec.__post_init__` refuses a spec whose `name` is not in its own `capabilities`
(`core/registry.py:158-165`): *"the registry keys on capability, so this spec would be unreachable."*
### Step 3 β€” understand the capability-vs-name asymmetry
**This is the trap the registry exists to encode** (`core/registry.py:30-45`). The VQA specialist declares:
```python
name = "vlm" # specialists/vqa/inference.py:114
capabilities = ("vqa", "caption") # specialists/vqa/inference.py:116
```
Every other specialist's `name` equals its single capability. A registry keyed on `name` would make `vqa`
permanently unfindable while every other specialist kept working. So the registry keys on **capability**
and **asserts** the capability tuple against the constructed object
(`core/registry.py:497-515`). If your spec's `capabilities` disagrees with what your specialist declares,
construction fails with a `SpecialistError` naming both tuples.
### Step 4 β€” register the task (only if it is a *new* task)
If the new specialist serves an existing task, nothing else is needed. If it is a new task, add it to:
- `Task` in `core/schemas.py:35-49`, and
- `TASK_CAPABILITY` in `core/planner.py:121-128`, and
- `CAPABILITY_ASSETS` in `core/planner.py:133-142`.
`CAPABILITY_ASSETS` mirrors each specialist's own `validate_request`, which **stays authoritative** β€” the
planner's copy is a *"cheap precondition so it can refuse before construction is attempted"*
(`core/planner.py:130-132`). A test asserts `CAPABILITY_ASSETS` equals `SpecialistSpec.requires_assets`
in both directions, and asserts the gateway carries **no third copy** of the asset-count table
(`docs/STEP7_BACKEND_CHAIN_REPORT.md` Β§3).
> **Adding a task touches `router.tasks`.** `router.num_tasks` must equal `len(router.tasks)`
> (`core/config.py:199-206`), so a new task means a config edit β€” **which moves the hash** (Β§8.2). This is
> the one part of "add a specialist" that has a global consequence.
### Step 5 β€” return the right shapes
`execute` must return a `SpecialistResult`; `produce_evidence` must return `list[Evidence]`;
`estimate_confidence` must return a `ConfidenceBreakdown`. The registry will construct your specialist
lazily and record its state.
### What the registry does with your specialist
| Behaviour | Mechanism |
|---|---|
| lazy construction | the builder is imported via `importlib.import_module` **inside** `build()` β€” no module-level specialist import (`core/registry.py:15-25,462-475`) |
| memoisation | the second `build(cap)` returns the cached entry (`core/registry.py:432-434`) |
| three states | `AVAILABLE` / `DEGRADED` / `UNAVAILABLE` (`core/registry.py:102-107`) |
| degradation detection | duck-typed on `has_checkpoint` / `has_head` / `has_encoder` / `model is None` (`core/registry.py:527-548`) |
| failure is retained | a construction failure returns an `UNAVAILABLE` entry rather than raising; **only an unknown capability raises** (`core/registry.py:418-450`) |
| corrupt β‰  missing | `ModelLoadError` / `ModelUnavailableError` map to `UNAVAILABLE`, never retried as `DEGRADED` (`core/registry.py:550-612`) |
| path scrubbing | the client-visible `detail` is `scrub_paths(...)`; the raw string goes to the log only (`core/registry.py:560-598`) |
> **The corrupt-vs-missing rule is load-bearing.** *"silently running an untrained model because a real
> checkpoint failed to load would be the worst outcome"* (`specialists/change/specialist.py:834-838`,
> quoted in `core/registry.py:63-75`). Do not add a fallback that re-adds a degradation the builder
> refused.
## 14. How evidence is emitted
Evidence is produced by `Specialist.produce_evidence(result)` and returned as `list[Evidence]`. The
`Evidence` model (`core/schemas.py:216-256`):
| Field | Type | Note |
|---|---|---|
| `evidence_id` | `str` | auto-generated `ev_<hex>`; **must be unique within a result** |
| `type` | `EvidenceType` | the evidence class |
| `source_specialist` | `str` | which specialist computed it |
| `coordinate_system` | `CoordinateSystem \| None` | required for spatial evidence |
| `coordinates` | `list[float] \| None` | the geometry |
| `score` | `float \| None` | `0.0 ≀ score ≀ 1.0` |
| `artifact_ref` | `str \| None` | **never a filesystem path** (F-16) |
| `payload` | `dict[str, Any]` | structured detail |
Two validators enforce correctness:
- `SpecialistResult._unique_evidence_ids` rejects a result with duplicate `evidence_id` values
(`core/schemas.py:345-351`).
- `Evidence._spatial_needs_crs` rejects spatial evidence (`BOUNDING_BOX`, `MASK`, `CHANGE_MAP`, `TILE`,
`IMAGE_CROP`, `JOINT_FEATURE_REGION`) that carries coordinates but **no** `coordinate_system`
(`core/schemas.py:241-255`).
> **`artifact_ref` is never a filesystem path.** v1 exposes no artifact-serving endpoint, so it is null
> unless a deployment supplies a client-fetchable reference. *"An artifact may still be written
> server-side where configured; being written is not the same as being retrievable."*
> (`core/schemas.py:227-238`.)
---
# Part V β€” The test workflow
## 15. Running tests
### 15.1 Always use the repository venv interpreter
pytest is installed **only** in the repository virtualenv. Invoking the system `pytest` fails or resolves
to a different interpreter (`release/repo/docs/REPRODUCIBILITY.md` Β§10.2):
```bash
.venv/Scripts/python.exe -m pytest tests/unit/test_frontend_live_wiring.py -q
```
On Windows, pytest must be given `-p no:cacheprovider` because the sandbox refuses `.pytest_cache` writes
(`docs/BACKEND_DEPLOYMENT_RUNBOOK.md` Β§0).
### 15.2 Run targeted files, not the whole tree
**A full `tests/unit` run trips the sandbox's bulk-delete guard** (4Γ— `test_safe_delete_shim` failures)
(`release/repo/docs/REPRODUCIBILITY.md` Β§10.3). The workaround is to run the targeted suites:
```bash
.venv/Scripts/python.exe -m pytest tests/unit/test_frontend_live_wiring.py -q # 106 passed
```
The runbook notes that **multi-suite invocations in one command have been refused by the environment
before; single suites are reliable** (`docs/BACKEND_DEPLOYMENT_RUNBOOK.md` Β§2.6):
```bash
$PY -m pytest tests/unit/test_deploy_config.py -p no:cacheprovider -q
$PY -m pytest tests/unit/test_app_serving.py -p no:cacheprovider -q
$PY -m pytest tests/unit/test_api_contract_doc.py tests/unit/test_frontend_guide_doc.py -p no:cacheprovider -q
```
> **Do not pipe pytest through `grep`** in the authoring sandbox β€” output is block-buffered and a killed
> pipeline swallows it. Redirect to a file instead (`release/repo/docs/REPRODUCIBILITY.md` Β§5.6).
### 15.3 `pytest.ini`
```ini
[pytest]
testpaths = tests
pythonpath = .
addopts = -q --tb=short
```
(`pytest.ini:1-4`)
`pythonpath = .` is what makes `from core.config import ...` resolve from the repo root without an
installed package.
## 16. The evidence-class markers
`pytest.ini` registers four markers whose purpose is to make a test's **evidence class** explicit
(`pytest.ini:23-27`):
| Marker | Meaning |
|---|---|
| `unit` | fast, no I/O, no server, no network. Evidence about a component in isolation. |
| `integration` | exercises two or more real components wired together in-process. |
| `smoke` | a minimal end-to-end path run against real local artifacts, not a mock. |
| `real_inference` | ran the real model on real inputs in this environment. |
The file's comment states the discipline:
> *"a unit test never claims a deployment was exercised, and no test may be reported as 'real inference'
> unless it ran the real model on real data in this environment."* (`pytest.ini:13-17`)
**`environment_blocked` is deliberately not a marker.** *"a blocked path is reported in the STEP 7 report
rather than encoded as a permanently-skipped test, because a skip can be mistaken for coverage"*
(`pytest.ini:19-21`). An unregistered mark is an **error** rather than a silent no-op (`pytest.ini:14-15`).
## 17. The known failures, and why they are not regressions
Running the entire unit tree trips **5–6** failures, classified by cause
(`release/repo/docs/REPRODUCIBILITY.md` Β§5.4):
| # | Failure | Cause | Regression? |
|---|---|---|---|
| 1–4 | `test_safe_delete_shim` (Γ—4) | the sandbox's bulk-**delete guard** (Windows verbatim-path behaviour) | **No** |
| 5 | one ordering flake in the router route test | passes in isolation; order/collection-dependent | **No** |
| 6 | one stale adapter test | asserts `optical_sar` absent when CROMA is *unshipped* β€” **CROMA is now shipped** | **No** |
Re-running the affected files together passes **137** tests, which is what isolates them as environmental
rather than behavioural (`release/repo/docs/REPRODUCIBILITY.md` Β§5.4).
**Known per-suite results:**
| Suite | Command | Expected |
|---|---|---|
| Frontend live-wiring | `pytest tests/unit/test_frontend_live_wiring.py` | **106 passed** |
| Doc/frontend suite | `pytest` on the 5 doc/frontend files | **183 passed** |
| Full unit suite | `pytest tests/unit` | 5–6 **environmental** failures |
| Gateway policy | `pytest tests/unit/test_gateway_policy.py` | **51 passed** |
| Evidence engine | `pytest tests/unit/test_evidence_engine.py` | **73 passed** |
(`release/repo/README.md` Β§The test suites; `docs/PHASE19_FINAL_HARDENING.md` Β§6.)
> **The precise full-suite *collected* count is `UNKNOWN β€” not established from the available evidence`.**
> Per-suite counts are known; the single collected total is not (`release/repo/docs/REPRODUCIBILITY.md`
> Β§5.4).
---
# Part VI β€” Scripts and notebooks
## 18. The class of scripts under `scripts/`
`scripts/` is a **flat** directory of executable helpers β€” no sub-packages. The listing holds **51 script
files** (47 `.py`, 3 `.ps1`, 1 `.mjs`) plus `__init__.py`. They fall into clear classes:
| Class | Examples | Purpose |
|---|---|---|
| **Training entry points** | `train_router.py`, `train_grounding.py`, `train_change.py`, `train_fusion.py`, `train_change_vqa.py` | thin CLIs over the `training/` modules (Β§20) |
| **Evaluation** | `eval_change.py`, `eval_fusion_115.py`, `eval_grounding_head.py`, `evaluate_change_vqa.py` | per-task evaluation |
| **Data preparation** | `prepare_bigearthnet.py`, `prepare_change_vqa.py`, `select_bigearthnet_slice.py` | build corpora and selection manifests |
| **Contract probes** | `probe_grounding_head_contract.py`, `probe_remoteclip_contract.py`, `probe_vlm_contract.py` | measure a real model's contract before relying on it |
| **Smoke tests** | `smoke_test_change.py`, `smoke_test_grounding_head.py`, `smoke_test_vlm.py` | a minimal real path per specialist |
| **Verification / gates** | `verify_gate1.py`, `verify_croma_forward.py`, `verify_grounding_e2e.py`, `verify_levir_real.py`, `verify_cdvqa_imagery.py` | prove a property end to end |
| **Threshold / hyperparameter sweeps** | `sweep_change_threshold.py`, `sweep_router_threshold.py`, `fusion_seed_variance.py` | sweep a frozen knob |
| **Phase-6 (VLM) tooling** | `phase6_train_vlm.py`, `phase6_adjudicate_test.py`, `phase6_close.py`, `phase6_rerule.py`, `phase6_recover_baseline_test.py` | the VLM acceptance workflow |
| **Phase-12 (fusion) tooling** | `p12_integrity_verify.py`, `p12_preflight_verify.py`, `run_phase12_extraction.ps1`, `run_phase12_resume_ref.ps1`, `run_phase12_resume_s1_ref.ps1` | the fusion feature-extraction workflow (PowerShell drivers) |
| **Calibration** | `fit_calibration.py` | fit the temperature-scaling artifact on validation (Β§22) |
| **Kaggle packaging / rehearsal** | `package_kaggle_code.py`, `rehearse_kaggle_notebook.py`, `rehearse_change_notebook.py` | package and dry-run the notebooks |
| **Deployment validation** | `validate_deploy_config.py` | prove `configs/deploy.yaml` is inert |
| **Frontend staging** | `stage_pages.mjs` | build the Cloudflare Pages bundle |
| **Environment / diagnostics** | `check_env.py`, `diagnose_feature_cache.py`, `croma_normalisation_arm_probe.py`, `analyze_grounding_resolution.py`, `analyze_reben_labels.py`, `check_cdvqa_second_overlap.py`, `establish_cdvqa_temporal_order.py`, `exp_grounding_resolution.py`, `extract_fusion_features.py` | environment and dataset diagnostics |
**The training entry points share one convention**, stated in their docstrings: *"A THIN CLI over
`training/<task>/train.py` … Everything that computes lives in the module; this file parses arguments,
reports the environment, prints the accounting, and returns an exit code."* (`scripts/train_change.py:1-6`,
`scripts/train_fusion.py:1-6`). The exit codes are a contract (`scripts/train_change.py:17-24`):
```
0 training completed (or --dry-run validated a present dataset)
2 the dataset is missing, empty, or cannot be split -- NOT a crash
3 the run started and the trainer raised a typed error
```
> **Re-exported names are part of the contract.** Several scripts re-export their trainer's symbols at
> module scope, and a test asserts it. E.g. `tests/unit/test_change_train_script_contract.py` asserts
> `change_loss`, `train_change_head` and `evaluate` are reachable as `scripts.train_change.*`
> (`scripts/train_change.py:26-30`).
## 19. `notebooks/`
| Notebook | Purpose |
|---|---|
| `kaggle_change_vqa_train.ipynb` | R-02 change-VQA training (Β§21) |
| `kaggle_phase6_vlm_lora.ipynb` | Phase-6 SmolVLM LoRA training (Β§21) |
| `kaggle_change_training.ipynb` | the change **detector** β€” *"frozen and out of scope"* (`docs/R02_KAGGLE_TRAINING_GUIDE.md` Β§4) |
| `kaggle_grounding_resolution.ipynb` | Phase-7 grounding resolution β€” unrelated to R-02 |
> **Do not confuse the two change notebooks.** For change-VQA use `kaggle_change_vqa_train.ipynb`; do
> **not** use `kaggle_change_training.ipynb` (that one trains the change *detector*) or
> `kaggle_grounding_resolution.ipynb` (Phase 7) (`docs/R02_KAGGLE_TRAINING_GUIDE.md` Β§4).
Two `.bak-*` files sit beside the Phase-6 notebook; they are editor backups, not runnable notebooks.
---
# Part VII β€” Training entry points
The six artifacts train in two places: four locally, two on an external GPU. This is a **deliberate
boundary** β€” the release ships *"frozen artifacts with provenance, not a retraining harness"*
(`release/repo/docs/REPRODUCIBILITY.md` Β§8.5).
| Artifact | Where it trains | Reproducible from this release? |
|---|---|---|
| router adapter | local CPU | **yes** β€” `configs/base.yaml` Β§`router.training` |
| grounding head | local | **yes** β€” `configs/base.yaml` Β§`grounding_training` |
| change head | local | **yes** β€” `configs/base.yaml` Β§`change` |
| optical_sar fusion head | local, seed sweep | **yes** |
| change_vqa head | **external GPU (Kaggle)** | **partly** β€” the promotion gate, evaluation and serving wiring are reproducible; there is no one-command retrain |
| vlm LoRA adapter | **external GPU** | **partly** β€” same |
(`release/repo/README.md` Β§What "reproduce" means.)
## 20. Local training (router, grounding, change, optical-SAR)
### 20.1 Router β€” CPU-only, and fast
```bash
python scripts/train_router.py --smoke # fast, stub encoder
python scripts/train_router.py --stub # fast, no model download
python scripts/train_router.py # full run, real MiniLM
```
(`scripts/train_router.py:1-10`)
The script's own measured note: *"Runs entirely on CPU. Measured cost with the real encoder: ~40 s to load
MiniLM on a cold cache, ~0.1 s to embed the corpus, ~1 s to train 60 epochs. There is no reason to spend
Kaggle GPU quota on this."* The encoder is frozen, so embeddings are cached and the adapter trains on
cached vectors (`configs/base.yaml:67-69`).
### 20.2 Grounding β€” two separable stages
```bash
# stage 1 only β€” measure the corpus before committing to training
python scripts/train_grounding.py --data-root <root> --extract-only
# both stages, real run
python scripts/train_grounding.py --data-root <root> --checkpoint <ckpt.pt>
# 2-batch forward+backward+checkpoint+reload, on CPU
python scripts/train_grounding.py --data-root <root> --debug
```
(`scripts/train_grounding.py:6-22`)
Splitting extraction from training means a hyperparameter change does not re-encode 15,699 images, and a
crashed training run does not lose the cache. The stated bar: *"the Phase 7 zero-shot baseline scored
0.0972 mean best IoU over 16,159 real eval records. This head must beat it by MIN_IMPROVEMENT_IOU to
justify existing. The comparison is printed whether or not it passes."*
### 20.3 Change β€” a thin CLI over `training/change/train.py`
```bash
# validate the data and the split; build nothing, train nothing
python scripts/train_change.py --data-root <LEVIR-CD root> --dry-run
# real run on CPU
python scripts/train_change.py --data-root <LEVIR-CD root> --device cpu
# a first real-data run that does not commit an hour
python scripts/train_change.py --data-root <root> --limit 256 --epochs 3
```
(`scripts/train_change.py:8-16`)
`--dry-run` is *"the leakage check without the cost: it loads the items, performs the scene-disjoint
split, runs `assert_image_disjoint`, prints the accounting and exits. It does NOT build the detector, so
it cannot trigger a pretrained-weights download, and it writes no files."*
### 20.4 Optical-SAR fusion β€” CPU-only, no result claimed
```bash
# validate the caches and the split; train nothing, write nothing
python scripts/train_fusion.py --train-cache train.npz --val-cache val.npz --dry-run
# a real run on CPU (the fusion head is CPU-only)
python scripts/train_fusion.py --train-cache train.npz --val-cache val.npz --arm A --epochs 20
```
(`scripts/train_fusion.py:8-15`)
> **No performance number is a result here.** The script states it: *"This loop has never seen the real
> paired BigEarthNet-S1+S2 corpus. Every run record it writes carries `result_status` and
> `pre_registered_metric_computed: false`; the pre-registered 11.5 metric is not computed."*
> (`scripts/train_fusion.py:17-20`.)
## 21. External GPU training (change-VQA, VLM LoRA)
Both external runs happen on **Kaggle** with **GPU T4 Γ—2**. Neither is a one-command retrain from this
release.
### 21.1 Change-VQA (R-02)
**Guide:** `docs/R02_KAGGLE_TRAINING_GUIDE.md` (37 KB). **Runbook:** `RUNBOOK_CHANGE_VQA_KAGGLE.md`.
| Property | Value | Source |
|---|---|---|
| notebook | `notebooks/kaggle_change_vqa_train.ipynb` | `docs/R02_KAGGLE_TRAINING_GUIDE.md` Β§4 |
| accelerator | **GPU T4 Γ—2** | Β§5 |
| internet | **On** (MiniLM downloads) | Β§5 |
| cells | **all 34, top to bottom** | Β§7 |
| hard stop | `HARD_STOP_SECONDS = 3 * 3600` (the plan's 3 h) | Β§7 |
| trainer defaults | `epochs=40, batch_size=128, seed=42, patience=6, time_limit=10800s` | Β§7 |
The path-discovery cells find the code root (four markers) and the CDVQA root (12 annotations + 4 image
dirs), and section 3c verifies the frozen STANet checkpoint by size and SHA256
(`RUNBOOK_CHANGE_VQA_KAGGLE.md` Β§5). **Test splits are not readable by the trainer:**
`training/change_vqa/train.py` loads only Train and Val, and *"there is no option in either file that
changes that"* (`scripts/train_change_vqa.py:11-15`).
### 21.2 VLM LoRA (Phase 6)
**Runbook:** `RUNBOOK_PHASE6_VLM_KAGGLE.md`.
| Property | Value | Source |
|---|---|---|
| notebook | `notebooks/kaggle_phase6_vlm_lora.ipynb` | `RUNBOOK_PHASE6_VLM_KAGGLE.md` Β§1 |
| accelerator | **GPU T4 Γ—2** | Β§4 |
| internet | **off** (base model attached as input) | Β§4 |
| precision | **`fp16`, not the plan's `bf16`** β€” T4 is SM 7.5 | Β§4 |
| base model | `HuggingFaceTB/SmolVLM-500M-Instruct`, ~1.02 GB `model.safetensors` | Β§4 |
The trainer raises a `ConfigError` on `bf16` rather than silently falling back, and the deviation is
recorded in the manifest under `plan_deviations` (`RUNBOOK_PHASE6_VLM_KAGGLE.md` Β§4).
> **The VLM adapter's status is `ACCEPTANCE-REJECTED`.** Its metrics are *usable* (`exact_match 0.963`)
> but it was not promoted. **USABLE β‰  ACCEPTED** (`DOCS_STYLE_GUIDE.md` Β§3). Do not describe the VLM path
> as accepted.
## 22. Calibration
```bash
python scripts/fit_calibration.py --dry-run # check inputs, exit
python scripts/fit_calibration.py # fit, write artifact
```
(`scripts/fit_calibration.py:19-21`)
It fits on **validation** data, and both the fitter entry point and the artifact writer **refuse a
held-out split** β€” *"Fitting on Test or Test2 would make the reported confidence a function of the answers
it is used to score β€” a leak, not a calibration."* (`scripts/fit_calibration.py:7-13`.)
> **Calibration made ECE worse** β€” `0.013755 β†’ 0.014929` β€” and is **retained only because it is in the
> frozen config**. Never present it as an improvement (`DOCS_STYLE_GUIDE.md` Β§3).
---
# Part VIII β€” Coding conventions
## 23. What the code actually does
These conventions are visible across the files read. They are not aspirational style rules; each is
observable in the code.
### 23.1 Every module has a substantial docstring stating *why*
The files read are densely commented at the module and function level, and the comments explain decisions
and failure modes rather than restating the code. Examples: `core/registry.py:1-76` (a 76-line module
docstring on the capability-key trap), `deploy/codespace/launch.sh:1-24` (why the launcher is defensive),
`gateway/app.py:53-106` (the annotation-scope trap). **A change that removes the reasoning from a
comment removes the reason a future reader will not re-introduce the bug.**
### 23.2 `from __future__ import annotations` is standard
It appears at the top of nearly every module (`core/config.py:11`, `core/registry.py:78`,
`specialists/base.py:19`, `gateway/app.py:48`, `deploy/render/main.py:40`, `deploy/render/codespaces.py:16`,
`deploy/codespace/warm_cache.py:28`, `scripts/*.py`). It is convenient β€” and it is the direct cause of
Trap 6 (Β§30).
### 23.3 Findings are recorded as short codes, inline
The code refers to findings by code (`C-1`, `C-6`, `C-8`, `F5-2`, `P7-1`, `F-15`, `F-16`, `F-17`) and
states the failure each guard prevents. This is a documentation convention enforced by comments, e.g.
`core/config.py:121-142` (finding F5-2, the 17Γ— overrun), `core/registry.py:227-235` (F-17, a config
surface removed rather than left as a silent no-op).
### 23.4 Pydantic models set `extra="forbid"` and carry validators
Every schema model forbids extra fields and uses `@field_validator` / `@model_validator` for cross-field
rules (Β§11). New models should follow the same pattern.
### 23.5 Loggers are module-level and named `satquery.<area>`
`logging.getLogger("satquery.orchestrator")` (`deploy/render/main.py:74`),
`logging.getLogger("satquery.registry")` (`core/registry.py:95`), `logging.getLogger(__name__)`
(`gateway/app.py:123`). Log calls use `%s`-style free text β€” there are no structured/JSON logs
(`docs/architecture/10-observability-and-ops.md` Β§4.4).
### 23.6 Client-visible strings are scrubbed
Server-side diagnostics that name absolute paths must not reach a client. `core/errors.scrub_paths` reduces
a path to a basename before it is published (`core/registry.py:297-312,560-598`;
`core/errors.py:39`). Any new client-visible field that could carry a path or an exception string must go
through the same scrub.
### 23.7 `noqa` comments state the reason
Where a lint suppression is used, the reason is written, e.g. `# noqa: E402 (see comment above)` in
`gateway/app.py:85-106`, and `# noqa: BLE001 - report, never crash the warm step` in
`deploy/codespace/warm_cache.py:74`.
### 23.8 Tests assert the *cause*, not the symptom
The regression test for the annotation-scope trap asserts that the route has no query parameters and that
`gateway.app.Request is starlette.requests.Request` β€” *"rather than the symptom, because asserting the
symptom would be brittle"* (`docs/STEP7_BACKEND_CHAIN_REPORT.md` Β§13). Follow this when writing a
regression test.
### 23.9 The 88-column soft limit
The files read wrap around 88 characters. There is no committed linter config in the files read, so this
is a convention, not an enforced rule β€” **the exact formatter/linter configuration is `UNKNOWN β€” not
established from the available evidence`.**
---
# Part IX β€” Known development traps
## 24. The traps, in one table
| # | Trap | One-line consequence |
|---|---|---|
| 1 | the monorepo `deploy/` is stale and untracked | it is **not** the deployed source |
| 2 | the sandbox proxy is dead | outbound calls need `--noproxy '*'` / `ProxyHandler({})` |
| 3 | pytest exists only in the venv | the system `pytest` resolves to the wrong interpreter |
| 4 | Chrome drops synthetic CDP key events without OS focus | a browser-driven run silently answers the default query |
| 5 | Cloudflare `_headers` rules concatenate | a later rule cannot "fix" an earlier one |
| 6 | `from __future__ import annotations` + FastAPI | an unresolvable `Request` annotation becomes a **required query parameter** |
| 7 | the full-suite run trips the bulk-delete guard | 4 spurious `test_safe_delete_shim` failures |
| 8 | a stale serve process keeps answering | it reports the **previous revision's** capabilities |
## 25. Trap 1 β€” the stale untracked `deploy/`
**Symptom.** You edit `deploy/render/main.py`, deploy your change, and nothing changes in production β€” or
you read `deploy/render/main.py` and cannot find the tunnel code the live service runs.
**Root cause.** `deploy/` inside the monorepo is **stale and untracked**. `git status` reports
`?? deploy/` (verified in the working copy). It is **not** the deployed source
(`release/repo/docs/DEPLOYMENT.md` Β§1; `release/CURRENT_RELEASE_STATE.md` Β§6).
**Evidence of divergence.** The monorepo `deploy/render/main.py` (532 lines) exposes `/api/health` with a
`config` block that has **no** `tunnel` field and no `transport_mode` / `tunnel_timeout_s` /
`wake_timeout_s` keys (`deploy/render/main.py:444-466`), whereas the **live** payload carries all of them
(`release/repo/docs/DEPLOYMENT.md` Β§5). The monorepo copy also lacks `tunnel_agent.py` and `doctor.sh`,
both of which `launch.sh` references (`deploy/codespace/launch.sh:83,89,153,160-168,179`).
**Fix.** Fetch the deployed file from the private repository and diff before editing. Treat the monorepo
`deploy/` as documentation of intent, not as source.
## 26. Trap 2 β€” the dead sandbox proxy needs `--noproxy '*'`
**Symptom.** Outbound HTTP calls fail or hang in the authoring sandbox.
**Root cause.** The sandbox proxy is dead; requests are routed to it and never reach the target.
**Fix.** Disable proxies for the call (`release/repo/docs/REPRODUCIBILITY.md` Β§10.1):
```bash
curl --noproxy '*' https://<backend-host>/api/health
```
```python
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
```
`release/tools/hf_verify.py:37-38` does exactly this (`opener_no_proxy()`). In a normal environment the
flag is harmless; in the sandbox it is mandatory.
## 27. Trap 3 β€” pytest only in the venv
**Symptom.** Invoking the system `pytest` fails or resolves to a different interpreter.
**Root cause.** pytest is installed only in `.venv`.
**Fix.** Always invoke the venv interpreter explicitly (`release/repo/docs/REPRODUCIBILITY.md` Β§10.2):
```bash
.venv/Scripts/python.exe -m pytest tests/unit/test_frontend_live_wiring.py -q
```
## 28. Trap 4 β€” Chrome drops synthetic CDP key events
**Symptom.** A browser-driven run silently answers the *default* query; the query box looks untouched;
`mock_nodes` is non-zero.
**Root cause.** Chrome **drops synthesized key events when the browser window does not hold OS focus**.
`press_key` / `fill_input` (real CDP key events) are focus-gated; `Input.insertText` (`type_text`) is not.
**Measured.** With Chrome backgrounded, `press_key("Z")` left `#qtext.value` unchanged, while
`type_text("Q")` inserted fine (`release/repo/docs/REPRODUCIBILITY.md` Β§6.4).
**Fix.** Use `type_text` (not `fill_input`), and **assert the input state before dispatch** β€” `q_ok`
(the query box really held the query), `obs_ok` (`#obsTail == 'ready'`), `t0_ok` (both frames present
where required). This is the single most dangerous trap because it produces a **silent false pass** β€” the
pipeline "works", it just answered a different question
(`release/repo/docs/REPRODUCIBILITY.md` Β§10.5).
## 29. Trap 5 β€” Cloudflare `_headers` concatenate
**Symptom.** A specific cache-control rule does not take effect; the browser caches a file you expected it
to revalidate.
**Root cause.** Cloudflare `_headers` rules **concatenate, they do not override.** Two matching rules are
merged: a specific rule nested under a broad `/assets/img/*` rule yields
`max-age=604800, …, max-age=0, must-revalidate` β€” and **Chromium takes the FIRST `max-age`**. The file's
own "later rules override" comment is **false** (`release/repo/docs/DEPLOYMENT.md` Β§10).
**Fix.** Order rules so the *broadest* rule appears last, and never rely on a later rule overriding an
earlier one. Related: Cloudflare **308-redirects `X.html` β†’ `/X`**, so reference the extensionless path
(`release/repo/docs/REPRODUCIBILITY.md` Β§10.4).
## 30. Trap 6 β€” the annotation-scope trap
**Symptom.** Every `POST` route returns `422` with FastAPI's own shape, without ever entering the handler:
```
POST /v1/analyze β†’ 422
{"detail":[{"type":"missing","loc":["query","request"],"msg":"Field required"}]}
```
(`docs/STEP7_BACKEND_CHAIN_REPORT.md` Β§13.)
**Root cause.** The module uses `from __future__ import annotations`, so every annotation is a **string**
at runtime. FastAPI resolves those strings via `get_typed_signature`, which calls
`eval(annotation, func.__globals__)`. If `Request` is imported **inside** `create_app`, the route closures
capture the name as a *local* of `create_app`; it never appears in `gateway.app.__globals__`, so
resolution fails and FastAPI is left holding a bare `ForwardRef('Request')`. **FastAPI does not raise** β€”
it silently falls back to treating the parameter as a **required query parameter named `request`**
(`gateway/app.py:53-73`).
Three things were wrong at once: the request body was never read, the gateway's own validation never ran,
and the error shape was FastAPI's `{"detail": ...}` rather than the contract's `{"error": {...}}`. **Every**
POST route was affected, including `/v1/assets`.
**The asymmetry to internalise.** An unresolvable **parameter** annotation is *silently reinterpreted*
(the handler never runs), while an unresolvable **return** annotation *raises*
(`pydantic.errors.PydanticUndefinedAnnotation: name 'JSONResponse' is not defined`, which made
`create_app()` unbuildable). Same root cause, opposite diagnosability
(`gateway/app.py:88-106`).
**Fix.** Bind the annotation subjects at **module scope** (`gateway/app.py:79-106`):
```python
if TYPE_CHECKING: # pragma: no cover
from starlette.requests import Request
from starlette.responses import Response
#: Runtime bindings used as annotation subjects in this module. Deliberately
#: module-level so `eval()` can find them. See the comment above.
from starlette.requests import Request # noqa: E402 (see comment above)
from starlette.responses import Response # noqa: E402 (see comment above)
from fastapi.responses import JSONResponse # noqa: E402 (see comment above)
```
The regression test asserts the **cause**: the route has no query parameters, and
`gateway.app.Request is starlette.requests.Request`.
> **The general rule.** Any name used as a FastAPI route annotation in a module with
> `from __future__ import annotations` **must** be importable from that module's globals. Import it at
> module scope, not inside the factory.
## 31. Trap 7 β€” the full-suite bulk-delete guard
**Symptom.** Running the whole `tests/unit` tree trips 4Γ— `test_safe_delete_shim` failures.
**Root cause.** Windows **verbatim-path** defects in the sandbox's bulk-delete guard. The precise
condition under which the shim intermittently triggers on Windows is `UNKNOWN β€” not established from the
available evidence` (`release/repo/docs/REPRODUCIBILITY.md` Β§10.3).
**Fix / workaround.** Run the targeted suites (106 and 183 pass cleanly); treat the 4 shim failures as
environmental, not regressions (Β§17).
## 32. Trap 8 β€” the stale serve process
**Symptom.** The Codespace answers `/v1/health` and `/v1/capabilities`, but reports the **previous
revision's** capabilities.
**Root cause.** A serve process started before a code or environment change keeps serving from old code.
The serve process reads its environment exactly once, at startup (`deploy/codespace/launch.sh:100-102`).
**Fix.** `launch.sh` already handles it: it records a **stamp** of the revision and the asset
configuration and restarts the server when the stamp disagrees
(`deploy/codespace/launch.sh:100-147`). The rule for a developer: **a restart, not a reload, is required
after any env or revision change.**
> *"A stale serve process is worse than no process: it answers /v1/health and /v1/capabilities from OLD
> code, so the deployment looks alive while reporting the previous revision's capabilities."*
> (`deploy/codespace/launch.sh:111-113`)
### Related platform traps worth knowing
| Trap | Detail |
|---|---|
| a forwarded Codespace port returns `302` for a private repo | this is *why* the outbound tunnel exists (`release/repo/docs/DEPLOYMENT.md` Β§10) |
| the tunnel agent must be started by the devcontainer `postStartCommand` | a restarted Codespace comes up with `agent_connected: false` otherwise |
| never retry `POST /api/infer` at the gateway | a retry consumes inference twice |
| `containerEnv` applies only at container **creation** | an env change needs a restart, which is why `launch.sh` re-exports on every start (`deploy/codespace/launch.sh:49-52`) |
| the Codespace filesystem is **ephemeral** | uploaded assets and logs vanish with the Codespace (`deploy/codespace/launch.sh:44-47`) |
---
# Part X β€” Status and evidence
## 33. `NOT RUN` / `OPEN` / `BLOCKED` / `UNKNOWN` for development
| # | Item | Status |
|---|---|---|
| 1 | B-07 β€” tunnel gaps; patch prepared, **not deployed** | **`OPEN`** |
| 2 | B-02 β€” `/api/health` `codespace_name` trailing `\n` | **`OPEN` (cosmetic)** |
| 3 | A `LICENSE` file | **`OPEN`** β€” **no LICENSE file exists**; README says to add one before public release |
| 4 | The monorepo README | **materially stale** β€” it calls the frontend "hermetic", describes a 4-endpoint `/v1/*` contract, omits the tunnel, and points at the stale `deploy/` (`release/CURRENT_RELEASE_STATE.md` Β§6) |
| 5 | `hf/SETUP.md` and `hf/README.md` | **stale** β€” they assert the project ships no weights, which is now false (`release/CURRENT_RELEASE_STATE.md` Β§6) |
| 6 | The exact formatter / linter configuration | **`UNKNOWN`** β€” no committed config in the files read (Β§23.9) |
| 7 | The precise full-suite collected test count | **`UNKNOWN`** β€” per-suite counts are known, the total is not |
| 8 | The exact Windows trigger for the delete-shim flake | **`UNKNOWN`** |
| 9 | Whether `doctor.sh` / `tunnel_agent.py` exist in the deployed inference repo | **`UNKNOWN`** β€” the monorepo copy lacks them |
| 10 | A system-level end-to-end benchmark | **`NOT RUN`** β€” none exists |
| 11 | The router **test**-split number | **`NOT RUN`** β€” only validation (`n = 86`, ungated) exists |
| 12 | Captioning benchmark | **`NOT RUN`** β€” implemented, not benchmarked |
| 13 | A one-command retrain for the two external artifacts | **not implemented** β€” deliberate boundary (Β§21) |
| 14 | `torch.compile`, CUDA, quantisation, thread capping | **not implemented** β€” CPU-first by design (Β§1) |
| 15 | Enforcement of a one-model cache (`cache_max_models: 1`) | **UNVERIFIED** β€” the value is reported, not proven enforced (`docs/DEPLOYMENT_DECISION.md` Β§5) |
> **The stale-README trap is worth its own line.** `README.md` in the monorepo is *"materially stale"*: it
> calls the frontend *"hermetic β€” no backend calls"* (it calls `/api/*` on Render), puts Render/Codespace
> as *"in progress"* (both deployed), describes a 4-endpoint `/v1/*` contract (the live contract is
> `/api/*`), omits the tunnel, and points at the stale untracked `deploy/` as the deployment source
> (`release/CURRENT_RELEASE_STATE.md` Β§6). The public release documentation is authoritative; the
> monorepo README is not.
## 34. Where the evidence lives
| What | Where |
|---|---|
| the frozen config, invariants and hash | `configs/base.yaml`; `core/config.py:76-80,94-222` |
| the binding schemas | `core/schemas.py` |
| the specialist interface | `specialists/base.py` |
| the capability table and lazy construction | `core/registry.py` |
| the planner mapping | `core/planner.py:121-142` |
| the dependency profiles and contract notes | `requirements.txt` |
| the test markers | `pytest.ini` |
| the launcher and warm-up | `deploy/codespace/launch.sh`, `deploy/codespace/warm_cache.py` |
| the annotation-scope trap | `gateway/app.py:53-106`; `docs/STEP7_BACKEND_CHAIN_REPORT.md` Β§13 |
| the deploy manifest is inert | `docs/PHASE18_DEPLOYMENT_PACKAGING.md`; `scripts/validate_deploy_config.py` |
| the change-VQA Kaggle guide | `docs/R02_KAGGLE_TRAINING_GUIDE.md`; `RUNBOOK_CHANGE_VQA_KAGGLE.md` |
| the VLM LoRA Kaggle runbook | `RUNBOOK_PHASE6_VLM_KAGGLE.md` |
| the test suites and their expected results | `release/repo/docs/REPRODUCIBILITY.md` Β§5 |
| the environment traps | `release/repo/docs/REPRODUCIBILITY.md` Β§10 |
| the platform traps | `release/repo/docs/DEPLOYMENT.md` Β§10 |
| the live topology, env vars, cold start | `release/repo/docs/DEPLOYMENT.md` |
| the operations manual | [../OPERATIONS.md](OPERATIONS.md) |
| the factual inventory | `release/CURRENT_RELEASE_STATE.md` |
### Cross-references
| For | See |
|---|---|
| the frozen config and `Config.hash == 78f1e3700da15aa1` | [07 β€” Configuration and Freeze](architecture/07-configuration-freeze.md) |
| the specialist contract in depth | [05 β€” Specialists](architecture/05-specialists.md) |
| the router's five heads and the label space | [04 β€” Router](architecture/04-router.md) |
| the evidence and confidence stages | [06 β€” Evidence and Confidence](architecture/06-evidence-and-confidence.md) |
| the request lifecycle and the nine-state spine | [03 β€” Request Lifecycle](architecture/03-request-lifecycle.md) |
| the four endpoints and error codes | [08 β€” The API Contract](architecture/08-api-contract.md) |
| how to operate the live stack | [../OPERATIONS.md](OPERATIONS.md) |
| per-artifact hyperparameters | [../TRAINING.md](TRAINING.md) |
| what a third party can and cannot reproduce | [../REPRODUCIBILITY.md](REPRODUCIBILITY.md) |
---
> **Chapter summary.** Python 3.11+ and a CPU are sufficient; there is no CUDA requirement, and all
> placement is `.to(device)`, never `.cuda()`. The single registry is `configs/base.yaml`, its hash
> **`78f1e3700da15aa1`** is frozen, and **editing the config moves the hash and invalidates every artifact
> keyed to it** β€” the one non-reversible action in the repository. `core/schemas.py` is the binding
> contract and every specialist returns exactly `SpecialistResult`; the specialist interface is four
> methods in `specialists/base.py`, and a new specialist is added by implementing it and adding a
> `SpecialistSpec` row keyed on **capability**, not name. Tests run from the venv, targeted, because the
> full suite trips the sandbox's bulk-delete guard. Four artifacts train locally; two β€” change-VQA and the
> VLM LoRA β€” train on an external GPU and are not one-command reproducible. Eight development traps are
> recorded, of which the most dangerous are the stale untracked `deploy/`, the annotation-scope trap that
> turns a `Request` parameter into a required query parameter, and the Chrome focus trap that produces a
> silent false pass. `NOT RUN`/`OPEN` items include B-07 and B-02 (both `OPEN`), the absent `LICENSE`, the
> stale monorepo README and `hf/` docs, and several `UNKNOWN β€” not established from the available
> evidence` gaps.