Instructions to use thundercode/SatQuery with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use thundercode/SatQuery with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
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.yamlis 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
- Prerequisites
- Local setup
- 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_preferencehonours theSATQUERY_DEVICEenv 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
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 viaAutoModelForImageTextToText;AutoModelForVision2Seqis deprecated (finding C-2).open-clip-torchβ the RemoteCLIP checkpoint is loaded viapretrained=<path>soload_checkpoint()runs its state-dict fixups (C-4).huggingface_hubβ used to pin the RemoteCLIP / SmolVLM / CROMA revisions.
The
einopslesson.einops>=0.7is not optional. It is required by the vendoredspecialists/optical_sar/vendor/use_croma.py(from einops import rearrange). It was found missing on 2026-09-18 by executing the vendored module: it raisedModuleNotFoundError, 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:
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):
# 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):
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
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:
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 inrouter/train.pyandrouter/dataset.pyand is driven byscripts/train_router.py(Β§20).
The repository has no
pyproject.tomland nosetup.py.appis a plain package, so the repo root must be onsys.pathforfrom app.space_app import ...to resolve (deploy/codespace/launch.sh:31-34;pytest.inisetspythonpath = .).
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:
# 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:
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):
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):
@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:
$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:
configs/deploy.yamlis inert. It carries aregistry: falsemarker 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). Butscripts/validate_deploy_config.pyhard-fails if thedeployment:block indeploy.yamldiffers key-for-key frombase.yaml's, so a change to one alone fails the validator (docs/DEPLOYMENT_DECISION.mdΒ§4).- The serving wiring uses
builders=.app/serving.pywires the change and change-VQA heads through the registry'sbuilders=override instead of config, which is "the seam that keepsConfig.hashunchanged 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.
Boxis 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 == GROUNDINGand there are no boxes or regions, the validator appends a warning and setsdegraded = True. - Change-VQA with no answer text is degraded. If
task == CHANGE_VQAand the answer is blank, the validator marks it degraded β but only if the specialist has not already setdegradeditself.
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:
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:
Taskincore/schemas.py:35-49, andTASK_CAPABILITYincore/planner.py:121-128, andCAPABILITY_ASSETSincore/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_tasksmust equallen(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 incore/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_idsrejects a result with duplicateevidence_idvalues (core/schemas.py:345-351).Evidence._spatial_needs_crsrejects spatial evidence (BOUNDING_BOX,MASK,CHANGE_MAP,TILE,IMAGE_CROP,JOINT_FEATURE_REGION) that carries coordinates but nocoordinate_system(core/schemas.py:241-255).
artifact_refis 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):
.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:
.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):
$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
grepin 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
[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.pyassertschange_loss,train_change_headandevaluateare reachable asscripts.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 usekaggle_change_training.ipynb(that one trains the change detector) orkaggle_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
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
# 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
# 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
# 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_statusandpre_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
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):
curl --noproxy '*' https://<backend-host>/api/health
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):
.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):
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 annotationsmust 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.mdin 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 untrackeddeploy/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 |
| the factual inventory | release/CURRENT_RELEASE_STATE.md |
Cross-references
| For | See |
|---|---|
the frozen config and Config.hash == 78f1e3700da15aa1 |
07 β Configuration and Freeze |
| the specialist contract in depth | 05 β Specialists |
| the router's five heads and the label space | 04 β Router |
| the evidence and confidence stages | 06 β Evidence and Confidence |
| the request lifecycle and the nine-state spine | 03 β Request Lifecycle |
| the four endpoints and error codes | 08 β The API Contract |
| how to operate the live stack | ../OPERATIONS.md |
| per-artifact hyperparameters | ../TRAINING.md |
| what a third party can and cannot reproduce | ../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 isconfigs/base.yaml, its hash78f1e3700da15aa1is 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.pyis the binding contract and every specialist returns exactlySpecialistResult; the specialist interface is four methods inspecialists/base.py, and a new specialist is added by implementing it and adding aSpecialistSpecrow 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 untrackeddeploy/, the annotation-scope trap that turns aRequestparameter into a required query parameter, and the Chrome focus trap that produces a silent false pass.NOT RUN/OPENitems include B-07 and B-02 (bothOPEN), the absentLICENSE, the stale monorepo README andhf/docs, and severalUNKNOWN β not established from the available evidencegaps.