thundercode commited on
Commit
c9654a2
Β·
verified Β·
1 Parent(s): 67d2633

release: add docs/DATASETS.md

Browse files
Files changed (1) hide show
  1. docs/DATASETS.md +1159 -101
docs/DATASETS.md CHANGED
@@ -1,162 +1,1220 @@
1
- # Datasets
2
 
3
- Every figure below was **measured from the data on disk**, not copied from a paper or a dataset
4
- schema. Where a corpus was only partially acquired, or where the local subset differs from the
5
- official release, that is stated β€” not smoothed over.
6
 
7
- **Status tags:** `ACQUIRED` Β· `MEASURED` Β· `PARTIAL` Β· `NOT DOWNLOADED` Β· `REJECTED`.
 
 
 
 
8
 
9
- ---
10
-
11
- ## 1. Summary
12
 
13
  | Task | Dataset | Role | Local state |
14
  |---|---|---|---|
15
  | `change` | **LEVIR-CD-256** | train / val / test | ACQUIRED β€” split 7120 / 1024 / 2048 |
16
- | `change_vqa` | **CDVQA** (+ **SECOND** imagery) | train / val / test | annotations + imagery ACQUIRED; name-verified 2,968/2,968 |
17
- | `grounding` | **VRSBench** | eval (16,159 records) | ACQUIRED β€” boxes normalised 0–100 |
18
- | `optical_sar` | **BigEarthNet** (CLC-19) | train / held-out test | PARTIAL β€” 28,000-patch local subset |
19
- | `vlm` | **BigEarthNet** (instruction pairs) | LoRA adaptation | PARTIAL β€” same 28k subset |
20
- | β€” | SECOND (SCD) | CDVQA imagery source | ACQUIRED, extracted to `data/cdvqa/{im1,im2,label1,label2}/` |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
- ## 2. LEVIR-CD-256 β€” change detection
 
 
23
 
24
- The standard change-detection benchmark, 256Γ—256 tiles. Split used (from `configs/base.yaml`):
 
 
 
 
25
 
26
- | Split | Count |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  |---|---|
28
- | train | 7,120 |
29
- | val | 1,024 |
30
- | test | **2,048** |
 
 
 
 
 
 
 
 
 
 
 
31
 
32
- **Measured test-split detail** (`artifacts/change/eval_test/eval_result.json`):
33
 
34
  | Property | Value |
35
  |---|---|
36
- | n | 2,048 |
37
  | images containing change | **935** |
38
- | mean change fraction | **0.0509** (β‰ˆ 5 % of pixels) |
 
39
  | threshold | 0.50 |
40
  | tile size | 256 |
41
  | device (eval) | cuda |
42
  | total pixels scored | 134,217,728 |
 
 
43
 
44
- The 5 % change fraction is why **pooled** and **macro** metrics are both reported: with a strong
45
- class imbalance, pooled IoU (0.8122) and macro IoU (0.8457) answer different questions. The
46
- per-pixel confusion counts (tp 5,978,997 / fp 523,658 / fn 858,407 / tn 126,856,666) are stored so
47
- any metric can be recomputed rather than trusted.
48
 
49
- ## 3. CDVQA (+ SECOND) β€” change-VQA
 
 
 
 
 
50
 
51
- **The CDVQA repository publishes annotations only β€” no imagery.** The imagery is publicly available
52
- as **SECOND (SCD)**. This project acquired SECOND, **name-verified it (2,968 / 2,968 MATCH)**,
53
- extracted it to `data/cdvqa/{im1,im2,label1,label2}/`, and verified a CDVQA example loads against it
54
- end-to-end.
55
 
56
- **Measured corpus figures:**
57
 
58
- | Property | Value |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  |---|---|
60
- | Val images | 400 |
61
- | Val questions | **16,441** |
62
- | Test questions | **39,686** |
63
- | Test2 questions | second held-out set (accuracy 0.651469) |
 
 
 
 
 
 
 
 
64
 
65
- **Temporal and label semantics β€” established from evidence, with honest uncertainty:**
66
 
67
- | Mapping | State |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  |---|---|
69
- | `label1` = pre, `label2` = post | **established** |
70
- | `im1` = pre, `im2` = post | **supported, not proven** |
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
- **Corpus root:** `data/cdvqa` β€” the directory that *contains* `annotations/`. Passing
73
- `data/cdvqa/annotations` is **rejected** by `_resolve_root` (verified by execution: it raises
74
- `no 'annotations' directory under data\cdvqa\annotations`). The correct call is
75
- `load_cdvqa('data/cdvqa')`.
76
 
77
- > **Not established: trainability of the raw loader.** The adapter is pair-aware and
78
- > `require_images=True` succeeds, but the raw corpus has no training loop of its own β€” the shipped
79
- > `change_vqa` head trains on **cached features**, not on the raw loader. See
80
- > [`TRAINING.md`](TRAINING.md).
81
 
82
- ## 4. VRSBench β€” grounding evaluation
 
 
 
 
 
 
 
 
 
 
83
 
84
- The grounding head is evaluated on VRSBench. **Measured: 16,159 eval records.**
 
 
 
 
 
85
 
86
- VRSBench stores boxes **normalised to 0–100**; this project stores boxes **normalised to 0–1**. The
87
- conversion is declared explicitly as `grounding.benchmark_box_scale: 100.0` so it cannot be applied
88
- twice or forgotten.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  Because the box convention is a common source of silent error, grounding is reported under **two
91
- protocols** (canonical and matched6) and **two decode variants** β€” see
92
- [`BENCHMARKS.md`](BENCHMARKS.md) Β§1.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
- ## 5. BigEarthNet β€” optical-SAR fusion and VLM adaptation
 
 
 
 
 
 
 
 
95
 
96
- BigEarthNet is used in two places: as the **label space and evaluation benchmark** for the
97
- optical-SAR fusion head, and as the **instruction-pair source** for the SmolVLM LoRA adaptation.
98
 
99
- **Measured local subset:**
 
 
 
100
 
101
  | Property | Value |
102
  |---|---|
103
- | S2 patches (local subset) | **28,000** |
104
- | tiles | 98 |
105
- | bands per patch | 12 |
106
- | full official corpus | 480,038 patches |
107
- | label space | **19 CLC classes** |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
- ### 5.1 Label-semantics caveat (important)
 
 
 
 
 
110
 
111
- > **The local 28k subset is 100 % single-label, against the official 1–11 multi-label scheme.**
112
- > Metrics computed on this subset are therefore **not comparable** to published multi-label
113
- > BigEarthNet numbers. Any statement of the form "BigEarthNet mAP = X" is **false** for this subset.
114
 
115
- ### 5.2 Fusion evaluation detail
 
116
 
117
- **Measured** (`artifacts/optical_sar/fusion_head_production_v001/pre_registered_115_metric.json`):
 
 
118
 
119
  | Property | Value |
120
  |---|---|
121
  | split | test |
122
  | n scored | **4,000** |
123
  | classes in label space | 19 |
124
- | classes present in the scored split | 14 |
125
- | classes absent | 5 |
126
- | macro-F1 denominator | **all 19 classes (absent classes contribute 0.0)** |
127
- | accuracy | 0.931 |
128
- | macro F1 | 0.434161 |
129
- | deciding statistic | **False** |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
- The macro-F1 denominator is recorded explicitly so a reader can see that absent classes drag the
132
- macro score down by construction. This is why accuracy (0.931) and macro-F1 (0.434161) must be read
133
- together.
134
 
135
- ### 5.3 The BigEarthNet data-format contradiction
 
 
 
 
136
 
137
- > **PARTIALLY β€” reported, not silently resolved.** The BigEarthNet data format contradicts the
138
- > original plan. This was reported rather than quietly patched, because silently changing the
139
- > preprocessing would move the frozen config hash. See
140
- > [`RESEARCH_NOTES.md`](RESEARCH_NOTES.md) for the full record.
 
 
141
 
142
- The BigEarthNet documentation β€” its uses, mentions, or endorsements β€” does **not** specify a
143
- percentile stretch. This project nevertheless applies percentile normalisation for optical inputs
144
- (2/98) to match the CROMA contract. That is a deliberate, documented choice, not an upstream fact.
145
 
146
- ## 6. Data hygiene and leakage controls
 
 
147
 
148
- - **Splits are by group, never by example**, for the router: template / hard-negative families are
149
- kept whole, and hard-negative families are placed in the **test** split so their accuracy measures
150
- generalisation rather than memorisation.
151
- - **Leakage split key: `scene_id`** (declared in `configs/base.yaml`).
152
- - **Immutable public test: true.** `hidden_data_access: false`. The evaluation config forbids
153
- touching hidden data.
154
 
155
- ## 7. What is NOT available
 
 
 
 
 
156
 
157
- | Corpus | State |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  |---|---|
159
- | BigEarthNet S1+S2 full corpus | **NOT DOWNLOADED** (only the 28k S2 subset is local) |
160
- | BigEarthNet multi-label (reBEN) results | **not produced** β€” the local subset is single-label |
161
- | Cross-dataset generalisation sets | **not used** |
162
- | Any private / hidden evaluation data | **not accessed** (`hidden_data_access: false`) |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Datasets β€” deep reference
2
 
3
+ **Status tags used on every substantive claim:** `IMPLEMENTED` Β· `VERIFIED` Β· `MEASURED` Β· `ATTEMPTED`
4
+ Β· `NOT RUN` Β· `BLOCKED` Β· `DEFERRED` Β· `REJECTED` Β· `OPEN` Β· `RESOLVED` Β· `CLOSED`.
 
5
 
6
+ Every figure in this document was **measured from the data on disk**, not copied from a paper or a
7
+ dataset schema. Where a corpus was only partially acquired, or where the local subset differs from the
8
+ official release, that is stated β€” not smoothed over. Where a fact is not established from the
9
+ available evidence, this document writes
10
+ `UNKNOWN β€” not established from the available evidence`.
11
 
12
+ **The corpora this project trains and evaluates on:**
 
 
13
 
14
  | Task | Dataset | Role | Local state |
15
  |---|---|---|---|
16
  | `change` | **LEVIR-CD-256** | train / val / test | ACQUIRED β€” split 7120 / 1024 / 2048 |
17
+ | `change_vqa` | **CDVQA** (annotations) + **SECOND** (imagery) | train / val / test / test2 | annotations + imagery ACQUIRED; name-verified 2,968 / 2,968 |
18
+ | `grounding` | **VRSBench** | train / eval (16,159 eval records) | ACQUIRED β€” boxes normalised 0–100 |
19
+ | `optical_sar` | **BigEarthNet** (reBEN v2, CLC-19) | train / held-out test | PARTIAL β€” 28,000-patch local selection |
20
+ | `vlm` | **BigEarthNet-S2 single-label** | LoRA adaptation | PARTIAL β€” same 28,000-patch request, 24,732 matched |
21
+
22
+ > **Two different BigEarthNet corpora are in play and must not be conflated.** The optical-SAR fusion
23
+ > head consumes the **reBEN v2** corpus (`data/bigearthnet_v2/reben/`); the VLM LoRA adapter consumes a
24
+ > **BigEarthNet-S2 single-label** subset (`run_manifest.json` β†’ `config.corpus_root`). They share the
25
+ > 28,000-patch request but are different trees with different label sources. Β§6 covers both.
26
+
27
+ **Sibling documents:** [`TRAINING.md`](TRAINING.md) (how each corpus is consumed),
28
+ [`EVALUATION.md`](EVALUATION.md) (protocols and honesty rules),
29
+ [`BENCHMARKS.md`](BENCHMARKS.md) (the numbers), [`REPRODUCIBILITY.md`](REPRODUCIBILITY.md)
30
+ (reproduction).
31
+
32
+ ---
33
+
34
+ ## Table of contents
35
+
36
+ 1. [How these figures were obtained](#1-how-these-figures-were-obtained)
37
+ 2. [Summary](#2-summary)
38
+ 3. [LEVIR-CD-256 β€” change detection](#3-levir-cd-256--change-detection)
39
+ 4. [CDVQA + SECOND β€” change-VQA](#4-cdvqa--second--change-vqa)
40
+ 5. [VRSBench β€” grounding](#5-vrsbench--grounding)
41
+ 6. [BigEarthNet β€” optical-SAR fusion and VLM adaptation](#6-bigearthnet--optical-sar-fusion-and-vlm-adaptation)
42
+ 7. [Data hygiene and leakage controls](#7-data-hygiene-and-leakage-controls)
43
+ 8. [What is NOT available β€” exhaustive](#8-what-is-not-available--exhaustive)
44
+ 9. [What is NOT RUN / OPEN / BLOCKED for this topic](#9-what-is-not-run--open--blocked-for-this-topic)
45
+ 10. [Where the evidence lives](#10-where-the-evidence-lives)
46
+
47
+ ---
48
+
49
+ ## 1. How these figures were obtained
50
+
51
+ Three rules govern this document.
52
+
53
+ 1. **Measured, not quoted.** Every count, fraction and split size was produced by reading the files on
54
+ disk β€” annotation JSON, label PNGs, a metadata parquet, or a directory listing. Where a paper's
55
+ figure is mentioned, it is mentioned as *corroborated by* the measurement, never as the source.
56
+ 2. **Discrepancies are reported, not reconciled.** Where the data contradicts the original plan, or
57
+ where two documents disagree, both are recorded. Silently resolving a discrepancy is how a
58
+ preprocessing change moves the frozen config hash
59
+ (`configs/base.yaml` β†’ `78f1e3700da15aa1`) without anyone noticing.
60
+ 3. **A shortfall is a number, not a shrinkage.** When a corpus is only partially acquired, the
61
+ shortfall is surfaced in a run manifest (e.g. `coverage.n_unmatched`), not absorbed by dropping
62
+ examples quietly.
63
+
64
+ The measurement scripts are named inline throughout. The verifier that re-checks the release's headline
65
+ numbers is described in [`BENCHMARKS.md`](BENCHMARKS.md) Β§8.
66
+
67
+ ---
68
+
69
+ ## 2. Summary
70
+
71
+ | Corpus | Task | Local size | Split | Local state |
72
+ |---|---|---|---|---|
73
+ | LEVIR-CD-256 | `change` | 10,192 tiles (A / B / label) | 7120 / 1024 / 2048 | ACQUIRED |
74
+ | CDVQA annotations | `change_vqa` | 12 JSON, 55.0 MB | Train/Val/Test/Test2 | ACQUIRED |
75
+ | SECOND imagery | `change_vqa` | 2,968 pairs β†’ 11,872 PNGs | n/a (paired) | ACQUIRED, name-verified 2,968/2,968 |
76
+ | VRSBench | `grounding` | 16,159 eval records; 142,390 train conversations | eval / train | ACQUIRED |
77
+ | BigEarthNet reBEN v2 | `optical_sar` | 28,000-patch selection of a 549,488-row corpus | 20,000 / 4,000 / 4,000 | PARTIAL |
78
+ | BigEarthNet-S2 single-label | `vlm` | 24,732 matched of 28,000 requested | 17,471 / 3,375 / 3,886 patches | PARTIAL |
79
+
80
+ ---
81
+
82
+ ## 3. LEVIR-CD-256 β€” change detection
83
+
84
+ The standard change-detection benchmark, 256 Γ— 256 tiles. It is the corpus behind the release's only
85
+ `VERIFIED` headline (pooled IoU 0.8122).
86
+
87
+ ### 3.1 The layout
88
+
89
+ The local corpus uses a **flat** layout (`docs/PHASE9_REAL_DATA_VERIFICATION.md`):
90
+
91
+ ```
92
+ <root>/{A,B,label}/<split>_<scene>_<tile>.png
93
+ ```
94
+
95
+ `A` is the pre-change image, `B` the post-change image, `label` the change mask. The loader recognises
96
+ both this flat layout and a nested layout (`training/change/dataset.py` β†’ `LAYOUT_NESTED` /
97
+ `LAYOUT_FLAT`), and the verification recorded `layout: flat`.
98
+
99
+ | Directory | PNGs |
100
+ |---|---|
101
+ | `A` | 10,192 |
102
+ | `B` | 10,192 |
103
+ | `label` | 10,192 |
104
+
105
+ (`artifacts/change/levir_real_data_verification.json` β†’ `dir_counts`; the check "A/B/label counts
106
+ agree" passed with detail `10192 / 10192 / 10192`.)
107
+
108
+ ### 3.2 The split
109
+
110
+ The split is a **frozen invariant**, not a choice made per run. `docs/PHASE9_FREEZE.md` Β§3 records it:
111
+
112
+ > Scene split: **445 / 64 / 128 scenes = 7120 / 1024 / 2048 tiles**, enforced by `change.levir_split` in
113
+ > `configs/base.yaml`.
114
+
115
+ | Split | Scenes | Tiles |
116
+ |---|---|---|
117
+ | train | 445 | **7,120** |
118
+ | val | 64 | **1,024** |
119
+ | test | **128** | **2,048** |
120
 
121
+ (`artifacts/change/levir_real_data_verification.json` β†’ `splits`.) The scene counts are the leakage
122
+ boundary; the tile counts are what the trainer sees. `training/change/dataset.py` defines
123
+ `LEVIR_SPLITS = {"train": 445, "val": 64, "test": 128}` and provides `assert_image_disjoint`.
124
 
125
+ **The split is scene-disjoint, and the disjointness is measured, not assumed.** The verification
126
+ recorded three separate checks β€” `train/val scene-disjoint`, `train/test scene-disjoint`,
127
+ `val/test scene-disjoint` β€” each passing with an empty offender list
128
+ (`artifacts/change/levir_real_data_verification.json` β†’ `checks`, and β†’ `scene_overlap` with all three
129
+ counts `0`).
130
 
131
+ **The declared split agrees with the derived split.** The verification compared the release's declared
132
+ split lists against a split derived from the file names:
133
+
134
+ | Split | declared | derived | agreement | declared-only | derived-only |
135
+ |---|---|---|---|---|---|
136
+ | train | 7,120 | 7,120 | 7,120 | 0 | 0 |
137
+ | val | 1,024 | 1,024 | 1,024 | 0 | 0 |
138
+ | test | 2,048 | 2,048 | 2,048 | 0 | 0 |
139
+
140
+ (`artifacts/change/levir_real_data_verification.json` β†’ `split_list_agreement`.) A disagreement here
141
+ would mean the split lists and the file names told different stories; they do not.
142
+
143
+ ### 3.3 The label contract
144
+
145
+ | Property | Value | Source |
146
+ |---|---|---|
147
+ | label values | `0`, `156`, `255` | `artifacts/change/levir_real_data_verification.json` β†’ `sample.label_values` |
148
+ | `LABEL_CHANGE_LEVEL` | **128** | `training/change/train.py` |
149
+ | binary decision | `>= 128` β†’ change | same |
150
+ | label is binary `0/255` | intermediate `156` is negligible | verification check |
151
+
152
+ Measured label-value counts over a 600-tile sample
153
+ (`artifacts/change/levir_real_data_verification.json` β†’ `sample.label_values`):
154
+
155
+ | Value | Pixels |
156
  |---|---|
157
+ | 0 | 37,590,438 |
158
+ | 156 | **17** |
159
+ | 255 | 1,731,145 |
160
+
161
+ The `156` pixels are **4.32e-07 of 39,321,600 px** β€” a negligible anti-aliasing residue. The check
162
+ "labels binary 0/255 (intermediate negligible)" passed, and the check "intermediate labels >=
163
+ LABEL_CHANGE_LEVEL (128)" passed with an empty `below-threshold` set. The threshold matters: it is what
164
+ makes the `156` residue fall on the change side rather than being silently rounded away.
165
+
166
+ Two further checks passed: "T1 and T2 are not identical" (0 identical pairs in the sample β€” a pair that
167
+ was byte-identical would be a null change example masquerading as a real one) and "label matches image
168
+ size" (0 mismatches).
169
+
170
+ ### 3.4 The test split, measured in detail
171
 
172
+ `artifacts/change/eval_test/eval_result.json`, `split: test`:
173
 
174
  | Property | Value |
175
  |---|---|
176
+ | n | **2,048** |
177
  | images containing change | **935** |
178
+ | **mean change fraction** | **0.0509** (β‰ˆ 5 % of pixels) |
179
+ | change-fraction quantiles | min 0.0, p50 0.0, p90 0.197205, max 0.684937 |
180
  | threshold | 0.50 |
181
  | tile size | 256 |
182
  | device (eval) | cuda |
183
  | total pixels scored | 134,217,728 |
184
+ | eval seconds | 55.359 |
185
+ | `config_drift` | false |
186
 
187
+ **Per-pixel confusion counts** (pooled; identical for the macro block, which aggregates the same pixels
188
+ per image):
 
 
189
 
190
+ | Count | Value |
191
+ |---|---|
192
+ | tp | 5,978,997 |
193
+ | fp | 523,658 |
194
+ | fn | 858,407 |
195
+ | tn | 126,856,666 |
196
 
197
+ The confusion counts are stored **so any metric can be recomputed rather than trusted**. The
198
+ per-image change fraction is stored for all 2,048 tiles
199
+ (`per_image_change_fraction`, a 2,048-element list), which is what makes the quantiles above
200
+ recomputable.
201
 
202
+ ### 3.5 Why both pooled and macro are reported
203
 
204
+ With a **~5 % mean change fraction** the class imbalance is severe, so the pooled and macro metrics
205
+ answer different questions and both are reported
206
+ ([`BENCHMARKS.md`](BENCHMARKS.md) Β§10.1):
207
+
208
+ | Metric | pooled | macro |
209
+ |---|---|---|
210
+ | f1 | **0.8964** | 0.7962 |
211
+ | iou | **0.8122** | 0.7180 |
212
+ | miou | 0.9007 | **0.8457** |
213
+ | precision | 0.9195 | 0.8506 |
214
+ | recall | 0.8745 | 0.7757 |
215
+
216
+ **Naming caveat.** The `macro` block carries both `iou` (0.7180, the macro-average of the per-image IoU)
217
+ and `miou` (0.8457, the mean IoU). The headline "macro IoU 0.8457" used across the release docs is the
218
+ **`miou`** field. Both key paths are named here so the two cannot be conflated.
219
+
220
+ **A threshold sweep was performed and the lever was CLOSED.** It was **val-only by construction**:
221
+ `scripts/sweep_change_threshold.py` refuses any `--split` other than `val` and exits `4` before loading
222
+ config, checkpoint, or data; its artifact records `test_split_touched: false` and its sha256 is
223
+ `34e20f62bc1dd7810f5ef5f26838213d59368cf1352a804d38cb0e140eaf97f2`. The best available gain was
224
+ **0.0007** pooled IoU against a **0.0019** epoch-to-epoch spread β€” 0.37Γ— the noise. **0.50 is retained;
225
+ this hypothesis is eliminated, not deferred** (`docs/PHASE9_FREEZE.md` Β§5).
226
+
227
+ ### 3.6 The three real-data defects found and fixed
228
+
229
+ `docs/PHASE9_REAL_DATA_VERIFICATION.md` records three defects that the verification caught before any
230
+ number was trusted. They are included here because each is a *silent* failure mode a reader could
231
+ re-introduce:
232
+
233
+ | # | Defect | Consequence if unfixed |
234
+ |---|---|---|
235
+ | 1 | **loader returned 0 items** | a training run on an empty dataset |
236
+ | 2 | **vacuous leakage guard** | a scene-disjointness check that could not fail, because it compared nothing |
237
+ | 3 | **the test split was folded into training** | a test number that was partly a training number |
238
+
239
+ Defect 3 is the dangerous one: it is exactly the failure the scene-disjointness rule exists to prevent,
240
+ and it would have *raised* the reported test IoU rather than lowering it. The verification's three
241
+ `scene-disjoint` checks (Β§3.2) are the pin that now prevents it.
242
+
243
+ ### 3.7 The registration gate β€” an `OPEN` serving-path defect
244
+
245
+ The change specialist's registration gate produces false positives: **1,202 / 2,048 test tiles
246
+ (58.7 %)** flagged as mis-registered, with the flag rate climbing 42 % β†’ 75 % β†’ 98 % β†’ 100 % as
247
+ ground-truth change fraction rises; **5,679 region claims suppressed** on 638 flagged tiles that do
248
+ contain change. An independent NCC check found **0 / 30** credible large offsets on the tiles the gate
249
+ blames. **`OPEN`** (`docs/PHASE9_FREEZE.md` Β§7). This affects the *serving path*, not the 0.8122
250
+ benchmark, which is computed offline from the mask directly.
251
+
252
+ ---
253
+
254
+ ## 4. CDVQA + SECOND β€” change-VQA
255
+
256
+ ### 4.1 The two-corpus arrangement
257
+
258
+ **The CDVQA repository publishes annotations only β€” no imagery.** The imagery is publicly available as
259
+ **SECOND** (semantic change detection). The local corpus is therefore assembled from two sources:
260
+
261
+ ```
262
+ data/cdvqa/
263
+ annotations/ 12 JSON files (55.0 MB) <- CDVQA repo (annotations only)
264
+ im1/ im2/ pre / post imagery 2,968 PNGs each <- SECOND (archive order)
265
+ label1/ label2/ pre / post label maps 2,968 PNGs each <- SECOND
266
+ second.zip 2,406,111,691 B (kept, not deleted) <- the raw SECOND archive
267
+ ```
268
+
269
+ Source of the annotations: `https://github.com/YZHJessica/CDVQA`.
270
+ Source of the imagery: the **SECOND** semantic-change-detection dataset, downloaded and extracted
271
+ locally. The paper states the arrangement verbatim (`docs/PHASE10_CDVQA_DATA_STATUS.md` Β§6):
272
+
273
+ > *"we choose the existing semantic change detection dataset SECOND as the basic data to automatically
274
+ > generate a CDVQA dataset."* … *"Thus, we use the 2,968 openly available pairs as our basic data."*
275
+
276
+ ### 4.2 SECOND β€” acquisition and verification
277
+
278
+ The imagery acquisition is a story with three findings, all recorded rather than hidden
279
+ (`docs/PHASE10_CDVQA_DATA_STATUS.md` Β§6).
280
+
281
+ **a. The archive is RAR, not zip.** It is *named* `second.zip` but the magic bytes are
282
+ `Rar!\x1a\x07\x01\x00` β€” a **RAR5** archive. The overlap checker originally assumed zip and reported
283
+ *"not a readable zip"* on a perfectly good download. It now detects the format by magic bytes and reads
284
+ RAR via libarchive. **Do not trust a `.zip` extension.**
285
+
286
+ **b. The names match β€” 2,968 / 2,968.** CDVQA's `file_name` values are a **sparse global identifier**,
287
+ not a compact index: min `00003`, max `24649`, **not contiguous** (21,679 gaps in a 24,647-wide span).
288
+ Whether SECOND used the same filenames was not established and had to be settled by download, not
289
+ assumption. It was:
290
+
291
+ ```
292
+ SECOND PNG files : 2,968
293
+ CDVQA unique names : 2,968 (expected 2,968)
294
+ intersection : 2,968
295
+ CDVQA not in SECOND: 0
296
+ SECOND not in CDVQA: 0
297
+ VERDICT: MATCH
298
+ ```
299
+
300
+ (`artifacts/cdvqa/second_overlap.json`: `verdict: "MATCH"`, `intersection: 2968`,
301
+ `cdvqa_not_in_second: 0`, `second_not_in_cdvqa: 0`, `cdvqa_count_matches_expectation: true`.) The checker
302
+ `scripts/check_cdvqa_second_overlap.py` exited **0**.
303
+
304
+ | SECOND archive property | Value |
305
+ |---|---|
306
+ | bytes | **2,406,111,691** |
307
+ | sha256 | `5ee2a82b5824b3f5e3c5bfaf018835862e7623993ff9ac5ce57fd11fcfad2b4e` |
308
+ | container | **RAR5** (magic `Rar!\x1a\x07\x01\x00`) |
309
+ | image format | 512 Γ— 512, 8-bit, **PNG colour type 2** (truecolour) |
310
+ | pairs | 2,968 β†’ 5,936 imagery PNGs + 5,936 label PNGs |
311
+
312
+ **c. The archive is a temporal PAIR.** SECOND's layout is `im1/ im2/ label1/ label2/`, and `im1` and
313
+ `im2` **share basenames** (`im1/00003.png` and `im2/00003.png` both exist). So one CDVQA `file_name`
314
+ denotes a **pair**, not an image. This is why the adapter is pair-aware and why
315
+ `imagery_verification.json` includes the check *"flat image_path() does NOT resolve (pair layout is what
316
+ is on disk)"* β€” the flat layout is a real, loadable-looking layout that is wrong for this corpus.
317
+
318
+ The extraction is verified end to end (`artifacts/cdvqa/imagery_verification.json`,
319
+ `all_checks_passed: true`): each of the four archive dirs holds 2,968 PNGs; `load_cdvqa(
320
+ require_images=True)` succeeds; every declared name exists in `im1`, `im2` and both label dirs; all
321
+ **153,130** examples' `image_path` **and** `image_paths` entries resolve on disk (153,130 / 153,130);
322
+ the layout is `second_pairs`; and `summarize` reports 2,968 declared / 2,968 present.
323
+
324
+ ### 4.3 The annotations, measured
325
+
326
+ | File | Bytes |
327
+ |---|---|
328
+ | `Train_images.json` | 3,358,035 |
329
+ | `Train_questions.json` | 13,201,947 |
330
+ | `Train_answers.json` | 6,931,041 |
331
+ | `Val_images.json` | 826,107 |
332
+ | `Val_questions.json` | 3,259,814 |
333
+ | `Val_answers.json` | 1,710,345 |
334
+ | `Test_images.json` | 2,021,288 |
335
+ | `Test_questions.json` | 7,921,042 |
336
+ | `Test_answers.json` | 4,160,913 |
337
+ | `Test2_images.json` | 1,961,840 |
338
+ | `Test2_questions.json` | 6,353,771 |
339
+ | `Test2_answers.json` | 3,278,618 |
340
+
341
+ **Total 55.0 MB** (`docs/PHASE10_CDVQA_DATA_STATUS.md` Β§1).
342
+
343
+ **Schema.** Each JSON has exactly one top-level key β€” `images`, `questions`, `answers`:
344
+
345
+ ```
346
+ images {id, res_x, res_y, questions_ids, file_name, active}
347
+ questions {id, date_added, img_id, type, question, answers_ids, active}
348
+ answers {id, date_added, question_id, answer, active}
349
+ ```
350
+
351
+ Two type traps a reader gets wrong by guessing: `date_added` is a **float unix timestamp** (e.g.
352
+ `1631214265.2322025`), and `res_x` / `res_y` are the **strings** `".1524m"` on every row of every split β€”
353
+ metadata, not a usable resolution figure.
354
+
355
+ **`images` has one row per question, not per image.** Train carries **25,600 image rows** but only
356
+ **1,600 distinct `file_name` values**. Deduplicating on `file_name` and unioning `questions_ids` is
357
+ mandatory; a reader that counts image rows reports a corpus **16Γ— too large**.
358
+
359
+ ### 4.4 The measured split sizes
360
+
361
+ | Split | image rows | **unique `file_name`** | questions | answers |
362
+ |---|---|---|---|---|
363
+ | Train | 25,600 | **1,600** | **65,967** | 65,967 |
364
+ | Val | 6,400 | **400** | **16,441** | 16,441 |
365
+ | Test | 15,488 | **968** | **39,686** | 39,686 |
366
+ | Test2 | 15,488 | **968** | **31,036** | 31,036 |
367
+ | **union** | | **2,968** | **153,130** | 153,130 |
368
+
369
+ This independently confirms the paper's 1,600 / 400 / 968 image pairs and 65,967 / 16,441 / 39,686 /
370
+ 31,036 question-answer pairs (`docs/PHASE10_CDVQA_DATA_STATUS.md` Β§3). The same figures are asserted as
371
+ constants in `training/change_vqa/dataset.py` (`EXPECTED_SPLIT_SIZES`, `EXPECTED_TOTAL_SCENES: 2968`,
372
+ `EXPECTED_MANIFEST_RECORDS: 3936`) and re-measured by the notebook's integrity gate.
373
+
374
+ ### 4.5 Finding 1 β€” Test and Test2 are the same 968 images
375
+
376
+ Measured cross-split `file_name` overlap, all 16 cells
377
+ (`docs/PHASE10_CDVQA_DATA_STATUS.md` Β§4):
378
+
379
+ | | Train | Val | Test | Test2 |
380
+ |---|---|---|---|---|
381
+ | **Train** | 1600 | 0 | 0 | 0 |
382
+ | **Val** | 0 | 400 | 0 | 0 |
383
+ | **Test** | 0 | 0 | 968 | **968** |
384
+ | **Test2** | 0 | 0 | **968** | 968 |
385
+
386
+ Train / Val / Test are **cleanly disjoint** β€” no leakage. But **Test and Test2 share 100 % of their
387
+ images.** They are two different question sets asked over the **same 968 scenes**, not two independent
388
+ held-out samples. The paper says this outright: *"Note that there is an overlap between the two test
389
+ sets."* The measurement confirms it exactly.
390
+
391
+ > **Consequence.** Pooling Test and Test2, or reporting them as two independent evaluations, is
392
+ > statistically wrong and inflates apparent confidence. Any metric computed over `Test βˆͺ Test2` is a
393
+ > single evaluation of 968 images with ~70k questions, not two evaluations of 968 each.
394
+
395
+ This is why the release quotes change-VQA under **two test sets** β€” Test 0.697626 / 0.378373 and Test2
396
+ 0.651469 / 0.372309 β€” and never pools them ([`BENCHMARKS.md`](BENCHMARKS.md) Β§4.4,
397
+ [`EVALUATION.md`](EVALUATION.md) rule 3). The allowed shared pair is recorded in the run record:
398
+ `dataset.integrity.allowed_shared_pairs: [["Test", "Test2"]]`
399
+ (`artifacts/change_vqa/run/run_record.json`).
400
+
401
+ ### 4.6 Finding 2 β€” `change_ratio_types` has a different vocabulary in every split
402
+
403
+ The eight question types, with total counts across all four splits and their answer vocabularies
404
+ (`docs/PHASE10_CDVQA_DATA_STATUS.md` Β§5):
405
+
406
+ | Question type | Questions | Answer vocabulary |
407
+ |---|---|---|
408
+ | `change_or_not` | 47,807 | 2 β€” `yes`, `no` |
409
+ | `change_ratio_types` | 29,576 | **7–9, split-dependent** |
410
+ | `increase_or_not` | 16,040 | 2 β€” `yes`, `no` |
411
+ | `decrease_or_not` | 15,986 | 2 β€” `yes`, `no` |
412
+ | `change_to_what` | 12,233 | 6 β€” change classes |
413
+ | `smallest_change` | 11,808 | 6 β€” change classes |
414
+ | `largest_change` | 11,808 | 6 β€” change classes |
415
+ | `change_ratio` | 7,872 | 11 β€” `0` + ten 10 % bins |
416
+
417
+ **`change_ratio_types` observed values differ per split:**
418
+
419
+ | Split | observed values |
420
+ |---|---|
421
+ | Train | 9 β€” `0` … `70_to_80` |
422
+ | Val | 7 β€” `0` … `50_to_60` (no `60_to_70`, no `70_to_80`) |
423
+ | Test | 8 β€” `0` … `60_to_70` (no `70_to_80`) |
424
+ | Test2 | 8 β€” same as Test |
425
+
426
+ Val can never produce `60_to_70`. Train can never produce `80_to_90` or `90_to_100`. **A single global
427
+ label space for this question type would score the model on bins its split does not contain**, and
428
+ per-type confusion matrices for `change_ratio_types` are **not comparable across splits**.
429
+
430
+ **`change_ratio` and `change_ratio_types` are different question types with different vocabularies**
431
+ despite the similar names β€” `change_ratio` has 11 values including `0`; `change_ratio_types` has a
432
+ 9-value union. They must not share a constant. The remaining six types are stable across all four
433
+ splits.
434
+
435
+ **The corpus uses underscore tokens** (`0_to_10`); the paper's prose writes them as percentages
436
+ (`0%-10%`). Same bins, different rendering β€” do not string-match the paper against the corpus.
437
+
438
+ ### 4.7 Finding 3 β€” the imagery is not shipped with the annotations
439
+
440
+ Covered in Β§4.1–§4.2. The measurement that ties it together: the union of unique `file_name` across
441
+ Train/Val/Test is **exactly 2,968**, which is exactly the number of SECOND's publicly available pairs β€”
442
+ consistent with the paper's statement that CDVQA is generated from SECOND's 2,968 pairs.
443
+
444
+ ### 4.8 Temporal and label semantics β€” established, with honest uncertainty
445
+
446
+ This is the part of the corpus where the difference between **established** and **supported** matters
447
+ most, and the release keeps them apart
448
+ (`artifacts/cdvqa/temporal_order_evidence_v2.json` β†’ `note`, verbatim):
449
+
450
+ > *"label1=pre / label2=post is established from the annotations against the FIXED SECOND palette (no
451
+ > fitting) β€” the reverse is directionally empty. im1=pre / im2=post is SUPPORTED by a content test
452
+ > (water darkness / vegetation greenness vs a white control), not proven. Nothing is inferred from
453
+ > directory names."*
454
+
455
+ | Mapping | State | Basis |
456
+ |---|---|---|
457
+ | **`label1` = pre, `label2` = post** | **ESTABLISHED** | the annotations, against the **fixed** SECOND palette, with **no fitting** |
458
+ | **`im1` = pre, `im2` = post** | **SUPPORTED β€” NOT PROVEN** | a content test (water darkness / vegetation greenness) vs a white control |
459
+
460
+ **The label-order evidence.** Two hypotheses were tested against the **fixed** SECOND palette:
461
+
462
+ | Hypothesis | overall agreement | n | classes collapsed to background |
463
+ |---|---|---|---|
464
+ | **h1 (label1 = pre, label2 = post)** | **1.0** | 18,724 | **none** |
465
+ | h2 (the reverse) | 0.7602 | 29,024 | `trees`, `water`, `playgrounds` |
466
+
467
+ Under h1 every class matches the palette with agreement **1.0** and **no class collapses**. Under h2
468
+ three classes collapse to background and agreement is 0.7602. The reverse is **directionally empty** β€”
469
+ this is the "established" basis.
470
+
471
+ **Why v2 supersedes v1.** The supersession reason is recorded verbatim: v1 *"fit the palette in-sample
472
+ to maximise H1 and computed the reversed rate as 1 - h1_rate, so the perfect split was guaranteed by
473
+ construction. v2 measures BOTH directions against the FIXED SECOND palette and reports directional vs
474
+ tie evidence separately."* A perfect separation produced by in-sample fitting is not evidence; v2
475
+ removes the fitting.
476
+
477
+ **A corroborating annotation signal.** `change_to_what` (a *change* question) agrees far better with an
478
+ answer derived from **label2** than from label1 β€” 0.9469 (n 9,104) vs 0.4079 (n 7,179) β€” and the gap is
479
+ stable across subsamples of 500 / 800 / 1,200 / all. A "what did it change *to*" question is answered
480
+ by the post-change map; the measurement says label2 is the post map.
481
+
482
+ **The image-order evidence β€” supportive, statistical, not exact.** `image_label_pairing`:
483
+
484
+ | Test | Value | n |
485
+ |---|---|---|
486
+ | `water_label1_im1_darker` (mean) | **0.826** | 500 |
487
+ | `water_label2_im2_darker` (mean) | 0.7076 | 472 |
488
+ | **white control: `im1` darker rate** | **0.4356** | 2,968 |
489
+ | `veg_label1_im1_greener` (mean) | 0.7139 | 2,415 |
490
+ | `veg_label2_im2_greener` (mean) | 0.7508 | 2,392 |
491
+
492
+ The water-mask test is the strongest: where `label1` marks water, `im1` is darker than `im2` in **82.6 %**
493
+ of cases, against a **white-pixel control rate of 43.6 %** β€” a ~39-point gap. The check is recorded as
494
+ `strength: "supportive, statistical (not exact)"`. **It is not a proof, and this document does not call
495
+ it one.** Nothing is inferred from directory names: `imagery_verification.json` records
496
+ `temporal_order_claimed: false`, and the adapter returns `image_paths()` in **archive order** by
497
+ construction, which does not itself establish order.
498
+
499
+ **Label palette** (fixed, from SECOND; `fixed_palette` and the `label_order` per-class RGB):
500
+
501
+ | Class | RGB |
502
  |---|---|
503
+ | `NVG_surface` | (128, 128, 128) |
504
+ | `buildings` | (128, 0, 0) |
505
+ | `low_vegetation` | (0, 128, 0) |
506
+ | `playgrounds` | (255, 0, 0) |
507
+ | `trees` | (0, 255, 0) |
508
+ | `water` | (0, 0, 255) |
509
+
510
+ `white_semantics`: `white_share: 0.8006`, `nonwhite_where_label1_eq_label2: 0.0423` β€” the white pixels
511
+ are the background/unchanged class, which is why the white control is the right null for the darkness
512
+ test.
513
+
514
+ ### 4.9 The corpus-root contract
515
 
516
+ **Corpus root:** `data/cdvqa` β€” the directory that *contains* `annotations/`.
517
 
518
+ Passing `data/cdvqa/annotations` is **rejected** by `_resolve_root`. Verified by execution
519
+ (`docs/PHASE10_CDVQA_DATA_STATUS.md`, header correction):
520
+
521
+ ```
522
+ load_cdvqa('data/cdvqa/annotations')
523
+ -> raises: no 'annotations' directory under data\cdvqa\annotations
524
+
525
+ load_cdvqa('data/cdvqa')
526
+ -> loads (Val: 400 images, 16,441 questions)
527
+ ```
528
+
529
+ The correct call is `load_cdvqa('data/cdvqa')`. This is a contract, not a preference: the loader looks
530
+ for `annotations/` **under** the root it is given.
531
+
532
+ ### 4.10 The answer space and the question-type resolver
533
+
534
+ The change-VQA head answers in a **closed** space of **19** answers
535
+ (`training/change_vqa/vocab.py`; `artifacts/change_vqa/run/run_record.json` β†’
536
+ `dataset.statistics`):
537
+
538
+ | Group | Members |
539
+ |---|---|
540
+ | binary (2) | `yes`, `no` |
541
+ | change classes (6) | `NVG_surface`, `buildings`, `low_vegetation`, `trees`, `water`, `playgrounds` |
542
+ | ratio bins (11) | `0`, `0_to_10`, `10_to_20`, `20_to_30`, `30_to_40`, `40_to_50`, `50_to_60`, `60_to_70`, `70_to_80`, `80_to_90`, `90_to_100` |
543
+
544
+ `n_distinct_answers: 19`; `answers_outside_frozen_vocabulary: []`;
545
+ `answers_illegal_for_their_type: 0`. The measured answer distribution over the 82,408 Train+Val records
546
+ is led by `no` (25,612) and `yes` (22,396), with the rarest answers being `50_to_60` (175) and
547
+ `playgrounds` (202).
548
+
549
+ **Eight question types** map to the answer space through `resolve_question_type`
550
+ (`training/change_vqa/vocab.py`, with 8 types plus an `unknown` slot β†’ 9 resolver slots and 3 temporal
551
+ slots). Measured counts over Train+Val:
552
+
553
+ | Question type | Count |
554
  |---|---|
555
+ | `change_or_not` | 28,799 |
556
+ | `change_ratio_types` | 12,149 |
557
+ | `increase_or_not` | 9,635 |
558
+ | `decrease_or_not` | 9,574 |
559
+ | `change_to_what` | 6,251 |
560
+ | `largest_change` | 6,000 |
561
+ | `smallest_change` | 6,000 |
562
+ | `change_ratio` | 4,000 |
563
+
564
+ The resolver's agreement rate with the corpus's own type labels is **0.9472** (`type_resolver_agreement:
565
+ 78059`, `type_resolver_agreement_rate: 0.9472`) β€” i.e. the resolver disagrees with the corpus on ~5 % of
566
+ questions, which is recorded rather than rounded to 1.0. `validate_vocabulary()` raises if one ratio bin
567
+ or one change class is removed from the ontology.
568
+
569
+ ### 4.11 The split-integrity gate and the F1 defect
570
 
571
+ **The corpus has a per-split question-id space, and this is a measured trap.**
572
+ `question_id` restarts at 0 in every split: Train `0..65966`, Val `0..16440`, Test `0..39685`, Test2
573
+ `0..31035` β€” all measured, **0 gaps** (`R02_CHANGE_VQA_IMPLEMENTATION_STATUS.md` Β§5, F1).
 
574
 
575
+ The original `build_cdvqa_examples` indexed its question→scene map on the **bare `question_id`**, so a
576
+ multi-split call let a later split overwrite an earlier one. Measured:
 
 
577
 
578
+ | Call | questions | scenes | correct? |
579
+ |---|---|---|---|
580
+ | `splits=("Train",)` | 65,967 | 1,600 | yes, 0 pairing errors |
581
+ | `splits=("Val",)` | 16,441 | 400 | yes, 0 pairing errors |
582
+ | `splits=("Train","Val")` | 82,408 | **1,601** | **no** β€” correct is 2,000 |
583
+
584
+ A mis-paired question still trained, still evaluated, and still reported a number β€” which is why it
585
+ survived. **Fixed at the root**: the key is now `(split, question_id)`, a **no-op for a single split**,
586
+ preserving the function signature, the 10-key output schema, the split identity and Test/Test2
587
+ semantics. It fixes Test/Test2 as well, where Test2's 31,036 ids used to clobber Test's over the whole
588
+ overlap and the wrong pairing was invisible because the two splits share their 968 scenes.
589
 
590
+ **The gate.** `training/change_vqa/dataset.py` provides `verify_split_integrity` and
591
+ `assert_split_integrity`; the latter raises when a record set has two splits sharing scenes. The
592
+ trainer's `FORBIDDEN_SPLITS` are `("Test", "Test2")`, and the notebook refuses to continue unless the
593
+ four measured split counts match exactly. The smoke test proves Test/Test2 are unreachable **the strong
594
+ way**: their annotation files are written as **invalid JSON**, so a run that completes *is* the proof
595
+ they were never parsed, and a Test-only sentinel string must not appear in the serialised run record.
596
 
597
+ ### 4.12 What the raw loader does *not* do
598
+
599
+ The CDVQA adapter is pair-aware and `require_images=True` succeeds β€” but the **raw corpus has no
600
+ training loop of its own**. The shipped `change_vqa` head trains on **cached features**, not on the raw
601
+ loader. These are different code paths and are not conflated (`TRAINING.md` Β§8). The R-02 status
602
+ document states the starting point was "worse than adapter-only": `training/data/cdvqa.py` decodes no
603
+ imagery, builds no tensors, and has no reasoning path β€” R-02 is greenfield for the reasoning layer.
604
+
605
+ ---
606
+
607
+ ## 5. VRSBench β€” grounding
608
+
609
+ VRSBench is the grounding training and evaluation source. The schema was **VERIFIED 2026-09-16 against
610
+ the real dataset files** downloaded from the official mirror (HF `xiang709/VRSBench`, the dataset
611
+ author's own repo, 4,669 downloads, **cc-by-4.0**). Published figures, current in the repo: 29,614
612
+ images, 52,472 object references, 1,232,221 VQA pairs, human-verified captions
613
+ (`training/data/vrsbench.py`).
614
+
615
+ ### 5.1 What the verified files actually contain
616
+
617
+ `VRSBench_EVAL_referring.json` β€” **16,159 records**:
618
+
619
+ ```
620
+ {
621
+ "image_id": "P0003_0002.png", # bare filename, lives in Images_val.zip
622
+ "question": "The large yellow vehicle situated closest to the green area.",
623
+ "ground_truth": "{<25><40><33><60>}", # 0-100 xyxy as a token STRING, not a list
624
+ "obj_corner": [x1,y1, x2,y2, x3,y3, x4,y4], # 0-1 polygon (metadata; GT string is authority)
625
+ "obj_cls", "dataset", "question_id", "type", "size_group"
626
+ }
627
+ ```
628
+
629
+ `VRSBench_train.json` β€” **142,390 conversations**, with tasks interleaved
630
+ (`[caption]`, `[refer]`, `[vqa]`; first-20k census 3,089 / 4,762 / 12,149).
631
+
632
+ `n_eval_records: 16159` is recorded in `artifacts/grounding/remoteclip_grounding_v001/
633
+ eval_result_canonical.json`.
634
+
635
+ ### 5.2 The box convention β€” 0–100 vs 0–1
636
+
637
+ **VRSBench stores boxes normalised to 0–100.** This project stores boxes normalised to **0–1**. The
638
+ conversion is declared explicitly as `VRSBENCH_BOX_SCALE = 100.0` (`training/data/vrsbench.py`) and
639
+ `grounding.benchmark_box_scale: 100.0` (`configs/base.yaml`), never an inline divide β€” so it cannot be
640
+ applied twice or forgotten.
641
+
642
+ The module docstring states the failure mode verbatim:
643
+
644
+ > *"Treating 0-100 values as 0-1 would produce boxes a hundred times too large β€” an error that would
645
+ > show up as a suspiciously high IoU, not as a crash."*
646
+
647
+ `geospatial/transform.py` provides the named conversion helpers
648
+ (`benchmark_boxes_to_normalized`, `normalized_boxes_to_benchmark`, both taking `scale`), with the same
649
+ finding recorded in their docstring: *"Finding from Phase 0: VRSBench states verbatim that 'all box
650
+ coordinates are normalized to 0-100'. Treating those numbers as pixels is a silent, catastrophic
651
+ bug β€” this function exists so that mistake can only be made once, explicitly."*
652
 
653
  Because the box convention is a common source of silent error, grounding is reported under **two
654
+ protocols** (canonical and matched6) and **two decode variants** β€” see Β§5.4 and
655
+ [`BENCHMARKS.md`](BENCHMARKS.md) Β§4.2.
656
+
657
+ ### 5.3 The train split β€” and its noisy ground truths
658
+
659
+ > **TRAIN GROUND TRUTHS ARE NOISY. EVAL GROUND TRUTHS ARE CLEAN.**
660
+
661
+ This is stated verbatim in `training/data/vrsbench.py`, and the numbers matter:
662
+
663
+ | Split | Records | Ground-truth box range | Out-of-range |
664
+ |---|---|---|---|
665
+ | **eval** (`VRSBench_EVAL_referring.json`) | 16,159 | all within **[0, 100]** | **0** |
666
+ | **train** (`VRSBench_train.json`) | 142,390 conversations (tasks interleaved: `[caption]` / `[refer]` / `[vqa]`; first-20k census 3,089 / 4,762 / 12,149) | **36,313 refer records examined**, values reach **βˆ’73 … 196** | **8,238 of 36,313** |
667
+
668
+ Out-of-range train boxes are **rejected** by the loader, which filters that noise rather than training
669
+ on it. The loader keeps a legacy "unknown schema β†’ fail loudly" path: any JSON file that matches neither
670
+ the verified layout nor the older key-fallback table raises with the keys it actually saw.
671
+
672
+ ### 5.4 The two protocols Γ— two decode variants
673
+
674
+ | Protocol / variant | Value | Artifact |
675
+ |---|---|---|
676
+ | canonical, `head_threshold` | **0.2838** | `eval_result_canonical.json` |
677
+ | canonical, `head_argmax` | **0.1215** | `eval_result_canonical.json` |
678
+ | matched6, `head_threshold` (top_k 6) | **0.2566** | `eval_result_matched6.json` |
679
+ | zero-shot baseline | **0.0972** | `run_record.json` β†’ `baseline_mean_best_iou` |
680
+
681
+ **Never quote one protocol alone.** The training-time validation number is different again: best val IoU
682
+ **0.0946**, which **did not beat** the zero-shot baseline of 0.0972 (`beats_baseline: false`). That
683
+ negative result is preserved ([`TRAINING.md`](TRAINING.md) Β§5.8).
684
+
685
+ ---
686
+
687
+ ## 6. BigEarthNet β€” optical-SAR fusion and VLM adaptation
688
+
689
+ BigEarthNet is used in **two** places, from **two different corpora**:
690
+
691
+ 1. as the **label space and evaluation benchmark** for the optical-SAR fusion head β€” the **reBEN v2**
692
+ corpus at `data/bigearthnet_v2/reben/`;
693
+ 2. as the **instruction-pair source** for the SmolVLM LoRA adaptation β€” a **BigEarthNet-S2 single-label**
694
+ subset.
695
+
696
+ ### 6.1 The reBEN v2 corpus (optical-SAR fusion)
697
+
698
+ **The label source is the official manifest, not per-patch metadata.** The local corpus has **no
699
+ `metadata.json`** in any patch directory; every one of the 28,000 S2 patch directories holds only band
700
+ TIFFs. `training/data/bigearthnet.py`'s `_read_metadata` therefore returns `{}` and
701
+ `discover_patches(require_labels=True)` filters out **every** patch. The labels exist in the release's
702
+ official manifest, keyed by `patch_id`:
703
+
704
+ ```
705
+ data/bigearthnet_v2/metadata.parquet 480,038 rows
706
+ patch_id, labels, split, country, s1_name, s2v1_name,
707
+ contains_seasonal_snow, contains_cloud_or_shadow
708
+ ```
709
+
710
+ (`training/data/bigearthnet_labels.py`; `DEFAULT_METADATA_PARQUET =
711
+ "data/bigearthnet_v2/metadata.parquet"`; `_REQUIRED_COLUMNS = ("patch_id", "labels", "split")`.)
712
+
713
+ **Measured corpus figures** (`artifacts/phase12_selection/feasibility_report.json`):
714
+
715
+ | Quantity | Value |
716
+ |---|---|
717
+ | `metadata.parquet` rows | **480,038** |
718
+ | `metadata_for_patches_with_snow_cloud_or_shadow.parquet` rows | 69,450 |
719
+ | rows total (the two files together) | **549,488** |
720
+ | **tiles** | **54** |
721
+ | ground cells | **253,957** |
722
+ | impure cells | **0** |
723
+ | `tile_derivable_rows` | 549,488 |
724
+ | `tile_mismatch_patch_id_vs_s1_name` | 0 |
725
+ | `s2v1_name_rows_with_a_tile_token` | 0 |
726
+ | blocks total | **184** (train 63 / val 65 / test 56) |
727
+ | eligible patches by split | train 53,245 / val 22,303 / test 20,989 |
728
+ | official split patches | train 272,544 / val 139,577 / test 137,367 |
729
+ | skipped multi/zero-label by split | train 219,299 / val 117,274 / test 116,378 |
730
+ | patches-per-cell histogram | {1: 96,670, 2: 58,498, 3: 59,334, 4: 39,455} |
731
+
732
+ > **Tile-count discrepancy β€” flagged, not silently reconciled.** The verified corpus figure is
733
+ > **54 tiles** (`artifacts/phase12_selection/feasibility_report.json` β†’ `corpus.tiles = 54`;
734
+ > `training/data/bigearthnet.py` β†’ "the corpus spans 54 tiles"; `training/data/bigearthnet_blocks.py`
735
+ > β†’ "52 of 54 tiles straddle two or more official [partitions]"). The sibling document
736
+ > [`MODELS.md`](MODELS.md) Β§3.3 states **"98 tiles"**, and the previous revision of this file repeated
737
+ > it. **No artifact read for this document reproduces 98.** The figure this document states is **54**,
738
+ > with its key path. The 98 figure is recorded here as a **documentation discrepancy** rather than
739
+ > quietly adopted or quietly deleted.
740
 
741
+ **The tile is a coarse leakage boundary; the block is the official unit.** The tile key is the
742
+ repository-wide primitive that `evaluation.leakage` uses everywhere else, but the official split's own
743
+ atomic unit is finer: every seasonal acquisition of a ground location `(tile, row, col)` carries the same
744
+ split, and all **253,957** ground cells are split-pure (**0 impure cells in 549,488 patches**). Those
745
+ locations then group into connected blocks that **nest inside a tile** (train frame, validation ring,
746
+ test core) β€” which is exactly why **52 of the 54 tiles straddle two or more partitions** (47 appear in
747
+ all three; only **2** are partition-pure). Using the tile key means `_official_split_scene_disjoint`
748
+ **rejects** the official split, and that rejection is a real measurement, not a formality
749
+ (`training/data/bigearthnet.py` β†’ `scene_id` docstring; `docs/PHASE12_LABEL_POLICY_DECISION.md` Β§6).
750
 
751
+ ### 6.2 The 28,000-patch selection
 
752
 
753
+ Training and evaluation do not use all 549,488 rows. A **28,000-patch selection** was drawn from the
754
+ single-label subset, scene-disjoint by T2 blocks
755
+ (`artifacts/phase12_selection/selection_manifest_seed10.jsonl`; the feasibility report records 20
756
+ per-seed manifests):
757
 
758
  | Property | Value |
759
  |---|---|
760
+ | selection manifest | `artifacts/phase12_selection/selection_manifest_seed10.jsonl` |
761
+ | manifest bytes / sha256 | 12,264,103 B / `fb4d8b4b024a17a842d351e9cfdbb3904648f1883406a5953a41449b375a7843` |
762
+ | records | **28,000** |
763
+ | T2 scene blocks | 61 |
764
+ | **tiles in the shipped selection** | **47** (12 of the 47 carry blocks in more than one split) |
765
+ | targets | train **20,000** / validation **4,000** / test **4,000** |
766
+ | scene key | `ben_<tile>:<tile-global ordinal>` (4-connected components of the (row, col) grid, per (tile, official split)) |
767
+ | selection order | `sha256('<seed>:<scene_id>')` ascending |
768
+ | label policy | `skip_ambiguous` |
769
+ | `all_seeds_reached_targets` / `all_seeds_scene_disjoint` | true / true |
770
+
771
+ The **tile key is too coarse** for this selection: `PairedSample.scene_id` defaults to `ben_<tile>`, but
772
+ 12 of the 47 tiles carry blocks in more than one split, so the tile key would raise a spurious "the split
773
+ leaks" abort and kill the run. The manifest's `scene_id` (`ben_<tile>:<block ordinal>`) is therefore
774
+ passed through explicitly via `PairedSample.scene_key`
775
+ (`training/fusion/reben_adapter.py`).
776
+
777
+ ### 6.3 The label policy β€” `require_single_label`
778
+
779
+ The optical-SAR fusion training and the 11.5 metric use **exactly-one-label patches only**. The policy
780
+ is `skip_ambiguous` (`feasibility_report.json` β†’ `corpus.label_policy`), and
781
+ `training/fusion/extract.py` implements it as `require_single_label`, counting skipped patches in
782
+ `n_skipped_by_policy`.
783
+
784
+ The label policy was **ruled by the owner** (`docs/PHASE12_LABEL_POLICY_DECISION.md`, rulings 1–6), and
785
+ the arithmetic is measured:
786
+
787
+ | Quantity | Value | Basis |
788
+ |---|---|---|
789
+ | **Mean labels per patch** | **2.9540** | full corpus |
790
+ | Mean labels per patch | 2.9495 | clean subset |
791
+ | ***f* over the full corpus** | **0.175685** (17.5685 %) | official metadata, 549,488 rows |
792
+ | ***f* over the clean subset** | **0.179934** (17.9934 %) | official metadata, 480,038 rows |
793
+ | **Single-label patches** | **96,537** | official metadata |
794
+ | Multi-label patches | **452,951** (82.43 %) | official metadata |
795
+ | Retained by `require_single_label` | 96,537 (17.57 %) = 53,245 train / 22,303 val / 20,989 test | β€” |
796
+ | Discarded by `require_single_label` | 452,951 patches (82.43 %) | β€” |
797
+
798
+ So the corpus carries **β‰ˆ 2.95 classes per patch on average**, and only **17.57 %** of patches carry
799
+ exactly one label. The *f* β‰₯ 7.28 % threshold the plan set is cleared with room to spare (17.57 % vs
800
+ 7.28 %), but the cost is explicit: the single-label policy **discards ~1.95 of every 2.95 labels per
801
+ patch (β‰ˆ 66 %)**.
802
+
803
+ ### 6.4 The single-label caveat β€” the figure that governs how these metrics may be used
804
 
805
+ > ### **The local subset is 100 % single-label, against the official 1–11 multi-label scheme.**
806
+ >
807
+ > Metrics computed on this subset are therefore **not comparable** to published multi-label BigEarthNet
808
+ > numbers. Any statement of the form "BigEarthNet mAP = X" is **false** for this subset.
809
+ > (`run_manifest.json` β†’ `corpus.coverage.single_label_fraction: 1.0`, `n_multi_label: 0`;
810
+ > `corpus.warnings`.)
811
 
812
+ The corpus-level figures the caveat is measured against: **β‰ˆ 2.95 labels per patch on average (max 11)**,
813
+ with only **17.57 %** of patches single-label (Β§6.3). The VLM run manifest states the consequence
814
+ verbatim:
815
 
816
+ > *"every one of the 24732 matched patches is single-label; the BigEarthNet corpus at large averages
817
+ > ~2.95 labels per patch (max 11), so this subset cannot support multi-label enumeration questions"*
818
 
819
+ ### 6.5 The fusion evaluation detail
820
+
821
+ `artifacts/optical_sar/fusion_head_production_v001/pre_registered_115_metric.json`:
822
 
823
  | Property | Value |
824
  |---|---|
825
  | split | test |
826
  | n scored | **4,000** |
827
  | classes in label space | 19 |
828
+ | classes present in the scored split | **14** β€” `[0,2,3,4,5,6,7,8,9,10,12,13,17,18]` |
829
+ | classes absent | **5** β€” `[1,11,14,15,16]` |
830
+ | **macro-F1 denominator** | **"all 19 classes (absent classes contribute 0.0)"** |
831
+ | accuracy | **0.931** |
832
+ | **macro F1** | **0.434161** |
833
+ | loss | 0.254592 |
834
+ | `is_deciding_statistic` | **false** |
835
+
836
+ The macro-F1 denominator is recorded explicitly so a reader can see that absent classes drag the macro
837
+ score down **by construction**. This is why accuracy (0.931) and macro-F1 (0.434161) must be read
838
+ together, and why the ruling is `OPEN`. Classes 5 and 6 are *present* in the scored split and still
839
+ score `0.0` (`docs/PHASE12_115_METRIC_COMPUTED.md`); the alternative denominator (14 classes) would give
840
+ macro-F1 **0.589218** instead of **0.434161**.
841
+
842
+ **The 19-class label space** is `CLC19_CLASSES`, an ordered 19-name tuple in
843
+ `training/data/bigearthnet.py`. The per-class test label distribution recorded in
844
+ `docs/PHASE12_115_METRIC_COMPUTED.md` is
845
+ `[13,0,63,39,846,4,1,6,453,246,4,0,9,18,0,0,0,34,2264]` β€” a strongly skewed distribution dominated by
846
+ class 18.
847
+
848
+ > **The BigEarthNet documentation β€” its uses, mentions, or endorsements β€” does not specify a percentile
849
+ > stretch.** This project nevertheless applies percentile normalisation for optical inputs (2/98) to
850
+ > match the CROMA contract. That is a **deliberate, documented choice**, not an upstream fact
851
+ > (`docs/RESEARCH_NOTES.md`).
852
+
853
+ ### 6.6 The VLM subset β€” a different BigEarthNet corpus
854
+
855
+ The VLM LoRA adapter consumes a **BigEarthNet-S2 single-label** subset
856
+ (`run_manifest.json` β†’ `config.corpus_root`:
857
+ `/kaggle/input/datasets/creatorballs/bigearth-net-s2-single-label/BigEarthNet-S2`), **not** the reBEN v2
858
+ fusion corpus.
859
+
860
+ | Property | Value | Key path |
861
+ |---|---|---|
862
+ | instruction families | `["presence"]` | `corpus.families` |
863
+ | n samples (questions) | 49,464 | `corpus.n_samples` |
864
+ | n requested patches | 28,000 | `corpus.coverage.n_requested` |
865
+ | n matched | **24,732** | `corpus.coverage.n_matched` |
866
+ | **coverage fraction** | **0.883286** | `corpus.coverage.coverage_fraction` |
867
+ | n unmatched | **3,268** (11.7 %) | `corpus.coverage.n_unmatched` |
868
+ | single-label fraction | **1.0** | `corpus.coverage.single_label_fraction` |
869
+ | scene blocks (train / val / test) | 1,160 / 482 / 202 | `corpus.scene_counts` |
870
+ | patches (train / val / test) | 17,471 / 3,375 / 3,886 | `corpus.split_info.patches_by_split` |
871
+ | samples (train / val / test) | 34,942 / 6,750 / 7,772 | `corpus.split_counts` |
872
+ | scene key | `ben_<tile>:<k>` | `corpus.split_info.scene_key` |
873
+ | render | RGB `[B04, B03, B02]`, per-band percentile stretch 2/98, uint8 | `corpus.render` |
874
+
875
+ **The unmatched 3,268 patches are excluded, not guessed.** `corpus.warnings`, verbatim:
876
 
877
+ > *"3268 of 28000 patches have no row in the manifest and carry NO labels; they are excluded rather than
878
+ > guessed (88.3% coverage)"*
 
879
 
880
+ `training/data/bigearthnet_labels.py` records the same rule: a patch with no manifest row gets **no
881
+ labels** β€” not a default, not a nearest-neighbour, not a guessed one. It is reported as unlabelled and
882
+ excluded, and the count is surfaced by `coverage()` so the shortfall is **a number in the run manifest
883
+ rather than a silent shrinkage of the training set**. `LabelCoverage` carries `n_requested`,
884
+ `n_matched`, `n_unmatched`, `n_multi_label`, `n_single_label` and `single_label_fraction`.
885
 
886
+ **The render is per-patch, not corpus-global** β€” `corpus.render.note` records that this is deliberate,
887
+ "so inference needs no training-set statistics". The split policy is
888
+ `release_partition_keyed_by_T2_blocks`, with a leakage check calling
889
+ `evaluation.leakage.assert_no_scene_overlap`, and the block reconstruction is conservative:
890
+ *"blocks are reconstructed from this corpus's patches, not the full release; that can only split a block
891
+ further, never merge across a partition."*
892
 
893
+ ### 6.7 The format contradiction β€” reported, not silently resolved
 
 
894
 
895
+ > **PARTIALLY β€” reported, not silently resolved.** The BigEarthNet data format contradicts the original
896
+ > plan. This was reported rather than quietly patched, because silently changing the preprocessing would
897
+ > move the frozen config hash (`78f1e3700da15aa1`). See [`RESEARCH_NOTES.md`](RESEARCH_NOTES.md).
898
 
899
+ The concrete contradictions the project hit, each measured:
 
 
 
 
 
900
 
901
+ | Contradiction | What the plan/release implied | What the data showed |
902
+ |---|---|---|
903
+ | per-patch `metadata.json` | a per-patch label source exists | **no `metadata.json` in any of the 28,000 patch dirs**; labels live only in the parquet (`training/data/bigearthnet_labels.py`) |
904
+ | S1/S2 co-located | one patch dir holds both modalities | **separate trees with disjoint names** β€” the S1 and S2 name sets intersect in **0 of 28,000**; the parquet's `s1_name` is the only join key (`training/fusion/reben_adapter.py`) |
905
+ | uniform band resolution | bands share one pixel size | **mixed native resolutions** β€” 120Γ—120 (10 m), 60Γ—60 (20 m), 20Γ—20 (60 m) inside the *same* patch; resampling onto the 10 m grid is well defined because all bands share the same geographic bounds |
906
+ | a tile-level split is geographical | "one tile does not straddle train/test" | **FALSE** β€” 52 of 54 tiles straddle; the official unit is the ground block (`training/data/bigearthnet.py`) |
907
 
908
+ Each contradiction is handled by an **additive** module rather than by editing the existing one:
909
+ `training/fusion/reben_adapter.py` "is ADDITIVE. It does not modify `pair_patches`, `discover_patches` or
910
+ the extraction core." This is why the frozen config hash did not move when the v2 corpus was integrated.
911
+
912
+ ---
913
+
914
+ ## 7. Data hygiene and leakage controls
915
+
916
+ This section covers the rules that apply to **every** corpus above, and the code that enforces them.
917
+
918
+ ### 7.1 Splits are by scene (or group), never by example
919
+
920
+ The classic remote-sensing leakage bug is slicing a large scene into tiles and then splitting the
921
+ **tiles** randomly. Adjacent tiles are near-duplicates, so a random tile split leaks the test set into
922
+ training. Every split in this project is at the **scene** (or **group**) level.
923
+
924
+ | Corpus | Split key | Definition |
925
+ |---|---|---|
926
+ | LEVIR-CD | scene | the LEVIR scene name inside `<split>_<scene>_<tile>.png`; 445 / 64 / 128 scenes |
927
+ | CDVQA | scene (`file_name`) | the CDVQA scene; Train/Val/Test disjoint, Test ∩ Test2 = 968 (allowed pair) |
928
+ | VRSBench | image | `training/grounding/dataset.py` splits by image |
929
+ | BigEarthNet (fusion) | **block** | `ben_<tile>:<block ordinal>` β€” 4-connected components of the (row, col) grid, per (tile, official split) |
930
+ | BigEarthNet (VLM) | block | `ben_<tile>:<k>` |
931
+ | Router | **group** | template family / hard-negative family (`router/dataset.py::split_by_group`) |
932
+
933
+ ### 7.2 The leakage module β€” five rules, enforced in code
934
+
935
+ `evaluation/leakage.py` opens with the rules and states that they are "non-negotiable and are enforced in
936
+ code, not by convention":
937
+
938
+ 1. **Split by SCENE, never by sample/tile.**
939
+ 2. **No scene may appear in more than one split.**
940
+ 3. **Duplicate content (same sha256) is rejected.**
941
+ 4. **Public test sets are immutable and off-limits to training code.**
942
+ 5. **Hidden data must never influence thresholds, prompts, or routing.**
943
+
944
+ And the module's own operating principle, verbatim:
945
+
946
+ > *"Every function here either returns a clean result or raises. None of them 'warn and continue' β€” a
947
+ > leakage warning that scrolls past is indistinguishable from no check."*
948
+
949
+ The enforcement surface:
950
+
951
+ | Function | What it does |
952
  |---|---|
953
+ | `assign_splits_by_scene(records, *, train_ratio, val_ratio, seed)` | assigns splits at the **scene** level, deterministically. Scene keys are **sorted first**, then shuffled with a seeded RNG, so the assignment is reproducible and independent of input order. **A scene's records all receive the same split β€” enforced, not hoped for.** Emits in sorted scene order (the seed controls *which* scene goes to which split, never the output order). |
954
+ | `audit_manifest(manifest, *, require_all_splits)` | full audit; **raises `LeakageError`** on a scene in >1 split or a duplicate content hash. Adds advisory warnings: not all splits populated; and `scene_key` falling back to `sample_id` (which makes scene-level isolation **vacuous** for those samples). |
955
+ | `assert_no_scene_overlap(train, test)` | hard assertion that two collections share no scene; raises with the overlap count and up to five example scene keys. |
956
+ | `PublicTestFirewall(root)` | two-layer defence (Β§7.4). |
957
+ | `assert_no_hidden_access(config)` | guards the hidden-evaluation contract (Β§7.5). |
958
+ | `deduplicate(records)` | drops duplicate-content records, returning `(kept, dropped_sample_ids)`. |
959
+ | `content_hash_file(path)` | streamed SHA-256, delegating to `preprocessing/raster.py::file_sha256`. |
960
+
961
+ ### 7.3 `leakage_split_key: scene_id`
962
+
963
+ Declared in `configs/base.yaml` Β§`evaluation`. The `scene_key` property resolves, in order, an explicit
964
+ `scene_key`, then `scene_id`, then a geographic hash, then the source scene, then falls back to
965
+ `sample_id`. The `audit_manifest` advisory explicitly flags the fallback case, because a sample whose
966
+ `scene_key` has fallen all the way back to its `sample_id` has **no** scene-level isolation at all.
967
+
968
+ ### 7.4 The public-test firewall
969
+
970
+ `evaluation/leakage.py::PublicTestFirewall` (`PUBLIC_TEST_DIRNAME = "public_test"`) blocks training-time
971
+ reads of the public test tree with **two layers**:
972
+
973
+ | Layer | Method | Behaviour |
974
+ |---|---|---|
975
+ | **path inspection** | `check_read(path)` | any read whose **resolved** path is under the public test root is refused while the firewall is armed |
976
+ | **manifest inspection** | `check_manifest(manifest)` | a manifest containing test-split records may not be handed to a training loop |
977
+
978
+ The firewall is **armed by default in training scripts** and disarmed only by the evaluation runner,
979
+ which is a separate entry point. `arm()` / `disarm()` / `.armed` are exposed so the state is inspectable.
980
+ Both checks raise `LeakageError` with the offending path or record count in the context.
981
+
982
+ ### 7.5 Hidden-set discipline
983
+
984
+ `assert_no_hidden_access(config)` enforces the hidden-evaluation contract at runtime:
985
+
986
+ | Condition | Behaviour |
987
+ |---|---|
988
+ | `evaluation.hidden_data_access is not False` | **raises** `LeakageError` |
989
+ | `evaluation.official_aggregate_weights is not None` | **raises** `LeakageError` |
990
+
991
+ The second is why `configs/base.yaml` records `official_aggregate_weights: null` β€” inventing an aggregate
992
+ formula is prohibited ([`BENCHMARKS.md`](BENCHMARKS.md) Β§10.7 and
993
+ [`EVALUATION.md`](EVALUATION.md) rule 8). `immutable_public_test: true` and `hidden_data_access: false`
994
+ are the two config values that make the firewall and the hidden guard meaningful.
995
+
996
+ ### 7.6 Deduplication
997
+
998
+ `deduplicate()` removes records whose content sha256 has already been seen, keeping the first
999
+ occurrence and returning the dropped sample ids. Duplicates are treated as both a leakage vector **and**
1000
+ a silent train/test contamination path, so they are removed, not tolerated. The dedup key is the
1001
+ **content** hash (streamed SHA-256), not the file name β€” two differently-named copies of the same pixels
1002
+ are the same record.
1003
+
1004
+ ### 7.7 The GeoTIFF contract
1005
+
1006
+ The raster input contract is `preprocessing/raster.py`, and it implements the frozen validation chain
1007
+ from `docs/ARCHITECTURE_FREEZE.md` Β§2.6:
1008
+
1009
+ ```
1010
+ file -> dimensions -> bands -> dtype -> CRS -> transform -> bounds -> nodata
1011
+ -> modality -> temporal metadata
1012
+ ```
1013
+
1014
+ Design rules, verbatim from the module docstring:
1015
+
1016
+ > *"Never raise a bare exception. Every failure is a typed SatQueryError. Never silently drop geospatial
1017
+ > metadata. If the source had a CRS, the returned AssetMetadata says so. A missing CRS degrades to
1018
+ > non-geospatial mode; it does not abort."*
1019
+
1020
+ `inspect_raster(path, *, max_pixels, explicit_modality, compute_hash, sensor)` **describes a raster
1021
+ without loading its pixel data** and returns `AssetMetadata` with full geospatial detail preserved. The
1022
+ recorded fields include `crs`, `transform` (the first 6 affine coefficients), `bounds`, `width`,
1023
+ `height`, `band_count`, `dtype`, `nodata`, `resolution`, `has_crs`, `is_georeferenced`, `driver`,
1024
+ `is_tiled`.
1025
+
1026
+ | Guard | Behaviour |
1027
+ |---|---|
1028
+ | file missing / not a regular file | `RasterReadError` |
1029
+ | rasterio unavailable | `RasterReadError` |
1030
+ | degenerate dimensions (`<= 0`) | `RasterReadError` |
1031
+ | `max_pixels` exceeded | `OversizedImageError` (**recoverable** β€” the caller may downscale) |
1032
+ | `band_count <= 0` | `UnsupportedBandsError` |
1033
+ | unreadable raster | `RasterReadError` |
1034
+
1035
+ **Modality inference** is a heuristic and is labelled as one: `_OPTICAL_BAND_COUNTS = {3,4,8,11,12,13}`
1036
+ and `_SAR_BAND_COUNTS = {1,2}`, with an explicit label honoured first. The docstring is explicit that
1037
+ these are "heuristics, not ground truth β€” the sensor adapter is authoritative when a sensor descriptor
1038
+ is supplied".
1039
+
1040
+ **Georeferencing is preserved through preprocessing, never silently stripped.** `read_bands` returns
1041
+ `(bands, H, W)` plus a profile that retains `crs`/`transform`/`bounds`/`nodata`; `write_raster` writes
1042
+ using that profile so evidence artifacts (change maps, masks) stay georeferenced. `preprocessing/
1043
+ imagery.py::load_image_array` converts a raster to a displayable uint8 `(H, W, 3)` array with a
1044
+ **deterministic** 2/98 percentile stretch (over finite values only, so a nodata sentinel does not crush
1045
+ the dynamic range) β€” and it **does not resample, crop, or reproject**, because the grounding specialist
1046
+ converts normalised boxes to pixel coordinates using the **original** raster's dimensions, and a silent
1047
+ resize here would put every box in the wrong place.
1048
+
1049
+ ### 7.8 CRS comparison and pair compatibility
1050
+
1051
+ "Can these two rasters be compared at all?" is answerable **without touching pixel arithmetic** β€”
1052
+ `geospatial/crs.py` is kept separate from `geospatial/transform.py` for exactly that reason.
1053
+
1054
+ `compare_crs(left, right)` returns a `CRSCompatibility`:
1055
+
1056
+ | Case | `compatible` | `identical` | reason |
1057
+ |---|---|---|---|
1058
+ | identical CRS | true | true | "identical CRS" |
1059
+ | different but same kind (both projected or both geographic) | true | false | "reprojection required (A -> B)" |
1060
+ | mixed projected/geographic | true | false | "mixed projected/geographic CRS …; reprojection required and should be verified" |
1061
+ | **either side missing a CRS** | **false** | false | "one or both rasters lack a CRS; spatial comparison is unsafe" |
1062
+
1063
+ A missing CRS on either side is **not** compatible for spatial comparison β€” but it is **not fatal for
1064
+ non-spatial tasks** (a VQA question about a single image does not need a CRS). `is_metric(crs)` returns
1065
+ `True` only when the CRS is projected and its axis unit is metres, so `pixel_area_m2()` returns `None`
1066
+ for a geographic CRS rather than guessing.
1067
+
1068
+ **Temporal metadata and pair compatibility.** The `AssetMetadata` carries the geospatial detail, and the
1069
+ pair-compatibility question ("are these two rasters a usable change pair?") is answered by the change
1070
+ specialist's preconditions rather than by a single function: the pair must share a scene, and `T1 != T2`
1071
+ (the two acquisitions must differ). The LEVIR verification measures this directly β€” the check "T1 and T2
1072
+ are not identical" passed with 0 identical pairs in a 600-tile sample
1073
+ (`artifacts/change/levir_real_data_verification.json`). For CDVQA the temporal question is the harder
1074
+ one and is handled by the evidence engine in Β§4.8, because the pair is identified by a **shared
1075
+ basename** in the SECOND layout, not by embedded temporal metadata.
1076
+
1077
+ ### 7.9 The pixel ↔ geo conversion is first-class and unit-tested
1078
+
1079
+ `geospatial/transform.py` makes the `coordinate_system` field real. Every spatial result the system
1080
+ produces passes through here at least once, so this is the single place a normalised 0–1 box, a pixel
1081
+ box, and a geographic box are converted between. The non-negotiable rules, verbatim:
1082
+
1083
+ > *"CRS and affine transform are preserved, never silently stripped. Conversions are explicit about
1084
+ > their source and target frames. A conversion that cannot be performed raises, rather than returning a
1085
+ > plausible lie."*
1086
+
1087
+ | Function | Contract |
1088
+ |---|---|
1089
+ | `normalized_to_pixel(box, width, height)` | values are **not clipped** β€” a box slightly outside the frame is preserved so the caller can decide whether that is a bug or a legitimate edge case |
1090
+ | `pixel_to_normalized(window, width, height)` | inverse; raises on non-positive dimensions |
1091
+ | `pixel_to_geo(window, transform)` | uses the affine; the geographic `miny` comes from the **bottom** row, because the y axis usually points down in pixel space |
1092
+ | `geo_to_pixel(bounds, transform)` | inverts the affine; raises `CoordinateError` if the transform is not invertible |
1093
+ | `to_normalized` / `to_pixel` / `to_geo` | schema-aware `Box` conversions; each requires enough context for the source frame and **raises** rather than guessing |
1094
+ | `intersect` / `iou` | axis-aligned intersection and IoU, used by grounding NMS and change-region overlap |
1095
+ | `benchmark_boxes_to_normalized` / `normalized_boxes_to_benchmark` | the explicit 0–100 ↔ 0–1 conversion (Β§5.2) |
1096
+ | `affine_from_metadata(geo)` | reconstructs an `Affine` from a `GeoMetadata.transform` list of length 6 |
1097
+
1098
+ `PixelWindow.__post_init__` raises `CoordinateError` on an **inverted** window (`col_max < col_min` or
1099
+ `row_max < row_min`), so a swapped-corner box cannot propagate.
1100
+
1101
+ ### 7.10 The deterministic input-quality gate
1102
+
1103
+ `preprocessing/quality.py` exists because of finding **F5-5** (`docs/PHASE5_VLM_CONTRACT.md`): a loaded
1104
+ SmolVLM-500M-Instruct, given 512Γ—512 **uniform random noise** and a prompt that explicitly says "answer
1105
+ only from what is visible" and "if the image does not contain enough information, say so plainly",
1106
+ produced:
1107
+
1108
+ > *"A black and white photograph of a man and a woman, who appear to be in a room, with a table in front
1109
+ > of them."*
1110
+
1111
+ That is a fluent, specific, entirely fabricated scene description. The prompt cannot prevent it β€” a
1112
+ 500M-parameter VLM will describe *something* for any input, and asking it to self-assess reliably is
1113
+ asking it to do the thing it just failed at.
1114
+
1115
+ So the gate is **deterministic and upstream of the model**. The discriminating signal is **lag-1 spatial
1116
+ autocorrelation**, not variance:
1117
+
1118
+ | Input | Autocorrelation |
1119
+ |---|---|
1120
+ | real remote-sensing imagery | 0.6 – 0.99 |
1121
+ | uniform random noise | ~ 0.00 |
1122
+ | a constant (blank) image | undefined; variance ~ 0 |
1123
+
1124
+ Variance alone cannot separate these: a flat desert scene and a flat "all-zero" tile both have
1125
+ near-zero variance, but the desert has strong neighbour correlation and the zero tile does not. The gate
1126
+ classifies an array as `STRUCTURED`, `FLAT`, `NOISE`, `TOO_SMALL` or `INVALID_VALUES`;
1127
+ `BLOCKING_VERDICTS = {NOISE, INVALID_VALUES}` must block a VLM call. The thresholds and their measured
1128
+ margins:
1129
+
1130
+ | Threshold | Value | Measured margin |
1131
+ |---|---|---|
1132
+ | `MIN_AUTOCORRELATION` | 0.10 | structured 0.952 vs noise βˆ’0.008 β€” a **0.96 margin** |
1133
+ | `FLAT_STD_EPSILON` | 1e-6 | a constant array β†’ `FLAT` |
1134
+ | `NOISE_ENTROPY_BITS` | 7.8 | structured ramp+texture 7.581 vs noise 7.988 β€” a **0.22-bit gap**, thin by design |
1135
+
1136
+ The module is explicit that **autocorrelation carries the gate** and entropy is a redundant second
1137
+ signal: *"It is kept because a future change to either metric should not silently disable the gate, but
1138
+ do not rely on it alone."* `assess_image_quality` has **no tolerance** for non-finite values β€” a fixed
1139
+ fraction would be size-dependent, so one NaN in a 64Γ—64 array (0.99976) would pass while one NaN in a
1140
+ 10Γ—10 array (0.99) would fail; the same defect must not be tolerated or rejected depending on image
1141
+ dimensions. Known limitation, recorded deliberately: a float GeoTIFF whose nodata sentinel is NaN is
1142
+ refused, because the alternative is computing correlation over NaN.
1143
+
1144
+ **Nothing here is learned, sampled, or probabilistic. Same input, same verdict.**
1145
+
1146
+ ---
1147
+
1148
+ ## 8. What is NOT available β€” exhaustive
1149
+
1150
+ | Corpus / resource | State |
1151
+ |---|---|
1152
+ | **BigEarthNet S1 + S2 full corpus (549,488 rows)** | **NOT DOWNLOADED** β€” only the 28,000-patch selection is materialised locally |
1153
+ | **BigEarthNet multi-label (reBEN) results** | **NOT PRODUCED** β€” the local subset is single-label; metrics are not comparable to published numbers (Β§6.4) |
1154
+ | **BigEarthNet-S2 full single-label corpus** | **NOT DOWNLOADED** β€” the VLM run matched 24,732 of 28,000 requested (Β§6.6) |
1155
+ | **Cross-dataset generalisation sets** | **not used** |
1156
+ | **Any private / hidden evaluation data** | **not accessed** (`hidden_data_access: false`) |
1157
+ | **The public test split, for training** | **blocked by the firewall** (Β§7.4) |
1158
+ | **CDVQA imagery from the CDVQA repo** | **does not exist** β€” the repo publishes annotations only; imagery comes from SECOND (Β§4.1) |
1159
+ | **CDVQA label-map semantics** | **partially decoded** β€” the palette is fixed and the order is established (Β§4.8), but "semantics **not decoded**" is the state recorded for `label1/`/`label2/` at acquisition (`docs/PHASE10_CDVQA_DATA_STATUS.md` Β§1) |
1160
+ | **VRSBench caption / VQA benchmark scoring** | **NOT RUN** β€” the loader verifies the schema; captioning metrics are governed by R-16, which is `OPEN` |
1161
+ | **VRSBench `obj_corner` polygons as training targets** | **not used** β€” the GT token string is the authority; `obj_corner` is metadata |
1162
+ | **A `tiles` figure for the VLM BigEarthNet-S2 subset** | `UNKNOWN β€” not established from the available evidence` |
1163
+ | **The authoritative router parameter count (50,822 vs 51,725)** | `UNKNOWN β€” not established from the available evidence` ([`TRAINING.md`](TRAINING.md) Β§4.1) |
1164
+ | **A licence file for this repository** | **does not exist** β€” `OPEN` |
1165
+ | **Kaggle runtime for the external runs** | **not measured** β€” the guides give measured *local* figures and label the Kaggle figures as estimates |
1166
+
1167
+ ---
1168
+
1169
+ ## 9. What is NOT RUN / OPEN / BLOCKED for this topic
1170
+
1171
+ **NOT RUN**
1172
+
1173
+ - Any **multi-label** BigEarthNet evaluation (the local subset is single-label).
1174
+ - Any **cross-dataset** generalisation evaluation.
1175
+ - The **VRSBench captioning / VQA** benchmarks (R-16 `OPEN`).
1176
+ - The **change-VQA** evaluation on any split beyond Test and Test2 (no third held-out set exists).
1177
+ - The **VLM adapter on a second instruction family** β€” `instruction_families: ["presence"]` only.
1178
+ - A **full-corpus** BigEarthNet download or extraction.
1179
+
1180
+ **OPEN**
1181
+
1182
+ - The **BigEarthNet tile-count discrepancy** (54 verified vs 98 in `MODELS.md`) β€” reported, not resolved
1183
+ (Β§6.1).
1184
+ - The **BigEarthNet format contradiction** β€” reported, not silently resolved (Β§6.7).
1185
+ - The **CDVQA `im1`/`im2` temporal order** β€” `SUPPORTED β€” NOT PROVEN` (Β§4.8).
1186
+ - The **CDVQA label-map semantics** β€” the palette is fixed and the order is established, but full
1187
+ semantic decoding of the label maps is not claimed.
1188
+ - The **optical-SAR ruling** β€” accuracy 0.931 / macro-F1 0.434161, `is_deciding_statistic: false`
1189
+ (Β§6.5).
1190
+ - The **change-VQA ruling** β€” `metric_ruling: OPEN` on both test sets.
1191
+ - The **change registration gate** β€” 58.7 % false positives on the serving path (Β§3.7).
1192
+ - The **licence** β€” no LICENSE file exists.
1193
+ - **R-03** β€” calibration; nothing is fitted.
1194
+
1195
+ **BLOCKED**
1196
+
1197
+ - Nothing blocks any training or evaluation run. The one recorded external blocker that *was* crossed is
1198
+ the optical-SAR Arm-B feature cache (a ~2.7 h CPU extraction), which was completed
1199
+ ([`TRAINING.md`](TRAINING.md) Β§7.4).
1200
+
1201
+ ---
1202
+
1203
+ ## 10. Where the evidence lives
1204
+
1205
+ | Corpus | Artifact(s) |
1206
+ |---|---|
1207
+ | LEVIR-CD-256 | `artifacts/change/levir_real_data_verification.json`, `artifacts/change/eval_test/eval_result.json`, `artifacts/change/levir_change_v001/{run_record,training_metadata}.json`, `artifacts/change/threshold_sweep_val.json`, `training/change/dataset.py`, `training/change/train.py`, `docs/PHASE9_REAL_DATA_VERIFICATION.md`, `docs/PHASE9_FREEZE.md` |
1208
+ | CDVQA + SECOND | `artifacts/cdvqa/{second_overlap,imagery_verification,temporal_order_evidence_v2}.json`, `docs/PHASE10_CDVQA_DATA_STATUS.md`, `docs/ARCHITECTURE_CHANGE_CDVQA_IMAGE_PAIRS.md`, `docs/PHASE10_ENTRY_GATE.md`, `artifacts/change_vqa/run/run_record.json`, `training/change_vqa/{dataset,vocab}.py`, `training/data/cdvqa.py` |
1209
+ | VRSBench | `training/data/vrsbench.py`, `artifacts/grounding/remoteclip_grounding_v001/{run_record,training_metadata,eval_result_canonical,eval_result_matched6}.json` |
1210
+ | BigEarthNet (fusion) | `artifacts/phase12_selection/feasibility_report.json`, `artifacts/phase12_selection/selection_manifest_seed10.jsonl`, `artifacts/optical_sar/fusion_head_production_v001/{pre_registered_115_metric,production_head_record}.json`, `training/data/bigearthnet.py`, `training/data/bigearthnet_labels.py`, `training/data/bigearthnet_blocks.py`, `training/fusion/reben_adapter.py`, `docs/PHASE12_LABEL_POLICY_DECISION.md`, `docs/PHASE12_115_METRIC_COMPUTED.md`, `docs/PHASE12_ENTRY_GATE.md` |
1211
+ | BigEarthNet (VLM) | `.scratch/phase6_real_adapter/phase6_adapter/run_manifest.json`, `scripts/prepare_bigearthnet.py` |
1212
+ | Leakage / firewall | `evaluation/leakage.py`, `evaluation/manifest_freeze.json`, `evaluation/public_test/`, `tests/leakage/` |
1213
+ | GeoTIFF contract / pair compatibility | `preprocessing/raster.py`, `preprocessing/imagery.py`, `preprocessing/quality.py`, `geospatial/crs.py`, `geospatial/transform.py`, `docs/ARCHITECTURE_FREEZE.md` Β§2.6 |
1214
+ | Quality gate | `preprocessing/quality.py`, `docs/PHASE5_VLM_CONTRACT.md` (finding F5-5) |
1215
+
1216
+ **Sibling documents:** [`TRAINING.md`](TRAINING.md) (how each corpus is consumed),
1217
+ [`EVALUATION.md`](EVALUATION.md) (protocols), [`BENCHMARKS.md`](BENCHMARKS.md) (the numbers),
1218
+ [`REPRODUCIBILITY.md`](REPRODUCIBILITY.md) (reproduction),
1219
+ [`RESEARCH_NOTES.md`](RESEARCH_NOTES.md) (the format contradiction),
1220
+ [`LIMITATIONS.md`](LIMITATIONS.md) (the honest counterweight).