wifi-densepose/docs/adr/ADR-105-no-synthetic-data-i...

6.9 KiB
Raw Blame History

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_sensingderive_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

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

{ "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

"pose_estimation": s.model_loaded,

D6 — generate_signal_field returns zero-filled grid

The body is now:

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:

{"persons": [], "total_persons": 0, "model_loaded": false, "source": "esp32"}

/api/v1/info:

{"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 — separate item list for what would replace each of the now-empty outputs with real data.