193 lines
6.9 KiB
Markdown
193 lines
6.9 KiB
Markdown
# ADR-105 — No Synthetic Data in Production Runtime
|
||
|
||
**Status**: Accepted
|
||
**Date**: 2026-05-17
|
||
**Scope**: `v2/crates/wifi-densepose-sensing-server/src/main.rs`
|
||
(REST handlers under `/api/v1/pose/*`, `/api/v1/info`,
|
||
`derive_pose_from_sensing`, `generate_signal_field`).
|
||
|
||
## Context
|
||
|
||
After we pulled the upstream Docker UI (`ruvnet/wifi-densepose:latest`)
|
||
and pointed it at our backend via `--ui-path /tmp/wdp_ui/ui`, the
|
||
operator inspected the rich SPA and noticed several panels showing
|
||
data we have no business showing:
|
||
|
||
* **Pose dashboard rendered a 17-keypoint skeleton** even though no
|
||
DensePose model is loaded. Trace: `derive_pose_from_sensing` →
|
||
`derive_single_person_pose` synthesised a geometric placeholder
|
||
with keypoint `confidence = 0.0` but plausible-looking coordinates.
|
||
* **`/api/v1/pose/stats.average_confidence` was the literal `0.87`**
|
||
hard-coded in the handler.
|
||
* **`/api/v1/pose/zones/summary` invented four zones** (`zone_1..4`)
|
||
marked `clear`, even though no zone configuration exists on this
|
||
deployment.
|
||
* **`/api/v1/info.features.pose_estimation` was permanently `true`**
|
||
regardless of whether a model was actually loaded.
|
||
* **`SignalField` (the 20×20 room-heatmap in WS payload) was
|
||
procedurally generated** by mapping subcarrier index `k` to angle
|
||
`2π·k/N` and dropping Gaussian hotspots at radius proportional to
|
||
variance. A single sensor has no directional information — the
|
||
resulting heatmap had no correspondence to where anything actually
|
||
was in the room. UI rendered a believable spatial visual that was
|
||
entirely a fiction.
|
||
|
||
All five were cosmetic noise hiding the real capability gap. Operator
|
||
asked for boots-on-the-ground honesty: surface real ESP32-derived
|
||
state and nothing else.
|
||
|
||
## Decisions
|
||
|
||
### D1 — `derive_pose_from_sensing` returns empty
|
||
|
||
The function body is now `Vec::new()`. The legacy heuristic
|
||
(`derive_single_person_pose` + bone-length tables) is unreachable
|
||
from production paths but left in the source for the day a real
|
||
trained pose model is wired in. All call sites compile unchanged
|
||
and just get an empty vector when there is no model.
|
||
|
||
### D2 — `/api/v1/pose/current` gated on `model_loaded`
|
||
|
||
```rust
|
||
let persons = if s.model_loaded {
|
||
s.latest_update.as_ref().and_then(|u| u.persons.clone()).unwrap_or_default()
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
```
|
||
|
||
Response now includes `"model_loaded": false` so the UI can decide
|
||
whether to render a placeholder ("No pose model loaded") or hide the
|
||
panel entirely.
|
||
|
||
### D3 — `/api/v1/pose/stats` drops the fake confidence
|
||
|
||
The hard-coded `"average_confidence": 0.87` is removed. Only
|
||
counters that come from real frame ingest remain
|
||
(`total_detections`, `frames_processed`) plus `model_loaded`.
|
||
|
||
### D4 — `/api/v1/pose/zones/summary` reports actual zone state
|
||
|
||
```json
|
||
{ "presence": <real>, "zones_configured": 0, "zones": {} }
|
||
```
|
||
|
||
No more invented `zone_1..4`. When the operator configures real
|
||
zones (open work), they get added here.
|
||
|
||
### D5 — `/api/v1/info.features.pose_estimation` reflects reality
|
||
|
||
```rust
|
||
"pose_estimation": s.model_loaded,
|
||
```
|
||
|
||
### D6 — `generate_signal_field` returns zero-filled grid
|
||
|
||
The body is now:
|
||
|
||
```rust
|
||
let grid = 20usize;
|
||
return SignalField {
|
||
grid_size: [grid, 1, grid],
|
||
values: vec![0.0; grid * grid],
|
||
};
|
||
```
|
||
|
||
UI renders blank instead of a synthesised spatial map. This is the
|
||
truthful state until a real multistatic localizer is wired (per
|
||
ADR-008 multi-AP attention or the `MultistaticFuser` already in
|
||
state). 77 lines of procedural-art code deleted.
|
||
|
||
## Files Touched
|
||
|
||
```
|
||
v2/crates/wifi-densepose-sensing-server/src/main.rs
|
||
- fn api_info (D5)
|
||
- fn pose_current (D2)
|
||
- fn pose_stats (D3)
|
||
- fn pose_zones_summary (D4)
|
||
- fn derive_pose_from_sensing (D1)
|
||
- fn generate_signal_field (D6)
|
||
docs/adr/ADR-105-no-synthetic-data-in-production-runtime.md (this)
|
||
```
|
||
|
||
Two commits:
|
||
|
||
* `9aa027e9` — D1..D5 (REST handlers + `derive_pose_from_sensing`)
|
||
* `30244d27` — D6 (`generate_signal_field` stub)
|
||
|
||
## Verified Acceptance
|
||
|
||
`/api/v1/sensing/latest` snapshot, deployment idle:
|
||
|
||
```
|
||
signal_field grid=[20,1,20], 400 values, 0 non-zero (was: random hotspots)
|
||
pose_keypoints null (was: 17-point heuristic)
|
||
persons null (was: synthesised array)
|
||
posture null (was: heuristic string)
|
||
signal_quality_score null
|
||
enhanced_motion null
|
||
vital_signs.br_bpm null (smoothed_br ≤ 1.0)
|
||
vital_signs.hr_bpm null
|
||
|
||
— still real —
|
||
features.mean_rssi -59 dBm ✓
|
||
features.variance 8.64 ✓
|
||
classification absent / present_still / present_moving / active per ADR-101
|
||
```
|
||
|
||
`/api/v1/pose/current`:
|
||
|
||
```json
|
||
{"persons": [], "total_persons": 0, "model_loaded": false, "source": "esp32"}
|
||
```
|
||
|
||
`/api/v1/info`:
|
||
|
||
```json
|
||
{"features": {..., "pose_estimation": false, ...}}
|
||
```
|
||
|
||
## Out of scope (already correct or developer-mode)
|
||
|
||
* `--source simulate` already exits with code 2 (parallel agent change).
|
||
* `--pretrain` / `--train` synthetic-fallback paths are explicit
|
||
dev-mode CLI flags. They never touch the runtime sensing path and
|
||
are out of scope for this ADR.
|
||
* `vital_signs` was already gated: `breathing_rate_bpm = Some(_)` only
|
||
when smoothed value > 1.0 BPM; otherwise `None`. No spurious BPM
|
||
reported.
|
||
* `enhanced_motion` / `enhanced_breathing` / `bssid_count` come from
|
||
`pipeline.process(&multi_ap_frame)` which consumes real CSI. When
|
||
the multi-BSSID pipeline is inactive they are `None`. Left alone.
|
||
|
||
## Open Items
|
||
|
||
* **UI badges for "no model"** — `raw.html` already renders correctly
|
||
on empty pose data; the richer Docker UI still tries to render a
|
||
skeleton from `pose_current` even when the array is empty. Need
|
||
a small UI patch: hide the pose canvas when `model_loaded == false`.
|
||
|
||
## Closed
|
||
|
||
* **Honest `enhanced_*` fields** — both `enhanced_motion` and
|
||
`enhanced_breathing` now carry a uniform `n_aps_used: u8` field
|
||
alongside the legacy `contributing_bssids` / `bssid_count`
|
||
counts. Consumers can gate on `n_aps_used >= 2` before trusting a
|
||
multi-AP enhancement. (commit 598a4b2f)
|
||
* **Real signal_field via multistatic fusion** — shipped in ADR-112.
|
||
When ≥ 2 ESP32 nodes are active, `MultistaticFuser` output drives
|
||
a coverage × activity 20×20 heatmap (isotropic Gaussian per node
|
||
position, gated by `cv²(fused_amplitude) × cross_node_coherence`).
|
||
Single-sensor / fusion-fail paths still return ADR-105's zero
|
||
grid. Map is honestly framed as coverage, not target position.
|
||
|
||
## References
|
||
|
||
* ADR-101 — classifier (only emits real-derived `motion_level`).
|
||
* ADR-103 — persistent baseline (only emits real-derived
|
||
baseline/threshold).
|
||
* [`docs/references/espectre-gap-analysis.md`](../references/espectre-gap-analysis.md)
|
||
— separate item list for what would replace each of the now-empty
|
||
outputs with real data.
|