Image-to-Text
PyTorch
Safetensors
PEFT
English
remote-sensing
satellite-imagery
earth-observation
change-detection
visual-grounding
image-captioning
visual-question-answering
optical-sar-fusion
sar
multimodal
lora
Instructions to use thundercode/SatQuery with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use thundercode/SatQuery with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
| # Models β 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 a router adapter, one is a LoRA | |
| adapter. Every backbone is **frozen** and publicly pinned by revision in `configs/base.yaml` β the | |
| project trains small modules on top of frozen encoders, not end-to-end networks. No backbone is | |
| fine-tuned; no backbone weight is redistributed. | |
| > **This file is the human-readable companion to the machine-generated | |
| > [`../models/manifest.json`](../models/manifest.json) and | |
| > [`../models/checksums.sha256`](../models/checksums.sha256).** | |
| > Where the two disagree, **the generated manifest wins** β it is computed by reading the files | |
| > (`release/tools/generate_model_manifest.py`), this document is written by hand. | |
| **The single most important rule in this document: do not fabricate.** Every number below is copied | |
| from a file that was read, and every number names the file and (where the source is a JSON artifact) | |
| the exact key path. Where a fact is not established, this document says so explicitly rather than | |
| estimating. | |
| --- | |
| ## Table of contents | |
| 1. [The six trained artifacts](#1-the-six-trained-artifacts) | |
| 2. [Frozen backbones β pinned, never retrained](#2-frozen-backbones--pinned-never-retrained) | |
| 3. [Per-artifact deep reference](#3-per-artifact-deep-reference) | |
| - 3.0 [Enforced configuration invariants, with arithmetic](#30-enforced-configuration-invariants-with-arithmetic) | |
| - 3.1 [`change` β STANet-style Siamese change detector](#31-change--stanet-style-siamese-change-detector) | |
| - 3.2 [`change_vqa` β change question answering head](#32-change_vqa--change-question-answering-head) | |
| - 3.3 [`optical_sar` β CROMA-base fusion head](#33-optical_sar--croma-base-fusion-head) | |
| - 3.4 [`grounding` β RemoteCLIP grounding head](#34-grounding--remoteclip-grounding-head) | |
| - 3.5 [`router` β intent adapter over frozen MiniLM](#35-router--intent-adapter-over-frozen-minilm) | |
| - 3.6 [`vlm` β SmolVLM LoRA adapter (USABLE_VERIFIED, ACCEPTANCE-REJECTED)](#36-vlm--smolvlm-lora-adapter-usable_verified-acceptance-rejected) | |
| 4. [Rejected, deferred and open model decisions](#4-rejected-deferred-and-open-model-decisions) | |
| 5. [Calibration β a measured negative result](#5-calibration--a-measured-negative-result) | |
| 6. [Distribution and licensing](#6-distribution-and-licensing) | |
| 7. [Status summary β what is NOT established](#7-status-summary--what-is-not-established) | |
| 8. [Evidence index](#8-evidence-index) | |
| --- | |
| ## 1. The six trained artifacts | |
| All six are published on the Hugging Face Hub under `thundercode/SatQuery`, one directory per task. | |
| The table below is reproduced from [`../models/manifest.json`](../models/manifest.json) | |
| (`artifacts[*]`), cross-checked against [`../models/checksums.sha256`](../models/checksums.sha256). | |
| | # | `id` | Task | Artifact path | Bytes | sha256 (full) | Kind | Backbone (frozen) | | |
| |---|---|---|---|---|---|---|---| | |
| | 1 | `change_head` | `change` | `artifacts/change/levir_change_v001/head.pt` | 63,231,009 | `c5ef31277b67aa01a593aec0eac503eeaccc6d674349fda20ca44c9cc6f8e9fa` | trained head | none external β ResNet-18 (torchvision ImageNet) + PAM, trained in-project | | |
| | 2 | `change_vqa_head` | `change_vqa` | `artifacts/change_vqa/run/head.pt` | 5,822,809 | `cfae5e43b97ca930f568dc5b8ae4f36b24e9ff717af226159802206ffd63a82a` | trained head | STANet change detector (frozen, backing the head's change features) | | |
| | 3 | `optical_sar_fusion_head` | `optical_sar` | `artifacts/optical_sar/fusion_head_production_v001/head.pt` | 14,427,457 | `785815729a3a39fc34dc41894efaf00d8739365d970a3f830a326e68ae888dab` | trained head (production) | `antofuller/CROMA` (`CROMA_base.pt`, rev `0dd28e3d633b`) | | |
| | 4 | `grounding_head` | `grounding` | `artifacts/grounding/remoteclip_grounding_v001/head.pt` | 12,639,041 | `93432f7034be91a8ffd9c1a84e3eeec00bed7832c043fe7f83d2be230284c6bb` | trained head | `chendelong/RemoteCLIP` (`RemoteCLIP-ViT-B-32.pt`, rev `bf1d8a3ccf2d`) | | |
| | 5 | `router_adapter` | `router` | `artifacts/router/router_adapter_v001/adapter.pt` | 211,961 | `8527c3ed28a293e13293d48601d48e3ceafa137b9acabddaf5de31a58a509b5c` | trained adapter | `sentence-transformers/all-MiniLM-L6-v2` (rev `1110a243fdf4`) | | |
| | 6 | `vlm_lora_adapter` | `vlm` | `.scratch/phase6_real_adapter/phase6_adapter/adapter_model.safetensors` | 34,798,048 | `07c76a75fa04624880ed7730590f5fdd7b145a8232e3c0af411c3c545a5adf5e` | **LoRA adapter** (PEFT) | `HuggingFaceTB/SmolVLM-500M-Instruct` (rev `a7da5b986cb5`) | | |
| Every artifact in the manifest carries `status: "PRESENT"` and `config_hash: "78f1e3700da15aa1"`. | |
| ### 1.1 What each `kind` means | |
| | `kind` | Meaning | How it is loaded | | |
| |---|---|---| | |
| | `trained_head` | A module trained in-project on top of a frozen encoder. The encoder is fetched separately. | `torch.load(...)` of a `state_dict`, or the module's own `load_*` function. | | |
| | `trained_adapter` | A small classifier attached to a frozen sentence encoder whose embeddings are cached. | `IntentAdapter.from_config_dict` + `load_state_dict`. | | |
| | `lora_adapter` | A PEFT LoRA delta attached to a frozen VLM at load time. | `peft.PeftModel.from_pretrained(model, dir)` (`specialists/vqa/model.py::_attach_adapter`). | | |
| ### 1.2 Training checkpoints are NOT released artifacts | |
| Training intermediates exist on the machines that trained β e.g. the grounding head's | |
| `checkpoint_last.pt` and the VLM adapter's `checkpoint-1500/`, `checkpoint-2000/`. These are | |
| **archived as provenance, not released as model weights** (`docs/TRAINING.md` Β§8). For the VLM | |
| adapter this matters concretely: the promoted adapter is the end-of-training **top-level** save, and | |
| it is **not** `checkpoint-2000` (three distinct digests; see Β§3.6). | |
| ### 1.3 Two cross-checks where the computed hash agrees with an independently-recorded value | |
| The manifest is generated by hashing the files on disk. For two artifacts, those computed digests can | |
| be compared against values that were recorded *independently*, at a different time, by a different | |
| process β which is a genuine external cross-check rather than a self-consistency claim. | |
| **Cross-check A β `change_vqa` = `cfae5e43β¦`.** The manifest's `sha256` for `change_vqa/head.pt` | |
| equals, exactly: | |
| - `artifacts/change_vqa/run/PROMOTION.json` β `artifact.sha256`; | |
| - the same file β `source.checkpoint_sha256_in_run_record` (the digest recorded in the Kaggle run | |
| record, *before* promotion); | |
| - `artifacts/calibration_v001.json` β `provenance.checkpoint_sha256` (recorded when the temperature | |
| was fitted, a separate step). | |
| `PROMOTION.json` β `source.hash_agrees_across` additionally records that the digest agrees across | |
| `model_metadata.json`, `run_record.json` and `hashes.json (run.checkpoint_sha256)`. The bytes were | |
| copied byte-identically (`source.byte_identical_to_source: true`) and | |
| `artifact.weights_modified: false`. | |
| **Cross-check B β `vlm` = `07c76a75β¦`.** The manifest's `sha256` for | |
| `vlm/adapter_model.safetensors` equals, exactly: | |
| - `artifacts/vlm/phase6_closure.json` β `production_adapter`β¦ (via | |
| `artifacts/vlm/run1_test_recovery/adapter_verification.json`) β | |
| `weights_file_sha256` = `07c76a75fa04624880ed7730590f5fdd7b145a8232e3c0af411c3c545a5adf5e`; | |
| - the same file β `promoted_adapter.sha256`; | |
| - the adapter's own `ARTIFACT_SHA256SUMS.json`, against which the directory was verified | |
| (`manifest_check.clean: true`, `n_files_in_manifest: 14`, `n_files_on_disk: 14`, | |
| `mismatched_files: []`). | |
| Both cross-checks are recorded in [`../MODEL_CARD.md`](../MODEL_CARD.md) Β§1 as well. | |
| ### 1.4 Why `parameters` is `null` for most artifacts | |
| The manifest records `parameters: null` for four of the six artifacts. That is deliberate: the | |
| generator does not open the checkpoints (opening a `.pt` to count parameters would require the model | |
| code and torch, and would make the manifest's generation depend on the environment). Parameter counts | |
| that *were* measured elsewhere are recorded in this document with their source. Where a parameter | |
| count is not established, this document writes | |
| `UNKNOWN β not established from the available evidence` rather than deriving one from file bytes. | |
| --- | |
| ## 2. Frozen backbones β pinned, never retrained | |
| Backbones are resolved from the Hugging Face Hub on first use, pinned by revision. The revision pins | |
| are the load-bearing part: a moving `main` would make every benchmark number unreproducible. | |
| | Role | Repository | Revision | Size | Measured identity | Notes | | |
| |---|---|---|---|---|---| | |
| | Router encoder | `sentence-transformers/all-MiniLM-L6-v2` | `1110a243fdf4` | 90.9 MB | 22,713,216 params, 384-dim embeddings | tokenizer ceiling **256**; truncation set to **128** | | |
| | VLM | `HuggingFaceTB/SmolVLM-500M-Instruct` | `a7da5b986cb5` | ~1015 MB safetensors | 516,165,824 params (base) | processor `longest_edge` **must** be pinned (F5-2) | | |
| | Grounding | `chendelong/RemoteCLIP` (`RemoteCLIP-ViT-B-32.pt`) | `bf1d8a3ccf2d` | 605.2 MB | 151,277,313 params; width **768**, **projected** dim **512** | patch size 32; 7Γ7 tokens at 224 | | |
| | Optical-SAR | `antofuller/CROMA` (`CROMA_base.pt`) | `0dd28e3d633b` | 777.6 MB (777,563,846 bytes) | 194,365,440 params; `encoder_dim` 768 | `image_resolution` 120; asymmetric `s1_depth=6`, `s2_depth=12` | | |
| | Change encoder | β (torchvision) | β | β | ResNet-18, `IMAGENET1K_V1` | `pretrained_used: true` in the trained artifact | | |
| ### 2.1 Router encoder β `all-MiniLM-L6-v2` | |
| - Declared in `configs/base.yaml` under `router:` β `model`, `revision: 1110a243fdf4`, | |
| `max_length: 128`, `embedding_dim: 384`. | |
| - **Finding F4-1.** The MiniLM tokenizer's own ceiling is **256** (verified by probe). `128` is a | |
| deliberate truncation well inside that ceiling, not the model limit. The encoder **asserts the | |
| configured value is β€ 256**, because truncating above the ceiling is a silent no-op β a control that | |
| appears to work and does nothing. | |
| - **Finding F4-2.** The encoder is frozen, so embeddings are cached and the adapter trains on cached | |
| vectors. Measured on CPU: **20 epochs over 4,096 Γ 384 in 0.28 s**. No GPU required. | |
| - The revision is asserted by `router/encoder.py` (guards on revision and `max_length`). | |
| ### 2.2 VLM β `SmolVLM-500M-Instruct` | |
| - Declared under `vlm:` β `checkpoint`, `revision: a7da5b986cb5`, `max_new_tokens: 128`, | |
| `do_sample: false`, `temperature: 0.0`, `processor_longest_edge: 512`, `do_image_splitting: true`, | |
| `max_images_per_call: 1`. | |
| - **Finding F5-1.** `AutoModelForVision2Seq` does **not exist** in transformers 5.17.0 (it is not | |
| merely deprecated β referencing it raises `AttributeError`). The loader class is resolved by | |
| feature detection over `("AutoModelForImageTextToText", "AutoModelForVision2Seq", | |
| "AutoModelForMultimodalLM")`, never hardcoded (`specialists/vqa/model.py::resolve_loader_class`). | |
| - **Finding F5-2 (cost).** The processor's default `longest_edge` is 2048, which upscales a 512-px | |
| tile 4Γ and then splits it (`do_image_splitting=True`) into **17 sub-images** and 1142 prompt | |
| tokens. MEASURED: default β `pixel_values (1, 17, 3, 512, 512)`; pinned β `pixel_values | |
| (1, 1, 3, 512, 512)`. The plan estimated a 4Γ cost overrun; the real figure is **~17Γ**. The pin | |
| **must** be set explicitly on the processor at construction time, and `generate` raises loudly if | |
| more than one image is produced for a single input. | |
| - **Finding F5-3.** SmolVLM requires one `<image>` token per image in the prompt; hand-written prompt | |
| strings raise `ValueError`. All prompts are built through `processor.apply_chat_template()`. | |
| - **Finding F5-4.** The dtype kwarg is not discoverable by signature (`from_pretrained` is | |
| `**kwargs`-only). Resolved by a call-time fallback over `("dtype", "torch_dtype")`. | |
| ### 2.3 Grounding β `RemoteCLIP ViT-B/32` | |
| - Declared under `grounding:` β `checkpoint_repo`, `checkpoint_file: RemoteCLIP-ViT-B-32.pt`, | |
| `checkpoint_revision: bf1d8a3ccf2d`, `model_name: ViT-B-32`, `image_size: 224`, | |
| `resolution_frozen: true`, `nms_iou: 0.50`, `max_candidates: 20`, `confidence_threshold: 0.40`, | |
| `benchmark_box_scale: 100.0`, `coordinate_system: normalized_0_1`, | |
| `encoder_projected_dim: 512`. | |
| - **Measured, not assumed** (`specialists/grounding/remoteclip.py`): `VERIFIED_PATCH_SIZE = 32`, | |
| `VERIFIED_TRANSFORMER_WIDTH = 768`, `VERIFIED_PROJECTED_DIM = 512`, | |
| `VERIFIED_PARAMETERS = 151_277_313`. `_verify_contract()` asserts all four against the loaded model | |
| at load time. | |
| - **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 (512). Using 768 anywhere here is a shape error torch would surface only at the | |
| similarity step β after the patch features have already been computed and cached. | |
| - Tokens: 7Γ7 = 49 (+1 CLS) at 224; 14Γ14 = 196 (+1 CLS) at 448. `SUPPORTED_RESOLUTIONS = (224, 448)`; | |
| which one to *use* was an empirical question answered by the resolution experiment (Β§4.1), and | |
| neither is marked "preferred" in the encoder. | |
| ### 2.4 Optical-SAR β `CROMA-base` | |
| - Declared under `croma:` β `checkpoint_repo`, `checkpoint_file: CROMA_base.pt`, | |
| `checkpoint_revision: 0dd28e3d633b`, `variant: base`, `image_resolution: 120`, `encoder_dim: 768`, | |
| `optical_channels: 12`, `sar_channels: 2`, `modalities: [optical, sar, joint]`, | |
| `modalities_used: [optical, sar, joint]`. | |
| - **Finding C-7.** `image_resolution % 8 == 0`; native 120 β 15Γ15 = **225 patches**. Enforced in | |
| `core/config.py` and re-checked in `CROMAEncoder.__init__`. | |
| - **Finding C-1 β CROMA is never given a mask.** The forward pass takes exactly two arguments, | |
| `model(SAR_images=..., optical_images=...)`. The availability mask is consumed by the **fusion | |
| head**, not by CROMA (see Β§3.3). The rationale: CROMA is a masked autoencoder, and handing it an | |
| availability mask invites it to reconstruct missing channels β precisely the fabrication the sensor | |
| adapter exists to prevent. | |
| - First real forward pass (measured): CPU, 0.89 s for a batch of 2 at 120 px, 194,365,440 parameters, | |
| `n_patches = 225`, `(B, 768)` GAPs and `(B, 225, 768)` tokens confirmed. Two facts no document had | |
| recorded surfaced: CROMA-base is **asymmetric** (`s1_depth=6`, `s2_depth=12`) and the joint | |
| cross-attention is **directional** (SAR queries optical). | |
| - **DEV-1 (constructor).** CROMA is distributed as a GitHub repository, not a pip package; the | |
| constructor is `use_croma.PretrainedCROMA`, and `use_croma.py` **must be vendored**. Ruled | |
| **ACCEPTED as an implementation detail** β no `ARCHITECTURE CHANGE` entry | |
| (`docs/PHASE14_OPTICAL_SAR_DECISIONS.md` Β§2). The vendored file is recorded at | |
| `specialists/optical_sar/vendor/use_croma.py`, 14,556 bytes, sha256 | |
| `a38567beed29eb08108a47cdc97fe98aec50fd4be0bd98a5266bcd18aafb7c5b`. | |
| - **DEV-2 (input normalisation).** Resolved 2026-09-18: option (a) β match upstream's per-channel | |
| `mean Β± 2Β·std` stretch immediately before the forward pass with `use_8_bit` enabled β adopted as the | |
| required implementation; option (c) registered as a **gated** experiment with (a) as its control; | |
| option (b) rejected as a standalone path. Implemented in `specialists/optical_sar/radiometry.py` | |
| and wired through `croma.py`. | |
| ### 2.5 Change encoder β ResNet-18, trained in-project | |
| The change detector's encoder is **not** an external pinned backbone in the sense the others are: it | |
| is a torchvision `resnet18`, requested with `change.pretrained: true` and recorded in the trained | |
| artifact's embedded config as `pretrained_used: true` with `encoder: "resnet18"` and | |
| `encoder_channels: [64, 128, 256, 512]`. | |
| The code does **not** make pretrained weights a hard dependency. Resolution order | |
| (`specialists/change/stanet.py`): (1) a local weights file, if given; (2) torchvision's | |
| `IMAGENET1K_V1` download; (3) random initialisation **with an explicit warning** β because | |
| `pretrained: true` in a config must not become a lie when a download fails. The trained artifact | |
| records `pretrained_used: true`, so this run used ImageNet weights. | |
| ### 2.6 Backbone licensing | |
| Backbones are **not redistributed** by this project. They are fetched from the Hub at run time and | |
| carry their own licences (see each model's HF page). See Β§6. | |
| --- | |
| ## 3. Per-artifact deep reference | |
| Each subsection below gives, for one artifact: its path, byte count, sha256, architecture, **every | |
| hyperparameter** (from `configs/base.yaml` and the code), the backbone and its pinned revision, the | |
| training data, the evaluation protocol, the measured numbers (with artifact key paths), the | |
| acceptance status, and its limitations. | |
| ### 3.0 Enforced configuration invariants, with arithmetic | |
| Two invariants are **enforced at load time**, not merely documented. Both exist because the failure | |
| mode they prevent is a *silent shape error* β a tensor of exactly the right shape that trains to a | |
| worse number, the hardest kind of bug to notice. | |
| #### 3.0.1 `fusion.input_dim == 2318` | |
| ``` | |
| input_dim = len(modalities_used) * encoder_dim + optical_channels + sar_channels | |
| = 3 * 768 + 12 + 2 | |
| = 2304 + 14 | |
| = 2318 | |
| ``` | |
| `configs/base.yaml` states the arithmetic in a comment above `fusion.input_dim: 2318`, and the code | |
| recomputes it **twice**, independently: | |
| - `core/config.py` recomputes it as | |
| `len(modalities_used) * encoder_dim + optical_channels + sar_channels` at load time (finding C-1); | |
| - `specialists/optical_sar/fusion_head.py::expected_fusion_dim` returns | |
| `int(modalities_used) * int(encoder_dim) + int(optical_channels) + int(sar_channels)`; | |
| - `build_fusion_head` **raises `ModelLoadError`** if `int(input_dim) != 2318`, naming the arithmetic | |
| (`3*768 + 12 + 2`) in the message; | |
| - `assemble_fusion_input` concatenates `[optical_GAP, sar_GAP, joint_GAP, optical_mask, sar_mask]` | |
| and **raises `SpecialistError`** if the assembled width is not the expected width. | |
| The order of the concatenation is frozen (freeze Β§2.5) and **asserted rather than assumed**, because a | |
| permutation produces a tensor of exactly the right shape that trains to a worse number. | |
| > The mask goes **here**, not into CROMA. Freeze Β§2.5: channel/band dropout during fusion-head | |
| > training is **mandatory** β it is what teaches the head to trust the availability mask. With no | |
| > dropout the head learns to read channel 4 unconditionally, because in training channel 4 was always | |
| > present; on a 4-band sensor channels 5β12 are *always* zero, and a head that never saw a masked | |
| > channel treats those zeros as a measurement of blackness rather than as absence. | |
| > `channel_dropout()` is a real function, tested directly (measured: `p=1.0` keeps 48/48; `p=0.4` | |
| > drops to 18/48; dropped features are exactly `0.0`, not renormalised; a band the sensor never | |
| > measured stays dropped even at `p=1.0`; the seeded RNG is deterministic). | |
| #### 3.0.2 `grounding_head.feature_dim == 2048` | |
| ``` | |
| feature_dim = 4 * grounding.encoder_projected_dim | |
| = 4 * 512 | |
| = 2048 | |
| ``` | |
| The per-cell feature is | |
| ``` | |
| f_i = concat([p_i, t, p_i * t, global_pool]) -> 4 * 512 = 2048 | |
| ``` | |
| where `p_i` is the patch token, `t` the text embedding broadcast to every cell, `p_i * t` the | |
| element-wise cross-modal alignment, and `global_pool` the mean over all patches. The global term | |
| matters: a per-cell MLP otherwise cannot see anything outside its own patch, and a 1/7-of-image | |
| receptive field is too small for objects that span several cells. | |
| The invariant is enforced **three** ways: | |
| - `core/config.py` **rejects any value other than** `4 * grounding.encoder_projected_dim` at load | |
| time β deliberately, so the head can be validated *without importing torch*; | |
| - `specialists/grounding/head.py::build_head` recomputes `expected = 4 * VERIFIED_PROJECTED_DIM` and | |
| raises `ValueError` naming both numbers if they differ; | |
| - `GroundingHead.forward` re-checks `feat.shape[-1] != self.feature_dim` and raises, and | |
| `RemoteCLIPEncoder._verify_contract` asserts `visual.proj.shape[1] == 512` against the real model. | |
| A mismatch is a silent shape error: torch raises only at the similarity step, after the patch | |
| features are already cached. | |
| #### 3.0.3 Other enforced invariants (summary) | |
| | Invariant | Enforced where | Failure mode prevented | | |
| |---|---|---| | |
| | `croma.image_resolution % 8 == 0` | `core/config.py`, `CROMAEncoder.__init__`, `OpticalSarSpecialist.__init__` | CROMA asserts it internally (finding C-7) | | |
| | `router.max_length <= 256` | `router/encoder.py` | truncating above the tokenizer ceiling is a silent no-op | | |
| | `change` spatial dims divisible by 8 | `STANetStyleChangeDetector.forward` | encoder stride; a 1-px-off raster is reflect-padded and cropped back, with a warning | | |
| | `sa_mode in {PAM, BAM}`; `BAM` raises | `STANetStyleChangeDetector.__init__` / `.forward` | a config asking for BAM fails visibly rather than silently aliasing PAM | | |
| | `fusion_head` device must be `"cpu"` | `build_fusion_head` | a device string that would silently fall back is worse than a refusal | | |
| | `task_dim >= 2` | `OpticalSarSpecialist.__init__` | a margin cannot exist over one class | | |
| | exactly 2 assets for change / change_vqa / optical_sar; exactly 1 for vqa/caption | each specialist's `validate_request` | a silent "just use the first two" produces a confident answer to a question the caller did not ask | | |
| --- | |
| ### 3.1 `change` β STANet-style Siamese change detector | |
| **Status:** `IMPLEMENTED`, `VERIFIED` β the only task whose headline metric carries the `VERIFIED` | |
| tag, because it is the only one measured against a single, immutable public test split with a frozen | |
| threshold. | |
| #### Identity | |
| | Field | Value | Source | | |
| |---|---|---| | |
| | Manifest `id` | `change_head` | `models/manifest.json` | | |
| | Path | `artifacts/change/levir_change_v001/head.pt` | manifest | | |
| | Bytes | 63,231,009 | manifest | | |
| | sha256 | `c5ef31277b67aa01a593aec0eac503eeaccc6d674349fda20ca44c9cc6f8e9fa` | manifest, `checksums.sha256` | | |
| | HF path | `change/head.pt` | manifest | | |
| | Architecture | STANet-style Siamese change detector (ResNet-18 + PAM) | manifest, code | | |
| | `kind` | `trained_head` | manifest | | |
| | `backbone` | `null` (trained in-project; ResNet-18 ImageNet trunk) | manifest | | |
| | Config hash | `78f1e3700da15aa1` | manifest | | |
| | Parameters | `UNKNOWN β not established from the available evidence` (manifest records `null`) | manifest | | |
| #### Architecture (`specialists/change/stanet.py`) | |
| A STANet-shaped Siamese network β shared Siamese encoder, spatial-temporal attention over feature | |
| differences, feature-difference aggregation decoder β **reimplemented rather than vendored**, per the | |
| Phase 0 resolution of finding C-9 (the upstream repo is Python 3.6-era and depends on `visdom`/`apex`). | |
| ``` | |
| T1 ---> [ shared encoder ] ---> f1 (4 levels) | |
| T2 ---> [ shared encoder ] ---> f2 (4 levels) | |
| | | |
| |f1 - f2| + concat([f1, f2, |f1-f2|]) | |
| | | |
| spatial attention (PAM) | |
| | | |
| progressive decoder + skips | |
| | | |
| 1-channel change logit | |
| ``` | |
| - **Weights are tied, not copied.** Both branches call the **same** module instance | |
| (`SharedResNetEncoder`), so there is no second encoder to fall out of sync β the "shared encoder" | |
| requirement from plan Β§16 enforced by construction, not by convention. | |
| - **Measured encoder shapes** (ResNet-18 trunk, 256Γ256 input): | |
| | Level | Channels | Grid | Positions | | |
| |---|---|---|---| | |
| | `layer1` | 64 | 64Γ64 | 4096 | | |
| | `layer2` | 128 | 32Γ32 | 1024 | | |
| | `layer3` | 256 | 16Γ16 | 256 | | |
| | `layer4` | 512 | 8Γ8 | 64 | | |
| - **The attention memory line is computed, not assumed.** STANet's PAM builds a full | |
| `positions Γ positions` attention matrix. At batch 8, fp32: | |
| ``` | |
| layer1 8 * 4096^2 * 4 = 537.0 MB <- exceeds a Kaggle session's budget | |
| layer2 8 * 1024^2 * 4 = 33.5 MB <- fine | |
| layer3 8 * 256^2 * 4 = 2.1 MB <- fine | |
| layer4 8 * 64^2 * 4 = 0.13 MB <- fine | |
| ``` | |
| Attention is therefore applied at **layers 2, 3 and 4** and **skipped at layer 1**, where the | |
| difference features are fused directly instead. The decision is **recomputed on every forward pass** | |
| from the actual batch size and a byte budget (`DEFAULT_ATTENTION_BUDGET_BYTES = 256 * 1024 * 1024`), | |
| so changing batch size or tile size moves the line correctly rather than silently overrunning | |
| memory. Applied/skipped levels are returned in the output so the trace can report them | |
| (`ChangeOutput.to_trace`). **This is a deviation from a literal STANet reproduction, recorded as | |
| one** β attention at 64Γ64 would not fit. It is not a silent substitution. | |
| - **Difference fusion.** `DifferenceFusion` reduces `concat([f1, f2, |f1-f2|])` (3C channels) back to | |
| a working width with `Conv2d(3C, out, 3, padding=1, bias=False) β BatchNorm2d β ReLU`. | |
| - **Attention module.** `SpatialAttention` is PAM-style: 1Γ1 `query`/`key` convolutions to | |
| `hidden = max(1, channels // reduction)` with `reduction = 8`, a 1Γ1 `value` to `channels`, and a | |
| 1Γ1 `out`; `softmax(q @ k / sqrt(hidden))` over positions, residual `x + out(attended)`. | |
| - **Decoder.** Progressive, coarse-to-fine: `dec3` and `dec2` are `DecoderBlock(width, width, width)`; | |
| `dec1` is `DecoderBlock(width, width, width // 2)`; `final_up` is | |
| `ConvTranspose2d(width//2, width//2, 4, stride=4)`; `head` is `Conv2d(width//2, 1, 1)` with | |
| `FINAL_BIAS_INIT = -2.0` (LEVIR-CD's changed-pixel fraction is roughly 5β15%, so a bias of β2.0 | |
| starts the prior near 0.12 rather than 0.5 and stops the first epochs being spent un-learning a | |
| saturated sigmoid). | |
| - **Loss.** Composite BCE + Dice, `change_loss(logits, target, bce_weight=0.5, dice_weight=0.5, | |
| pos_weight=None)`. Dice is computed on soft probabilities so it is differentiable and directly | |
| optimises region overlap; `eps = 1e-6` guards the empty-target case (a tile with no change would | |
| otherwise be `0/0 = NaN`). `pos_weight` is left as an explicit knob rather than a hidden default, | |
| because Dice already addresses the class imbalance. | |
| #### Hyperparameters β every value | |
| From `configs/base.yaml` (`change:`): | |
| | Key | Value | | |
| |---|---| | |
| | `tile_size` | 256 | | |
| | `tile_overlap` | 0 | | |
| | `threshold` | 0.50 | | |
| | `min_component_pixels` | 32 | | |
| | `encoder` | `resnet18` | | |
| | `sa_mode` | `PAM` (`BAM` declared but not implemented; raises) | | |
| | `pretrained` | true | | |
| | `learning_rate` | 0.001 | | |
| | `batch_size` | 8 | | |
| | `bce_weight` | 0.5 | | |
| | `dice_weight` | 0.5 | | |
| | `levir_split.train` | 7120 | | |
| | `levir_split.val` | 1024 | | |
| | `levir_split.test` | 2048 | | |
| From the trained artifact's **embedded** config | |
| (`artifacts/change/eval_test/eval_result.json` β `checkpoint_embedded_config`), which is the config | |
| actually baked into the weights: | |
| | Key | Value | | |
| |---|---| | |
| | `width` | 128 | | |
| | `sa_mode` | `PAM` | | |
| | `attention_budget_bytes` | 268435456 (256 MB) | | |
| | `encoder` | `resnet18` | | |
| | `encoder_channels` | `[64, 128, 256, 512]` | | |
| | `frozen_encoder` | false | | |
| | `pretrained_used` | true | | |
| #### Training data | |
| LEVIR-CD-256 β the standard 256Γ256 change-detection benchmark. Split used (from `configs/base.yaml`): | |
| train **7,120** / val **1,024** / test **2,048**. The test split is immutable and public. | |
| #### Evaluation protocol | |
| `artifacts/change/eval_test/eval_result.json`. Environment recorded in the artifact: device `cuda`, | |
| torch `2.10.0+cu128`, Python `3.12.13`, platform `Linux-6.12.90+-x86_64-with-glibc2.35`, | |
| `cuda_available: true`, `seconds: 55.359`. Config-hash integrity is recorded explicitly: | |
| `config_hash: 78f1e3700da15aa1`, `checkpoint_config_hash: 78f1e3700da15aa1`, | |
| `checkpoint_config_hash_checked: true`, `config_drift: false`, | |
| `config_drift_acknowledged: false`. | |
| Evaluation population: `n: 2048`, `n_images_with_change: 935`, | |
| `mean_change_fraction: 0.0509`, `n_pixels: 134217728`, `threshold: 0.5`. | |
| Change-fraction quantiles (`change_fraction_quantiles`): `min 0.0`, `p50 0.0`, `p90 0.197205`, | |
| `max 0.684937`. The median tile has **zero** changed pixels, which is why pooled and macro metrics are | |
| both reported. | |
| #### Measured numbers | |
| All from `artifacts/change/eval_test/eval_result.json`. | |
| | Metric | Value | Exact key path | | |
| |---|---|---| | |
| | **pooled IoU** | **0.8122** | `metrics.pooled.iou` | | |
| | **macro IoU** | **0.8457** | `metrics.macro.miou` | | |
| | **pooled F1** | **0.8964** | `metrics.pooled.f1` | | |
| | pooled mean IoU (`miou`) | 0.9007 | `metrics.pooled.miou` | | |
| | pooled precision | 0.9195 | `metrics.pooled.precision` | | |
| | pooled recall | 0.8745 | `metrics.pooled.recall` | | |
| | macro IoU (`iou`, per-class change IoU) | 0.718 | `metrics.macro.iou` | | |
| | macro F1 | 0.7962 | `metrics.macro.f1` | | |
| | macro precision | 0.8506 | `metrics.macro.precision` | | |
| | macro recall | 0.7757 | `metrics.macro.recall` | | |
| | tp | 5,978,997 | `metrics.pooled.tp` | | |
| | fp | 523,658 | `metrics.pooled.fp` | | |
| | fn | 858,407 | `metrics.pooled.fn` | | |
| | tn | 126,856,666 | `metrics.pooled.tn` | | |
| The confusion counts are identical between the pooled and macro blocks (as they must be β they are the | |
| same pixel population; the two blocks differ only in how the class-wise scores are aggregated). | |
| **Pooled vs macro, and why both.** The test split is only β 5 % changed pixels. Pooled IoU answers | |
| "how well does the mask overlap overall"; macro IoU answers "how well does each class do, averaged". | |
| Quoting one alone would hide the imbalance question. | |
| #### Acceptance status | |
| `VERIFIED`. This is the only task whose headline number carries the `VERIFIED` tag in | |
| [`BENCHMARKS.md`](BENCHMARKS.md) Β§1, because it is the only one measured against a single, immutable | |
| public test split with a frozen threshold. | |
| #### Serving wiring (and why the checkpoint is not in the config) | |
| A trained head exists and is benchmarked, yet it is **deliberately not wired into serving by default** | |
| (`specialists/change/specialist.py`). Populating `change.checkpoint_path` in `configs/base.yaml` would | |
| move `Config.hash`, and `scripts/eval_change.py` refuses to score on a hash drift (exit 3) β so that | |
| one edit would invalidate the project's own benchmark number. The wiring path is therefore the | |
| registry `builders=` override (`core/registry.py`), which injects the checkpoint without touching the | |
| config. With no checkpoint wired, a missing artifact is a **deployment** case, not a crash: the | |
| specialist runs the randomly-initialised model, marks the result `degraded`, and says so plainly, | |
| because a randomly-initialised change map is a map of noise and presenting it as a detection would be | |
| exactly the fabrication the evidence system exists to prevent. `has_checkpoint` distinguishes "a | |
| checkpoint was loaded" from "we built a random one and are being honest about it". | |
| #### Limitations | |
| - **Absolute IoU is high, but this is one benchmark.** LEVIR-CD-256 is overhead optical; it says | |
| nothing about the hidden ISRO/SAC distribution (Cartosat-2S + RISAT). | |
| - **Attention is skipped at layer 1** β a recorded deviation from literal STANet, forced by memory. | |
| - **The detector is not the product path by default** (Β§ above). | |
| - **Post-processing suppresses spatial claims on a mis-registered pair.** Poor co-registration drives | |
| confidence to 0.0 and withholds region claims; the maps are still written, but the region assertions | |
| are not made. This is by design (`architecture freeze Β§2.4`, plan Β§40) but it means the reported | |
| metric is for a well-registered benchmark, not for arbitrary pairs. | |
| - **Minimum region size is 32 px** (`change.min_component_pixels`), so sub-32-px changes are | |
| discarded by construction. | |
| --- | |
| ### 3.2 `change_vqa` β change question answering head | |
| **Status:** `IMPLEMENTED`, `MEASURED`, ruling **OPEN**. | |
| A head that answers natural-language change questions over a temporal pair. It answers the eight CDVQA | |
| question types (`change_or_not`, `change_ratio`, `change_ratio_types`, `change_to_what`, | |
| `increase_or_not`, `decrease_or_not`, `largest_change`, `smallest_change`) over a **closed 19-answer | |
| space**. | |
| #### Identity | |
| | Field | Value | Source | | |
| |---|---|---| | |
| | Manifest `id` | `change_vqa_head` | `models/manifest.json` | | |
| | Path | `artifacts/change_vqa/run/head.pt` | manifest | | |
| | Bytes | 5,822,809 | manifest | | |
| | sha256 | `cfae5e43b97ca930f568dc5b8ae4f36b24e9ff717af226159802206ffd63a82a` | manifest, `PROMOTION.json` | | |
| | HF path | `change_vqa/head.pt` | manifest | | |
| | Architecture | `change_vqa_head_v1` | `PROMOTION.json` β `artifact.architecture` | | |
| | **Parameters** | **1,453,912** | `PROMOTION.json` β `artifact.parameters` | | |
| | Backbone | STANet change detector (frozen, backing the head's change features) | manifest | | |
| | Config hash | `78f1e3700da15aa1` | manifest | | |
| | `satquery_trained` | true | `PROMOTION.json` β `artifact.satquery_trained` | | |
| | `eval_mode` | true | `PROMOTION.json` β `artifact.eval_mode` | | |
| | `non_finite_tensors` | **0** | `PROMOTION.json` β `artifact.non_finite_tensors` | | |
| | `weights_modified` | **false** | `PROMOTION.json` β `artifact.weights_modified` | | |
| #### Architecture and the frozen dependency | |
| The head consumes **cached features**, not raw imagery: a frozen STANet detector produces a change | |
| representation, and a frozen question text encoder (`sentence-transformers/all-MiniLM-L6-v2`) produces | |
| a text vector; the head maps those to an answer index. Recorded feature specs | |
| (`PROMOTION.json` β `identity`): `feature_spec: change_feat_v1`, | |
| `change_cache_spec: c801326f85a185f8`, `text_cache_spec: d2801ea1a314354a`, | |
| `preprocessing_version: change_vqa_preproc_v1`. | |
| The **frozen dependency** is the STANet detector itself (`PROMOTION.json` β `frozen_dependency`): | |
| | Field | Value | | |
| |---|---| | |
| | role | STANet change detector backing the head's change features | | |
| | path | `artifacts/change/levir_change_v001/head.pt` | | |
| | sha256 | `c5ef31277b67aa01a593aec0eac503eeaccc6d674349fda20ca44c9cc6f8e9fa` | | |
| | bytes | 63,231,009 | | |
| | `detector_trained` | true | | |
| | `verified_byte_exact_vs_local` | true | | |
| This digest is byte-identical to the `change` artifact in Β§3.1 β the head is wired to the same | |
| checkpoint the change task releases. | |
| **The detector is frozen and shared in spirit, not in instance** (`specialists/change/vqa_specialist.py`). | |
| When the planner routes a change+language request it plans **both** a `change` step and a | |
| `change_vqa` step, so the detector runs twice for one request. That is a real cost, stated rather than | |
| hidden: the weights are loaded once (the registry caches the specialist instance), so the second cost | |
| is a forward pass, not a 60 MB load. The alternative (passing the change map between steps) needs the | |
| controller to hand artifacts between steps, which it does not do today. `execute` therefore accepts an | |
| optional `change_map` path in `request.params` for a future planner to populate β **unused today and | |
| documented as such**, so the hook exists without pretending the wiring does. | |
| #### Hyperparameters and selection | |
| | Key | Value | Source | | |
| |---|---|---| | |
| | `apply_type_mask` | true (serving default) | config + `vqa_specialist.py` | | |
| | `image_size` | 256 (feature extractor default) | `vqa_specialist.py` (`change_vqa.image_size`) | | |
| | `seed` | 42 | `PROMOTION.json` β `identity.seed` | | |
| | `epoch_selected` | **8** | `PROMOTION.json` β `identity.epoch_selected` | | |
| | `selected_on` | Val answer accuracy | `PROMOTION.json` β `identity.selected_on` | | |
| | `val_answer_accuracy` | **0.700018** | `PROMOTION.json` β `identity.val_answer_accuracy` | | |
| | `stop_reason` | `early_stopping` | `PROMOTION.json` β `identity.stop_reason` | | |
| | `LOW_CONFIDENCE_THRESHOLD` | 0.40 (a reporting threshold, not a calibration) | `vqa_specialist.py` | | |
| The **type mask** restricts the answer to those legal for the question type. It is on by default in | |
| serving because the type is known from the question and an illegal answer is never right; the | |
| evaluation reports masked and unmasked separately so the mask's contribution is visible. | |
| `PROMOTION.json` β `verification.mask_gain` is **0.0**. | |
| #### Training data | |
| CDVQA annotations + SECOND (SCD) imagery (`docs/DATASETS.md` Β§3). The CDVQA repository publishes | |
| **annotations only**; imagery is acquired from SECOND, name-verified **2,968 / 2,968 MATCH**, extracted | |
| to `data/cdvqa/{im1,im2,label1,label2}/`. Val images 400; val questions **16,441**; test questions | |
| **39,686**. | |
| **Temporal and label semantics** β established from evidence, with honest uncertainty | |
| (`vqa_specialist.py::TEMPORAL_ORDER_NOTE`): `T1 = pre, T2 = post`; `label1=pre, label2=post` is | |
| **proven** (agreement 1.0000 over 2,968 scenes); `im1=pre, im2=post` is **supported statistically, not | |
| proven**. The distinction travels in the evidence payload so a reader can see which leg the result | |
| rests on. | |
| **Trained externally.** The head was trained outside the repository, on an external GPU (Kaggle), | |
| following `docs/R02_KAGGLE_TRAINING_GUIDE.md`, whose entry status was | |
| `IMPLEMENTATION_READY_FOR_EXTERNAL_TRAINING` and whose explicit contract is: *training produces an | |
| artifact, not a verified capability, and the run record says `TRAINED_UNVERIFIED`.* The returned | |
| checkpoint was then **promoted** through a byte-identity gate | |
| (`PROMOTION.json` β `state`): `before_promotion: TRAINED_UNVERIFIED`, | |
| `after_promotion: PROMOTED`. The note is explicit: *promotion records provenance and wires the serving | |
| path; it does not itself confer VERIFIED status β that is the maintainer's ruling.* | |
| #### Evaluation protocol | |
| `artifacts/change_vqa/run/PROMOTION.json` β `verification`. Verification report | |
| `ARTIFACT_VERIFICATION.md` (2026-09-22): `checks_passed: 93`, `checks_failed: 0`, | |
| `checks_unverified: 0`. | |
| **Two test sets are reported. Quoting only the better one would be selective.** | |
| | Test set | n | accuracy | macro F1 | | |
| |---|---|---|---| | |
| | `test` | 39,686 | **0.697626** | **0.378373** | | |
| | `test2` | 31,036 | **0.651469** | **0.372309** | | |
| | Field | Value | Exact key path | | |
| |---|---|---| | |
| | test accuracy | 0.697626367 | `verification.test_accuracy` | | |
| | test macro F1 | 0.378373275 | `verification.test_macro_f1` | | |
| | test2 accuracy | 0.651469262 | `verification.test2_accuracy` | | |
| | test2 macro F1 | 0.372308516 | `verification.test2_macro_f1` | | |
| | n scored, test | 39,686 | `verification.n_scored_test` | | |
| | n scored, test2 | 31,036 | `verification.n_scored_test2` | | |
| | global majority baseline, test | 0.311546 | `verification.global_majority_baseline_test` | | |
| | global majority baseline, test2 | 0.178728 | `verification.global_majority_baseline_test2` | | |
| | mask gain | 0.0 | `verification.mask_gain` | | |
| | metric ruling | `OPEN β the plan leaves the accuracy/macro-F1 interpretation owner-gated. No official aggregate metric is asserted here.` | `verification.metric_ruling` | | |
| **The wide gap between accuracy and macro-F1 means the head is carried by common classes and performs | |
| poorly on rare ones.** The global-majority baselines make the gap concrete: 0.311546 on `test` means a | |
| constant predictor would score 0.31, and the head scores 0.70; but the macro-F1 of 0.378 shows the | |
| per-class picture is far weaker than the aggregate. | |
| #### Acceptance status | |
| **OPEN.** No promotion/acceptance decision has been recorded. `PROMOTION.json` β `state.note` states | |
| this explicitly. The `metric_ruling` is `OPEN`. | |
| #### Serving behaviour β degraded mode produces no answer, on purpose | |
| `ChangeSpecialist` degrades to an untrained detector and still emits a change map. This specialist must | |
| **not** copy that, because the outputs are not comparable: a random change map is visibly noise, | |
| whereas an untrained 19-way classifier still emits a fluent, confident-looking `yes`. A user cannot | |
| tell the second from a real answer, so the untrained case returns **no answer at all** β `answer=""`, | |
| `degraded=True`, and a warning naming exactly which piece is missing. The controller then falls through | |
| to another attributed specialist. | |
| There is also a **feature-spec mismatch refusal**: `feature_spec_mismatch()` compares the spec hash the | |
| head was trained on against the spec hash this deployment produces, and refuses to answer if they | |
| differ. The reason is measured, not hypothetical: `configs/base.yaml`'s `change:` section carries no | |
| `checkpoint_path`, so the registry builds the change feature extractor with `checkpoint_path=None` and | |
| gets an **untrained** STANet β while `scripts/prepare_change_vqa.py` defaults to the trained LEVIR | |
| checkpoint. Training and serving would consume different representations, and the head would still emit | |
| a fluent `yes`. So it refuses, and names both specs. | |
| Confidence is **uncalibrated and says so**: `ConfidenceBreakdown.method` is `"uncalibrated"`, `raw` is | |
| the softmax probability of the chosen answer, `components` carries the top1-top2 margin, the number of | |
| distinct answers seen, and the normalised entropy. Nothing is fitted here β the calibration contract | |
| (Β§5) is a separate artefact. | |
| #### Limitations | |
| - **Weak on rare classes** (macro-F1 0.378 / 0.372 vs accuracy 0.698 / 0.651). | |
| - **Two test sets, both reported** β and the second is materially lower on accuracy (0.651 vs 0.698). | |
| - **Ruling OPEN** β no official aggregate metric is asserted. | |
| - **Serving needs two artefacts to be honest**: a trained head *and* a trained feature extractor; the | |
| shipped config wires neither by default. | |
| - **`im1=pre, im2=post` is supported statistically but not proven** β a residual provenance risk. | |
| --- | |
| ### 3.3 `optical_sar` β CROMA-base fusion head | |
| **Status:** `IMPLEMENTED`, `MEASURED`, ruling **OPEN**. This is the spec's #1 evaluation priority, and | |
| it is the only workflow whose inputs are two **different sensors**. | |
| #### Identity | |
| | Field | Value | Source | | |
| |---|---|---| | |
| | Manifest `id` | `optical_sar_fusion_head` | `models/manifest.json` | | |
| | Path | `artifacts/optical_sar/fusion_head_production_v001/head.pt` | manifest | | |
| | Bytes | 14,427,457 | manifest, `pre_registered_115_metric.json` β `head_bytes` | | |
| | sha256 | `785815729a3a39fc34dc41894efaf00d8739365d970a3f830a326e68ae888dab` | manifest, `checksums.sha256`, metric JSON `head_sha256` | | |
| | HF path | `optical_sar/head.pt` | manifest | | |
| | Architecture | CROMA-base fusion head (input_dim 2318 β hidden 512 β 19 classes) | manifest | | |
| | Backbone | `antofuller/CROMA` (`CROMA_base.pt`, rev `0dd28e3d633b`) | manifest | | |
| | Config hash | `78f1e3700da15aa1` | manifest | | |
| | Parameters | `UNKNOWN β not established from the available evidence` (manifest records `null`) | manifest | | |
| #### Architecture (`specialists/optical_sar/fusion_head.py`) | |
| CROMA produces three 768-dimensional vectors per sample (an optical GAP, a SAR GAP, and a joint GAP). | |
| The fusion head turns those, plus the availability masks, into a task prediction. | |
| ``` | |
| optical_GAP (B, 768) | |
| SAR_GAP (B, 768) | |
| joint_GAP (B, 768) | |
| optical_mask (B, 12) <- availability, from the sensor adapter | |
| sar_mask (B, 2) <- availability, from the sensor adapter | |
| --------- | |
| concat (B, 2318) | |
| ``` | |
| The frozen head architecture (`build_fusion_head`, freeze Β§2.5 / plan Β§17): | |
| ``` | |
| LayerNorm -> Linear(input_dim, hidden_dim) -> GELU -> Dropout -> Linear(hidden_dim, task_dim) | |
| ``` | |
| No activation on the output: this is a logit-producing task head, and a softmax here would be applied | |
| twice once a loss function adds its own. | |
| **The mask goes here, not into CROMA (finding C-1).** See Β§3.0.1. `CROMA_GAP_KEYS = ("optical_GAP", | |
| "SAR_GAP", "joint_GAP")` is declared once so the rest of the package refers to one spelling. | |
| #### Hyperparameters β every value | |
| From `configs/base.yaml` (`croma:` and `fusion:`): | |
| | Key | Value | | |
| |---|---| | |
| | `croma.checkpoint_repo` | `antofuller/CROMA` | | |
| | `croma.checkpoint_file` | `CROMA_base.pt` | | |
| | `croma.checkpoint_revision` | `0dd28e3d633b` | | |
| | `croma.variant` | `base` | | |
| | `croma.image_resolution` | 120 | | |
| | `croma.encoder_dim` | 768 | | |
| | `croma.optical_channels` | 12 | | |
| | `croma.sar_channels` | 2 | | |
| | `croma.modalities` | `[optical, sar, joint]` | | |
| | `croma.modalities_used` | `[optical, sar, joint]` | | |
| | `fusion.input_dim` | 2318 | | |
| | `fusion.hidden_dim` | 512 | | |
| | `fusion.dropout` | 0.2 | | |
| | `fusion.num_classes` | 19 | | |
| **Seed sweep.** Training was run as two arms (**armA**, **armB**) Γ five seeds (**100β104**), with a | |
| per-arm variance report (`armA_seed_variance_report.json`). The **production head** is a distinct, | |
| frozen artifact (`fusion_head_production_v001/head.pt`) with its own `production_head_record.json` and | |
| a `phase12_rerun_verification.json`. | |
| **A/B arm decision.** Made separately on `best_val_accuracy`: **A 0.837100 vs B 0.839100**, floor | |
| **0.0285** β **Arm A retained** (owner ruling R-14, | |
| `docs/PHASE12_115_METRIC_COMPUTED.md` Β§5). The metric JSON records `cache_arm: "A"`. | |
| **Channel dropout is mandatory** (freeze Β§2.5). Plan Β§20's robustness schedule: optical is trained at | |
| 100/80/60/40 % channel availability and SAR at 100/50 %. The mask is updated to match the dropped | |
| features β that is the entire point: dropping features while leaving the mask saying "present" would | |
| teach the head that the mask lies. Dropped channels are zeroed, **not renormalised** β renormalising | |
| would fabricate a scale the real missing-channel case does not have. | |
| #### Training data | |
| BigEarthNet (CLC-19), **single-label subset** (see the caveat below). Measured local subset: 28,000 | |
| S2 patches, 98 tiles, 12 bands per patch; full official corpus 480,038 patches. Training consumes | |
| cached CROMA features (`fusion_features/`, `fusion_features_armB/`, ~231 MB each), which are | |
| **reproducible and not released as model weights**. | |
| **Measured context for the single-label policy:** single-label patches are **17.57 %** of the corpus | |
| (96,537 of 549,488), and under this policy the rarest class survives as **1 patch** β a **59,204 : 1** | |
| imbalance (`docs/PHASE12_LABEL_POLICY_DECISION.md` Β§3.0). | |
| #### Evaluation protocol | |
| `artifacts/optical_sar/fusion_head_production_v001/pre_registered_115_metric.json`. This is a | |
| **separate, later, read-only** step (`scripts/eval_fusion_115.py`), deliberately **not** part of the | |
| trainer: every `run_record.json` carries `pre_registered_metric_computed = false` and | |
| `result_status = "PLUMBING_ONLY β fixture/loop evidence, NOT a result; the pre-registered 11.5 metric | |
| is not computed"`. That flag is **correct about the trainer** and must not be "fixed" β the trainer | |
| fits on `train`/`val` and never opens the test split, deliberately, so the held-out split cannot be | |
| contaminated by the search over 10 runs, 2 arms and 5 seeds. | |
| The tool **refuses (exit 2)** rather than guessing when: the head or cache is missing; the split is | |
| empty; the cache `config_hash` is not `78f1e3700da15aa1` (a different experiment); or the head cannot | |
| be built against the frozen `(B, 2318)` input contract. | |
| Definition (per `docs/PHASE14_CROMA_NORMALISATION_CHANGE.md` Β§4): *fusion-head accuracy and macro-F1 | |
| over the 19-class label space on the held-out split.* | |
| #### Measured numbers | |
| All from `artifacts/optical_sar/fusion_head_production_v001/pre_registered_115_metric.json`: | |
| | Metric | Value | Exact key path | | |
| |---|---|---| | |
| | **accuracy** | **0.931** | `accuracy` | | |
| | **macro F1** | **0.434161** | `macro_f1` | | |
| | loss | 0.254592 | `loss` | | |
| | split | `test` | `split` | | |
| | n scored | 4,000 | `n_scored` | | |
| | num classes | 19 | `num_classes` | | |
| | macro-F1 denominator | all 19 classes (absent classes contribute 0.0) | `macro_f1_denominator` | | |
| | classes present | `[0,2,3,4,5,6,7,8,9,10,12,13,17,18]` | `classes_present` | | |
| | classes absent | `[1,11,14,15,16]` | `classes_absent` | | |
| | deciding statistic | **false** | `is_deciding_statistic` | | |
| | cache arm | A | `cache_arm` | | |
| | cache path | `artifacts/optical_sar/fusion_features/test.npz` | `cache_path` | | |
| | cache config hash | `78f1e3700da15aa1` | `cache_config_hash` | | |
| **Optical-SAR accuracy 0.931 must ALWAYS travel with macro-F1 0.434161.** The two are recorded side by | |
| side in the artifact precisely so neither can be quoted alone. | |
| Per-class F1 (`_per_class_f1`, 19 terms, indexed 0β18): | |
| ``` | |
| [1.000, 0.000, 0.428, 0.571, 0.887, 0.000, 0.000, 0.800, 0.929, 0.940, | |
| 0.182, 0.000, 0.200, 0.438, 0.000, 0.000, 0.000, 0.877, 0.998] | |
| ``` | |
| **Two different averages come from the same per-class vector, and this is a trap:** | |
| | Averaged over | Value | | |
| |---|---| | |
| | **all 19 slots** β the pre-registered definition | **0.434161** | | |
| | the 14 present classes only | **0.589218** | | |
| A reader who computes the second and compares it to the recorded scalar will wrongly conclude the | |
| recorded figure is wrong. It is the 19-slot mean, and the trainer's `_macro_f1` divides by | |
| `num_classes` by construction. Both are reproducible: | |
| `sum(per_class)/19 = 0.43416082670034284` and | |
| `sum(per_class[c] for c in present)/14 = 0.5892182648076082` (`docs/PHASE12_115_METRIC_COMPUTED.md` | |
| Β§3.5). | |
| **Five absent classes contribute `0.0` by definition β but two present classes also score `0.0`.** | |
| Classes **1, 11, 14, 15, 16** are absent (their `0.0` describes no prediction); classes **5** and | |
| **6** are **present** and score `0.0`, i.e. a **real total miss**. So the honest reading is *both*: part | |
| of the low macro-F1 is populational, **and** there is genuine per-class failure. High accuracy with a | |
| wide per-class spread is the signature of prediction dominated by frequent classes. | |
| Measured test-set label distribution (`docs/PHASE12_115_METRIC_COMPUTED.md` Β§3.4): | |
| ``` | |
| [13, 0, 63, 39, 846, 4, 1, 6, 453, 246, 4, 0, 9, 18, 0, 0, 0, 34, 2264] | |
| ``` | |
| 14 of 19 classes present; five have zero samples; top-to-bottom ratio **2,264 : 1** (class 18 vs class | |
| 6). The majority class holds 2,264 / 4,000 = **0.566**, and the head scores **0.931** β so the accuracy | |
| is not a constant predictor. Per-class recall on the held-out split (selected rows): class 18 n=2264 | |
| recall **0.997**; class 9 n=246 recall **0.951**; class 17 n=34 recall **0.941**; class 8 n=453 | |
| **0.905**; class 4 n=846 **0.809**; class 3 n=39 **0.410**; class 12 n=9 **0.111**; classes 5 and 6 | |
| recall **0.000**. | |
| **Four independent verification checks** (`docs/PHASE12_115_METRIC_COMPUTED.md` Β§3): (1) calling | |
| `evaluate_fusion_head` directly returns `n=4000, accuracy=0.931, macro_f1=0.43416082670034284`; | |
| (2) rebuilding the input tensor by hand β `concat([optical_gap, sar_gap, joint_gap])` then | |
| `concat([β¦, optical_mask, sar_mask])`, widths `(4000,768)Γ3 + (4000,12) + (4000,2) = 2318` β gives | |
| 0.931 again (this checks feature *ordering*); (3) `sklearn.metrics` `accuracy_score = 0.931`, | |
| `f1_score(average="macro", labels=range(19), zero_division=0) = 0.43416082670034284`; (4) the | |
| per-class breakdown above. | |
| #### Acceptance status | |
| **OPEN.** `is_deciding_statistic: false`. The artifact's own `advisory` field states: *"This tool | |
| 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."* Ruling 5 attaches **no | |
| numerical threshold**; ruling 4 requires it be described as a **single-label subset evaluation, never | |
| as a multi-label BigEarthNet/reBEN result**. | |
| #### β οΈ The caveat that governs how these numbers may be used | |
| The cache metadata records `label_policy = require_single_label`, `n_skipped_by_policy = 0`. reBEN | |
| v2.0 is a **multi-label** corpus while the frozen head is a **single-label 19-class softmax** trained | |
| with `cross_entropy`. The extraction therefore used policy (b) β restrict to single-label patches β | |
| which preserves the frozen architecture and the 19-class space exactly but **changes the evaluation | |
| population**. | |
| So this metric **may not** be presented as: a multi-label BigEarthNet/reBEN result; comparable to | |
| published BigEarthNet numbers (almost all multi-label); or a statement about all 19 classes (**5 have | |
| no test samples here**). It **may** be presented as: *the pre-registered 11.5 metric, as computed under | |
| the single-label extraction policy the frozen architecture requires, on the held-out split.* | |
| #### Limitations | |
| - **Accuracy is carried by common classes** (0.931 vs macro-F1 0.434161; two present classes at 0.0). | |
| - **The live service returns a bare class index** (`class_18`), not a human-readable label | |
| (`_label_for` falls back to `f"class_{index}"` when `class_labels` is empty). | |
| - **Single-label subset** β not comparable to multi-label BigEarthNet numbers. | |
| - **Ruling OPEN**; no threshold attached. | |
| - **The confidence path floors to 0.0 when there is no prediction or no trained head** β a signal | |
| *gap*, not a weak signal. Measured with no checkpoint: `raw = 0.0`, | |
| `components = {optical_confidence: 0.333, sar_confidence: 1.0, cross_modal_agreement: 0.0, | |
| has_croma: 0.0, trained_head: 0.0, no_prediction: 1.0}`, `degradation_reason: "CROMA is not loaded"`. | |
| - **Two assets must be the right two.** `validate_request` rejects 0, 1, 3, and also **two of the same | |
| kind** β a plausible mistake that, if accepted, would place a second optical image into the 2-channel | |
| SAR slot, zero-fill, run CROMA, and answer about one modality while claiming to fuse two. | |
| --- | |
| ### 3.4 `grounding` β RemoteCLIP grounding head | |
| **Status:** `IMPLEMENTED`, `MEASURED` β **two protocols** and **two decode variants**. | |
| #### Identity | |
| | Field | Value | Source | | |
| |---|---|---| | |
| | Manifest `id` | `grounding_head` | `models/manifest.json` | | |
| | Path | `artifacts/grounding/remoteclip_grounding_v001/head.pt` | manifest | | |
| | Bytes | 12,639,041 | manifest | | |
| | sha256 | `93432f7034be91a8ffd9c1a84e3eeec00bed7832c043fe7f83d2be230284c6bb` | manifest, `checksums.sha256` | | |
| | HF path | `grounding/head.pt` | manifest | | |
| | Architecture | RemoteCLIP ViT-B/32 grounding head (feature_dim 2048, hidden 512) | manifest | | |
| | Backbone | `chendelong/RemoteCLIP` (`RemoteCLIP-ViT-B-32.pt`, rev `bf1d8a3ccf2d`) | manifest | | |
| | Config hash | `78f1e3700da15aa1` | manifest | | |
| | **Parameters** | **1,052,677** | `docs/PHASE8_GROUNDING_HEAD_DECISION.md` | | |
| #### Architecture (`specialists/grounding/head.py`) | |
| A trainable head over the **frozen** RemoteCLIP ViT-B/32 encoder. Per-cell feature is | |
| `concat([patch, text, patchΒ·text, global_pool]) = 4 Γ 512 = 2048` (enforced at config load β Β§3.0.2). | |
| ``` | |
| FROZEN INPUTS (measured, docs/PHASE7_GROUNDING_CONTRACT.md): | |
| patch tokens : (B, 49, 512) at 224px -- 7x7 grid, projected dim 512 | |
| text emb : (B, 512) | |
| ``` | |
| The head is `proj: Linear(2048, 512)`, `norm: LayerNorm(512)`, `drop: Dropout(0.10)`, | |
| `out: Linear(512, 5)` β five outputs `[tx, ty, tw, th, objectness]`. Forward: `proj β GELU β LayerNorm | |
| β Dropout β out`. | |
| **Small-std init, and objectness bias low** (`_init_weights`): `proj.weight ~ N(0, 0.02)`, | |
| `proj.bias = 0`, `out.weight ~ N(0, 0.01)`, `out.bias = 0` except `out.bias[4] = -2.0`. With one | |
| positive cell in 49 the task starts 1:48 imbalanced; a bias of β2.0 starts the objectness prior near | |
| 0.12, closer to the truth than 0.5, and stops the first epochs being spent un-learning a saturated | |
| sigmoid. | |
| **Box parameterisation β cell-relative (YOLO-style).** Cell `(r, c)` of a `grid_h Γ grid_w` map | |
| predicts `[tx, ty, tw, th, obj]`: | |
| ``` | |
| cx = (c + sigmoid(tx)) / grid_w | |
| cy = (r + sigmoid(ty)) / grid_h | |
| w = sigmoid(tw) | |
| h = sigmoid(th) | |
| box = clip((cx - w/2, cy - h/2, cx + w/2, cy + h/2), 0, 1) | |
| ``` | |
| Cell-relative rather than absolute because 7Γ7 is coarse: an absolute regressor must learn 49 separate | |
| mappings onto the same global coordinates; a cell-relative one only learns a local offset. After | |
| clipping, `enforce_order` guarantees `x2 β₯ x1` and `y2 β₯ y1` β without it a decoded box can be | |
| inverted, which makes IoU zero and silently kills the gradient. | |
| **Positive assignment.** Exactly one cell per target: the one containing the ground-truth box | |
| **centre** (standard single-stage-detector convention, YOLO/FCOS). Ties go to the earlier cell so the | |
| assignment is deterministic. This makes the 1-of-49 confidence imbalance explicit and therefore | |
| correctable. | |
| **NMS** is pure torch (no torchvision dependency), so the deployed Space needs no extra package and | |
| the behaviour is identical on CPU and GPU. | |
| **Loss** (`grounding_loss`): `box = L1(pos_boxes, targets)` (weight 0.5), | |
| `giou = 1 - GIoU(pos_boxes, targets)` (weight 0.3), and `conf = BCE_with_logits(obj_logits, | |
| conf_targets, weight=weights)` (weight 0.2) where the single positive cell is up-weighted by | |
| `positive_confidence_weight = 20.0`. Box and GIoU are computed on the **decoded** coordinates of the | |
| single positive cell per sample, so the loss is computed on exactly the coordinates the metric | |
| measures. GIoU is used alongside L1 because L1 alone is scale-blind. `LossBreakdown` keeps the | |
| components separate so a collapse in one is visible in the logs. | |
| #### Hyperparameters β every value | |
| From `configs/base.yaml` (`grounding:`, `grounding_head:`, `grounding_training:`): | |
| | Key | Value | | |
| |---|---| | |
| | `grounding.checkpoint_repo` | `chendelong/RemoteCLIP` | | |
| | `grounding.checkpoint_file` | `RemoteCLIP-ViT-B-32.pt` | | |
| | `grounding.checkpoint_revision` | `bf1d8a3ccf2d` | | |
| | `grounding.model_name` | `ViT-B-32` | | |
| | `grounding.image_size` | **224** | | |
| | `grounding.resolution_frozen` | true | | |
| | `grounding.nms_iou` | 0.50 | | |
| | `grounding.max_candidates` | 20 | | |
| | `grounding.confidence_threshold` | 0.40 | | |
| | `grounding.benchmark_box_scale` | 100.0 | | |
| | `grounding.coordinate_system` | `normalized_0_1` | | |
| | `grounding.encoder_projected_dim` | 512 | | |
| | `grounding_head.hidden_dim` | 512 | | |
| | `grounding_head.dropout` | 0.10 | | |
| | `grounding_head.feature_dim` | 2048 | | |
| | `grounding_head.positive_confidence_weight` | **20.0** | | |
| | `grounding_head.decode` | `cell_relative` | | |
| | `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 | | |
| **Why `positive_confidence_weight = 20.0`.** Objectness BCE sees ~1 positive cell out of 49. | |
| Unweighted, the optimum is "no object" everywhere; the weight is what stops that collapse. | |
| #### Training data | |
| VRSBench. The head trains on the train split and is evaluated on the VRSBench **eval** split, n = | |
| **16,159** records, scored once, no sampling. VRSBench stores boxes normalised to **0β100**; this | |
| project stores boxes normalised to **0β1**, via the declared `benchmark_box_scale: 100.0` so the | |
| conversion cannot be applied twice or forgotten. | |
| **Validation trajectory** (20 epochs, CPU, ~37 s/epoch) β `docs/PHASE8_GROUNDING_HEAD_DECISION.md`: | |
| ``` | |
| epoch 1/20 loss 0.4284 box 0.0641 giou 0.7948 conf 0.7897 val_iou 0.0436 | |
| epoch 5/20 loss 0.3864 box 0.0489 giou 0.7581 conf 0.6725 val_iou 0.0673 | |
| epoch 10/20 loss 0.3653 box 0.0446 giou 0.7313 conf 0.6179 val_iou 0.0831 | |
| epoch 15/20 loss 0.3488 box 0.0414 giou 0.7062 conf 0.5811 val_iou 0.0926 | |
| epoch 20/20 loss 0.3419 box 0.0401 giou 0.6937 conf 0.5685 val_iou 0.0943 | |
| ``` | |
| All four loss components move monotonically in the right direction. `conf` falls from an | |
| over-confident 0.79 as the positive-cell weighting corrects the objectness prior; `box` and `giou` fall | |
| together, which is the healthy pattern (a falling `box` with a rising `giou` would mean the box is | |
| drifting in size). **Validation IoU (0.0943 at epoch 20) is much lower than eval IoU (0.2566).** These | |
| are not comparable and the gap is expected: validation is a held-out slice of the **train** split, | |
| which has noisier ground truth (29.5 % of its boxes are out-of-range and filtered, and the surviving | |
| ones come from a different annotation pass). This is a property of VRSBench, not a bug. | |
| #### Evaluation protocol β two protocols Γ two decode variants | |
| `artifacts/grounding/remoteclip_grounding_v001/eval_result_canonical.json` (top_k = 20, the config | |
| default) and `β¦/eval_result_matched6.json` (top_k = 6, decode-matched to the baseline). Both record | |
| `n_eval_records: 16159`, `resolution: 224`, `grid: 7`, `limited_run: false`, | |
| `frozen_config_evaluation: true`, device `cpu`, torch `2.14.0+cpu`, and | |
| `config_hash: 78f1e3700da15aa1`, `config_drift: false`. | |
| **The `head_decode` block is recorded in each artifact** β `config_default_top_k: 20`, `nms_iou: 0.5`, | |
| `score_threshold: 0.4`, `top_k: 20` (canonical) or `top_k: 6` (matched6). Without it the artifact could | |
| not say which setting produced its number, and a re-run at the config default would silently yield a | |
| different figure. | |
| #### Measured numbers | |
| **The trained head, under the two protocols:** | |
| | Protocol | mean best IoU | recall@0.5 | Artifact key path | | |
| |---|---|---|---| | |
| | canonical (head threshold decode, top_k = 20) | **0.2838** | **0.2198** | `results.head_threshold.mean_best_iou` / `results.head_threshold.recall.0.50` | | |
| | matched6 (head threshold decode, top_k = 6) | **0.2566** | **0.1938** | `results.head_threshold.mean_best_iou` / `results.head_threshold.recall.0.50` | | |
| **Two decode variants, reported for completeness** (a reviewer must see the whole grid, not one cell): | |
| | Variant | mean best IoU | recall@0.10 | recall@0.25 | recall@0.50 | | |
| |---|---|---|---|---| | |
| | head argmax decode (canonical) | **0.1215** | 0.3183 | 0.2088 | 0.0795 | | |
| | zero-shot matched (no trained head, canonical) | **0.0972** | 0.3298 | 0.1188 | 0.0234 | | |
| **Full canonical grid** (`eval_result_canonical.json` β `results`): | |
| | Strategy | `mean_best_iou` | `recall.0.10` | `recall.0.25` | `recall.0.50` | `latency_ms_per_image` | `seconds` | | |
| |---|---|---|---|---|---|---| | |
| | `head_argmax` (strategy `argmax`) | 0.1215 | 0.3183 | 0.2088 | 0.0795 | 0.655 | 10.6 | | |
| | `head_threshold` (strategy `threshold`, top_k 20) | 0.2838 | 0.6882 | 0.5047 | 0.2198 | 2.205 | 35.6 | | |
| | `zero_shot_matched` | 0.0972 | 0.3298 | 0.1188 | 0.0234 | β | 17.9 | | |
| **Full matched6 grid** (`eval_result_matched6.json` β `results`): `head_argmax` identical (0.1215); | |
| `head_threshold` (top_k 6) 0.2566 / 0.6315 / 0.4545 / 0.1938 at 2.158 ms; `zero_shot_matched` | |
| identical (0.0972). Each artifact also carries `phase7_reference: {mean_best_iou: 0.0972, | |
| recall_at_0.50: 0.0234, source: "docs/PHASE7_RESOLUTION_DECISION.md"}`. | |
| **Decode-matched delta (head threshold top_k = 6 vs zero-shot): +0.1594 IoU, +0.1704 Recall@0.50.** | |
| The pre-registered bar was `MIN_IMPROVEMENT_IOU = 0.02`; the measured margin is **8Γ the bar**. | |
| **`head_argmax` vs zero-shot is NOT apples-to-apples.** 1 box against ~6 boxes flatters the head, | |
| because mean best IoU takes the max over predictions. It is reported because it is the number directly | |
| comparable to the Phase 7 resolution experiment's zero-shot argmax, not because it decides anything. | |
| **A defect this phase had to fix first** (`docs/PHASE8_GROUNDING_HEAD_DECISION.md`). The evaluation | |
| script originally built its own single-box baseline with `argmax_candidate`, while Phase 7 measured the | |
| baseline through `ground_phrase` (threshold box + up to 5 local maxima). Same 16,159 records, same | |
| metric, same cached features β **different decode**: Phase 7 via `ground_phrase` = 0.0972, eval via | |
| `argmax_candidate` = 0.0092, a 10Γ gap. The eval printed `head beats zero-shot: True (+0.1123)` when | |
| the matched comparison was +0.0243. Both cleared the bar, but only the second is a claim about the head | |
| rather than about the decode. **Fix:** the decode was extracted into one function both paths call β | |
| `specialists/grounding/inference.py::decode_candidates_from_features` β so there is exactly one place | |
| that turns similarity into boxes. Verification that the fix is real, not cosmetic: re-running the eval | |
| reproduced the Phase 7 number exactly (`|difference| = 0.0000`). | |
| **A second defect: candidate count changes the number.** Mean best IoU is a max over predictions, so | |
| emitting more boxes raises it mechanically. The head's threshold decode defaulted to `max_candidates: | |
| 20` while the baseline emits 5.99 β an uncontrolled asymmetry in the head's favour. Fix: | |
| `--head-top-k` and `--head-score-threshold` overrides, plus the recorded `head_decode` block. Measured | |
| cost of the cap: top_k 20 β 0.2838 (+0.1866 vs zero-shot); top_k 6 β 0.2566 (+0.1594). Capping to the | |
| baseline's own budget costs 0.027 IoU β **the win survives.** | |
| #### Acceptance status | |
| `MEASURED`. Phase 8 verdict: **the trained head beats the zero-shot baseline**; Phase 8 is done. The | |
| head is **not yet wired into the specialist** as of the Phase 8 decision record | |
| (`specialists/grounding/inference.py` still exposes only the zero-shot path), and that integration step | |
| must not change the zero-shot module's behaviour because the resolution experiment depends on it. | |
| #### Limitations | |
| - **Absolute IoU is low.** 0.2838 / 0.2566 is not "solved". | |
| - **Protocol-sensitive.** 0.2838 (canonical) vs 0.1215 (argmax) β absolute values depend on decode. | |
| - **Localisation floor.** At 224 each token covers 1/7 (~0.143) of the image width; at 448 it is 1/14. | |
| - **`head_threshold` at top_k = 20 was not tuned.** 20 is the config default, not a | |
| validation-selected optimum. Selecting it on eval would be benchmark tuning. | |
| - **Nothing about the hidden distribution.** VRSBench is overhead optical; the hidden set is | |
| Cartosat-2S + RISAT. | |
| - **Not a system-level result.** "Beats the zero-shot baseline on VRSBench eval" β "performs well on | |
| the hidden ISRO/SAC set". | |
| --- | |
| ### 3.5 `router` β intent adapter over frozen MiniLM | |
| **Status:** `IMPLEMENTED`, `MEASURED`, **TEST NOT RUN**. | |
| A **~50,822-parameter adapter** over the frozen MiniLM encoder. Because the encoder is frozen, | |
| embeddings are cached and the adapter trains on cached vectors β no GPU required (measured: 20 epochs / | |
| 4,096 vectors in 0.28 s on CPU). | |
| #### Identity | |
| | Field | Value | Source | | |
| |---|---|---| | |
| | Manifest `id` | `router_adapter` | `models/manifest.json` | | |
| | Path | `artifacts/router/router_adapter_v001/adapter.pt` | manifest | | |
| | Bytes | 211,961 | manifest | | |
| | sha256 | `8527c3ed28a293e13293d48601d48e3ceafa137b9acabddaf5de31a58a509b5c` | manifest, `checksums.sha256` | | |
| | HF path | `router/adapter.pt` | manifest | | |
| | Architecture | task/modality adapter over frozen MiniLM embeddings (~50,822 params) | manifest | | |
| | Backbone | `sentence-transformers/all-MiniLM-L6-v2` (rev `1110a243fdf4`) | manifest | | |
| | Config hash | `78f1e3700da15aa1` | manifest | | |
| | Parameters | `UNKNOWN β not established from the available evidence` (manifest records `null`) | manifest | | |
| > **Parameter-count discrepancy β flagged, not smoothed over.** `specialists/router/adapter.py`'s | |
| > docstring and `models/manifest.json` both state **~50,822** parameters. The Phase 4 completion | |
| > report (`docs/PHASE4_ROUTER_REPORT.md`) states **51,725** adapter parameters on a frozen | |
| > 22,713,216-param encoder and an artifact size of 223.8 KB, whereas the released artifact is | |
| > 211,961 bytes. These two figures were produced at different times and have not been reconciled. | |
| > The release documents the **manifest** figure (50,822) as the shipped number; which figure is | |
| > authoritative is | |
| > `UNKNOWN β not established from the available evidence`. | |
| #### Architecture (`router/adapter.py`) | |
| The only trainable part of the router. It sits on top of the frozen MiniLM embedding and emits **five | |
| heads**: | |
| ``` | |
| embedding (384) | |
| | | |
| LayerNorm | |
| | | |
| Linear(384 -> hidden_dim) default hidden_dim = 128 | |
| | | |
| GELU | |
| | | |
| Dropout | |
| | | |
| +--> 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 | |
| ``` | |
| `_init_weights` uses small-std init (`std = 0.02`) with zero bias on every head, keeping the initial | |
| sigmoid near 0.5 β without it the binary heads can start saturated and BCE gradients vanish before the | |
| task head has learned anything useful. `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). | |
| #### Hyperparameters β every value | |
| From `configs/base.yaml` (`router:`): | |
| | 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 | | |
| **Splits are by GROUP (template / hard-negative family), never by example.** 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 so their accuracy measures generalisation, not memorisation. | |
| #### Training data | |
| A **synthetic** corpus: 576 examples, 54 groups, hand-written and templated | |
| (`threshold_sweep_val.json`: `corpus_total: 576`, `corpus_groups: 54`, `corpus_limited: true`). Split | |
| sizes: `train 410`, `val 86`, `test 80`. The plan's minima are `plan_min_val_queries: 500` and | |
| `plan_min_hard_negatives: 100`; the corpus is well below both, and the artifact says so. | |
| #### Evaluation protocol and measured numbers | |
| `artifacts/router/threshold_sweep_val.json`. The sweep iterates thresholds 0.50 β 0.99 and records | |
| coverage, covered-task accuracy, fallback rate and `n_covered` per threshold. `select_by: | |
| "covered_accuracy"`; `selected.threshold: 0.76` (coverage 0.790698, covered-task accuracy 1.0); | |
| `shipped_threshold: 0.70` (coverage 0.848837, covered-task accuracy 0.972603). | |
| | Field | Value | Exact 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_support` | 8 | `val_min_support` | | |
| | `val_task_counts` | `{caption: 8, change: 20, grounding: 14, optical_sar: 10, unsupported: 19, vqa: 15}` | `val_task_counts` | | |
| | `split_sizes` | `{train: 410, test: 80, val: 86}` | `split_sizes` | | |
| | `delta_vs_shipped.coverage` | β0.0581 | `delta_vs_shipped.coverage` | | |
| | `delta_vs_shipped.covered_task_accuracy` | 0.0274 | `delta_vs_shipped.covered_task_accuracy` | | |
| | adapter encoder params | 22,713,216 | `adapter_encoder.parameters` | | |
| | `adapter_config_hash` | `615478910dc266bf` | `adapter_config_hash` | | |
| | `config_hash` | `78f1e3700da15aa1` | `config_hash` | | |
| **This number is (a) validation-only, (b) ungated, and (c) small (n = 86).** The artifact's own note is | |
| blunt: *"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. Backlog P1-9's 'n=80' is the TEST split; the | |
| sweep target is val n=86. **The test split was NOT touched.**"* | |
| **The router TEST split was NOT RUN.** Do not read 0.965116 as a test result. The sweep scored 86 | |
| validation examples and zero test examples, and `test_split_touched` is `false`. | |
| > **Historical note, recorded so the two are not conflated.** The Phase 4 completion report | |
| > (`docs/PHASE4_ROUTER_REPORT.md`, verified 2026-09-16, Gate 2 PASS) records an **earlier** gate-2 | |
| > evaluation that included a test column β test task accuracy **0.975**, macro F1 0.976, per-class test | |
| > recall (vqa 1.000/n=15, caption 0.846/n=13, grounding 1.000/n=17, change 1.000/n=13, optical_sar | |
| > 1.000/n=12, unsupported 1.000/n=10) and hard-negative accuracy **0.800**. That evaluation predates | |
| > the shipped threshold sweep, and its own standing caveats are that the corpus is synthetic and that | |
| > *"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. | |
| #### Acceptance status | |
| `MEASURED`; the headline is validation, ungated, n = 86. No test result is claimed. | |
| #### Limitations | |
| - **Corpus-limited** (n = 86 val; plan minimum 500; hard negatives in val = 0 by design). | |
| - **Not a calibration** β the threshold is a justified default, not a calibrated value. | |
| - **Synthetic corpus** β 576 hand-written/templated examples. | |
| - **`caption` has the smallest support** (8 in val), the tightest per-class floor. | |
| - **Known misroutes** are catalogued in [`LIMITATIONS.md`](LIMITATIONS.md) Β§2 (e.g. *"What is the new | |
| runway?"* reads `change`, not `vqa`; *"How much built-up area was added?"* under-triggers to `vqa`). | |
| - **Threshold 0.70 is uncalibrated** β it gates the lexical fallback. | |
| --- | |
| ### 3.6 `vlm` β SmolVLM LoRA adapter (USABLE_VERIFIED, ACCEPTANCE-REJECTED) | |
| **Status:** `IMPLEMENTED`, `MEASURED`, **ACCEPTANCE-REJECTED**. | |
| > This is Β§3.6 because [`TRAINING.md`](TRAINING.md) Β§7 cross-references it. The section number is kept | |
| > stable so that link resolves. | |
| A PEFT LoRA adapter on frozen `HuggingFaceTB/SmolVLM-500M-Instruct`. **The artifact's status is | |
| `CLOSED` with headline `ACCEPTANCE-REJECTED`.** This is not a contradiction β it is the project's | |
| central truthfulness distinction: **`USABLE_VERIFIED`** (the adapter demonstrably works; the metrics | |
| are real and reproducible) and **`ACCEPTANCE-REJECTED`** (it is *not accepted* for production | |
| promotion). **USABLE β ACCEPTED.** The deployed caption/VQA path therefore uses the **unadapted** | |
| SmolVLM. | |
| #### Identity | |
| | Field | Value | Source | | |
| |---|---|---| | |
| | Manifest `id` | `vlm_lora_adapter` | `models/manifest.json` | | |
| | Path | `.scratch/phase6_real_adapter/phase6_adapter/adapter_model.safetensors` | manifest | | |
| | Bytes | 34,798,048 | manifest | | |
| | sha256 | `07c76a75fa04624880ed7730590f5fdd7b145a8232e3c0af411c3c545a5adf5e` | manifest, `checksums.sha256` | | |
| | HF path | `vlm/adapter_model.safetensors` | manifest | | |
| | Architecture | PEFT LoRA (r=16, alpha=32, dropout=0.05) on text_model projections | manifest | | |
| | Base model | `HuggingFaceTB/SmolVLM-500M-Instruct` (rev `a7da5b986cb5`) | manifest, closure | | |
| | `kind` | `bigearthnet_smolvlm_lora` | closure β `production_adapter.kind` | | |
| | **tree sha256** | `5c6b86317d1e65962702dc9e377009b3df41cc13de1b15bceccb70ad977775e7` | closure β `adapter_tree_sha256` | | |
| | **file sha256** | `07c76a75fa04624880ed7730590f5fdd7b145a8232e3c0af411c3c545a5adf5e` | closure β `weights_file_sha256` | | |
| | Trainable params | **8,683,520** (1.6823 % of 516,165,824) | closure β `trainable_params` | | |
| | Config hash | `78f1e3700da15aa1` | manifest | | |
| | `retrained_for_closure` | **false** | closure | | |
| | `modified_for_closure` | **false** | closure | | |
| #### Architecture and target modules | |
| LoRA, `peft_type=LORA`, `r=16`, `alpha=32`, `dropout=0.05`, targeting | |
| `model.text_model.*.{q,k,v,o,gate,up,down}_proj` β `lora_target_module_count: 224`. | |
| **The vision-tower hazard did not occur.** `trainable_subtrees` is exactly | |
| `{"model.text_model": 8683520}`; the vision model (86,433,024) and connector (11,796,480) appear in | |
| `frozen_params`, which are: | |
| | Frozen subtree | Parameters | | |
| |---|---| | |
| | `model.connector` | 11,796,480 | | |
| | `model.text_model` | 361,944,000 | | |
| | `model.vision_model` | 86,433,024 | | |
| | `other` | 47,308,800 | | |
| The `trainable_params` figure was measured two independent ways (closure β `trainable_params.how`): | |
| summing `numel` over every tensor in `adapter_model.safetensors` read from the safetensors header | |
| (no model load), and loading through the production path and reading `sum(p.numel())` then subtracting | |
| the manifest frozen-params sum. Both give 8,683,520; `manifest_matches_measurement: true`. | |
| #### Hyperparameters β every value | |
| From `configs/base.yaml` (`training:`) and the closure record: | |
| | Key | Value | | |
| |---|---| | |
| | `training.precision` | **fp16** (finding C-6: T4 is SM 7.5 β fp16, NOT bf16) | | |
| | `training.vlm_batch_size` | 2 | | |
| | `training.vlm_gradient_accumulation` | 8 | | |
| | `training.vlm_learning_rate` | 0.0002 | | |
| | `training.vlm_epochs` | 1 | | |
| | `training.lora_rank` | 16 | | |
| | `training.lora_alpha` | 32 | | |
| | `training.lora_dropout` | 0.05 | | |
| | `training.weight_decay` | 0.01 | | |
| | `training.warmup_ratio` | 0.05 | | |
| | `training.gradient_checkpointing` | true | | |
| | `training.save_every_steps` | 500 | | |
| | PEFT version | **0.19.1** | | |
| | Seed | 42 | | |
| | `precision_recorded` | fp16 | | |
| #### Training data | |
| BigEarthNet instruction pairs (the same 28,000-patch local subset used for the fusion head), on an | |
| external GPU (Kaggle T4-class). This is the only artifact that requires a GPU to train. | |
| #### Evaluation protocol | |
| `artifacts/vlm/phase6_closure.json` (generated by `scripts/phase6_close.py`, which reads the evidence | |
| rather than restating it). The evaluation is on a **frozen 1,000-question subset** of the test split. | |
| The subset's identity was proven **without a model** (Gate Aβ³): `available_per_split {val: 6750, test: | |
| 7772}`, subset `n = 1000`, and the test per-class counts an **exact** match to the recovery subset | |
| (19 classes, sum 1000), `config_hash 78f1e3700da15aa1` identical to Run 1's. | |
| Gate D reproduced Run 1's adapted-test control **exactly** β loading the local adapter and evaluating | |
| it returned `exact_match 0.963`, `n 1000`, confusion `{tp:500, fp:19, tn:463, fn:18}`, | |
| `f1 0.9643201542912246` β identical to Run 1's recorded values, which proves the local artifact *is* | |
| Run 1's adapter and that CPU/fp32 reproduces the Kaggle T4 endpoint. | |
| #### Measured numbers β the "usable" side | |
| `artifacts/vlm/phase6_closure.json` β `why_usable_verified.adapted_test`: | |
| | Metric | Value | Exact key path | | |
| |---|---|---| | |
| | **exact_match** | **0.963** | `why_usable_verified.adapted_test.exact_match` | | |
| | **F1** | **0.96432** | `why_usable_verified.adapted_test.f1` | | |
| | n | 1000 | `β¦adapted_test.n` | | |
| | precision | 0.963391 | `β¦adapted_test.precision` | | |
| | recall | 0.965251 | `β¦adapted_test.recall` | | |
| | tp / fp / tn / fn | 500 / 19 / 463 / 18 | `β¦adapted_test.confusion` | | |
| | aggregate test delta | **+49.50 pp** (46.80 β 96.30 pp) | `why_usable_verified.aggregate_test_delta_pp` | | |
| Per-class accuracy on the adapted test subset ranges from 0.8 (Agro-forestry areas, n=20) to 1.0 | |
| (several classes); `Mixed forest` 0.878788 (n=33), `Permanent crops` 0.862069 (n=29), `Broad-leaved | |
| forest` 0.898551 (n=69), `Pastures` 0.916667 (n=132), `Marine waters` 1.0 (n=329). | |
| #### Measured numbers β the "rejected" side | |
| `artifacts/vlm/phase6_closure.json` β `why_acceptance_rejected`. Decision split: **test**. Rule | |
| version: **v002**. | |
| | | | | |
| |---|---| | |
| | **V1** | **passes** β test `46.80 β 96.30 pp`, delta **+49.50 pp** (required β₯ +5.00) | | |
| | **V2** | **fails** β **Mixed forest**: `n=33`, `100.00 β 87.8788 pp`, `drop 12.1212 pp`, `lost_questions 4`, `z 2.1335` | | |
| It fails **both** halves of v002 (`lost β₯ 4` **and** `z β₯ 1.96`). Per item V, a complete run that fails | |
| V2 is `REJECTED`. | |
| Thresholds used (`why_acceptance_rejected.thresholds_used`): `accept_min_delta_pp: 5.0`, | |
| `accept_min_delta_pp_ceiling: 2.0`, `ceiling_baseline_pp: 95.0`, `max_class_drop_pp: 1.0` (legacy | |
| v001), `min_class_questions: 20`, `test_val_disagreement_pp: 10.0`; `v2_criterion`: `class_drop_z: | |
| 1.96`, `min_class_drop_questions: 4`, `min_class_questions: 20`, with | |
| `se_formula: "sqrt((p_b*(1-p_b) + p_a*(1-p_a)) / n)"` and `z_formula: "(baseline - adapted) / se"`. | |
| **The rejection is narrow.** `n_classes_failed: 1`, `n_classes_held: 5`, `n_classes_improved: 11`, | |
| `n_classes_total: 19`. The next-worst class (Inland wetlands, β6.6667 pp, n=30) lost only 2 questions | |
| and therefore sits below V2's materiality floor. | |
| **It is not a split artefact.** The same class also degraded on **val** in Run 1 (drop 6.4516 pp, | |
| n=31) β the very value that motivated v001's flag. The adapter hurts Mixed forest on **both** splits, | |
| so this is a property of the adapter, not an accident of one subset. Mixed forest also sits at a | |
| 100.00 pp baseline on test, so any loss is a drop from the ceiling. | |
| **Residual risk, reported not resolved** (`residual_risk`): the verdict rests on **4 questions in one | |
| class of 33** β the unfloored minimum-size exposure recorded at `PHASE6_AUDIT_AND_CONTRACT.md` Β§8.6. | |
| With no `n β₯ N` floor in V2, a 33-question class can flip the verdict of a run whose aggregate endpoint | |
| improved by 49.5 pp. | |
| **BERTScore is unavailable**, not zero: `thresholds_used.bertscore.available: false`, reason | |
| `roberta-large` is not in the local HuggingFace cache. BLEU/ROUGE are **excluded** because target | |
| answers are one token and they are meaningless at that length. | |
| #### Both rejection records are preserved | |
| Neither is rewritten. A future reader must be able to see what each rule said, on which split, at the | |
| time it said it (`preserved_records`): | |
| | Record | Rule | Split | Verdict | Source | | |
| |---|---|---|---|---| | |
| | Run 1's own manifest | `v001` | val | **`REJECTED`** (3 classes) | Run 1's original manifest, preserved verbatim as `preserved_records.v001_val_rejected` in `phase6_closure.json` | | |
| | Test-split adjudication | `v002` | **test** (independent) | **`REJECTED`** (1 class) | `artifacts/vlm/run1_test_recovery/test_adjudication.json` | | |
| | Recovery manifest | `v002` | val | `ACCEPTED` β **not final acceptance** | `artifacts/vlm/run1_test_recovery/run_manifest.json` | | |
| **v001 on val** failed three classes β Mixed forest (6.4516 pp, n=31), Transitional woodland shrub | |
| (9.375 pp, n=32), Agro-forestry areas (4.6512 pp, n=43) β with `val_delta_pp 42.0` and | |
| `test_delta_pp null`. **The val-split `ACCEPTED` under v002 is recorded for completeness only and is | |
| NOT final acceptance** β it decides on the *same val subset that motivated v002*, which | |
| `docs/PHASE6_RUN1_REJECTION_DIAGNOSIS.md` Β§7.4 condition 3 forbids. On data that did not motivate the | |
| relaxation, the run does not pass. | |
| #### Acceptance status β and what closure does NOT claim | |
| `status: CLOSED`; `headline: "Phase 6 is closed. The Run 1 LoRA adapter is promoted to the production | |
| VLM adapter: USABLE and VERIFIED, but ACCEPTANCE-REJECTED."` The record keeps verified and accepted | |
| separate: *"'Verified' answers: is this artifact the one we trained, and does it work? 'Accepted' | |
| answers: did it clear the bar predeclared before we looked? Both are true, and they are different | |
| questions."* | |
| `what_closure_does_not_claim`: | |
| - It does **not** claim Run 1 was accepted β it was rejected by v001 on val and by v002 on the | |
| independent test split. | |
| - It does **not** claim the Mixed forest regression is resolved. | |
| - It does **not** claim a new adapter exists or is planned. | |
| - It does **not** alter the v002 rule or its verdict. | |
| **Forward rule** (`forward_rule`): any future improved adapter MUST be a **new** experiment/version. It | |
| MUST NOT rewrite, amend, or supersede Run 1's records; it must not retrain or modify the Run 1 adapter | |
| in place; it must not report a new adapter's metrics under Run 1's identity; it must not edit v001's or | |
| v002's recorded verdicts to match a later outcome. Precedent: v002 itself followed this rule. | |
| #### How it is enabled, and the two traps | |
| The adapter is attached via an **environment setting, not a code change**: | |
| `specialists/vqa/model.py` resolves it in the order explicit `adapter_path` argument β | |
| **`SATQUERY_VLM_ADAPTER`** (`ADAPTER_ENV_VAR`) β none, then attaches it with | |
| `PeftModel.from_pretrained`. A load failure raises rather than silently serving the base model: an | |
| inference result attributed to an adapter that did not actually attach is worse than a hard failure. | |
| Two traps a future reader will hit (`known_traps`): | |
| 1. **`adapter_sha256` names two different values.** `training/vlm/artifact.py` computes a **tree hash** | |
| over the `{relpath: sha256}` weight map (`5c6b8631β¦`), while | |
| `specialists/vqa/model.py::_adapter_sha256` computes the **file** sha256 of | |
| `adapter_model.safetensors` (`07c76a75β¦`). Recomputing one and comparing it to the other yields a | |
| false "the artifact was altered" conclusion. | |
| 2. **The promoted adapter is NOT `checkpoint-2000`.** The three weight files have three different | |
| digests: top-level `07c76a75β¦`, `checkpoint-1500` `7273588eβ¦`, `checkpoint-2000` `bf249943β¦`. So | |
| "just use the last checkpoint" is not equivalent to this artifact. | |
| #### Limitations | |
| - **Acceptance-rejected** β the deployed path uses the unadapted model. | |
| - **Residual risk** β the verdict rests on 4 questions in one class of 33. | |
| - **`Mixed forest` regression unresolved**; it degrades on both val and test. | |
| - **The path lives under `.scratch`** β `.gitignore` excludes `artifacts/`, `checkpoints/` and | |
| `*.safetensors`, so the ~105 MB local directory is not committed; a future cleanup could remove it. | |
| It is reconstructible from `phase6_realbundle.zip` and verifiable against the two digests above. | |
| - **BERTScore not computed** (unavailable offline); BLEU/ROUGE excluded as meaningless at one-token | |
| answers. | |
| --- | |
| ## 4. Rejected, deferred and open model decisions | |
| | Decision | Outcome | Evidence | | |
| |---|---|---| | |
| | Grounding image resolution 448 vs 224 | **224 chosen; 448 REJECTED** | `docs/PHASE7_RESOLUTION_DECISION.md`; Β§4.1 below | | |
| | VLM adapter promotion | **REJECTED** | metrics usable, acceptance rejected (Β§3.6) | | |
| | Calibration | **kept but ineffective** | ECE worsened (Β§5) | | |
| | Optical-SAR ruling | **OPEN** | no decision recorded; `is_deciding_statistic: false` | | |
| | Change-VQA ruling | **OPEN** | `verification.metric_ruling` | | |
| | CROMA constructor (`use_croma.PretrainedCROMA`, vendored) | **ACCEPTED** as an implementation detail | `docs/PHASE14_OPTICAL_SAR_DECISIONS.md` Β§2 (DEV-1) | | |
| | CROMA input normalisation | **RESOLVED** 2026-09-18: option (a) adopted; (c) gated experiment; (b) rejected | `docs/PHASE14_CROMA_NORMALISATION_CHANGE.md` (DEV-2) | | |
| | CROMA patch count (225) | **CONSISTENT but UNVERIFIED** upstream; checked at load time | `docs/PHASE14_OPTICAL_SAR_DECISIONS.md` Β§4 (DEV-3) | | |
| | `sa_mode: BAM` | **declared, NOT IMPLEMENTED** β raises rather than aliasing PAM | `specialists/change/stanet.py` | | |
| | Change head wired into serving by default | **DEFERRED** β registry override, not config | `specialists/change/specialist.py` | | |
| | Grounding head wired into `inference.py` | **DEFERRED** (Phase 8 integration step) | `docs/PHASE8_GROUNDING_HEAD_DECISION.md` | | |
| | `optical.normalization` / `sar.representation` config keys | **OPEN** β read by no code | `docs/PHASE14_OPTICAL_SAR_DECISIONS.md` Β§6 item 6 | | |
| ### 4.1 The grounding resolution experiment β 448 REJECTED | |
| **Rule, fixed before the result was seen** (`docs/PHASE7_RESOLUTION_DECISION.md`; recorded in the | |
| artifact as `rule_changed_since_preregistration: false`): | |
| ``` | |
| 448 WINS if Recall@0.5 improves by >= 0.05 absolute | |
| OR mean best IoU improves by >= 0.05 absolute | |
| 224 WINS otherwise | |
| INCONCLUSIVE if fewer than 30 samples were scored | |
| ``` | |
| **Run:** full VRSBench eval split, **16,159 / 16,159 records scored at both resolutions**, Tesla T4, | |
| `--all --device cuda --tag full`. | |
| **Result: 224 WINS.** | |
| ``` | |
| recall@0.5 gain 448/224 : -0.0022 | |
| bestIoU gain 448/224 : -0.0147 | |
| latency ratio : 1.59x | |
| ``` | |
| Neither component came close to the +0.05 margin. Both were **negative**. | |
| | Metric | 224 | 448 | delta | | |
| |---|---|---|---| | |
| | token grid | 7 Γ 7 = 49 | 14 Γ 14 = 196 | 4.0Γ tokens | | |
| | attention cost (nΒ²) | 1Γ | 16Γ | β | | |
| | with boxes | 16159/16159 | 16159/16159 | β | | |
| | **mean best IoU** | **0.0972** | 0.0825 | **β0.0147** | | |
| | Recall@0.10 | **0.3298** | 0.2599 | **β0.0699** | | |
| | Recall@0.25 | **0.1187** | 0.0944 | **β0.0243** | | |
| | Recall@0.50 | **0.0234** | 0.0212 | **β0.0022** | | |
| | matched IoU | 0.0972 | 0.0825 | β0.0147 | | |
| | latency mean | **20.0 ms** | 31.8 ms | **1.59Γ** | | |
| | latency p90 | **20.9 ms** | 32.9 ms | 1.57Γ | | |
| | peak VRAM | **592.1 MB** | 599.8 MB | +7.7 MB | | |
| | wall time | **~8.5 min** | ~11.2 min | 1.32Γ | | |
| **448 is worse on every quality metric and slower. There is no axis on which it wins.** | |
| **Best-IoU distribution** β a whole-distribution move toward the zero-overlap bucket, not a tail | |
| effect: | |
| | bucket | 224 | 448 | | |
| |---|---|---| | |
| | 0.00β0.10 | 10,829 | 11,957 | | |
| | 0.10β0.25 | 3,412 | 2,677 | | |
| | 0.25β0.50 | 1,540 | 1,182 | | |
| | 0.50β0.75 | 336 | 307 | | |
| | 0.75β1.01 | 42 | 36 | | |
| **Paired analysis β independent confirmation.** Both resolutions scored the **same 16,159 samples**, so | |
| the paired test is the stronger statistic: it removes between-object variance. | |
| ``` | |
| paired samples : 16159 | |
| mean 224 : 0.0972 | |
| mean 448 : 0.0825 | |
| mean paired diff : -0.0147 (95% CI -0.0160 .. -0.0134) | |
| t statistic : -22.63 | |
| CI excludes zero : True | |
| 448 better on : 1371/16159 ( 8.5%) | |
| 448 worse on : 3372/16159 (20.9%) | |
| identical : 11416/16159 (70.6%) | |
| ``` | |
| **The pre-registered rule and the paired test AGREE on 224.** There is no rule-versus-evidence | |
| disagreement to escalate: both say 224, and the confidence interval excludes zero by a wide margin. The | |
| win/loss split is also informative β 448 wins on only 8.5 % of records and loses on 20.9 %; the finer | |
| grid is not merely neutral, it is **actively harmful** on a fifth of the corpus. | |
| **Paired recall ladder** β the gap **narrows as the threshold rises**, the signature of a method that | |
| cannot reach high IoU either way: | |
| | threshold | 224 | 448 | diff | 95% CI | | |
| |---|---|---|---|---| | |
| | 0.10 | 0.3298 | 0.2599 | β0.0699 | excludes zero | | |
| | 0.25 | 0.1187 | 0.0944 | β0.0243 | excludes zero | | |
| | 0.50 | 0.0234 | 0.0212 | β0.0022 | excludes zero | | |
| **Why 448 did not help β the honest reading.** The zero-shot method selects a patch by text similarity | |
| and returns that patch's box. At 224 a box is 1/7 of the image; at 448 it is 1/14. A finer grid is only | |
| better if the target is small **and** the similarity peak lands on the correct fine cell. Two things | |
| work against that here: (1) the peak is not sharper at 448 β splitting each cell into four gives four | |
| chances to pick a wrong sub-cell, and the similarity field on frozen features is smooth, so the argmax | |
| moves around (losses outnumber wins 2.5 : 1); (2) **Recall@0.10 drops the most (β0.0699)** β if finer | |
| tokens genuinely localised better, the *loosest* threshold would benefit most. It degrades most, which | |
| means the fine grid adds positional noise rather than positional precision. **This is the zero-shot | |
| baseline's limitation, not a property of RemoteCLIP.** | |
| **Degeneracy notes: none.** At n=12, n=40 and n=6 the smoke runs reported `Recall@0.5 = 0.0000` at | |
| **both** resolutions and emitted a degeneracy warning. At full scale the metric is non-zero | |
| (0.0234 / 0.0212), so the note correctly did not fire β a genuine sub-floor artefact that the full run | |
| resolved, which is why the sub-floor runs were never treated as evidence. | |
| **What this establishes:** grounding runs at 224, frozen in `configs/base.yaml` | |
| (`grounding.image_size: 224`, `grounding.resolution_frozen: true`); peak VRAM for the frozen encoder at | |
| 224 is 592 MB (inside the ZeroGPU free tier and a T4); encoder latency at 224 on a T4 is 20 ms/image; | |
| the 224 localisation floor is 1/7 of image width per token. | |
| **What this does NOT establish:** whether the zero-shot baseline is good (it is not β 0.0972 / 0.0234 | |
| are weak, an ablation floor for the Phase 8 head); whether a **trained** head has the same resolution | |
| sensitivity (re-opening the question after Phase 8 is legitimate *if* the head's validation curve | |
| suggests it, and would be a new pre-registered experiment, not a silent retune); anything about hidden | |
| ISRO/SAC imagery. | |
| --- | |
| ## 5. Calibration β a measured negative result | |
| **Status:** `MEASURED`; the calibration is **not an improvement**. | |
| Temperature scaling is enabled (`confidence.temperature_scaling: true`) with | |
| `artifacts/calibration_v001.json` (`schema: calibration_v1`). It is scoped to the **R-02 change-VQA | |
| head's answer confidence only** β *other specialists emit their own raw scores and are unaffected* | |
| (`scope.note`). | |
| | Field | Value | Exact key path | | |
| |---|---|---| | |
| | **temperature** | **0.9772731820958189** (β 0.9772732) | `temperature_scaling.temperature` | | |
| | fitted on | `Val` | `temperature_scaling.fitted_on` | | |
| | n samples | 16,441 | `temperature_scaling.n_samples` | | |
| | method | `temperature_scaling` | `provenance.method` | | |
| | objective | `mean_negative_log_likelihood` | `provenance.objective` | | |
| | optimizer | `golden_section_on_log_temperature` | `provenance.optimizer` | | |
| | space | `multiclass_logits` | `provenance.space` | | |
| | iterations | 200 | `provenance.iterations` | | |
| | `log_temperature` | β0.022989052824434128 | `fit_diagnostics.log_temperature` | | |
| | `hit_bound` | false | `fit_diagnostics.hit_bound` | | |
| | `effective` | true | `fit_diagnostics.effective` | | |
| | held-out splits excluded | `[Test, Test2]` | `provenance.held_out_splits_excluded` | | |
| | checkpoint sha256 | `cfae5e43b97ca930f568dc5b8ae4f36b24e9ff717af226159802206ffd63a82a` | `provenance.checkpoint_sha256` | | |
| **The result β ECE got worse:** | |
| | Metric | Before | After | Improvement | | |
| |---|---|---|---| | |
| | **ECE** | **0.013755** | **0.014929** | **β0.001174** | | |
| | NLL | 0.689741 | 0.689631 | +0.00011 | | |
| `ece_improvement = β0.001174` is **negative**: expected calibration error **increased**. NLL improved | |
| by a negligible 0.00011 (fit diagnostics record 0.0001103574982127542). `n_bins: 15`, | |
| `n_classes: 19`, `n_samples: 16441`. | |
| **The scaling is retained because it is part of the frozen configuration, not because it helped.** This | |
| is recorded as a negative result, not smoothed over. | |
| **The reliability diagram is explicitly labelled pre-scaling.** `artifacts/calibration_v001.json` β | |
| `reliability_diagram.ece` is **0.013755** (the *before* value), and the block carries the note: *"Equal- | |
| width bins over predicted-class confidence. ECE is bin-count sensitive and is not an aggregate score."* | |
| The diagram on the Benchmark page is labelled **pre-scaling** so a reader cannot mistake it for the | |
| calibrated result. The bin populations range from 8 (bin 0.133β0.2) to 2,814 (bin 0.933β1.0), and two | |
| bins (0.0β0.0667 and 0.0667β0.1333) are empty. | |
| **The consumer contract** (`consumer_contract`): class `TemperatureCalibration` in module | |
| `evidence.confidence`; applied as `sigmoid(logit(z) / T)` for a scalar `z` and `softmax(logits / T)` | |
| for a distribution; read keys `temperature`, `fitted_on|split`, `artifact`, `n_samples`; resolved by | |
| `load_calibration(config, base_dir='configs')`. | |
| **Limitations.** ECE is bin-count sensitive and is not an aggregate score. The calibrated curve is not | |
| plotted β only the pre-scaling diagram is. The temperature is fitted only on the change-VQA head; no | |
| other specialist is calibrated. | |
| --- | |
| ## 6. Distribution and licensing | |
| ### 6.1 Backbones are not redistributed | |
| Every backbone is fetched from the Hugging Face Hub at run time, pinned by revision (Β§2). No backbone | |
| weight is included in this release, and no backbone is fine-tuned. Their licences are their own β see | |
| each model's HF page: | |
| | Backbone | Repository | Pinned revision | | |
| |---|---|---| | |
| | Router encoder | `sentence-transformers/all-MiniLM-L6-v2` | `1110a243fdf4` | | |
| | VLM | `HuggingFaceTB/SmolVLM-500M-Instruct` | `a7da5b986cb5` | | |
| | Grounding | `chendelong/RemoteCLIP` | `bf1d8a3ccf2d` | | |
| | Optical-SAR | `antofuller/CROMA` | `0dd28e3d633b` | | |
| The change encoder's ResNet-18 trunk is a torchvision model with ImageNet weights; it is likewise not | |
| redistributed. | |
| ### 6.2 The six trained artifacts are published by this project | |
| The six trained artifacts (Β§1) are published on the Hugging Face Hub under `thundercode/SatQuery` | |
| (`models/manifest.json` β `release_repo`), **labelled by kind**, each with its backbone dependency | |
| documented and each accompanied by a checksum: | |
| - [`../models/manifest.json`](../models/manifest.json) β schema `satquery_model_manifest_v1`, | |
| generated by `release/tools/generate_model_manifest.py`, `artifact_count: 6`, | |
| `generated_utc: 2026-09-25T18:15:38+00:00`, `config_hash: 78f1e3700da15aa1`. Its `note` states: *"No | |
| byte count or hash is typed by hand."* | |
| - [`../models/checksums.sha256`](../models/checksums.sha256) β six lines, keyed by the path in the | |
| repository, verifiable with `sha256sum -c checksums.sha256`. | |
| See [`../HF_RELEASE_VERIFICATION.md`](../HF_RELEASE_VERIFICATION.md) for the upload/verification record. | |
| ### 6.3 No licence file exists β OPEN | |
| **There is no `LICENSE` file in the source repository.** This is an **OPEN** item flagged in | |
| [`LIMITATIONS.md`](LIMITATIONS.md) Β§5 (item 29): a licence must be selected **before any public release | |
| of the code**. Model weights carry the terms of their backbone licences; the six trained artifacts are | |
| distributed under whatever terms the owner selects. | |
| ### 6.4 What this release does not contain | |
| - **No backbone weights** (fetched at run time, pinned by revision). | |
| - **No training checkpoints** (archived as provenance, not released β Β§1.2). | |
| - **No feature caches** (`fusion_features/`, the grounding feature cache, etc. β reproducible, not | |
| released as weights). | |
| - **No hidden/private evaluation data** (`evaluation.hidden_data_access: false`). | |
| --- | |
| ## 7. Status summary β what is NOT established | |
| ### 7.1 Status of each artifact | |
| | Artifact | Implemented | Measured | Acceptance | Ruling | | |
| |---|---|---|---|---| | |
| | `change` | yes | yes | **VERIFIED** | closed | | |
| | `change_vqa` | yes | yes | MEASURED | **OPEN** | | |
| | `optical_sar` | yes | yes | MEASURED | **OPEN** | | |
| | `grounding` | yes | yes | MEASURED (2 protocols) | Phase 8 complete; head not wired | | |
| | `router` | yes | validation only | MEASURED | **TEST NOT RUN** | | |
| | `vlm` | yes | yes | **ACCEPTANCE-REJECTED** | CLOSED | | |
| ### 7.2 NOT RUN / OPEN / BLOCKED for this topic | |
| | Item | State | | |
| |---|---| | |
| | **System-level end-to-end accuracy** | **NOT RUN β none exists**; no such number is claimed anywhere | | |
| | **Router test split** | **NOT RUN** | | |
| | **Grounding resolution at 448 for the trained head** | **NOT RUN** β only the zero-shot baseline was measured at 448; a re-open would be a new pre-registered experiment | | |
| | **`grounding_head` wired into `inference.py`** | **DEFERRED** (Phase 8 integration step) | | |
| | **`change` head wired into serving by default** | **DEFERRED** (registry override, not config) | | |
| | **`change_vqa` serving** | requires both a trained head *and* a trained feature extractor; neither wired by default | | |
| | **`sa_mode: BAM`** | **NOT IMPLEMENTED** (raises) | | |
| | **Router adapter parameter count** | **UNKNOWN** β 50,822 (manifest/docstring) vs 51,725 (Phase 4 report), unreconciled | | |
| | **`optical_sar` / `change` head parameter counts** | `UNKNOWN β not established from the available evidence` (manifest records `null`) | | |
| | **`optical.normalization` / `sar.representation` config keys** | read by **no code** β **OPEN** | | |
| | **CROMA `number_of_patches`** | **UNVERIFIED** upstream; checked against 225 at load time | | |
| | **Grounding validation IoU** | the Phase 8 table records 0.0943 at epoch 20; the prose of the same document rounds it to 0.0946 β the small discrepancy is unreconciled | | |
| | **BigEarthNet multi-label evaluation** | **NOT PRODUCED** β the local subset is single-label | | |
| | **Cross-dataset generalisation** | **NOT RUN** β each specialist is evaluated only on its own training-family test split | | |
| | **`LICENSE` file** | **OPEN** β none exists | | |
| | **B-07 tunnel gaps** | patch prepared, **NOT deployed**. **OPEN** | | |
| | **B-02 `codespace_name` trailing `\n`** | cosmetic; **OPEN** | | |
| ### 7.3 Explicit non-claims | |
| - **No claim of state-of-the-art performance** on any benchmark. | |
| - **No claim that the trained heads generalise** beyond their training-family test splits. | |
| - **No claim that calibration improves confidence** β the measured ECE worsened. | |
| - **No claim that the VLM adapter is accepted** for production use. | |
| - **No claim of a system-level accuracy**, because no system-level benchmark exists. | |
| - **No claim that any artifact's accuracy may be quoted without its companion metric** (optical-SAR | |
| accuracy without macro-F1; grounding under one protocol; change-VQA on one test set). | |
| --- | |
| ## 8. Evidence index | |
| | Topic | Evidence | | |
| |---|---| | |
| | Manifest + checksums | [`../models/manifest.json`](../models/manifest.json), [`../models/checksums.sha256`](../models/checksums.sha256), `release/tools/generate_model_manifest.py`, `release/tools/model_manifest_report.txt` | | |
| | Model card | [`../MODEL_CARD.md`](../MODEL_CARD.md) | | |
| | HF release verification | [`../HF_RELEASE_VERIFICATION.md`](../HF_RELEASE_VERIFICATION.md) | | |
| | Config registry | `configs/base.yaml`, `core/config.py` | | |
| | `change` | `specialists/change/stanet.py`, `specialists/change/specialist.py`, `artifacts/change/eval_test/eval_result.json` | | |
| | `change_vqa` | `specialists/change/vqa_specialist.py`, `artifacts/change_vqa/run/PROMOTION.json` | | |
| | `optical_sar` | `specialists/optical_sar/{croma,fusion_head,specialist}.py`, `artifacts/optical_sar/fusion_head_production_v001/pre_registered_115_metric.json`, `docs/PHASE12_115_METRIC_COMPUTED.md`, `docs/PHASE14_OPTICAL_SAR_DECISIONS.md` | | |
| | `grounding` | `specialists/grounding/{head,remoteclip}.py`, `artifacts/grounding/remoteclip_grounding_v001/eval_result_{canonical,matched6}.json`, `docs/PHASE7_RESOLUTION_DECISION.md`, `docs/PHASE8_GROUNDING_HEAD_DECISION.md` | | |
| | `router` | `router/adapter.py`, `artifacts/router/threshold_sweep_val.json`, `docs/PHASE4_ROUTER_REPORT.md` | | |
| | `vlm` | `specialists/vqa/model.py`, `artifacts/vlm/phase6_closure.json`, `artifacts/vlm/run1_test_recovery/adapter_verification.json`, `docs/PHASE6_CLOSURE.md` | | |
| | Calibration | `artifacts/calibration_v001.json` | | |
| | Cross-links | [`BENCHMARKS.md`](BENCHMARKS.md) (metrics and their rules), [`EVALUATION.md`](EVALUATION.md) (how each number was produced), [`TRAINING.md`](TRAINING.md) (how each head was trained), [`DATASETS.md`](DATASETS.md) (the corpora), [`LIMITATIONS.md`](LIMITATIONS.md) (the honest catalogue), [`REPRODUCIBILITY.md`](REPRODUCIBILITY.md) (how to reproduce) | | |