SatQuery / docs /TRAINING.md
thundercode's picture
release: add docs/TRAINING.md
be08c80 verified
|
Raw
History Blame Contribute Delete
90.7 kB
# Training — deep reference
**Status tags used on every substantive claim:** `IMPLEMENTED` · `VERIFIED` · `MEASURED` · `ATTEMPTED`
· `NOT RUN` · `BLOCKED` · `DEFERRED` · `REJECTED` · `OPEN` · `RESOLVED` · `CLOSED`.
SatQuery AI trains **six** artifacts. Four are task heads, one is an intent-router adapter, one is a
PEFT LoRA adapter. **Every backbone is frozen.** No backbone is fine-tuned end-to-end; no backbone
weight is redistributed. Training produces a small module on top of a frozen encoder, and — for five
of the six artifacts — it does so by training on **cached embeddings or cached features**, never on raw
imagery decoded inside the training loop.
> **The single most important rule in this document: do not fabricate.** Every hyperparameter and every
> measured figure below was read from a file. Where a figure is a *quoted* value, the file it came from
> is named inline. Where a fact is **not** established from the available evidence, this document says
> so explicitly (`UNKNOWN — not established from the available evidence`) rather than estimating.
**The second most important rule:** a training run produces an **artifact**, not a **verified
capability**. The external training guide for the change-VQA head states this as a contract, verbatim:
> **Training produces an artifact, not a verified capability, and the run record says
> `TRAINED_UNVERIFIED`.** (`docs/R02_KAGGLE_TRAINING_GUIDE.md`)
Every artifact in this document carries a status. None of them is promoted to a system-level claim by
the fact that it trained. See [`BENCHMARKS.md`](BENCHMARKS.md) for the evaluation-side view and
[`MODELS.md`](MODELS.md) for the released-artifact view.
---
## Table of contents
1. [The training philosophy](#1-the-training-philosophy)
2. [Where each artifact trains — the reproducibility boundary](#2-where-each-artifact-trains--the-reproducibility-boundary)
3. [The frozen training configuration](#3-the-frozen-training-configuration)
4. [Router adapter — local CPU, cached embeddings](#4-router-adapter--local-cpu-cached-embeddings)
5. [Grounding head — local CPU, cached features](#5-grounding-head--local-cpu-cached-features)
6. [Change head — external GPU, STANet-style Siamese](#6-change-head--external-gpu-stanet-style-siamese)
7. [Optical-SAR fusion head — local CPU, seed sweep](#7-optical-sar-fusion-head--local-cpu-seed-sweep)
8. [Change-VQA head — external GPU, cached change features](#8-change-vqa-head--external-gpu-cached-change-features)
9. [VLM LoRA adapter — external GPU, PEFT](#9-vlm-lora-adapter--external-gpu-peft)
10. [Calibration — fitted, not trained](#10-calibration--fitted-not-trained)
11. [The reproducibility contract for training](#11-the-reproducibility-contract-for-training)
12. [What was NOT trained — exhaustive](#12-what-was-not-trained--exhaustive)
13. [What is NOT RUN / OPEN / BLOCKED for this topic](#13-what-is-not-run--open--blocked-for-this-topic)
14. [Where the evidence lives](#14-where-the-evidence-lives)
---
## 1. The training philosophy
### 1.1 Frozen backbone + small trainable head
The project's core architectural decision is that **the backbone is never updated**. Every artifact in
this document is a small module attached to an encoder that is loaded from a pinned revision and then
frozen. The frozen contracts are enumerated in `docs/ARCHITECTURE_FREEZE.md` §2:
| Component | Frozen backbone | What is trainable | Freeze reference |
|---|---|---|---|
| Intent router | `sentence-transformers/all-MiniLM-L6-v2` | the adapter only | `docs/ARCHITECTURE_FREEZE.md` §2.1 |
| VLM | `HuggingFaceTB/SmolVLM-500M-Instruct` | a LoRA delta on the language model | `docs/ARCHITECTURE_FREEZE.md` §2.2 |
| Grounding | `chendelong/RemoteCLIP` (`ViT-B-32`) | projection + grounding head | `docs/ARCHITECTURE_FREEZE.md` §2.3 |
| Change | — (reimplemented, not vendored) | the whole Siamese network (see §6) | `docs/ARCHITECTURE_FREEZE.md` §2.4 |
| Optical-SAR | `antofuller/CROMA` (`CROMA_base.pt`) | the fusion head only | `docs/ARCHITECTURE_FREEZE.md` §2.5 |
| Change-VQA | frozen STANet change detector | a two-stage 1.45 M-parameter head | `docs/R02_CHANGE_VQA_IMPLEMENTATION_STATUS.md` §2 |
The **one exception** is the change head. `artifacts/change/levir_change_v001/model_metadata.json` and
the checkpoint's own embedded config record `frozen_encoder: false` and `pretrained_used: true`
(`artifacts/change/eval_test/eval_result.json` → `checkpoint_embedded_config`). The STANet detector's
ResNet-18 encoder is initialised from torchvision `IMAGENET1K_V1` and is **trained**, not frozen — which
is why its checkpoint is 63 MB while the other heads are 5–14 MB. This is a deliberate, recorded
exception, not an inconsistency: the change detector *is* the trained artifact, whereas the other five
artifacts sit on top of a backbone someone else trained.
### 1.2 Cached-embedding training — the cost-reduction strategy
Because the backbone is frozen, its output for a fixed input never changes. The project therefore
computes the backbone output **once**, writes it to a cache, and trains the head on the cache. This
turns a GPU-bound problem into a CPU-bound one and is the reason four of the six artifacts train on a
laptop:
| Artifact | What is cached | Cache artifact |
|---|---|---|
| Router | MiniLM sentence embeddings (384-d) | `artifacts/router/cache/` |
| Grounding | RemoteCLIP patch + text features | `artifacts/grounding/remoteclip_grounding_v001/cache/remoteclip_224_v1` |
| Optical-SAR | CROMA fused features (2318-d) | `artifacts/optical_sar/fusion_features/{train,val,test}.npz` |
| Change-VQA | change features (1045-d) + MiniLM question features (384-d) | `.../prepared/change_features.npz`, `.../prepared/text_features_<split>.npz` |
| VLM | **not cached** — the VLM LoRA adapter trains on rendered images | — |
The router is the clearest demonstration. `router/adapter.py` records, verbatim in its module docstring:
> Measured cost (Phase 4 probe): 50,822 parameters, 20 epochs over 4,096 x 384
i.e. **20 epochs over 4,096 cached 384-dimensional vectors**, on CPU. The measured wall-clock for that
probe is **0.28 s** (`router/adapter.py`). No GPU is required for any of the four CPU-trained
artifacts.
### 1.3 The frozen configuration hash
Every trained artifact records the frozen config hash it was trained against. The hash is computed over
the **parsed configuration object** — not over the file bytes — by `core/config.py:77-80`:
```
78f1e3700da15aa1
```
This value appears in `artifacts/change/levir_change_v001/model_metadata.json` →
`config_hash`, `artifacts/change_vqa/run/run_record.json` → `config_hash`,
`artifacts/optical_sar/fusion_head_v001/armA_seed100/run_record.json` → `config_hash`,
`artifacts/grounding/remoteclip_grounding_v001/run_record.json` → `config_hash`, and
`models/manifest.json` → `artifacts[*].config_hash`. It is a frozen invariant in
`docs/PHASE9_FREEZE.md` §3 and is enforced by
`tests/test_config.py::test_config_hash_matches_the_shipped_checkpoint_record`.
> **Editing `configs/base.yaml` moves the hash and detaches every artifact from it.** This is stated
> as a hazard, not a nicety, in `docs/PHASE9_FREEZE.md` §6: a config edit makes
> `scripts/eval_change.py` exit `3`. The change head's `head.pt` is *not* reachable through
> `change.checkpoint_path` (which resolves to `None`) precisely so that the benchmark number stays
> attached to an unmodified config; the wiring is done through a registry `builders=` override instead.
**A second hash exists and must not be confused with the first.** The router adapter records its *own*
adapter-config hash, `615478910dc266bf`, in `artifacts/router/router_adapter_v001/metadata.json` →
`config_hash` and `artifacts/router/threshold_sweep_val.json` → `adapter_config_hash`. That hashes the
adapter's config block, not the repo-wide config. `models/manifest.json` attaches the repo-wide
`78f1e3700da15aa1` to the released router artifact. Both are recorded; they hash different objects.
### 1.4 The hash-exempt environment channels
Some training runs need to vary a knob without editing `configs/base.yaml` — because editing the config
would move the hash. The project solves this with **environment channels that are deliberately excluded
from the hash**:
| Channel | Effect | Used by |
|---|---|---|
| `SATQUERY_CROMA_USE_8_BIT` | selects the optical-SAR preprocessing arm (`use_8_bit` true/false) | the optical-SAR seed sweep (§7) |
| `SATQUERY_DEVICE` | overrides device selection | training scripts generally |
`docs/PHASE14_GATE_F_DECISION_RECORD_2026-09-20.md` §3.1 records that the arm "travels on the
hash-exempt env channel, never through `configs/base.yaml`", which is why all ten optical-SAR run
records carry `config_hash: 78f1e3700da15aa1` (§7.7 of that document). `docs/REPRODUCIBILITY.md` §2.6
documents the same mechanism from the reproduction side.
### 1.5 No magic numbers in Python
Every hyperparameter lives in `configs/base.yaml`. Training modules read them from the config object;
they do not carry their own defaults for anything that affects a result. Where a module *does* carry a
constant (e.g. the change-VQA trainer's `SEED = 42`, `DEFAULT_EPOCHS = 40`,
`DEFAULT_TIME_LIMIT_SECONDS = 3*3600` in `training/change_vqa/train.py`), it is a CLI default that the
external run overrides explicitly and that is recorded in the run's `argv`.
### 1.6 What training never does
The frozen non-negotiables (`docs/ARCHITECTURE_FREEZE.md` §5) bound what training may do:
- **No LLM-generated coordinates.** The VLM is never used to produce coordinates.
- **No LLM-generated confidence.** Confidence comes from the evidence engine, not a language model.
- **No hidden-test-specific branches, thresholds, or prompts.** Public test is immutable; hidden data
must never influence a threshold, a prompt or a routing decision.
- **Scene-level leakage isolation.** Splits are by scene (or group), never by sample.
- **Prompts are versioned files, frozen before benchmark evaluation.**
- **Every result carries an observable execution trace. No chain-of-thought.**
These are enforced in code, not by convention — see
[`DATASETS.md`](DATASETS.md) §7 for the leakage module and the public-test firewall.
---
## 2. Where each artifact trains — the reproducibility boundary
The single most consequential fact about this project's training story is that **two of the six
artifacts were trained outside this repository**, on an external GPU, and the repository ships the
*contract and the gate* for those runs rather than a one-command retrain.
`docs/REPRODUCIBILITY.md` §8 states this plainly.
| # | Artifact | Where it trains | Hardware | Guide / record |
|---|---|---|---|---|
| 1 | `router/adapter.pt` | **local CPU** | CPU only | `configs/base.yaml` §`router.training` |
| 2 | `grounding/head.pt` | **local CPU** | CPU (`torch 2.14.0+cpu`) | `scripts/train_grounding.py` |
| 3 | `change/head.pt` | **external GPU (Kaggle)** | Tesla T4 | `docs/PHASE9_GPU_HANDOFF.md` |
| 4 | `optical_sar/head.pt` | **local CPU** (seed sweep) | CPU (`torch 2.14.0+cpu`) | `docs/PHASE14_OPTICAL_SAR_DECISIONS.md` |
| 5 | `change_vqa/head.pt` | **external GPU (Kaggle)** | **T4 ×2** | `docs/R02_KAGGLE_TRAINING_GUIDE.md` |
| 6 | `vlm/adapter_model.safetensors` | **external GPU (Kaggle)** | **T4 ×2** | `RUNBOOK_PHASE6_VLM_KAGGLE.md` |
> **The change head is trained on an external GPU, not locally.** Its `run_record.json` records
> `artifact_dir: /kaggle/working/satquery-ai/artifacts/change/levir_change_v001` and
> `data_root: /kaggle/input/datasets/keykeylv/levir-cd-256`
> (`artifacts/change/levir_change_v001/run_record.json`). The earlier `docs/PHASE9_GPU_HANDOFF.md`
> describes the handoff; `docs/PHASE9_GPU_RUN_RESULTS.md` reports the returned run.
### 2.1 Local CPU vs external GPU — what "local" means
"Local" in this document means the development machine recorded in the run records, e.g.
`artifacts/optical_sar/fusion_head_v001/armA_seed100/run_record.json` → `environment`:
`{"cuda_available": false, "platform": "Windows-10-10.0.26200-SP0", "python": "3.11.16",
"torch": "2.14.0+cpu"}`. The grounding run record records the same
(`artifacts/grounding/remoteclip_grounding_v001/run_record.json` → `environment`), as does the router
threshold sweep (`artifacts/router/threshold_sweep_val.json` → `environment`).
The four locally-trained artifacts therefore reproduce on a machine with **no CUDA device at all**.
This is a direct consequence of the cached-feature strategy in §1.2.
### 2.2 The mixed-precision (AMP) policy
The precision policy is frozen and has a hardware reason.
| Situation | Precision used | Reason |
|---|---|---|
| CUDA device present | **fp16** with `GradScaler` | `docs/ARCHITECTURE_FREEZE.md` §4: "T4 = SM 7.5, no bf16 tensor cores (C-6)" |
| CPU only | fp32 (autocast is a no-op) | no CUDA dispatch key to reach |
| bf16 requested | **refused** | `training/vlm/trainer.py` refuses bf16 (finding C-6) |
`training/vlm/config.py` defines `PRECISION_CHOICES = ("fp16", "bf16", "fp32")`, but the trainer
refuses the bf16 branch. The change-VQA trainer's AMP selector is more careful still: it gates on
compute capability rather than on `torch.cuda.is_bf16_supported()`, because **that predicate returns
`True` on a T4** via *emulated* bf16, which uses no tensor cores and is therefore slower than fp16 and
no more accurate. The correct branch on a T4 is **fp16 with a scaler**, and the first external Kaggle
log confirms the selector took it:
`mixed precision : True (cuda autocast (float16) with GradScaler)`
(`PRE_KAGGLE_READINESS_REPORT.md` §1b, K2). The recorded value is
`artifacts/change_vqa/run/run_record.json` → `optimization.amp_reason`.
**A defect class follows from this policy and is worth stating here.** Computing a binary
cross-entropy on *sigmoid probabilities* — `BCE(sigmoid(z), t)` — saturates: its backward is
`(p − t)/(p(1 − p))`, and at `p = 1.0` exactly PyTorch clamps that to a **finite** `−9.99999996e11`, so
`GradScaler` cannot flag it. Under fp16 autocast the cast node turns it into `−inf`, the sigmoid's own
backward multiplies by `sigmoid'(z) = 0`, and `inf × 0 = nan`. Saturation needs no AMP at all:
`sigmoid(17.0)` is exactly `1.0` in plain fp32. The fix is to compute the loss from the **pre-sigmoid
logits** with `binary_cross_entropy_with_logits`. The full chain, the reproduction table, and the seven
tests that pin it are in `PRE_KAGGLE_READINESS_REPORT.md` §1b (K3) and
`R02_CHANGE_VQA_IMPLEMENTATION_STATUS.md` §4. The relevant source is
`training/change_vqa/model.py` (`binary_cross_entropy_from_logits`, and the docstring on
`binary_cross_entropy_probabilities` which survives as the clamped fallback).
### 2.3 Device selection
Device is resolved per run and recorded in every run record's `environment.requested_device`. No run
silently falls back: the change-VQA notebook "refuses to fall back silently" on a missing GPU
(`RUNBOOK_CHANGE_VQA_KAGGLE.md` §4), and the trainer's environment cell "stopped on `DEVICE is 'cuda'
but no GPU is visible`, which is the correct behaviour on this CPU-only machine"
(`PRE_KAGGLE_READINESS_REPORT.md` §6).
---
## 3. The frozen training configuration
`configs/base.yaml` is the single source of truth for every hyperparameter. It is a frozen artifact:
`docs/PHASE9_FREEZE.md` §2 records it at **10,637 bytes, SHA256 prefix `88434f7f8f78e2b8`, mtime
2026-09-16 19:30** — the *last* artifact to have moved, two days before the change-head GPU run. The
full freeze (every frozen key, with reason) is `docs/ARCHITECTURE_FREEZE.md` §4; the reproduction-side
account is [`REPRODUCIBILITY.md`](REPRODUCIBILITY.md) §2.
The training-relevant blocks are reproduced here, key by key, from `configs/base.yaml`.
### 3.1 `router` — and `router.training`
| Key | Value |
|---|---|
| `model` | `sentence-transformers/all-MiniLM-L6-v2` |
| `revision` | `1110a243fdf4` |
| `max_length` | 128 (asserted ≤ 256) |
| `embedding_dim` | 384 |
| `device` | `auto` |
| `hidden_dim` | 128 |
| `dropout` | 0.10 |
| `confidence_threshold` | 0.70 |
| `num_tasks` | 6 |
| `tasks` | `[vqa, caption, grounding, change, optical_sar, unsupported]` |
| `training.epochs` | 60 |
| `training.batch_size` | 64 |
| `training.learning_rate` | 0.001 |
| `training.weight_decay` | 0.01 |
| `training.task_loss_weight` | 1.0 |
| `training.modality_loss_weight` | 0.3 |
| `training.binary_loss_weight` | 0.5 |
| `training.val_ratio` | 0.15 |
| `training.hard_negatives_to_test` | **true** |
### 3.2 `grounding_training` and `grounding_head`
| Key | Value |
|---|---|
| `grounding_training.learning_rate` | 0.0001 |
| `grounding_training.batch_size` | 16 |
| `grounding_training.epochs` | 20 |
| `grounding_training.weight_decay` | 0.0001 |
| `grounding_training.warmup_ratio` | 0.05 |
| `grounding_training.grad_clip` | 1.0 |
| `grounding_training.val_fraction` | 0.10 |
| `grounding_training.num_workers` | 2 |
| `grounding_training.save_every_steps` | 500 |
| `grounding_training.box_loss_weight` | 0.5 |
| `grounding_training.giou_loss_weight` | 0.3 |
| `grounding_training.confidence_loss_weight` | 0.2 |
| `grounding_head.feature_dim` | **2048** (enforced — see §5.2) |
| `grounding_head.positive_confidence_weight` | **20.0** |
| `grounding_head.decode` | `cell_relative` |
### 3.3 `change`
| Key | Value |
|---|---|
| `tile_size` | 256 |
| `tile_overlap` | 0 |
| `threshold` | 0.50 |
| `min_component_pixels` | 32 |
| `encoder` | `resnet18` |
| `sa_mode` | `PAM` |
| `learning_rate` | 0.001 |
| `batch_size` | 8 |
| `bce_weight` | 0.5 |
| `dice_weight` | 0.5 |
| `levir_split` | `{train: 7120, val: 1024, test: 2048}` |
| `checkpoint_path` | `None` (see §6.8 — deliberate) |
### 3.4 `croma` and `fusion`
| Key | Value |
|---|---|
| `croma.variant` | `base` |
| `croma.image_resolution` | 120 (native; `120 % 8 == 0` → 225 patches) |
| `croma.encoder_dim` | 768 |
| `croma.optical_channels` | 12 |
| `croma.sar_channels` | 2 |
| `fusion.input_dim` | **2318** = 3 × 768 + 12 + 2 (enforced — see §7.2) |
| `fusion.hidden_dim` | 512 |
| `fusion.dropout` | 0.2 |
| `fusion.num_classes` | 19 |
### 3.5 `training` — the VLM LoRA block
| Key | Value |
|---|---|
| `precision` | **fp16** |
| `vlm_batch_size` | 2 |
| `vlm_gradient_accumulation` | 8 |
| `vlm_learning_rate` | 0.0002 |
| `vlm_epochs` | 1 |
| `lora_rank` | 16 |
| `lora_alpha` | 32 |
| `lora_dropout` | 0.05 |
| `weight_decay` | 0.01 |
| `warmup_ratio` | 0.05 |
| `gradient_checkpointing` | true |
| `save_every_steps` | 500 |
| `vlm.processor_longest_edge` | **512** (frozen — finding F5-2) |
### 3.6 `evaluation` — the invariants that bound training
| Key | Value | Meaning |
|---|---|---|
| `immutable_public_test` | `true` | the public test split may not be re-scored or edited |
| `hidden_data_access` | `false` | hidden annotations are never available during development |
| `leakage_split_key` | `scene_id` | splits are keyed on scene, not sample |
| `official_aggregate_weights` | `null` | inventing an aggregate formula is prohibited |
`evaluation/leakage.py::assert_no_hidden_access` enforces the last two at runtime and *raises* if either
is violated.
### 3.7 `deployment` — frozen paperwork, not a training knob
`deployment.torch_compile: false` (ZeroGPU does not support it — C-8) and
`deployment.cpu_mode: required` are frozen because a training-time compile flag would change the
artifact's execution semantics. `docs/ARCHITECTURE_FREEZE.md` §4 records the reason for each. See
[`DEPLOYMENT.md`](DEPLOYMENT.md).
### 3.8 What invalidates the hash
Per `docs/REPRODUCIBILITY.md` §2.7, the hash moves when any *hashable* key changes. Two things do
**not** move it:
1. A value that travels on a hash-exempt environment channel (§1.4).
2. A call-site argument such as a registry `builders=` override — which is exactly why the change
head's serving wiring and the change-VQA serving wiring both use that seam (§6.8, §8.10).
Everything else in the table above does. A moved hash detaches every artifact from its recorded
config and trips the drift guard.
---
## 4. Router adapter — local CPU, cached embeddings
### 4.1 What it is
A small classifier attached to the frozen MiniLM sentence encoder. It emits **five heads**:
`task` (6 classes), `modality` (4 classes), and three binary heads (`temporal`, `spatial_output`,
`language_output`). The architecture is fixed in `docs/ARCHITECTURE_FREEZE.md` §2.1:
```
embedding (384)
|
LayerNorm
|
Linear(384 -> hidden_dim) default hidden_dim = 128
|
GELU
|
Dropout(0.1)
|
+--> task_head Linear(hidden, 6)
+--> modality_head Linear(hidden, 4)
+--> temporal_head Linear(hidden, 1) logit
+--> spatial_head Linear(hidden, 1) logit
+--> language_head Linear(hidden, 1) logit
```
`router/adapter.py` initialises every head with small-std init (`std = 0.02`) and zero bias, keeping the
initial sigmoid near 0.5 — without it the binary heads can start saturated and the BCE gradients vanish
before the task head learns anything. `forward` asserts the input is 2-D and that
`embeddings.shape[1] == input_dim`, and requires embeddings already detached from the frozen encoder:
the adapter does not back-propagate into MiniLM.
**Parameter-count discrepancy — flagged, not smoothed over.** Two figures exist and have not been
reconciled:
| Figure | Source |
|---|---|
| **~50,822** | `router/adapter.py` module docstring ("Phase 4 probe"); `models/manifest.json` architecture string |
| **51,725** | `artifacts/router/router_adapter_v001/metadata.json` → `num_parameters`; `docs/PHASE4_ROUTER_REPORT.md` |
The released artifact is **211,961 bytes** (`models/manifest.json`). Which figure is authoritative is
`UNKNOWN — not established from the available evidence`. This document quotes both with their sources
and does not pick one. (`docs/MODELS.md` §3.5 records the same discrepancy.)
### 4.2 The corpus
The router is trained on a **synthetic** corpus — hand-written plus template-generated queries. It is
small, and every artifact says so.
| Property | Value | Source |
|---|---|---|
| total examples | **576** | `artifacts/router/router_adapter_v001/metadata.json` → `corpus.total` |
| groups | **54** | same → `corpus.groups` |
| by source | curated **66**, template **510** | same → `corpus.by_source` |
| corpus hash | `8054810736ef97c3db873b2d7073948773a8982f1d16411833d39e15e1871e83` | same → `corpus.hash` |
| by task | caption 91, change 115, grounding 128, optical_sar 50, unsupported 105, vqa 87 | same → `corpus.by_task` |
| binary positives | language_output 471, spatial_output 164, temporal 115 | same → `corpus.positives` |
The plan's minima are `plan_min_val_queries: 500` and `plan_min_hard_negatives: 100`
(`artifacts/router/threshold_sweep_val.json`). The corpus is **below both**, and the artifact's own
note says so verbatim:
> *"corpus-limited: val n=86 vs plan >=500. This is NOT a calibration — the corpus is synthetic and too
> small (min per-class support 8, caption) and val carries 0 hard negatives (hn_* families are held out
> to TEST by design). Selecting a threshold here yields a justified default, not a calibrated value. The
> corpus was NOT padded with generated queries."*
### 4.3 The frozen-encoder + cached-embedding strategy
This is the artifact where the strategy is most visible. The encoder is frozen, so its 384-d output for
a fixed string is constant; the project embeds the 576-example corpus once, caches the vectors under
`artifacts/router/cache/`, and trains the adapter on the cache. The recorded cost is the probe figure
quoted in §1.2: **20 epochs over 4,096 × 384 vectors in 0.28 s on CPU**.
The whole router run took **4.92 s** wall-clock
(`artifacts/router/router_adapter_v001/metadata.json` → `duration_seconds`), and the threshold sweep
that follows training took **0.206 s** (`artifacts/router/threshold_sweep_val.json` → `seconds`). There
is no GPU in either record.
### 4.4 Group-based splits with hard negatives in the test split
The router is the artifact where the split rule is most explicit, and it is a **leakage-prevention**
rule rather than a convenience.
- Splits are **by group** — template family / hard-negative family — **never by example**
(`router/dataset.py::split_by_group`).
- The reason, stated in `docs/ARCHITECTURE_FREEZE.md` §2.1's neighbourhood and in
[`MODELS.md`](MODELS.md) §3.5: template-generated queries are near-duplicates. Splitting by example
would put `"Show me the water body."` in train and `"Show me the road."` in val — one token apart —
and report a fake accuracy.
- **Hard-negative families are placed in the test split.** `configs/base.yaml` sets
`router.training.hard_negatives_to_test: true`, and `router/dataset.py` prefixes hard-negative
families with `hn_`. Finding **F4-3** records the consequence: hard-negative families are held out to
test so their accuracy measures **generalisation rather than memorisation**.
**Measured split sizes** (`artifacts/router/router_adapter_v001/metadata.json` → `split`):
| Split | examples | groups | hard negatives |
|---|---|---|---|
| train | 410 | 37 | — |
| val | 86 | 8 | **0** (by design) |
| test | 80 | 9 | the `hn_*` families |
The split audit records `clean: true` and `groups_across_splits: {}` — i.e. no group straddles two
splits.
### 4.5 Hyperparameters
From `configs/base.yaml` §`router.training`, corroborated by
`artifacts/router/router_adapter_v001/metadata.json`:
| Hyperparameter | Value |
|---|---|
| epochs | 60 |
| batch size | 64 |
| learning rate | 0.001 |
| weight decay | 0.01 |
| task loss weight | 1.0 |
| modality loss weight | 0.3 |
| binary loss weight | 0.5 |
| `val_ratio` | 0.15 |
| `hard_negatives_to_test` | true |
| seed | 42 |
### 4.6 The full training procedure
1. **Embed** the 576-example corpus with the frozen MiniLM encoder at `max_length: 128` and cache the
384-d vectors.
2. **Split by group** (`split_by_group`), holding `hn_*` families out to test.
3. **Train the adapter** for 60 epochs, batch 64, AdamW-class optimisation at `lr = 0.001`,
`weight_decay = 0.01`, on the composite loss
`1.0 · task + 0.3 · modality + 0.5 · binary`.
4. **Select** the epoch by validation (group-split) performance; the history is recorded per epoch in
`artifacts/router/router_adapter_v001/metadata.json` → `history` (each entry carries `epoch`, `loss`,
`lr`, `val_combined_accuracy`, `val_task_accuracy`).
5. **Sweep the confidence threshold** on **val only** (`scripts/` sweep →
`artifacts/router/threshold_sweep_val.json`), 50 thresholds from 0.50 to 0.99.
6. **Ship** the adapter (`artifacts/router/router_adapter_v001/adapter.pt`).
### 4.7 Measured numbers
`artifacts/router/threshold_sweep_val.json` is the release's router artifact. Its headline:
| Field | Value | Key path |
|---|---|---|
| **overall ungated task accuracy** | **0.965116** | `overall_ungated_accuracy` |
| split | `val` | `split` |
| n val | **86** | `n_val` |
| n val examples scored | 86 | `n_val_examples_scored` |
| **n test examples scored** | **0** | `n_test_examples_scored` |
| **`test_split_touched`** | **false** | `test_split_touched` |
| `corpus_limited` | true | `corpus_limited` |
| hard negatives in val | **0** | `hard_negatives_in_val` |
| val min per-class support | 8 (caption) | `val_min_support` |
| split sizes | train 410 / val 86 / test 80 | `split_sizes` |
| adapter encoder params | 22,713,216 | `adapter_encoder.parameters` |
| adapter config hash | `615478910dc266bf` | `adapter_config_hash` |
| repo config hash | `78f1e3700da15aa1` | `config_hash` |
> ### **0.965116 is validation-only, ungated, n = 86. The router TEST split was NOT RUN.**
>
> `n_test_examples_scored` is **0** and `test_split_touched` is **false**. Do not read 0.965116 as a
> test result. The number is "ungated" because the plan's acceptance target (`docs/` §59 in the master
> plan: task accuracy ≥ 95 % on 500 validation queries / 100 hard negatives / 50 unsupported) is
> measured on a corpus that has 86 val examples and **0** hard negatives. A router can reach high
> accuracy on easy queries while failing exactly on the hard-negative families the plan calls out.
**Historical note, recorded so the two are not conflated.** `artifacts/router/router_adapter_v001/
metadata.json` → `metrics.test` contains a **test block** (n = 80, `task_accuracy: 0.975`,
`macro_f1_task: 0.976`, `hard_negative_accuracy: 0.8`). That block is the **earlier Phase 4 gate-2
evaluation**, which predates the shipped threshold sweep and carries its own caveat — per
`docs/PHASE4_ROUTER_REPORT.md`, *"the 0.975 headline is partly earned on templates the split kept in
training. Treat the router as working, not as benchmarked."* The release's position for the shipped
artifact is **TEST NOT RUN**; the Phase 4 numbers are retained as a historical record, not promoted to
a release benchmark. (`docs/MODELS.md` §3.5 records the same distinction.)
### 4.8 The threshold sweep is val-only by construction
`artifacts/router/threshold_sweep_val.json` iterates thresholds 0.50 → 0.99 and records `coverage`,
`covered_task_accuracy`, `fallback_rate` and `n_covered` at each.
| Row | Threshold | coverage | covered_task_accuracy | fallback_rate | n_covered |
|---|---|---|---|---|---|
| shipped (`shipped_row`) | **0.70** | 0.848837 | 0.972603 | 0.151163 | 73 |
| sweep-selected (`selected`) | 0.76 | 0.790698 | **1.0** | 0.209302 | 68 |
`select_by: "covered_accuracy"`. The delta against the shipped threshold
(`delta_vs_shipped`) is `coverage: −0.0581`, `covered_task_accuracy: 0.0274` — the sweep's own criterion
trades **5.8 pp of coverage for 2.7 pp of covered accuracy**. **The shipped threshold remains 0.70.**
This is a *justified default*, not a calibrated value, and the artifact says so.
---
## 5. Grounding head — local CPU, cached features
### 5.1 What it is
A trainable head over the **frozen** RemoteCLIP `ViT-B-32` encoder
(`chendelong/RemoteCLIP`, file `RemoteCLIP-ViT-B-32.pt`, 605 MB). The encoder is frozen; the projection
and the grounding head are trainable (`docs/ARCHITECTURE_FREEZE.md` §2.3). The head has **1,052,677
parameters** (`artifacts/grounding/remoteclip_grounding_v001/training_metadata.json` →
`head_parameters`) and emits five outputs — `tx, ty, tw, th, objectness` — over a **7 × 7** token grid
(`grid: 7`, `head.decode: cell_relative`, `head.dropout: 0.1`, `head.feature_dim: 2048`,
`head.hidden_dim: 512`).
### 5.2 The enforced 2048-dimensional invariant
Per-cell feature is the concatenation
```
concat([patch, text, patch·text, global_pool]) = 4 × 512 = 2048
```
`core/config.py` **rejects any value other than `4 × grounding.encoder_projected_dim` at load time**, and
the specialist asserts the same 512 against the real model. The reason is stated in
[`REPRODUCIBILITY.md`](REPRODUCIBILITY.md) §2.3 (invariant 2): a mismatch is a **silent** shape error
that torch only raises at the similarity step — after patch features are already cached. The load-time
guard makes the error loud and early. `configs/base.yaml` records `grounding_head.feature_dim: 2048`.
### 5.3 The feature cache
The grounding run is a **two-stage** pipeline (`scripts/train_grounding.py`): stage 1 extracts and caches
RemoteCLIP features, stage 2 trains the head on the cache. The recorded cache
(`artifacts/grounding/remoteclip_grounding_v001/run_record.json` → `cache`) is:
| Quantity | Value |
|---|---|
| cache dir | `artifacts/grounding/remoteclip_grounding_v001/cache/remoteclip_224_v1` |
| images total | 15,699 |
| image cache hits / misses | 15,699 / 0 |
| images undecodable / missing source | 0 / 0 |
| phrases total | 24,439 |
| text cache hits / misses | 24,439 / 0 |
| cache build seconds | 2.1 (images 0.9 s, text 0.0 s) |
The cache version string is `remoteclip_224_v1` — the resolution is baked into the cache identity, so a
head trained at 224 cannot silently be served features extracted at another resolution.
### 5.4 Hyperparameters
From `configs/base.yaml` §`grounding_training`, corroborated by the run's history (first epoch
`lr = 0.0001`, decaying):
| Hyperparameter | Value |
|---|---|
| learning rate | 0.0001 |
| batch size | 16 |
| epochs | 20 |
| weight decay | 0.0001 |
| warmup ratio | 0.05 |
| grad clip | 1.0 |
| val fraction | 0.10 |
| num workers | 2 |
| save every steps | 500 |
| box loss weight | 0.5 |
| GIoU loss weight | 0.3 |
| confidence loss weight | 0.2 |
| `positive_confidence_weight` | **20.0** |
| seed | 42 |
The loss weights appear in the per-epoch history as `loss_box`, `loss_giou`, `loss_confidence` and
`loss_total` (`artifacts/grounding/remoteclip_grounding_v001/run_record.json` → `history`).
### 5.5 Why `positive_confidence_weight = 20.0`
The objectness branch is a per-cell binary classification over a 7 × 7 grid. Roughly **1 cell in 49** is
positive. Unweighted, the loss-minimising solution is "no object everywhere"; the weight is what stops
that collapse. The value is frozen in `configs/base.yaml` §`grounding_head`, not chosen per run.
### 5.6 The training procedure
1. **Extract** RemoteCLIP image + text features for the VRSBench grounding split, into
`cache/remoteclip_224_v1` (§5.3).
2. **Split** by image (leakage split by image; `training/grounding/dataset.py`), with
`val_fraction: 0.10`. `run_record.json` records `train_items: 23042` / `val_items: 2548`;
`training_metadata.json` records `train_images: 14130` / `val_images: 1569`; and the cache's
`images_total` is 15,699 = 14,130 + 1,569. `docs/PHASE8_HANDOFF.md` §2 restates the same split.
3. **Train** the 1,052,677-parameter head for 20 epochs, batch 16, `lr = 1e-4`, `weight_decay = 1e-4`,
`warmup_ratio = 0.05`, `grad_clip = 1.0`, on
`0.5 · box + 0.3 · giou + 0.2 · confidence` with `positive_confidence_weight = 20.0`.
4. **Select** the best validation IoU. `docs/PHASE8_GROUNDING_HEAD_DECISION.md` records the decision.
5. **Evaluate** under two protocols and two decode variants (§5.9).
6. **Ship** `artifacts/grounding/remoteclip_grounding_v001/head.pt` (12,639,041 bytes, sha256
`93432f7034be91a8ffd9c1a84e3eeec00bed7832c043fe7f83d2be230284c6bb`; `models/manifest.json`).
**Wall-clock:** **805.9 s** on CPU (`artifacts/grounding/remoteclip_grounding_v001/run_record.json` →
`wall_seconds`; the per-epoch `seconds` in the run history sum to 765.9 s over 20 epochs — ~38 s/epoch —
with the remainder being setup).
### 5.7 Resolution is frozen at 224 — 448 was REJECTED
`grounding.image_size: 224` (native). The 448 variant (→ 196 tokens) was a **gated experiment** in
`docs/ARCHITECTURE_FREEZE.md` §2.3: adopted only if validation IoU improved. It did not.
`docs/ARCHITECTURE_FREEZE.md` §4 records the resolution as "**RESOLVED by measurement**: 448 lost on
IoU, all recall thresholds and latency over 16,159 records (`docs/PHASE7_RESOLUTION_DECISION.md`)". The
paired test statistic is recorded as **mean diff −0.0147, 95 % CI [−0.0160, −0.0134], t = −22.63, at
1.59× latency** (see [`BENCHMARKS.md`](BENCHMARKS.md) §4.2 and
[`EVALUATION.md`](EVALUATION.md) §5, "a pre-registered rejection"). The localisation floor at 224 is
**32 px** (7 × 7 tokens of 32 px each).
### 5.8 Measured training numbers
From `artifacts/grounding/remoteclip_grounding_v001/training_metadata.json` and `run_record.json`:
| Field | Value | Key path |
|---|---|---|
| best val IoU | **0.0946** | `best_val_iou` (full: `0.09463294948954619` in `run_record.json`) |
| zero-shot baseline | **0.0972** | `baseline.mean_best_iou` |
| `beats_baseline` | **false** | `beats_baseline` |
| required margin | 0.02 | `baseline.required_margin` |
| first epoch val IoU (argmax decode) | 0.0436 | `history[0].val_iou_argmax` |
| head parameters | 1,052,677 | `head_parameters` |
| grid | 7 | `grid` |
| device | cpu | `device` |
| data root | `training/data/vrsbench` | `data_root` |
> **The head did not beat the zero-shot baseline on validation IoU** (0.0946 < 0.0972;
> `beats_baseline: false`, improvement −0.002567). This is a **negative result**, preserved as such.
> It does not invalidate the head — the head is what the two-protocol evaluation is computed with, and
> the zero-shot number (0.0972) is one of the two decode variants the release quotes. But no claim of
> improvement over zero-shot is made, because the measurement says there is none.
### 5.9 Two protocols × two decode variants
Grounding is the artifact where collapsing protocols is most dangerous, because a box-convention
mistake is silent. The release therefore reports:
| Protocol / variant | Value | Artifact |
|---|---|---|
| **canonical** protocol, `head_threshold` decode | **0.2838** | `eval_result_canonical.json` → `head_threshold.mean_best_iou` |
| **canonical** protocol, `head_argmax` decode | **0.1215** | `eval_result_canonical.json` → `head_argmax.mean_best_iou` |
| **matched6** protocol, `head_threshold` decode | **0.2566** | `eval_result_matched6.json` → `head_threshold.mean_best_iou` (top_k 6) |
| zero-shot baseline | **0.0972** | `run_record.json` → `baseline_mean_best_iou` |
`n_eval_records: 16159`, `resolution: 224` (`eval_result_canonical.json`). **Never quote one protocol
alone.** The convention is stated in [`BENCHMARKS.md`](BENCHMARKS.md) §1 and
[`EVALUATION.md`](EVALUATION.md) §4.2.
---
## 6. Change head — external GPU, STANet-style Siamese
### 6.1 What it is
A reimplemented STANet-style Siamese change detector — **reimplemented, not vendored**
(`docs/ARCHITECTURE_FREEZE.md` §2.4, finding C-9). Architecture
(`specialists/change/stanet.py`, and the checkpoint's embedded config in
`artifacts/change/eval_test/eval_result.json` → `checkpoint_embedded_config`):
| Component | Value |
|---|---|
| encoder | ResNet-18 (torchvision `IMAGENET1K_V1`, `pretrained_used: true`) |
| encoder channels | `[64, 128, 256, 512]` |
| self-attention | **PAM** (`sa_mode: PAM`) — the BAM alternative is not used |
| width | 128 |
| `frozen_encoder` | **false** (see §1.1 — this is the one trained encoder) |
| `attention_budget_bytes` | 268,435,456 (256 MiB; PAM is skipped at layer 1 to stay inside it) |
| tied weights | yes (Siamese) |
| model parameters | **15,779,969** |
### 6.2 Hyperparameters
From `configs/base.yaml` §`change`, corroborated by
`artifacts/change/levir_change_v001/run_record.json` → `hyperparameters`:
| Hyperparameter | Value |
|---|---|
| tile size | 256 |
| tile overlap | 0 |
| learning rate | 0.001 |
| batch size | 8 |
| epochs | 20 |
| weight decay | 0.0001 |
| grad clip | 1.0 |
| loss | `0.5 · BCE + 0.5 · Dice` (`bce_weight` 0.5, `dice_weight` 0.5) |
| `pos_weight` | `null` (not used) |
| LR schedule | cosine (`CosineAnnealingLR(optimizer, T_max = epochs)`, `training/change/train.py:750`) |
| threshold | 0.50 |
| `min_component_pixels` | 32 |
| seed | 42 |
### 6.3 Training data and split
LEVIR-CD-256, flat layout. `run_record.json` records `train_items: 7120`, `train_scenes: 445`,
`val_items: 1024`, `val_scenes: 64`; `docs/PHASE9_REAL_DATA_VERIFICATION.md` records the full split as
**445 / 64 / 128 scenes = 7120 / 1024 / 2048 tiles**, matching `change.levir_split`. The split is
scene-disjoint (verified — see [`DATASETS.md`](DATASETS.md) §3).
### 6.4 The procedure
1. **Handoff** the code and data to an external GPU. `docs/PHASE9_GPU_HANDOFF.md` records the Kaggle
code zip `artifacts/kaggle/satquery-code.zip` (152 files, 0.6 MB), the cell map, and the cell-5
gate.
2. **Train** 20 epochs, batch 8, `lr = 1e-3`, `0.5·BCE + 0.5·Dice`, cosine schedule.
3. **Select** the best validation IoU. `best_val_iou` full precision `0.8232294319484017`
(`run_record.json`), `first_val_iou` `0.7335963646366181`; `improved: true`.
4. **Score the immutable test split exactly once.**
5. **Freeze** the phase (`docs/PHASE9_FREEZE.md`).
**Run identity:** `run_id: change_head_20260918T070723Z`, `wall_seconds: 4014.3`
(`run_record.json`) / `duration_seconds: 4012.29` (`training_metadata.json`). `peak_vram_mb: 1483.0`
(`training_metadata.json`). Device `cuda`, torch `2.10.0+cu128`, python 3.12.13.
### 6.5 Measured training numbers
From `artifacts/change/levir_change_v001/training_metadata.json` (and `model_metadata.json`, which
carries the identical block):
| Field | Value |
|---|---|
| best val IoU (pooled) | **0.8232** |
| final val pooled | f1 0.9030, iou 0.8232, miou 0.9075, precision 0.9176, recall 0.8890 |
| final val macro | f1 0.8317, iou 0.7507, miou 0.8645, precision 0.8778, recall 0.8122 |
| val n | 1024 |
| val images with change | 436 |
| val mean change fraction | 0.042 |
| first val IoU | 0.7336 |
| device | cuda |
### 6.6 The test result — the only `VERIFIED` headline
`artifacts/change/eval_test/eval_result.json`, `split: test`, `n: 2048`:
| Metric | pooled | macro |
|---|---|---|
| f1 | **0.8964** | 0.7962 |
| iou | **0.8122** | 0.7180 |
| miou | 0.9007 | **0.8457** |
| precision | 0.9195 | 0.8506 |
| recall | 0.8745 | 0.7757 |
| tp / fp / fn / tn | 5,978,997 / 523,658 / 858,407 / 126,856,666 | same pixel counts |
| n pixels | 134,217,728 | same |
`threshold: 0.5`, `tile_size: 256`, `seconds: 55.359`, `n_images_with_change: 935`,
`mean_change_fraction: 0.0509`, `config_drift: false`.
> **This is the only `VERIFIED` headline in the release** — pooled IoU 0.8122 / pooled F1 0.8964, with
> the macro pair (iou 0.7180 / **miou 0.8457**) reported alongside. `docs/PHASE9_FREEZE.md` §4 lists it
> as "Benchmark performance ✅ ... reproduced". The generalisation gap is val 0.8232 → test 0.8122.
**Metric-naming caveat.** The `macro` block carries *both* `iou` (0.7180, the macro-average of the
per-image IoU) and `miou` (0.8457, the mean IoU). The headline "macro IoU 0.8457" used across the
release docs is the **`miou`** field. This document names the key path so the two cannot be conflated.
### 6.7 The threshold lever is CLOSED
`docs/PHASE9_FREEZE.md` §5 eliminates the threshold hypothesis. A **val-only** sweep scored 19
thresholds; `scripts/sweep_change_threshold.py` refuses any `--split` other than `val` and exits `4`
before loading config, checkpoint, or data — so the sweep cannot touch the test split. Its artifact
records `test_split_touched: false` and its sha256 is
`34e20f62bc1dd7810f5ef5f26838213d59368cf1352a804d38cb0e140eaf97f2`.
| Threshold | pooled IoU | macro IoU |
|---|---|---|
| **0.50 (retained)** | 0.8232 | 0.7507 |
| best pooled (0.35–0.40) | 0.8239 (+0.0007) | — |
| best macro (0.25) | — | 0.7550 (+0.0043) |
Four thresholds do dominate 0.50 on all three reported metrics — but 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.**
### 6.8 The freeze, and the orphaned-head hazard
`docs/PHASE9_FREEZE.md` freezes the change specialist: the artifacts in its §2 may not be edited,
regenerated, or re-scored, and a longer cosine run must use `levir_change_v002` or it overwrites the
audited `head.pt` in place. The freeze also documents a verified hazard: the Kaggle notebook
`notebooks/kaggle_change_training.ipynb` **hardcodes the frozen output directory**, so re-running it
writes straight over `levir_change_v001/head.pt`.
The trained head was, at freeze time, **not reachable from the serving path**: `change.checkpoint_path`
resolves to `None`, so the registry builds a random-initialised detector and the change specialist
reports `DEGRADED` with `has_checkpoint: False`. This is deliberate — pointing `base.yaml` at the
checkpoint *moves the config hash* and detaches the benchmark number. The fix uses the registry
`builders=` override, which reaches the change builder without moving the hash
(`app/serving.py`; `docs/ARCHITECTURE_CHANGE_CHANGE_SERVING_WIRING.md`). Wired that way, a real
`AnalysisController.run()` on `test_79_2.png` returns `available`, `degraded=False`, confidence 0.7987.
Unwired, the same query returns `DEGRADED` with zero change regions. **"Phase 9 benchmark 0.8122" and
"the app detects change" are different claims, and only the first is true.**
### 6.9 The registration-gate defect — `OPEN`
`docs/PHASE9_FREEZE.md` §7 records an open defect inherited by Phase 10: the change specialist's
registration gate produces false positives. **1,202 / 2,048 test tiles (58.7 %)** are flagged as
mis-registered; `response < 0.15` is involved in 98.7 % of them; the flag rate climbs 42 % → 75 % → 98 %
→ 100 % as ground-truth change fraction rises. On 638 flagged tiles that do contain change, **5,679
region claims are suppressed** — 5,338 of them on tiles the model scores at IoU ≥ 0.7 (median 0.841).
An independent NCC check found **0 / 30** credible large offsets on the tiles the gate blames. One
counter-hypothesis remains untested: ~50 % of *zero-change* tiles are also flagged, consistent with low
image texture. **`OPEN`.** This is a *serving-path* defect; it does not affect the 0.8122 benchmark,
which is computed offline.
---
## 7. Optical-SAR fusion head — local CPU, seed sweep
### 7.1 What it is
A fusion head over the **frozen** CROMA-base encoder (`antofuller/CROMA`, `CROMA_base.pt`, MIT;
777,563,846 bytes; sha256 `0238d814b53108f3574bf1ea240e38a0a6edd46173816d9a6962070561893b63`). The head
is `LayerNorm → Linear(2318, W) → GELU → Dropout → Linear(W, task_dim)`
(`docs/ARCHITECTURE_FREEZE.md` §2.5), with `W = 512`, `task_dim = 19`, dropout 0.2, and **1,201,711
parameters** (`artifacts/optical_sar/fusion_head_production_v001/production_head_record.json` →
`head_config.head_parameters`).
### 7.2 The enforced 2318-dimensional invariant
The fusion head's input is derived, not hardcoded (`docs/REPRODUCIBILITY.md` §2.3, invariant 1):
```
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)
```
`core/config.py` rejects any other value at load time. `docs/ARCHITECTURE_FREEZE.md` §2.5 records that
**CROMA never receives a mask** (finding C-1): the masks are *inputs to the fusion head*, which is what
lets the head learn to trust the availability signal. `croma.image_resolution: 120` is native
(`120 % 8 == 0` → 225 patches).
### 7.3 The feature caches
Training consumes cached CROMA features. The Arm-A cache
(`artifacts/optical_sar/fusion_features/`) contains
`train.npz` (170,919,398 B), `train.json` (1,581,540 B), `val.npz` (34,195,674 B),
`val.json` (317,536 B), `test.npz` (34,165,540 B), `test.json` (317,537 B). The Arm-B cache lives in
`artifacts/optical_sar/fusion_features_armB/`. These caches are **reproducible and are not released as
model weights**.
**The arm is baked into the cache.** `scripts/train_fusion.py` refuses to train a cache whose recorded
arm differs from `--arm`; the resume provenance guard
(`training/fusion/extract.py`, `CacheProvenanceError`) refuses to append Arm-B rows into an Arm-A cache.
This is why §7.4's blocker is a *compute* item, not a code defect.
### 7.4 The Arm A / Arm B contract — and why "Arm B" is not the registered Arm B
`docs/PHASE14_GATE_F_DECISION_RECORD_2026-09-20.md` §2 (D-01) amends the Phase 14 contract. The arms as
actually implemented and run:
| Arm | Definition | `use_8_bit` |
|---|---|---|
| **Arm A (control)** | percentile/dB conditioning → per-channel `mean ± 2·std` stretch → uint8 round-trip | **true** |
| **Arm B (variant)** | percentile/dB conditioning → per-channel `mean ± 2·std` stretch → bounded stretch, **no** uint8 round-trip | **false** |
Three facts must be stated together, or the pre-registration is silently rewritten:
1. **The §4-registered arm B — "percentile/dB only, no encoder-input stretch" — is NON-CONSTRUCTIBLE.**
`normalise_for_croma` has no skip branch: it always applies the `mean ± 2·std` stretch, and
`use_8_bit` only toggles the uint8 round-trip vs a clip
(`specialists/optical_sar/radiometry.py:377-401`). The registered arm is retained unedited for the
record and was never run.
2. **The arm actually run under the name "B" is the registered arm C** ("A but `use_8_bit=false`").
Calling the current B the registered B would equate two different arms.
3. **No Arm C was introduced.** The arm set is frozen at exactly two.
So the comparison that was actually run is: **"does the uint8 round-trip in the encoder-input path
change fusion-head validation accuracy?"** — the quantisation/range-bound question — **not** the
stretch-vs-no-stretch question §4 originally registered. The deviation is recorded, not hidden.
The Arm-A run records carry a `normalization` string describing exactly what was executed, e.g.
`artifacts/optical_sar/fusion_head_v001/armA_seed100/run_record.json` →
`normalization: "uint8 axis, use_8_bit=true: per-channel mean+-2std encoder-input stretch, then the
uint8 round-trip (scale to 0..255, clip, quantise, /255) -> {0/255,...,1}. This is the transform
actually executed; the registered PHASE14 §4 stage-1 percentile/dB conditioning is not run."`
### 7.5 Hyperparameters
From `artifacts/optical_sar/fusion_head_v001/armA_seed100/run_record.json` (`hyperparameters`,
`head_config`), identical across both arms:
| Hyperparameter | Value |
|---|---|
| batch size | 64 |
| epochs | 20 |
| learning rate | 0.001 |
| weight decay | 0.0001 |
| grad clip | 1.0 |
| warmup ratio | 0.05 |
| `input_dim` | 2318 |
| `hidden_dim` | 512 |
| `task_dim` | 19 |
| dropout | 0.2 |
| head parameters | 1,201,711 |
| seed | 100–104 |
### 7.6 Channel / band dropout is mandatory
`docs/ARCHITECTURE_FREEZE.md` §2.5: "Channel/band dropout during fusion-head training is **mandatory**;
it is what teaches the head to trust the availability mask." The recorded rates are
| Modality | dropout rates |
|---|---|
| optical | `[1.0, 0.8, 0.6, 0.4]` |
| SAR | `[1.0, 0.5]` |
(`artifacts/optical_sar/fusion_head_v001/armA_seed100/run_record.json` → `channel_dropout`). A rate of
`1.0` means the modality is entirely dropped for that sample, which forces the head to produce a
prediction from the surviving modality plus the mask.
### 7.7 The seed sweep and the decision
Training was run as **two arms × five seeds (100–104)**. The **deciding metric is
`best_val_accuracy`** — the maximum validation accuracy over epochs (`Gate F D-02`); macro-F1 is
co-reported, never deciding; `final_val` is not deciding.
| | Arm A | Arm B |
|---|---|---|
| accuracies | 0.83425 · 0.82450 · 0.83850 · 0.85300 · 0.83525 | 0.84300 · 0.84675 · 0.84450 · 0.84400 · 0.81725 |
| **mean** | **0.837100** | **0.839100** |
| min / max | 0.82450 / 0.85300 | 0.81725 / 0.84675 |
| spread (max − min) | **0.02850** | 0.02950 |
(`artifacts/optical_sar/fusion_head_v001/armA_seed_variance_report.json`,
`armB_seed_variance_report.json`; `docs/PHASE14_GATE_F_DECISION_RECORD_2026-09-20.md` §7.1.)
**The decision rule was pre-registered.** Gate F §6 (written *before* any Arm-B result existed) pinned
the A/B statistic to the five-seed **MEAN**:
```
B_mean − A_mean = 0.839100 − 0.837100 = +0.002000
threshold (fixed in §5/§6, never re-chosen) = 0.0285
+0.002000 > 0.0285 → FALSE
```
> ### **ARM A RETAINED. Arm B is NOT adopted.**
The substantive finding: **the uint8 round-trip is not accuracy-limiting at this scale.** Removing it
shifts validation accuracy by **+0.2 percentage points** against a **2.85-point** seed-to-seed noise
floor — the effect is roughly **14× smaller than the noise it would have to clear**. Robustness checks
(§7.4 of the Gate F record) confirm the verdict does not depend on which arm supplies the floor, and
that under the excluded best-single-seed reading Arm A wins too. The paired design is proven: both arms
carry **identical `sample_ids` and `scene_ids` in identical order** while the `.npz` sha256 differs in
all three splits — same samples, same order, different bytes, so the delta is attributable to
preprocessing, not sampling.
**The σ = 0 fallback.** Gate F D-03 amends the decision rule so that if the measured spread is zero the
floor is the validation-accuracy resolution `1/N_val`. With `N_val = 4000`, that floor is **0.00025**.
It did not apply here (the measured spread is non-zero at 0.0285), but it is part of the contract.
### 7.8 The production head
The production head is a **distinct, frozen artifact** — not the same directory as the sweep.
`artifacts/optical_sar/fusion_head_production_v001/production_head_record.json`:
| Field | Value |
|---|---|
| designated by | **R-14** (owner ruling, 2026-09-21) |
| source | `artifacts/optical_sar/fusion_head_v001/armA_seed103/head.pt` |
| arm / seed | **A / 103** |
| sha256 | `785815729a3a39fc34dc41894efaf00d8739365d970a3f830a326e68ae888dab` |
| bytes | 14,427,457 |
| `best_val_accuracy` | **0.853** |
| `arm_a_mean_best_val_accuracy` | 0.8371 |
| `test_split_used_for_selection` | **false** |
| `is_ab_deciding_statistic` | **false** |
Selection basis, verbatim from the record: arm selection is the **five-seed MEAN** of
`best_val_accuracy` (pre-registered); production-head selection is the **highest `best_val_accuracy`
among the retained Arm-A seeds** (a post-experiment artifact-selection rule only). The five Arm-A
candidate digests are listed in `retained_arm_a_candidates`. The provenance note states the production
head bytes are an **unmodified, byte-identical copy** (`shutil.copyfile`) of the trained Arm-A seed-103
artifact — no re-serialisation, re-pickling or tensor round-trip. **The test split was not used for
selection.**
### 7.9 The pre-registered 11.5 metric
`artifacts/optical_sar/fusion_head_production_v001/pre_registered_115_metric.json` (a rerun is recorded
in `phase12_rerun_verification.json`, same values):
| Field | Value |
|---|---|
| metric | `pre_registered_11.5` |
| split | `test` |
| n scored | **4,000** |
| head sha256 | `785815729a3a39fc34dc41894efaf00d8739365d970a3f830a326e68ae888dab` |
| **accuracy** | **0.931** |
| **macro-F1** | **0.434161** |
| loss | 0.254592 |
| num classes | 19 |
| classes present | 14 (`[0,2,3,4,5,6,7,8,9,10,12,13,17,18]`) |
| classes absent | 5 (`[1,11,14,15,16]`) |
| macro-F1 denominator | **"all 19 classes (absent classes contribute 0.0)"** |
| `is_deciding_statistic` | **false** |
> ### **Accuracy 0.931 is never quoted without macro-F1 0.434161, and the ruling is `OPEN`.**
>
> The macro-F1 denominator is all 19 classes, so the five absent classes contribute `0.0` by
> construction — which is why the two numbers answer different questions. Classes 5 and 6 are *present*
> in the scored split and still score `0.0`
> (`docs/PHASE12_115_METRIC_COMPUTED.md`). The metric JSON's own `advisory` says it "reports ONE head's
> held-out accuracy and macro-F1. It selects no head, ranks nothing and compares no arms. Whether this
> constitutes a Phase 12 pass is the owner's ruling."
### 7.10 The `PLUMBING_ONLY` label — R-08
Every run record in **both** arms carries
`result_status = "PLUMBING_ONLY — fixture/loop evidence, NOT a result; the pre-registered 11.5 metric is
not computed"` (`training/fusion/train.py:121-124`). The label is **stale** for the real 20,000-sample
runs, and it was **deliberately not changed mid-experiment**: editing the constant would have left
Arm A's already-written records inconsistent with Arm B's. Both arms carry the identical text, so the
comparison is unaffected. It is flagged for an owner ruling (**R-08**) and no historical record was
rewritten (`docs/PHASE14_GATE_F_DECISION_RECORD_2026-09-20.md` §7.7). The `run_record.json` also carries
`pre_registered_metric_computed: false` and a matching `final_val.note`.
---
## 8. Change-VQA head — external GPU, cached change features
This is the artifact with the most complete external-training contract in the repository. Its
authoritative guide is `docs/R02_KAGGLE_TRAINING_GUIDE.md` (37,543 B, 19 points); the short form is
`RUNBOOK_CHANGE_VQA_KAGGLE.md`; the pre-flight checklist is `PRE_KAGGLE_READINESS_REPORT.md` (33 items).
### 8.1 What it is
A **two-stage** head over cached change features
(`training/change_vqa/model.py`; `R02_CHANGE_VQA_IMPLEMENTATION_STATUS.md` §2):
```
frozen STANet detector -> change feature (1045-d)
-> STAGE 1 class-wise change estimate (13 outputs)
-> STAGE 2 question-conditioned 19-way answer
-> answer + evidence + provenance
```
Stage 1's outputs are **concatenated into** stage 2's input, so the answer is computed *from* the change
estimate. That is what makes `largest_change` / `smallest_change` answerable at all: a single pooled
vector has no per-class structure to compare. It is **one trainable module with one loss**, not two
models. `ARCHITECTURE_VERSION = "change_vqa_head_v1"`.
| Component | Value | Source |
|---|---|---|
| architecture | `change_vqa_head_v1` | `artifacts/change_vqa/run/model_metadata.json` |
| parameters | **1,453,912** | `run_record.json` → `model.parameters` |
| `trunk_dim` | 512 | `model.trunk_dim` |
| `text_dim` | 256 | `model.text_dim` |
| dropout | 0.10 | `model.dropout` |
| stage-1 estimator | `Linear(1045, 256) → GELU → Linear(256, 13)` | `training/change_vqa/model.py` |
| answer head | `Linear(fused, 512) → GELU → Dropout → Linear(512, 256) → GELU → Linear(256, 19)` | same |
The architecture was chosen from four measured facts
(`R02_CHANGE_VQA_IMPLEMENTATION_STATUS.md` §2): the answer space is **closed and has 19 members**
(rules out a generative decoder); answers are short (`yes`, `buildings`, `10_to_20`); `label1`/`label2`
ship for all 2,968 scenes (class-wise change is **computable supervision**, not a guessed target); and
the budget is ≤ 3 h on T4×2 (rules out training anything large).
### 8.2 The frozen dependency
The head reads features produced by the **frozen** LEVIR STANet change detector
(`artifacts/change/levir_change_v001/head.pt`, sha256
`c5ef31277b67aa01a593aec0eac503eeaccc6d674349fda20ca44c9cc6f8e9fa`, 63,231,009 B, 15,779,969 params).
The detector is never updated. A missing checkpoint is a **hard error** unless
`--allow-untrained-detector` is passed — "because a cache built from an untrained detector still trains,
still evaluates, and still reports a number" (`RUNBOOK_CHANGE_VQA_KAGGLE.md` §3). The notebook verifies
the checkpoint by size and sha256 and **refuses to continue** on a mismatch; this was verified by
injecting a 23-byte file in its place.
### 8.3 The feature caches
Training consumes two caches, both identified by a **spec hash**:
| Cache | Spec | Hash |
|---|---|---|
| change features (1045-d) | `change_feat_v1` | `change_cache_spec` **`c801326f85a185f8`** (trained; untrained spec is `714efac5b0e6a6d1`) |
| question features (384-d) | MiniLM-L6-v2 | `text_cache_spec` **`d2801ea1a314354a`** |
(`artifacts/change_vqa/run/model_metadata.json`.) The feature extractor re-runs the detector's
submodules and **trains nothing**: `training/change_vqa/features.py` → `ChangeFeatureExtractor`
("Trains no"). `FEATURE_SPEC = "change_feat_v1"`, `DEFAULT_IMAGE_SIZE = 256`,
`CHANGE_FEATURE_DIM = 1045` = 1024 level_pooled + 5 change_stats + 16 change_grid. The trainer refuses a
cache built under a different spec, and the specialist returns an empty answer with `degraded=True` on a
spec mismatch rather than answering from the wrong feature space.
**Cost:** feature extraction is the bottleneck, not head training. Measured **≈ 0.25 s/scene on CPU**
(64 scenes in 22 s including startup); ≈ 8 min for 2,000 scenes. Head training is ≈ 0.5 s/epoch over 26
batches at the smoke scale. The T4 figure is **not measured and not claimed**.
### 8.4 The training procedure
The run record's `argv` is the exact invocation
(`artifacts/change_vqa/run/run_record.json` → `argv`):
```
--prepared-dir /kaggle/working/outputs/change_vqa/prepared
--output-dir /kaggle/working/outputs/change_vqa/run
--data-root /kaggle/input/datasets/creatorballs/cdvqa-dataset
--device cuda
--time-limit-seconds 10800
--epochs 40
--batch-size 256
--seed 42
--patience 6
```
The trainer's own defaults are `epochs=40`, `batch_size=128`, `seed=42`, `patience=6`,
`time_limit=10800 s` (`training/change_vqa/train.py`), with `DEFAULT_LR = 1e-3`,
`DEFAULT_WEIGHT_DECAY = 1e-4`, `DEFAULT_WARMUP_RATIO = 0.05`, `DEFAULT_GRAD_CLIP = 1.0`,
`DEFAULT_MIN_DELTA = 1e-4`. The guide instructs: *"Do not change these to get past an error."*
The procedure, in order:
1. **Assemble the code** as a Kaggle dataset (249 files, 248 define the digest; the guide's §1a holds the
digest value and the recompute snippet).
2. **Assemble the CDVQA data** as a second Kaggle dataset (§4 of the readiness report: `annotations/`
53 MB + `im1/` 1,128 MB + `im2/` 1,156 MB + `label1/` 22 MB + `label2/` 22 MB ≈ 2.4 GB). A **split
integrity gate** refuses to continue unless the four split counts match exactly.
3. **Attach the frozen STANet checkpoint.**
4. **Create the notebook with T4 ×2 and Internet on** (MiniLM weights download).
5. **Discover paths** (marker search, not an assumed mount slug) and **verify the STANet digest**.
6. **Run the discovery, environment and integrity cells** — `integrity clean : True`, no size errors.
7. **Prepare Train and Val only** (targets → change features → question features). Held-out splits are
prepared later, *after* the head is fitted, so no held-out label is read before the model is frozen.
8. **Train** (time-budgeted at 3 h; the budget is checked before every batch).
9. **Evaluate** on Test and Test2, masked and unmasked, with a guard over all three artifacts and a
check that reads `eval_summary.json` back.
10. **Return the export directory** — the unit of review.
### 8.5 Optimisation
`artifacts/change_vqa/run/run_record.json` → `optimization`:
| Field | Value |
|---|---|
| optimizer | **AdamW** |
| learning rate | 0.001 |
| weight decay | 0.0001 |
| scheduler | `cosine_with_warmup` |
| grad clip | 1.0 |
| AMP | **fp16** (`torch.float16`) with **GradScaler** |
| `amp_reason` | `cuda autocast (float16) with GradScaler` |
| `native_bfloat16` | **false** |
| total steps planned | 10,320 |
| warmup steps | 516 |
| epochs requested | 40 |
| **epochs completed** | **14** |
Loss weights (`optimization.loss_weights`, and `training/change_vqa/model.py`):
| Term | Weight |
|---|---|
| answer (CE) | 1.0 |
| delta (MSE) | 1.0 |
| magnitude (BCE) | 1.0 |
| total_changed (BCE) | **0.5** |
### 8.6 Selection
`run_record.json` → `selection`:
| Field | Value |
|---|---|
| metric | **Val answer accuracy** |
| **best epoch** | **8** |
| **best accuracy** | **0.700018** |
| patience | 6 |
| `min_delta` | 0.0001 |
| **`stop_reason`** | **`early_stopping`** |
The trainer's `FORBIDDEN_SPLITS` are `("Test", "Test2")` and the training split is `Train` with `Val`
as the selection split (`training/change_vqa/train.py`). `model_metadata.json` records
`test_splits_used: false`, `detector_trained: true`, `seed: 42`, `epoch: 8`.
### 8.7 The state vocabulary — `TRAINED_UNVERIFIED`
`run_record.json` → `state` is **`"TRAINED_UNVERIFIED"`**, with the note:
> *"training produces an artifact, not a verified capability. R-02 reaches VERIFIED only after the
> returned checkpoint has been evaluated on the held-out split."*
`confidence.method` is `"uncalibrated"` — raw softmax plus a top1−top2 margin; **nothing is fitted**.
The post-training state constant is `POST_TRAINING_STATE = "TRAINED_UNVERIFIED"`
(`training/change_vqa/train.py`).
### 8.8 The byte-identity promotion gate
The returned checkpoint was **promoted** through a byte-identity gate. `artifacts/change_vqa/run/
PROMOTION.json` (schema `change_vqa_promotion_v1`, promoted 2026-09-22):
| Property | Value | Key path |
|---|---|---|
| sha256 | `cfae5e43b97ca930f568dc5b8ae4f36b24e9ff717af226159802206ffd63a82a` | `artifact.sha256` |
| bytes | 5,822,809 | `artifact.bytes` |
| architecture | `change_vqa_head_v1` | `artifact.architecture` |
| parameters | 1,453,912 | `artifact.parameters` |
| non-finite tensors | **0** | `artifact.non_finite_tensors` |
| weights modified during promotion | **false** | `artifact.weights_modified` |
| byte-identical to source | **true** | `source.byte_identical_to_source` |
| hash agrees across | `model_metadata.json`, `run_record.json`, `hashes.json` | `source.hash_agrees_across` |
| config hash | `78f1e3700da15aa1` | — |
| epoch selected | 8 | — |
| val answer accuracy | 0.700018 | — |
| `stop_reason` | early_stopping | — |
| frozen dependency | `artifacts/change/levir_change_v001/head.pt`, sha256 `c5ef3127…e9fa` | — |
| state before | `TRAINED_UNVERIFIED` | — |
| state after | **`PROMOTED`** | — |
The promotion verification also records 93 passed / 0 failed / 0 skipped, and the two test-set scores:
| Split | accuracy | macro-F1 | n scored | baseline (global majority) |
|---|---|---|---|---|
| Test | 0.697626367 | 0.378373275 | 39,686 | 0.311546 |
| Test2 | 0.651469262 | 0.372308516 | 31,036 | 0.178728 |
with `metric_ruling: OPEN`.
> ### What the promotion gate does — and does not — establish.
>
> **It establishes** that the returned bytes are the bytes that were trained, unmodified. It is a
> **byte-identity** gate: `weights_modified: false`, `byte_identical_to_source: true`, zero non-finite
> tensors, and the digest agreeing across three independently-written records.
>
> **It does not confer `VERIFIED`.** `PROMOTION.json` records `state after: PROMOTED`, and the metric
> ruling is `OPEN`. Promotion is an *acceptance of the artifact*, not a *validation of the capability*.
> The change-VQA head is quoted under **two test sets** (Test 0.697626/0.378373 and Test2
> 0.651469/0.372309) and its ruling is `OPEN`; never quote one test set alone
> ([`BENCHMARKS.md`](BENCHMARKS.md) §4.4).
### 8.9 The two AMP/loss defects and their fixes
The external run history is instructive and is recorded rather than hidden
(`PRE_KAGGLE_READINESS_REPORT.md` §1b; `R02_CHANGE_VQA_IMPLEMENTATION_STATUS.md` §4):
| # | Defect | Why it blocked the run | Fix |
|---|---|---|---|
| **K1** | `F.binary_cross_entropy` on **sigmoid probabilities** under CUDA autocast | `aten::binary_cross_entropy` is registered as an outright **ERROR** under CUDA autocast → `RuntimeError: … unsafe to autocast` at step 1 on a T4 | a helper that casts both operands to fp32 **and** invokes the op with autocast disabled (casting alone is not sufficient) |
| **K3** | **Saturated-sigmoid BCE → NaN loss** | `BCE(sigmoid(z))`'s backward at `p = 1.0` is clamped to a **finite** `−9.99999996e11`, so `GradScaler` cannot flag it; under fp16 the cast node turns it into `−inf`, the sigmoid backward multiplies by `0`, and `inf × 0 = nan`. The probability form is also **wrong** before it is unstable: PyTorch clamps BCE at 100.0 per element, so a saturated element reports 65.0 where the truth is 30.0 | the loss is computed from the **pre-sigmoid logits** with `binary_cross_entropy_with_logits` — same function mathematically, exact numerically |
The K3 CPU reproduction (`PRE_KAGGLE_READINESS_REPORT.md` §1b):
| Quantity | Pre-fix | Fixed |
|---|---|---|
| parameter tensors with non-finite gradients | **4 / 20** | **0 / 20** |
| total loss at the saturated element | `133.53` (clamped) | `472.16` (exact) |
| gradient w.r.t. the logit | `nan` | `-1.0` |
| single saturated element, `z = +30, t = 0` | `100.0` forward (clamped) | `30.0` forward (exact) |
**`--no-amp` is not the fix** — it would have hidden the NaN while leaving the loss silently clamped and
wrong. The published contract is unchanged: `class_mag` is still `sigmoid(mag_raw)`, `class_delta` still
`tanh`, `total_changed` still `sigmoid`; the logits are an **addition** used only by the loss.
### 8.10 The serving wiring
`app/serving.py` wires `change_vqa` through the registry `builders=` override with **the same**
`CHANGE_CHECKPOINT` the change detector receives, plus `CHANGE_VQA_HEAD =
artifacts/change_vqa/run/head.pt`. Absent artifacts are passed as `None`, never as a fabricated path —
the builder's contract is that missing **degrades** while **corrupt** raises `ModelLoadError`.
`configs/base.yaml` is byte-identical (`88434f7f…`) and `Config.hash` is still `78f1e3700da15aa1`
(finding F2, `R02_CHANGE_VQA_IMPLEMENTATION_STATUS.md` §5).
### 8.11 The 3-hour budget contract
`HARD_STOP_SECONDS = 3 * 3600 = 10800` (plan §46). The budget is checked **before every batch**; on
exhaustion the run saves `checkpoint_last.pt`, flushes the log, and exits with a structured
`time_limit_reached` reason rather than dying silently. The recorded run used **64.278 s** of a
10,800 s budget (`run_record.json` → `budget`). If `epochs_completed` is 0, the time limit is shorter
than one epoch — raise it (`RUNBOOK_CHANGE_VQA_KAGGLE.md` §8).
---
## 9. VLM LoRA adapter — external GPU, PEFT
### 9.1 What it is
A PEFT LoRA adapter on the **frozen** `HuggingFaceTB/SmolVLM-500M-Instruct` (revision
`a7da5b986cb5`, Apache-2.0). It is attached to the model's **language-model projections only**; the
vision tower and the connector are frozen.
### 9.2 Hyperparameters — every value
From `configs/base.yaml` §`training`, corroborated by
`.scratch/phase6_real_adapter/phase6_adapter/run_manifest.json` → `config` and `lora`:
| Hyperparameter | Value | Source |
|---|---|---|
| base model | `HuggingFaceTB/SmolVLM-500M-Instruct` | `run_manifest.json` → `config.base_model` |
| revision | `a7da5b986cb5` | `config.revision` |
| PEFT version | **0.19.1** | `run_manifest.json` → `environment.peft`; `adapter_config.json` |
| `lora_rank` (`r`) | **16** | `config.lora_rank` |
| `lora_alpha` | **32** | `config.lora_alpha` |
| `lora_dropout` | **0.05** | `config.lora_dropout` |
| target modules | `[q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj]` | `config.lora_target_modules` |
| `n_target_modules` | **224** | `lora.n_target_modules` |
| `all_trainable_in_text_model` | **true** | `lora.all_trainable_in_text_model` |
| precision | **fp16** | `config.precision` |
| batch size | 2 | `config.batch_size` |
| gradient accumulation | 8 | `config.gradient_accumulation` |
| effective batch size | **16** | `config.effective_batch_size` |
| learning rate | 0.0002 | `config.learning_rate` |
| epochs | 1 | `config.epochs` |
| weight decay | 0.01 | `config.weight_decay` |
| warmup ratio | 0.05 | `config.warmup_ratio` |
| gradient checkpointing | **true** | `config.gradient_checkpointing` |
| `max_seq_length` | 512 | `config.max_seq_length` |
| `processor_longest_edge` | **512** | `config.processor_longest_edge` |
| `do_image_splitting` | true | `config.do_image_splitting` |
| seed | 42 | `config.seed` |
| `save_every_steps` | 500 | `config.save_every_steps` |
| `max_wall_seconds` | 27,000 | `config.max_wall_seconds` |
| train / val ratio | 0.8 / 0.1 | `config.train_ratio`, `config.val_ratio` |
| `negative_ratio` | 1.0 | `config.negative_ratio` |
| `rgb_percentiles` | `[2.0, 98.0]` | `config.rgb_percentiles` |
### 9.3 The target-module regex — and the vision-tower hazard
`training/vlm/lora.py` defines:
```
LANGUAGE_MODEL_TARGET_REGEX = r"^model\.text_model\..*\.(q_proj|k_proj|v_proj|o_proj|gate_proj|up_proj|down_proj)$"
```
The anchoring is load-bearing. The module docstring records the hazard: the **vision tower's** attention
projections are *also* named `q_proj`/`k_proj`/`v_proj`, so a plain module-name **list** (or an
unanchored regex) would silently attach LoRA deltas to the vision tower as well. The anchored regex
scopes the adapter to `model.text_model.*`. The run manifest confirms the outcome:
`all_trainable_in_text_model: true`, `observed_prefixes: ["base_model.model."]`, and all 224 target
module names begin `model.text_model.layers.<n>.…` (`run_manifest.json` → `lora.target_module_names`).
`training/vlm/config.py` also carries `LORA_TARGET_MODULES` (the seven names) as the declarative list.
### 9.4 fp16 because a T4 is SM 7.5 — a recorded plan deviation
The master plan's §43 specifies **bf16** for this stage. The run uses **fp16**, and the deviation is
recorded so it is auditable — `run_manifest.json` → `config.plan_deviations`:
> *"precision: plan section 43 specifies bf16; fp16 is used because T4 is SM 7.5 and has no bf16 tensor
> cores (finding C-6). Recorded so the deviation is auditable."*
This is finding **C-6**, frozen in `docs/ARCHITECTURE_FREEZE.md` §4 (`training.precision: fp16`,
reason "T4 = SM 7.5, no bf16 tensor cores"). `training/vlm/trainer.py` **refuses** bf16 outright.
### 9.5 The corpus
The VLM instruction pairs come from a **BigEarthNet-S2 single-label** subset
(`run_manifest.json` → `config.corpus_root`:
`/kaggle/input/datasets/creatorballs/bigearth-net-s2-single-label/BigEarthNet-S2`), **not** the reBEN v2
fusion corpus. This matters and is stated explicitly.
| Property | Value | Key path |
|---|---|---|
| instruction families | `["presence"]` | `corpus.families` |
| n samples (questions) | 49,464 | `corpus.n_samples` |
| n requested patches | 28,000 | `corpus.coverage.n_requested` |
| n matched | **24,732** | `corpus.coverage.n_matched` |
| **coverage fraction** | **0.883286** | `corpus.coverage.coverage_fraction` |
| n unmatched | **3,268** | `corpus.coverage.n_unmatched` |
| n single-label | 24,732 | `corpus.coverage.n_single_label` |
| **single-label fraction** | **1.0** | `corpus.coverage.single_label_fraction` |
| n multi-label | **0** | `corpus.coverage.n_multi_label` |
| scene blocks (train / val / test) | 1,160 / 482 / 202 | `corpus.scene_counts` |
| patches (train / val / test) | 17,471 / 3,375 / 3,886 | `corpus.split_info.patches_by_split` |
| samples (train / val / test) | 34,942 / 6,750 / 7,772 | `corpus.split_counts` |
| scene key | `ben_<tile>:<k>` | `corpus.split_info.scene_key` |
| render | RGB `[B04, B03, B02]`, per-band percentile stretch 2/98, uint8 | `corpus.render` |
The corpus carries **two warnings verbatim** (`corpus.warnings`):
> *"3268 of 28000 patches have no row in the manifest and carry NO labels; they are excluded rather than
> guessed (88.3% coverage)"*
>
> *"every one of the 24732 matched patches is single-label; the BigEarthNet corpus at large averages
> ~2.95 labels per patch (max 11), so this subset cannot support multi-label enumeration questions"*
The split policy is `release_partition_keyed_by_T2_blocks` with a leakage check calling
`evaluation.leakage.assert_no_scene_overlap` (`corpus.split_info`). The block reconstruction is
conservative by construction: *"blocks are reconstructed from this corpus's patches, not the full
release; that can only split a block further, never merge across a partition."* The render note records
that the stretch is **per-patch, not corpus-global**, so inference needs no training-set statistics.
### 9.6 Training state
`run_manifest.json` → `training_state`:
| Field | Value |
|---|---|
| `epochs_completed` | **1** |
| `best_val_loss` | 0.10925133040291257 |
| checkpoints | `checkpoint-500`, `checkpoint-1000`, `checkpoint-1500`, `checkpoint-2000` |
| `learning_rates` | a warmup ramp from ~3.67e-6 upward |
The evaluation budget (`evaluation_budget`) is 1,000 per split, drawn once and reused for baseline and
adapted (contract item U): `selected_per_split: {test: 1000, val: 1000}`,
`truncated: {adapted_test: false, adapted_val: false, baseline_test: false, baseline_val: false}`.
### 9.7 Metrics and the acceptance decision
`run_manifest.json` → `metrics` (adapted, val, n = 1000): `exact_match: 0.911`, `f1: 0.911794`,
`precision: 0.907298`, `recall: 0.916335`, confusion `tp 460 / fp 47 / tn 451 / fn 42`. The baseline
(`baseline`, n = 1000): `exact_match: 0.491`, `f1: 0.099115`, confusion `tp 28 / fp 35 / tn 463 / fn 474`.
The **decision** (`run_manifest.json` → `decision`) is **`REJECTED`**:
- V1 passed: *"val baseline=49.10 pp, adapted=91.10 pp, delta=+42.00 pp; required (V1) >= +5.00 pp"*.
- V2 failed: *"V2 failed: 3 class(es) dropped more than 1.0 pp on validation"* — the three classes are
`Mixed forest` (−6.4516 pp), `Transitional woodland, shrub` (−9.375 pp) and `Agro-forestry areas`
(−4.6512 pp), each listed in `decision.class_failures`.
> **`USABLE ≠ ACCEPTED`.** The adapter's metrics are usable — a later test adjudication records
> `exact_match 0.963`, `f1 0.96432`, baseline test 0.468 (`artifacts/vlm/run1_test_recovery/
> test_adjudication.json`) — but the artifact is **`ACCEPTANCE-REJECTED`** for promotion, and the
> deployed caption/VQA path uses the **unadapted** model. The rejection is preserved in the record
> (`artifacts/vlm/phase6_closure.json`), not scrubbed. See [`MODELS.md`](MODELS.md) §3.6 and
> [`EVALUATION.md`](EVALUATION.md) §4.5.
### 9.8 Finding F5-2 — the processor cost
The processor's default `longest_edge` is 2048, which upscales 512-px tiles 4× and then splits them into
**17 sub-images** (`pixel_values (1, 17, 3, 512, 512)`, 1,142 prompt tokens). Pinning
`processor_longest_edge: 512` yields `pixel_values (1, 1, 3, 512, 512)`. The plan estimated a **4×** cost
overrun; the **measured** figure is **~17×**. This is finding **F5-2**, frozen in
`docs/ARCHITECTURE_FREEZE.md` §4 ("Tiles must not be upscaled or split").
### 9.9 Finding F5-3 — the image token
SmolVLM requires one `<image>` token per image in the prompt; hand-written prompt strings raise
`ValueError`. Prompts are therefore always built through `processor.apply_chat_template()`. This is
finding **F5-3** and is enforced in `specialists/vqa/prompts.py`.
### 9.10 The adapter digests
| Item | Value | Source |
|---|---|---|
| adapter dir tree hash | `5c6b86317d1e65962702dc9e377009b3df41cc13de1b15bceccb70ad977775e7` | `run_manifest.json` → `adapter_sha256`; `artifacts/vlm/run1_test_recovery/adapter_verification.json` |
| `adapter_model.safetensors` sha256 | `07c76a75fa04624880ed7730590f5fdd7b145a8232e3c0af411c3c545a5adf5e` | `.scratch/phase6_real_adapter/phase6_adapter/ARTIFACT_SHA256SUMS.json` |
| manifest check | `clean: true`, 14 files in manifest / 14 on disk, `mismatched_files: []` | same |
| `checkpoint-1500` | `7273588e…` | `ARTIFACT_SHA256SUMS.json` |
| `checkpoint-2000` | `bf249943…` | `ARTIFACT_SHA256SUMS.json` |
> **The promoted adapter is the end-of-training top-level save, not `checkpoint-2000`.** Three distinct
> digests exist; conflating them is a real error. See [`MODELS.md`](MODELS.md) §1.2 and §3.6.
| Component | Parameters | Source |
|---|---|---|
| trainable (LoRA) | **8,683,520** (1.682312 % of total) | `run_manifest.json` → `trainable_params`, `trainable_fraction` |
| frozen `model.text_model` | 361,944,000 | `frozen_params` |
| frozen `model.vision_model` | 86,433,024 | `frozen_params` |
| frozen `model.connector` | 11,796,480 | `frozen_params` |
| frozen `other` | 47,308,800 | `frozen_params` |
| frozen total | 507,482,304 | sum of the four above |
| base model total | 516,165,824 | `docs/MODELS.md` §2.2 |
---
## 10. Calibration — fitted, not trained
Calibration is a **fitted** post-hoc step, not a trained artifact, and it is included here because it is
the one place a temperature is set.
`artifacts/calibration_v001.json` (schema `calibration_v1`):
| Field | Value | Key path |
|---|---|---|
| specialist | `change_vqa` | `specialist` |
| **temperature** | **0.9772731820958189** | `temperature_scaling.temperature` |
| fitted on | `Val`, n = 16,441 | `provenance.fitted_on`, `provenance.n_samples` |
| checkpoint sha256 | `cfae5e43…` | `provenance.checkpoint_sha256` |
| **ECE before** | **0.013755** | `metrics.ece_before` |
| **ECE after** | **0.014929** | `metrics.ece_after` |
| improvement | **−0.001174** | — |
| NLL before / after | 0.689741 / 0.689631 | — |
| `type_mask_applied` | false | — |
> ### **The temperature made calibration WORSE.**
>
> ECE went **0.013755 → 0.014929 — worse**. It is retained only because it is in the frozen config, and
> this is a **measured negative result**, not an improvement. `docs/` records it as such
> ([`EVALUATION.md`](EVALUATION.md) §4.7, [`BENCHMARKS.md`](BENCHMARKS.md) §4.7). Nothing else is fitted:
> the change-VQA head ships `confidence.method: "uncalibrated"`.
---
## 11. The reproducibility contract for training
1. **Seed 42** everywhere (`project.seed`), recorded in every run record's
`reproducibility.seed`. The change-VQA record additionally documents
`torch_seeded: true`, `numpy_seeded: true`, `python_random_seeded: true`,
`torch_cuda_manual_seed_all: true`, `cudnn_deterministic: true`, `cudnn_benchmark: false`, and
`deterministic_algorithms_enabled: false` with its reason (*"not enabled: it raises on operators
lacking a deterministic implementation, which would abort the run rather than make it
reproducible"*).
2. **Precision `fp16`** on CUDA, never bf16 (§2.2). CPU runs are fp32 with autocast a no-op.
3. **Every artifact records the frozen config hash** `78f1e3700da15aa1`; a config edit moves the hash
and invalidates the artifact (§1.3).
4. **`save_every_steps: 500`.** Checkpoints are archived as provenance, **not released** as model
weights ([`MODELS.md`](MODELS.md) §1.2).
5. **Training guides state their own entry status** and **never** claim a trained artifact is a verified
capability (`docs/R02_KAGGLE_TRAINING_GUIDE.md`).
6. **Splits are by scene or group**, never by sample (§4.4; [`DATASETS.md`](DATASETS.md) §7).
7. **The public test split is immutable** and off-limits to training code; hidden data is never accessed
(`evaluation.immutable_public_test: true`, `evaluation.hidden_data_access: false`).
8. **No one-command retrain for the external artifacts.** The repository ships the *contract*, the
*promotion gate*, the *evaluation path* and the *serving wiring* for them — not a retraining harness
(`docs/REPRODUCIBILITY.md` §8.5).
---
## 12. What was NOT trained — exhaustive
| Item | State | Evidence |
|---|---|---|
| **Backbone fine-tuning (any)** | **NOT DONE** — all backbones frozen | `docs/ARCHITECTURE_FREEZE.md` §2, §5 |
| **Router on the test split** | **NOT RUN** — `n_test_examples_scored: 0`, `test_split_touched: false` | `artifacts/router/threshold_sweep_val.json` |
| **Any end-to-end / joint training** | **NOT RUN** — no system-level training exists | `docs/ARCHITECTURE_FREEZE.md` §5 |
| **Change head at a second resolution** | **NOT RUN** — 448 was REJECTED for grounding, not retrained for change | §5.7 |
| **Change head at a longer cosine schedule (T_max = 60)** | **NOT RUN** — P2 in `docs/CHANGE_TRAINING_EXPERIMENT_PLAN.md`, "PREPARED, NOT EXECUTED" | same |
| **Change-head threshold retune** | **CLOSED / eliminated** — the lever is 0.37× the epoch noise | `docs/PHASE9_FREEZE.md` §5 |
| **Benchmark adapters (trained)** | **NOT RUN** — adapters are evaluation code, not trained | `evaluation/benchmark_adapters/` |
| **Arm B (optical-SAR) training beyond the five seeds** | **NOT RUN** — the comparison concluded; Arm A retained | `docs/PHASE14_GATE_F_DECISION_RECORD_2026-09-20.md` §7 |
| **The §4-registered optical-SAR arm B** | **NON-CONSTRUCTIBLE** — never built, never run | same, §2 (D-01) |
| **Arm C (optical-SAR)** | **NOT INTRODUCED** — the arm set is frozen at two | same, §2 (D-01) |
| **Change-VQA head retrained after promotion** | **NOT RUN** — one external run; the returned checkpoint was promoted, not retrained | `artifacts/change_vqa/run/PROMOTION.json` |
| **VLM adapter promoted** | **REJECTED** — trained, but `ACCEPTANCE-REJECTED`; deployed path uses the unadapted model | `artifacts/vlm/phase6_closure.json`; `run_manifest.json` → `decision.status: REJECTED` |
| **VLM adapter on a second instruction family** | **NOT RUN** — `instruction_families: ["presence"]` only | `run_manifest.json` → `corpus.families` |
| **Any multi-label VLM training** | **NOT RUN** — the subset is 100 % single-label | `run_manifest.json` → `corpus.warnings` |
| **Calibration re-fit after the negative result** | **NOT RUN** — the temperature is retained only because it is in the frozen config | §10 |
| **Router retrain with the test split scored** | **NOT RUN** | §4.7 |
| **Grounding head at 448** | **REJECTED by measurement**, not retrained | §5.7 |
| **BigEarthNet multi-label (reBEN) fusion training** | **NOT RUN** — the local subset is single-label; metrics are not comparable to published numbers | [`DATASETS.md`](DATASETS.md) §6.5 |
| **Cross-dataset generalisation training** | **NOT RUN** | [`DATASETS.md`](DATASETS.md) §8 |
| **Distributed / multi-node training** | **NOT DONE** — modular monolith, one process | `docs/ARCHITECTURE_FREEZE.md` §5 |
| **Kubernetes / queues / microservices around training** | **NOT DONE** | `R02_CHANGE_VQA_IMPLEMENTATION_STATUS.md` §6 ("No overbuild") |
### 12.1 Things that trained but whose *quality* is not established
| Artifact | Trained? | Quality established? |
|---|---|---|
| `change` head | yes (external GPU) | **yes** — the only `VERIFIED` headline (pooled IoU 0.8122) |
| `change_vqa` head | yes (external GPU) | metrics measured; **ruling `OPEN`**; promotion is byte-identity only |
| `optical_sar` head | yes (local CPU, 10 runs) | measured (accuracy 0.931 / macro-F1 0.434161); **ruling `OPEN`** |
| `router` adapter | yes (local CPU) | val-only, ungated, n = 86; **test NOT RUN** |
| `grounding` head | yes (local CPU) | **did not beat the zero-shot baseline** on val IoU (0.0946 < 0.0972) |
| `vlm` LoRA adapter | yes (external GPU) | metrics usable; **`ACCEPTANCE-REJECTED`** |
---
## 13. What is NOT RUN / OPEN / BLOCKED for this topic
**NOT RUN**
- The router **test split** (`n_test_examples_scored: 0`).
- Any **end-to-end / joint training**; no system-level training exists.
- The change head's **P2** longer-cosine variant (`docs/CHANGE_TRAINING_EXPERIMENT_PLAN.md`,
"PREPARED, NOT EXECUTED").
- The change head at a **second resolution**.
- Any **retrain** of the external artifacts after their runs.
- **Multi-label** BigEarthNet training (local subset is single-label).
- **Cross-dataset generalisation** training.
**OPEN**
- The **change-VQA** metric ruling (`PROMOTION.json` → `metric_ruling: OPEN`).
- The **optical-SAR** ruling (`pre_registered_115_metric.json` → `is_deciding_statistic: false`;
the artifact's own `advisory` says the ruling is the owner's).
- **R-03** — calibration: nothing is fitted beyond the (negative-result) temperature; the change-VQA
head ships `method: "uncalibrated"`.
- **R-08** — the stale `PLUMBING_ONLY` `result_status` label on the optical-SAR run records.
- The **change registration gate** (`docs/PHASE9_FREEZE.md` §7) — 58.7 % false-positive rate on the
serving path.
- **R-01** — the BigEarthNet → SmolVLM LoRA adaptation gap (the adapter was rejected).
- **F5** — the A–R test-area lettering is a reconstruction (documentation caveat;
`R02_CHANGE_VQA_IMPLEMENTATION_STATUS.md` §8).
**BLOCKED**
- Nothing blocks the external training runs. `PRE_KAGGLE_READINESS_REPORT.md` §11 lists six unresolved
items, and explicitly states: *"None block the external run."*
- The one *recorded* external blocker that **was** crossed: the optical-SAR Arm-B feature cache did not
exist and required a ~2.7 h CPU extraction (`docs/PHASE14_GATE_F_DECISION_RECORD_2026-09-20.md` §3).
It was completed (`armB_seed{100..104}` run records exist) and the comparison concluded.
---
## 14. Where the evidence lives
| Topic | Artifact(s) |
|---|---|
| Router training + sweep | `artifacts/router/router_adapter_v001/metadata.json`, `artifacts/router/threshold_sweep_val.json`, `router/adapter.py`, `router/dataset.py`, `docs/PHASE4_ROUTER_REPORT.md` |
| Grounding training | `artifacts/grounding/remoteclip_grounding_v001/{run_record,training_metadata}.json`, `artifacts/grounding/remoteclip_grounding_v001/eval_result_{canonical,matched6}.json`, `scripts/train_grounding.py`, `training/grounding/`, `docs/PHASE7_RESOLUTION_DECISION.md`, `docs/PHASE8_GROUNDING_HEAD_DECISION.md`, `docs/PHASE8_HANDOFF.md` |
| Change training | `artifacts/change/levir_change_v001/{run_record,training_metadata,model_metadata}.json`, `artifacts/change/eval_test/eval_result.json`, `artifacts/change/levir_real_data_verification.json`, `artifacts/change/threshold_sweep_val.json`, `docs/PHASE9_GPU_HANDOFF.md`, `docs/PHASE9_GPU_RUN_RESULTS.md`, `docs/PHASE9_REAL_DATA_VERIFICATION.md`, `docs/PHASE9_FREEZE.md`, `docs/CHANGE_TRAINING_EXPERIMENT_PLAN.md` |
| Optical-SAR sweep + production head | `artifacts/optical_sar/fusion_head_v001/arm{A,B}_seed{100..104}/`, `arm{A,B}_seed_variance_report.json`, `artifacts/optical_sar/fusion_head_production_v001/{production_head_record,pre_registered_115_metric,phase12_rerun_verification}.json`, `training/fusion/train.py`, `docs/PHASE14_OPTICAL_SAR_DECISIONS.md`, `docs/PHASE14_GATE_F_DECISION_RECORD_2026-09-20.md`, `docs/PHASE12_115_METRIC_COMPUTED.md`, `docs/PHASE12_LABEL_POLICY_DECISION.md` |
| Change-VQA external run | `artifacts/change_vqa/run/{PROMOTION,run_record,model_metadata,hashes}.json`, `docs/R02_KAGGLE_TRAINING_GUIDE.md`, `RUNBOOK_CHANGE_VQA_KAGGLE.md`, `R02_CHANGE_VQA_IMPLEMENTATION_STATUS.md`, `PRE_KAGGLE_READINESS_REPORT.md`, `training/change_vqa/` |
| VLM LoRA | `.scratch/phase6_real_adapter/phase6_adapter/{run_manifest.json,adapter_config.json,ARTIFACT_SHA256SUMS.json}`, `artifacts/vlm/run1_test_recovery/`, `artifacts/vlm/phase6_closure.json`, `RUNBOOK_PHASE6_VLM_KAGGLE.md`, `training/vlm/` |
| Calibration | `artifacts/calibration_v001.json` |
| Frozen configuration | `configs/base.yaml`, `docs/ARCHITECTURE_FREEZE.md`, `docs/PHASE9_FREEZE.md`, `docs/REPRODUCIBILITY.md` §2 |
| Leakage / firewall | `evaluation/leakage.py`, `evaluation/manifest_freeze.json` — see [`DATASETS.md`](DATASETS.md) §7 |
**Sibling documents:** [`MODELS.md`](MODELS.md) (released artifacts), [`EVALUATION.md`](EVALUATION.md)
(protocols and honesty rules), [`BENCHMARKS.md`](BENCHMARKS.md) (the numbers),
[`REPRODUCIBILITY.md`](REPRODUCIBILITY.md) (how to reproduce), [`DATASETS.md`](DATASETS.md) (the
corpora), [`LIMITATIONS.md`](LIMITATIONS.md) (the honest counterweight).