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
07 — Configuration Freeze
Parent: Architecture hub · Status tags: IMPLEMENTED · VERIFIED ·
MEASURED · OPEN
0. Why this chapter exists, and what it is really about
This is not a chapter about a YAML file. It is a chapter about a single decision and its consequences:
every tunable number in SatQuery AI lives in one registry file, that file is validated against the frozen architecture at load time, and it is hashed so that every measurement the project has ever published can be traced back to the exact configuration that produced it.
Those three properties — one place, validated, hashed — are load-bearing for the whole project, and they are in tension with each other. "One place" makes the config a magnet for edits. "Validated" means a bad edit fails loudly instead of quietly. "Hashed" means a good edit still breaks something, because it detaches published numbers from the configuration they were measured under.
The consequence a reader must internalise before touching anything in configs/base.yaml:
Editing the registry MOVES the config hash. Moving the hash INVALIDATES every artifact keyed to it. The frozen value is
78f1e3700da15aa1. A value that "looks wrong" is therefore not casually fixed — the fix has a cost that is not visible at the point of editing.
Everything else in this chapter — the loader pipeline, the twelve validation rules, the finding ids,
the deploy.yaml manifest, the frozen-value table, the open-for-tuning list — is elaboration of that
sentence.
| Property | Mechanism | Where |
|---|---|---|
| One place | configs/base.yaml is the only file the loader reads |
core/config.py:25 (DEFAULT_CONFIG) |
| Validated | Config.__init__ calls _validate(); a bad config raises before any model loads |
core/config.py:46-49, :94-222 |
| Hashed | Config.hash = first 16 hex chars of a sha256 over the whole registry |
core/config.py:76-80 |
Grounding note. Every rule, key, number and finding id in this chapter was read out of the
repository at core/config.py, configs/base.yaml, configs/deploy.yaml, the docs/ records it
cites, and the test suite. Where a claim could not be established from a file, it is written as
UNKNOWN — not established from the available evidence rather than guessed.
Part A — The registry, and the rule
1. The registry
The registry is one file:
configs/base.yaml # 294 lines, the authoritative registry
core/config.py names it once, at module scope:
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CONFIG = REPO_ROOT / "configs" / "base.yaml"
(core/config.py:24-25)
That is the only path the loader resolves by default. load_config() with no argument reads
exactly this file, and get_config() — the singleton most of the codebase imports — reads exactly
this file. The loader never globs configs/*.yaml. This is not incidental; it is the property
that lets configs/deploy.yaml sit beside the registry without joining it (Part E).
1.1 What the registry contains
configs/base.yaml is organised into eighteen top-level blocks. Every one of them is loaded,
validated and hashed:
| Block | Purpose | Representative keys |
|---|---|---|
project |
identity, seed, schema version | name, version, seed, schema_version |
image |
input geometry and tiling policy | max_pixels, tile_size, tile_overlap, max_tiles, top_k_tiles |
optical |
optical band handling | normalization, lower_percentile, upper_percentile, canonical_channels |
sar |
SAR band handling | representation, clip_min_db, clip_max_db, canonical_channels |
router |
intent router (encoder + adapter + training) | model, revision, max_length, embedding_dim, num_tasks, tasks, training.* |
vlm |
SmolVLM contract | checkpoint, revision, loader_class_preference, processor_longest_edge, prompt_must_use_chat_template |
grounding |
RemoteCLIP grounding encoder | checkpoint_repo, image_size, resolution_frozen, encoder_projected_dim |
grounding_head |
the trainable head over the frozen encoder | hidden_dim, dropout, feature_dim, positive_confidence_weight, decode |
grounding_training |
grounding head training protocol | learning_rate, epochs, box_loss_weight, giou_loss_weight, confidence_loss_weight |
change |
Siamese change detection | tile_size, threshold, encoder, sa_mode, bce_weight, dice_weight, levir_split.* |
croma |
optical-SAR encoder | checkpoint_repo, image_resolution, encoder_dim, optical_channels, sar_channels, modalities_used |
fusion |
optical-SAR fusion head | input_dim, hidden_dim, dropout, num_classes |
evidence |
evidence aggregation bounds | max_items, coordinate_system_default |
confidence |
calibration switch + artifact | temperature_scaling, calibration_file |
agent |
controller policy | max_specialists, timeout_seconds, unload_after_workflow, states |
training |
VLM adaptation protocol | precision, vlm_batch_size, lora_rank, gradient_checkpointing |
evaluation |
evaluation isolation | immutable_public_test, hidden_data_access, leakage_split_key |
deployment |
frozen deployment declaration | platform, sdk, zerogpu, torch_compile, gpu_duration_*, cpu_mode_required |
(Every key above is present in configs/base.yaml; the block count is eighteen. The table is the
authoritative enumeration.)
2. The rule: "no magic numbers in Python"
The very top of configs/base.yaml states the rule in three lines:
# SatQuery AI — authoritative configuration registry
# Architecture v1.0 (docs/ARCHITECTURE_FREEZE.md)
#
# 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:1-5)
The core/config.py module docstring restates the intent from the loader's side:
"One config system. No duplicated constants. Every value in
configs/base.yamlis loaded, validated against the frozen architecture, and hashed so evaluation runs are reproducible." (core/config.py:1-9)
2.1 Why the rule exists — the two reasons, and they are different
It is tempting to read "no magic numbers" as a style preference. It is not. It buys two specific properties, and the second is the one that matters most.
Reason 1 — one place to change. A number that lives in a module is a number that can be
duplicated. image.tile_size is 512. If it were a literal in preprocessing/tiling.py, then the VLM
processor's longest_edge pin, the grounding tile geometry, and the tiling policy would each carry
their own copy, and the copies would drift. The loader's own comment on the F5-2 guard makes this
explicit — the pin is "tied to image.tile_size" so that "the processor cannot silently start
upscaling tiles again" (core/config.py:129-130). A cross-block invariant is only expressible if
both blocks are in the same registry and the loader can compare them.
Reason 2 — one hash to pin. This is the reason that turns a style rule into an architecture
decision. Because every value is in one file, the whole file can be hashed, and that hash can be
recorded alongside a measurement. Config.hash is that hash. artifacts/change/levir_change_v001/model_metadata.json
records the hash the shipped change head was trained under, and scripts/eval_change.py refuses to
score when the current hash drifts from it (exit 3) — proven by the test docstring at
tests/test_config.py:46-73:
"The hash is a frozen CONTRACT, not merely a stability property. … A well-meaning config edit therefore silently invalidates the project's benchmark."
If numbers lived in modules, there would be no single artifact to hash, and the reproduction check would have to enumerate every module — which is the same as not having it. The "one place" rule is what makes the "one hash" rule possible. They are one decision, not two.
2.2 What the rule does NOT cover — deployment state
The rule is "everything tunable lives here", and the corollary is that things that are not architecture deliberately do not. The clearest worked example is the asset-upload store, whose sizing lives in the environment, not the registry:
"Read from the environment rather than from
configs/base.yamlon purpose: adding a key there movesConfig.hashoff78f1e3700da15aa1and invalidates the frozen Phase-9 benchmark. Sizing is deployment state." (app/space_app.py,_asset_max_filesdocstring)
The same reasoning governs SATQUERY_ASSET_MAX_FILES, SATQUERY_ASSET_TTL_S,
SATQUERY_MAX_FILE_BYTES, SATQUERY_MAX_BODY_BYTES, SATQUERY_ASSET_ENABLED and
SATQUERY_ASSET_DIR (docs/DEPLOYMENT_ARCHITECTURE.md §4). And owner decision D-4 records the
same pattern for a model path: rather than add grounding_head.head_path to base.yaml, the
default lives in code as DEFAULT_HEAD_PATH, "because setting it would have moved the frozen config
hash" (docs/OWNER_DECISIONS_2026-09-23.md D-4).
The pattern is a rule of thumb the reader can apply:
If the value changes the architecture, it belongs in the registry and is hashed. If the value changes only where or how large the deployment is, it belongs in the environment.
Two facts make that rule checkable rather than aspirational: change.checkpoint_path and
grounding_head.head_path are both absent from configs/base.yaml (verified by reading all 294
lines), and core/registry.py declares them as optional config keys resolved at the call site
(docs/OWNER_DECISIONS_2026-09-23.md D-4; docs/DEPLOYMENT_ARCHITECTURE.md §5.6).
Part B — The loader pipeline
3. load_config — the five stages
The entry point is load_config, 28 lines, and it does exactly five things in a fixed order:
def load_config(
path: str | Path | None = None,
overrides: dict[str, Any] | None = None,
) -> Config:
"""Load, merge and validate the configuration registry."""
cfg_path = Path(path) if path else DEFAULT_CONFIG
if not cfg_path.exists():
raise ConfigError(f"config file not found: {cfg_path}")
with cfg_path.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
if overrides:
data = _deep_merge(data, overrides)
# Environment overrides for the two values most likely to differ by host.
if env_prec := os.environ.get("SATQUERY_PRECISION"):
data.setdefault("training", {})["precision"] = env_prec
if env_dev := os.environ.get("SATQUERY_TORCH_COMPILE"):
data.setdefault("deployment", {})["torch_compile"] = env_dev.lower() == "true"
return Config(data, source=str(cfg_path))
(core/config.py:246-267)
The stages, in order, with what each one is for:
| # | Stage | Code | What it establishes |
|---|---|---|---|
| 1 | Resolve path | Path(path) if path else DEFAULT_CONFIG |
explicit path, or the one registry |
| 2 | Fail fast if absent | if not cfg_path.exists(): raise ConfigError |
a missing registry is a named error, not a FileNotFoundError traceback |
| 3 | Parse YAML | yaml.safe_load(fh) or {} |
a mapping; empty file → {}, never None |
| 4 | Deep-merge overrides | _deep_merge(data, overrides) |
programmatic overrides that do not touch the file |
| 5 | Env overrides | SATQUERY_PRECISION, SATQUERY_TORCH_COMPILE |
the two values most likely to differ by host |
| 6 | Construct + validate | Config(data, source=...) |
_validate() runs in __init__ |
Two ordering facts are load-bearing and easy to miss:
- Environment overrides win over
overrides. Stage 5 runs after stage 4, and it assigns directly intodata. A caller that passesoverrides={"training": {"precision": "bf16"}}whileSATQUERY_PRECISION=fp16is set getsfp16. The environment is the outermost layer. - Environment overrides still go through validation. They are written into
databeforeConfig(...)is constructed, soSATQUERY_TORCH_COMPILE=truedoes not quietly enabletorch.compile— it setsdeployment.torch_compile = True, which the C-8 guard then rejects with aConfigError. LikewiseSATQUERY_PRECISION=tf32is rejected by the C-6 guard. The environment is a legitimate override surface, not a way around the guards.
Note also stage 3's or {}: yaml.safe_load returns None for an empty document, and the loader
coerces that to an empty mapping so the subsequent data.setdefault(...) calls cannot raise
AttributeError. A registry that parses to nothing then fails validation on every required key,
which is the correct outcome — but it fails with named errors, not a crash inside the loader.
3.1 _deep_merge — recursive, right-biased, and shallow-safe
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
out = dict(base)
for key, value in override.items():
if key in out and isinstance(out[key], dict) and isinstance(value, dict):
out[key] = _deep_merge(out[key], value)
else:
out[key] = value
return out
(core/config.py:33-40)
The semantics, exhaustively:
- A new top-level key is copied verbatim —
overridecan add blocks the registry lacks. This is exercised bytest_unknown_key_in_override_is_kept_not_crashing(tests/test_config.py:345-348): "overrides may add keys; the registry is permissive, the guards are specific." - A nested mapping merges recursively —
{"fusion": {"hidden_dim": 1024}}changes onlyfusion.hidden_dim, leavingfusion.input_dim,fusion.dropoutandfusion.num_classesintact. This is the whole point of a deep merge. - A non-mapping value replaces — a scalar, list,
Noneor a mapping-over-scalar all overwrite. There is no list concatenation and no list merging; a list override is a replacement. baseis not mutated —out = dict(base)copies the top level, and recursive calls copy each nested level they descend into. The caller'sdatadict fromyaml.safe_loadis safe to reuse.
The isinstance(..., dict) check on both sides is what makes the merge total: if base[key] is a
list and override[key] is a dict (or vice versa), the branch falls to else and the override wins
outright rather than raising.
The consequence for hash stability is direct: test_hash_changes_when_config_changes
(tests/test_config.py:35-38) asserts that load_config(overrides={"fusion": {"hidden_dim": 1024}})
produces a hash different from the base config. An override is a config change, and a config
change is a hash change. That is exactly why the registry's builders= override (Part D.4) is a
call-site mechanism and not a config key.
3.2 The two environment overrides, exactly
if env_prec := os.environ.get("SATQUERY_PRECISION"):
data.setdefault("training", {})["precision"] = env_prec
if env_dev := os.environ.get("SATQUERY_TORCH_COMPILE"):
data.setdefault("deployment", {})["torch_compile"] = env_dev.lower() == "true"
(core/config.py:261-265)
| Variable | Writes | Coercion | Validation that then applies |
|---|---|---|---|
SATQUERY_PRECISION |
training.precision |
none (verbatim string) | C-6: must be fp16 / bf16 / fp32 |
SATQUERY_TORCH_COMPILE |
deployment.torch_compile |
.lower() == "true" → bool |
C-8: True is forbidden |
Three precise behaviours worth recording:
- Truthiness gate, not presence gate.
os.environ.get(...)is used in a walrus with a truthiness test, soSATQUERY_TORCH_COMPILE=""(empty string) is ignored — the registry value stands. Only a non-empty value overrides. The comment calls these "the two values most likely to differ by host". - The bool coercion is case-insensitive and exact.
SATQUERY_TORCH_COMPILE=TRUE→True;SATQUERY_TORCH_COMPILE=1→False(because"1".lower() != "true");SATQUERY_TORCH_COMPILE=yes→False. Anything that is nottrue(case-insensitively) becomesFalse, which is the C-8-safe direction — an unparsable value cannot enabletorch.compile. setdefaultmakes the write safe on a partial registry.data.setdefault("training", {})only creates the block if it is absent, so an override that already providedtrainingis not clobbered at the block level (only theprecisionleaf is set).
These two are the only environment overrides in the loader. Every other environment variable in the
project — device selection, asset store sizing, gateway limits — is read at its point of use, not by
the registry loader. That split is deliberate: SATQUERY_DEVICE (Part B.6) must be readable without
importing torch, which the loader's validation path must not require.
4. get_config() — the process-wide singleton
@lru_cache(maxsize=1)
def get_config() -> Config:
"""Process-wide singleton. Import this, do not re-read YAML."""
return load_config()
(core/config.py:270-273)
Three properties follow from lru_cache(maxsize=1):
- The YAML is read once per process. The first caller pays the file read and the
_validate()pass; every later caller gets the identical object. There is no re-read and no re-validation. - The hash is computed on demand but the data is fixed.
hashis a@property(core/config.py:76-80), so it recomputes the sha256 each access — but it hashes the same frozen_data, so it is stable. It is not cached, and it does not need to be. maxsize=1with no arguments means exactly one cached entry. Becauseget_config()takes no arguments, the cache can never hold more than oneConfig, which is what "process-wide singleton" means here. There is no key to vary.
The import convention. The docstring is an instruction, not a description: "Import this, do not
re-read YAML." A module that calls load_config() in a hot path would re-read the file and
re-validate on every call. get_config() is the intended surface, and the registry it returns is the
one every subsystem sees.
One caveat about Config's "immutable" docstring. The class docstring calls itself an
"Immutable, validated, hashable view over the YAML registry" (core/config.py:44). Read precisely,
that means the class exposes no mutating API: there is no setter, no __setitem__, and the only
way to obtain a plain mutable copy is as_dict(), which returns a fresh structure via a JSON
round-trip. The underlying self._data dict is reachable through __getitem__ and get, and no
code path in the repository mutates it. The practical guarantee a reader should rely on is therefore:
treat the returned Config as read-only, and if you need a modified registry, call load_config
with overrides and accept that the hash moves.
5. Access surfaces: get, require, __getitem__, as_dict, seed
Four access methods and two convenience properties make up the whole read surface.
5.1 get(path, default=None) — dotted-path traversal
def get(self, path: str, default: Any = None) -> Any:
"""Dotted-path access: cfg.get('croma.image_resolution')."""
node: Any = self._data
for part in path.split("."):
if not isinstance(node, dict) or part not in node:
return default
node = node[part]
return node
(core/config.py:57-64)
Behaviour, exhaustively:
- The path is split on
"."; each segment must be a key of a dict. - A missing segment returns
default— never raises. - Traversing through a non-dict returns
default.cfg.get("project.name.oops")returnsNone, becauseproject.nameis a string andisinstance(node, dict)fails on the next iteration. defaultdefaults toNone, sogetcannot distinguish "key absent" from "key present andNone" by itself. That ambiguity is whatrequireexists to resolve.- A single-segment path works:
cfg.get("project")returns the whole block.
test_dotted_access_and_require (tests/test_config.py:84-89) pins the basics:
cfg.get("change.tile_size") == 256.
5.2 require(path) — a sentinel, so None is a legal value
def require(self, path: str) -> Any:
sentinel = object()
value = self.get(path, sentinel)
if value is sentinel:
raise ConfigError(f"required config key missing: {path}")
return value
(core/config.py:66-71)
The sentinel = object() is the whole design. If require called self.get(path) with the default
None and then tested if value is None, a key that is legitimately None — and the registry has
several, such as evaluation.official_aggregate_weights: null — would be reported as missing. A
fresh object() is unique, cannot appear in YAML, and therefore separates "absent" from "present and
null" exactly.
require raises ConfigError (a WorkflowPlanError subclass), so a missing required key surfaces as
the project's own error taxonomy, not a bare KeyError. test_dotted_access_and_require pins both
halves: cfg.require("project.seed") == 42, and cfg.require("does.not.exist") raises ConfigError.
5.3 __getitem__ — top-level only, and it raises
def __getitem__(self, key: str) -> Any:
if key not in self._data:
raise KeyError(key)
return self._data[key]
(core/config.py:52-55)
Unlike get, cfg["x"] is a top-level lookup that raises KeyError when absent. It does not
understand dotted paths — cfg["croma.image_resolution"] raises KeyError, because there is no
top-level key with that literal name. The two surfaces are deliberately different: [] is for
"this must exist and I want it to fail loudly", get is for "this may not exist and I have a
fallback".
5.4 as_dict() — a JSON-safe deep copy
def as_dict(self) -> dict[str, Any]:
return json.loads(json.dumps(self._data, default=str))
(core/config.py:73-74)
The json.dumps(..., default=str) → json.loads(...) round-trip does two things: it produces a
fresh structure (no aliasing of the registry's nested dicts), and it coerces any non-JSON value
to its string form via default=str. test_as_dict_roundtrips_to_json_safe_types
(tests/test_config.py:332-337) asserts the result json.dumps cleanly and that
payload["project"]["name"] == "satquery-ai". This is the surface used when a registry snapshot has
to be embedded in a response or a trace.
5.5 seed — the one convenience property for reproducibility
@property
def seed(self) -> int:
return int(self.get("project.seed", 42))
(core/config.py:82-84)
Note the fallback: if project.seed were absent, seed returns 42 rather than raising. The
registry declares project.seed: 42 (configs/base.yaml:10), so the fallback is a safety net, not
the source of truth. test_seed_is_int (tests/test_config.py:92-93) pins 42.
6. device_preference and SATQUERY_DEVICE
@property
def device_preference(self) -> str:
override = os.environ.get("SATQUERY_DEVICE")
if override:
return override
return "cuda" if _torch_cuda_available() else "cpu"
(core/config.py:86-91)
def _torch_cuda_available() -> bool:
try:
import torch # noqa: PLC0415
return bool(torch.cuda.is_available())
except Exception:
return False
(core/config.py:237-243)
Four facts about this property:
- Environment wins. If
SATQUERY_DEVICEis set to any non-empty string, that string is returned verbatim — no validation, no case-folding, no normalisation at this layer. The value is passed through as the caller wrote it. - Otherwise it probes torch, and the probe is defensive.
_torch_cuda_available()wraps the import and the call in a singletry/except Exceptionand returnsFalseon any failure. On a machine with no torch installed — which is the CPU development environment and the base CI image — the property returns"cpu"rather than raisingModuleNotFoundError. The comment# noqa: PLC0415records that the local import is intentional: it keepsimport core.configcheap. - It is not part of the hash.
device_preferenceis a computed property overos.environand a runtime probe. It does not read or writeself._data, so it cannot moveConfig.hash. This is why the device can differ per host without invalidating a single published number. - It is deliberately not read on the metadata path.
docs/DEPLOYMENT_ARCHITECTURE.md§3.3 records thatbuild_serving_registry()"viaConfig.device_preference, a@propertythat calls_torch_cuda_available()" was one of two things that did import torch on the health path, and that both were removed. The testtest_the_metadata_path_does_not_import_torchruns the import in a subprocess and assertstorch imported: False. So the property exists, is correct, and is simply not on the path that must stay torch-free.
A note on validation of the served value. docs/DEPLOYMENT_ARCHITECTURE.md §4 records finding
F-8: the served device value is validated downstream (the contract publishes a closed set
cpu | cuda | mps | null, and an unrecognised value yields null rather than being echoed). That
validation lives in the deployment adapter, not in Config.device_preference. The distinction is
worth keeping straight: the config layer passes the string through; the serving layer narrows it.
7. ConfigError and the error taxonomy
class ConfigError(WorkflowPlanError):
code = "config_error"
user_message = "The system configuration is invalid."
(core/config.py:28-30)
A configuration failure is a WorkflowPlanError subclass carrying a machine code
(config_error) and a client-safe user_message. It is raised from exactly three places:
| Site | Trigger |
|---|---|
load_config |
the registry file does not exist (config file not found: …) |
require |
a required key is missing (required config key missing: …) |
_validate |
one or more frozen-architecture guards failed (a multi-line list) |
Because _validate collects all errors and raises one ConfigError listing every failure, a
bad registry reports every problem in a single startup message rather than one per run. That
collection behaviour is covered in Part C.13.
Part C — Every validation rule
8. How _validate is structured
def _validate(self) -> None:
errors: list[str] = []
...
if errors:
raise ConfigError(
"configuration failed frozen-architecture validation:\n - "
+ "\n - ".join(errors)
)
(core/config.py:94-95, :218-222)
Every check appends to errors; nothing raises mid-pass. The single raise at the end means the
failure message is a bulleted list of every violated rule at once. _validate runs from
Config.__init__ (core/config.py:46-49), so a Config object that exists is a Config object
that passed every rule — there is no unvalidated state to hold.
The rules are grouped by the finding they encode. The table below is the map; each rule gets its own sub-section.
| # | Rule | Finding | Source |
|---|---|---|---|
| C.1 | croma.image_resolution is an int multiple of 8 |
C-7 | core/config.py:97-105 |
| C.2 | training.precision ∈ {fp16, bf16, fp32} |
C-6 | core/config.py:107-112 |
| C.3 | deployment.torch_compile must not be True |
C-8 | core/config.py:114-119 |
| C.4 | vlm.processor_longest_edge is an int ≥ 1 and ≤ image.tile_size |
F5-2 (C-3) | core/config.py:121-142 |
| C.5 | vlm.prompt_must_use_chat_template must be True |
F5-3 | core/config.py:144-150 |
| C.6 | fusion.input_dim == 3·768 + 12 + 2 |
C-1 | core/config.py:152-162 |
| C.7 | croma.optical_channels == 12, croma.sar_channels == 2 |
C-1 | core/config.py:164-168 |
| C.8 | grounding_head.feature_dim == 4 · grounding.encoder_projected_dim |
P7-1 | core/config.py:170-196 |
| C.9 | router.tasks includes unsupported |
router ontology | core/config.py:198-201 |
| C.10 | router.num_tasks == len(router.tasks) |
router ontology | core/config.py:202-206 |
| C.11 | change.encoder present; change.sa_mode ∈ {BAM, PAM} |
change config | core/config.py:208-212 |
| C.12 | image.top_k_tiles ≤ image.max_tiles |
tiling policy | core/config.py:214-216 |
9. C-7 — croma.image_resolution must be a multiple of 8
# --- C-7: CROMA image_resolution must be a multiple of 8 -----------
croma_res = self.get("croma.image_resolution")
if croma_res is None:
errors.append("croma.image_resolution is required")
elif not isinstance(croma_res, int) or croma_res % 8 != 0:
errors.append(
f"croma.image_resolution must be an int multiple of 8 "
f"(CROMA asserts image_resolution % 8 == 0); got {croma_res!r}"
)
(core/config.py:97-105)
What it checks. Three things at once: the key is present, it is an int, and it is divisible by
8.
Why. CROMA's own constructor contains assert image_resolution % 8 == 0
(docs/PHASE0_CONTRACT_VALIDATION.md §1.1). The 8 is CROMA's patch size: num_patches = int((image_resolution / 8) ** 2). A resolution that is not a multiple of 8 does not merely produce a
different patch count — it violates an assertion inside the vendored model, which means the
failure would surface at model construction, after the config has been accepted and the process has
started. The loader moves that failure to startup, and gives it a name.
The registry's value is image_resolution: 120 with the comment "finding C-7: image_resolution % 8 == 0. Native 120 → 225 patches." (configs/base.yaml:208-209). The arithmetic:
120 / 8 = 15, and 15² = 225 patches — matching the verified num_patches = 225 (15×15) at
docs/PHASE0_CONTRACT_VALIDATION.md §1.3.
Why the type check matters. not isinstance(croma_res, int) rejects a float such as 120.0 even
though 120.0 % 8 == 0.0 would be falsy-safe — because CROMA's int(...) cast and its assertion are
written against an integer, and a float would change num_patches' arithmetic. It also rejects
True, since isinstance(True, int) is True in Python but True % 8 != 0, so the guard still
catches it.
Pinned by: test_croma_resolution_must_be_multiple_of_8 (rejects 121) and
test_croma_native_resolution_is_valid (accepts 120, asserts % 8 == 0) —
tests/test_config.py:123-131.
10. C-6 — training.precision must be a viable precision, and T4 makes it fp16
# --- C-6: precision must be viable on the target accelerator -------
precision = self.get("training.precision")
if precision not in {"fp16", "bf16", "fp32"}:
errors.append(
f"training.precision must be fp16|bf16|fp32, got {precision!r}"
)
(core/config.py:107-112)
What it checks. Membership in a closed set of three strings. Note there is no type guard beyond set membership — a non-string simply fails to be in the set and is rejected.
Why three, and why fp16 is the frozen choice. The Phase-0 contract validation measured the
training accelerator and found the plan's declared bf16 unusable:
| Source | Statement |
|---|---|
docs/PHASE0_CONTRACT_VALIDATION.md §7 (contradiction register) |
C-6: plan said precision: bf16; "T4 is SM 7.5, no bf16 tensor cores"; resolution "fp16 + AMP default" |
configs/base.yaml:254 (block header) |
"Training (finding C-6: T4 is SM 7.5 → fp16, NOT bf16)" |
configs/base.yaml:257 |
precision: fp16 |
docs/ARCHITECTURE_FREEZE.md §4 |
training.precision = fp16, reason "T4 = SM 7.5, no bf16 tensor cores (C-6)" |
The rationale in one sentence: bf16 requires hardware support that the target accelerator does not
have. NVIDIA's T4 is compute capability SM 7.5, which predates bf16 tensor cores; requesting
bf16 there is not "slower", it is unsupported. fp16 is the mixed-precision format that the T4 does
accelerate, so fp16 is the frozen default rather than a preference.
fp32 remains in the allowed set because a full-precision run is a legitimate (if slower) choice and
is not architecturally invalid; bf16 remains in the set because the guard is about internal
consistency of the value, not about forbidding the format — the host-specific consequence is
documented, and the loader's job is to reject typos and invented formats like tf32 or bf16-amp.
Pinned by: test_precision_must_be_known (rejects tf32) and test_precision_defaults_to_fp16_for_t4
(asserts fp16) — tests/test_config.py:175-182.
11. C-8 — deployment.torch_compile is forbidden
# --- C-8: ZeroGPU forbids torch.compile ----------------------------
if self.get("deployment.torch_compile") is True:
errors.append(
"deployment.torch_compile=true is forbidden: ZeroGPU does not "
"support torch.compile (finding C-8)"
)
(core/config.py:114-119)
What it checks. is True — an identity test against the boolean True. Not truthiness. A value
of 1, "true" (string) or "yes" does not trip this guard, because none of them is True.
The registry's torch_compile: false (configs/base.yaml:287) is a real YAML boolean, so the
comparison is boolean-to-boolean.
Why. docs/PHASE0_CONTRACT_VALIDATION.md §5.1 records the verified ZeroGPU constraint directly:
"torch.compile | not supported (use AoTI, torch 2.8+)". Finding C-8's resolution states:
"torch.compile is forbidden throughout." Enabling it on a ZeroGPU Space is not a performance
regression — it is a configuration the platform does not support, so the loader makes it impossible
to ship.
The registry carries the same statement inline:
deployment:
platform: huggingface-spaces
sdk: gradio
zerogpu: true
# finding C-8: ZeroGPU does not support torch.compile. Never enable.
torch_compile: false
(configs/base.yaml:282-287)
Interaction with the environment override. As noted in Part B.3.2,
SATQUERY_TORCH_COMPILE=true sets this key to True and is then rejected here. The guard is the
backstop for the override, not a bypass of it.
Pinned by: test_torch_compile_is_forbidden_on_zerogpu — tests/test_config.py:185-187. The
deploy validator independently requires torch_compile: False in both base.yaml and deploy.yaml
(scripts/validate_deploy_config.py, REQUIRED_BOOL).
12. F5-2 — the VLM processor must not upscale our tiles
This is the longest guard in the file, and its comment block is the reason: it encodes a measurement, not an assumption.
# --- C-3 / F5-2: the VLM processor must not upscale our tiles -----
#
# MEASURED (docs/PHASE5_VLM_CONTRACT.md): the processor's default
# longest_edge is 2048. A 512 px tile is upscaled 4x and then split by
# do_image_splitting into 4x4 sub-images + 1 overview = 17 images and
# 1142 prompt tokens, versus 1 image when pinned. The plan estimated a
# 4x overrun; the real figure is ~17x.
#
# Tying this to image.tile_size makes the pin a control rather than a
# comment: the processor cannot silently start upscaling tiles again.
proc_edge = self.get("vlm.processor_longest_edge")
tile_size = self.get("image.tile_size")
if not isinstance(proc_edge, int) or proc_edge < 1:
errors.append(
f"vlm.processor_longest_edge must be an int >= 1, got {proc_edge!r}"
)
elif isinstance(tile_size, int) and proc_edge > tile_size:
errors.append(
f"vlm.processor_longest_edge={proc_edge} exceeds "
f"image.tile_size={tile_size}; the processor would upscale every "
f"tile and then split it into ~17 sub-images (finding F5-2)"
)
(core/config.py:121-142)
What it checks. Two things: processor_longest_edge is a positive integer, and — when
image.tile_size is an int — it does not exceed the tile size.
Why — the measurement. The VLM processor's default longest_edge is 2048. A 512 px tile is
therefore upscaled 4×, and then do_image_splitting=True cuts the 2048 px image into 4×4 = 16
sub-images of 512 px each, plus one overview image. The measured result
(docs/PHASE5_VLM_CONTRACT.md §"The headline finding"):
INPUT: one 512×512 RGB tile
DEFAULT (size.longest_edge = 2048, do_image_splitting = True)
pixel_values (1, 17, 3, 512, 512) <- 17 images
input_ids (1, 1142) <- 1142 tokens
PINNED (size = {"longest_edge": 512})
pixel_values (1, 1, 3, 512, 512) <- 1 image
The plan's finding C-3 predicted a 4× cost overrun. The measured figure is ~17×, and the same document states why the difference is not academic: "the difference between '4×' and '17×' is the difference between a cost you absorb and a cost that makes the deployment quota non-viable." At 1142 prompt tokens per call, an un-pinned processor "would spend a user's entire daily quota on one or two queries" against the 5 GPU-minutes/day free tier.
Why the guard is tied to the tile size rather than merely asserting 512. The comment says it
plainly: "Tying this to image.tile_size makes the pin a control rather than a comment: the
processor cannot silently start upscaling tiles again." A hard-coded 512 check would pass even if
someone later changed image.tile_size to 1024, leaving the processor at 512 — now downscaling.
The relational check keeps the two numbers in the relationship the architecture requires, which is
only possible because both live in one registry.
The isinstance(tile_size, int) guard on the second branch is a defensive detail: if
image.tile_size were missing or non-int, the comparison is skipped rather than raising a TypeError
inside the validator. The first branch still enforces positivity on proc_edge independently.
The registry values. processor_longest_edge: 512 with the F5-2 comment and the measured
before/after, and the note "This value MUST be set explicitly on the processor at construction
time." (configs/base.yaml:98-105). image.tile_size: 512 (configs/base.yaml:18). The two are
equal, which is the strongest form of the constraint.
Why do_image_splitting stays true. The registry explains: "Kept true so genuinely oversized
inputs still split, but at 512 a tile no longer exceeds max_image_size (also 512) and splitting does
not trigger." (configs/base.yaml:106-108). The pin removes the upscaling that triggers splitting;
it does not remove the splitting capability.
Pinned by four tests (tests/test_config.py:137-162): test_processor_longest_edge_must_be_positive_int
(rejects 0), test_processor_must_not_upscale_tiles (asserts ≤), test_processor_upscaling_is_rejected
(rejects 4096), and test_processor_pin_is_tied_to_tile_size (asserts equality with
image.tile_size). And the pin is confirmed in the real load path, not just the probe:
docs/PHASE5_VLM_CONTRACT.md records max_images_seen = 1 as "the F5-2 confirmation: the pin
survives model construction, processor construction, and generation. Unpinned it would read 17."
13. F5-3 — prompts must go through the chat template
# --- F5-3: prompts must go through the chat template --------------
if self.get("vlm.prompt_must_use_chat_template") is not True:
errors.append(
"vlm.prompt_must_use_chat_template must be true: SmolVLM raises "
"ValueError on prompts lacking one <image> token per image "
"(finding F5-3)"
)
(core/config.py:144-150)
What it checks. is not True — so anything other than the boolean True (including a missing
key, 1, "true", or None) is rejected. This is the strictest form in the file, and deliberately
so: the setting is not a tunable, it is an invariant.
Why. SmolVLM requires one <image> token per image in the prompt. A hand-written prompt
string lacks them and raises ValueError. docs/PHASE5_VLM_CONTRACT.md §F5-3 records the finding,
and the registry records the fix as a permanent fact rather than a rediscovery:
# Finding F5-3: SmolVLM requires one <image> token per image in the prompt.
# Hand-written prompt strings raise ValueError; always build prompts through
# processor.apply_chat_template(). Recorded so it is not rediscovered.
prompt_must_use_chat_template: true
(configs/base.yaml:109-112)
Why it is a config guard at all. The value does not parametrise anything — the prompt builder
either uses the chat template or it does not. Making it a registry key with a hard is not True
check converts an implementation convention into an enforced invariant: "the chat-template path must
not be disableable" (tests/test_config.py:165-169). A future refactor cannot quietly introduce a
hand-built prompt path, because the registry declares the invariant and the loader enforces it.
Pinned by: test_chat_template_is_required (rejects False) — tests/test_config.py:165-169.
14. C-1 — fusion.input_dim must match the verified CROMA concatenation
# --- C-1: fusion input dim must match the verified concatenation ---
expected = self._expected_fusion_dim()
declared = self.get("fusion.input_dim")
if expected is not None and declared != expected:
errors.append(
f"fusion.input_dim={declared} disagrees with the verified CROMA "
f"concatenation ({expected} = 3*encoder_dim + optical_channels + "
f"sar_channels). CROMA emits optical/SAR/joint GAP vectors; the "
f"availability mask is consumed by the fusion head, not by CROMA "
f"(finding C-1)."
)
(core/config.py:152-162)
What it checks. fusion.input_dim equals _expected_fusion_dim(), when that expectation is
computable.
14.1 The expected value is derived, not hard-coded
def _expected_fusion_dim(self) -> int | None:
dim = self.get("croma.encoder_dim")
mods = self.get("croma.modalities_used") or []
opt = self.get("croma.optical_channels")
sar = self.get("croma.sar_channels")
if None in (dim, opt, sar) or not mods:
return None
return len(mods) * int(dim) + int(opt) + int(sar)
(core/config.py:224-231)
The formula is len(modalities_used) · encoder_dim + optical_channels + sar_channels. It returns
None — meaning "cannot compute, skip the check" — if any input is missing. This is why the guard is
if expected is not None and declared != expected: a partial registry produces other, more specific
errors (missing channel counts, missing encoder_dim) rather than a spurious dimension mismatch.
14.2 The arithmetic, with the registry's values
| Term | Source | Value |
|---|---|---|
len(croma.modalities_used) |
configs/base.yaml:214 → [optical, sar, joint] |
3 |
croma.encoder_dim |
configs/base.yaml:210 |
768 |
croma.optical_channels |
configs/base.yaml:211 |
12 |
croma.sar_channels |
configs/base.yaml:212 |
2 |
expected = 3 · 768 + 12 + 2
= 2304 + 14
= 2318
And the registry declares fusion.input_dim: 2318 (configs/base.yaml:219), with the derivation
written inline:
fusion:
# concatenated dim = 3 * 768 + 12 + 2 = 2318
# finding C-1: the availability mask is consumed HERE, not by CROMA.
input_dim: 2318
14.3 Why — C-1 is the project's most consequential contract finding
docs/PHASE0_CONTRACT_VALIDATION.md §1.2 records finding C-1 (P0). CROMA's ViT.__init__ builds
its patch embedding as a fixed-shape nn.Linear:
pixels_per_patch = int(self.patch_size * self.patch_size * in_channels)
self.linear_input = nn.Linear(pixels_per_patch, self.dim)
and its forward signature is def forward(self, SAR_images=None, optical_images=None): — no mask
parameter, no missing-channel token, no mask argument anywhere. The finding is therefore:
*"CROMA cannot consume a channel-availability mask. The master plan §19 … is not implementable against the official code."*
The resolution is what fixes the fusion dimension: zero-fill the optical/SAR tensors to the
canonical channel counts (which is compatible with the fixed Linear), and route the availability
mask to the fusion head as a first-class input. The verified concatenation
(docs/ARCHITECTURE_FREEZE.md §2.5) is:
optical_GAP (B, 768)
SAR_GAP (B, 768)
joint_GAP (B, 768)
optical_mask (B, 12) <- availability, from sensor adapter
sar_mask (B, 2) <- availability, from sensor adapter
---------
concat (B, 2318)
The three GAP vectors come from CROMA's verified six-key forward output
(docs/PHASE0_CONTRACT_VALIDATION.md §1.3); the two masks come from the sensor adapter, not from
CROMA. The guard's error message names exactly this: "the availability mask is consumed by the
fusion head, not by CROMA (finding C-1)."
14.4 Why it is enforced rather than documented
If fusion.input_dim disagreed with the real concatenation width, the fusion head's first Linear
would have the wrong in_features. PyTorch would raise a shape error — but only when the mismatched
tensor reached that layer, which is after CROMA has run and after the fusion head has been
constructed and loaded. The loader's check moves that failure to startup, before any model is
touched, and its message contains the derivation so the reader can see why 2318 is the right
number rather than merely that 2318 is expected.
Pinned by: test_fusion_dim_matches_verified_croma_concatenation (recomputes the formula and
asserts 2318, then asserts fusion.input_dim == expected) and test_fusion_dim_mismatch_is_rejected
(rejects 999) — tests/test_config.py:99-110.
15. C-1 (continued) — the fixed channel counts, 12 and 2
# --- channel counts must match CROMA's fixed inputs ---------------
if self.get("croma.optical_channels") != 12:
errors.append("croma.optical_channels must be 12 (CROMA s2_channels is fixed)")
if self.get("croma.sar_channels") != 2:
errors.append("croma.sar_channels must be 2 (CROMA s1_channels is fixed)")
(core/config.py:164-168)
What it checks. Exact equality with 12 and 2. Two independent guards, each naming the CROMA
attribute it mirrors.
Why — these are fixed, not defaulted. docs/PHASE0_CONTRACT_VALIDATION.md §1.1 quotes CROMA's
source directly:
| Attribute | Verified value | Evidence (verbatim) |
|---|---|---|
| SAR channels | fixed 2 | self.s1_channels = 2 # fixed at 2 SAR backscatter channels |
| Optical channels | fixed 12 | self.s2_channels = 12 # fixed at 12 multispectral optical channels |
Because CROMA's patch embedding is a fixed-shape Linear over
patch_size² · in_channels, the channel count is not a hyperparameter — it is baked into the weight
shapes. Feeding 8 optical channels to a model whose linear_input expects 12 is a shape error at the
input layer. The guards prevent a config from claiming a channel count the frozen encoder cannot
accept.
The registry states the same, with the canonical-order note:
optical:
normalization: percentile
lower_percentile: 2
upper_percentile: 98
# CROMA expects exactly 12 optical channels.
canonical_channels: 12
...
sar:
representation: db
clip_min_db: -30
clip_max_db: 5
# CROMA expects exactly 2 SAR channels (VV, VH).
canonical_channels: 2
(configs/base.yaml:26-38)
Note the relationship to the optical and sar blocks: they each carry a canonical_channels key
and the croma block carries optical_channels / sar_channels. The guards check the croma
values, because those are the ones the fusion dimension formula consumes. The optical / sar
canonical_channels values are the sensor-adapter's statement of the same fact from its side.
docs/ARCHITECTURE_FREEZE.md §2.5 adds the operational detail: optical input is "12 channels,
zero-filled to canonical order" and SAR input is "2 channels, zero-filled to canonical order".
Pinned by: test_croma_channel_counts_are_pinned (rejects optical_channels: 8 and
sar_channels: 3) — tests/test_config.py:113-117.
16. P7-1 — grounding_head.feature_dim == 4 · encoder_projected_dim
This is the guard whose enforcement is the point, so it gets the longest comment in the file after F5-2.
# --- Phase 8: the head's assembled feature must match the encoder ---
#
# Per-cell feature = concat([patch, text, patch*text, global_pool]),
# i.e. 4 x the encoder's PROJECTED dim. Measured as 512, NOT the 768
# transformer width (finding P7-1): `visual.proj` is (768, 512).
#
# A mismatch here is a SILENT shape error. torch only raises at the
# similarity step, by which point the patch features have already been
# computed and cached — so the failure surfaces far from its cause.
#
# grounding.encoder_projected_dim is declared in config so this guard
# needs no torch import; specialists/grounding/remoteclip.py asserts
# the same value against the real model at load time.
projected = self.get("grounding.encoder_projected_dim")
head_dim = self.get("grounding_head.feature_dim")
if not isinstance(projected, int) or projected < 1:
errors.append(
f"grounding.encoder_projected_dim must be a positive int, "
f"got {projected!r}"
)
elif not isinstance(head_dim, int) or head_dim != 4 * projected:
errors.append(
f"grounding_head.feature_dim={head_dim} but the frozen encoder "
f"projects to {projected}, so the assembled per-cell feature is "
f"{4 * projected}-d. Expected feature_dim == 4 * "
f"grounding.encoder_projected_dim (finding P7-1)."
)
(core/config.py:170-196)
What it checks. grounding.encoder_projected_dim is a positive int, and
grounding_head.feature_dim == 4 · encoder_projected_dim.
16.1 The arithmetic
| Term | Source | Value |
|---|---|---|
grounding.encoder_projected_dim |
configs/base.yaml:142 |
512 |
grounding_head.feature_dim |
configs/base.yaml:158 |
2048 |
expected = 4 · 512 = 2048 ✓ matches the declared feature_dim
16.2 Why the factor is 4, and why 512 and not 768
The per-cell feature is the concatenation of four vectors:
per-cell = concat([patch, text, patch·text, global_pool]) -> 4 · projected_dim
The registry records the definition and the "4×" (configs/base.yaml:150-158), and the freeze states
the head structure. The trap the comment names is 768 vs 512:
"the 768-vs-512 distinction is the one that bites.
visual.positional_embeddingis 768 wide andvisual.projis (768, 512); the embeddings the text tower can be compared against are the PROJECTED ones. Using 768 anywhere here would be a shape error that torch would only surface at the similarity computation, by which point the patch features have already been computed and cached." (specialists/grounding/remoteclip.py:15-19)
So RemoteCLIP ViT-B/32 has transformer width 768 but projects to 512. The comparison space
is 512-dimensional, so the head must assemble 4·512 = 2048, not 4·768 = 3072. 4 · 768 = 3072 would
be the "obvious" wrong answer, and it is a shape error rather than a type error — which is exactly
why the loader refuses it.
16.3 Why it is ENFORCED rather than documented — the silence argument
This is the load-bearing sentence in the guard's comment:
"A mismatch here is a SILENT shape error. torch only raises at the similarity step, by which point the patch features have already been computed and cached — so the failure surfaces far from its cause."
Unpack the failure mode:
- The head is constructed with
feature_dim = 3072(say). - The encoder runs and produces patch features projected to 512.
- The head assembles a 3072-wide feature from 512-wide inputs — this may still construct, if the
assembly pads or the mismatch lands at a
Linearwhosein_featureswas set fromfeature_dim. - The error does not fire at construction. It fires at the similarity step, after every patch feature has been computed and cached.
The consequences that make this worse than an ordinary shape error:
- It is far from its cause. The traceback points at the similarity computation inside the encoder or head, not at the config value that was wrong.
- It happens after expensive work. Patch features are already computed and cached by the time it raises, so a training or inference run has paid the cost before failing.
- It is easy to misattribute. A reader seeing a shape error in the similarity step is likely to
suspect the encoder or the head's forward, not a single integer in
configs/base.yaml.
Enforcing the relation in the loader converts a distant, expensive, misattributable runtime failure into a startup failure whose message contains the expected value and the derivation.
16.4 The guard needs no torch import — and a second assertion backs it at load
The comment records the design that makes this possible:
"
grounding.encoder_projected_dimis declared in config so this guard needs no torch import;specialists/grounding/remoteclip.pyasserts the same value against the real model at load time."
So there are two enforcement points, and they check different things:
| Point | Checks | Against |
|---|---|---|
core/config.py P7-1 guard |
feature_dim == 4 · encoder_projected_dim |
the registry's declaration |
specialists/grounding/remoteclip.py |
visual.proj.shape[1] == VERIFIED_PROJECTED_DIM |
the real loaded model |
The config guard is arithmetic over declared numbers, so it is torch-free and runs at startup. The specialist guard checks the actual model, so it catches a declaration that is internally consistent but wrong about the checkpoint:
#: Measured, not assumed. Asserted against at load time.
VERIFIED_PATCH_SIZE = 32
VERIFIED_TRANSFORMER_WIDTH = 768
VERIFIED_PROJECTED_DIM = 512
VERIFIED_PARAMETERS = 151_277_313
(specialists/grounding/remoteclip.py:38-42)
proj = getattr(visual, "proj", None)
if proj is not None and proj.shape[1] != VERIFIED_PROJECTED_DIM:
raise ModelLoadError(
f"visual.proj maps to {proj.shape[1]}, expected "
f"{VERIFIED_PROJECTED_DIM}",
specialist="grounding",
)
(specialists/grounding/remoteclip.py:195-201)
The two together mean: a registry that is self-consistent passes the config guard, and a model whose
visual.proj disagrees with the declaration fails at load. Neither alone is sufficient.
Pinned by: test_head_feature_dim_matches_the_frozen_encoder (imports VERIFIED_PROJECTED_DIM
and asserts feature_dim == 4 · VERIFIED_PROJECTED_DIM) and test_head_feature_dim_mismatch_is_rejected
(rejects 768) — tests/test_config.py:273-288.
16.5 The neighbouring grounding_head value, and why it is not guarded
grounding_head.positive_confidence_weight: 20.0 is in the same block but is not a validated
invariant — it is a tuning value. Its rationale is documented in the registry rather than enforced:
# Objectness BCE sees 1 positive cell out of 49. Unweighted, the optimum is
# "no object" everywhere; this weight is what stops that collapse.
positive_confidence_weight: 20.0
(configs/base.yaml:159-161)
The distinction is the point of Part C: relations that encode a contract are enforced; values that
encode a tuning choice are documented. The test suite pins the direction of the tuning value
(test_head_positive_confidence_weight_is_set asserts > 1.0) without pinning the number, which is
the right granularity for a tunable.
17. Router ontology — unsupported must exist, and the count must match
# --- router ontology ----------------------------------------------
tasks = self.get("router.tasks") or []
if "unsupported" not in tasks:
errors.append("router.tasks must include 'unsupported'")
if self.get("router.num_tasks") != len(tasks):
errors.append(
f"router.num_tasks={self.get('router.num_tasks')} does not match "
f"router.tasks length ({len(tasks)})"
)
(core/config.py:198-206)
What it checks. Two invariants: the task list contains the literal unsupported, and the declared
count equals the list's length.
Why unsupported must be present. unsupported is the router's explicit "this is not a
satellite-imagery question" class (docs/ARCHITECTURE_FREEZE.md §2.1 lists the six classes as
vqa | caption | grounding | change | optical_sar | unsupported). It is the target of the
below-threshold fallback: "Below router.confidence_threshold → deterministic lexical fallback →
else unsupported." Removing it would leave the router with no way to decline a question, which
would force every query into a specialist — the exact failure the class exists to prevent. The guard
makes that impossible to configure away.
Note the or [] on the first line: a missing or null router.tasks is treated as an empty list, so
"unsupported" not in [] is True and the guard fires with the right message, rather than raising
TypeError on in None.
Why num_tasks must equal len(tasks). num_tasks is the classifier's output width — the
number of logits the adapter's task head emits. The registry declares it separately from the list
because the two are consumed in different places:
router:
...
num_tasks: 6
tasks:
- vqa
- caption
- grounding
- change
- optical_sar
- unsupported
(configs/base.yaml:59-66)
Two sources for one number is a drift hazard: a task list of six with a head of five would be a shape mismatch between the ontology and the classifier, and it would fail only when the head was built. The guard keeps them equal, so the ontology and the head width cannot disagree.
The asymmetry a reader should know about. docs/architecture/01-system-overview.md §2 records
that the router's six classes and the capabilities endpoint's six tasks are different sixes —
unsupported is in the router's label space but not the capability list, and change_vqa is the
reverse. The config's router.tasks is the router's ontology (with unsupported), and the guard
is scoped to that. core/schemas.py::Task carries all seven values.
Pinned by: test_router_tasks_include_unsupported, test_router_num_tasks_matches_list, and
test_router_missing_unsupported_is_rejected (rejects ["vqa", "caption"]) —
tests/test_config.py:197-208.
18. Change config — encoder required, sa_mode ∈ {BAM, PAM}
# --- change config -------------------------------------------------
if self.get("change.encoder") is None:
errors.append("change.encoder is required")
if self.get("change.sa_mode") not in {"BAM", "PAM"}:
errors.append("change.sa_mode must be BAM or PAM")
(core/config.py:208-212)
What it checks. change.encoder is present (specifically, not None), and change.sa_mode is
one of two exact strings.
Why encoder is required. The change specialist is a Siamese network: two encoders sharing
weights, plus a decoder. The encoder backbone is not optional — a change detector with no encoder
cannot produce a difference representation. The registry declares encoder: resnet18
(configs/base.yaml:187). The check is is None, not truthiness, so an empty string would pass;
the intent is "the key must be populated with a backbone name", and the registry supplies one.
Why sa_mode is a closed set of two. The spatial-attention module has exactly two verified modes.
docs/PHASE0_CONTRACT_VALIDATION.md §4.1 records the upstream STANet contract:
| Property | Verified value |
|---|---|
| Model variants | CDF0 (base), CDFA (with SA module) |
| Attention modes | --SA_mode BAM or --SA_mode PAM |
The upstream training invocation shows the flag in use: --model CDFA --SA_mode PAM. The registry
declares:
change:
...
encoder: resnet18
sa_mode: PAM # BAM | PAM
pretrained: true
(configs/base.yaml:187-189)
The guard's job is to reject anything else — a typo like Pam, pan, or none — because an
unrecognised mode would silently select neither attention variant. Note the set is case-sensitive:
pam is rejected. The comment # BAM | PAM in the registry is the human-readable form of the same
constraint.
Why the freeze's change contract is what it is. docs/ARCHITECTURE_FREEZE.md §2.4 records the
verified hyperparameters that go with this: "lr = 1e-3, batch_size = 8, patch size 256×256
non-overlapping, loss 0.5·BCE + 0.5·Dice", all present in the change block
(configs/base.yaml:190-193). The encoder and SA mode are the two structural choices; the loader
guards those, and the test suite separately pins the hyperparameters
(test_change_hyperparameters_match_verified_stanet, tests/test_config.py:228-233).
Pinned by: test_change_sa_mode_is_valid and test_change_sa_mode_rejects_junk (rejects XYZ) —
tests/test_config.py:236-242.
19. Tiling policy — top_k_tiles ≤ max_tiles
# --- tiling policy -------------------------------------------------
if self.get("image.top_k_tiles", 0) > self.get("image.max_tiles", 0):
errors.append("image.top_k_tiles cannot exceed image.max_tiles")
(core/config.py:214-216)
What it checks. The number of tiles actually sent through a specialist does not exceed the hard ceiling on tiles examined.
Why. The two values have distinct meanings, stated in the registry:
image:
max_pixels: 25000000
tile_size: 512
tile_overlap: 128
max_tiles: 64
# plan section 9.1 tile policy: whole-image thumbnail first, then top-K tiles.
# max_tiles is the hard ceiling on tiles *examined*; top_k_tiles is how many
# are actually sent through a specialist.
top_k_tiles: 4
(configs/base.yaml:16-24)
max_tiles: 64— the hard ceiling on tiles examined. This bounds the preprocessing work.top_k_tiles: 4— how many tiles are actually sent through a specialist. This bounds the expensive inference work.
top_k_tiles > max_tiles would mean "send more tiles to a specialist than were ever examined", which
is incoherent: the selection step cannot choose from a set larger than the set it built. The guard
makes that impossible.
The 0 defaults are deliberate. Both lookups use self.get(key, 0), so a missing top_k_tiles
or max_tiles reads as 0 and the comparison is 0 > 0 → False. The guard does not fire for a
missing key; that is handled elsewhere (the key simply has no value, and downstream code that needs it
will fail with its own named error). The guard's scope is precisely "the ordering relation between two
present values", nothing more.
The relationship to the VLM pin. image.tile_size: 512 is the value the F5-2 guard compares
against vlm.processor_longest_edge. So the image block participates in two guards — F5-2 and the
tiling check — which is a small illustration of why centralising the registry makes cross-block
invariants expressible.
Pinned by: test_top_k_cannot_exceed_max_tiles (rejects top_k_tiles: 999) —
tests/test_config.py:214-216. A companion test pins a neighbouring relation that the loader does
not guard: test_tile_overlap_smaller_than_tile_size asserts image.tile_overlap < image.tile_size
and change.tile_overlap < change.tile_size (tests/test_config.py:219-222) — a test-only invariant.
20. Rules the loader does NOT enforce (and the tests that pin them instead)
Not every frozen value is a loader guard. The distinction is worth stating explicitly, because a
reader who assumes "frozen ⇒ guarded" will overestimate what _validate protects.
| Value | Why it is not a loader guard | Where it is pinned |
|---|---|---|
grounding.image_size: 224 |
the value is frozen by measurement, not by a structural relation; there is no arithmetic to check | test_grounding_resolution_is_frozen_at_224, test_resolution_experiment_flag_is_gone (tests/test_config.py:248-267) |
grounding.resolution_frozen: true |
a declaration of a decided question, not a constraint | same tests |
evaluation.hidden_data_access: false |
an isolation policy, asserted where evaluation runs | test_hidden_data_access_is_disabled (tests/test_config.py:317-318) |
evaluation.official_aggregate_weights: null |
"the uploaded specification explicitly prohibits inventing an official aggregate formula" (plan §63) | test_no_official_aggregate_weights_are_invented (tests/test_config.py:321-322) |
evaluation.leakage_split_key: scene_id |
a leakage policy, exercised by the leakage tests | test_leakage_split_key_is_scene_level (tests/test_config.py:325-326) |
change.learning_rate, change.batch_size, change.tile_size |
verified upstream hyperparameters, not structural relations | test_change_hyperparameters_match_verified_stanet (tests/test_config.py:228-233) |
deployment.cpu_mode_required: true |
a deployment declaration; the validator checks it, the loader does not | test_cpu_mode_is_required (tests/test_config.py:190-191); scripts/validate_deploy_config.py REQUIRED_BOOL |
grounding_training.* |
a training protocol, present rather than constrained | test_grounding_training_exposes_run_protocol_keys (tests/test_config.py:300-306) |
grounding.benchmark_box_scale: 100.0 |
a unit conversion; wrongness is a data error, not a shape error | test_vrsbench_box_scale_is_declared (tests/test_config.py:309-311) |
The general principle: _validate guards the values whose wrongness produces a failure far from
its cause (a shape error in a model, a silent upscale, a forbidden platform feature). Values whose
wrongness is locally visible — a bad learning rate, an incorrect box scale — are pinned by tests and
documented in the registry, but not enforced at load.
21. The validation failure mode: collect all, raise once
if errors:
raise ConfigError(
"configuration failed frozen-architecture validation:\n - "
+ "\n - ".join(errors)
)
(core/config.py:218-222)
Because every check appends and nothing raises mid-pass, a registry with three problems reports all three in one message. The rendered shape is:
configuration failed frozen-architecture validation:
- croma.image_resolution must be an int multiple of 8 (CROMA asserts image_resolution % 8 == 0); got 121
- training.precision must be fp16|bf16|fp32, got 'tf32'
- image.top_k_tiles cannot exceed image.max_tiles
Two properties of this design:
- Fix-once feedback. An operator editing a config sees every violation in one run rather than discovering them one per attempt.
- The message is a contract surface. Each line names the key and the expectation, and the
finding-carrying rules name their finding id (
C-8,F5-2,C-1,P7-1). A reader who hits a guard can search the id and find the measurement that produced it — which is the whole reason the ids are in the messages.
The error is a WorkflowPlanError subclass (Part B.7), so a config failure is catchable as the
project's own taxonomy and carries code = "config_error" and a client-safe user_message.
Part D — The hash
22. Config.hash — the definition
@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]
(core/config.py:76-80)
Decomposed, exactly:
| Step | Code | Effect |
|---|---|---|
| Serialise | json.dumps(self._data, sort_keys=True, default=str) |
the whole registry as canonical JSON |
| Canonicalise key order | sort_keys=True |
key insertion order cannot change the hash |
| Coerce non-JSON types | default=str |
any non-serialisable value becomes its string form |
| Encode | .encode() |
UTF-8 bytes |
| Digest | hashlib.sha256(blob) |
a 64-hex-character sha256 |
| Truncate | .hexdigest()[:16] |
the first 16 hex characters |
Three consequences worth stating plainly:
- It hashes the whole registry, not a subset. Every block, every key, every value.
sort_keys=Truemeans the hash depends on the content, not on the file's key ordering, so reformatting the YAML without changing values does not move the hash. Adding, removing or editing a value does. default=strmakes the hash total. A value the JSON encoder cannot handle is stringified rather than raising, so hashing never fails on an exotic type.- 16 hex characters is 64 bits. It is a fingerprint, not a cryptographic commitment: it is chosen to be short enough to print in a manifest and a log line. Two different registries colliding in 64 bits is not a concern for this use — the hash is a drift detector, not a security primitive.
repr(Config) surfaces it: <Config source=… hash=…> (core/config.py:233-234).
23. The frozen value: 78f1e3700da15aa1 — VERIFIED
The frozen config hash is 78f1e3700da15aa1.
This is stated in three independent places and was re-verified by execution while writing this
chapter — get_config().hash was run in the repository's virtual environment and returned exactly
78f1e3700da15aa1:
| Source | Statement |
|---|---|
tests/test_config.py:43 |
FROZEN_CONFIG_HASH = "78f1e3700da15aa1" |
docs/PHASE9_FREEZE.md §3 |
*"Config hash |
docs/OWNER_DECISIONS_2026-09-23.md §cross-cutting rule 4 |
"Config.hash = 78f1e3700da15aa1 is verified untouched." |
docs/DEPLOYMENT_ARCHITECTURE.md §3.3 requirement 5 |
the entrypoint "must not … otherwise move Config.hash off 78f1e3700da15aa1" |
The test that pins it is explicit that the value — not merely the stability — is the contract:
def test_config_hash_matches_the_shipped_checkpoint_record() -> None:
"""The hash is a frozen CONTRACT, not merely a stability property.
`test_config_hash_is_stable` only checks `a.hash == b.hash`; it never pins
the VALUE. But the value is load-bearing: `scripts/eval_change.py` reads
`config_hash` from `artifacts/change/levir_change_v001/model_metadata.json`
and REFUSES TO SCORE when the current hash drifts (exit 3). A well-meaning
config edit therefore silently invalidates the project's benchmark.
"""
assert load_config().hash == FROZEN_CONFIG_HASH, (...)
(tests/test_config.py:46-81)
There are two distinct hash tests, and the difference between them is the whole point:
| Test | Asserts | Catches |
|---|---|---|
test_config_hash_is_stable |
a.hash == b.hash, len == 16 |
non-determinism |
test_config_hash_matches_the_shipped_checkpoint_record |
hash == "78f1e3700da15aa1" |
any value drift |
test_config_hash_is_stable would pass even if every value in the registry changed, as long as the
hash stayed deterministic. Only the second test pins the value, and it is the one that guards the
published benchmark.
23.1 The sibling artifact hash
configs/base.yaml also has a file-level sha256, distinct from the registry hash. docs/PHASE9_FREEZE.md
§2 records it as a frozen artifact:
| Path | Bytes | SHA256 (first 16) |
|---|---|---|
configs/base.yaml |
10,637 | 88434f7f8f78e2b8 |
The two are related but not identical: the file hash covers the file's bytes (including comments
and formatting), while Config.hash covers the parsed data. Reformatting the YAML moves the file
hash and leaves Config.hash untouched. Both are frozen; neither substitutes for the other.
24. The rule: editing the config MOVES the hash and INVALIDATES keyed artifacts
This is the central operational fact of the chapter.
24.1 The dependency chain
configs/base.yaml
│ (parsed)
▼
Config._data ──► Config.hash ──► recorded in artifacts/change/levir_change_v001/model_metadata.json
│ │
│ (validated by _validate) ▼
▼ scripts/eval_change.py compares
the running system current hash vs recorded hash
│
drift ──────────┴────────── match
│ │
▼ ▼
exit 3: REFUSES TO SCORE scoring proceeds
24.2 The practical consequence
A value that "looks wrong" is not casually fixed.
Concretely, the project's own most-tempting edit is the worked example. docs/PHASE9_FREEZE.md §6
records it:
"Editing
base.yamlto point at the checkpoint moves the config hash away from78f1e3700da15aa1— which trips the drift guard and makesscripts/eval_change.pyexit3. The frozen benchmark number would be detached from its config."
The edit in question — populating change.checkpoint_path so the trained change head is reachable
from serving — is architecturally correct. It is the right thing for the system to do. It is still
refused, because it would detach the published 0.8122 test number from the configuration it was
measured under. The resolution was not to edit the config; it was the registry's builders=
override, a call-site mechanism that injects the checkpoint without moving the hash
(docs/PHASE9_FREEZE.md §6; docs/DEPLOYMENT_ARCHITECTURE.md §3.3 requirement 2).
Owner decision D-4 records the same discipline for the grounding head: the default head path
"lives in code" as DEFAULT_HEAD_PATH rather than in base.yaml, "because setting it would have
moved the frozen config hash." The cross-cutting rule is stated in
docs/OWNER_DECISIONS_2026-09-23.md:
*"Frozen artefacts stay frozen.
Config.hash = 78f1e3700da15aa1is verified untouched. New defaults live in code, not in the config registry."*
24.3 There is no single "drifted value" — five renderings, five hashes
A subtlety that catches people who try to report the drift: because the hash covers the whole registry, the moved hash depends on the exact string written in. The test docstring records a measurement of five renderings of the same logical change:
| Rendering of the same path | Resulting hash |
|---|---|
backslashes / a Path object |
f4487e1a2cf13733 |
| forward slashes | f571a5f85372ff54 |
| relative | b1d8637d6ab733d9 |
| bare filename | 4fcc6a4a4f681c87 |
| directory with a trailing slash | f5a6ec3db8217218 |
(tests/test_config.py:59-68)
The consequence, stated in the test: "Do NOT quote a single 'drifted value' — it does not exist. … Only the UNMODIFIED hash above is stable and quotable." A doc that printed one drifted hash would be printing a fact about one particular string, not about the change.
24.4 Why the drift guard is a guard and not an inconvenience
scripts/eval_change.py exiting 3 on drift is the mechanism that makes the freeze real. Without
it, a config edit would silently change the system under test, and the recorded 0.8122 would be
re-reported as if nothing had changed. docs/PHASE9_FREEZE.md §1 states the freeze's meaning:
"This freeze is a claim about the past: 'these are the numbers this run produced, and here is the evidence they have not moved.'"
The hash is how "they have not moved" is checkable. docs/PHASE9_FREEZE.md §3 lists the config hash
among the frozen invariants alongside the scene split, the decision threshold and the model
identity — and each of those invariants names where it is enforced.
Part E — configs/deploy.yaml, and why it is left undisturbed
25. What configs/deploy.yaml is
configs/deploy.yaml is a 45-line Hugging Face Spaces packaging manifest from Phase 18. It is
not part of the configuration registry, and the file says so in its own header, in capitals:
# SatQuery AI — Hugging Face Spaces packaging manifest (Phase 18).
#
# THIS FILE IS NOT PART OF THE CONFIG REGISTRY.
#
# `core/config.py` reads exactly ONE file — `configs/base.yaml` (the module's
# `DEFAULT_CONFIG`) — through a single `yaml.safe_load`. It never globs
# `configs/*.yaml`, so this file is never read by the loader. That matters
# because `Config.hash` is a sha256 over the WHOLE registry; merging any key from
# this file into the registry would move the hash off the recorded benchmark
# value `78f1e3700da15aa1`. The `registry: false` marker below makes the
# non-membership explicit and machine-readable, and
# `scripts/validate_deploy_config.py` asserts it.
(configs/deploy.yaml:1-12)
It carries a single top-level marker and a deployment: block that is a byte-for-byte copy of
configs/base.yaml lines 282-295:
registry: false
deployment:
platform: huggingface-spaces
sdk: gradio
zerogpu: true
# finding C-8: ZeroGPU does not support torch.compile. Never enable.
torch_compile: false
# ZeroGPU free tier = 5 GPU-min/day. Declared duration reserves quota up front.
gpu_duration_vqa: 20
gpu_duration_grounding: 45
gpu_duration_change: 30
gpu_duration_optical_sar: 45
cpu_mode_required: true
lazy_load: true
cache_max_models: 1
(configs/deploy.yaml:29-45)
The file's header states the reason the copy is identical:
"The
deployment:block below is a byte-for-byte copy ofconfigs/base.yamllines 282-295. It is kept identical on purpose so the two cannot silently drift;scripts/validate_deploy_config.pyenforces the equality key for key."
26. Why it is FROZEN PAPERWORK
docs/DEPLOYMENT_TOPOLOGY.md §3.4 states the conclusion, and docs/DEPLOYMENT_DECISION.md §4 gives
the reasoning. The reasoning is structural, not sentimental:
"the legacy
configs/deploy.yamlstill describes an HF Space + Gradio + ZeroGPU target. That manifest is frozen paperwork — no Gradio runtime exists in code, and editing it would moveConfig.hash. It is left undisturbed." (docs/DEPLOYMENT_TOPOLOGY.md§3.4)
Two independent reasons, and both paths are closed:
26.1 Reason 1 — Config.hash cannot move
The registry hash is 78f1e3700da15aa1 (Part D). configs/deploy.yaml carries registry: false and
is never read by the loader, so editing the file does not move the hash directly. But
scripts/validate_deploy_config.py hard-fails if the deployment: block in deploy.yaml differs
key-for-key from base.yaml's:
"So changing
zerogpu: true→falseindeploy.yamlalone fails the validator, and movingbase.yamlto match moves the frozen hash. Both paths are closed. The files stay as they are." (docs/DEPLOYMENT_DECISION.md§4)
The validator's check is symmetric and names the mismatch in either direction
(scripts/validate_deploy_config.py, _check_deployment_blocks_equal): a key present in one and
missing from the other is reported, and a key present in both with different values is reported with
both values. So there is no quiet edit: either the file fails the validator, or the registry hash
moves.
The validator's own docstring is careful about what it does and does not prove:
"This is a NECESSARY condition for the file being outside the registry — NOT a proof against a merge: the registry is built FROM
configs/base.yaml, so a key merged intobase.yamlwould appear on both sides and compare equal. The real guarantee against a merge is theConfig.hashregression guard,tests/unit/test_deploy_config.py::test_config_hash_regression_guard, which pins the recorded hash."
That is the honest statement: the key-equality check catches divergence between the two files; the hash regression test catches a merge. Neither alone is sufficient, and the design uses both.
26.2 Reason 2 — there is no Gradio runtime to conflict with
The manifest declares sdk: gradio, but no Gradio application exists in the code:
"No
import gradio, nogr.Blocks, nogr.Interfaceand no Gradio entrypoint exists anywhere. Gradio appears only asrequirements.txt:36and the manifest valuesdk: gradio(configs/base.yaml:284)." (docs/DEPLOYMENT_DECISION.md§4)
The one ZeroGPU code path sits inside a function that is never applied to a route:
def decorate_gpu(task: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
...
spaces = _spaces_module()
if spaces is None or not hasattr(spaces, "GPU"):
def _identity(fn): return fn
return _identity
return spaces.GPU(duration=duration)
(app/space_app.py:144-165)
The routes use plain @api.get / @api.post (app/space_app.py:521, :549, :555, :661), and
the real entrypoint is FastAPI's build_space_app() (app/space_app.py:409). The manifest therefore
describes a Space that does not exist in code:
"Conclusion: the frozen contract describes a Gradio Space that does not exist in code. It is frozen paperwork, not a competing deployment." (
docs/DEPLOYMENT_DECISION.md§4)
Because there is nothing for it to conflict with, there is no benefit to editing it — and a real cost (reason 1). So it stays.
26.3 What the manifest deliberately does NOT specify
The header lists the absences explicitly, so a reader does not mistake them for oversights:
"Deliberately ABSENT (the freeze does not specify these): a hardware SKU, an SDK version pin, a requirements filename, the Space entrypoint/
app_file, Space visibility/owner, and a Python version." (configs/deploy.yaml:24-27)
27. The gpu_duration_* values and the 5 GPU-minute budget
The deployment block's four durations encode a quota constraint measured in Phase 0:
| Task | gpu_duration_* |
Source |
|---|---|---|
vqa / caption |
20 s | configs/deploy.yaml |
grounding |
45 s | configs/deploy.yaml |
change |
30 s | configs/deploy.yaml |
optical_sar |
45 s | configs/deploy.yaml |
change_vqa |
30 s | follows change — shares the STANet detector |
(docs/DEPLOYMENT_ARCHITECTURE.md §3.4)
Why the durations exist at all. docs/PHASE0_CONTRACT_VALIDATION.md §5.1 records the verified
ZeroGPU constraints:
| Constraint | Verified value |
|---|---|
| SDK | Gradio only (Docker/Static cannot schedule onto ZeroGPU) |
| Decorator | @spaces.GPU(duration=N), default 60 s |
| Free account quota | 5 GPU-minutes/day |
| Quota window | 24 h from first use, not calendar day |
torch.compile |
not supported (use AoTI, torch 2.8+) |
Finding C-8 records the operational consequence the plan omitted:
"the plan never states the operational consequence: at 5 min/day, a single VQA call declaring the 60 s default consumes 1/5 of a user's entire daily budget." (
docs/PHASE0_CONTRACT_VALIDATION.md§5.2)
The resolution is that "every GPU-decorated function declares an explicit, realistic duration" —
hence the four frozen values. The registry states the same rationale inline: "ZeroGPU free tier = 5
GPU-min/day. Declared duration reserves quota up front." (configs/base.yaml:288).
28. The change_vqa detail — a duration that has no key of its own
app/space_app.py transcribes the durations into a Python dict:
#: ZeroGPU duration per task, from `configs/deploy.yaml` -- the FROZEN values,
#: not new guesses. `change_vqa` has no key of its own and reuses `change`,
#: because adding a key would move `Config.hash` off `78f1e3700da15aa1`
#: (`docs/DEPLOYMENT_ARCHITECTURE.md` section 3.4).
GPU_DURATIONS: dict[str, int] = {
"vqa": 20,
"caption": 20,
"grounding": 45,
"change": 30,
"optical_sar": 45,
"change_vqa": 30,
}
(app/space_app.py:105-116)
Note the shape: six tasks, four gpu_duration_* keys. vqa and caption share the vqa budget;
change_vqa shares the change budget. And change_vqa is the interesting one, because the reason
is the hash:
"
change_vqahas nogpu_duration_change_vqakey, and adding one would moveConfig.hash. Repurposing thechangebudget is the choice that avoids that, and it is recorded here as a decision rather than presented as a plan fact." (docs/DEPLOYMENT_ARCHITECTURE.md§3.4)
The justification for the repurposing itself is also recorded: change_vqa "follows change — it
shares the STANet detector." So the shared budget is not arbitrary; it reflects that the two tasks
run the same detector. The config-freeze reasoning and the engineering reasoning agree here, which is
why this is a good example rather than a tension.
decorate_gpu refuses an undeclared task rather than guessing a duration:
if task not in GPU_DURATIONS:
raise KeyError(
f"no gpu_duration_* is declared for {task!r}; add it to "
f"configs/deploy.yaml (which moves Config.hash) or map it to an "
f"existing task. Do not guess a duration."
)
(app/space_app.py:152-157)
The error message names both options and the cost of the first — a small, self-documenting instance of the chapter's central rule.
29. The ZeroGPU decoration has never executed
The honest status of the ZeroGPU path is stated in the entrypoint's own docstring:
"
spaces(the ZeroGPU decorator package) is not installed in this environment … The decoration is therefore applied conditionally … The consequence is recorded indocs/PHASE19_FINAL_HARDENING.md: the ZeroGPU decoration has never executed here. It is specified from finding C-8 and the frozengpu_duration_*values, and that is all it is." (app/space_app.py:31-42)
This is IMPLEMENTED (not run): the code exists, the durations are frozen and transcribed, and the
decoration has never been exercised. docs/architecture/02-deployment-topology.md §15 lists
"ZeroGPU decoration execution | NOT RUN" in the same terms. The conditional application is itself
correct rather than a workaround, because configs/deploy.yaml sets cpu_mode_required: true — a CPU
run must work, and it does, via the identity decorator.
Part F — The frozen values a reader might want to change
This is the table the chapter exists to provide. Every row is a value that a reader, seeing it in
configs/base.yaml, might reasonably want to change — and the reason it is frozen.
30. Frozen values, and why
| Key | Frozen value | Why it is frozen | Enforced by |
|---|---|---|---|
croma.image_resolution |
120 |
CROMA asserts % 8 == 0; native resolution → 225 patches. A non-multiple of 8 violates an assertion inside the vendored model. |
C-7 guard (core/config.py:97-105) |
croma.optical_channels |
12 |
CROMA's s2_channels is fixed at 12; the patch embedding is a fixed-shape Linear. |
channel guard (core/config.py:164-166) |
croma.sar_channels |
2 |
CROMA's s1_channels is fixed at 2 (VV, VH). |
channel guard (core/config.py:167-168) |
fusion.input_dim |
2318 |
Must equal 3·768 + 12 + 2; a mismatch is a Linear shape error after CROMA has run. |
C-1 guard (core/config.py:152-162) |
training.precision |
fp16 |
The target accelerator (T4, SM 7.5) has no bf16 tensor cores. | C-6 guard (core/config.py:107-112) |
vlm.processor_longest_edge |
512 |
The processor default (2048) upscales and splits every tile into 17 sub-images and 1142 tokens (measured). | F5-2 guard (core/config.py:121-142) |
vlm.prompt_must_use_chat_template |
true |
SmolVLM raises ValueError without one <image> token per image. |
F5-3 guard (core/config.py:144-150) |
grounding.encoder_projected_dim |
512 |
RemoteCLIP's visual.proj is (768, 512); the comparison space is the projected dim, not the transformer width. |
P7-1 guard (core/config.py:183-196) + remoteclip.py load-time assert |
grounding_head.feature_dim |
2048 |
4 · 512; a mismatch is a silent shape error at the similarity step, after patch features are cached. |
P7-1 guard (core/config.py:190-196) |
deployment.torch_compile |
false |
ZeroGPU does not support torch.compile (use AoTI, torch 2.8+). |
C-8 guard (core/config.py:114-119); deploy validator |
deployment.cpu_mode_required |
true |
CPU must work; ZeroGPU is an accelerator, not a dependency. | deploy validator (REQUIRED_BOOL) |
grounding.image_size |
224 |
RESOLVED by measurement: 448 lost on mean best IoU (−0.0147), every recall threshold, and latency (1.59×) over 16,159 VRSBench records. | test + resolution_frozen: true |
grounding.resolution_frozen |
true |
Asserts the resolution question is decided; a stale "experiment" flag would invite a re-run. | test |
change.sa_mode |
PAM |
Upstream STANet's two verified modes are BAM and PAM; an unrecognised mode selects neither. |
change guard (core/config.py:211-212) |
change.tile_size |
256 |
STANet's verified convention: 256×256 non-overlapping patches. | test |
change.bce_weight + change.dice_weight |
0.5 + 0.5 |
STANet's verified loss is 0.5·BCE + 0.5·Dice. |
test (sums to 1.0) |
image.tile_size |
512 |
The tile size the VLM processor pin is tied to, and the unit of the tiling policy. | referenced by F5-2 guard |
image.top_k_tiles |
4 |
Plan §9.1 tile policy: whole-image thumbnail first, then top-K tiles. | tiling guard (≤ max_tiles) |
router.tasks (incl. unsupported) |
6 classes | unsupported is the router's "not a satellite question" outcome; removing it forces every query into a specialist. |
ontology guard (core/config.py:198-206) |
router.num_tasks |
6 |
The classifier's output width; must equal the ontology length. | ontology guard (core/config.py:202-206) |
evaluation.official_aggregate_weights |
null |
The uploaded specification prohibits inventing an official aggregate formula. | test |
evaluation.hidden_data_access |
false |
Evaluation isolation. | test |
evaluation.leakage_split_key |
scene_id |
Scene-level leakage isolation is a non-negotiable. | test |
project.seed |
42 |
Reproducibility. | Config.seed |
deployment.gpu_duration_* |
20/45/30/45 |
Derived from the verified 5 GPU-min/day ZeroGPU budget. | deploy validator (positive int) |
31. The hash itself
| Item | Frozen value | Why |
|---|---|---|
Config.hash |
78f1e3700da15aa1 |
Recorded in artifacts/change/levir_change_v001/model_metadata.json; scripts/eval_change.py exits 3 on drift. |
configs/base.yaml file sha256 (first 16) |
88434f7f8f78e2b8 |
A frozen Phase-9 artifact (docs/PHASE9_FREEZE.md §2); 10,637 bytes, mtime 2026-09-16 19:30. |
Part G — What is NOT frozen: open for tuning
The freeze is a claim about which values are decided, not a claim that nothing may ever change. The master plan draws the line explicitly, in two adjacent sections, and the repository's own structure follows it.
32. The plan's own split
The plan separates immutable decisions from tunables in §67, §68 and §69.
32.1 §67 — Immutable decisions
The implementation model must not redesign these (plan §67):
[ ] modular-monolith architecture
[ ] tiny NLP intent router
[ ] deterministic policy engine
[ ] common specialist interface
[ ] SmolVLM VLM layer
[ ] RemoteCLIP grounding path
[ ] STANet-style change path
[ ] CROMA optical-SAR path
[ ] shared evidence engine
[ ] calibrated confidence
[ ] common result schema
[ ] execution trace
[ ] leakage isolation
These are structural. Note that the list is about components and interfaces, not numbers. The config registry is the mechanism by which the numbers those components depend on are pinned.
32.2 §68 — Variables open for tuning
The plan's tunable list (plan §68):
LoRA rank
VLM learning rate
router adapter dimension
router confidence threshold
grounding head architecture
grounding learning rate
change threshold
change loss weighting
CROMA fusion head width
tile size
tile overlap
top-K tile count
confidence calibration temperature
32.3 §69 — Variables requiring experimental optimization
The plan gives explicit ranges for the tunables it expects to be searched (plan §69):
| Component | Variable | Range |
|---|---|---|
| Router | hidden dimension | 64–256 |
| Router | dropout | 0–0.3 |
| Router | confidence | 0.60–0.90 |
| Grounding | head width | 256–1024 |
| Grounding | learning rate | 5e-5–2e-4 |
| Grounding | NMS | 0.4–0.6 |
| Change | threshold | 0.30–0.70 |
| Change | minimum component | 16–128 px |
| CROMA | fusion width | 256–1024 |
| CROMA | dropout | 0–0.3 |
The plan's selection rule is one line: "Selection: validation only."
32.4 Where the registry's values sit against the plan's ranges
The registry's tunables fall inside (or beside) the plan's ranges. Cross-referencing them is instructive, because it shows the registry is the plan's tunables, realised:
| Plan variable | Registry key | Registry value | In plan range? |
|---|---|---|---|
| Router hidden dimension | router.hidden_dim |
128 |
✅ within 64–256 |
| Router dropout | router.dropout |
0.10 |
✅ within 0–0.3 |
| Router confidence | router.confidence_threshold |
0.70 |
✅ within 0.60–0.90 |
| Grounding head width | grounding_head.hidden_dim |
512 |
✅ within 256–1024 |
| Grounding learning rate | grounding_training.learning_rate |
0.0001 (1e-4) |
✅ within 5e-5–2e-4 |
| Grounding NMS | grounding.nms_iou |
0.50 |
✅ within 0.4–0.6 |
| Change threshold | change.threshold |
0.50 |
✅ within 0.30–0.70 |
| Change min component | change.min_component_pixels |
32 |
✅ within 16–128 px |
| CROMA fusion width | fusion.hidden_dim |
512 |
✅ within 256–1024 |
| CROMA dropout | fusion.dropout |
0.2 |
✅ within 0–0.3 |
| LoRA rank | training.lora_rank |
16 |
(no range given) |
| VLM learning rate | training.vlm_learning_rate |
0.0002 |
(no range given) |
| Tile size | image.tile_size |
512 |
(no range given) |
| Tile overlap | image.tile_overlap |
128 |
(no range given) |
| Top-K tile count | image.top_k_tiles |
4 |
(no range given) |
| Calibration temperature | — | UNKNOWN — not established from the available evidence |
the artifact is calibration_v001.json; no temperature is declared in base.yaml |
Two honest notes on that table:
- The plan's tunables are the registry's values. Every range-bearing tunable the plan names has a registry key with a value inside the stated range. This is the plan's intent realised, not a coincidence — the registry was written from the freeze, which was written from the plan.
- The calibration temperature is not in the registry.
confidence.temperature_scaling: trueandconfidence.calibration_file: calibration_v001.jsonare the registry's confidence keys (configs/base.yaml:231-233); the fitted temperature itself lives in the artifact file. Whether that temperature is a "tunable" in the plan's §68 sense isUNKNOWN — not established from the available evidence; the artifact is covered indocs/architecture/06-evidence-and-confidence.md§8–§10, including the measured result that calibration made ECE worse (0.013755 → 0.014929) and is retained only because it is in the frozen config.
33. The real tension: tunable ≠ free
Here is the part a reader must not miss. A value can be on the plan's tunable list and still be frozen in practice.
The plan's §68 list says change threshold is open for tuning. The registry has
change.threshold: 0.50. But docs/PHASE9_FREEZE.md §5 records that the threshold lever is
CLOSED:
*"The val-only sweep scored 19 thresholds. Pooled IoU peaks at 0.35–0.40 (0.8239) against 0.8232 at 0.50 — a gain of +0.0007. … the last five validation epochs span 0.8213–0.8232, a spread of 0.0019, so the entire available threshold gain is 0.37× the epoch-to-epoch noise. A gain smaller than the run's own variance is not a finding. 0.50 is retained. This hypothesis is eliminated, not deferred."*
So there are three states a value can be in, not two:
| State | Meaning | Example |
|---|---|---|
| Structurally frozen | a guard rejects any other value | fusion.input_dim, training.precision |
| Measured-frozen | a value was chosen by measurement and the question is closed | grounding.image_size (224), change.threshold (0.50) |
| Open for tuning | genuinely searchable, with a validation-only selection rule | the plan's §69 ranges |
And a fourth, operational state that overrides all three:
| State | Meaning | Example |
|---|---|---|
| Hash-pinned | editing it moves Config.hash and detaches a published number |
any key in configs/base.yaml |
The last row is the reason this chapter exists. A tunable that is "open" is still an edit to
configs/base.yaml, and an edit to configs/base.yaml still moves the hash. The supported way to
change a value without detaching a benchmark is not to edit the registry — it is to pass
overrides to load_config, or to use a call-site mechanism like the registry's builders=
override, and to accept that the resulting run has a different hash and is therefore a different
measurement.
docs/OWNER_DECISIONS_2026-09-23.md states this as a cross-cutting rule:
"Frozen artefacts stay frozen.
Config.hash = 78f1e3700da15aa1is verified untouched. New defaults live in code, not in the config registry."
34. The override surfaces that DO exist
If the registry is frozen, what is not? Three legitimate surfaces:
| Surface | Mechanism | Effect on the hash | Example |
|---|---|---|---|
| Programmatic overrides | load_config(overrides={...}) |
moves it — a different registry, honestly different | load_config(overrides={"fusion": {"hidden_dim": 1024}}) |
| Environment overrides | SATQUERY_PRECISION, SATQUERY_TORCH_COMPILE |
moves it — they write into data before hashing |
SATQUERY_PRECISION=fp32 |
| Call-site injection | the registry's builders= override |
leaves it bit-identical | build_serving_registry() wiring the change head |
The first two are config changes and the hash moves with them — which is correct, because the run
really is different. The third is the one that changes behaviour without changing the
configuration, and it is the mechanism the project uses when a trained artifact needs to be wired
in without detaching the benchmark (docs/PHASE9_FREEZE.md §6;
docs/DEPLOYMENT_ARCHITECTURE.md §3.3 requirement 2).
Environment variables outside the loader are a fourth, weaker surface: SATQUERY_DEVICE (Part
B.6) changes the device without touching the registry, and the asset-store variables (Part A.2.2)
change deployment sizing. These are not config-registry values and therefore cannot move the hash —
they are the "deployment state" half of the Part A.2.2 rule of thumb.
Part H — Honest boundaries
35. What is NOT RUN / OPEN / UNKNOWN for this topic
| Item | Status | Note |
|---|---|---|
Config.hash frozen value 78f1e3700da15aa1 |
VERIFIED |
re-run by execution while writing this chapter; matches tests/test_config.py:43 |
Every _validate guard firing on a bad value |
VERIFIED |
each guard has a pytest.raises test in tests/test_config.py |
configs/deploy.yaml left undisturbed |
VERIFIED |
registry: false; validator enforces key-for-key equality |
| ZeroGPU decoration execution | NOT RUN |
"the ZeroGPU decoration has never executed here" (app/space_app.py) |
The 5 GPU-min/day quota against a live Space |
NOT RUN |
no live Space; the budget is a verified platform constraint, not a measured consumption |
SATQUERY_TORCH_COMPILE=true observed at runtime |
NOT RUN |
the guard is tested in-process; no deployment was run with the variable set |
SATQUERY_PRECISION used to switch precision in a real run |
UNKNOWN — not established from the available evidence |
the override is implemented; no run record was found that exercised it |
| The calibration temperature as a tunable | UNKNOWN — not established from the available evidence |
the value lives in the artifact, not the registry |
Whether change.checkpoint_path ever appeared in base.yaml |
absent | verified by reading all 294 lines; the registry declares it optional and it resolves to None |
A live config with image.top_k_tiles as the binding constraint |
UNKNOWN — not established from the available evidence |
the guard is tested; no run record was found |
Config.hash computed on a registry other than configs/base.yaml in production |
UNKNOWN — not established from the available evidence |
get_config() reads the default path; no production override was found |
The Config docstring's "immutable" claim as a hard guarantee |
IMPLEMENTED |
no mutating API exists; _data is reachable via __getitem__, and no code path mutates it |
| The CROMA mask contradiction (C-1) as resolved in code | RESOLVED (design) |
the mask routes to the fusion head; the fusion dimension is guarded. Whether the fusion head has been trained is a Phase-12 question (docs/OWNER_DECISIONS_2026-09-23.md D-1) |
deployment.platform: huggingface-spaces describing the live deployment |
superseded | the active topology is Render + Codespace + Cloudflare Pages (docs/DEPLOYMENT_TOPOLOGY.md); the manifest is frozen paperwork |
36. Where the evidence lives
| Claim | Source |
|---|---|
| The loader, all guards, the hash, the access methods | core/config.py (275 lines; guards at :94-222) |
| The registry and every value | configs/base.yaml (294 lines) |
_deep_merge semantics |
core/config.py:33-40 |
Env overrides SATQUERY_PRECISION / SATQUERY_TORCH_COMPILE |
core/config.py:261-265 |
get_config() singleton |
core/config.py:270-273 |
device_preference + SATQUERY_DEVICE |
core/config.py:86-91, :237-243 |
ConfigError taxonomy |
core/config.py:28-30 |
C-7 % 8 rule |
core/config.py:97-105; docs/PHASE0_CONTRACT_VALIDATION.md §1.1 |
| C-6 precision / T4 SM 7.5 | core/config.py:107-112; docs/PHASE0_CONTRACT_VALIDATION.md §7; configs/base.yaml:254-257 |
C-8 torch.compile forbidden |
core/config.py:114-119; docs/PHASE0_CONTRACT_VALIDATION.md §5.1 |
| F5-2 / C-3 processor upscaling, the 17 sub-images and 1142 tokens | core/config.py:121-142; docs/PHASE5_VLM_CONTRACT.md §headline finding |
| F5-3 chat template | core/config.py:144-150; docs/PHASE5_VLM_CONTRACT.md §F5-3 |
| C-1 fusion dim 2318, mask to the fusion head | core/config.py:152-162, :224-231; docs/PHASE0_CONTRACT_VALIDATION.md §1.2–§1.3; docs/ARCHITECTURE_FREEZE.md §2.5 |
| CROMA fixed channel counts 12 / 2 | core/config.py:164-168; docs/PHASE0_CONTRACT_VALIDATION.md §1.1 |
| P7-1 feature dim 2048, the silence argument | core/config.py:170-196; specialists/grounding/remoteclip.py:15-19, :38-42, :195-201 |
| Router ontology guards | core/config.py:198-206; docs/ARCHITECTURE_FREEZE.md §2.1 |
Change encoder / sa_mode guards |
core/config.py:208-212; docs/PHASE0_CONTRACT_VALIDATION.md §4.1 |
| Tiling guard | core/config.py:214-216; configs/base.yaml:16-24 |
The frozen hash 78f1e3700da15aa1 |
tests/test_config.py:43; docs/PHASE9_FREEZE.md §3; docs/OWNER_DECISIONS_2026-09-23.md; docs/DEPLOYMENT_ARCHITECTURE.md §3.3 |
| The five drifted-hash renderings | tests/test_config.py:59-68 |
The configs/base.yaml file sha256 88434f7f8f78e2b8 |
docs/PHASE9_FREEZE.md §2 |
configs/deploy.yaml is not registry material |
configs/deploy.yaml:1-12; scripts/validate_deploy_config.py |
| Why the manifest is frozen paperwork | docs/DEPLOYMENT_TOPOLOGY.md §3.4; docs/DEPLOYMENT_DECISION.md §4 |
ZeroGPU duration mapping + change_vqa reuses change |
docs/DEPLOYMENT_ARCHITECTURE.md §3.4; app/space_app.py:105-116, :144-165 |
ZeroGPU verified constraints (5 GPU-min/day, Gradio-only, no torch.compile) |
docs/PHASE0_CONTRACT_VALIDATION.md §5.1–§5.2 |
| New defaults live in code, not the registry (D-4) | docs/OWNER_DECISIONS_2026-09-23.md D-4 and cross-cutting rule 4 |
| The threshold lever is closed | docs/PHASE9_FREEZE.md §5 |
| The plan's immutable / tunable / experimental split | plan §67, §68, §69 |
| The guard tests | tests/test_config.py (348 lines) |
| The deploy validator's required values | scripts/validate_deploy_config.py (REQUIRED_BOOL, REQUIRED_INT, REQUIRED_STR) |
Next: 08 API contract — the four endpoints, the ResultEnvelope and error
envelopes, the error codes, and the transport headers.