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
File size: 41,248 Bytes
848485b 267161f 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 267161f 6c5f243 848485b 6c5f243 848485b 6c5f243 267161f 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 267161f 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 267161f 6c5f243 848485b 6c5f243 848485b 6c5f243 267161f 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 267161f 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 267161f 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 267161f 6c5f243 267161f 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 6c5f243 848485b 267161f 848485b 267161f 848485b 267161f 6c5f243 267161f 6c5f243 848485b 6c5f243 848485b 6c5f243 267161f 6c5f243 267161f 6c5f243 267161f 6c5f243 267161f 6c5f243 267161f 6c5f243 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 | # Research Notes
Engineering findings, negative results, and design decisions that would otherwise be lost. Every one
was learned by **probe or execution**, not assumption, and every one is recorded so it is not
rediscovered. Where a finding changed code, the change is named; where a finding was *reported rather
than patched*, the reason is given.
**Status tags:** `MEASURED` · `RESOLVED` · `REJECTED` · `OPEN` · `ATTEMPTED`.
**How to read this document.** §2 lists the twelve numbered findings that changed the code
(F4-1…F5-3, P7-1, C-1, C-6…C-9), each with **what was measured**, **the consequence**, and **the file
that records it**. §3–§9 are the larger case studies: the pre-registered resolution rejection, the
router-defect fix, the harness false-positive, the `auto`-mode fallthrough, the
`interpret()`/`chooseTask()` asymmetry, the environment findings, and the BigEarthNet contradiction.
**Companions.** [`LIMITATIONS.md`](LIMITATIONS.md) (the exhaustive catalogue of what does not work),
[`REPRODUCIBILITY.md`](REPRODUCIBILITY.md) (the environment traps in operator form),
[`architecture/04-router.md`](architecture/04-router.md), [`DEPLOYMENT.md`](DEPLOYMENT.md) §8.1.
---
## 1. The convention that matters most here
A finding is only recorded when it was **measured**, and the measurement is quoted rather than
summarised. Two examples of why this convention exists, both recorded in the source documents:
- A per-class guardrail (V2, below) was written with a 1.0 pp threshold that sits **below the
measurement resolution** of its own statistic — it flagged three classes whose drops were
statistically indistinguishable from zero. The rule was wrong, not the model.
- A "bounded, not fixed" classification of a path-disclosure finding was **wrong in three ways**, and
the third mattered most: it was not bounded and not measured. The recorded lesson is in the wording:
**`"bounded"` is a claim, and a claim is not a measurement.**
Where a value could not be established, the honest entry is *"not measured"* rather than a hedge.
---
## 2. Findings that changed the code
### F4-1 — the MiniLM tokenizer ceiling is 256, not 128 (`MEASURED`)
**What was measured.** The config said `router.max_length: 128`. A probe showed the MiniLM tokenizer's
**own ceiling is 256**. 128 is therefore a deliberate truncation *well inside* the ceiling, not the
model's limit — but the config read as if it were the latter.
**Consequence.** `router.max_length: 128` is retained, but `FrozenEncoder` now applies it explicitly
and **rejects** any value above 256, because truncating above the ceiling is a **silent no-op** — "a
control that appears to work and does nothing". Satellite queries are short, so halving the sequence
halves attention cost for no measurable accuracy loss.
**Recorded in.** `docs/PHASE4_ROUTER_REPORT.md` §F4-1; `docs/ARCHITECTURE_FREEZE.md`;
`artifacts/router/router_adapter_v001/metadata.json` (`encoder.max_length: 128`).
### F4-2 — the router does not need a GPU (`MEASURED`)
**What was measured.** The encoder is frozen, so embeddings are a pure function of the query text.
Embedding the corpus measured **0.118 s / 64 queries on CPU**; training the **51,725-parameter** adapter
on the cached vectors measured **0.28 s for 20 epochs over 4,096 vectors**.
**Consequence.** The router can be retrained during development at **zero GPU quota cost**. The plan's
budget line — "Router | CPU/T4 | <1 h" — was roughly **three orders of magnitude pessimistic**; Phase 4
ran to completion locally.
> **Number discipline.** `artifacts/router/router_adapter_v001/metadata.json` records
> `num_parameters: 51725`. The measured figure is **51,725**; use it rather than any other recollection
> of the parameter count.
**Recorded in.** `docs/PHASE4_ROUTER_REPORT.md` §F4-2;
`artifacts/router/router_adapter_v001/metadata.json`.
### F4-3 — the corpus needs *group*-level splitting (`MEASURED`)
**What was measured.** 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.
**Consequence.** Splits are by **group** (template id, or hard-negative family), never by example,
mirroring `evaluation.leakage.assign_splits_by_scene` deliberately: "the failure mode is identical, so
the guard should look identical". Hard-negative families are placed in the **test** split so their
accuracy measures generalisation rather than memorisation. The corpus metadata records
`groups: 54` over `total: 576` queries.
**Recorded in.** `docs/PHASE4_ROUTER_REPORT.md` §F4-3;
`artifacts/router/router_adapter_v001/metadata.json` (`corpus.groups`).
### F5-1 — `AutoModelForVision2Seq` is absent, not deprecated (`MEASURED`)
**What was measured.** In transformers **5.17.0**:
| Class | Present? |
|---|---|
| `AutoModelForImageTextToText` | **present** |
| `AutoModelForVision2Seq` | **ABSENT** |
| `AutoModelForMultimodalLM` | present |
The class **does not exist** (it is not merely deprecated).
**Consequence.** The loader is resolved by **feature detection**, never hardcoded to one class name, so
a transformers version that renames or removes a class does not break the load path.
**Recorded in.** `docs/PHASE5_VLM_CONTRACT.md` §F5-1.
### F5-2 — the processor cost overrun is ~17×, not 4× (`MEASURED`)
**What was measured.** The processor's default `longest_edge` is **2048**, which upscales a 512-px tile
**4×** and then splits it (`do_image_splitting=True`) into sub-images:
```
INPUT: one 512×512 RGB tile
DEFAULT (size.longest_edge = 2048, do_image_splitting = True)
pixel_values (1, 17, 3, 512, 512) <- 17 images
prompt tokens 1142
PINNED (processor_longest_edge = 512)
pixel_values (1, 1, 3, 512, 512) <- 1 image
```
The plan estimated a 4× cost overrun; the real figure is **~17×**.
**Consequence.** `processor_longest_edge` **must** be set explicitly on the processor at construction
time, and `core/config.py` now **enforces** `processor_longest_edge <= image.tile_size` so the pin is a
control rather than a comment. Confirmed in the real load path: `max_images_seen = 1` — "the F5-2 pin
holds in the real load path, not just the probe. Unpinned it would read 17."
**Recorded in.** `docs/PHASE5_VLM_CONTRACT.md` §F5-2 (and the `max_images_seen` confirmation);
`docs/ARCHITECTURE_FREEZE.md` (`vlm.processor_longest_edge: 512`).
### F5-3 — SmolVLM requires `<image>` tokens in the prompt (`MEASURED`)
**What was measured.** Hand-written prompt strings fail:
```
ValueError: The total number of <image> tokens in the prompts should be the
same as the number of images passed. Found [0] <image> tokens and [1] images
```
**Consequence.** Prompts are **always** built through `processor.apply_chat_template()`, enforced by
the config key `vlm.prompt_must_use_chat_template: true`.
**Recorded in.** `docs/PHASE5_VLM_CONTRACT.md` §F5-3.
### P7-1 — the RemoteCLIP projected dimension is 512, not 768 (`MEASURED`)
**What was measured.** `visual.positional_embedding` is **768 wide**, but `visual.proj` is **(768, 512)**
— the embeddings comparable against the text tower are the **projected** ones, i.e. **512**
(`dim_match: True`). The grounding head's per-cell feature is therefore `4 × 512 = 2048`.
**Consequence.** Using 768 anywhere in the grounding path would be a **silent shape error**, caught only
at the similarity computation — after the patch features have already been computed and cached.
`grounding.encoder_projected_dim: 512` is declared in config so `core/config.py` can validate the head
**without importing torch**, and `RemoteCLIPEncoder._verify_contract()` asserts the same value against
the real model at load time.
**Recorded in.** `docs/PHASE7_GROUNDING_CONTRACT.md` §P7-1; `docs/ARCHITECTURE_FREEZE.md`.
### C-1 — the availability mask is consumed by the head, not by CROMA (`MEASURED`)
**What was measured.** CROMA always sees the canonical channel counts (**12 optical, 2 SAR**,
zero-filled to canonical order). The availability mask is applied by the **fusion head**, not the
encoder. The verified fusion-head input dimensions:
```
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)
```
i.e. `input_dim = 3 × 768 + 12 + 2 = 2318`.
**Consequence.** Channel/band dropout during fusion-head training is **mandatory** — "it is what teaches
the head to trust the availability mask". The sensor adapter never fabricates a missing band; missing
channels are masked/zero-filled per the validated adapter policy.
**Recorded in.** `docs/ARCHITECTURE_FREEZE.md` §2.5 (C-1).
### C-6 — T4 is SM 7.5, so training uses fp16, not bf16 (`MEASURED`)
**What was measured.** The target GPU (Tesla T4) is compute capability **7.5**; bf16 tensor cores are
**unavailable** there.
**Consequence.** `training.precision: fp16`. The loader validates the value is one of `fp16|bf16|fp32`.
The fp16-vs-planned-bf16 deviation is documented as a finding in its own right, not hidden.
**Recorded in.** `docs/ARCHITECTURE_FREEZE.md` (`training.precision` row, C-6);
`docs/PHASE6_RUN1_REJECTION_DIAGNOSIS.md` §6.3.
### C-7 — `image_resolution % 8 == 0` (`MEASURED`)
**What was measured.** CROMA requires `image_resolution % 8 == 0`. The native value **120** satisfies it
and yields **225 patches**.
**Consequence.** Enforced at config load (`croma.image_resolution: 120`).
**Recorded in.** `docs/ARCHITECTURE_FREEZE.md` (`croma.image_resolution` row, C-7).
### C-8 — ZeroGPU does not support `torch.compile` (`MEASURED`)
**What was measured.** `torch.compile` must never be enabled on the (historical) ZeroGPU target.
**Consequence.** Enforced: the loader **fails startup** if `deployment.torch_compile` is true. This is
why setting `SATQUERY_TORCH_COMPILE=true` fails startup rather than silently taking effect (see
[`DEPLOYMENT.md`](DEPLOYMENT.md) §6.3).
**Recorded in.** `docs/ARCHITECTURE_FREEZE.md` (`deployment.torch_compile` row, C-8);
`configs/deploy.yaml` header.
### C-9 — STANet hyperparameters are upstream-verified (`MEASURED`)
**What was measured.** Change detection uses a STANet-style architecture with **upstream-verified**
hyperparameters: ResNet-18 encoder, **PAM** self-attention mode, tile **256** (non-overlapping),
threshold **0.50**, loss `0.5·BCE + 0.5·Dice`, `lr = 1e-3`, `batch_size = 8`. The LEVIR-CD split is
**7120 / 1024 / 2048** (256-px patches). Post-processing: threshold → morphological cleanup → connected
components → minimum-component filter.
**Consequence.** The implementation is **reimplemented, not vendored** — so the upstream hyperparameters
are pinned as the contract, and the trained head's embedded metadata carries the same values
(`sa_mode: "PAM"`, `width: 128`, `encoder: "resnet18"`).
**Recorded in.** `docs/ARCHITECTURE_FREEZE.md` §2.5 (C-9);
`artifacts/change/eval_test/eval_result.json` (`checkpoint_embedded_config`).
---
## 3. The grounding resolution decision — a pre-registered rejection (`REJECTED`)
**Question.** Should grounding decode at **448** or **224**?
**Answer: 224. 448 was rejected** — notable because the rejection was *pre-registered* and then
*confirmed* by a paired test over identical samples (n = 16,159), on a Tesla T4
(`--all --device cuda --tag full`).
### 3.1 The rule, fixed before the result was seen
```
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
```
The artifact records `rule_changed_since_preregistration: false` — the rule was **not** modified after
the result was seen.
### 3.2 The 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**.
### 3.3 Measured detail
| 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 — the shift is 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 |
The 224 column dominates the top three buckets; 448 has ~1,100 more near-total misses.
### 3.4 Paired analysis — independent confirmation
Both resolutions scored the **same 16,159 samples**, so the paired test 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 paired test and the pre-registered rule agree.** 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**.
### 3.5 Recall ladder, paired
| 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 |
The gap **narrows as the threshold rises** — the signature of a method that cannot reach high IoU either
way. At IoU 0.50 the two are within 0.002 of each other and both are near the floor.
### 3.6 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. Two things work against the finer grid:
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. 448 wins
on 8.5 % and loses on 20.9 % — losses outnumber wins by 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. A **learned** head
trained to regress boxes from these features may respond differently.
### 3.7 What this establishes, and what it does not
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**; encoder latency at 224 on a T4 is **20 ms/image** (5× faster than the CPU figure of
97 ms); the 224 localisation floor is **1/7 of image width per token**.
Does **not** establish: whether the zero-shot baseline is *good* (it is not — mean best IoU 0.0972 and
Recall@0.5 0.0234 are weak, and this is an ablation floor for the Phase-8 head, not a product); 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 (VRSBench is overhead optical;
the hidden set is Cartosat-2S + RISAT, a different distribution entirely).
**Degeneracy note.** At n=12, n=40 and n=6 the smoke runs reported `Recall@0.5 = 0.0000` at **both**
resolutions and the script emitted a degeneracy warning; at full scale the metric is non-zero
(0.0234 / 0.0212), so the note correctly did not fire. The sub-floor runs were never treated as
evidence.
**Reproduction.**
```bash
python scripts/exp_grounding_resolution.py \
--vrsbench <data-root> \
--checkpoint <RemoteCLIP-ViT-B-32.pt> \
--all --device cuda --tag full
python scripts/analyze_grounding_resolution.py --tag _full
```
Artifacts: `per_sample_224_full.jsonl`, `per_sample_448_full.jsonl`,
`resolution_experiment_full.json` — 16,159 lines each. Every aggregate is recomputable from the JSONL
without re-running the encoder.
**Recorded in.** `docs/PHASE7_RESOLUTION_DECISION.md` (full document).
---
## 4. The router defect — a real bug, found and fixed (`RESOLVED`)
### 4.1 Symptom
The query *"Where are the built-up areas in this image?"* — with **one** asset attached — collapsed to
**`vqa`** and answered **"River"**, instead of routing to `grounding`. A second query, *"Where is the
new airport?"*, behaved the same way.
### 4.2 Root cause
Two functions with different information:
- **`interpret()`** — produces the console's *reading*; **asset-count-blind** (text only).
- **`chooseTask()`** — performs *dispatch*; **asset-count-aware**.
The defect was in the reading/dispatch path's handling of spatial/lexical cues. The pre-fix replay of
the shipped functions shows the mechanism exactly:
```
"Where are the built-up areas in this image?" — one asset.
Before: reading=change, temporal=required → dispatched=vqa (wanted=change_vqa, substituted=true).
\bbuilt\b matched the temporal regex and `area` matched inside "areas".
```
So the word "built" was in the temporal (change) set, and "area" matched as a substring of "areas".
Region queries fell through to the generic VQA specialist.
### 4.3 Fix and verification
The fix was deployed to `SatQuery-Frontend` (`mission.js` `interpret()`, `ff46eba42b18`) and the sibling
`core.js` `SQ.policy` (`2d7ae53b482d`). It was validated by **three independent live passes**:
| Pass | Deployed HEAD | Result |
|---|---|---|
| 1 | `ff46eba42b18` + `d413d3672311` | 8/8 |
| 2 | `2d7ae53b482d` | 8/8 |
| 3 | `2d7ae53b482d` | 8/8 |
Both defect queries now dispatch to `grounding`:
| Query | Pass-1 run id | Pass-2 run id | Pass-3 run id |
|---|---|---|---|
| Where are the built-up areas in this image? | `run_f0d7a90b5aa1` | `run_2a07dcdbae96` | **`run_467ffa406f22`** |
| Where is the new airport? | `run_69e38a182a71` | `run_9134f40a258c` | **`run_46980ba55c62`** |
**24 live runs, 24 correct dispatches, 0 mock nodes.** No run id is shared between passes. Post-fix the
live answers read `reading=grounding, temporal=none` and `[grounding] Located 6 candidate region(s) …
Highest objectness 0.82` / `0.83`. Screenshots (`A1…B2*.png`) are in the live-validation scratch
directory.
### 4.4 The residuals the fix left (and did not hide)
- *"What is the new runway?"* still reads `change` rather than `vqa` (the `new`-as-change heuristic
fires on non-`where` questions). Strictly better than pre-fix, where `new` was unconditionally
temporal. "A lexical router cannot cleanly separate 'the new X' from 'what's new'."
- *"How much built-up area was added?"* now reads `vqa` (under-trigger), because `built` was dropped
from the temporal set and `area` no longer matches inside `areas`.
**Recorded in.** `docs/FINAL_DELIVERY_TODO.md` §5 B-08, §6 E-09/E-11/E-14;
`.workbuddy-ai/scratch/live_validation/LIVE_VALIDATION_POSTFIX.md`; `run_final2.txt`, `run_final3.txt`;
`results_final.json`, `results_pass3.json`.
### 4.5 The corpus the fix was validated against (and its limits)
The router is trained and validated on a **576-query** corpus in **54** groups
(`corpus_total: 576`, `corpus_groups: 54`), distributed by task: caption 91, change 115, grounding 128,
optical_sar 50, unsupported 105, vqa 87. The validation split used for the reported accuracy is
**n = 86**; the corpus is flagged `corpus_limited: true`. The adapter has **51,725** parameters over a
384-dim frozen MiniLM encoder (6 task classes, 4 modalities, 3 binary heads).
This is why the router number must be read as **indicative only** — see
[`LIMITATIONS.md`](LIMITATIONS.md) §1 (L-12) and §8 (L-67). The fix was validated by **live behaviour**
(24 runs, 24 correct dispatches), not by a corpus accuracy jump, precisely because the corpus is small.
**Recorded in.** `artifacts/router/router_adapter_v001/metadata.json`;
`artifacts/router/threshold_sweep_val.json`.
---
## 5. The harness false-positive — caught before it could lie (`RESOLVED`)
### 5.1 What happened
An earlier live-validation harness typed queries with **synthetic CDP key events**, which Chrome
**silently drops when the window lacks OS focus**. The harness therefore dispatched the page's *default*
query and still recorded a "result" — a **false pass**. Measured directly: with Chrome backgrounded,
`press_key("Z")` left `#qtext.value` unchanged, while `type_text("Q")` (CDP `Input.insertText`, not
focus-gated) inserted fine. The re-run attempt failed on case 1 with `run_id=0002`, `mock_nodes=9`,
`answer="No answer yet"`, and only a `capabilities` call — the **mock path**.
### 5.2 Fix
The current harness **asserts form state before dispatch** (`q_ok`, `obs_ok`, `t0_ok`) and uses
deterministic query entry (`js()` value-set + `type_text()` via CDP `Input.insertText`). Two further
harness bugs were found and fixed, both causing **false failures**:
- the answer `[task]` tag exists only for region tasks (vqa/caption answers are bare); and
- the intent panel is a **concatenated** string, so `task([a-z_]+)` must be matched **non-greedily** up
to `modality`.
The harness now computes the dispatched task as `answer_tag` when present, else the reading.
### 5.3 Independent check
The earlier 8/8 run was re-examined and confirmed **not** infected — its intents were query-specific
(e.g. A1 read `taskvqa…temporalnone`, not the default's `taskchange…temporalrequired`), its answers
embedded the query text, and A6's answer proved two files were uploaded. The failure mode is recorded
because it is exactly the **silent false-positive an evaluation harness must never have**.
**Recorded in.** `.workbuddy-ai/scratch/live_validation/LIVE_VALIDATION_POSTFIX.md` ("Why this pass
needed a new harness"); `docs/FINAL_DELIVERY_TODO.md` §6 E-14.
---
## 6. The `transport_mode: auto` fallthrough (`OPEN`)
`SATQUERY_TRANSPORT=auto` tries the tunnel, then falls through to the forward path on timeout. The
forward path to a **private** repo returns `302` quickly, but the wake step still consumes
`SATQUERY_WAKE_TIMEOUT_S` (120 s) first — so a worst-case failed request takes ≈ **249 s**
(150 + 120). This is the **root shape** of the observed transient tunnel gap.
The recorded root cause is precise: in `auto` transport mode a tunnel timeout **falls through** to the
forward path (`SatQuery-Backend/main.py:546`), which then burns `wake_timeout_s=120` on a `302` → the
observed `504`.
A patch (`fix-b07-forward-unavailable.patch`) was authored and verified (`git apply --check` clean,
`py_compile` clean, applies to the deployed `89d80eaddec5`). It adds `forward_unavailable` (**503**,
terminal `302`/`401`/`403` on the forward path) and `upstream_timeout` (**504**, tunnel healthy but
slow) codes, plus the `codespace_name` `.strip()` fix.
> **Status: `OPEN`.** The patch is **prepared but NOT deployed.** The deployed health payload still
> shows the trailing `\n`.
**Recorded in.** `release/CURRENT_RELEASE_STATE.md` §6; `docs/FINAL_DELIVERY_TODO.md` §5 B-07;
[`DEPLOYMENT.md`](DEPLOYMENT.md) §8.1.
---
## 7. The `interpret()` / `chooseTask()` asymmetry — intentional (`RESOLVED`)
For *"What changed between the earlier and later image?"* with **one** asset attached, the console
**reads** `change` while dispatch correctly falls back to **`change_vqa`**. This is **not** a bug:
- the **reading** describes the question's intent (asset-count-blind);
- the **dispatch** respects what can actually be computed with the assets present (asset-count-aware).
It is documented so it is not mistaken for a defect. In the live-validation table this appears as the
one case where reading ≠ dispatched (`change` → `change_vqa`) — "flagged, not failed".
**Recorded in.** `.workbuddy-ai/scratch/live_validation/LIVE_VALIDATION_POSTFIX.md` ("Discriminator
note"); [`architecture/04-router.md`](architecture/04-router.md).
---
## 8. Environment findings (would otherwise cost hours)
| Finding | Detail |
|---|---|
| **Dead proxy in the authoring sandbox** | outbound calls need `--noproxy '*'` (curl) or `ProxyHandler({})` (Python). |
| **The sandbox proxy is slow for uploads** | `huggingface_hub` uploads stalled at ~51 kB/s through `http_proxy=127.0.0.1:58294`; the fix is to unset `http_proxy`/`https_proxy` and set `no_proxy='*'`. |
| **`hf_hub_download` returned an EMPTY file** | sha256 `e3b0c442…` (the empty-content hash), which produced a **false FAIL** for all six artifacts. The honest check is a direct HTTPS download with `ProxyHandler({})`. |
| **pytest is only in the repo venv** | `.venv/Scripts/python.exe`; a bare `pytest` misses it. |
| **The full test suite trips a bulk-delete guard** | sandbox-specific; affects `test_safe_delete_shim`. |
| **Cloudflare `_headers` concatenate** | two matching rules are merged, not overridden; Chromium takes the **first** `max-age`. |
| **Cloudflare 308-redirects `X.html` → `/X`** | reference the extensionless path. |
| **A forwarded Codespace port returns `302`** | for a private repo — this is *why* the tunnel exists. |
| **Chrome drops synthetic CDP key events without OS focus** | the harness false-positive (§5). |
| **`browser-use` block-buffers stdout** | even when redirected; needs explicit line buffering to stream. |
| **WDAC blocked `orjson`, so FastAPI could not import** *(historical — no longer reproduces)* | `ImportError: DLL load failed while importing orjson: An Application Control policy has blocked this file.` Verified as `OSError [WinError 4551]` from a direct `ctypes.CDLL` on the binary. FastAPI's own guard catches `ModuleNotFoundError`, not a policy-blocked `ImportError`, so it aborted the import. When the block lifted, **four live defects became reachable** that had been sitting in `gateway/` the whole time (G-1…G-4). Do not cite this as a current limitation. |
| **`pandas._libs.parsers` / `sparsefuncs_fast` App Control block** | 56 evaluation-side tests remain uncollectable. Unchanged; unrelated to the backend chain. |
| **CPU-only torch** | CUDA autocast is a no-op; no GPU path was exercised in the authoring environment. |
### 8.1 A withdrawn explanation, recorded rather than deleted
The reason that nine backend-chain tests (D–I, Q, R) were NOT RUN was originally recorded as "the
environment's egress proxy intercepts outbound HTTP and returns `502`". That explanation was
**re-measured on 2026-09-22 and does not hold**: `https://huggingface.co` returned **HTTP 200**,
`/api/models` returned 200 with real JSON, and a real weight-file path returned 200. The correct reason
is that **no upstream exists** — no Space and no service has ever been deployed, and the gateway is a
pure proxy that holds no second copy of the capability table, so a metadata request *is* an upstream
request. The classification (NOT RUN / ENVIRONMENT-BLOCKED) is unchanged; only the reason changed.
### 8.2 The transferable lesson from the stale-negative class
Three documents written after the Phase-12 A/B experiment still described it as un-run, even though it
had completed 34 hours earlier. **A stale negative claim is more dangerous than a stale positive one**:
a wrong number is eventually contradicted by the artifact it describes, but "this has never been
executed" is contradicted by *nothing* — no test fails, no hash moves, no invariant breaks. The rule
this suggests: **never assert that an artifact does not exist from memory; assert it from a command, and
record the command.** No test can check that a documented absence is still absent.
**Recorded in.** `docs/PHASE19_FINAL_HARDENING.md` §5 (historical blocker) and §5.3 (CPU-only torch);
`docs/STEP7_BACKEND_CHAIN_REPORT.md` §5, §11, §13, §15; `docs/PHASE12_CURRENCY_CORRECTION.md` §6;
[`REPRODUCIBILITY.md`](REPRODUCIBILITY.md).
---
## 9. The BigEarthNet format contradiction (`ATTEMPTED`, reported not resolved)
The BigEarthNet data format **contradicts the original plan**. This was **reported rather than silently
patched**, because quietly changing the preprocessing would move the frozen config hash. Two facts
matter:
1. **The percentile stretch is not upstream.** The BigEarthNet documentation — its uses, mentions, or
endorsements — does **not** specify a percentile stretch. This project nevertheless applies
percentile normalisation (**2/98**) for optical inputs to match the CROMA contract. That is a
**deliberate, documented choice**, not an upstream fact.
2. **The local subset is single-label.** It is **100 % single-label** against the official **1–11
multi-label** scheme, so metrics computed on it are **not comparable** to published multi-label
numbers.
**Consequence.** The contradiction is recorded as a limitation and an open item rather than resolved by
editing preprocessing (which would move `Config.hash` off `78f1e3700da15aa1`).
**Recorded in.** [`LIMITATIONS.md`](LIMITATIONS.md) §3 (L-24);
`docs/PHASE12_LABEL_POLICY_DECISION.md`; `docs/PHASE14_CROMA_NORMALISATION_CHANGE.md`.
---
## 10. Additional measured defects that shaped the design
These are not headline findings, but each changed a decision or a guard. They are recorded together
because they share a shape: a value or a claim that was produced without a measurement, then corrected
by one.
### 10.1 The train/serve skew (the F2 finding) (`RESOLVED`)
**What was measured.** `scripts/prepare_change_vqa.py` builds its change features from the **trained**
STANet (`DEFAULT_CHANGE_CHECKPOINT`), but serving had no equivalent wiring: `change.checkpoint_path` is
unset in `configs/base.yaml`, so the registry passed no `checkpoint_path` and the change-VQA specialist
would construct an **untrained** STANet and answer from a representation the head was never fitted on.
**Consequence.** `app/serving.py::_wired_change_vqa_builder` supplies the **same** checkpoint the
change builder uses, so the detector behind a change answer and the detector behind the head's training
features are one artifact by construction. The specialist's own `feature_spec_mismatch()` check stays
armed as a second line of defence, not the only one.
**Recorded in.** `app/serving.py` (`_wired_change_vqa_builder` docstring).
### 10.2 The optical-SAR encoder was unreachable by default (`RESOLVED`)
**What was measured.** `specialists/optical_sar/specialist.py` builds CROMA only when handed a
`checkpoint_path` that exists. `croma.checkpoint_path` is not in `configs/base.yaml` — and must not be,
or `Config.hash` moves — so the registry passed no path, the gate was False, and the default serving
composition ran with `encoder=None` while the checkpoint sat on disk the whole time. The registry then
correctly reported `DEGRADED` ("no encoder; running on fallback"): a deployment that could never answer
an optical/SAR question.
**Consequence.** `app/serving.py::_wired_optical_sar_builder` resolves the checkpoint from the **pinned
identity** through the hash-exempt channel (`croma.resolve_checkpoint_path`: env → config → pinned Hub
cache, offline first) and wires the fusion head the same way. The resolution `source` is logged so
"which checkpoint did this process actually use" is answerable from the trace.
**Recorded in.** `app/serving.py` (`_wired_optical_sar_builder` docstring);
`specialists/optical_sar/specialist.py`.
### 10.3 G-1 … G-4 — four defects the first real ASGI run found (`RESOLVED`)
**What was measured.** The ASGI layer had **never executed** in the repository, and the reason was
recorded in the code as an environment blocker (a WDAC block on `orjson.pyd`). When the block lifted,
within the first hour of actually running the server **four real defects surfaced**, four of them latent
for as long as the blocker was believed:
| ID | Defect | Severity |
|---|---|---|
| **G-1** | `request: Request` never resolved — an in-function import left `Request` out of `__globals__`, so FastAPI silently reinterpreted the parameter as a required **query** parameter named `request`; **every POST body was misread** and no handler ran | **Critical** |
| **G-2** | an unsupported `force_task` enum value was forwarded upstream instead of refused locally → wrong status, wasted round trip | High |
| **G-3** | an empty `HF_TOKEN` produced `Authorization: Bearer `, which httpx rejects → a crash reported as an upstream failure | High |
| **G-4** | a non-JSON upstream error body was relayed verbatim → contract break and internal-text disclosure | High |
**Consequence.** All four fixed, with regression tests that assert the **cause** rather than the
symptom. The transferable lesson: "a documented blocker is a place where evidence stops, and nothing was
watching for the blocker to lift."
**Recorded in.** `docs/STEP7_BACKEND_CHAIN_REPORT.md` §13, §14.
### 10.4 F-1 … F-7 — the gateway layer-crossing audit (`RESOLVED` / recorded)
**What was measured.** A series of findings, each found by driving the real ASGI stack rather than by
reading:
| ID | Finding | Disposition |
|---|---|---|
| **F-1** | a vacuous assertion in the gateway's own test suite | fixed |
| **F-2** | an upstream CORS header bypassed the allowlist entirely (**security**) | fixed |
| **F-3** | `404`/`405` did not carry the contract's error envelope | fixed (gateway); `app/space_app.py` does not register the handler (bounded defect F-12b) |
| **F-4** | `rate_limited` was emitted but documented nowhere | fixed |
| **F-5** | the per-IP rate limiter is defeated by a client-supplied `X-Forwarded-For` (measured 5/8 throttled without the header, **0/8** with a fresh value per request) | **ruled**: fairness only, not a security control |
| **F-6** | the body-size cap was **declarative**, not enforced — an omitted `Content-Length` buffered the entire body past the cap (measured: 12 MiB body → 13.9 MiB peak; allocation tracked body size with no ceiling) | fixed with a streaming check alongside the header check |
| **F-7** | two parsers of one variable (`SATQUERY_MAX_FILE_BYTES`) diverged in opposite directions | fixed; both layers now refuse a malformed value and name the variable |
**Consequence.** The rate limiter's claim was narrowed to what the measurement supports, and the size
cap is now enforced twice — deliberately, because "the header check protects the gateway's memory
against honest clients, not against hostile ones".
**Recorded in.** `docs/STEP8_FINAL_CONFORMANCE_AUDIT.md` §15–§17;
`docs/DEPLOYMENT_ARCHITECTURE.md` §5.2, §4 (F-6 note).
### 10.5 F-13 … F-17 — what an unauthenticated client could learn (`RESOLVED` / recorded)
**What was measured.** A pass-by-pass audit of every value written to a client-visible field found that
`trace.inputs` (F-13), `trace.steps[PARSE].detail["inputs"]` (F-14), and the construction-failure
exception string (F-15) published **server-side filesystem paths** to an unauthenticated client —
F-15's case was "strictly worse" because the path was purely server-side and reachable on the **first**
request. F-16 found `result.change_map` / `evidence[].artifact_ref` carrying a real filesystem path
where the contract promised an `artifact://` URI. F-17 found `change_vqa.artifact_dir` configured but
never read.
**Consequence.** The owner ruled: sanitize client-facing exception messages to a **basename** (not a
replacement — the reason survives) and log the raw detail server-side; set an unavailable `artifact_ref`
to `null` with an explicit non-retrievable warning; do **not** fabricate `artifact://` URIs. The F-15
ruling needed **four** carriers, not three, because `result.execution_trace = trace` serializes the
trace object **twice**. F-17 is **documented, not patched**.
**Recorded in.** `docs/DEPLOYMENT_ARCHITECTURE.md` §5.3–§5.6; [`SECURITY.md`](SECURITY.md).
### 10.6 The single capability authority (`RESOLVED`)
**What was measured.** Two independent producers answered "what can this deployment do?":
`AnalysisController.health()` reported **6** capabilities from the registry; `space_app.describe_deployment()`
reported **2** from two `Path.exists()` calls — and only the latter was served. Also measured:
`HealthStatus(**controller.health())` **fails** with 3 `extra_forbidden` errors.
**Consequence.** Owner ruling: the **registry is authoritative**, and `app/deployment.py` is the single
adapter that derives contract vocabulary from the registry's spec table and the filesystem. This is the
"three-copy problem": any table that exists in two places will drift, and this one already had.
**Recorded in.** `docs/DEPLOYMENT_ARCHITECTURE.md` §3.3.1;
`docs/STEP7_BACKEND_CHAIN_REPORT.md` §16.
### 10.7 T-1 / T-2 — two test-side findings worth carrying (`RESOLVED`)
**What was measured.** (T-1) An earlier note recorded "22 documented error codes"; the real taxonomy and
the contract both have **23**. The test now parses the contract's §5.2 table and compares it against
`core/errors.py`, so the two must agree. (T-2) Three documentation tests initially failed by matching the
**prohibition itself** (e.g. a search for `gr.Blocks` matched the docstring that forbids it); the tests
now parse the module with `ast` and strip docstrings before searching — "a search for a forbidden token
must run over executable code".
**Consequence.** Both corrections became **bidirectional** checks rather than one-off fixes.
**Recorded in.** `docs/STEP7_BACKEND_CHAIN_REPORT.md` §3, §13; `docs/PHASE19_FINAL_HARDENING.md` §3.7.
### 10.8 The calibration measurement, stated exactly (`MEASURED`)
**What was measured.** Temperature scaling was fitted on the Val split: `temperature = 0.9772731820958189`,
`n_samples = 16441`, `ece_before = 0.013755`, `ece_after = 0.014929`,
`ece_improvement = −0.001174` (**worse**), `nll_before = 0.6897411`, `nll_after = 0.6896308`,
`nll_improvement = +0.0001104`, `hit_bound = false`, `effective = true`. The raw softmax was **already
near-calibrated**, and temperature scaling made ECE very slightly worse while improving NLL marginally.
**Consequence.** The path is live (`core/controller.py` → `EvidenceEngine`), so a deployed result
carries a calibrated value; but this is a **measurement, not a quality judgment**, and must not be
described as scaling being "more accurate".
**Recorded in.** `artifacts/calibration_v001.json`; `docs/STEP7_BACKEND_CHAIN_REPORT.md` §7.
---
## 11. Where the evidence lives
| Topic | Evidence |
|---|---|
| Findings F4-1…F4-3 | `docs/PHASE4_ROUTER_REPORT.md` |
| Findings F5-1…F5-3 | `docs/PHASE5_VLM_CONTRACT.md` |
| Finding P7-1 | `docs/PHASE7_GROUNDING_CONTRACT.md` |
| Findings C-1, C-6…C-9 | `docs/ARCHITECTURE_FREEZE.md` |
| CROMA normalisation upstream evidence | `docs/CROMA_NORMALISATION_UPSTREAM_EVIDENCE.md` |
| The 448-vs-224 paired test | `docs/PHASE7_RESOLUTION_DECISION.md` |
| The VLM rejection | `docs/PHASE6_RUN1_REJECTION_DIAGNOSIS.md`, `artifacts/vlm/phase6_closure.json` |
| Router defect + 3 live passes | `.workbuddy-ai/scratch/live_validation/` (`run_output.txt`, `run_final2.txt`, `run_final3.txt`) |
| The undeployed B-07 patch | session scratch `fix-b07-forward-unavailable.patch` |
| Live validation harness | `.workbuddy-ai/scratch/run_all_postfix2.harness`, `recompute_verdicts.py` |
| The withdrawn egress explanation | `docs/STEP7_BACKEND_CHAIN_REPORT.md` §5 |
| The stale-negative lesson | `docs/PHASE12_CURRENCY_CORRECTION.md` §6 |
| The BigEarthNet contradiction | `docs/PHASE12_LABEL_POLICY_DECISION.md`, `docs/PHASE14_CROMA_NORMALISATION_CHANGE.md` |
| The gateway layer-crossing findings (F-1…F-7) | `docs/STEP8_FINAL_CONFORMANCE_AUDIT.md` §15–§17 |
| The ASGI defects (G-1…G-4) | `docs/STEP7_BACKEND_CHAIN_REPORT.md` §13 |
| The path-disclosure rulings (F-13…F-17) | `docs/DEPLOYMENT_ARCHITECTURE.md` §5.3–§5.6 |
| The train/serve skew and optical-SAR wiring | `app/serving.py` |
| The calibration measurement | `artifacts/calibration_v001.json`, `docs/STEP7_BACKEND_CHAIN_REPORT.md` §7 |
|