File size: 92,482 Bytes
0bace1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e92a5b5
 
 
 
 
 
 
 
0bace1a
 
e92a5b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0bace1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e92a5b5
 
0bace1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e92a5b5
0bace1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
# SatQuery AI β€” Satellite Imagery Question Answering

**Satellite imagery question answering, remote-sensing change detection, visual grounding and
optical-SAR fusion in one open, CPU-runnable system.**

Ask a natural-language question about a satellite or aerial image β€” or a pair of images β€” and SatQuery
AI routes it to the right specialist model, collects evidence, and returns a single confidence-scored
result envelope. It is a **remote-sensing vision-language system** built as a *router plus specialists*
pipeline: satellite image captioning, visual question answering, **text-guided visual grounding**,
**bi-temporal satellite image change detection**, change-VQA, and **Sentinel-1 / Sentinel-2 optical-SAR
fusion**. It runs on **CPU**, is served from a static frontend, and is live at
**https://satquery.pages.dev**.

[![Release](https://img.shields.io/badge/release-1.0.0-blue)](RELEASE_MANIFEST.md)
[![Live demo](https://img.shields.io/badge/demo-satquery.pages.dev-success)](https://satquery.pages.dev)
[![Hugging Face](https://img.shields.io/badge/%F0%9F%A4%97-models-yellow)](https://huggingface.co/thundercode/SatQuery)
[![Docs](https://img.shields.io/badge/docs-37%20documents-informational)](docs/architecture/README.md)
[![License](https://img.shields.io/badge/license-not%20selected-red)](#license)

<p align="center">
  <img src="screenshots/analyze-grounding.png" alt="The SatQuery AI Analyze console answering a text-guided visual grounding question about satellite imagery against live inference" width="820">
</p>

> **Status: research prototype, pre-1.0.** The architecture is frozen. This repository is the
> documented public release of the system, its trained artifacts, and its measured results β€”
> **including the negative ones.**

This README is the front door to a long-form documentation set. It is written to the same standard as
the rest of the release: **every number, path, run identifier and status is taken from a file that was
actually read**, and where something was never run, that is stated rather than implied.

---

## Topics

This system spans several searchable areas. If you are looking for a **remote-sensing
vision-language model**, a **satellite image change-detection** baseline, a **text-guided visual
grounding** implementation for Earth observation, **Sentinel-1/Sentinel-2 optical-SAR fusion**, or a
**router-and-specialists** design that runs on CPU, this repository covers that ground.

| Area | What is here |
|---|---|
| **Satellite imagery question answering** | six task specialists behind one typed `ResultEnvelope` |
| **Remote-sensing VQA & captioning** | SmolVLM-500M over frozen backbones, with a trained LoRA adapter |
| **Visual grounding (text β†’ box)** | RemoteCLIP ViT-B/32 + a trained grounding head; VRSBench protocol |
| **Change detection (bi-temporal)** | STANet-style Siamese detector on LEVIR-CD-256 |
| **Change-VQA** | natural-language questions over a temporal pair |
| **Optical-SAR fusion** | CROMA-base, 19-class BigEarthNet CLC head |
| **Multimodal / vision-language** | evidence aggregation, calibrated confidence, execution traces |
| **Deployment** | static frontend + gateway + outbound-tunnel inference on CPU |

Search terms this project is described by: `satellite imagery question answering` Β·
`remote sensing vision language model` Β· `satellite image change detection` Β· `visual grounding remote
sensing` Β· `optical SAR fusion` Β· `Sentinel-1 Sentinel-2 fusion` Β· `satellite image captioning` Β·
`LEVIR-CD` Β· `VRSBench` Β· `BigEarthNet` Β· `router and specialists architecture` Β· `vision-language model
on CPU`.

---

## Table of contents

- [Topics](#topics)
- [Motivation](#motivation)
- [What the system supports](#what-the-system-supports)
- [Supported inputs](#supported-inputs)
- [Architecture](#architecture)
  - [Repository map](#repository-map)
  - [The four endpoints](#the-four-endpoints)
  - [Documentation map](#documentation-map)
- [Routing and the execution trace](#routing-and-the-execution-trace)
  - [The eight execution events](#the-eight-execution-events)
- [Real inference vs. the preview path](#real-inference-vs-the-preview-path)
- [Models](#models)
- [Measured results](#measured-results)
  - [Grounding: three decode variants, two protocols](#grounding-three-decode-variants-two-protocols)
  - [Fusion: measured but the ruling is open](#fusion-measured-but-the-ruling-is-open)
  - [Change-VQA: two test sets, and they disagree](#change-vqa-two-test-sets-and-they-disagree)
  - [Phase 6 / VLM: deployment success β‰  model acceptance](#phase-6--vlm-deployment-success--model-acceptance)
  - [Calibration: it got worse, and we say so](#calibration-it-got-worse-and-we-say-so)
- [Live validation](#live-validation)
  - [Representative real run IDs](#representative-real-run-ids)
  - [Screenshots](#screenshots)
- [Installation](#installation)
- [Local development](#local-development)
- [Deployment](#deployment)
  - [Deployment caveats](#deployment-caveats)
- [Reproducibility](#reproducibility)
- [Known limitations](#known-limitations)
- [Links](#links)
- [Citation](#citation)
- [License](#license)

---

## Motivation

Remote-sensing analysis is fragmented. Detecting change between two acquisitions, localising an
object, captioning a scene, answering a question about it, and fusing optical with SAR each live in a
different model, a different preprocessing convention, and a different output schema. Assembling them
into one answer means re-solving the same problems β€” tiling, band handling, coordinate systems,
confidence β€” every time.

SatQuery AI explores a single hypothesis: **a small deterministic router plus a shared evidence
contract can make a heterogeneous specialist ensemble behave like one system**, without a large
language model in the control path. The router *understands* the query. A deterministic policy
*decides* which specialists run. The specialists *compute*. The evidence engine *proves* the answer.

Two design rules follow from that, and they are non-negotiable in the codebase:

- **No LLM-generated coordinates. No LLM-generated confidence.** Coordinates come from detection and
  segmentation heads; confidence comes from a calibrated scoring path.
- **Every result carries an observable execution trace** β€” never chain-of-thought.

### Why not one end-to-end model

The design is a *router plus specialists*, not a single model that "does satellite QA". Each layer has
exactly one verb, assigned in the architecture freeze:

> router **understands**; policy engine **decides**; specialists **compute**; VLM **explains**;
> evidence engine **proves**.

That division is not decoration β€” it resolves real ambiguities about *where* a decision belongs. The
`Intent` type makes the first row explicit in code:

```python
class Intent(BaseModel):
    """Output of the learned router. Advisory only β€” the controller decides."""
```

The four constraints that force the modular design:

| Constraint | Consequence |
|---|---|
| The system must run on **CPU** | End-to-end VLM inference at usable quality needs a GPU; small per-task modules do not. |
| Different tasks have **incompatible outputs** | `change` returns a spatial change map; `caption` returns prose; `optical_sar` returns a class distribution. One head cannot emit all three. |
| Tasks have **different data and metrics** | Each specialist is trained and evaluated on its own split with its own protocol. |
| **Truthfulness** | Per-task metrics are auditable. A single end-to-end number would hide which component failed. |

The cost of this design is that there is **no system-level accuracy number** β€” because there is no
single model to measure. That absence is stated rather than papered over; see
[Known limitations](#known-limitations) item 1.

### What the system is deliberately not

| Absent | Why |
|---|---|
| Database, authentication, users, job queue | the gateway is stateless by design; inference is synchronous |
| GPU requirement | device is selected via `SATQUERY_DEVICE`; all placement is `.to(device)`, never `.cuda()` |
| Gradio GUI | the frontend is a separate static tier; `app/space_app.py` serves JSON only |
| Chain-of-thought in traces | traces carry observable facts only β€” states, timings, counts, config hash, model refs |
| Backbone redistribution | backbones are fetched from the Hugging Face Hub, pinned by revision |
| System-level end-to-end benchmark | **NOT RUN β€” none exists** |
| Router test-split evaluation | **NOT RUN** |

## What the system supports

Six specialist tasks. All six are reported `available: true` by the live capability contract
(`GET /api/capabilities`, probed 2026-09-25, `schema_version 1.0`).

| Task | What it answers | Assets | `requires_pair` | `max_assets` |
|---|---|---|---|---|
| `vqa` | A free-form question about a single scene | 1 | false | 1 |
| `caption` | A description of a single scene | 1 | false | 1 |
| `grounding` | *Where* is a described object or region β€” returns boxes | 1 | false | 1 |
| `change` | *What changed* between two co-registered acquisitions β€” returns change regions | 2 | true | 2 |
| `change_vqa` | A yes/no or short question about a detected change | 2 | true | 2 |
| `optical_sar` | Joint scene classification from an optical + SAR pair | 2 | true | 2 |

The live contract also carries per-task notes β€” `vqa`/`caption` fetch SmolVLM weights from the Hub on
first use, `grounding` fetches the RemoteCLIP encoder on first use, and `optical_sar` declares
`modalities: ["optical", "sar"]`.

### The ontology is wider than the capability list

There are **two** related vocabularies, and they are not the same six:

- `core/schemas.py::Task` carries **seven** values: `vqa`, `caption`, `grounding`, `change`,
  `optical_sar`, `change_vqa`, `unsupported`.
- The router's label space (`router/label_space.py::TASK_CLASSES`) is **six** classes:
  `vqa, caption, grounding, change, optical_sar, unsupported`.
- `GET /api/capabilities` lists **six** tasks β€” the same six as the router's *minus* `unsupported`,
  *plus* `change_vqa`.

This asymmetry is intentional. `unsupported` is a **routing outcome** ("this is not a satellite-imagery
question"), not a servable capability. `change_vqa` is reached through the change family rather than
being a separate router class, and it is servable. The three sets are reconciled in one place β€” the
frontend's `ROUTER_TASK_TO_SERVER` map (`frontend/assets/js/mission.js`) β€” because `AnalysisRequest` is
`extra="forbid"` and any string outside the `Task` enum is a 422.

## Supported inputs

Confirmed by the implementation, not assumed:

| Modality | Task(s) | Format |
|---|---|---|
| Optical, single image | `vqa`, `caption`, `grounding` | JPEG, PNG, TIFF |
| Temporal optical pair | `change`, `change_vqa` | Two images of **identical dimensions** |
| Optical + SAR pair | `optical_sar` | GeoTIFF/TIFF preferred |

**Modality is inferred server-side from band count**, not from the file extension: `{1, 2}` bands β‡’
SAR, `{3, 4, 8, 11, 12, 13}` bands β‡’ optical. The browser cannot read band count, so the console warns
when a submitted pair looks like two ordinary photographs rather than an optical/SAR pair.

**Per-file upload limit: 4,194,304 bytes (4 MiB).** Larger files are refused with HTTP 413 β€” imagery
must be downscaled first.

### The size cap is one number shared by two layers

The cap is not a literal in two places; both the gateway and the inference service read
`SATQUERY_MAX_FILE_BYTES`, and the default is `4 * 1024 * 1024` in both. The inference service's
`_asset_max_file_bytes()` (`app/space_app.py`) is deliberately strict about it:

- a **missing** variable β‡’ the 4 MiB default;
- a **non-integer** value β‡’ `ValueError` (not silently defaulted);
- a **non-positive** value β‡’ `ValueError` (a cap of `0` refuses every upload, which is a configuration
  error rather than a limit).

The reason is recorded in the source: a silent default would let a deployment whose operator typed a
malformed cap keep accepting uploads against a limit nobody chose, while the gateway refused to boot
for the *same* value β€” the two layers disagreeing about what "too large" means, which is exactly the
failure the shared variable exists to prevent.

The accepted content types mirror the gateway's allowlist (defence in depth β€” the Space validates
independently rather than trusting the gateway to be its only caller):

```
image/tiff, image/geotiff, image/png, image/jpeg, application/octet-stream
```

### Uploads are handle-based, and the Space owns the bytes

`POST /v1/assets` accepts one file and returns an **opaque ephemeral handle**. The store lives on the
inference host, not the gateway, because the inference host is where `inspect_raster` reads the bytes
and where `cache_max_models: 1` serialises their consumption β€” a gateway-side store would put the bytes
on a different machine from the reader. The upload endpoint is **off by default** and enabled only when
`SATQUERY_ASSET_ENABLED` and `SATQUERY_ASSET_DIR` are both set; otherwise it answers a named
`model_unavailable` envelope explaining the switch. Handle capacity defaults to **32** and the handle
TTL to **900.0 s**, both overridable by environment (and read from the environment rather than
`configs/base.yaml` on purpose β€” adding a key there would move the frozen config hash).

### Input geometry and normalisation

Downstream of the format check, input handling is governed by the frozen registry
(`configs/base.yaml`):

| Key | Value | Meaning |
|---|---|---|
| `image.max_pixels` | 25,000,000 | hard ceiling on decoded pixels |
| `image.tile_size` | 512 | tile edge |
| `image.tile_overlap` | 128 | tile stride overlap |
| `image.max_tiles` | 64 | hard ceiling on tiles *examined* |
| `image.top_k_tiles` | 4 | tiles actually sent through a specialist |
| `optical.normalization` | percentile | 2nd–98th percentile stretch |
| `optical.lower_percentile` / `upper_percentile` | 2 / 98 | stretch bounds |
| `optical.canonical_channels` | 12 | CROMA expects exactly 12 optical channels |
| `sar.representation` | db | SAR is converted to decibels |
| `sar.clip_min_db` / `clip_max_db` | βˆ’30 / 5 | dB clip window |
| `sar.canonical_channels` | 2 | CROMA expects exactly 2 SAR channels (VV, VH) |

The tile policy follows the plan's section 9.1: whole-image thumbnail first, then top-K tiles. `max_tiles`
is the hard ceiling on tiles examined; `top_k_tiles` is how many are actually dispatched β€” and the loader
**rejects** a config where `top_k_tiles > max_tiles`.

## Architecture

This is the **actually deployed** topology. An older direct-client-to-inference design is superseded.

```mermaid
flowchart TD
  B["Browser<br/>(static console)"] -->|HTTPS| CF["Cloudflare Pages<br/>satquery.pages.dev"]
  CF -->|"HTTPS JSON Β· /api/*"| R["Render<br/>satquery-orchestrator"]
  R -->|"outbound long-poll<br/>POST /tunnel/agent"| T{{"outbound tunnel"}}
  T --> C["GitHub Codespace<br/>FastAPI inference Β· CPU Β· :8000"]
  C --> S["Specialists"]
  S --> M["SmolVLM Β· RemoteCLIP Β· STANet-change<br/>CROMA-fusion Β· MiniLM router"]
  M --> E["Evidence engine<br/>+ temperature scaling"]
  E --> RE["ResultEnvelope"]
  RE -->|"tunnel β†’ Render"| B
```

### Why a tunnel

The inference host runs in a GitHub Codespace. The forwarded-port path is not reachable for a private
repo (it returns HTTP 302), so the orchestrator keeps a **long-poll tunnel**: the Codespace dials out
to `POST /tunnel/agent` and holds the connection; Render queues work onto it. `transport_mode` is
`auto`, and the tunnel is the live transport. There is **no** `SATQUERY_UPSTREAM_URL` and **no**
`HF_TOKEN` in the live configuration β€” the transport is the outbound tunnel, not a forwarded port.

The live health payload (`GET /api/health`, probed 2026-09-25) records the tunnel's state directly:

```json
{"status":"ok","service":"satquery-orchestrator",
 "tunnel":{"agent_connected":true,"agent_id":"codespaces-fd1038","pending":0,"completed":97},
 "config":{"codespace_name":"potential-space-trout-r4ppw969w45j2pvvw\n","codespace_port":8000,
           "transport_mode":"auto","tunnel_timeout_s":150.0,"wake_timeout_s":120.0,
           "upstream_timeout_s":90.0,"device":"cpu","has_github_token":true}}
```

Note `codespace_name` still carries a trailing `\n` β€” that is item B-02, cosmetic, and
[still open](#deployment-caveats).

### The nine-state controller

The inference host is a FastAPI service built by `build_space_app()`. Behind the transport sits a
deterministic controller with a **nine-state** finite state machine (`core/schemas.py::ControllerState`,
mirrored in `configs/base.yaml` Β§`agent.states`):

```
RECEIVE β†’ PARSE β†’ VALIDATE β†’ PLAN β†’ PREPROCESS β†’ EXECUTE β†’ AGGREGATE β†’ VERIFY β†’ RESPOND
```

The controller is the **only** component that dispatches. `agent.max_specialists` is 4,
`agent.timeout_seconds` is 120, and `agent.unload_after_workflow` is true β€” the controller unloads
models after a workflow so that `cache_max_models: 1` is honoured rather than thrashing the cache.

### Frozen backbones, trained modules

Four backbones are pinned by `repo_id` + `revision` and fetched from the Hub on first use. Nothing is
fine-tuned end-to-end.

| Role | Repository | Revision | Size / notes |
|---|---|---|---|
| Router encoder | `sentence-transformers/all-MiniLM-L6-v2` | `1110a243fdf4` | 90.9 MB, 22,713,216 params, 384-dim embeddings |
| VLM | `HuggingFaceTB/SmolVLM-500M-Instruct` | `a7da5b986cb5` | ~1015 MB safetensors |
| Grounding | `chendelong/RemoteCLIP` (`RemoteCLIP-ViT-B-32.pt`) | `bf1d8a3ccf2d` | 605.2 MB; width 768, **projected** dim 512 |
| Optical-SAR | `antofuller/CROMA` (`CROMA_base.pt`) | `0dd28e3d633b` | 777.6 MB; `encoder_dim` 768, `image_resolution` 120 |

Two consequences follow: the system is small (the six trained artifacts total ~125 MiB; everything else
is public weights), and **no backbone weights are redistributed** by this release.

### The evidence and confidence stages

Specialists each emit `Evidence` for what *they* computed. The `EvidenceEngine`
(`evidence/engine.py`) does not re-derive any of it; it aggregates:

```
collect across specialists β†’ deduplicate β†’ order β†’ renumber β†’ cap
```

The pipeline is **pure and deterministic** β€” no clock, no RNG, no I/O β€” and its purity is a test
assertion, not a hope. Three details matter:

- **Stable identity.** `Evidence.evidence_id` defaults to a random uuid, which is useless across runs.
  The engine renumbers to `evidence_001`, `evidence_002`, … (zero-padded to three digits, well past the
  `evidence.max_items` bound of 32) so a downstream artefact can cite one item deterministically.
- **Defined order.** Items are sorted by `(type, source_specialist, score DESC, coordinates)`. The
  coordinate tie-breaker is what makes the order *total*; without it, two items sharing type,
  specialist and score would fall back to Python's stable-sort insertion order, reintroducing
  input-order dependence.
- **Content identity, not container identity.** Dedup keys on
  `(type, source_specialist, coordinate_system, rounded coordinates, rounded score)` β€” `payload`,
  `artifact_ref` and `evidence_id` are deliberately excluded. Two items that agree on the same
  geolocation, one carrying a `crs` and one not, have made the same claim about the world; the surviving
  item's payload is merged with the discarded one's so the `crs` is not lost. Two items that share a type
  and score but **disagree** on coordinates are two different claims and are both kept β€” suppressing a
  spatial disagreement would be the silent contradiction the freeze forbids.

The engine records `dropped_duplicates` (non-zero is normal and healthy β€” it means two specialists
agreed), `dropped_over_limit` (non-zero is a warning β€” a specialist's evidence did not survive), and
`truncated`. `evidence_digest()` exists so that reproducibility is an assertion:

```
aggregate(inputs_a) is reproducible iff digest(a) == digest(b)
```

The confidence stage is `evidence/confidence.py`, and it exists to enforce one rule: **a calibration that
claims to be fitted when it is not is a false claim of reliability.** So when no fitted artifact is
available it passes the raw score through unchanged, sets `method="uncalibrated"`, and leaves
`calibrated=None` β€” which is what makes it honest, because `ConfidenceBreakdown.value` then returns
`raw`, and any consumer can distinguish "we calibrated this" from "we did not". The temperature scaling
itself is `calibrated = sigmoid(logit(z) / T)`, with two stated failure modes handled explicitly: a
fitted `T` of exactly `1.0` is the identity map and is reported as uncalibrated rather than silently
pretending to have done something, and `z = 0.0` (whose log-odds diverge) is clamped at the boundary so
a hard zero cannot become a NaN.

### Repository map

> **Scope of this repository.** It contains the **documentation and the source code only**. It
> deliberately does **not** contain: datasets, the `artifacts/` tree (trained weights, checkpoints,
> evaluation outputs), Jupyter notebooks, the static frontend, logs, or any credential. The six
> trained artifacts are published on the [Hugging Face Hub](https://huggingface.co/thundercode/SatQuery);
> the frozen backbones are fetched from the Hub at run time. Some documents cite the source
> repository's internal phase reports by filename as provenance β€” those files are not part of this
> release.

| Path | Contents |
|---|---|
| `app/` | FastAPI inference service and its composition root (`build_space_app()`, `serving.py`) |
| `core/` | The typed contracts, config registry + loader, nine-state controller, planner, registry, errors |
| `router/` | The MiniLM intent router: encoder, five-head adapter, classifier, lexical fallback, label space, dataset, training |
| `specialists/` | One module per specialist: `vqa/`, `grounding/`, `change/`, `optical_sar/`, plus `base.py` |
| `evidence/` | The evidence engine (aggregation) and the confidence/calibration module |
| `preprocessing/`, `geospatial/` | Raster handling, tiling, normalisation, sensor adapters |
| `gateway/` | The orchestrator / gateway (`/api/*`, CORS, wake flow, error translation) |
| `deploy/` | Deployment entrypoints: Codespace `serve.py` + `tunnel_agent.py`, Render blueprint |
| `training/`, `evaluation/` | Training entry points and evaluation harnesses |
| `scripts/` | Repository scripts (data prep, calibration fitting, packaging) |
| `tests/` | The test suite |
| `configs/base.yaml` | The frozen configuration registry β€” the single source of truth for every tunable |
| `docs/` | Architecture, models, benchmarks, datasets, training, evaluation, deployment, security, testing, operations, performance, glossary, limitations |
| `models/` | The **generated** artifact manifest and checksums (identities, not the weights) |
| `tools/` | Verification tooling (metric verification, manifest generation, archive tools) |
| `screenshots/` | Real live-run captures referenced by the documentation |

Not in this repository: `artifacts/`, `data/`, `notebooks/`, `frontend/`, `logs/`, `reports/`.

The component inventory in full (every path is the authoritative location):

| Layer | Module | Responsibility |
|---|---|---|
| **Contracts** | `core/schemas.py` | the binding typed contract: `Task`, `Intent`, `Evidence`, `SpecialistResult`, `ResultEnvelope`, `ExecutionTrace`, … |
| **Config** | `core/config.py` | load, deep-merge, validate, hash the registry; `get_config()` singleton |
| **Errors** | `core/errors.py` | the error taxonomy (`ConfigError`, `ModelLoadError`, `RoutingError`, `WorkflowPlanError`, …) |
| **Planning** | `core/planner.py` | turn an `Intent` into a concrete, ordered `ExecutionPlan` |
| **Registry** | `core/registry.py` | specialist registration / lookup |
| **Controller** | `core/controller.py` | the nine-state FSM; the only thing that dispatches |
| **Router** | `router/encoder.py` | frozen MiniLM embedding, cached |
| | `router/adapter.py` | the five-head `IntentAdapter` (the only trainable router part) |
| | `router/classifier.py` | learned classification + confidence gate + fallback selection |
| | `router/fallback.py` | deterministic lexical fallback (`lexical_route`) |
| | `router/label_space.py` | the ontology, single source of truth |
| | `router/dataset.py`, `router/train.py` | dataset generation and training |
| **Specialists** | `specialists/base.py` | the specialist interface |
| | `specialists/vqa/{model,inference,prompts}.py` | SmolVLM VQA |
| | `specialists/grounding/{remoteclip,head,inference,specialist}.py` | RemoteCLIP + head |
| | `specialists/change/{stanet,specialist,postprocess,vqa_specialist}.py` | change detection + change-VQA |
| | `specialists/optical_sar/{croma,fusion_head,inference,specialist,sensor_adapter,radiometry,prompts}.py` | CROMA fusion |
| **Evidence** | `evidence/engine.py` | aggregation: dedup β†’ sort β†’ renumber β†’ cap |
| | `evidence/confidence.py` | temperature scaling, honest pass-through |
| **Inference app** | `app/space_app.py` | `build_space_app()`; the four-endpoint JSON contract |
| | `app/serving.py` | the composition root (`build_serving_controller()`) |
| | `app/deployment.py` | capability / health payload builders |
| **Gateway** | `gateway/` | the Render orchestrator, asset store, policy/error translation |
| **Frontend** | `frontend/` | the static site |

### The four endpoints

The inference service serves exactly **four** JSON endpoints (`app/space_app.py`). No Gradio GUI exists
in code; the file serves JSON only, by design, so it cannot compete with the static frontend's contract.

| Endpoint | Method | Purpose |
|---|---|---|
| `/v1/health` | GET | liveness + capability states; **loads no model** |
| `/v1/capabilities` | GET | the six servable tasks with `requires_pair` / `max_assets` |
| `/v1/assets` | POST | accept one uploaded file, return an opaque ephemeral handle |
| `/v1/analyze` | POST | run one analysis; returns a `ResultEnvelope` |

The gateway in front of it exposes the same functionality under `/api/*` and holds the request-side
security boundary. Two details are worth recording because they cost real debugging time:

- **Framework-raised errors carry the same envelope.** An unmatched route (404) and a method mismatch
  (405) are wrapped by a Starlette exception handler so they answer with the contract's error shape
  (`routing_error`, `recoverable: false`) rather than FastAPI's default `{"detail": …}`. An unhandled
  exception answers with `satquery_error` and a fixed, operator-safe message; the exception's own text is
  logged **server-side only**, so a traceback cannot disclose internal paths to an unauthenticated caller.
- **The upload body is read bounded.** `POST /v1/assets` uses `read_body_bounded(request, cap)` rather
  than `await request.body()`, so the cap is applied *while reading* rather than after the whole body has
  been buffered. The measured defect this fixed: with the cap at 1 MiB, a 64 MiB body produced a peak
  allocation of 128 MiB, tracking body size linearly with no ceiling, and the `413` came only after
  everything had been held.

### Documentation map

The architecture reference is a hub plus ten deep sub-documents. Every one of them is written at
long-form depth, with real signatures, schemas, numbers and file paths.

| # | Document | What it covers |
|---|---|---|
| β€” | [`docs/architecture/README.md`](docs/architecture/README.md) | the architecture hub: thesis, sub-document index, cross-cutting principles, what is deliberately absent |
| 01 | [`docs/architecture/01-system-overview.md`](docs/architecture/01-system-overview.md) | the thesis, the component inventory, the frozen-backbone strategy, what is deliberately absent |
| 02 | [`docs/architecture/02-deployment-topology.md`](docs/architecture/02-deployment-topology.md) | the four tiers, the gateway, the outbound tunnel, wake flow, cold start, `transport_mode` |
| 03 | [`docs/architecture/03-request-lifecycle.md`](docs/architecture/03-request-lifecycle.md) | the nine-state controller, validation rules, modality inference, tiling |
| 04 | [`docs/architecture/04-router.md`](docs/architecture/04-router.md) | frozen MiniLM, the five-head adapter, `interpret()` vs `chooseTask()`, the lexical fallback, the label space |
| 05 | [`docs/architecture/05-specialists.md`](docs/architecture/05-specialists.md) | all six tasks: entry points, preprocessing, postprocessing, outputs |
| 06 | [`docs/architecture/06-evidence-and-confidence.md`](docs/architecture/06-evidence-and-confidence.md) | the evidence schema, the aggregation pipeline, temperature scaling, the eight execution events |
| 07 | [`docs/architecture/07-configuration-freeze.md`](docs/architecture/07-configuration-freeze.md) | the registry, the enforced invariants, the config hash, why it is frozen |
| 08 | [`docs/architecture/08-api-contract.md`](docs/architecture/08-api-contract.md) | the four endpoints, the envelopes, error codes, transport headers |
| 09 | [`docs/architecture/09-frontend.md`](docs/architecture/09-frontend.md) | the static pages, the Analyze console, real-vs-preview, platform traps |
| 10 | [`docs/architecture/10-observability-and-ops.md`](docs/architecture/10-observability-and-ops.md) | health, counters, traces, what is and is not observed |

Companion references, all in this repository:

| Document | Contents |
|---|---|
| [`docs/MODELS.md`](docs/MODELS.md) | the six artifacts in detail, backbone pinning, rejected model decisions |
| [`MODEL_CARD.md`](MODEL_CARD.md) | the Hugging Face model card (intended use, out-of-scope use, measured performance) |
| [`models/manifest.json`](models/manifest.json) | machine-generated byte counts and sha256, one entry per artifact |
| [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md) | the headline metric table and the rules it follows |
| [`docs/EVALUATION.md`](docs/EVALUATION.md) | how each number was produced; evaluation-honesty rules; behavioural validation |
| [`docs/DATASETS.md`](docs/DATASETS.md) | LEVIR-CD-256, VRSBench, BigEarthNet, CDVQA/SECOND β€” measured corpus figures |
| [`docs/TRAINING.md`](docs/TRAINING.md) | per-artifact hyperparameters and where each was trained |
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | live revisions, env vars, deploy mechanics, platform traps |
| [`docs/REPRODUCIBILITY.md`](docs/REPRODUCIBILITY.md) | what a third party can and cannot reproduce |
| [`docs/RESEARCH_NOTES.md`](docs/RESEARCH_NOTES.md) | findings that changed the code; the router defect; negative results |
| [`docs/LIMITATIONS.md`](docs/LIMITATIONS.md) | the honest catalogue of everything not done or done poorly |
| [`docs/CHANGELOG.md`](docs/CHANGELOG.md) | versioned record of what changed and what was verified |

## Routing and the execution trace

Routing is deliberately two-stage, and the split matters:

1. **`interpret()` β€” the reading.** A lexical pass over the query produces a *reading*: task intent,
   modality, temporal requirement, spatial scope, and expected evidence kind. It is
   **asset-count-blind**.
2. **`chooseTask()` β€” the dispatch.** The reading is combined with the number of attached assets to
   decide the task actually dispatched. This is why a reading of `change` with **one** asset dispatches
   `change_vqa` β€” the documented quantifier upgrade.

The server-side router has the same two-part shape in Python: `router/classifier.py::IntentRouter.route()`
produces an `Intent`, and `core/planner.py` turns that `Intent` into an ordered `ExecutionPlan`. The
planner's docstring states the rule it exists to enforce: **the planner is the only component permitted
to choose what runs; its input is a router prediction, its output is a plan, and the router's `Intent` is
an input to a decision, never the decision.** A design where `intent.task` selects a specialist in one
step has collapsed *understand* into *decide*, and three things break: the router can never be overruled,
a query needing two specialists can never get both, and there is no auditable record of the decision.

### The five router heads

The learned router is a small adapter over frozen MiniLM embeddings. `router/label_space.py` is the single
source of truth for its output ontology β€” both the dataset generator and the training script import from
it, so a class added in one place cannot silently desynchronise the other.

| Head | Classes / kind | Loss |
|---|---|---|
| `task` | 6 classes: `vqa, caption, grounding, change, optical_sar, unsupported` | softmax cross-entropy |
| `modality` | 4 classes: `optical, sar, optical_sar, unknown` | softmax cross-entropy |
| `temporal` | binary | single logit + `BCEWithLogitsLoss` |
| `spatial_output` | binary | single logit + `BCEWithLogitsLoss` |
| `language_output` | binary | single logit + `BCEWithLogitsLoss` |

The three binary heads use a single logit rather than a two-way softmax: a 2-way softmax would waste a
parameter and make the loss harder to weight. Because the heads are independent by construction, a
confident task label with an incoherent binary head is possible; the classifier resolves that in favour
of the task label (the controller keys off the task), and records that it did so.

Router configuration (`configs/base.yaml` Β§`router`):

| Key | Value |
|---|---|
| `model` | `sentence-transformers/all-MiniLM-L6-v2` |
| `revision` | `1110a243fdf4` |
| `max_length` | 128 |
| `embedding_dim` | 384 |
| `hidden_dim` | 128 |
| `dropout` | 0.10 |
| `confidence_threshold` | 0.70 |
| `num_tasks` | 6 |
| `training.epochs` / `batch_size` / `learning_rate` | 60 / 64 / 0.001 |
| `training.weight_decay` | 0.01 |
| `training.task_loss_weight` / `modality_loss_weight` / `binary_loss_weight` | 1.0 / 0.3 / 0.5 |
| `training.val_ratio` | 0.15 |
| `training.hard_negatives_to_test` | true |

**Finding F4-1 β€” the tokenizer ceiling.** The MiniLM tokenizer's own ceiling is **256** (verified by
probe). The project truncates to **128** β€” a deliberate truncation *well inside* the ceiling, not the
model limit. Satellite queries are short; halving the sequence halves attention cost for no measurable
accuracy loss. The encoder **asserts** `max_length ≀ 256`, because truncating above the ceiling is a
silent no-op.

**Finding F4-2 β€” the router needs no GPU.** The encoder is frozen, so embeddings are cached and the
50,822-parameter adapter trains on cached vectors. **Measured on CPU: 20 epochs / 4,096 vectors in
0.28 s.**

**Finding F4-3 β€” splits must be by group.** Splits are by **group** (template / hard-negative family),
never by example. Hard-negative families are placed in the **test** split so their accuracy measures
generalisation rather than memorisation; splitting by example would leak template variants across the
boundary.

### The confidence gate and the fallback

`IntentRouter.route()` runs the learned router first, and falls back to the lexical rules only when the
learned router is below the confidence gate (or when the encoder cannot be loaded at all):

```
query
  β†’ encoder.encode          (frozen MiniLM, 384-d)
  β†’ adapter                 (5 heads, softmax / sigmoid)
  β†’ confidence gate         (router.confidence_threshold = 0.70)
  β†’ lexical fallback        (only if below the gate)
  β†’ Intent                  (validated pydantic model)
```

The router **never refuses to answer**; `above_threshold` carries the uncertainty, and the planner β€” not
the router β€” decides what to do about it. The fallback is purely lexical: no model, no embeddings, no
randomness, ordered rules with the highest specificity first, and it **never invents capability** β€” if
nothing matches it returns `unsupported` with low confidence rather than guessing a task. Its precedence
is explicit, and it matters because the phrasings overlap:

```
dual_modality > temporal > spatial > caption > vqa > unsupported
```

Two examples of why precedence is load-bearing, both from the source:

- *"show me where the change happened"* has both a spatial term and a temporal term β†’ `change` +
  `spatial_output=True`.
- *"compare optical and radar to locate built-up areas"* has dual-modality **and** spatial β†’ `optical_sar`
  (spatial does not apply to the joint workflow, whose output is a classification).

The fallback's confidence band (0.72–0.92) **overlaps and can exceed** the trained model's β€” on the spec's
own examples the fallback returns 0.850–0.920 against the trained model's 0.780–1.000. So the planner
applies a **provenance discount** to its own reading of the confidence and never edits
`Intent.confidence` itself: a lexical fallback at 0.9 is not the same evidence as a learned model at 0.9,
and treating them identically would let a matched keyword outrank the model it fell back from.
`IntentRouter.adapter_source` returns `'trained'` or `'lexical_fallback'` so a caller can check this in
one place instead of inferring it from a confidence band β€” because `from_config` defaults `adapter_path`
to `None`, meaning the default router runs the **fallback**, not the trained adapter.

### A router bug worth recording

An earlier revision evaluated the temporal rule before the location rule, so *"Where are the built-up
areas in this image?"* matched `\bbuilt\b` as a *change* marker and `area` inside *"areas"* as a
quantifier. With one asset it collapsed to `vqa` and answered **"River"**. Fixed on 2026-09-25 in
`frontend/assets/js/mission.js`; the fix is covered by regression tests and verified live. The same defect
existed on a second surface (`SQ.policy` in `core.js`) and was fixed the same day.

The fix is four lexical changes, each documented in the source because each was a real failure:

| Change | Why |
|---|---|
| `built` removed from the temporal term set entirely | *"built-up areas"* is land-cover vocabulary, not a change marker. While it sat in the temporal set, the location question *"Where are the built-up areas in this image?"* was read as a change request and answered with the degenerate one-word *"River"*. Measured live, 2026-09-25. |
| `\barea\b` instead of bare `area` | Without the boundary the substring matched inside *"areas"*, so the already-mis-read change question was upgraded **again** to `change_vqa`. The boundary keeps the quantifier reading for a real *"how much area changed"* while refusing the plural land-cover noun. |
| `new` counts as a change marker **only** when the query is not a `where` question | The repo ships `eo/new-airport.jpg`, so *"Where is the new airport?"* is a real question, and `new` is a place descriptor as often as a change marker. |
| the change stem is matched **without** a trailing `\b` | `\bchang\b` cannot match *"changed"*, *"changes"* or *"changing"* β€” there is no word boundary between the stem and its inflection. With the boundary, the page's own default question (*"What changed here?"*) fell through to the `vqa` branch, so the change path was unreachable from the UI that exists to reach it. |

Both defect queries now dispatch to `grounding` and are captured in the screenshot set below.

### The eight execution events

The console renders an execution trace built from **eight events**, emitted by the frontend around
real network calls (`SQ.EVENT_NAMES` in `frontend/assets/js/core.js`):

| # | Event | Emitted when |
|---|---|---|
| 1 | `QUERY_RECEIVED` | The query and assets are accepted |
| 2 | `QUERY_UNDERSTOOD` | `interpret()` has produced the reading |
| 3 | `ROUTE_SELECTED` | `chooseTask()` has selected the dispatched task |
| 4 | `SPECIALIST_STARTED` | The inference request has been issued |
| 5 | `SPECIALIST_COMPLETED` | The specialist has returned |
| 6 | `EVIDENCE_GENERATED` | Evidence items are available |
| 7 | `CONFIDENCE_COMPUTED` | The calibrated confidence is available |
| 8 | `RESULT_ASSEMBLED` | The `ResultEnvelope` is complete |

These are a **frontend** vocabulary driven by observable events β€” not a backend protocol and not a
model's reasoning trace. The run engine is deliberately dumb: it renders whatever events it receives, and
swapping the mock driver for a websocket/SSE feed of the same event names is the entire integration
surface. On live runs the trace bar reaches **94.4444 %** (17/18) and every node is marked live; the
preview path is the only source of mock-marked nodes.

The trace is deliberately *not* chain-of-thought. `ExecutionTrace` (`core/schemas.py`) carries
`run_id`, `schema_version`, `task`, `query`, `inputs`, `modalities`, `intent`, `validation`, `workflow`,
`steps`, `selected_models`, `parameters`, `outputs`, `confidence`, `timings`, `fallbacks`, `errors`,
`contradiction`, `config_hash`, `started_at`, `finished_at` β€” observable facts, with no field for model
reasoning and no LLM-generated confidence. The router's own trace projection is the model of this: it
emits the task, modality, the three booleans, the rounded confidence, the source, `above_threshold`,
`used_fallback` and `fallback_rule` β€” and nothing else.

## Real inference vs. the preview path

- **Real path (production).** With assets attached, the console calls
  `POST /api/infer` on the Render orchestrator. Every run returns a real `run_*` identifier from the
  inference service. **Live validation recorded 0 mock nodes across 24 live runs.**
- **Preview path.** With *no* files selected, the console renders a labelled illustrative preview so
  the interface is explorable without the stack awake. Preview nodes are explicitly marked `is-mock`
  and never appear in a live run.

The distinction is observable, not asserted: a live run shows `live Β· N evidence Β· transport …` and
zero `.trace__node.is-mock` elements.

Two related traps are worth stating because they are the kind of thing a reader will otherwise
misdiagnose:

- **A pair-requiring task with one asset is refused, not silently downgraded server-side.** With one
  asset, `change` answers `invalid_request` (*"change requires exactly 2 assets (T1 and T2); got 1"*) and
  the whole envelope comes back `degraded: true`. Measured live, 2026-09-25. The console's job is to
  avoid asking for a pair-requiring task when only one asset exists; it does so in `chooseTask()`, and it
  **names the substitution** rather than hiding it.
- **The asset set sent is per-task.** `vqa`, `grounding` and `caption` accept a single image, while
  `change`, `change_vqa` and `optical_sar` require a pair. Sending the optional earlier frame to a
  single-image task makes the backend reject the whole request β€” measured live when a pair was uploaded
  and a VQA question asked. The fix isolates the file set so the pair is only ever sent to the tasks that
  declared it.

## Models

Six trained artifacts are released. **Four are task heads and two are adapters** β€” none is a complete
standalone model, and each documents its backbone dependency. Full detail: [`docs/MODELS.md`](docs/MODELS.md),
[`MODEL_CARD.md`](MODEL_CARD.md), and the generated [`models/manifest.json`](models/manifest.json).

| Task | Backbone (pinned) | Custom component | Artifact | Size | Eval data | Metric | Status |
|---|---|---|---|---|---|---|---|
| `change` | STANet-style, ResNet-18 encoder, PAM | change head | `head.pt` | 63,231,009 B | LEVIR-CD-256, test n=2048 | pooled IoU **0.8122** Β· macro IoU **0.8457** Β· pooled F1 **0.8964** | **VERIFIED** |
| `grounding` | `chendelong/RemoteCLIP` ViT-B/32 @ `bf1d8a3ccf2d` (frozen) | trainable head (2048β†’512) | `head.pt` | 12,639,041 B | VRSBench, n=16159 | mean_best_IoU **0.2838** Β· recall@0.5 **0.2198** (canonical) | measured β€” two protocols |
| `optical_sar` | `antofuller/CROMA` base @ `0dd28e3d633b` | fusion head (2318β†’512β†’19) | `head.pt` | 14,427,457 B | BigEarthNet, 19 CLC classes, test n=4000 | accuracy **0.931** Β· macro_F1 **0.434161** | measured β€” **ruling OPEN** |
| `change_vqa` | as `change` | change-VQA head | `head.pt` | 5,822,809 B | test n=39686 | accuracy **0.697626** Β· macro_F1 **0.378373** | measured β€” **ruling OPEN** |
| `router` | `sentence-transformers/all-MiniLM-L6-v2` @ `1110a243fdf4` | intent adapter | `adapter.pt` | 211,961 B | val n=86 | accuracy **0.965116** | **TEST NOT RUN** |
| `vqa` / `caption` | `HuggingFaceTB/SmolVLM-500M-Instruct` @ `a7da5b986cb5` | **LoRA** (r=16, Ξ±=32, dropout 0.05) | `adapter_model.safetensors` | 34,798,048 B | frozen 1000-Q subset | exact_match **0.963** Β· F1 **0.96432** | **ACCEPTANCE-REJECTED** |

Backbones are third-party and pinned by `repo_id` + `revision` in `configs/base.yaml`; they are
fetched from the Hugging Face Hub, not redistributed here.

### The six artifacts, byte-for-byte

`models/manifest.json` is **generated by reading the files** β€” no byte count or hash is typed by hand. Its
schema is `satquery_model_manifest_v1`, generated 2026-09-25T18:15:38+00:00, `artifact_count: 6`, and
every entry carries the frozen `config_hash` `78f1e3700da15aa1`.

| # | `id` | Task | Kind | Local path | HF path | Bytes | sha256 (first 16) |
|---|---|---|---|---|---|---|---|
| 1 | `change_head` | `change` | trained head | `artifacts/change/levir_change_v001/head.pt` | `change/head.pt` | 63,231,009 | `c5ef31277b67aa01` |
| 2 | `change_vqa_head` | `change_vqa` | trained head | `artifacts/change_vqa/run/head.pt` | `change_vqa/head.pt` | 5,822,809 | `cfae5e43b97ca930` |
| 3 | `optical_sar_fusion_head` | `optical_sar` | trained head | `artifacts/optical_sar/fusion_head_production_v001/head.pt` | `optical_sar/head.pt` | 14,427,457 | `785815729a3a39fc` |
| 4 | `grounding_head` | `grounding` | trained head | `artifacts/grounding/remoteclip_grounding_v001/head.pt` | `grounding/head.pt` | 12,639,041 | `93432f7034be91a8` |
| 5 | `router_adapter` | `router` | trained adapter | `artifacts/router/router_adapter_v001/adapter.pt` | `router/adapter.pt` | 211,961 | `8527c3ed28a293e1` |
| 6 | `vlm_lora_adapter` | `vlm` | LoRA adapter | `.scratch/phase6_real_adapter/phase6_adapter/adapter_model.safetensors` | `vlm/adapter_model.safetensors` | 34,798,048 | `07c76a75fa046248` |

Total released weight payload: **131,130,325 bytes (~125 MiB)**. Two of the six hashes are cross-checked
against values recorded **independently** elsewhere in the project β€” `change_vqa_head` against
`artifacts/change_vqa/run/PROMOTION.json`, and `vlm_lora_adapter` against the adapter's own provenance
manifest β€” and both agree. That is an external cross-check, not a self-consistency claim.

The manifest also records each artifact's architecture and the metric artifact it came from:

| `id` | Architecture | Source metric artifact |
|---|---|---|
| `change_head` | STANet-style Siamese change detector (ResNet-18 + PAM) | `artifacts/change/eval_test/eval_result.json` |
| `change_vqa_head` | `change_vqa_head_v1` (1,453,912 parameters) | `artifacts/change_vqa/run/PROMOTION.json` |
| `optical_sar_fusion_head` | CROMA-base fusion head (input 2318 β†’ hidden 512 β†’ 19 classes) | `artifacts/optical_sar/fusion_head_production_v001/pre_registered_115_metric.json` |
| `grounding_head` | RemoteCLIP ViT-B/32 grounding head (feature 2048, hidden 512) | `artifacts/grounding/remoteclip_grounding_v001/eval_result_canonical.json` |
| `router_adapter` | task/modality adapter over frozen MiniLM embeddings (~50,822 params) | `artifacts/router/threshold_sweep_val.json` |
| `vlm_lora_adapter` | PEFT LoRA (r=16, Ξ±=32, dropout 0.05) on text-model projections | `artifacts/vlm/phase6_closure.json` |

Training checkpoints also exist (`checkpoint_last.pt` at 189,291,829 B for change; `checkpoint_last.pt` at
12,640,331 B for grounding; `checkpoint-1500` / `checkpoint-2000` for the LoRA adapter) and are
**not** the released artifacts β€” they are archived as provenance.

### The Hugging Face release

The six artifacts are published at **https://huggingface.co/thundercode/SatQuery** (public,
`private: false`, `gated: false`), HEAD `55681e0cddb91a4a5655da98a49bc025e537b657`, 22 files, last
modified `2026-09-25T18:21:52Z`.

The release was verified by **re-downloading each artifact over direct HTTPS and hashing the bytes
received**, rather than trusting the upload step: 6/6 `MATCH`, 0 failed, and the four support files
(`README.md`, `MODEL_CARD.md`, `models/manifest.json`, `models/checksums.sha256`) confirmed present. The
pre-existing content was a 25-byte stub README (literally `---\nlicense: unknown\n---`) which was
replaced, and the standard HF LFS routing `.gitattributes`, which was left untouched.

> **A verification method that was itself wrong (recorded).** The *first* verification attempt reported
> all six artifacts `DIFFER`, with every remote hash equal to
> `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` β€” the sha256 of **empty content**.
> The cause was not the upload: `hf_hub_download` returned an empty file in this environment, so the
> verifier hashed nothing. It was caught by a second, independent method (a direct `curl` download), which
> produced the correct hash for `router/adapter.pt` and confirmed it to be a real PyTorch zip (`PK\x03\x04`).
> The verifier was then rewritten to use direct HTTPS with proxies disabled. The failed first attempt is
> recorded because a verifier that silently hashes an empty file would have produced a **false failure** β€”
> and, with a different bug, could just as easily have produced a **false pass**.

**No secret was uploaded.** The uploaded set is the model card, the manifest, the checksums, the docs, and
the six weight files; no tokens, keys, environment files or credentials exist in any uploaded file, and
the token used for the upload is not written into any released file.

## Measured results

Every number below traces to an artifact, a test, or a live run. **Nothing here is a system-level
benchmark β€” no such benchmark exists** (see [Known limitations](#known-limitations)).

| Metric | Value | Split / protocol | Source key | Status |
|---|---|---|---|---|
| Change pooled IoU | 0.8122 | LEVIR-CD-256 test, n=2048, thr 0.50 | `metrics.pooled.iou` | **VERIFIED** |
| Change macro IoU | 0.8457 | same | `metrics.macro.miou` | **VERIFIED** |
| Change pooled F1 | 0.8964 | same | `metrics.pooled.f1` | **VERIFIED** |
| Grounding mean_best_IoU (canonical, `head_threshold`) | 0.2838 | VRSBench, n=16159 | `results.head_threshold.mean_best_iou` | measured |
| Grounding recall@0.5 (canonical, `head_threshold`) | 0.2198 | same | `results.head_threshold.recall.0.50` | measured |
| Grounding mean_best_IoU (matched6, `head_threshold`) | 0.2566 | VRSBench, n=16159 | `results.head_threshold.mean_best_iou` | measured |
| Grounding recall@0.5 (matched6, `head_threshold`) | 0.1938 | same | `results.head_threshold.recall.0.50` | measured |
| Grounding `head_argmax` decode (both protocols) | 0.1215 | same | `results.head_argmax.mean_best_iou` | measured β€” **worse** |
| Grounding zero-shot baseline | 0.0972 | same | `results.zero_shot_matched.mean_best_iou` | measured |
| Optical-SAR accuracy | 0.931 | BigEarthNet, held-out test n=4000 | `accuracy` | measured β€” ruling **OPEN** |
| Optical-SAR macro_F1 | 0.434161 | same | `macro_f1` | measured β€” ruling **OPEN** |
| Change-VQA accuracy (**test**) | 0.697626 | test n=39686 | `verification.test_accuracy` | measured β€” ruling **OPEN** |
| Change-VQA macro_F1 (**test**) | 0.378373 | same | `verification.test_macro_f1` | measured β€” ruling **OPEN** |
| Change-VQA accuracy (**test2**) | 0.651469 | second test set | `verification.test2_accuracy` | measured β€” **lower** |
| Change-VQA macro_F1 (**test2**) | 0.372309 | second test set | `verification.test2_macro_f1` | measured β€” **lower** |
| VLM adapter exact_match | 0.963 | frozen 1000-Q subset | `artifacts/vlm/phase6_closure.json` | USABLE_VERIFIED β€” **ACCEPTANCE-REJECTED** |
| VLM adapter F1 | 0.96432 | same | same | USABLE_VERIFIED β€” **ACCEPTANCE-REJECTED** |
| Router **overall ungated** accuracy | 0.965116 | val, n=86, corpus-limited | `overall_ungated_accuracy` | **TEST NOT RUN** |
| System-level end-to-end benchmark | β€” | β€” | β€” | **NOT RUN β€” none exists** |

### Source artifacts and the rules the table follows

Each metric family has exactly one source artifact:

| Metric family | Artifact |
|---|---|
| change | `artifacts/change/eval_test/eval_result.json` |
| grounding | `artifacts/grounding/remoteclip_grounding_v001/eval_result_canonical.json`, `…_matched6.json` |
| optical-SAR | `artifacts/optical_sar/fusion_head_production_v001/pre_registered_115_metric.json` |
| change-VQA | `artifacts/change_vqa/run/PROMOTION.json` |
| router | `artifacts/router/threshold_sweep_val.json` |
| calibration | `artifacts/calibration_v001.json` |
| VLM | `artifacts/vlm/phase6_closure.json` |

All **20** quoted metrics are checked against these files by
[`release/tools/verify_readme_metrics.py`](https://github.com/Anish-lab-blip/SatQuery-AI), which resolves
nested artifact keys (including keys that themselves contain dots β€” the `recall` dict is keyed
`"0.10"/"0.25"/"0.50"`, so a naive `split(".")` walk would break) and compares each value at the precision
printed here. It exits non-zero if any claim fails and prints `ALL CLAIMS VERIFIED` only when everything
matches. The table obeys six rules:

1. **Two protocols are never collapsed.** Grounding is reported under *both* the canonical and matched6
   protocols. Quoting 0.2838 alone would be selective.
2. **Two test sets are never collapsed.** Change-VQA is reported on `test` **and** `test2`.
3. **accuracy never travels without macro-F1.** For imbalanced multi-class heads (optical-SAR,
   change-VQA) the macro-F1 is reported alongside accuracy, always.
4. **Validation is not test.** The router number is labelled "overall **ungated** accuracy", val, n = 86.
5. **A negative result stays negative.** Calibration ECE worsened and is shown worsening.
6. **USABLE β‰  ACCEPTED.** The VLM metrics are real; the artifact is nevertheless acceptance-rejected.

There is also **no composite or vanity score**: no single headline accuracy for the system, and none
invented by averaging the per-task numbers.

### Grounding: three decode variants, two protocols

The grounding head is evaluated under **two matching protocols** (canonical, matched6) and **three**
decode variants. Quoting a single number would misrepresent the result, so all of them are listed:

| Decode | canonical mean_best_IoU | matched6 mean_best_IoU |
|---|---|---|
| `head_threshold` (the headline number) | **0.2838** | **0.2566** |
| `head_argmax` | 0.1215 | 0.1215 |
| `zero_shot_matched` (baseline, no head) | 0.0972 | 0.0972 |

The head clears the zero-shot baseline, but only the threshold decode is meaningfully above it β€” the
argmax decode (0.1215) is barely better than zero-shot. The absolute level is modest either way:
**grounding is useful, not solved.**

The recall@0.5 numbers travel with the IoU numbers: canonical **0.2198**, matched6 **0.1938**. The box
convention is a common source of silent error, which is why the project converts VRSBench's 0–100 boxes
to its own 0–1 convention through a *declared* `benchmark_box_scale: 100.0` β€” so the conversion cannot be
applied twice or forgotten β€” and reports both protocols.

The head itself is a trainable head over the frozen RemoteCLIP ViT-B/32 encoder. Its per-cell feature is
`concat([patch, text, patchΒ·text, global_pool])` = `4 Γ— 512 = 2048` (finding P7-1: the transformer width
is 768, but `visual.proj` maps to a projected dim of **512**). Cells are assigned by ground-truth box
centre (`cell_relative` decode). The objectness BCE is weighted **20Γ—** because only ~1 of 49 cells is
positive; unweighted, the optimum collapses to "no object" everywhere. Image resolution is frozen at
**224** β€” 448 was evaluated and **rejected** (see below).

### The grounding resolution decision β€” a pre-registered rejection

**Question:** should grounding decode at 448 or 224? **Answer: 224. 448 was rejected** β€” and the rejection
is notable because it was *pre-registered* and then *confirmed* by a paired test over identical samples
(n = 16,159):

| Comparison (448 vs 224) | Value |
|---|---|
| mean best IoU | **βˆ’0.0147** |
| recall@0.5 | βˆ’0.0022 |
| recall@0.10 | βˆ’0.0699 |
| recall@0.25 | βˆ’0.0243 |
| latency | **1.59Γ—** |
| paired 95 % CI | [βˆ’0.0160, βˆ’0.0134] |
| paired t | **βˆ’22.63** |
| 448 better on | 8.5 % of records |
| 448 worse on | **20.9 %** of records |

448 lost on **every** axis. The pre-registered decision rule and the paired test **agree** on 224. This is
a model of how a resolution decision should be made: declared in advance, then tested. Recorded as
`RESOLVED 2026-09-16` in `configs/base.yaml` and in
[`docs/RESEARCH_NOTES.md`](docs/RESEARCH_NOTES.md) Β§2.

### Fusion: measured but the ruling is open

Optical-SAR fusion reaches **0.931 accuracy** on a 19-class held-out set of 4,000 β€” but only
**0.434 macro_F1**. Those two numbers describe very different things: the model is accurate on
frequent classes and weak on rare ones. The acceptance ruling for this head is **OPEN**, and the
headline accuracy must never be quoted without the macro_F1 beside it.

The metric JSON records why the macro score is low by construction: of the 19 classes in the label space,
**14 are present** and **5 are absent** in the scored split, and the **macro-F1 denominator is all 19**
(absent classes contribute 0.0). It also records `is_deciding_statistic: False` β€” this is a reported
measurement, not a decision statistic. The feature concatenation is the frozen one:

```
input_dim = 3 Γ— 768 + 12 + 2 = 2318   β†’   hidden 512   β†’   num_classes 19   (BigEarthNet CLC)
```

and the **availability mask is consumed by the fusion head, not by CROMA** (finding C-1) β€” CROMA always
sees the canonical channel counts (12 optical, 2 SAR).

Two further caveats on this head, stated rather than hidden:

- **The live service returns a bare class index** (`class_18`), not a human-readable label. The modality
  accounting in the response confirms the right channels reached the fusion head, but the presentation is
  not user-facing.
- **The local BigEarthNet subset is 100 % single-label**, against the official 1–11 multi-label scheme, so
  its metrics are **not comparable** to published BigEarthNet numbers. Any statement of the form
  "BigEarthNet mAP = X" is false for this subset.

### Change-VQA: two test sets, and they disagree

`artifacts/change_vqa/run/PROMOTION.json` records **two** test evaluations:

| Split | accuracy | macro_F1 |
|---|---|---|
| `test` | 0.697626 | 0.378373 |
| `test2` | **0.651469** | 0.372309 |

The `test` numbers are the higher pair. Both are reported here; quoting only `test` would overstate
the result. The acceptance ruling is **OPEN**.

The head was trained **outside this repository**, on an external GPU (Kaggle), and promoted through a
byte-identity gate: sha256 `cfae5e43b97ca930f568dc5b8ae4f36b24e9ff717af226159802206ffd63a82a`, 5,822,809
bytes, architecture `change_vqa_head_v1`, 1,453,912 parameters, **0 non-finite tensors**, weights not
modified during promotion, byte-identical to source, hash agreeing across `model_metadata.json`,
`run_record.json` and `hashes.json`. Selection was epoch **8**, chosen on **val answer accuracy
0.700018**, stopped by early stopping; seed 42. It trains on **cached change + text features**, not on
raw imagery β€” the raw CDVQA loader loads examples but has no training loop of its own, and the two paths
are not conflated.

### Phase 6 / VLM: deployment success β‰  model acceptance

The Phase-6 SmolVLM LoRA adapter reaches **exact_match 0.963** and **F1 0.96432** on a frozen
1,000-question subset. It is marked **USABLE_VERIFIED** and **ACCEPTANCE-REJECTED**.

Those two verdicts are not in conflict, and the distinction is the point:

- **USABLE_VERIFIED** β€” the adapter loads, runs, and produces the measured numbers in the deployed
  pipeline. The F1 is **+49.5 pp** over the unadapted baseline.
- **ACCEPTANCE-REJECTED** β€” the change did not clear the project's own pre-registered acceptance bar. The
  record of *why* is stored in `artifacts/vlm/phase6_closure.json` under `why_acceptance_rejected`; the
  artifact's `status` is `CLOSED`.

A model can be a working engineering artifact and a rejected research result at the same time.
This release keeps both labels. The deployed caption/VQA path therefore uses the **unadapted** SmolVLM.

The adapter's own shape is recorded: PEFT **0.19.1**, `r = 16`, `alpha = 32`, `dropout = 0.05`, targeting
`model.text_model.*.{q,k,v,o,gate,up,down}_proj`, precision **fp16** (finding C-6: the T4 is compute
capability 7.5, so fp16 β€” **not** bf16), batch size 2, gradient accumulation 8, learning rate 0.0002,
1 epoch, gradient checkpointing on, `save_every_steps` 500.

### Calibration: it got worse, and we say so

The `change_vqa` confidence path applies temperature scaling (`T = 0.9773`). Measured on the
validation split (n=16441):

| | ECE | NLL |
|---|---|---|
| Before temperature scaling | **0.013755** | 0.689741 |
| After temperature scaling | **0.014929** | 0.689631 |

**Calibration did not improve β€” it moved slightly worse.** The fitted temperature is `0.9772732` and
`ece_improvement` is **βˆ’0.001174**: negative. The scaling is retained because it is part
of the frozen configuration, not because it helped. The reliability curve plotted on the Benchmark page
is explicitly labelled as the **pre-scaling** diagram so a reader cannot mistake it for the calibrated
result. The calibrated curve is **not plotted**.

### What is NOT benchmarked

| Benchmark | Status | Note |
|---|---|---|
| **System-level end-to-end accuracy** | **NOT RUN β€” none exists** | There is no measured end-to-end benchmark of the full router β†’ specialist β†’ envelope pipeline. No such number is claimed anywhere. |
| **Router test split** | **NOT RUN** | Only the validation split (n = 86) was scored. |
| **Benchmark adapters** | **NOT RUN** | Adapter-based benchmark runs were not executed. |
| **Efficiency / latency benchmark** | not systematically measured | Per-specialist latency is recorded incidentally in artifacts (e.g. grounding `latency_ms_per_image` 2.205 ms for the head), but there is no end-to-end latency benchmark. |
| **Cross-dataset generalisation** | **NOT RUN** | Each specialist is evaluated only on its own training-family test split. |
| **Human evaluation** | **NOT RUN** | β€” |
| **Adversarial / robustness evaluation** | **NOT RUN** | β€” |

## Live validation

Validation drove the **production site** in a headed browser, one upload per case, with per-case
screenshots and recorded run identifiers. It is **behavioural** evidence β€” that the pipeline runs and
routes correctly β€” and it is **not** an accuracy claim; accuracy and behaviour are evaluated separately.

| Property | Result |
|---|---|
| Independent full passes | **3** |
| Cases per pass | 8 (6 regression + 2 defect) |
| Passes at 8/8 | **3 of 3** |
| Live runs executed | **24** |
| Correct dispatches | **24** |
| Mock-node contamination | **0** on every live run |
| Trace fill | 94.4444 % on every live run |
| Frontend regression suite | **106 passed** (`tests/unit/test_frontend_live_wiring.py`) |

Each pass produced **fresh run identifiers** β€” no run id is shared between passes. The three passes ran
against two frontend revisions:

| Pass | Deployed HEAD | Result |
|---|---|---|
| 1 | `ff46eba42b18` + `d413d3672311` | 8/8 |
| 2 | `2d7ae53b482d` | 8/8 |
| 3 | `2d7ae53b482d` | 8/8 |

The harness asserts the form state **before** dispatch β€” that the query box really holds the intended
query, that `#obsTail` reads `ready`, and that both frames are attached for pair tasks. This matters:
an earlier harness revision typed with synthetic key events that Chrome silently drops when the window
lacks OS focus, so it dispatched the page's *default* query and still recorded a "result". The
assertions exist because of that failure. The earlier 8/8 run was independently re-examined and confirmed
**not** to have been infected (its answers were query-specific and the query text was embedded in the
answers), but the failure mode is recorded because it is exactly the kind of silent false-positive an
evaluation harness must never have.

Deployed-artifact integrity was checked separately: **9 files** were re-read from the GitHub API and
compared byte-for-byte against local copies, and all 9 were **sha256 byte-identical**; the deployed HEAD
was re-read from the API.

### Representative real run IDs

One full pass (pass 3 of 3) β€” the same pass the screenshots below are drawn from. Run identifiers
are fresh on every pass; the other two passes recorded different ids.

| Case | Query | Dispatched | Run ID |
|---|---|---|---|
| vqa | What type of terrain dominates this scene? | `vqa` | `run_fef26e91e7e6` |
| caption | Describe the main visual characteristics of this scene. | `caption` | `run_96281bdfcc08` |
| grounding | Where are the visible buildings in this image? | `grounding` | `run_e49adc8d319f` |
| change | What changed between the earlier and later image? | `change` | `run_aedc59cbcdc9` |
| change_vqa | Did the coastline advance between the two observations? | `change_vqa` | `run_62ca98d510be` |
| optical_sar | …combining the optical and SAR observations? | `optical_sar` | `run_beacf6aa4e21` |
| **grounding** | **Where are the built-up areas in this image?** | **`grounding`** | **`run_467ffa406f22`** |
| **grounding** | **Where is the new airport?** | **`grounding`** | **`run_46980ba55c62`** |

The last two are the router-defect queries. Both previously collapsed to `vqa` and answered "River".

Note the fifth row: *"Did the coastline advance between the two observations?"* is read as `change` and
**dispatches `change_vqa`** β€” the documented quantifier upgrade, because the page's Answer block promises
an answer and the server's `change` returns a spatial map with no language output. With one asset
attached, `change` would instead be refused outright (*"requires exactly 2 assets"*); the console avoids
asking for a pair-requiring task when only one asset exists.

### Screenshots

Eight captures from the post-fix live run (headed browser, 1384Γ—855, one upload per case). Each
panel shows the run identifier, the frozen config hash `78f1e3700da15aa1`, and the evidence list
returned by the specialist β€” nothing is mocked.

| | |
|---|---|
| ![Grounding β€” built-up areas](screenshots/analyze-grounding.png) | ![Optical-SAR](screenshots/analyze-optical-sar.png) |
| **Grounding** β€” "Where are the built-up areas in this image?" β€” the fixed router defect (`run_467ffa406f22`, dispatched `grounding`, not `vqa`) | **Optical-SAR** fusion on a real optical/SAR GeoTIFF pair (`run_beacf6aa4e21`, fused class 18) |
| ![Grounding β€” new airport](screenshots/analyze-grounding-new-airport.png) | ![Grounding β€” visible buildings](screenshots/analyze-grounding-buildings.png) |
| **Grounding** β€” "Where is the new airport?" β€” second defect query (`run_46980ba55c62`, dispatched `grounding`) | **Grounding** β€” "Where are the visible buildings in this image?" (`run_e49adc8d319f`) |
| ![Change](screenshots/analyze-change.png) | ![Caption](screenshots/analyze-caption.png) |
| **Change** detection on a same-shape temporal pair | **Caption** of a single scene (`run_96281bdfcc08`, calibrated confidence 1.000) |
| ![VQA](screenshots/analyze-vqa.png) | ![Change-VQA](screenshots/analyze-change-vqa.png) |
| **VQA** β€” "What type of terrain dominates this scene?" | **Change-VQA** β€” "Did the coastline advance between the two observations?" |

All eight are reproduced byte-for-byte in the evidence archive (Phase 7) with SHA-256 recorded in
`RELEASE_MANIFEST.md`.

## Installation

Python 3.11+ and a CPU are sufficient. No CUDA requirement β€” device is selected via
`SATQUERY_DEVICE`; all placement is `.to(device)`, never `.cuda()`.

```bash
git clone https://github.com/Anish-lab-blip/SatQuery-AI
cd SatQuery-AI
python -m venv .venv
source .venv/Scripts/activate      # Windows git-bash; use .venv/bin/activate on Linux/macOS
pip install -r requirements.txt
```

Backbones are fetched from the Hugging Face Hub on first use, pinned by revision in
`configs/base.yaml`. The frozen config hash is **`78f1e3700da15aa1`** β€” the loader refuses to run a
config that violates the recorded invariants (for example `fusion.input_dim == 3*encoder_dim + 12 + 2`).

### The invariants the loader enforces

`core/config.py` validates the registry at load time and raises `ConfigError` β€” naming every violation β€”
rather than letting a bad value reach runtime. The invariants are not documentation; they are checks:

| Invariant | Why it exists |
|---|---|
| `croma.image_resolution % 8 == 0` | CROMA asserts this (finding C-7); native 120 β†’ 225 patches |
| `training.precision ∈ {fp16, bf16, fp32}` | the T4 is SM 7.5, so bf16 is unavailable (finding C-6) |
| `deployment.torch_compile is not true` | ZeroGPU does not support `torch.compile` (finding C-8) |
| `vlm.processor_longest_edge ≀ image.tile_size` | the processor's default `longest_edge` is 2048, which upscales a 512 px tile 4Γ— and then splits it into **17** sub-images β€” a ~17Γ— overrun, not the 4Γ— the plan estimated (finding F5-2). Tying the pin to `image.tile_size` makes it a *control*, so the processor cannot silently start upscaling again. |
| `vlm.prompt_must_use_chat_template is true` | SmolVLM raises `ValueError` on prompts lacking one `<image>` token per image (finding F5-3) |
| `fusion.input_dim == 3*encoder_dim + optical_channels + sar_channels` (= 2318) | CROMA emits optical/SAR/joint GAP vectors; the availability mask is consumed by the head (finding C-1) |
| `croma.optical_channels == 12` and `croma.sar_channels == 2` | CROMA's `s2_channels` / `s1_channels` are fixed |
| `grounding_head.feature_dim == 4 * grounding.encoder_projected_dim` (= 2048) | a mismatch is a **silent** shape error β€” torch raises only at the similarity step, after patch features are already cached (finding P7-1) |
| `router.tasks` includes `unsupported` and `router.num_tasks == len(router.tasks)` | the ontology and its declared size cannot drift apart |
| `change.sa_mode ∈ {BAM, PAM}` and `change.encoder` is set | the change architecture is not implicit |
| `image.top_k_tiles ≀ image.max_tiles` | the dispatch ceiling cannot exceed the examination ceiling |

Because a config edit moves `Config.hash` and invalidates every artifact keyed to it, deployment state
that must not move the hash (asset-store capacity, TTL, the per-file cap) is read from the **environment**
rather than from `configs/base.yaml` β€” the same reasoning that keeps the config hash frozen.

## Local development

```bash
# Inference service, CPU (this is the launcher the Codespace runs)
PORT=8000 python deploy/codespace/serve.py

# Health
curl localhost:8000/v1/health
```

The frontend is fully static and needs no build step to serve locally:

```bash
python -m http.server 5500 --directory frontend
```

Run the frontend regression suite:

```bash
python -m pytest tests/unit/test_frontend_live_wiring.py -q
```

### The test suites

| Suite | Command | Expected |
|---|---|---|
| Frontend live-wiring | `pytest tests/unit/test_frontend_live_wiring.py` | **106 passed** |
| Doc/frontend suite | `pytest` on the 5 doc/frontend files | **183 passed** |
| Full unit suite | `pytest tests/unit` | 5–6 **environmental** failures (sandbox delete guard Γ— 4, 1 ordering flake, 1 stale adapter test) |

The full-suite failures are **not hidden**, and they are not regressions: 4 are the sandbox's bulk-delete
guard (`test_safe_delete_shim`), 1 is an ordering flake that passes in isolation, and 1 is a stale adapter
test (CROMA is now shipped). Re-running the affected files together gives **137 passed**, confirming the
failures are attributable to the sandbox environment and test ordering rather than the code under test.

### Reproduce a live run

The deployed stack is reachable. Note the authoring sandbox has a dead proxy, so outbound calls need
`--noproxy '*'` (curl) or `ProxyHandler({})` (Python):

```bash
curl --noproxy '*' https://<backend-host>/api/health
curl --noproxy '*' https://<backend-host>/api/capabilities
```

`/api/capabilities` returns six tasks, all `available: true`. A live run requires the tunnel agent to be
connected (`agent_connected: true`); if the Codespace is stopped, the request parks until the tunnel
timeout. See [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) Β§5–6.

## Deployment

The live topology is **Cloudflare Pages β†’ Render β†’ outbound tunnel β†’ GitHub Codespace**.

| Layer | Role | Source |
|---|---|---|
| Cloudflare Pages | Static frontend at **https://satquery.pages.dev** | `frontend/` |
| Render | Orchestrator / API gateway, `/api/*`, CORS, wake flow | `gateway/` |
| GitHub Codespace | FastAPI inference host, CPU, port 8000 | `app/` |
| Hugging Face | Model cards, released artifacts, checksums | this release |

Deployment sources are **separate repositories** from this release. The wake flow is: Cloudflare β†’
Render β†’ start the Codespace if stopped β†’ poll `/v1/health` β†’ surface *"Waking inference engine…"* β†’
`POST /infer` β†’ result.

### Live revisions at this release

| Component | Repository | Visibility | Revision | Host |
|---|---|---|---|---|
| Frontend | `Anish-lab-blip/SatQuery-Frontend` | private | **`2d7ae53b482d`** | Cloudflare Pages β†’ `satquery.pages.dev` |
| Backend / orchestrator | `Anish-lab-blip/SatQuery-Backend` | private | **`89d80eaddec5`** | Render β†’ `<backend-host>` |
| Inference | `Anish-lab-blip/SatQuery-Inference` | private | **`5a0936ace491`** | Codespace, port 8000, via outbound tunnel |
| Public umbrella | `Anish-lab-blip/SatQuery-AI` | **public** | `3dcabd32da41` | this release home |

> **Trap.** `deploy/` inside the monorepo is **stale and untracked**. It is **not** the deployed source.
> Edits must go to the three real repositories. Recorded in
> [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) Β§1.

### Environment variables

Render (gateway), measured live:

| Variable | Value (live) |
|---|---|
| `CODESPACE_NAME` | `potential-space-trout-r4ppw969w45j2pvvw` |
| `CODESPACE_PORT` | `8000` |
| `SATQUERY_ALLOWED_ORIGINS` | `https://satquery.pages.dev` |
| `SATQUERY_DEVICE` | `cpu` |
| `SATQUERY_TRANSPORT` | `auto` |
| `SATQUERY_TUNNEL_TIMEOUT_S` | `150` |
| `SATQUERY_WAKE_TIMEOUT_S` | `120` |
| `SATQUERY_UPSTREAM_TIMEOUT_S` | `90` |
| `GITHUB_TOKEN` | present |

Codespace (inference):

| Variable | Purpose |
|---|---|
| `PORT` | platform-assigned; **must be read** |
| `SATQUERY_DEVICE` | `cpu` \| `cuda` \| `mps` \| `null`; read **without importing torch** |
| `SATQUERY_MAX_FILE_BYTES` | per-file cap (shared with Render) |
| `SATQUERY_ASSET_ENABLED` / `SATQUERY_ASSET_DIR` | both required for `/v1/assets`; fails closed otherwise |
| `SATQUERY_ASSET_MAX_FILES` / `SATQUERY_ASSET_TTL_S` | optional handle capacity / lifetime |

Deploy mechanics: the frontend is staged by `scripts/stage_pages.mjs` and deployed with
`npx wrangler pages deploy`; the backend is a `render.yaml` blueprint whose `main.py` exposes `app`; the
inference host runs `deploy/codespace/serve.py` on `$PORT` and the devcontainer forwards port 8000 and
starts the tunnel agent on `postStartCommand`. Repository writes are performed through the GitHub Git
Data API (blob β†’ tree β†’ commit β†’ `PATCH` ref) with **sha256 byte-verification** of every uploaded blob,
rather than `git push`, so each deployed file is verified by content hash.

### Deployment caveats

- **Cold start.** The inference host may be stopped when idle. The first request after a cold start
  can exceed the client timeout while weights are fetched; a retry a few seconds later normally
  succeeds. Warm the stack before any demonstration and confirm
  `GET /api/health` reports `tunnel.agent_connected: true`. Cold start is **tens of seconds** and is
  documented rather than papered over.
- **Tunnel gaps.** The tunnel agent can be briefly absent. A request issued during such a gap may hang
  or return HTTP 504. **This is not fixed in production** β€” a prepared patch
  (`forward_unavailable` 503 / `upstream_timeout` 504 plus a `codespace_name` fix) exists and is
  documented, but it was deliberately not deployed. Root cause: in `auto` transport mode a tunnel
  timeout falls through to the forwarded-port path (`main.py:546`), which then spends the 120 s wake
  timeout on an HTTP 302 β€” the observed ~249 s failure (150 + 120).
- **`codespace_name`** is still reported with a trailing newline by `/api/health` (cosmetic; the wake
  path strips it).
- **Never retry `POST /api/infer` at the gateway** β€” a retry consumes inference twice.
- **Platform traps, recorded so they are not rediscovered.** Cloudflare `_headers` rules **concatenate**
  rather than override, and Chromium takes the first `max-age` it encounters, so a later rule cannot "fix"
  an earlier one. Cloudflare 308-redirects `X.html` β†’ `/X`, so the extensionless path must be referenced.
  A forwarded Codespace port returns 302 for a private repo β€” which is *why* the tunnel exists. And the
  tunnel agent must be started by the devcontainer's `postStartCommand`, or a restarted Codespace comes up
  with `agent_connected: false`.

### Historical context

The superseded design ran inference on an **HF Space with ZeroGPU** behind a **Railway** gateway. The
active design moves to **Render + Codespace**, CPU-first, with an outbound tunnel. The four-endpoint
contract, the gateway responsibility table, the env-var vocabulary and the config freeze are unchanged β€”
only host names moved. `configs/deploy.yaml` still describes the old HF-Space/ZeroGPU target and is left
**undisturbed as frozen paperwork** (editing it would move the config hash); no Gradio runtime exists in
code. The declared ZeroGPU durations are transcribed, not invented β€” `app/space_app.py` carries
`GPU_DURATIONS` = `vqa` 20, `caption` 20, `grounding` 45, `change` 30, `optical_sar` 45, `change_vqa` 30,
and a task with no declared duration is a programming error rather than a default, because silently
picking one would reserve the wrong amount of the 5 GPU-minute daily budget. The decoration has **never
executed** here (`spaces` is not installed in this environment); `configs/deploy.yaml` sets
`cpu_mode_required: true`, so a CPU run must work, and it does.

## Reproducibility

1. **Configuration.** `configs/base.yaml` is the single registry; no magic numbers in Python. Its hash
   is recorded in every execution trace. Frozen hash: `78f1e3700da15aa1`. A config edit moves the hash and
   invalidates every artifact keyed to it.
2. **Backbones.** Pinned by `repo_id` + `revision`, never by floating tag:
   `HuggingFaceTB/SmolVLM-500M-Instruct@a7da5b986cb5`,
   `chendelong/RemoteCLIP@bf1d8a3ccf2d`,
   `sentence-transformers/all-MiniLM-L6-v2@1110a243fdf4`,
   `antofuller/CROMA@0dd28e3d633b`.
3. **Released artifacts.** `models/manifest.json` and `models/checksums.sha256` are **generated from
   the actual files** β€” never hand-typed. Verify with `sha256sum -c models/checksums.sha256`.
4. **Splits.** LEVIR-CD-256: train 7120 / val 1024 / test 2048. Grounding: VRSBench n=16159.
   Fusion: held-out test n=4000. Change-VQA: test n=39686. Leakage isolation is by `scene_id`.
5. **Prompts** are versioned files, frozen before benchmark evaluation.
6. **Negative results are preserved.** Rejected and open rulings are recorded, not removed.

The frozen contract, in the registry's own vocabulary:

| Guarantee | How it is enforced |
|---|---|
| Frozen configuration | all tunables live in `configs/base.yaml`; the loader validates invariants and computes a hash |
| Frozen config hash | `78f1e3700da15aa1`; every artifact records the hash it was produced against |
| Pinned backbones | every backbone is pinned by revision; the Hub resolves the exact commit |
| Seed | `project.seed: 42` |
| Immutable public test | `evaluation.immutable_public_test: true`; `hidden_data_access: false` |
| Byte-verified artifacts | every released artifact ships with a sha256 in `models/checksums.sha256` |
| Verified metrics | every quoted number is checked against its artifact by the metric-verification tool |

### Reproduce the metric check (cheap, no GPU)

```bash
python release/tools/verify_readme_metrics.py
```

It **reads** the artifacts under `artifacts/`, **compares** each of the 20 quoted metrics at the precision
printed in this README, and **also asserts** the statuses (that the VLM headline contains
`ACCEPTANCE-REJECTED`; the router's `corpus_limited` / `n_val`; the calibration temperature and
`ece_improvement`). It **exits 0** and prints `ALL CLAIMS VERIFIED` only when everything matches.

### What "reproduce" means, and what it does not

| Artifact | Where it trains | Reproducible from this release? |
|---|---|---|
| router adapter | local CPU | yes β€” `configs/base.yaml` Β§`router.training` |
| grounding head | local | yes β€” `configs/base.yaml` Β§`grounding_training` |
| change head | local | yes β€” `configs/base.yaml` Β§`change` |
| optical_sar fusion head | local, seed sweep | yes (see [`docs/TRAINING.md`](docs/TRAINING.md) Β§5) |
| change_vqa head | **external GPU (Kaggle)** | **partly** β€” the promotion gate, evaluation and serving wiring are reproducible; there is no one-command retrain |
| vlm LoRA adapter | **external GPU** | **partly** β€” same |

For the two externally-trained artifacts, the repository reproduces the **promotion gate**
(byte-identity, sha256, zero non-finite tensors), the **evaluation**, and the **serving wiring**; it does
**not** ship a one-command retrain. That is stated rather than implied.

### What is NOT reproducible from this release

| Item | Reason |
|---|---|
| The private deployment repos | they are private; the deployed sources are not in this release |
| System-level end-to-end benchmark | **no such benchmark exists** |
| Router test-split number | **not run** |
| CDVQA / SECOND imagery | public but large; the release documents the acquisition + name-verification procedure, not the data |
| BigEarthNet full corpus | **not downloaded** (only a 28k S2 subset was used) |
| The historical ZeroGPU/Gradio deploy target | frozen paperwork only; no runtime exists in code |

Environment traps worth recording for anyone reproducing: the authoring sandbox has a **dead proxy**
(outbound calls need `--noproxy '*'` for curl or `ProxyHandler({})` for Python); pytest is installed only
in the repository virtualenv (`.venv/Scripts/python.exe`); the full-suite run trips the sandbox's
bulk-delete guard; Cloudflare 308-redirects `X.html` β†’ `/X`; and Chrome drops synthetic CDP key events
when the window lacks OS focus, which is relevant to any browser-driven reproduction of the live
validation.

## Known limitations

1. **No system-level end-to-end benchmark exists.** Per-specialist metrics are real; a single
   end-to-end number is **NOT RUN**.
2. **Router accuracy is validation-only** (n=86, corpus-limited). Its **test set was never run**.
3. **Optical-SAR fusion returns a bare class index** (`class_18`), not a human-readable label. The
   modality accounting in the response confirms the right channels reached the fusion head, but the
   presentation is not user-facing.
4. **VQA is weak-but-related.** Asked what terrain dominates a scene, it answers "Grassland".
5. **Fusion macro_F1 is low (0.434161)** against 0.931 accuracy β€” rare classes are poorly handled. Of
   the 19 classes, 5 are absent from the scored split and contribute 0.0 to macro-F1 by construction.
6. **Grounding IoU is modest** (0.2838 canonical, 0.2566 matched6) β€” useful, not solved β€” and it is
   protocol-sensitive: the argmax decode (0.1215) is barely above the zero-shot baseline (0.0972).
7. **Calibration makes ECE slightly worse** (0.013755 β†’ 0.014929), and is retained only because it is
   part of the frozen configuration. The calibrated reliability curve is not plotted.
8. **Router lexical residuals.** *"What is the new runway?"* reads `change` rather than `vqa` (the
   `new`-as-change heuristic fires outside `where` questions), and *"How much built-up area was
   added?"* reads `vqa` (under-trigger). A lexical router cannot cleanly separate "the new X" from
   "what's new"; a trained intent router exists in `artifacts/router/` but is not attached.
9. **B-07 tunnel gaps are not fixed in production** (see [Deployment caveats](#deployment-caveats)).
10. **No license has been selected** for this repository. Until one is, the artifacts carry
    `license: unknown` and no reuse rights should be assumed. This is an open owner decision.
11. **The Anatomy of a Run page** renders a recorded run whose plate uses the 720Γ—720 variant of an
    image analysed at 730Γ—730 β€” identical content, scaled by the canvas, but the "actual analysed
    image" wording is slightly loose.
12. **The BigEarthNet local subset is single-label** (100 %) against the official 1–11 multi-label
    scheme, so its metrics are **not comparable** to published numbers.
13. **The VLM adapter is not accepted** β€” metrics usable (exact_match 0.963), status
    acceptance-rejected; the deployed path uses the unadapted model.
14. **The deployment repos are private**, so their links 404 for an outside audience β€” by design.

### Explicit non-claims

- **No claim of state-of-the-art performance** on any benchmark.
- **No claim of production readiness** for model quality β€” the deployment runs, but the models carry the
  limitations above.
- **No claim that the trained heads generalise** beyond their training-family test splits.
- **No claim that calibration improves confidence.**
- **No claim that the VLM adapter is accepted** for production use.
- **No system-level accuracy** is claimed anywhere, and none is produced by averaging the per-task
  numbers.

## Links

| | |
|---|---|
| Live demo | https://satquery.pages.dev |
| GitHub | https://github.com/Anish-lab-blip/SatQuery-AI |
| Hugging Face | https://huggingface.co/thundercode/SatQuery |

Third-party models this work builds on (pinned, not redistributed):

| Model | Revision | Role |
|---|---|---|
| [`HuggingFaceTB/SmolVLM-500M-Instruct`](https://huggingface.co/HuggingFaceTB/SmolVLM-500M-Instruct) | `a7da5b986cb5` | VQA + captioning backbone |
| [`chendelong/RemoteCLIP`](https://huggingface.co/chendelong/RemoteCLIP) | `bf1d8a3ccf2d` | remote-sensing grounding encoder |
| [`sentence-transformers/all-MiniLM-L6-v2`](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) | `1110a243fdf4` | router embedding |
| [`antofuller/CROMA`](https://huggingface.co/antofuller/CROMA) | `0dd28e3d633b` | optical/SAR fusion encoder |

Datasets referenced by the evaluations: LEVIR-CD-256 (change), VRSBench (grounding),
BigEarthNet (optical-SAR fusion, 19 CLC classes), CDVQA + SECOND (change-VQA). No dataset is
redistributed here.

Documentation in this repository:

| Document | Contents |
|---|---|
| [`docs/architecture/README.md`](docs/architecture/README.md) | architecture hub + index of the ten sub-documents |
| [`docs/architecture/01-system-overview.md`](docs/architecture/01-system-overview.md) | thesis, component inventory, frozen backbones |
| [`docs/architecture/02-deployment-topology.md`](docs/architecture/02-deployment-topology.md) | four tiers, tunnel, wake flow, cold start |
| [`docs/architecture/03-request-lifecycle.md`](docs/architecture/03-request-lifecycle.md) | nine-state controller, validation, modality inference, tiling |
| [`docs/architecture/04-router.md`](docs/architecture/04-router.md) | MiniLM, five heads, `interpret()` vs `chooseTask()`, the lexical fallback |
| [`docs/architecture/05-specialists.md`](docs/architecture/05-specialists.md) | all six tasks end to end |
| [`docs/architecture/06-evidence-and-confidence.md`](docs/architecture/06-evidence-and-confidence.md) | evidence schema, aggregation, temperature scaling, the eight events |
| [`docs/architecture/07-configuration-freeze.md`](docs/architecture/07-configuration-freeze.md) | the registry, invariants, the config hash |
| [`docs/architecture/08-api-contract.md`](docs/architecture/08-api-contract.md) | four endpoints, envelopes, error codes |
| [`docs/architecture/09-frontend.md`](docs/architecture/09-frontend.md) | static pages, the Analyze console, real-vs-preview |
| [`docs/architecture/10-observability-and-ops.md`](docs/architecture/10-observability-and-ops.md) | health, counters, traces |
| [`docs/MODELS.md`](docs/MODELS.md) | the six artifacts in detail; rejected decisions |
| [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md) | headline metrics and the rules they follow |
| [`docs/EVALUATION.md`](docs/EVALUATION.md) | per-task protocols; evaluation-honesty rules |
| [`docs/DATASETS.md`](docs/DATASETS.md) | measured corpus figures and caveats |
| [`docs/TRAINING.md`](docs/TRAINING.md) | per-artifact hyperparameters |
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | live revisions, env vars, traps |
| [`docs/REPRODUCIBILITY.md`](docs/REPRODUCIBILITY.md) | what a third party can reproduce |
| [`docs/RESEARCH_NOTES.md`](docs/RESEARCH_NOTES.md) | findings, the router defect, negative results |
| [`docs/LIMITATIONS.md`](docs/LIMITATIONS.md) | the full limitation catalogue |
| [`docs/CHANGELOG.md`](docs/CHANGELOG.md) | versioned record |
| [`MODEL_CARD.md`](MODEL_CARD.md) | the Hugging Face model card |

Supporting and subsystem documentation:

| Document | Contents |
|---|---|
| [`docs/SECURITY.md`](docs/SECURITY.md) | trust model, secret custody, CORS allowlist, limits, what is *not* defended |
| [`docs/TESTING.md`](docs/TESTING.md) | suite inventory, the doc-guard tests, the harness false-positive lesson |
| [`docs/GEOSPATIAL.md`](docs/GEOSPATIAL.md) | raster contract, CRS, the coordinate-system rule, band inference, normalisation |
| [`docs/DATA_PIPELINE.md`](docs/DATA_PIPELINE.md) | asset β†’ specialist input, cached features, sensor adapter |
| [`docs/OPERATIONS.md`](docs/OPERATIONS.md) | the runbook, warming, incident triage, known operational gaps |
| [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | local setup, the config-hash rule, how to add a specialist, dev traps |
| [`docs/PERFORMANCE.md`](docs/PERFORMANCE.md) | model footprint, measured component timings, cost traps |
| [`docs/FRONTEND.md`](docs/FRONTEND.md) | the 11 pages, the Analyze console, the eight events, Cloudflare traps |
| [`docs/SERVING.md`](docs/SERVING.md) | `build_space_app()`, the four endpoints, lazy loading, the G-1 trap |
| [`docs/GLOSSARY.md`](docs/GLOSSARY.md) | every domain term and status vocabulary, defined |
| [`docs/MASTER_ARCHITECTURE_PLAN.md`](docs/MASTER_ARCHITECTURE_PLAN.md) | the **original** master plan (historical; superseded in parts) |
| [`HF_RELEASE_VERIFICATION.md`](HF_RELEASE_VERIFICATION.md) | the Hugging Face release verification record |
| [`RELEASE_MANIFEST.md`](RELEASE_MANIFEST.md) | every released file with its size and sha256 |
| [`tools/`](tools/) | the verification tooling (`verify_readme_metrics.py`, manifest generators, archive tools) |

> **Honesty rule.** Every document in this repository uses one status vocabulary β€”
> `IMPLEMENTED` Β· `VERIFIED` Β· `MEASURED` Β· `ATTEMPTED` Β· `NOT RUN` Β· `BLOCKED` Β· `DEFERRED` Β·
> `REJECTED` Β· `OPEN` Β· `RESOLVED` Β· `CLOSED` β€” and negative results are recorded rather than
> omitted. Where evidence was missing, the text says
> `UNKNOWN β€” not established from the available evidence` instead of guessing.

## Citation

No paper accompanies this release. Until one exists, cite the repository:

```bibtex
@misc{satqueryai2026,
  title  = {SatQuery AI: an interactive vision-language assistant for
            multimodal remote-sensing image analysis},
  author = {SatQuery AI contributors},
  year   = {2026},
  url    = {https://github.com/Anish-lab-blip/SatQuery-AI}
}
```

## License

**Not yet selected.** See limitation 10. Backbone models remain under their own upstream licenses.

No `LICENSE` file exists in this repository. Until one is selected, the released artifacts carry
`license: unknown` and **no reuse rights should be assumed**. This is an open owner decision, recorded
as `OPEN` in [`docs/LIMITATIONS.md`](docs/LIMITATIONS.md) Β§5 and
[`docs/CHANGELOG.md`](docs/CHANGELOG.md). The six trained artifacts are small modules over frozen
backbones; the backbones are not redistributed here and remain under their own upstream licences β€”
consult each backbone's Hugging Face page.