Image-to-Text
PyTorch
Safetensors
PEFT
English
remote-sensing
satellite-imagery
earth-observation
change-detection
visual-grounding
image-captioning
visual-question-answering
optical-sar-fusion
sar
multimodal
lora
Instructions to use thundercode/SatQuery with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use thundercode/SatQuery with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
release: add docs/architecture/09-frontend.md
Browse files- docs/architecture/09-frontend.md +1655 -0
docs/architecture/09-frontend.md
ADDED
|
@@ -0,0 +1,1655 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 09 — The Frontend
|
| 2 |
+
|
| 3 |
+
**Parent:** [Architecture hub](../ARCHITECTURE.md) · **Status tags:** `IMPLEMENTED` · `VERIFIED` ·
|
| 4 |
+
`MEASURED` · `NOT RUN` · `SUPPORTED` · `OPEN`
|
| 5 |
+
|
| 6 |
+
**Sources of truth for this chapter, all read before writing:**
|
| 7 |
+
|
| 8 |
+
| Source | Lines | What it establishes |
|
| 9 |
+
|---|---|---|
|
| 10 |
+
| `frontend/assets/js/core.js` | 1029 | the 8 execution events (`SQ.EVENT_NAMES`), the 9-state trace spine, the deterministic policy, the raster synthesiser |
|
| 11 |
+
| `frontend/assets/js/live.js` | 392 | the real client: `/api/assets` → `/api/infer`, base-URL resolution, error translation |
|
| 12 |
+
| `frontend/assets/js/mission.js` | ~1000 | the Analyze console: `runLive` / `runMock`, `markState`, the trace fill formula, the intent panel |
|
| 13 |
+
| `frontend/_headers` | 77 | the Cloudflare cache/security rules and the measured concatenation finding |
|
| 14 |
+
| `frontend/*.html` | 11 files | the page set, the shared header `<nav>` with the GitHub and Hugging Face links |
|
| 15 |
+
| `scripts/stage_pages.mjs` | ~380 | the reference-driven staging pipeline, its exit codes and its 25 MiB limit |
|
| 16 |
+
| `docs/DEPLOYMENT_DECISION.md` | 205 | the hermeticity audit, the film, what was deliberately not created |
|
| 17 |
+
| `docs/DEPLOYMENT_TOPOLOGY.md` | 248 | §3.1 the Pages tier; the "EXCEPT `mission.html`" correction |
|
| 18 |
+
| `docs/FINAL_DELIVERY_TODO.md` | 366 | §1.4 the status board, §1.7 item 9 the Cloudflare finding, §5 B-08, §6 E-11/E-13/E-14 |
|
| 19 |
+
| `DELIVERY_REPORT_2026-09-25.md` | 299 | §3 the live validation and the **harness trap**; §5 the cache-busting measurement |
|
| 20 |
+
| session `HANDOFF_NEXT_AGENT.md` | 149 | §4 the hard constraints, including the 308 redirect and the harness rules |
|
| 21 |
+
|
| 22 |
+
> **The honesty rule this chapter inherits.** `frontend/HANDOFF.md` and the style guide both forbid
|
| 23 |
+
> presenting a synthetic value as a measured one. This chapter therefore labels every figure with
|
| 24 |
+
> where it came from, and it names the one place where the shipped code does something the
|
| 25 |
+
> documentation around it does not describe.
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## 1. Where the frontend sits
|
| 30 |
+
|
| 31 |
+
The frontend is the **first** of the four tiers. It is a static site served by Cloudflare Pages.
|
| 32 |
+
|
| 33 |
+
```
|
| 34 |
+
USER
|
| 35 |
+
│ HTTPS
|
| 36 |
+
▼
|
| 37 |
+
Cloudflare Pages (static frontend) ← frontend/ , staged via scripts/stage_pages.mjs
|
| 38 |
+
│ HTTPS, JSON
|
| 39 |
+
▼
|
| 40 |
+
Render (orchestrator / API gateway) ← deploy/render/ , render.yaml blueprint
|
| 41 |
+
│ server-to-server
|
| 42 |
+
▼
|
| 43 |
+
GitHub Codespace (FastAPI inference) ← deploy/codespace/
|
| 44 |
+
```
|
| 45 |
+
(`docs/DEPLOYMENT_TOPOLOGY.md` §1)
|
| 46 |
+
|
| 47 |
+
`docs/DEPLOYMENT_TOPOLOGY.md` §3.1 gives the tier's responsibility in one line:
|
| 48 |
+
|
| 49 |
+
> *"**Responsibility:** serve the static site. No backend, no secrets, no API calls of any kind
|
| 50 |
+
> (verified hermetic — see `DEPLOYMENT_DECISION.md` §3)."*
|
| 51 |
+
|
| 52 |
+
and then the document's own header corrects that claim for one page:
|
| 53 |
+
|
| 54 |
+
> *"§3.1's "no API calls of any kind (verified hermetic)" holds for every static page EXCEPT
|
| 55 |
+
> `mission.html`, which calls the orchestrator."* (`docs/DEPLOYMENT_TOPOLOGY.md` header note)
|
| 56 |
+
|
| 57 |
+
### 1.1 The hermeticity audit, and its one exception
|
| 58 |
+
|
| 59 |
+
`docs/DEPLOYMENT_DECISION.md` §3 records the audit that made the static-only deployment viable:
|
| 60 |
+
|
| 61 |
+
> *"Audited across all of `frontend/` (excluding `.tools/`): **zero** occurrences of `fetch(`,
|
| 62 |
+
> `XMLHttpRequest`, `axios`, `EventSource`, `WebSocket`, `/v1/`, `import.meta.env` or `process.env`.
|
| 63 |
+
> The only URL-shaped string anywhere is the SVG XML namespace at `frontend/assets/js/core.js:31`,
|
| 64 |
+
> which is not a fetch."* (`docs/DEPLOYMENT_DECISION.md` §3)
|
| 65 |
+
|
| 66 |
+
The namespace string is real and is an XML namespace, not a network call:
|
| 67 |
+
|
| 68 |
+
```js
|
| 69 |
+
svg: function (tag, attrs) {
|
| 70 |
+
var n = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
| 71 |
+
```
|
| 72 |
+
(`frontend/assets/js/core.js:30-31`)
|
| 73 |
+
|
| 74 |
+
> *"The staging audit reports **`external network deps: 0 (HERMETIC)`**."*
|
| 75 |
+
> (`docs/DEPLOYMENT_DECISION.md` §3)
|
| 76 |
+
|
| 77 |
+
That audit predates the Analyze console. `_headers` now carries a dated correction of the same claim:
|
| 78 |
+
|
| 79 |
+
> *"NOTE (2026-09-25): the site is no longer fully static — mission.html loads live.js and POSTs to
|
| 80 |
+
> the Render orchestrator (the Analyze console), so a real CSP would also need a connect-src for that
|
| 81 |
+
> API origin."* (`frontend/_headers:14-17`)
|
| 82 |
+
|
| 83 |
+
The page set is therefore **mostly static, with one live page**, and the two statements are not in
|
| 84 |
+
conflict: the audit measured what it measured on the tree it measured, and the correction names the
|
| 85 |
+
change.
|
| 86 |
+
|
| 87 |
+
### 1.2 Fonts are self-hosted, which is what makes the site hermetic
|
| 88 |
+
|
| 89 |
+
> *"`assets/css/system.css` previously opened with a render-blocking `@import` of the Google Fonts CSS
|
| 90 |
+
> API. That `@import` is gone, replaced by 10 `@font-face` blocks pointing at 11 woff2 files in
|
| 91 |
+
> `assets/fonts/` (518,198 B total, plus `OFL.txt`). This matters more than it looks: a CSS `@import`
|
| 92 |
+
> is render-blocking **and** transitively script-blocking — a classic synchronous `<script>` waits on
|
| 93 |
+
> pending stylesheets, so a font-host stall could kill the site's JS."*
|
| 94 |
+
> (`HANDOFF_NEXT_AGENT.md` §4.1, session workspace)
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## 2. The eleven pages
|
| 99 |
+
|
| 100 |
+
The site is **eleven** deployable HTML files: ten content pages plus a real 404 page.
|
| 101 |
+
|
| 102 |
+
| # | File | `<title>` | Role |
|
| 103 |
+
|---|---|---|---|
|
| 104 |
+
| 1 | `index.html` | `SATQUERY — Ask the Earth a question.` | the landing page; hosts the self-hosted launch film |
|
| 105 |
+
| 2 | `mission.html` | `SATQUERY — Analyze` | **the Analyze console** — the only page that calls the API |
|
| 106 |
+
| 3 | `architecture.html` | `SATQUERY — Architecture` | the architecture walk-through; carries its own sample query |
|
| 107 |
+
| 4 | `atlas.html` | `SATQUERY — Earth Query Atlas` | the query atlas |
|
| 108 |
+
| 5 | `benchmark.html` | `SATQUERY — Benchmark Lab` | per-specialist metrics with honest status labels |
|
| 109 |
+
| 6 | `research.html` | `SATQUERY — Research Ledger` | research entries traced to real artifacts/limitations |
|
| 110 |
+
| 7 | `journey.html` | `SATQUERY — The Lab` | build history by phase |
|
| 111 |
+
| 8 | `run.html` | `SATQUERY — Anatomy of a Run` | a real captured `ResultEnvelope`, rendered |
|
| 112 |
+
| 9 | `video.html` | `SATQUERY — Film archive` | the launch film |
|
| 113 |
+
| 10 | `references.html` | `SATQUERY — References` | provenance and credits |
|
| 114 |
+
| 11 | `404.html` | `SATQUERY — Not found` | a real 404 in the site's design language |
|
| 115 |
+
|
| 116 |
+
The 404 page was added deliberately and is counted:
|
| 117 |
+
|
| 118 |
+
> *"`frontend/404.html` | Real 404 page in the existing design language (light/warm/ochre, one accent,
|
| 119 |
+
> no rounded cards). Auto-discovered by the staging seed list, so its references are walked — **11
|
| 120 |
+
> deployable pages now**."* (`docs/DEPLOYMENT_DECISION.md` §6)
|
| 121 |
+
|
| 122 |
+
### 2.1 The header, shared by all eleven
|
| 123 |
+
|
| 124 |
+
Every page carries the same `<nav>` fragment, with the GitHub and Hugging Face links last:
|
| 125 |
+
|
| 126 |
+
```html
|
| 127 |
+
<a class="navlink navlink--ext" href="https://github.com/Anish-lab-blip/SatQuery-AI" target="_blank" rel="noopener" title="SatQuery source repository on GitHub">GitHub</a>
|
| 128 |
+
<a class="navlink navlink--ext" href="https://huggingface.co/thundercode/SatQuery" target="_blank" rel="noopener" title="SatQuery model on Hugging Face">Hugging Face</a>
|
| 129 |
+
```
|
| 130 |
+
(`frontend/mission.html:38-39`)
|
| 131 |
+
|
| 132 |
+
The **GitHub** target is the only **public** repository, and that is why it is the one linked:
|
| 133 |
+
|
| 134 |
+
> *"Target: `https://github.com/Anish-lab-blip/SatQuery-AI` — the ONLY **public** repo (Frontend/
|
| 135 |
+
> Backend/Inference are private → their links would 404 for the audience)."*
|
| 136 |
+
> (`docs/FINAL_DELIVERY_TODO.md` §4, P9-T01)
|
| 137 |
+
|
| 138 |
+
The **Hugging Face** link is B-01, and its history is worth recording because it shows the blocker
|
| 139 |
+
lifecycle:
|
| 140 |
+
|
| 141 |
+
| Stage | Status |
|
| 142 |
+
|---|---|
|
| 143 |
+
| earlier | *"`B-01` | HF page + token not created | No HF header link; no HF doc push | Owner → P9-T02 | link to a pinned model page in the interim | **BLOCKED**" |
|
| 144 |
+
| 2026-09-25 | *"**CLOSED** 2026-09-25 — owner supplied `https://huggingface.co/thundercode/SatQuery`; link added to all 11 pages"* (`docs/FINAL_DELIVERY_TODO.md` §5) |
|
| 145 |
+
|
| 146 |
+
The verification is a live DOM query, not a file grep:
|
| 147 |
+
|
| 148 |
+
> *"`E-13` | B-01 | live DOM query for the HF anchor |
|
| 149 |
+
> `a[href*="huggingface.co/thundercode/SatQuery"]` present on the deployed site | VERIFIED"*
|
| 150 |
+
> (`docs/FINAL_DELIVERY_TODO.md` §6)
|
| 151 |
+
|
| 152 |
+
**Measured, in the working tree:** all eleven files carry exactly one occurrence of each link.
|
| 153 |
+
|
| 154 |
+
| Link | Files carrying it | Occurrences per file |
|
| 155 |
+
|---|---|---|
|
| 156 |
+
| `https://huggingface.co/thundercode/SatQuery` | 11 / 11 | 1 |
|
| 157 |
+
| `https://github.com/Anish-lab-blip/SatQuery-AI` | 11 / 11 | 1 |
|
| 158 |
+
|
| 159 |
+
(measured by grepping `frontend/*.html`)
|
| 160 |
+
|
| 161 |
+
The link is styled by a class added with it, so the external-link affordance is part of the same
|
| 162 |
+
change:
|
| 163 |
+
|
| 164 |
+
> *"Files: all 11 `frontend/*.html` header `<nav>`, reusing the existing `.navlink--ext` pattern."*
|
| 165 |
+
> (`docs/FINAL_DELIVERY_TODO.md` §4, P9-T02)
|
| 166 |
+
|
| 167 |
+
### 2.2 The deployed revision
|
| 168 |
+
|
| 169 |
+
| Repo | Role | Deployed HEAD |
|
| 170 |
+
|---|---|---|
|
| 171 |
+
| `Anish-lab-blip/SatQuery-Frontend` | Cloudflare Pages (static) | `2d7ae53b482d` |
|
| 172 |
+
|
| 173 |
+
(`docs/FINAL_DELIVERY_TODO.md` §6 E-10; `DELIVERY_REPORT_2026-09-25.md` §1)
|
| 174 |
+
|
| 175 |
+
and the three commits that produced it:
|
| 176 |
+
|
| 177 |
+
| Commit | Subject | Files |
|
| 178 |
+
|---|---|---|
|
| 179 |
+
| `ff46eba42b18` | correct the lexical-router misroute; same-shape change demo pair; measured calibration curve; HF header link | 17 |
|
| 180 |
+
| `d413d3672311` | give the change-demo pair new URLs; drop the ineffective cache carve-out | 8 (+5, −2) |
|
| 181 |
+
| `2d7ae53b482d` | sibling `SQ.policy` misroute; state which calibration diagram is plotted | 2 |
|
| 182 |
+
|
| 183 |
+
(`DELIVERY_REPORT_2026-09-25.md` §1)
|
| 184 |
+
|
| 185 |
+
> **The deployed repo's root *is* the local `frontend/` directory.** *"`Anish-lab-blip/SatQuery-Frontend`
|
| 186 |
+
> — Cloudflare Pages; **repo root == local `frontend/`**"* (session `HANDOFF_NEXT_AGENT.md` §2). There
|
| 187 |
+
> is no build step in the deployed repo; the staging script is a local packaging convenience, not a
|
| 188 |
+
> CI pipeline.
|
| 189 |
+
|
| 190 |
+
---
|
| 191 |
+
|
| 192 |
+
## 3. The staging pipeline — `scripts/stage_pages.mjs`
|
| 193 |
+
|
| 194 |
+
Cloudflare Pages has **no `.assetsignore`**, so the deployable tree must be curated. That is what the
|
| 195 |
+
staging script is for.
|
| 196 |
+
|
| 197 |
+
> *"Cloudflare Pages constraints that already bit us: per-file limit is **25 MiB** (26,214,400 B);
|
| 198 |
+
> Pages has **no `.assetsignore`**, so you must stage a curated directory (hence `stage_pages.mjs`)."*
|
| 199 |
+
> (`HANDOFF_NEXT_AGENT.md` §4.3, session workspace)
|
| 200 |
+
|
| 201 |
+
### 3.1 It is reference-driven, not a hardcoded list
|
| 202 |
+
|
| 203 |
+
```js
|
| 204 |
+
/**
|
| 205 |
+
* stage_pages.mjs — build a Cloudflare-Pages-deployable staging tree for the
|
| 206 |
+
* SatQuery frontend by walking the ACTUAL asset references of the deployable
|
| 207 |
+
* HTML pages (reference-driven closure), rather than a hardcoded file list.
|
| 208 |
+
*
|
| 209 |
+
* Why reference-driven: a font set under assets/fonts/ and a regenerated film
|
| 210 |
+
* encode are both landing. A hardcoded list would silently omit them; this
|
| 211 |
+
* walks each page's src/href/poster, then each CSS @import/url(), then each JS
|
| 212 |
+
* import/export-from/dynamic-import, and copies the transitive closure.
|
| 213 |
+
*/
|
| 214 |
+
```
|
| 215 |
+
(`scripts/stage_pages.mjs:1-10`)
|
| 216 |
+
|
| 217 |
+
The limit is a constant in the script:
|
| 218 |
+
|
| 219 |
+
```js
|
| 220 |
+
const PAGES_FILE_LIMIT = 26214400; // 25 MiB (Cloudflare Pages hard limit)
|
| 221 |
+
const BIG_WARN_BYTES = 10485760; // 10 MiB (informational)
|
| 222 |
+
```
|
| 223 |
+
(`scripts/stage_pages.mjs:43-44`)
|
| 224 |
+
|
| 225 |
+
### 3.2 Options and exit codes
|
| 226 |
+
|
| 227 |
+
| Flag | Meaning |
|
| 228 |
+
|---|---|
|
| 229 |
+
| `--frontend=<dir>` | Source frontend dir (default `<repo>/frontend`) |
|
| 230 |
+
| `--out=<dir>` | Staging dir (default `<repo>/.deploy/pages`) |
|
| 231 |
+
| `--launch-src=<path>` | Rewrite the homepage launch-film `<video src>` **in the staged copy only** |
|
| 232 |
+
| `--include=<file>` | Force-add a file no page references (repeatable) |
|
| 233 |
+
| `--no-clean` | Do not wipe the staging dir before staging |
|
| 234 |
+
| `--help` / `-h` | usage |
|
| 235 |
+
|
| 236 |
+
| Exit code | Meaning |
|
| 237 |
+
|---|---|
|
| 238 |
+
| `0` | success (staged + verified) |
|
| 239 |
+
| `2` | a staged file exceeds the 25 MiB Cloudflare Pages per-file limit |
|
| 240 |
+
| `3` | a reference in the staged tree does not resolve (broken deploy) |
|
| 241 |
+
| `1` | other error |
|
| 242 |
+
|
| 243 |
+
(`scripts/stage_pages.mjs:25-32`)
|
| 244 |
+
|
| 245 |
+
> *"**SAFETY: never writes to the frontend/ source tree. Copies out only.**"*
|
| 246 |
+
> (`scripts/stage_pages.mjs:34`)
|
| 247 |
+
|
| 248 |
+
The `--include=` files are needed because the walk is strictly reference-driven:
|
| 249 |
+
|
| 250 |
+
> *"The `--include=` files are force-added because no page references them; the default is strictly
|
| 251 |
+
> reference-driven."* (`HANDOFF_NEXT_AGENT.md` §4.1, session workspace)
|
| 252 |
+
|
| 253 |
+
`_headers` and `robots.txt` are the canonical examples:
|
| 254 |
+
|
| 255 |
+
> *"`_headers` and `robots.txt` must be force-included because no page references them.
|
| 256 |
+
> `provenance.json` and `CREDITS.md` likewise — they are provenance records, not assets."*
|
| 257 |
+
> (`docs/DEPLOYMENT_DECISION.md` §7)
|
| 258 |
+
|
| 259 |
+
### 3.3 A real measured run
|
| 260 |
+
|
| 261 |
+
```bash
|
| 262 |
+
cd C:/Users/anish/satquery-ai
|
| 263 |
+
|
| 264 |
+
node scripts/stage_pages.mjs \
|
| 265 |
+
--out=.deploy/dist-final \
|
| 266 |
+
--include=_headers \
|
| 267 |
+
--include=robots.txt \
|
| 268 |
+
--include=assets/img/eo/provenance.json \
|
| 269 |
+
--include=assets/img/eo/CREDITS.md
|
| 270 |
+
|
| 271 |
+
npx wrangler pages deploy "C:/Users/anish/satquery-ai/.deploy/dist-final" --project-name <name>
|
| 272 |
+
```
|
| 273 |
+
(`docs/DEPLOYMENT_DECISION.md` §7)
|
| 274 |
+
|
| 275 |
+
**Measured result of that staging run:**
|
| 276 |
+
|
| 277 |
+
```
|
| 278 |
+
files staged : 60
|
| 279 |
+
total bytes : 39,173,936 (37.36 MiB)
|
| 280 |
+
largest file : assets/video/satquery-launch-50s.mp4 22,710,313 B (21.66 MiB)
|
| 281 |
+
25 MiB headroom left : 3,504,087 B on the largest file
|
| 282 |
+
missing refs in staged : 0
|
| 283 |
+
external network deps : 0 (HERMETIC)
|
| 284 |
+
exit : 0
|
| 285 |
+
```
|
| 286 |
+
(`docs/DEPLOYMENT_DECISION.md` §7)
|
| 287 |
+
|
| 288 |
+
An earlier run of the same script reports a slightly different total, and the difference is recorded
|
| 289 |
+
rather than reconciled:
|
| 290 |
+
|
| 291 |
+
```
|
| 292 |
+
files staged : 57
|
| 293 |
+
total bytes : 39,163,483 B (37.35 MiB)
|
| 294 |
+
largest file : 22,710,313 B (21.66 MiB)
|
| 295 |
+
missing refs : 0
|
| 296 |
+
external network deps : 0
|
| 297 |
+
exit : 0
|
| 298 |
+
```
|
| 299 |
+
(`HANDOFF_NEXT_AGENT.md` §4.1, session workspace)
|
| 300 |
+
|
| 301 |
+
> **Do not treat either number as a constant.** `HANDOFF_NEXT_AGENT.md` §8 item 10 records exactly
|
| 302 |
+
> this hazard: *"Hardcoded counts in docs drift. The RUNBOOK's archive size moved 353 → 792 → 364 →
|
| 303 |
+
> 366 as the tree changed. Re-measure rather than trusting a recorded number."* 57 and 60 files are
|
| 304 |
+
> two measurements of two trees.
|
| 305 |
+
|
| 306 |
+
### 3.4 A real bug the script had, and its fix
|
| 307 |
+
|
| 308 |
+
> *"`RE_CSS_IMPORT`'s bare-token alternative captured the prose word `of` out of a stylesheet comment
|
| 309 |
+
> (*"This replaced an @import of the Google Fonts CSS API"*) and failed the run with a phantom missing
|
| 310 |
+
> reference. Fixed at the root in `scripts/stage_pages.mjs` by adding `stripComments(ext, text)`,
|
| 311 |
+
> called at the top of `extractRefs`: CSS `/* */`, HTML `<!-- -->`, and — deliberately — **block
|
| 312 |
+
> comments only for JS**, because stripping `//` naively would truncate anything after a `//` inside a
|
| 313 |
+
> string such as `'http://www.w3.org/2000/svg'`. A reference inside a comment is never fetched, so
|
| 314 |
+
> this is correct, not a suppression."* (`HANDOFF_NEXT_AGENT.md` §4.1, session workspace)
|
| 315 |
+
|
| 316 |
+
---
|
| 317 |
+
|
| 318 |
+
## 4. The Analyze console
|
| 319 |
+
|
| 320 |
+
`mission.html` is the only page that talks to the backend. Its `<title>` is `SATQUERY — Analyze`, and
|
| 321 |
+
its structure is a scientific instrument, not a chat window:
|
| 322 |
+
|
| 323 |
+
| Element | `id` | Role |
|
| 324 |
+
|---|---|---|
|
| 325 |
+
| query box | `qtext` | *"What changed here?"* by default (`mission.html:51`) |
|
| 326 |
+
| run button | `btnRun` | *"Run query"* (`mission.html:53`) |
|
| 327 |
+
| observation block | `obsTail` | `none` → `ready` when a file is selected (`mission.html:67`) |
|
| 328 |
+
| primary file input | `fileInput` | hidden; flipped by the drop zone (`mission.html:72`) |
|
| 329 |
+
| second file input | `fileInputT0` | the T0 frame for pair tasks (`mission.html:120`) |
|
| 330 |
+
| intent panel | `intentHost` | the router's reading, as chips (`mission.html:102`) |
|
| 331 |
+
| viewer state | `viewerState` | *"Illustrative frame"* → *"Your upload"* → *"Your upload · analysed"* (`mission.html:141`) |
|
| 332 |
+
| plate | `plateImg` | the user's own image (`mission.html:146`) |
|
| 333 |
+
| evidence SVG layer | `ev` | region overlay (`mission.html:155`) |
|
| 334 |
+
| comparison view | `cmpWrap`, `cmpT0`, `cmpT1`, `cmpRange`, `cmpCredit`, `cmpEmpty` | the T0/T1 wipe (`mission.html:171-185`) |
|
| 335 |
+
| answer | `answerHost` | the server's string, verbatim (`mission.html:198`) |
|
| 336 |
+
| evidence list | `evHost` | the server's `Evidence` records (`mission.html:211`) |
|
| 337 |
+
| confidence | `confC` | `—` until a real result arrives (`mission.html:226`) |
|
| 338 |
+
| provenance | `pRun`, `pPolicy`, `pProtocol`, `pSchema` | `awaiting backend` until a real result (`mission.html:239`) |
|
| 339 |
+
| trace bar | `trace` | the 9-state spine (`mission.html:268`) |
|
| 340 |
+
| event drawer | `drawer`, `evlog` | the raw event log (`mission.html:280-286`) |
|
| 341 |
+
|
| 342 |
+
### 4.1 The design law the console must obey
|
| 343 |
+
|
| 344 |
+
> *"**Frontend design law:** light/warm/ochre, ONE accent = ochre `#A5662E`. **NO rounded-rectangle
|
| 345 |
+
> card aesthetic** — the target is a *scientific instrument*, not an "AI dashboard". No
|
| 346 |
+
> glassmorphism, no drop-shadow-as-elevation, no map tiles or map providers. The retired
|
| 347 |
+
> graphite/dark tokens are forbidden."* (`HANDOFF_NEXT_AGENT.md` §7, repo copy)
|
| 348 |
+
|
| 349 |
+
### 4.2 The viewer modes
|
| 350 |
+
|
| 351 |
+
The plan (§51) lists viewer tabs `Original`, `Evidence`, `Grounding`, `Change`, `Optical`, `SAR`,
|
| 352 |
+
`Fusion`. The shipped console implements a **four-mode** viewer, driven by `setMode()`:
|
| 353 |
+
|
| 354 |
+
```js
|
| 355 |
+
function setMode(m) {
|
| 356 |
+
mode = m;
|
| 357 |
+
var showCmp = (m === 'comparison');
|
| 358 |
+
cmpWrap.hidden = !showCmp;
|
| 359 |
+
plate.style.visibility = showCmp ? 'hidden' : 'visible';
|
| 360 |
+
evNote.hidden = !(m === 'evidence' || m === 'masked');
|
| 361 |
+
|
| 362 |
+
if (m === 'evidence') { evNoteText.textContent = 'Awaiting backend — no region, mask or change map has been returned.'; viewerState.textContent = 'Evidence · none'; }
|
| 363 |
+
else if (m === 'masked') { evNoteText.textContent = 'Awaiting backend — no availability or change mask has been returned.'; viewerState.textContent = 'Masked · none'; }
|
| 364 |
+
else if (m === 'comparison') {
|
| 365 |
+
if (cmpWrap.dataset.ready === '1') { viewerState.textContent = 'Comparison'; cmpEmpty.hidden = true; }
|
| 366 |
+
else { viewerState.textContent = 'Comparison · needs pair'; cmpEmpty.hidden = false; }
|
| 367 |
+
} else { viewerState.textContent = plateImg.dataset.uploaded ? (plateImg.dataset.analysed ? 'Your upload · analysed' : 'Your upload') : 'Illustrative frame'; }
|
| 368 |
+
...
|
| 369 |
+
}
|
| 370 |
+
```
|
| 371 |
+
(`frontend/assets/js/mission.js:840-857`)
|
| 372 |
+
|
| 373 |
+
> **The plan's seven tabs are not the shipped four modes.** The modes are `original` (the default),
|
| 374 |
+
> `evidence`, `masked` and `comparison` — visible in the `if/else` chain above. `Grounding`, `Change`,
|
| 375 |
+
> `Optical`, `SAR` and `Fusion` are **not** separate viewer modes in the shipped console; region and
|
| 376 |
+
> mask output is rendered through the `ev` overlay on the `evidence`/`masked` modes, and the
|
| 377 |
+
> optical/SAR pair is rendered as the `comparison` wipe. This is a divergence between the plan's GUI
|
| 378 |
+
> sketch and the built page, and it is recorded rather than papered over.
|
| 379 |
+
|
| 380 |
+
The empty-state wording is itself a disclosure, not a placeholder: *"Awaiting backend — no region,
|
| 381 |
+
mask or change map has been returned."*
|
| 382 |
+
|
| 383 |
+
### 4.3 The confidence panel refuses to invent a number
|
| 384 |
+
|
| 385 |
+
```js
|
| 386 |
+
confC.textContent = '—';
|
| 387 |
+
confNote.textContent = 'Calibrated confidence is reported only with a real result. Until then it reads “—” — not a placeholder number.';
|
| 388 |
+
```
|
| 389 |
+
(`frontend/assets/js/mission.js:452`, `:457`)
|
| 390 |
+
|
| 391 |
+
---
|
| 392 |
+
|
| 393 |
+
## 5. REAL versus PREVIEW — the two drivers, one event seam
|
| 394 |
+
|
| 395 |
+
`mission.js` opens with the distinction as the file's governing design:
|
| 396 |
+
|
| 397 |
+
```js
|
| 398 |
+
/* =============================================================================
|
| 399 |
+
SATQUERY — ANALYZE (mission)
|
| 400 |
+
Drives the page from the production event seam: SQ.run().ingest(type, payload).
|
| 401 |
+
|
| 402 |
+
TWO DRIVERS, ONE EVENT SEAM
|
| 403 |
+
---------------------------
|
| 404 |
+
* LIVE (default when files are chosen): the browser uploads the user's own
|
| 405 |
+
imagery to `POST /api/assets`, receives asset IDs, posts them to
|
| 406 |
+
`POST /api/infer`, and feeds the REAL result into the same eight events.
|
| 407 |
+
Every value on screen then traces to the server's own response.
|
| 408 |
+
* PREVIEW (no files chosen): the deterministic router still runs so the
|
| 409 |
+
instrument is legible, but the specialist/result stages stay honestly empty
|
| 410 |
+
("awaiting backend") instead of pretending an analysis happened.
|
| 411 |
+
|
| 412 |
+
What is NEVER done in either mode: fabricating an answer, a confidence value,
|
| 413 |
+
an evidence record, a run id or a coordinate. If the backend is unreachable
|
| 414 |
+
the page says which step failed and shows the server's own message.
|
| 415 |
+
============================================================================= */
|
| 416 |
+
```
|
| 417 |
+
(`frontend/assets/js/mission.js:1-18`)
|
| 418 |
+
|
| 419 |
+
### 5.1 The switch is the presence of a selected file
|
| 420 |
+
|
| 421 |
+
```js
|
| 422 |
+
function runQuery() {
|
| 423 |
+
var q = qtext.value.trim() || QUERY;
|
| 424 |
+
/* Live whenever the user has actually selected imagery; preview otherwise.
|
| 425 |
+
This is the whole point: the demo demonstrates the intended workflow, and
|
| 426 |
+
a fixture is never silently substituted for a real upload. */
|
| 427 |
+
if (selectedT1) runLive(q);
|
| 428 |
+
else runMock(q);
|
| 429 |
+
}
|
| 430 |
+
```
|
| 431 |
+
(`frontend/assets/js/mission.js:799-806`)
|
| 432 |
+
|
| 433 |
+
| Driver | Trigger | `liveRun` | Label shown |
|
| 434 |
+
|---|---|---|---|
|
| 435 |
+
| `runLive` | a file is selected (`selectedT1` truthy) | `true` | `live · N evidence · transport …` |
|
| 436 |
+
| `runMock` | no file selected | `false` | `preview — no files selected` |
|
| 437 |
+
|
| 438 |
+
The label is set at the top of each driver:
|
| 439 |
+
|
| 440 |
+
```js
|
| 441 |
+
traceNow.textContent = 'preview — no files selected';
|
| 442 |
+
```
|
| 443 |
+
(`frontend/assets/js/mission.js:563`)
|
| 444 |
+
|
| 445 |
+
```js
|
| 446 |
+
traceNow.textContent = 'live · ' + (ev.length) + ' evidence · transport ' + (out.transport || 'direct');
|
| 447 |
+
```
|
| 448 |
+
(`frontend/assets/js/mission.js:752`)
|
| 449 |
+
|
| 450 |
+
### 5.2 What the preview does and does not emit — a correction to the common summary
|
| 451 |
+
|
| 452 |
+
The preview path is often summarised as "it emits no specialist events". **The shipped code emits all
|
| 453 |
+
eight event names in both modes.** What the preview withholds is the *content*, not the event:
|
| 454 |
+
|
| 455 |
+
```js
|
| 456 |
+
/* ----------------------------------------------------------- mock driver --
|
| 457 |
+
PREVIEW ONLY — runs when no file has been chosen. Emits the eight
|
| 458 |
+
production events through the seam with EMPTY payloads. RECEIVE/PARSE/PLAN
|
| 459 |
+
carry the honest interpretation; the specialist + result stages carry
|
| 460 |
+
nothing fabricated. */
|
| 461 |
+
```
|
| 462 |
+
(`frontend/assets/js/mission.js:550-554`)
|
| 463 |
+
|
| 464 |
+
The preview's specialist events carry a component **name** and no measurement:
|
| 465 |
+
|
| 466 |
+
```js
|
| 467 |
+
specialists.forEach(function (name, i) {
|
| 468 |
+
at(cursor, function () { engine.ingest('SPECIALIST_STARTED', { component: name, index: i + 1, of: specialists.length, stage: 'EXECUTE' }); });
|
| 469 |
+
cursor += 260;
|
| 470 |
+
at(cursor, function () { engine.ingest('SPECIALIST_COMPLETED', { component: name }); });
|
| 471 |
+
cursor += 120;
|
| 472 |
+
});
|
| 473 |
+
```
|
| 474 |
+
(`frontend/assets/js/mission.js:573-578`)
|
| 475 |
+
|
| 476 |
+
and the result stages carry explicit emptiness:
|
| 477 |
+
|
| 478 |
+
```js
|
| 479 |
+
at(cursor + 200, function () {
|
| 480 |
+
engine.ingest('EVIDENCE_GENERATED', { regions: [], count: 0, note: 'no specialist output connected' });
|
| 481 |
+
});
|
| 482 |
+
at(cursor + 460, function () {
|
| 483 |
+
engine.ingest('CONFIDENCE_COMPUTED', { degraded: true, degradation_reason: 'No result produced — no calibrated confidence.' });
|
| 484 |
+
});
|
| 485 |
+
at(cursor + 700, function () {
|
| 486 |
+
engine.ingest('RESULT_ASSEMBLED', { text: null, task: intent.task, evidence_ids: [], confidence: null, provenance: null });
|
| 487 |
+
});
|
| 488 |
+
```
|
| 489 |
+
(`frontend/assets/js/mission.js:580-588`)
|
| 490 |
+
|
| 491 |
+
The accurate statement is therefore:
|
| 492 |
+
|
| 493 |
+
> **The preview emits all eight event names, but no specialist measurement, no evidence record, no
|
| 494 |
+
> confidence value, no answer, and no run id.** `EVIDENCE_GENERATED` carries `regions: []` and the
|
| 495 |
+
> note *"no specialist output connected"*; `CONFIDENCE_COMPUTED` carries `degraded: true` with the
|
| 496 |
+
> reason *"No result produced — no calibrated confidence."*; `RESULT_ASSEMBLED` carries
|
| 497 |
+
> `text: null`, `confidence: null`, `provenance: null`.
|
| 498 |
+
|
| 499 |
+
The style guide's rule applies here: the code is authoritative, and a summary that says "no
|
| 500 |
+
specialist events" is not what the code does. Recorded.
|
| 501 |
+
|
| 502 |
+
### 5.3 The state notes distinguish preview from live *visually*
|
| 503 |
+
|
| 504 |
+
```js
|
| 505 |
+
/* Notes for the PREVIEW driver only. When a real result arrives these are
|
| 506 |
+
overwritten by measured facts (component names, model revisions, timings). */
|
| 507 |
+
var STATE_NOTE = {
|
| 508 |
+
RECEIVE: 'received', PARSE: 'interpreted (mock router)', VALIDATE: 'validated (mock router)',
|
| 509 |
+
PLAN: 'routed (mock router)', PREPROCESS: 'awaiting backend', EXECUTE: 'awaiting backend',
|
| 510 |
+
AGGREGATE: 'awaiting backend', VERIFY: 'awaiting backend', RESPOND: 'awaiting backend'
|
| 511 |
+
};
|
| 512 |
+
```
|
| 513 |
+
(`frontend/assets/js/mission.js:367-373`)
|
| 514 |
+
|
| 515 |
+
and the `is-mock` class is the visual marker:
|
| 516 |
+
|
| 517 |
+
```js
|
| 518 |
+
/**
|
| 519 |
+
* Mark a ControllerState reached, optionally with a measured note.
|
| 520 |
+
*
|
| 521 |
+
* `is-mock` is the "driven by the preview router" styling. When a real result
|
| 522 |
+
* is being rendered the note is a measurement, so the mock class is removed
|
| 523 |
+
* instead — otherwise a genuine run would be visually indistinguishable from
|
| 524 |
+
* the preview, which is exactly the confusion this page is built to avoid.
|
| 525 |
+
*/
|
| 526 |
+
function markState(id, note, isLive) {
|
| 527 |
+
var n = traceNodes[id];
|
| 528 |
+
if (!n) return;
|
| 529 |
+
n.node.classList.toggle('is-mock', !isLive);
|
| 530 |
+
n.tm.textContent = note !== undefined ? note : (STATE_NOTE[id] || '');
|
| 531 |
+
...
|
| 532 |
+
```
|
| 533 |
+
(`frontend/assets/js/mission.js:396-408`)
|
| 534 |
+
|
| 535 |
+
In the **live** driver the notes become measurements, which is the point of the distinction:
|
| 536 |
+
|
| 537 |
+
```js
|
| 538 |
+
/* State notes become MEASUREMENTS, replacing the preview wording. */
|
| 539 |
+
markState('PREPROCESS', specialists.join(', '), true);
|
| 540 |
+
markState('EXECUTE', (trace.selected_models || []).map(function (m) { return m.name; }).join(', ') || 'executed', true);
|
| 541 |
+
markState('AGGREGATE', ev.length + ' evidence', true);
|
| 542 |
+
markState('VERIFY', conf ? (conf.method || 'uncalibrated') : 'no confidence', true);
|
| 543 |
+
markState('RESPOND', out.transport ? ('via ' + out.transport) : 'responded', true);
|
| 544 |
+
```
|
| 545 |
+
(`frontend/assets/js/mission.js:745-750`)
|
| 546 |
+
|
| 547 |
+
### 5.4 The live driver's event sequence is a record, not an animation
|
| 548 |
+
|
| 549 |
+
```js
|
| 550 |
+
/* ----------------------------------------------------------- live driver -
|
| 551 |
+
The real flow. Uploads the user's files, then runs the analysis and feeds
|
| 552 |
+
the server's own result into the same eight events.
|
| 553 |
+
|
| 554 |
+
The event sequence is emitted around the network calls rather than faked on
|
| 555 |
+
a timer: QUERY_RECEIVED/UNDERSTOOD/ROUTE_SELECTED are genuinely known before
|
| 556 |
+
the request (they are the client's own reading), SPECIALIST_STARTED is
|
| 557 |
+
emitted when the request is dispatched, and SPECIALIST_COMPLETED through
|
| 558 |
+
RESULT_ASSEMBLED are emitted from the response. So the trace's shape is a
|
| 559 |
+
real record of when the work happened, not a plausible-looking animation. */
|
| 560 |
+
```
|
| 561 |
+
(`frontend/assets/js/mission.js:591-600`)
|
| 562 |
+
|
| 563 |
+
The three pre-dispatch events are emitted before `SQ.live.run` is called; the rest are emitted inside
|
| 564 |
+
its `.then()`:
|
| 565 |
+
|
| 566 |
+
```js
|
| 567 |
+
engine.ingest('QUERY_RECEIVED', { query: query });
|
| 568 |
+
engine.ingest('QUERY_UNDERSTOOD', { intent: intent, dispatched: choice.substituted ? forced : null });
|
| 569 |
+
engine.ingest('ROUTE_SELECTED', { intent: intent, specialists: specialists, policy: 'deterministic-rule', policyVersion: 'pc-3.2.1', force_task: forced });
|
| 570 |
+
|
| 571 |
+
var started = {};
|
| 572 |
+
specialists.forEach(function (name) { started[name] = performance.now(); });
|
| 573 |
+
|
| 574 |
+
SQ.live
|
| 575 |
+
.run(filesToSend, query, { forceTask: forced })
|
| 576 |
+
.then(function (out) { ... });
|
| 577 |
+
```
|
| 578 |
+
(`frontend/assets/js/mission.js:654-668`)
|
| 579 |
+
|
| 580 |
+
> **A nuance worth stating.** `SPECIALIST_STARTED`/`COMPLETED` are emitted **from the response**, not
|
| 581 |
+
> at dispatch time — the code comment above says "SPECIALIST_STARTED is emitted when the request is
|
| 582 |
+
> dispatched", but the implementation emits both inside the `.then()` (lines 678-687), measuring
|
| 583 |
+
> `elapsed_ms` from a timestamp taken *before* the request. The timestamps are honest (they bracket
|
| 584 |
+
> the real network call); the event *ordering* is response-time. Recorded because the comment and the
|
| 585 |
+
> code differ on this one point.
|
| 586 |
+
|
| 587 |
+
### 5.5 The failure path never fills the gap
|
| 588 |
+
|
| 589 |
+
```js
|
| 590 |
+
.catch(function (err) {
|
| 591 |
+
/* HONEST FAILURE. Say which step failed, and show the server's message.
|
| 592 |
+
The specialist/result stages stay unfilled rather than being given
|
| 593 |
+
invented content, and the trace states are marked as not executed. */
|
| 594 |
+
var stage = (err && err.stage) || 'request';
|
| 595 |
+
var status = (err && err.status) ? ' (HTTP ' + err.status + ')' : '';
|
| 596 |
+
traceNow.textContent = 'failed at ' + stage + status;
|
| 597 |
+
answerHost.innerHTML = '<span class="answer__empty label">No result — the ' + stage + ' step failed</span>';
|
| 598 |
+
...
|
| 599 |
+
markState('PREPROCESS', 'not executed', false);
|
| 600 |
+
markState('EXECUTE', 'not executed', false);
|
| 601 |
+
markState('AGGREGATE', 'not executed', false);
|
| 602 |
+
markState('VERIFY', 'not executed', false);
|
| 603 |
+
markState('RESPOND', 'not executed', false);
|
| 604 |
+
```
|
| 605 |
+
(`frontend/assets/js/mission.js:773-791`)
|
| 606 |
+
|
| 607 |
+
The plate caption also refuses to keep calling the image illustrative once a real run has touched it:
|
| 608 |
+
|
| 609 |
+
```js
|
| 610 |
+
/* The plate caption must not keep calling the image illustrative once a
|
| 611 |
+
real analysis has run on it. The image is still the user's own file,
|
| 612 |
+
so the credit names the run rather than claiming the imagery is a
|
| 613 |
+
SatQuery output -- the picture is input, the FINDINGS are output.
|
| 614 |
+
BOTH halves of the caption are driven: the leading <b> is a literal in
|
| 615 |
+
the markup, and leaving it as "Illustrative" would keep asserting the
|
| 616 |
+
frame is a stand-in while showing the user's own upload. */
|
| 617 |
+
plateCreditLead.textContent = 'Your upload';
|
| 618 |
+
plateCredit.textContent = 'analysed in run ' + (env.run_id || '—');
|
| 619 |
+
```
|
| 620 |
+
(`frontend/assets/js/mission.js:754-762`)
|
| 621 |
+
|
| 622 |
+
### 5.6 The mode is disclosed in the answer's own tail
|
| 623 |
+
|
| 624 |
+
```js
|
| 625 |
+
answerHost.textContent = answerText;
|
| 626 |
+
ansTail.textContent = 'live';
|
| 627 |
+
```
|
| 628 |
+
(`frontend/assets/js/mission.js:714-715`)
|
| 629 |
+
|
| 630 |
+
and on an empty answer it says so rather than leaving the previous text:
|
| 631 |
+
|
| 632 |
+
```js
|
| 633 |
+
answerHost.innerHTML = '<span class="answer__empty label">The engine returned no answer text for this task</span>';
|
| 634 |
+
ansTail.textContent = 'empty';
|
| 635 |
+
```
|
| 636 |
+
(`frontend/assets/js/mission.js:718-719`)
|
| 637 |
+
|
| 638 |
+
---
|
| 639 |
+
|
| 640 |
+
## 6. The eight execution events, and the trace bar
|
| 641 |
+
|
| 642 |
+
### 6.1 The event names
|
| 643 |
+
|
| 644 |
+
```js
|
| 645 |
+
SQ.EVENT_NAMES = [
|
| 646 |
+
'QUERY_RECEIVED', 'QUERY_UNDERSTOOD', 'ROUTE_SELECTED',
|
| 647 |
+
'SPECIALIST_STARTED', 'SPECIALIST_COMPLETED',
|
| 648 |
+
'EVIDENCE_GENERATED', 'CONFIDENCE_COMPUTED', 'RESULT_ASSEMBLED'
|
| 649 |
+
];
|
| 650 |
+
```
|
| 651 |
+
(`frontend/assets/js/core.js:616-620`)
|
| 652 |
+
|
| 653 |
+
`core.js`'s own header states the integration seam this defines:
|
| 654 |
+
|
| 655 |
+
> *"The run engine is deliberately dumb: it renders whatever events it receives. Nothing about the
|
| 656 |
+
> visuals depends on the events being synthetic. Swapping the mock driver for a websocket / SSE feed
|
| 657 |
+
> of the same event names is the entire integration surface."* (`frontend/assets/js/core.js:8-11`)
|
| 658 |
+
|
| 659 |
+
and the seam itself:
|
| 660 |
+
|
| 661 |
+
```js
|
| 662 |
+
/**
|
| 663 |
+
* THE INTEGRATION SEAM.
|
| 664 |
+
* Feed real execution events here — same names, same payload shapes — and
|
| 665 |
+
* every visual state in the prototype updates identically.
|
| 666 |
+
*/
|
| 667 |
+
ingest: function (type, payload) {
|
| 668 |
+
```
|
| 669 |
+
(`frontend/assets/js/core.js:737-742`)
|
| 670 |
+
|
| 671 |
+
### 6.2 The event envelope
|
| 672 |
+
|
| 673 |
+
Every emitted event carries four fields:
|
| 674 |
+
|
| 675 |
+
```js
|
| 676 |
+
function emit(type, payload) {
|
| 677 |
+
var ev = {
|
| 678 |
+
type: type,
|
| 679 |
+
t: performance.now() - t0,
|
| 680 |
+
seq: state.events.length + 1,
|
| 681 |
+
payload: payload || {}
|
| 682 |
+
};
|
| 683 |
+
state.events.push(ev);
|
| 684 |
+
listeners.forEach(function (fn) { try { fn(ev, state); } catch (e) { console.error(e); } });
|
| 685 |
+
}
|
| 686 |
+
```
|
| 687 |
+
(`frontend/assets/js/core.js:709-718`)
|
| 688 |
+
|
| 689 |
+
| Field | Meaning |
|
| 690 |
+
|---|---|
|
| 691 |
+
| `type` | one of the eight names |
|
| 692 |
+
| `t` | milliseconds since `QUERY_RECEIVED` |
|
| 693 |
+
| `seq` | 1-based sequence number within the run |
|
| 694 |
+
| `payload` | the event-specific body |
|
| 695 |
+
|
| 696 |
+
The event drawer prints exactly this:
|
| 697 |
+
|
| 698 |
+
```js
|
| 699 |
+
function logEvent(ev) {
|
| 700 |
+
var line = document.createElement('div');
|
| 701 |
+
line.innerHTML = '<span class="n">' + U.pad(ev.seq, 2) + ' +' + Math.round(ev.t) + 'ms </span>' +
|
| 702 |
+
'<span class="k">' + ev.type + '</span> ' +
|
| 703 |
+
'<span>' + JSON.stringify(ev.payload) + '</span>';
|
| 704 |
+
evlog.appendChild(line);
|
| 705 |
+
evlog.scrollTop = evlog.scrollHeight;
|
| 706 |
+
}
|
| 707 |
+
```
|
| 708 |
+
(`frontend/assets/js/mission.js:428-435`)
|
| 709 |
+
|
| 710 |
+
### 6.3 The nine-state spine
|
| 711 |
+
|
| 712 |
+
The trace bar is **not** the eight events. It is a **nine-state** `ControllerState` spine, and the
|
| 713 |
+
eight events map onto it:
|
| 714 |
+
|
| 715 |
+
```js
|
| 716 |
+
var STATES = ['RECEIVE', 'PARSE', 'VALIDATE', 'PLAN', 'PREPROCESS', 'EXECUTE', 'AGGREGATE', 'VERIFY', 'RESPOND'];
|
| 717 |
+
var EVENT_TO_STATE = {
|
| 718 |
+
QUERY_RECEIVED: 'RECEIVE', QUERY_UNDERSTOOD: 'PARSE', ROUTE_SELECTED: 'PLAN',
|
| 719 |
+
SPECIALIST_STARTED: 'PREPROCESS', SPECIALIST_COMPLETED: 'EXECUTE',
|
| 720 |
+
EVIDENCE_GENERATED: 'AGGREGATE', CONFIDENCE_COMPUTED: 'VERIFY', RESULT_ASSEMBLED: 'RESPOND'
|
| 721 |
+
};
|
| 722 |
+
```
|
| 723 |
+
(`frontend/assets/js/mission.js:361-366`)
|
| 724 |
+
|
| 725 |
+
| Event | State(s) marked |
|
| 726 |
+
|---|---|
|
| 727 |
+
| `QUERY_RECEIVED` | `RECEIVE` |
|
| 728 |
+
| `QUERY_UNDERSTOOD` | `PARSE` **and** `VALIDATE` |
|
| 729 |
+
| `ROUTE_SELECTED` | `PLAN` |
|
| 730 |
+
| `SPECIALIST_STARTED` | `PREPROCESS` |
|
| 731 |
+
| `SPECIALIST_COMPLETED` | `EXECUTE` |
|
| 732 |
+
| `EVIDENCE_GENERATED` | `AGGREGATE` |
|
| 733 |
+
| `CONFIDENCE_COMPUTED` | `VERIFY` |
|
| 734 |
+
| `RESULT_ASSEMBLED` | `RESPOND` |
|
| 735 |
+
|
| 736 |
+
The subscription is the mechanism:
|
| 737 |
+
|
| 738 |
+
```js
|
| 739 |
+
case 'QUERY_UNDERSTOOD':
|
| 740 |
+
renderIntent(ev.payload.intent, ev.payload.dispatched || null);
|
| 741 |
+
intentTail.textContent = ev.payload.dispatched ? ('routed as ' + ev.payload.dispatched) : 'resolved';
|
| 742 |
+
markState('PARSE', undefined, liveRun); markState('VALIDATE', undefined, liveRun);
|
| 743 |
+
traceNow.textContent = 'interpreting';
|
| 744 |
+
break;
|
| 745 |
+
```
|
| 746 |
+
(`frontend/assets/js/mission.js:511-516`)
|
| 747 |
+
|
| 748 |
+
`VALIDATE` has **no event of its own** and is marked together with `PARSE`. The `ControllerState` enum
|
| 749 |
+
in `core/schemas.py:79-89` defines all nine; the frontend's `STATES` array is a literal transcription
|
| 750 |
+
of it.
|
| 751 |
+
|
| 752 |
+
### 6.4 The fill formula, and the measured 94.4444 %
|
| 753 |
+
|
| 754 |
+
The bar's width is a pure function of the furthest state reached:
|
| 755 |
+
|
| 756 |
+
```js
|
| 757 |
+
var idx = STATES.indexOf(id);
|
| 758 |
+
if (idx > traceProgress) traceProgress = idx;
|
| 759 |
+
STATES.forEach(function (s, k) {
|
| 760 |
+
var node = traceNodes[s].node;
|
| 761 |
+
node.classList.toggle('is-done', k < traceProgress);
|
| 762 |
+
node.classList.toggle('is-active', k === traceProgress);
|
| 763 |
+
node.classList.toggle('is-idle', k > traceProgress);
|
| 764 |
+
});
|
| 765 |
+
if (traceFill) {
|
| 766 |
+
traceFill.style.width = (((traceProgress + 0.5) / STATES.length) * 100) + '%';
|
| 767 |
+
}
|
| 768 |
+
```
|
| 769 |
+
(`frontend/assets/js/mission.js:414-424`)
|
| 770 |
+
|
| 771 |
+
The comment states the intent:
|
| 772 |
+
|
| 773 |
+
> *"Advance the trace to the furthest state reached. This is driven by the same events that carry the
|
| 774 |
+
> real result, so the bar and the node states move only when the run actually reaches a stage — never
|
| 775 |
+
> on a timer. The fill spans from the left edge to the centre of the current node."*
|
| 776 |
+
> (`frontend/assets/js/mission.js:410-413`)
|
| 777 |
+
|
| 778 |
+
**The arithmetic, worked:**
|
| 779 |
+
|
| 780 |
+
```
|
| 781 |
+
STATES.length = 9
|
| 782 |
+
final traceProgress = 8 (RESPOND is the last index, and it is reached)
|
| 783 |
+
fill = ((8 + 0.5) / 9) * 100
|
| 784 |
+
= (8.5 / 9) * 100
|
| 785 |
+
= 94.4444444…%
|
| 786 |
+
```
|
| 787 |
+
|
| 788 |
+
So **94.4444 % is the fully-complete trace bar**, not a partial one: it is 8.5/9, and the missing
|
| 789 |
+
5.5556 % is the half-node at the right edge that the "centre of the current node" rule deliberately
|
| 790 |
+
leaves unfilled.
|
| 791 |
+
|
| 792 |
+
**Measured, live, 2026-09-25:**
|
| 793 |
+
|
| 794 |
+
> *"| Execution trace (progress bar) | **VERIFIED** | `.trace__fill` width is set from real event
|
| 795 |
+
> count (measured 94.4444% live, 2026-09-25) |"* (`docs/FINAL_DELIVERY_TODO.md` §1.4)
|
| 796 |
+
|
| 797 |
+
and confirmed across all three live passes:
|
| 798 |
+
|
| 799 |
+
> *"Common to all twenty-four: a real `run_*` id, `mock_nodes = 0`, live trace bar at 94.4444%, the HF
|
| 800 |
+
> link present in the DOM, and every `/api/*` call addressed to
|
| 801 |
+
> `satquery-backend-m4yv.onrender.com` (`capabilities` → `assets` → `infer`; two `assets` calls for
|
| 802 |
+
> the pair tasks)."* (`DELIVERY_REPORT_2026-09-25.md` §3)
|
| 803 |
+
|
| 804 |
+
The implementation note records that the bar was *fixed* to reach this:
|
| 805 |
+
|
| 806 |
+
> *"`markState` now sets `traceFill.style.width` from the furthest state reached and toggles
|
| 807 |
+
> `is-done`/`is-active`/`is-idle`; `resetUI` resets both. CSS classes already existed
|
| 808 |
+
> (`system.css:654-660`)."* (`docs/FINAL_DELIVERY_TODO.md` §4, P5-T02)
|
| 809 |
+
|
| 810 |
+
and the reset clears it:
|
| 811 |
+
|
| 812 |
+
```js
|
| 813 |
+
traceProgress = -1;
|
| 814 |
+
if (traceFill) traceFill.style.width = '0';
|
| 815 |
+
```
|
| 816 |
+
(`frontend/assets/js/mission.js:440-441`)
|
| 817 |
+
|
| 818 |
+
> **94.4444 % is an artifact metric, not a system-level claim.** It measures one CSS width in one
|
| 819 |
+
> page. It says nothing about the pipeline's own progress reporting — the server returns a completed
|
| 820 |
+
> `ResultEnvelope` with no streaming, so the bar reflects the *client's* event timeline, which is
|
| 821 |
+
> itself reconstructed from a single request/response pair.
|
| 822 |
+
|
| 823 |
+
### 6.5 The bar is not a determinate progress bar, and the docs say so
|
| 824 |
+
|
| 825 |
+
> *"A determinate-looking progress bar would lie. Use an indeterminate state with a 'this can take up
|
| 826 |
+
> to a minute' hint."* (`docs/FRONTEND_INTEGRATION.md` §6)
|
| 827 |
+
|
| 828 |
+
The trace bar is a **stage** indicator — which states have been reached — not a percentage of elapsed
|
| 829 |
+
time. The 94.4444 % figure is the completed state of that stage indicator, and reading it as "94 % of
|
| 830 |
+
the work is done" would be a misreading the code does not invite.
|
| 831 |
+
|
| 832 |
+
### 6.6 The deterministic policy the preview uses
|
| 833 |
+
|
| 834 |
+
The preview's routing is a **rule list**, not the learned router, and the page says so in the intent
|
| 835 |
+
panel (`source`, `policyVersion: 'pc-3.2.1'`, `basis: 'rule match, no learned router'`).
|
| 836 |
+
|
| 837 |
+
```js
|
| 838 |
+
/* --- deterministic policy: mirrors the intended controller, rules only --- */
|
| 839 |
+
SQ.policy = function (query) {
|
| 840 |
+
var q = (query || '').toLowerCase();
|
| 841 |
+
var rules = [];
|
| 842 |
+
function hit(re, name) { var m = re.test(q); rules.push({ rule: name, fired: m }); return m; }
|
| 843 |
+
...
|
| 844 |
+
```
|
| 845 |
+
(`frontend/assets/js/core.js:622-626`)
|
| 846 |
+
|
| 847 |
+
The B-08 defect lived here and in `mission.js`, and its fix is worth recording because it is the
|
| 848 |
+
clearest example of a *lexical* router failing in a way that produced a wrong answer rather than an
|
| 849 |
+
error:
|
| 850 |
+
|
| 851 |
+
> *"**Sibling defect**: `core.js` `SQ.policy` (the architecture page's mock router) still carried
|
| 852 |
+
> `built` in its change regex, so that page's **own shipped sample** — *"Where is the built-up
|
| 853 |
+
> area?"* — fired `intent.change` on `built` and `intent.quantify` on `area`, and was answered as
|
| 854 |
+
> `CHANGE_VQA` with a change-detector specialist. Same defect as `mission.js`, on a second surface.
|
| 855 |
+
> `where` is now evaluated first, `built` removed, and `new` counts only outside a `where` question;
|
| 856 |
+
> a small `record()` helper keeps the rule list's display order. Regression tests drive the
|
| 857 |
+
> **shipped** `SQ.policy` — **4 red before the fix, 6 green after**."*
|
| 858 |
+
> (`DELIVERY_REPORT_2026-09-25.md` §1.3)
|
| 859 |
+
|
| 860 |
+
The fixed rule order is visible in the code, with the reason in a comment:
|
| 861 |
+
|
| 862 |
+
```js
|
| 863 |
+
/* `where` is evaluated FIRST because the change rule below depends on it: a
|
| 864 |
+
word that reads as "change" only OUTSIDE a location question must not turn
|
| 865 |
+
a `where` question into a change request. */
|
| 866 |
+
var where = /where|locate|position|which part|bound|outline|coordinate/.test(q);
|
| 867 |
+
|
| 868 |
+
var sar = hit(/\bsar\b|radar|backscatter|sentinel-1|insar/, 'sensor.sar');
|
| 869 |
+
/* `built` was removed and `new` counts only outside a `where` question.
|
| 870 |
+
"Where is the built-up area?" -- the architecture page's OWN sample --
|
| 871 |
+
previously fired intent.change on "built", then intent.quantify on "area",
|
| 872 |
+
and was answered as CHANGE_VQA with a CHANGE_DETECTOR specialist: a
|
| 873 |
+
location question routed to a change question. */
|
| 874 |
+
var changeStem = /chang|differ|expand|grow|encroach|lost|removed/.test(q);
|
| 875 |
+
var newAsChange = /\bnew\b/.test(q) && !where;
|
| 876 |
+
var change = record('intent.change', changeStem || newAsChange);
|
| 877 |
+
```
|
| 878 |
+
(`frontend/assets/js/core.js:631-645`)
|
| 879 |
+
|
| 880 |
+
**B-08 is `CLOSED`**, with two documented residuals:
|
| 881 |
+
|
| 882 |
+
> *"Residuals: "What is the new runway?" still reads `change` (non-`where` + `new`); "How much
|
| 883 |
+
> built-up area was added?" now reads `vqa` (under-trigger) — both documented."*
|
| 884 |
+
> (`docs/FINAL_DELIVERY_TODO.md` §5, B-08)
|
| 885 |
+
|
| 886 |
+
### 6.7 The answer bank is prototype text, and it is labelled
|
| 887 |
+
|
| 888 |
+
`SQ.ANSWER_BANK` holds per-task sentence templates for the **preview** path only:
|
| 889 |
+
|
| 890 |
+
```js
|
| 891 |
+
SQ.ANSWER_BANK = {
|
| 892 |
+
CHANGE_ANALYSIS: 'Significant change detected. {n} coherent regions totalling {area}; dominant transition is {dominant}. Registration residual {reg} px — the pair is usable for pixel comparison.',
|
| 893 |
+
...
|
| 894 |
+
};
|
| 895 |
+
```
|
| 896 |
+
(`frontend/assets/js/core.js:677-684`)
|
| 897 |
+
|
| 898 |
+
They are filled from `SQ.scene()`, whose own comment is the disclosure:
|
| 899 |
+
|
| 900 |
+
```js
|
| 901 |
+
/* SYNTHETIC DEMO CONSTANTS — the geo / temporal / metric fields below are
|
| 902 |
+
illustrative placeholders, not real observations. They exist only so the
|
| 903 |
+
prototype renders a populated instrument; the platform string above
|
| 904 |
+
already marks the plate as PROTOTYPE SYNTHETIC. Any page that displays
|
| 905 |
+
these values MUST disclose that they are synthetic (see the `synthetic`
|
| 906 |
+
flag below) and MUST NOT present them as measured satellite data.
|
| 907 |
+
core.js is shared across pages and is intentionally NOT removed here —
|
| 908 |
+
only labelled. If a live backend ever supplies a real scene, it should
|
| 909 |
+
set synthetic:false and override these fields. */
|
| 910 |
+
return {
|
| 911 |
+
seed: seed,
|
| 912 |
+
biome: biome,
|
| 913 |
+
regions: regions,
|
| 914 |
+
synthetic: true, // every field below is a demo placeholder
|
| 915 |
+
gsd: 10, // metres per pixel (placeholder)
|
| 916 |
+
aoi: { lat: 31.204, lon: 72.816 }, // placeholder coordinate
|
| 917 |
+
dates: { t0: '2024-03-14', t1: '2025-03-19' }, // placeholder epochs
|
| 918 |
+
sensor: 'OPTICAL / MSI',
|
| 919 |
+
platform: 'SENTINEL-2 · L2A (PROTOTYPE SYNTHETIC)',
|
| 920 |
+
registrationRMSE: 0.42 // placeholder residual
|
| 921 |
+
};
|
| 922 |
+
```
|
| 923 |
+
(`frontend/assets/js/core.js:268-288`)
|
| 924 |
+
|
| 925 |
+
> **`SQ.ANSWER_BANK` and `SQ.scene()` are preview-only.** The live driver never reads them: it renders
|
| 926 |
+
> `result.answer` verbatim (`mission.js:712-714`). The `synthetic: true` flag and the `platform`
|
| 927 |
+
> string exist so a reader of the *preview* cannot mistake it for a measurement. This is the design
|
| 928 |
+
> the style guide's "never upgrade a status" rule requires, implemented in the data itself.
|
| 929 |
+
|
| 930 |
+
---
|
| 931 |
+
|
| 932 |
+
## 7. The live client — `frontend/assets/js/live.js`
|
| 933 |
+
|
| 934 |
+
### 7.1 The endpoints it calls
|
| 935 |
+
|
| 936 |
+
```js
|
| 937 |
+
/*: The orchestrator's proxied routes (deploy/render/main.py). These are NOT
|
| 938 |
+
the Space's own `/v1/*` routes -- the browser never talks to the Space
|
| 939 |
+
directly; the orchestrator is the only public door. */
|
| 940 |
+
SQ.ENDPOINTS = {
|
| 941 |
+
assets: '/assets',
|
| 942 |
+
infer: '/infer',
|
| 943 |
+
capabilities: '/capabilities',
|
| 944 |
+
health: '/health'
|
| 945 |
+
};
|
| 946 |
+
```
|
| 947 |
+
(`frontend/assets/js/live.js:52-60`)
|
| 948 |
+
|
| 949 |
+
### 7.2 Base-URL resolution, in three ordered steps
|
| 950 |
+
|
| 951 |
+
```js
|
| 952 |
+
/**
|
| 953 |
+
* The orchestrator base URL, with no trailing slash.
|
| 954 |
+
*
|
| 955 |
+
* Resolution order is documented in the file header. Returning `/api` rather
|
| 956 |
+
* than '' keeps the failure mode legible: a misconfigured deployment asks the
|
| 957 |
+
* Pages host for `/api/infer` and gets a clean 404, instead of the page's
|
| 958 |
+
* own index.html being fetched as JSON and producing a confusing parse error.
|
| 959 |
+
*/
|
| 960 |
+
SQ.live.baseUrl = function () {
|
| 961 |
+
var injected = window.SATQUERY_API_BASE;
|
| 962 |
+
if (injected) return _normalizeBase(String(injected));
|
| 963 |
+
|
| 964 |
+
var meta = document.querySelector('meta[name="satquery-api-base"]');
|
| 965 |
+
if (meta && meta.content) return _normalizeBase(String(meta.content));
|
| 966 |
+
|
| 967 |
+
return '/api';
|
| 968 |
+
};
|
| 969 |
+
```
|
| 970 |
+
(`frontend/assets/js/live.js:91-107`)
|
| 971 |
+
|
| 972 |
+
| Order | Source | Why |
|
| 973 |
+
|---|---|---|
|
| 974 |
+
| 1 | `window.SATQUERY_API_BASE` | an inline config so a deployment points at its own backend without rebuilding the JS |
|
| 975 |
+
| 2 | `<meta name="satquery-api-base">` | the same idea, declarative |
|
| 976 |
+
| 3 | `/api` on the current origin | correct for a same-origin deployment and for the local dev proxy |
|
| 977 |
+
|
| 978 |
+
The normaliser exists because an absolute base naturally omits `/api`:
|
| 979 |
+
|
| 980 |
+
```js
|
| 981 |
+
/**
|
| 982 |
+
* A configured base, with `/api` guaranteed for absolute origins.
|
| 983 |
+
* ...
|
| 984 |
+
* someone configuring an absolute URL naturally writes the
|
| 985 |
+
* ORIGIN -- `https://host` -- and then `url()` produced `https://host/assets`
|
| 986 |
+
* instead of `https://host/api/assets`. Every call 404s, and it is a silent
|
| 987 |
+
* failure: the page reports a network error rather than a misconfiguration.
|
| 988 |
+
*/
|
| 989 |
+
function _normalizeBase(raw) {
|
| 990 |
+
var base = String(raw).replace(/\/+$/, '');
|
| 991 |
+
if (base.indexOf('://') === -1) return base; // relative: as written
|
| 992 |
+
var after = base.slice(base.indexOf('://') + 3);
|
| 993 |
+
var slash = after.indexOf('/');
|
| 994 |
+
var path = slash === -1 ? '' : after.slice(slash);
|
| 995 |
+
if (path === '' || path === '/') return base + '/api';
|
| 996 |
+
return base;
|
| 997 |
+
}
|
| 998 |
+
```
|
| 999 |
+
(`frontend/assets/js/live.js:109-131`)
|
| 1000 |
+
|
| 1001 |
+
> *"Rule 3 is why development needs no secret: a dev server that proxies `/api` to Render lets the
|
| 1002 |
+
> browser talk to `http://localhost:8080/api/...` and the CORS allowlist is then a non-issue. Direct
|
| 1003 |
+
> cross-origin calls also work, and that is what the localhost CORS entries in
|
| 1004 |
+
> `deploy/render/main.py` exist for."* (`frontend/assets/js/live.js:40-43`)
|
| 1005 |
+
|
| 1006 |
+
### 7.3 The content-type map, derived from the extension
|
| 1007 |
+
|
| 1008 |
+
```js
|
| 1009 |
+
/*: Extensions the Space's store accepts, mirroring the gateway content-type
|
| 1010 |
+
allowlist (gateway/policy.py `allowed_content_types`). The browser sets the
|
| 1011 |
+
Content-Type header from this map; a wrong type is a 422 from the store, so
|
| 1012 |
+
guessing it from the extension is more reliable than trusting the File's
|
| 1013 |
+
own `.type`, which browsers leave empty for GeoTIFF. */
|
| 1014 |
+
SQ.CONTENT_TYPES = {
|
| 1015 |
+
tif: 'image/tiff',
|
| 1016 |
+
tiff: 'image/tiff',
|
| 1017 |
+
png: 'image/png',
|
| 1018 |
+
jpg: 'image/jpeg',
|
| 1019 |
+
jpeg: 'image/jpeg'
|
| 1020 |
+
};
|
| 1021 |
+
```
|
| 1022 |
+
(`frontend/assets/js/live.js:62-73`)
|
| 1023 |
+
|
| 1024 |
+
Note the client's map has **four** types and omits `image/geotiff` and `application/octet-stream`,
|
| 1025 |
+
which the server's allowlist of five includes. A `.geotiff` file therefore has no client-side mapping
|
| 1026 |
+
and is refused by `uploadAsset` before any request:
|
| 1027 |
+
|
| 1028 |
+
```js
|
| 1029 |
+
var contentType = SQ.contentTypeFor(file);
|
| 1030 |
+
if (!contentType) {
|
| 1031 |
+
return Promise.reject(
|
| 1032 |
+
LiveError(
|
| 1033 |
+
'upload',
|
| 1034 |
+
'Unsupported file type: ' + (file && file.name ? file.name : '(unnamed)') +
|
| 1035 |
+
'. Use GeoTIFF, TIFF, PNG or JPEG.'
|
| 1036 |
+
)
|
| 1037 |
+
);
|
| 1038 |
+
}
|
| 1039 |
+
```
|
| 1040 |
+
(`frontend/assets/js/live.js:204-213`)
|
| 1041 |
+
|
| 1042 |
+
> **This is a real client/server asymmetry.** The message says "Use GeoTIFF, TIFF, PNG or JPEG" while
|
| 1043 |
+
> the map has no `geotiff` extension key, so a file named `scene.geotiff` is refused with a message
|
| 1044 |
+
> that names its own format. The server would accept it as `image/geotiff`. Recorded as a defect in
|
| 1045 |
+
> the client, not smoothed over.
|
| 1046 |
+
|
| 1047 |
+
### 7.4 Uploads are sequential, on purpose
|
| 1048 |
+
|
| 1049 |
+
```js
|
| 1050 |
+
/**
|
| 1051 |
+
* Upload several Files, sequentially, preserving order.
|
| 1052 |
+
*
|
| 1053 |
+
* Sequential rather than parallel, and that is a considered choice: the
|
| 1054 |
+
* Codespace runs `cache_max_models: 1` and puts v1 execution in a single
|
| 1055 |
+
* process with sequential plans (gateway/assets.py). Firing five uploads at
|
| 1056 |
+
* once gains nothing and makes a partial failure harder to reason about --
|
| 1057 |
+
* the caller learns exactly which file failed, by index.
|
| 1058 |
+
*/
|
| 1059 |
+
```
|
| 1060 |
+
(`frontend/assets/js/live.js:245-254`)
|
| 1061 |
+
|
| 1062 |
+
This is the client's implementation of the contract's *"Serialize requests"* obligation
|
| 1063 |
+
(`docs/API_CONTRACT.md` §6, item 3).
|
| 1064 |
+
|
| 1065 |
+
### 7.5 The analysis request body is minimal, because `extra="forbid"`
|
| 1066 |
+
|
| 1067 |
+
```js
|
| 1068 |
+
var body = { assets: ids, query: String(query || '') };
|
| 1069 |
+
if (opts.forceTask) body.force_task = opts.forceTask;
|
| 1070 |
+
```
|
| 1071 |
+
(`frontend/assets/js/live.js:301-302`)
|
| 1072 |
+
|
| 1073 |
+
> *"`extra="forbid"` is why this function sends nothing else: an extra key is a 422, not an ignored
|
| 1074 |
+
> field. `force_task` is omitted rather than sent as null, because both are accepted but omitting it
|
| 1075 |
+
> keeps the payload minimal and lets the server's own router decide."* (`frontend/assets/js/live.js:287-291`)
|
| 1076 |
+
|
| 1077 |
+
### 7.6 The response is asserted at the boundary
|
| 1078 |
+
|
| 1079 |
+
```js
|
| 1080 |
+
return resp.json().catch(function () { return null; }).then(function (parsed) {
|
| 1081 |
+
if (!resp.ok) throw describeFailure('infer', resp.status, parsed);
|
| 1082 |
+
if (!parsed || !parsed.result) {
|
| 1083 |
+
throw LiveError('infer', 'The service returned no result.', {
|
| 1084 |
+
status: resp.status,
|
| 1085 |
+
detail: JSON.stringify(parsed).slice(0, 400)
|
| 1086 |
+
});
|
| 1087 |
+
}
|
| 1088 |
+
return { envelope: parsed, state: state, transport: transport };
|
| 1089 |
+
});
|
| 1090 |
+
```
|
| 1091 |
+
(`frontend/assets/js/live.js:318-327`)
|
| 1092 |
+
|
| 1093 |
+
and on the upload path:
|
| 1094 |
+
|
| 1095 |
+
```js
|
| 1096 |
+
/* Assert the shape at the boundary. A 200 whose body lacks asset_id
|
| 1097 |
+
would otherwise travel into `/api/infer` as `undefined` and fail
|
| 1098 |
+
there, naming the wrong cause. */
|
| 1099 |
+
if (!body || typeof body.asset_id !== 'string' || !body.asset_id) {
|
| 1100 |
+
throw LiveError('upload', 'Upload succeeded but returned no asset id.', { ... });
|
| 1101 |
+
}
|
| 1102 |
+
```
|
| 1103 |
+
(`frontend/assets/js/live.js:224-231`)
|
| 1104 |
+
|
| 1105 |
+
### 7.7 The error object carries the contract's classification
|
| 1106 |
+
|
| 1107 |
+
```js
|
| 1108 |
+
function LiveError(stage, message, opts) {
|
| 1109 |
+
opts = opts || {};
|
| 1110 |
+
var err = new Error(message);
|
| 1111 |
+
err.name = 'SQ.LiveError';
|
| 1112 |
+
err.stage = stage;
|
| 1113 |
+
err.status = opts.status || 0;
|
| 1114 |
+
err.code = opts.code || '';
|
| 1115 |
+
err.detail = opts.detail || '';
|
| 1116 |
+
err.recoverable = !!opts.recoverable;
|
| 1117 |
+
return err;
|
| 1118 |
+
}
|
| 1119 |
+
```
|
| 1120 |
+
(`frontend/assets/js/live.js:152-162`)
|
| 1121 |
+
|
| 1122 |
+
> *"`stage` names the step ('upload' | 'infer'), `status` is the HTTP status if a response was
|
| 1123 |
+
> received, and `code` is the contract's error code when the server supplied the v1 envelope. The
|
| 1124 |
+
> server's own message is preserved rather than replaced -- a generic "something went wrong" would
|
| 1125 |
+
> hide the difference between "your file is too large" and "the engine is waking"."*
|
| 1126 |
+
> (`frontend/assets/js/live.js:142-150`)
|
| 1127 |
+
|
| 1128 |
+
### 7.8 The capabilities call exists so the page can refuse to promise
|
| 1129 |
+
|
| 1130 |
+
```js
|
| 1131 |
+
/**
|
| 1132 |
+
* GET /api/capabilities, for the UI to show what the engine can actually do.
|
| 1133 |
+
*
|
| 1134 |
+
* Not part of the analysis flow; it exists so the page can refuse to promise
|
| 1135 |
+
* a task the deployment cannot serve, rather than failing after an upload.
|
| 1136 |
+
*/
|
| 1137 |
+
```
|
| 1138 |
+
(`frontend/assets/js/live.js:368-373`)
|
| 1139 |
+
|
| 1140 |
+
`mission.js` consumes it at start-up:
|
| 1141 |
+
|
| 1142 |
+
```js
|
| 1143 |
+
engine = SQ.run({ query: QUERY });
|
| 1144 |
+
engine.on(onEvent);
|
| 1145 |
+
resetUI();
|
| 1146 |
+
runMock(QUERY);
|
| 1147 |
+
loadCapabilities();
|
| 1148 |
+
```
|
| 1149 |
+
(`frontend/assets/js/mission.js:971-975`)
|
| 1150 |
+
|
| 1151 |
+
### 7.9 The pair-aware dispatch — the client honours `requires_pair`
|
| 1152 |
+
|
| 1153 |
+
> *"`/api/capabilities` declares `requires_pair` and `max_assets` per task, and the page chooses the
|
| 1154 |
+
> task with the asset count in mind."* (`frontend/assets/js/mission.js:134-135`)
|
| 1155 |
+
|
| 1156 |
+
```js
|
| 1157 |
+
/* Send ONLY the assets the dispatched task requires. A single-image task
|
| 1158 |
+
(vqa / grounding / caption) must NOT receive the optional T0 frame: the
|
| 1159 |
+
backend rejects a two-asset payload for a one-asset task with
|
| 1160 |
+
`invalid_request`. Temporal tasks (change / change_vqa) need T0+T1, and
|
| 1161 |
+
optical_sar needs the optical+SAR pair (T1 + the second modality in T0). */
|
| 1162 |
+
var filesToSend = assetsForTask(forced, selectedT1, selectedT0);
|
| 1163 |
+
```
|
| 1164 |
+
(`frontend/assets/js/mission.js:624-629`)
|
| 1165 |
+
|
| 1166 |
+
and the substitution is disclosed before the request, not after:
|
| 1167 |
+
|
| 1168 |
+
```js
|
| 1169 |
+
/* Say the substitution where the user is looking, before the request, so
|
| 1170 |
+
the result is not surprising. It is a fact about the request, not an
|
| 1171 |
+
error: one image genuinely cannot support change detection. */
|
| 1172 |
+
if (choice.substituted) {
|
| 1173 |
+
obsNote.innerHTML = 'Analysing as <span class="mono">' + forced +
|
| 1174 |
+
'</span> — ' + choice.reason + '. Add a T0 frame to run <span class="mono">' +
|
| 1175 |
+
choice.wanted + '</span>.';
|
| 1176 |
+
}
|
| 1177 |
+
```
|
| 1178 |
+
(`frontend/assets/js/mission.js:645-652`)
|
| 1179 |
+
|
| 1180 |
+
This was a **deployed defect** (P1 in the status board) and its fix is recorded:
|
| 1181 |
+
|
| 1182 |
+
> *"Acceptance: deployed `mission.js` contains `assetsForTask`; vqa-with-pair returns real result
|
| 1183 |
+
> (only T1 uploaded)."* (`docs/FINAL_DELIVERY_TODO.md` §4, P4-T01)
|
| 1184 |
+
|
| 1185 |
+
### 7.10 Optical-SAR gets an early warning, because its pair is two modalities
|
| 1186 |
+
|
| 1187 |
+
```js
|
| 1188 |
+
/* Optical-SAR is the one task whose pair is two MODALITIES, not two times.
|
| 1189 |
+
Warn early (before the upload) when the second file looks like a plain
|
| 1190 |
+
photo rather than a radar product, so the round-trip does not fail opaquely. */
|
| 1191 |
+
if (forced === 'optical_sar' && pairNote) {
|
| 1192 |
+
var sarCheck = validateOpticalSar(selectedT1, selectedT0);
|
| 1193 |
+
pairNote.innerHTML = sarCheck.level !== 'ok' ? sarCheck.message : pairNoteDefault;
|
| 1194 |
+
}
|
| 1195 |
+
```
|
| 1196 |
+
(`frontend/assets/js/mission.js:631-637`)
|
| 1197 |
+
|
| 1198 |
+
The contract's rule the validator implements:
|
| 1199 |
+
|
| 1200 |
+
> *"**Optical+SAR contract:** modality inferred from band count — `{1,2}` ⇒ SAR, `{3,4,8,11,12,13}` ⇒
|
| 1201 |
+
> optical; both GeoTIFF, same W×H, uint8/uint16, rasterio-readable."*
|
| 1202 |
+
> (session `HANDOFF_NEXT_AGENT.md` §4)
|
| 1203 |
+
|
| 1204 |
+
---
|
| 1205 |
+
|
| 1206 |
+
## 8. Cloudflare traps
|
| 1207 |
+
|
| 1208 |
+
Three measured behaviours of the Cloudflare Pages tier cost real time on this project. All three are
|
| 1209 |
+
recorded because they are non-obvious and each one produced a wrong assumption.
|
| 1210 |
+
|
| 1211 |
+
### 8.1 `_headers` rules CONCATENATE — they do not override
|
| 1212 |
+
|
| 1213 |
+
This is the finding, and the file's own earlier comment was **false**:
|
| 1214 |
+
|
| 1215 |
+
```text
|
| 1216 |
+
# Format: a path pattern, then indented Header: value lines. `*` matches any
|
| 1217 |
+
# number of characters. A request that matches several rules inherits ALL of
|
| 1218 |
+
# them, and a header set by more than one rule is JOINED with a comma in file
|
| 1219 |
+
# order -- it is NOT overridden. Verified live 2026-09-25: a narrower
|
| 1220 |
+
# Cache-Control rule did not replace the broader one, it appended to it. To
|
| 1221 |
+
# remove a header contributed by a broader rule, detach it with a
|
| 1222 |
+
# "! Header-Name" line (Cloudflare Pages supports `!` detach).
|
| 1223 |
+
```
|
| 1224 |
+
(`frontend/_headers:3-9`)
|
| 1225 |
+
|
| 1226 |
+
**The measurement.** A specific `max-age=0` rule placed under the broad `/assets/img/*`
|
| 1227 |
+
`max-age=604800` rule produced this live response:
|
| 1228 |
+
|
| 1229 |
+
```
|
| 1230 |
+
Cache-Control: public, max-age=604800, public, max-age=0, must-revalidate
|
| 1231 |
+
```
|
| 1232 |
+
(`DELIVERY_REPORT_2026-09-25.md` §5; `frontend/_headers:70`)
|
| 1233 |
+
|
| 1234 |
+
**The consequence, and why it is worse than a normalisation failure:** Chromium takes the **first**
|
| 1235 |
+
`max-age` it finds, so the broad week-long value still won:
|
| 1236 |
+
|
| 1237 |
+
> *"Chromium honours the **first** `max-age`, so the returning browser kept the stale image and the
|
| 1238 |
+
> carve-out was ineffective."* (`DELIVERY_REPORT_2026-09-25.md` §5)
|
| 1239 |
+
|
| 1240 |
+
The `_headers` file records the same conclusion and forbids reintroducing the pattern:
|
| 1241 |
+
|
| 1242 |
+
```text
|
| 1243 |
+
# NOTE (2026-09-25): an earlier revision of this file tried to carve the
|
| 1244 |
+
# delta-growth change-demo pair out of the week-long rule above with two literal
|
| 1245 |
+
# path blocks carrying max-age=0. It did NOT work. Cloudflare does not override a
|
| 1246 |
+
# header when a second rule sets it -- it JOINS the values in file order, and the
|
| 1247 |
+
# live response was "public, max-age=604800, public, max-age=0, must-revalidate".
|
| 1248 |
+
# Chromium takes the FIRST max-age it finds, so the broad week-long value still
|
| 1249 |
+
# won and a returning browser kept the stale image. Do not reintroduce a
|
| 1250 |
+
# Cache-Control carve-out here: any rule broad enough to matter also matches
|
| 1251 |
+
# /assets/img/*, so the broad value is always present. The pair was instead given
|
| 1252 |
+
# NEW URLs (delta-growth-t0-1975-720.jpg / delta-growth-t1-2025-720.jpg), which
|
| 1253 |
+
# is the only cache-busting that does not depend on _headers semantics.
|
| 1254 |
+
```
|
| 1255 |
+
(`frontend/_headers:66-76`)
|
| 1256 |
+
|
| 1257 |
+
The status board records the corrected comment as a *documentation* fix, which is what it was:
|
| 1258 |
+
|
| 1259 |
+
> *"| Cache-busting | **VERIFIED (with a caveat)** | `_headers` revalidates JS/CSS; the EO pair was
|
| 1260 |
+
> instead given NEW URLs because Cloudflare **concatenates** matching `_headers` rules — see §1.7
|
| 1261 |
+
> item 9 |"* (`docs/FINAL_DELIVERY_TODO.md` §1.4)
|
| 1262 |
+
|
| 1263 |
+
and §1.7 item 9:
|
| 1264 |
+
|
| 1265 |
+
> *"**Cloudflare `_headers` CONCATENATES matching rules** instead of overriding them. A specific rule
|
| 1266 |
+
> under a broad `/assets/img/*` rule produces `Cache-Control: public, max-age=604800, …, max-age=0,
|
| 1267 |
+
> must-revalidate`, and Chromium honours the **first** `max-age` — so a per-path override cannot
|
| 1268 |
+
> un-cache a long-lived asset. Measured live 2026-09-25. The cache-busting fix therefore **renames**
|
| 1269 |
+
> the asset to a new URL rather than adding a `_headers` rule."*
|
| 1270 |
+
> (`docs/FINAL_DELIVERY_TODO.md` §1.7 item 9)
|
| 1271 |
+
|
| 1272 |
+
**A consequence that persists:** the deleted old URLs still answer from the edge cache.
|
| 1273 |
+
|
| 1274 |
+
> *"Consequence: the old URLs still answer `200` from Cloudflare's **edge cache** (`CF-Cache-Status:
|
| 1275 |
+
> HIT`) although the files are deleted; a cache-busted request returns `404`. Nothing references
|
| 1276 |
+
> them."* (`DELIVERY_REPORT_2026-09-25.md` §5)
|
| 1277 |
+
|
| 1278 |
+
### 8.2 The `!` detach escape hatch
|
| 1279 |
+
|
| 1280 |
+
Cloudflare Pages supports `! Header-Name` to detach a header contributed by a broader rule. The
|
| 1281 |
+
`_headers` file records it as the **correct** way to remove an inherited header
|
| 1282 |
+
(`frontend/_headers:8-9`) — but the project chose URL renaming instead for the EO pair, because
|
| 1283 |
+
*"any rule broad enough to matter also matches `/assets/img/*`, so the broad value is always
|
| 1284 |
+
present"* and a detach would have removed `Cache-Control` for *every* image rather than for one.
|
| 1285 |
+
|
| 1286 |
+
### 8.3 The `308` redirect: `X.html` → `/X`
|
| 1287 |
+
|
| 1288 |
+
> *"**Cloudflare Pages 308-redirects `X.html` → `/X`.** Drive `https://satquery.pages.dev/mission`."*
|
| 1289 |
+
> (session `HANDOFF_NEXT_AGENT.md` §4)
|
| 1290 |
+
|
| 1291 |
+
| Request | Response |
|
| 1292 |
+
|---|---|
|
| 1293 |
+
| `GET /mission.html` | `308` → `Location: /mission` |
|
| 1294 |
+
| `GET /mission` | `200`, the page |
|
| 1295 |
+
|
| 1296 |
+
**Why it matters for a harness.** A headed-browser driver that navigates to
|
| 1297 |
+
`https://satquery.pages.dev/mission.html` is redirected, and any assertion written against the
|
| 1298 |
+
*pre-redirect* URL — or against a `location.pathname` that still ends in `.html` — sees a different
|
| 1299 |
+
document URL than it expected. The rule the project adopted is to drive the **extensionless** path.
|
| 1300 |
+
|
| 1301 |
+
**Why it does not matter for the site itself.** `docs/DEPLOYMENT_DECISION.md` §6 records that no
|
| 1302 |
+
`_redirects` file was created, and the reason:
|
| 1303 |
+
|
| 1304 |
+
> *"**`_redirects`** — every link in the site is already a literal `.html` path; there are no pretty
|
| 1305 |
+
> URLs to map."*
|
| 1306 |
+
|
| 1307 |
+
So the site's own internal links are `.html` and Cloudflare's `308` is a *host-level* behaviour that
|
| 1308 |
+
the site does not depend on. The two facts are consistent: the site never needs the redirect, and a
|
| 1309 |
+
harness that types the URL directly must account for it.
|
| 1310 |
+
|
| 1311 |
+
### 8.4 What was deliberately NOT created
|
| 1312 |
+
|
| 1313 |
+
`docs/DEPLOYMENT_DECISION.md` §6 lists the files that were considered and refused, with reasons:
|
| 1314 |
+
|
| 1315 |
+
| Not created | Reason |
|
| 1316 |
+
|---|---|
|
| 1317 |
+
| `_redirects` | every link is already a literal `.html` path; there are no pretty URLs to map |
|
| 1318 |
+
| `sitemap.xml` | *"needs a canonical production domain. Inventing one would publish a URL that does not resolve, so it is omitted until the Pages domain is fixed."* |
|
| 1319 |
+
| `wrangler.toml` | optional for Pages; the deploy command carries the project name |
|
| 1320 |
+
| a **Content-Security-Policy** | *"`atlas.html` carries one inline `style=""` attribute, so a strict CSP would need `'unsafe-inline'` anyway. A CSP permitting unsafe-inline is security theatre; add a real one after that attribute is moved into `pages.css`."* |
|
| 1321 |
+
|
| 1322 |
+
> **Note the correction.** `sitemap.xml` *does* exist in the tree (`frontend/sitemap.xml`, 741 B) and
|
| 1323 |
+
> `robots.txt` carries a `Sitemap:` line, so the "needs a canonical domain" blocker was resolved
|
| 1324 |
+
> after that decision record was written. The decision record is retained as the historical record;
|
| 1325 |
+
> the tree is the current state.
|
| 1326 |
+
|
| 1327 |
+
---
|
| 1328 |
+
|
| 1329 |
+
## 9. Cache-busting
|
| 1330 |
+
|
| 1331 |
+
### 9.1 The rule that matters: JS and CSS must revalidate
|
| 1332 |
+
|
| 1333 |
+
```text
|
| 1334 |
+
# CSS and JS are NOT content-hashed. They MUST revalidate on every request, or a
|
| 1335 |
+
# deploy is masked by a cached asset for up to the max-age window — observed on
|
| 1336 |
+
# 2026-09-25 when a returning browser served the pre-fix mission.js and kept
|
| 1337 |
+
# hitting the old invalid_request. max-age=0 + must-revalidate makes the browser
|
| 1338 |
+
# re-fetch (and Cloudflare re-validate) on every load, so a deploy is picked up
|
| 1339 |
+
# immediately, exactly like the HTML above.
|
| 1340 |
+
/assets/css/*
|
| 1341 |
+
Cache-Control: public, max-age=0, must-revalidate
|
| 1342 |
+
|
| 1343 |
+
/assets/js/*
|
| 1344 |
+
Cache-Control: public, max-age=0, must-revalidate
|
| 1345 |
+
```
|
| 1346 |
+
(`frontend/_headers:50-60`)
|
| 1347 |
+
|
| 1348 |
+
**The incident that produced the rule is named in the comment:** a returning browser served the
|
| 1349 |
+
**pre-fix `mission.js`** and *"kept hitting the old `invalid_request`"*. The cache was masking a
|
| 1350 |
+
correct deploy — the same failure class as the phantom defect chase that
|
| 1351 |
+
session `HANDOFF_NEXT_AGENT.md` §4 warns about:
|
| 1352 |
+
|
| 1353 |
+
> *"**Stale browser cache can mask a correct deploy.** Verify server-side (GitHub API sha256) *and*
|
| 1354 |
+
> client-side (CDP `Network.clearBrowserCache`), or you will chase a phantom."*
|
| 1355 |
+
|
| 1356 |
+
### 9.2 The full `_headers` policy
|
| 1357 |
+
|
| 1358 |
+
| Path pattern | `Cache-Control` | Why |
|
| 1359 |
+
|---|---|---|
|
| 1360 |
+
| `/*` | *(none set)* | the baseline block sets only security headers |
|
| 1361 |
+
| `/` | `public, max-age=0, must-revalidate` | HTML revalidates every time |
|
| 1362 |
+
| `/*.html` | `public, max-age=0, must-revalidate` | *"so a deploy is picked up immediately rather than being masked by a cached page that still points at yesterday's CSS"* |
|
| 1363 |
+
| `/assets/video/*` | `public, max-age=604800` | the launch film, 22,710,313 B — *"the single largest asset on the site and the one worth not re-downloading"* |
|
| 1364 |
+
| `/assets/fonts/*` | `public, max-age=31536000` | *"Stable, versioned by presence rather than by filename, so a long max-age is appropriate."* |
|
| 1365 |
+
| `/assets/css/*` | `public, max-age=0, must-revalidate` | not content-hashed |
|
| 1366 |
+
| `/assets/js/*` | `public, max-age=0, must-revalidate` | not content-hashed |
|
| 1367 |
+
| `/assets/img/*` | `public, max-age=604800` | *"Copernicus / ESA / NASA reference imagery. Stable."* |
|
| 1368 |
+
|
| 1369 |
+
(`frontend/_headers:21-64`)
|
| 1370 |
+
|
| 1371 |
+
The baseline security headers, which apply to every path:
|
| 1372 |
+
|
| 1373 |
+
```text
|
| 1374 |
+
/*
|
| 1375 |
+
X-Content-Type-Options: nosniff
|
| 1376 |
+
Referrer-Policy: strict-origin-when-cross-origin
|
| 1377 |
+
X-Frame-Options: DENY
|
| 1378 |
+
Cross-Origin-Opener-Policy: same-origin
|
| 1379 |
+
```
|
| 1380 |
+
(`frontend/_headers:21-25`)
|
| 1381 |
+
|
| 1382 |
+
> **No CSP, and the file says why.** *"No Content-Security-Policy is set, deliberately. NOTE
|
| 1383 |
+
> (2026-09-25): the site is no longer fully static — mission.html loads live.js and POSTs to the
|
| 1384 |
+
> Render orchestrator (the Analyze console), so a real CSP would also need a connect-src for that API
|
| 1385 |
+
> origin. atlas.html additionally carries one inline `style=""` attribute, so any CSP would have to
|
| 1386 |
+
> allow `'unsafe-inline'` anyway."* (`frontend/_headers:14-19`)
|
| 1387 |
+
|
| 1388 |
+
### 9.3 The status, with its caveat
|
| 1389 |
+
|
| 1390 |
+
> *"| Cache-busting | **VERIFIED (with a caveat)** | `_headers` revalidates JS/CSS; the EO pair was
|
| 1391 |
+
> instead given NEW URLs because Cloudflare **concatenates** matching `_headers` rules"*
|
| 1392 |
+
> (`docs/FINAL_DELIVERY_TODO.md` §1.4)
|
| 1393 |
+
|
| 1394 |
+
and the phase item is honest about which half was verified when:
|
| 1395 |
+
|
| 1396 |
+
> *"**P4-T02** — Cache-busting for JS/CSS | Status: **COMPLETE** (live confirmation pending P10-T01
|
| 1397 |
+
> re-deploy) | Acceptance: returning users get fresh JS on next load. | Evidence: file edited
|
| 1398 |
+
> (2026-09-25). Post-deploy `curl -I` to confirm header."* (`docs/FINAL_DELIVERY_TODO.md` §4)
|
| 1399 |
+
|
| 1400 |
+
> **`UNKNOWN — not established from the available evidence`:** the post-deploy `curl -I` output
|
| 1401 |
+
> confirming the live `Cache-Control` on `/assets/js/*`. The file was edited and the phase marked
|
| 1402 |
+
> complete; the recorded evidence is the file edit, not a captured response header.
|
| 1403 |
+
|
| 1404 |
+
---
|
| 1405 |
+
|
| 1406 |
+
## 10. The harness lesson — a headed-browser driver that records false passes
|
| 1407 |
+
|
| 1408 |
+
This is the most valuable operational finding in this chapter, because it is a **false pass**, not a
|
| 1409 |
+
false failure.
|
| 1410 |
+
|
| 1411 |
+
### 10.1 The failure, as it was observed
|
| 1412 |
+
|
| 1413 |
+
> *"When I re-ran the suite to cover the final commit, the first case came back `run_id=0002`,
|
| 1414 |
+
> `mock_nodes=9`, `answer="No answer yet"`, and only the `capabilities` call — i.e. the **mock** path.
|
| 1415 |
+
> Diagnosis: the harness drove the query box with `fill_input()`, which types using **real CDP key
|
| 1416 |
+
> events**, and Chrome **drops synthesized key events when the browser window does not hold OS
|
| 1417 |
+
> focus**. Measured directly: with Chrome backgrounded, `press_key("Z")` left `#qtext.value`
|
| 1418 |
+
> unchanged, while `type_text("Q")` (CDP `Input.insertText`, not focus-gated) inserted fine.
|
| 1419 |
+
> `fill_input` has **no assertion**, so the harness happily clicked Run with the page's **default**
|
| 1420 |
+
> query still in the box."* (`DELIVERY_REPORT_2026-09-25.md` §3)
|
| 1421 |
+
|
| 1422 |
+
**The shape of the false pass:** the query box kept the page's default (`What changed here?`), the
|
| 1423 |
+
harness clicked Run anyway, the page produced a *result* — and that result was recorded as the
|
| 1424 |
+
verdict for a case whose query was never entered. The harness had no way to tell the difference
|
| 1425 |
+
between "the query was entered and the run used it" and "the query was never entered".
|
| 1426 |
+
|
| 1427 |
+
### 10.2 Why the earlier 8/8 run was *not* infected — the three discriminators
|
| 1428 |
+
|
| 1429 |
+
The report does not simply re-run and hope. It checks whether the earlier result was contaminated,
|
| 1430 |
+
using evidence the harness recorded:
|
| 1431 |
+
|
| 1432 |
+
> *"I then checked whether the earlier 8/8 run was infected by the same silent failure. It was not:
|
| 1433 |
+
>
|
| 1434 |
+
> * its recorded intents are **query-specific** — A1 reads `taskvqa…temporalnone`, whereas the default
|
| 1435 |
+
> query *"What changed here?"* would read `taskchange…temporalrequired` (exactly what the failed run
|
| 1436 |
+
> showed);
|
| 1437 |
+
> * its answers **embed the query text** — e.g. `[grounding] Located 6 candidate region(s) for 'Where
|
| 1438 |
+
> are the built-up areas in this image?'`;
|
| 1439 |
+
> * A6 required two files (`optical 4/12 + SAR 2/2` channels), which only the uploaded pair supplies.
|
| 1440 |
+
>
|
| 1441 |
+
> So the 8/8 result is a valid measurement."* (`DELIVERY_REPORT_2026-09-25.md` §3)
|
| 1442 |
+
|
| 1443 |
+
The three discriminators generalise:
|
| 1444 |
+
|
| 1445 |
+
| Discriminator | What it proves |
|
| 1446 |
+
|---|---|
|
| 1447 |
+
| the recorded **intent** is query-specific | the query reached the router |
|
| 1448 |
+
| the **answer** embeds the query text | the server received the intended query |
|
| 1449 |
+
| a case **requires an artefact** only the setup supplies | the setup really happened |
|
| 1450 |
+
|
| 1451 |
+
### 10.3 The fix: deterministic query entry plus pre-dispatch assertions
|
| 1452 |
+
|
| 1453 |
+
> *"The harness has since been rebuilt (`run_all_postfix2.harness`) to set the query deterministically
|
| 1454 |
+
> and to **assert the form state before clicking Run**, recording per case: `q_ok` (the box really
|
| 1455 |
+
> held the query), `obs_ok` (`#obsTail == 'ready'` and one file on `#fileInput`), `t0_ok` (both frames
|
| 1456 |
+
> for pair tasks), `no_mock_nodes`, and a computed `verdict`. A silent no-op can no longer be recorded
|
| 1457 |
+
> as a pass."* (`DELIVERY_REPORT_2026-09-25.md` §3)
|
| 1458 |
+
|
| 1459 |
+
The three pre-dispatch assertions, and the rule they implement:
|
| 1460 |
+
|
| 1461 |
+
> *"**Do NOT use `fill_input()` or `press_key()` to enter the query.** They type with real CDP key
|
| 1462 |
+
> events, which Chrome **silently drops when the browser window does not hold OS focus** — the box
|
| 1463 |
+
> keeps its default text and the run silently exercises the wrong query. Use `js()` to set
|
| 1464 |
+
> `#qtext.value` (plus `input`/`change` events) and/or `type_text()` (CDP `Input.insertText`, not
|
| 1465 |
+
> focus-gated). **Always assert the form state before clicking Run** — `q_ok` (box holds the query),
|
| 1466 |
+
> `obs_ok` (`#obsTail == 'ready'`), `t0_ok` (both frames for pair tasks) — or a no-op will be recorded
|
| 1467 |
+
> as a pass. `upload_file()` is fine and flips `#obsTail` to `ready`."*
|
| 1468 |
+
> (session `HANDOFF_NEXT_AGENT.md` §5.2)
|
| 1469 |
+
|
| 1470 |
+
| Assertion | Checks | Failure it prevents |
|
| 1471 |
+
|---|---|---|
|
| 1472 |
+
| `q_ok` | `#qtext.value` holds the intended query | the silent-drop false pass |
|
| 1473 |
+
| `obs_ok` | `#obsTail == 'ready'` **and** one file on `#fileInput` | an upload that did not land |
|
| 1474 |
+
| `t0_ok` | both frames present, for pair tasks | a pair task run on one asset |
|
| 1475 |
+
| `no_mock_nodes` | `mock_nodes == 0` | the preview path being recorded as live |
|
| 1476 |
+
|
| 1477 |
+
`obs_ok`'s second half is a real DOM fact, because the page sets that tail from the upload:
|
| 1478 |
+
|
| 1479 |
+
`#obsTail` reads `none` in the markup (`mission.html:67`) and the live driver flips it to `ready`.
|
| 1480 |
+
|
| 1481 |
+
### 10.4 Two more harness bugs — both false *failures*
|
| 1482 |
+
|
| 1483 |
+
> *"Two further harness bugs surfaced while re-running — both produced **false failures**, never false
|
| 1484 |
+
> passes, but they are easy to repeat:
|
| 1485 |
+
>
|
| 1486 |
+
> 1. **The answer tag is not universal.** The server prefixes the answer with `[task]` only for the
|
| 1487 |
+
> region tasks (`grounding`, `change`, `change_vqa`, `optical_sar`). vqa answers are bare
|
| 1488 |
+
> (`Grassland`) and caption answers are prose, so a tag-only discriminator wrongly fails them.
|
| 1489 |
+
> Fix: the **dispatched** task is `answer_tag` when present, else the intent panel's reading.
|
| 1490 |
+
> 2. **The intent panel renders a concatenated string** — `task<name>modality<…>temporal<…>`.
|
| 1491 |
+
> Matching `task([a-z_]+)` greedily swallows the whole string; it must be `task([a-z_]+?)modality`."*
|
| 1492 |
+
> (`DELIVERY_REPORT_2026-09-25.md` §3)
|
| 1493 |
+
|
| 1494 |
+
The concatenation is a real property of the intent panel, which renders chips without separators:
|
| 1495 |
+
|
| 1496 |
+
```js
|
| 1497 |
+
function renderIntent(intent, dispatched) {
|
| 1498 |
+
intentHost.innerHTML = '';
|
| 1499 |
+
var rows = [
|
| 1500 |
+
['task', dispatched || intent.task], ['modality', intent.modality], ['temporal', intent.temporal],
|
| 1501 |
+
['spatial', intent.spatial_output], ['evidence', intent.evidence], ['source', intent.source]
|
| 1502 |
+
];
|
| 1503 |
+
if (dispatched) rows.push(['reading', intent.task]);
|
| 1504 |
+
rows.forEach(function (r) {
|
| 1505 |
+
var c = U.el('span', 'chip chip--plain');
|
| 1506 |
+
c.innerHTML = '<span class="k">' + r[0] + '</span>' + r[1];
|
| 1507 |
+
intentHost.appendChild(c);
|
| 1508 |
+
});
|
| 1509 |
+
}
|
| 1510 |
+
```
|
| 1511 |
+
(`frontend/assets/js/mission.js:344-356`)
|
| 1512 |
+
|
| 1513 |
+
### 10.5 The reading-versus-dispatch distinction, which is a *feature* not a bug
|
| 1514 |
+
|
| 1515 |
+
> *"**Read the *dispatched* task from the answer's `[task]` tag when present, else from the intent
|
| 1516 |
+
> panel's reading** — the panel shows the router's *reading*, and a quantifier upgrade legitimately
|
| 1517 |
+
> makes the two differ (A5 reads `change`, dispatches `change_vqa`)."*
|
| 1518 |
+
> (session `HANDOFF_NEXT_AGENT.md` §5.3)
|
| 1519 |
+
|
| 1520 |
+
The panel preserves both, on purpose:
|
| 1521 |
+
|
| 1522 |
+
```js
|
| 1523 |
+
if (dispatched) rows.push(['reading', intent.task]);
|
| 1524 |
+
```
|
| 1525 |
+
(`frontend/assets/js/mission.js:352`)
|
| 1526 |
+
|
| 1527 |
+
with the reasoning in the docstring:
|
| 1528 |
+
|
| 1529 |
+
> *"The `task` chip then names what was SENT and a `reading` chip preserves what the router saw —
|
| 1530 |
+
> showing only one of the two would either misreport the request or hide the router's input."*
|
| 1531 |
+
> (`frontend/assets/js/mission.js:341-343`)
|
| 1532 |
+
|
| 1533 |
+
### 10.6 The safety property that made the harness bugs survivable
|
| 1534 |
+
|
| 1535 |
+
> *"Pass 3's raw harness output reports `SUMMARY 0/8` — because it was launched with the harness build
|
| 1536 |
+
> that still had the two discriminator bugs. Its verdicts in `results_pass3.json` are recomputed from
|
| 1537 |
+
> the recorded evidence by `recompute_verdicts.py`. This is exactly the intended safety property:
|
| 1538 |
+
> **the recorded evidence is independent of the verdict computation**, so a harness bug never forces a
|
| 1539 |
+
> 24-minute browser re-run — and never silently flips a real failure into a pass."*
|
| 1540 |
+
> (`DELIVERY_REPORT_2026-09-25.md` §3)
|
| 1541 |
+
|
| 1542 |
+
That is the generalisable lesson: **record evidence, compute verdicts separately.** A harness that
|
| 1543 |
+
computes its verdict inline has no way to re-derive it when the verdict logic turns out to be wrong.
|
| 1544 |
+
|
| 1545 |
+
### 10.7 The three live passes
|
| 1546 |
+
|
| 1547 |
+
| Pass | Target | Result | Raw output |
|
| 1548 |
+
|---|---|---|---|
|
| 1549 |
+
| 1 | `ff46eba42b18` + `d413d3672311` | 8/8 | `run_output.txt` |
|
| 1550 |
+
| 2 | final HEAD `2d7ae53b482d`, asserting harness | 8/8 | `run_final2.txt` → `results_final.json` |
|
| 1551 |
+
| 3 | final HEAD `2d7ae53b482d`, repeat | 8/8 | `run_final3.txt` → `results_pass3.json` |
|
| 1552 |
+
|
| 1553 |
+
> *"24 live runs, 24 correct dispatches, no run id repeated across passes."*
|
| 1554 |
+
> (`DELIVERY_REPORT_2026-09-25.md` §3)
|
| 1555 |
+
|
| 1556 |
+
The eight cases and their pass-2 run ids:
|
| 1557 |
+
|
| 1558 |
+
| case | query | expected | dispatched | pass 2 run_id |
|
| 1559 |
+
|---|---|---|---|---|
|
| 1560 |
+
| A1 | What type of terrain dominates this scene? | vqa | vqa | `run_0843db184e32` |
|
| 1561 |
+
| A2 | Describe the main visual characteristics of this scene. | caption | caption | `run_5b766f2d7df7` |
|
| 1562 |
+
| A3 | Where are the visible buildings in this image? | grounding | grounding | `run_ea590b6fd70f` |
|
| 1563 |
+
| A4 | What changed between the earlier and later image? | change | change | `run_65a4b2f9d912` |
|
| 1564 |
+
| A5 | Did the coastline advance between the two observations? | change_vqa | change_vqa | `run_efe24b98d217` |
|
| 1565 |
+
| A6 | …combining the optical and SAR observations? | optical_sar | optical_sar | `run_6375b80dcb8e` |
|
| 1566 |
+
| **B1** | **Where are the built-up areas in this image?** | **grounding** | **grounding** | **`run_2a07dcdbae96`** |
|
| 1567 |
+
| **B2** | **Where is the new airport?** | **grounding** | **grounding** | **`run_9134f40a258c`** |
|
| 1568 |
+
|
| 1569 |
+
(`DELIVERY_REPORT_2026-09-25.md` §3)
|
| 1570 |
+
|
| 1571 |
+
> **A6's query is elided in the source as `"…combining the optical and SAR observations?"`** — the
|
| 1572 |
+
> leading words are not reproduced in the report, and this chapter does not invent them.
|
| 1573 |
+
|
| 1574 |
+
### 10.8 The two verdicts, kept separate
|
| 1575 |
+
|
| 1576 |
+
> *"1. **Deployment / integration: PASS** — the full pipeline works on unseen imagery and questions.
|
| 1577 |
+
> 2. **Model quality: MIXED** — caption and grounding are meaningful; change/change_vqa are plausible;
|
| 1578 |
+
> VQA is weak-but-related; optical-SAR still returns a bare class index
|
| 1579 |
+
> (`class_18 (margin 1.000; optical channels 4/12, SAR channels 2/2)`), not a human label."*
|
| 1580 |
+
> (`DELIVERY_REPORT_2026-09-25.md` §3)
|
| 1581 |
+
|
| 1582 |
+
This is the style guide's rule applied at the harness level: *"a mixed result is never 'all work
|
| 1583 |
+
perfectly'."* The integration passes; the model quality does not, and the two are not merged.
|
| 1584 |
+
|
| 1585 |
+
### 10.9 The harness's hard constraints
|
| 1586 |
+
|
| 1587 |
+
| Constraint | Detail |
|
| 1588 |
+
|---|---|
|
| 1589 |
+
| `browser-use` block-buffers stdout | *"the output file sits at 0 bytes until the process exits — that looks exactly like a stall but is not"* |
|
| 1590 |
+
| `grep` block-buffers when piped | *"piping the harness through `grep` swallows all output if the pipeline is killed — redirect to a file"* |
|
| 1591 |
+
| sandbox proxy is dead | *"Every network call needs `--noproxy '*'` (curl) or `ProxyHandler({})` / `--no-proxy-server` (Python / browser)"* |
|
| 1592 |
+
| the harness is a `.harness` script | piped to `browser-use.exe` via stdin; helpers are `goto_url`, `upload_file`, `fill_input`, `type_text`, `press_key`, `js`, `capture_screenshot`, `wait_for_element` |
|
| 1593 |
+
|
| 1594 |
+
(session `HANDOFF_NEXT_AGENT.md` §4, §5.1, §5.5)
|
| 1595 |
+
|
| 1596 |
+
---
|
| 1597 |
+
|
| 1598 |
+
## 11. What is NOT RUN, OPEN, SUPPORTED or BLOCKED for this topic
|
| 1599 |
+
|
| 1600 |
+
| Item | Status | Detail |
|
| 1601 |
+
|---|---|---|
|
| 1602 |
+
| The Analyze console's live path | **VERIFIED** | `mission.js` live driver; 8/8 × 3 passes; real `run_*` ids (`docs/FINAL_DELIVERY_TODO.md` §6 E-11, E-14) |
|
| 1603 |
+
| The preview/mock path | **SUPPORTED** | *"`runMock` only when no file selected; emits empty payloads, marked `is-mock`; not in production path"* (`docs/FINAL_DELIVERY_TODO.md` §1.4) |
|
| 1604 |
+
| The trace bar's fill | **VERIFIED (live)** | measured 94.4444 % (`docs/FINAL_DELIVERY_TODO.md` §1.4) |
|
| 1605 |
+
| Benchmark page | **VERIFIED (section 03 only)** | §03's reliability curve is real from `artifacts/calibration_v001.json`; the remaining section-03 PR curves are *"still labelled illustrative"* (`docs/FINAL_DELIVERY_TODO.md` §4, P6-T01 note) |
|
| 1606 |
+
| Research page | **VERIFIED** | *"entries now trace to real artifacts/reports with honest limitations"* (`docs/FINAL_DELIVERY_TODO.md` §4, P7-T01) |
|
| 1607 |
+
| Journey/Lab page | **VERIFIED** | *"stages correspond to real phases/reports; implemented/verified/attempted/blocked distinguished"* (`docs/FINAL_DELIVERY_TODO.md` §4, P7-T02) |
|
| 1608 |
+
| Anatomy of a Run | **VERIFIED (was SYNTHETIC)** | rebuilt around the real captured `run_d124d8b9adea`; *"synthetic `SEED=917`/`SQ-RUN-0917`/`a3f19c2`/`0.74` removed"* (`docs/FINAL_DELIVERY_TODO.md` §4, P8-T01/T02) |
|
| 1609 |
+
| HF header link | **VERIFIED** | present in all 11 navs; live DOM-confirmed (`docs/FINAL_DELIVERY_TODO.md` §6 E-13) |
|
| 1610 |
+
| GitHub header link | **VERIFIED** | target is the only public repo (`docs/FINAL_DELIVERY_TODO.md` §4, P9-T01) |
|
| 1611 |
+
| Cache-busting | **VERIFIED (with a caveat)** | see §9.3 — the live `curl -I` confirmation is not in the recorded evidence |
|
| 1612 |
+
| `frontend/.tools/shot.sh` rendering the live tree | **NOT RUN** | the script pointed `ROOT` at the **retired prototype**; the fix was specified and *"was **never started**"* (`HANDOFF_NEXT_AGENT.md` §0, §4.2 item E) |
|
| 1613 |
+
| Five audited visual defects (contrast, occluded disclosure, `[hidden]`, 390 px overflow, `shot.sh`) | **NOT RUN** | *"I authorised all five and sent the spec, but the session was interrupted before any file was touched."* (`HANDOFF_NEXT_AGENT.md` §4.2) — `mission.html` mtime and the untouched `system.css` are the verification |
|
| 1614 |
+
| The client/server `image/geotiff` asymmetry | **OPEN (defect, undocumented elsewhere)** | the client's `CONTENT_TYPES` map has no `geotiff` key while the server's allowlist has `image/geotiff` (§7.3) |
|
| 1615 |
+
| The plan's seven viewer tabs vs the shipped four modes | **DIVERGENCE, recorded** | see §4.2 |
|
| 1616 |
+
| **UNKNOWN — not established from the available evidence** | — | whether the deployed bundle's `_headers` is byte-identical to the working tree's; the live `Cache-Control` header on `/assets/js/*`; the measured rendering of the five authorised-but-unstarted visual fixes; the A6 query's leading words |
|
| 1617 |
+
|
| 1618 |
+
### 11.1 Two honesty constraints that outlive the sprint
|
| 1619 |
+
|
| 1620 |
+
> *"**Imagery honesty:** everything in `frontend/assets/img/eo/` is Copernicus / ESA / NASA reference
|
| 1621 |
+
> material with `satquery_result: false` and `role: illustrative`. Nothing may imply it is SatQuery
|
| 1622 |
+
> pipeline output. **Never replace a fake value with another fake value** — either measure it or label
|
| 1623 |
+
> it with `.disclose`."* (`HANDOFF_NEXT_AGENT.md` §7, repo copy)
|
| 1624 |
+
|
| 1625 |
+
> *"**Never fabricate.** No invented confidence values, areas, RMSE, run IDs, acquisition dates,
|
| 1626 |
+
> lat/lon, model outputs or execution times."* (`HANDOFF_NEXT_AGENT.md` §7, repo copy)
|
| 1627 |
+
|
| 1628 |
+
---
|
| 1629 |
+
|
| 1630 |
+
## 12. Where the evidence lives
|
| 1631 |
+
|
| 1632 |
+
| Claim class | File | What it establishes |
|
| 1633 |
+
|---|---|---|
|
| 1634 |
+
| the 8 events, the 9 states, the policy | `frontend/assets/js/core.js` | `SQ.EVENT_NAMES` (:616), `SQ.STAGES` (:605), `SQ.policy` (:623), `SQ.scene` (:207) |
|
| 1635 |
+
| the live client | `frontend/assets/js/live.js` | endpoints (:55), base URL (:99), upload (:202), infer (:294), run (:345) |
|
| 1636 |
+
| the console | `frontend/assets/js/mission.js` | `runMock` (:556), `runLive` (:601), `onEvent` (:502), `markState` (:404), the fill formula (:423) |
|
| 1637 |
+
| the cache/security policy | `frontend/_headers` | the concatenation finding (:3-9), the EO note (:66-76), the JS/CSS rule (:50-60) |
|
| 1638 |
+
| the page set and the header nav | `frontend/*.html` | 11 files; the two external links on each |
|
| 1639 |
+
| the staging pipeline | `scripts/stage_pages.mjs` | the reference walk, the 25 MiB limit (:43), the exit codes (:25-32) |
|
| 1640 |
+
| hermeticity, the film, what was not created | `docs/DEPLOYMENT_DECISION.md` | §3, §6, §7 |
|
| 1641 |
+
| the Pages tier and its one live page | `docs/DEPLOYMENT_TOPOLOGY.md` | §3.1 and the header correction |
|
| 1642 |
+
| the status board, the Cloudflare finding, B-08 | `docs/FINAL_DELIVERY_TODO.md` | §1.4, §1.7 item 9, §4, §5, §6 |
|
| 1643 |
+
| the live validation and the harness trap | `DELIVERY_REPORT_2026-09-25.md` | §1, §3, §5 |
|
| 1644 |
+
| the hard constraints, the harness rules | session `HANDOFF_NEXT_AGENT.md` | §4, §5 |
|
| 1645 |
+
| the design law and the imagery-honesty rule | repo `HANDOFF_NEXT_AGENT.md` | §7 |
|
| 1646 |
+
|
| 1647 |
+
### 12.1 Cross-references
|
| 1648 |
+
|
| 1649 |
+
| For… | Read |
|
| 1650 |
+
|---|---|
|
| 1651 |
+
| the topology, the tiers, the tunnel | [02 — Deployment Topology](./02-deployment-topology.md) |
|
| 1652 |
+
| the controller's nine states in full, and the server-side events | [03 — Request Lifecycle](./03-request-lifecycle.md) §36–§38 |
|
| 1653 |
+
| the evidence records and the confidence rules the console renders | [06 — Evidence and Confidence](./06-evidence-and-confidence.md) |
|
| 1654 |
+
| the endpoints the client calls, and their envelopes | [08 — The API Contract](./08-api-contract.md) |
|
| 1655 |
+
| the health payload, the trace as an observability object, the runbook | [10 — Observability and Operations](./10-observability-and-ops.md) |
|