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.md
Browse files- docs/ARCHITECTURE.md +201 -0
docs/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Architecture
|
| 2 |
+
|
| 3 |
+
**Status vocabulary used throughout these docs:** `IMPLEMENTED` (code exists and runs) Β·
|
| 4 |
+
`VERIFIED` (checked against evidence) Β· `MEASURED` (a number was produced) Β· `ATTEMPTED`
|
| 5 |
+
(tried, outcome recorded) Β· `NOT RUN` Β· `BLOCKED` Β· `DEFERRED` Β· `REJECTED` (tried and
|
| 6 |
+
explicitly not accepted). Every architectural claim below carries one of these tags.
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## 1. What the system is
|
| 11 |
+
|
| 12 |
+
SatQuery AI answers natural-language questions about satellite imagery. It is a **router +
|
| 13 |
+
specialists** system: a small intent router reads the question, dispatches it to one of six
|
| 14 |
+
task specialists, and assembles the specialist's output into a single structured
|
| 15 |
+
`ResultEnvelope` that the browser renders as an answer, an evidence list, and a confidence.
|
| 16 |
+
|
| 17 |
+
It is not a single end-to-end vision-language model. The only learned components are:
|
| 18 |
+
|
| 19 |
+
- a **50,822-parameter router adapter** over a frozen `all-MiniLM-L6-v2` sentence encoder;
|
| 20 |
+
- four trained **task heads/adapters** (change, change_vqa, optical-SAR fusion, grounding);
|
| 21 |
+
- one **LoRA adapter** over a frozen `SmolVLM-500M-Instruct` for caption/VQA.
|
| 22 |
+
|
| 23 |
+
Everything else is frozen, publicly-pinned backbone weights resolved at run time.
|
| 24 |
+
|
| 25 |
+
## 2. Deployment topology (IMPLEMENTED, VERIFIED)
|
| 26 |
+
|
| 27 |
+
The shipped system is a four-tier chain. **Status: VERIFIED live on 2026-09-25** β the health,
|
| 28 |
+
capabilities and infer endpoints were probed against the running deployment.
|
| 29 |
+
|
| 30 |
+
```
|
| 31 |
+
Browser
|
| 32 |
+
β HTTPS
|
| 33 |
+
βΌ
|
| 34 |
+
Cloudflare Pages β satquery.pages.dev (static frontend, 11 pages)
|
| 35 |
+
β HTTPS/JSON
|
| 36 |
+
βΌ
|
| 37 |
+
Render β satquery-backend-m4yv.onrender.com (orchestrator / gateway)
|
| 38 |
+
β outbound long-poll tunnel POST /tunnel/agent
|
| 39 |
+
βΌ
|
| 40 |
+
GitHub Codespace β FastAPI inference, port 8000 (CPU)
|
| 41 |
+
β build_space_app()
|
| 42 |
+
βΌ
|
| 43 |
+
Specialists: SmolVLM Β· RemoteCLIP Β· MiniLM Β· CROMA Β· STANet
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
```mermaid
|
| 47 |
+
flowchart LR
|
| 48 |
+
U[Browser] -->|HTTPS| CF["Cloudflare Pages<br/>static frontend"]
|
| 49 |
+
CF -->|"HTTPS JSON<br/>/api/health Β· /api/capabilities Β· /api/infer"| R["Render<br/>orchestrator / gateway"]
|
| 50 |
+
R -->|"outbound long-poll<br/>POST /tunnel/agent"| C["GitHub Codespace<br/>FastAPI inference :8000"]
|
| 51 |
+
C --> S[(SmolVLM Β· RemoteCLIP<br/>MiniLM Β· CROMA Β· STANet)]
|
| 52 |
+
C -->|ResultEnvelope| R
|
| 53 |
+
R -->|"envelope + error translation"| CF
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### 2.1 Why a gateway and a tunnel
|
| 57 |
+
|
| 58 |
+
- **The gateway (Render)** is deliberately thin and stateless: schema validation, size limits,
|
| 59 |
+
per-IP rate limiting, a CORS allowlist (**never** `*`), request IDs, timeouts, secret custody,
|
| 60 |
+
and translation of upstream failures into the documented error envelope. It is **not** a model
|
| 61 |
+
host and has no database, auth, or queue.
|
| 62 |
+
- **The tunnel** exists because the inference host (a Codespace) is not publicly addressable for
|
| 63 |
+
this private repository β a forwarded port returns `302`. Instead the Codespace runs
|
| 64 |
+
`tunnel_agent.py`, which **dials out** to `POST /tunnel/agent` and long-polls. Work is executed
|
| 65 |
+
against `http://127.0.0.1:8000` locally. This inverts the usual direction: the inference host
|
| 66 |
+
needs no inbound firewall hole.
|
| 67 |
+
|
| 68 |
+
> **Status:** IMPLEMENTED and VERIFIED. `GET /api/health` reported `tunnel.agent_connected:true`
|
| 69 |
+
> with a non-zero `completed` counter on 2026-09-25.
|
| 70 |
+
|
| 71 |
+
### 2.2 Cold start and the `transport_mode: auto` fallthrough (KNOWN ISSUE, OPEN)
|
| 72 |
+
|
| 73 |
+
The Render free tier sleeps when idle and the Codespace may be stopped. Cold start is therefore
|
| 74 |
+
tens of seconds and is **documented, not hidden**. A timeout on the tunnel path is surfaced to the
|
| 75 |
+
client as a recoverable error.
|
| 76 |
+
|
| 77 |
+
`SATQUERY_TRANSPORT=auto` means: try the tunnel; if it does not answer within
|
| 78 |
+
`SATQUERY_TUNNEL_TIMEOUT_S` (150 s), **fall through to the forward path**. The forward path to a
|
| 79 |
+
private repo returns `302` quickly, but the wake step still consumes `SATQUERY_WAKE_TIMEOUT_S`
|
| 80 |
+
(120 s) first β so a worst-case failed request can take β 249 s. This is the root of the observed
|
| 81 |
+
"transient tunnel gap" and is recorded as **OPEN** (not fixed) in
|
| 82 |
+
[`LIMITATIONS.md`](LIMITATIONS.md). A deployed fix (`codespace_name` newline handling on the
|
| 83 |
+
wake path) was authored and pushed separately; the fallthrough itself is by design.
|
| 84 |
+
|
| 85 |
+
## 3. Request lifecycle (IMPLEMENTED, VERIFIED)
|
| 86 |
+
|
| 87 |
+
The controller is a nine-state machine, declared in `configs/base.yaml`:
|
| 88 |
+
|
| 89 |
+
```
|
| 90 |
+
RECEIVE β PARSE β VALIDATE β PLAN β PREPROCESS β EXECUTE β AGGREGATE β VERIFY β RESPOND
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
1. **RECEIVE / PARSE** β the query and 1β2 image assets are decoded.
|
| 94 |
+
2. **VALIDATE** β modality inference from band count, dimension equality for pairs, byte cap
|
| 95 |
+
(`4,194,304` bytes/file), GeoTIFF acceptance for the optical-SAR path.
|
| 96 |
+
3. **PLAN** β the router embeds the query with the frozen MiniLM encoder and the trained adapter
|
| 97 |
+
predicts a task + modality. Dispatch is asset-count-aware: a single image cannot be a change
|
| 98 |
+
task.
|
| 99 |
+
4. **PREPROCESS** β percentile normalisation for optical (2/98), dB clip for SAR (β30β¦+5 dB),
|
| 100 |
+
tiling for large scenes.
|
| 101 |
+
5. **EXECUTE** β the chosen specialist runs.
|
| 102 |
+
6. **AGGREGATE** β the specialist's raw output becomes `Evidence` items
|
| 103 |
+
(`Region` / `ChangeRegion`).
|
| 104 |
+
7. **VERIFY** β confidence is computed; temperature scaling is applied from
|
| 105 |
+
`calibration_v001.json`.
|
| 106 |
+
8. **RESPOND** β a `ResultEnvelope` is assembled and returned.
|
| 107 |
+
|
| 108 |
+
### 3.1 The eight execution events
|
| 109 |
+
|
| 110 |
+
The frontend renders a live trace from eight named events (source of truth:
|
| 111 |
+
`frontend/assets/js/core.js`, `SQ.EVENT_NAMES`):
|
| 112 |
+
|
| 113 |
+
| # | Event | Emitted when |
|
| 114 |
+
|---|---|---|
|
| 115 |
+
| 1 | `QUERY_RECEIVED` | the request is accepted |
|
| 116 |
+
| 2 | `QUERY_UNDERSTOOD` | the router produces task + modality |
|
| 117 |
+
| 3 | `ROUTE_SELECTED` | the specialist is chosen |
|
| 118 |
+
| 4 | `SPECIALIST_STARTED` | specialist execution begins |
|
| 119 |
+
| 5 | `SPECIALIST_COMPLETED` | specialist returns |
|
| 120 |
+
| 6 | `EVIDENCE_GENERATED` | evidence items are built |
|
| 121 |
+
| 7 | `CONFIDENCE_COMPUTED` | confidence (and calibration) is computed |
|
| 122 |
+
| 8 | `RESULT_ASSEMBLED` | the envelope is finalised |
|
| 123 |
+
|
| 124 |
+
**Live runs emit all eight; the offline preview path emits none of the specialist events and is
|
| 125 |
+
labelled as a preview in the UI.** Measured trace fill on every live run: **94.4444 %**.
|
| 126 |
+
|
| 127 |
+
## 4. Two reading paths: `interpret()` vs `chooseTask()` (IMPLEMENTED, VERIFIED)
|
| 128 |
+
|
| 129 |
+
There are two separate functions, and they see different information:
|
| 130 |
+
|
| 131 |
+
- **`interpret()`** β produces the *human-readable* interpretation shown in the console. It is
|
| 132 |
+
**asset-count-blind**: it only sees the query text.
|
| 133 |
+
- **`chooseTask()`** β performs the *dispatch*. It is **asset-count-aware**: it knows how many
|
| 134 |
+
assets are attached and will not route a single-image query to a two-image task.
|
| 135 |
+
|
| 136 |
+
This asymmetry is real and explains a documented behaviour: for the query *"What changed between
|
| 137 |
+
the earlier and later image?"* with a single asset attached, the console can *read* `change` while
|
| 138 |
+
dispatch correctly falls back to `change_vqa`. This is intentional, not a bug, and is covered in
|
| 139 |
+
[`RESEARCH_NOTES.md`](RESEARCH_NOTES.md).
|
| 140 |
+
|
| 141 |
+
## 5. The four-endpoint inference contract (IMPLEMENTED, VERIFIED)
|
| 142 |
+
|
| 143 |
+
The Codespace exposes exactly four routes; the gateway mirrors each under `/api/*`:
|
| 144 |
+
|
| 145 |
+
| Codespace | Gateway | Cost | Purpose |
|
| 146 |
+
|---|---|---|---|
|
| 147 |
+
| `GET /v1/health` | `GET /api/health` | cheap | liveness, tunnel state, counters |
|
| 148 |
+
| `GET /v1/capabilities` | `GET /api/capabilities` | cheap | per-task availability |
|
| 149 |
+
| `POST /v1/analyze` | `POST /api/infer` | **costly** | run a query |
|
| 150 |
+
| `POST /v1/assets` | `POST /api/assets` | **costly** | stage an asset handle |
|
| 151 |
+
|
| 152 |
+
The gateway must **not** retry `POST /api/infer` on its own β a retry would consume inference a
|
| 153 |
+
second time. The client decides on retry. Errors are translated into a stable envelope with a
|
| 154 |
+
machine code (`invalid_request`, `tunnel_offline`, β¦) and a `recoverable` flag.
|
| 155 |
+
|
| 156 |
+
**Verified:** `POST /api/infer {}` returns `422 invalid_request` with header
|
| 157 |
+
`x-satquery-transport: tunnel`.
|
| 158 |
+
|
| 159 |
+
## 6. The frozen configuration (VERIFIED)
|
| 160 |
+
|
| 161 |
+
All tunables live in `configs/base.yaml`; **no magic numbers in Python**. The loader validates
|
| 162 |
+
invariants and computes a hash. The frozen hash is:
|
| 163 |
+
|
| 164 |
+
```
|
| 165 |
+
78f1e3700da15aa1
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
The loader **refuses to run** a config that violates a recorded invariant β for example
|
| 169 |
+
`fusion.input_dim == 3 * croma.encoder_dim + croma.optical_channels + croma.sar_channels`
|
| 170 |
+
(2318) and `grounding_head.feature_dim == 4 * grounding.encoder_projected_dim` (2048). A mismatch
|
| 171 |
+
in the latter is a *silent* shape error otherwise (torch raises only later, after features are
|
| 172 |
+
cached), so it is enforced at load time.
|
| 173 |
+
|
| 174 |
+
Editing `configs/base.yaml` moves the hash, which invalidates every artifact keyed to it. This is
|
| 175 |
+
why the stale `configs/deploy.yaml` (which still describes an HF-Space/ZeroGPU target) is left
|
| 176 |
+
**undisturbed as frozen paperwork** β it is never read at run time.
|
| 177 |
+
|
| 178 |
+
## 7. Component map
|
| 179 |
+
|
| 180 |
+
| Layer | Path (in the source repo) | Notes |
|
| 181 |
+
|---|---|---|
|
| 182 |
+
| Frontend | `frontend/` | static, staged by `scripts/stage_pages.mjs` |
|
| 183 |
+
| Gateway | `deploy/render/` | `main.py` exposes `app`; `render.yaml` blueprint |
|
| 184 |
+
| Inference entry | `deploy/codespace/serve.py` | serves `build_space_app()` on `$PORT` |
|
| 185 |
+
| Inference app | `app/space_app.py` | `build_space_app()`; 4-endpoint contract |
|
| 186 |
+
| Composition root | `app/serving.py` | reuses the local serving path |
|
| 187 |
+
| Specialists | `specialists/` | vqa/caption (SmolVLM), grounding (RemoteCLIP), change (STANet), optical_sar (CROMA) |
|
| 188 |
+
| Router | `router/` | frozen MiniLM + trained adapter |
|
| 189 |
+
| Config | `configs/base.yaml` | authoritative registry; hashed |
|
| 190 |
+
| Artifacts | `artifacts/` | weights, metrics, calibration, reports |
|
| 191 |
+
|
| 192 |
+
> **Caveat.** `deploy/` inside the monorepo is **stale and untracked**; the deployed sources live in
|
| 193 |
+
> three separate private repositories. See [`DEPLOYMENT.md`](DEPLOYMENT.md).
|
| 194 |
+
|
| 195 |
+
## 8. What is deliberately absent
|
| 196 |
+
|
| 197 |
+
- **No database, no auth, no queue.** The gateway is stateless by design.
|
| 198 |
+
- **No GPU requirement.** Device is selected via `SATQUERY_DEVICE`; all placement is `.to(device)`,
|
| 199 |
+
never `.cuda()`. The shipped deployment runs CPU-only.
|
| 200 |
+
- **No system-level end-to-end benchmark.** There is no measured end-to-end accuracy number, and
|
| 201 |
+
none is claimed. Per-specialist metrics exist and are reported in [`BENCHMARKS.md`](BENCHMARKS.md).
|