SatQuery / docs /architecture /07-configuration-freeze.md
thundercode's picture
release: add docs/architecture/07-configuration-freeze.md
b86eb40 verified
|
Raw
History Blame Contribute Delete
106 kB

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.yaml is 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.yaml on purpose: adding a key there moves Config.hash off 78f1e3700da15aa1 and invalidates the frozen Phase-9 benchmark. Sizing is deployment state." (app/space_app.py, _asset_max_files docstring)

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:

  1. Environment overrides win over overrides. Stage 5 runs after stage 4, and it assigns directly into data. A caller that passes overrides={"training": {"precision": "bf16"}} while SATQUERY_PRECISION=fp16 is set gets fp16. The environment is the outermost layer.
  2. Environment overrides still go through validation. They are written into data before Config(...) is constructed, so SATQUERY_TORCH_COMPILE=true does not quietly enable torch.compile — it sets deployment.torch_compile = True, which the C-8 guard then rejects with a ConfigError. Likewise SATQUERY_PRECISION=tf32 is 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 — override can add blocks the registry lacks. This is exercised by test_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 only fusion.hidden_dim, leaving fusion.input_dim, fusion.dropout and fusion.num_classes intact. This is the whole point of a deep merge.
  • A non-mapping value replaces — a scalar, list, None or a mapping-over-scalar all overwrite. There is no list concatenation and no list merging; a list override is a replacement.
  • base is not mutated — out = dict(base) copies the top level, and recursive calls copy each nested level they descend into. The caller's data dict from yaml.safe_load is 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, so SATQUERY_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 not true (case-insensitively) becomes False, which is the C-8-safe direction — an unparsable value cannot enable torch.compile.
  • setdefault makes the write safe on a partial registry. data.setdefault("training", {}) only creates the block if it is absent, so an override that already provided training is not clobbered at the block level (only the precision leaf 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):

  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.
  2. The hash is computed on demand but the data is fixed. hash is 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.
  3. maxsize=1 with no arguments means exactly one cached entry. Because get_config() takes no arguments, the cache can never hold more than one Config, 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") returns None, because project.name is a string and isinstance(node, dict) fails on the next iteration.
  • default defaults to None, so get cannot distinguish "key absent" from "key present and None" by itself. That ambiguity is what require exists 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:

  1. Environment wins. If SATQUERY_DEVICE is 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.
  2. Otherwise it probes torch, and the probe is defensive. _torch_cuda_available() wraps the import and the call in a single try/except Exception and returns False on 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 raising ModuleNotFoundError. The comment # noqa: PLC0415 records that the local import is intentional: it keeps import core.config cheap.
  3. It is not part of the hash. device_preference is a computed property over os.environ and a runtime probe. It does not read or write self._data, so it cannot move Config.hash. This is why the device can differ per host without invalidating a single published number.
  4. It is deliberately not read on the metadata path. docs/DEPLOYMENT_ARCHITECTURE.md §3.3 records that build_serving_registry() "via Config.device_preference, a @property that calls _torch_cuda_available()" was one of two things that did import torch on the health path, and that both were removed. The test test_the_metadata_path_does_not_import_torch runs the import in a subprocess and asserts torch 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_embedding is 768 wide and visual.proj is (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:

  1. The head is constructed with feature_dim = 3072 (say).
  2. The encoder runs and produces patch features projected to 512.
  3. 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 Linear whose in_features was set from feature_dim.
  4. 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_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."

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:

  1. Fix-once feedback. An operator editing a config sees every violation in one run rather than discovering them one per attempt.
  2. 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:

  1. It hashes the whole registry, not a subset. Every block, every key, every value. sort_keys=True means 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.
  2. default=str makes the hash total. A value the JSON encoder cannot handle is stringified rather than raising, so hashing never fails on an exotic type.
  3. 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.yaml to point at the checkpoint moves the config hash away from 78f1e3700da15aa1 — which trips the drift guard and makes scripts/eval_change.py exit 3. 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 = 78f1e3700da15aa1 is 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 of configs/base.yaml lines 282-295. It is kept identical on purpose so the two cannot silently drift; scripts/validate_deploy_config.py enforces 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.yaml still describes an HF Space + Gradio + ZeroGPU target. That manifest is frozen paperwork — no Gradio runtime exists in code, and editing it would move Config.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 → false in deploy.yaml alone fails the validator, and moving base.yaml to 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 into base.yaml would appear on both sides and compare equal. The real guarantee against a merge is the Config.hash regression 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, no gr.Blocks, no gr.Interface and no Gradio entrypoint exists anywhere. Gradio appears only as requirements.txt:36 and the manifest value sdk: 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_vqa has no gpu_duration_change_vqa key, and adding one would move Config.hash. Repurposing the change budget 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 in docs/PHASE19_FINAL_HARDENING.md: the ZeroGPU decoration has never executed here. It is specified from finding C-8 and the frozen gpu_duration_* values, and that is all it is." (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:

  1. 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.
  2. The calibration temperature is not in the registry. confidence.temperature_scaling: true and confidence.calibration_file: calibration_v001.json are 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 is UNKNOWN — not established from the available evidence; the artifact is covered in docs/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 = 78f1e3700da15aa1 is 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.