Files
ruvnet--RuView/docs/calibration-guide.md
T
rUv e6062977c9 docs: add calibration guide and trust/engine-error diagnostics (#1456) (#1457)
Closes the two documentation gaps from #1456 (follow-up to #1401):

- docs/calibration-guide.md: what calibrate/enroll/train-room actually
  enforce, grounded in v2/crates/wifi-densepose-calibration and
  wifi-densepose-cli source (not just ADR-135/151 aspirational prose).
  Covers the hard 600-frame baseline minimum, per-anchor quality gate
  thresholds, the unsolved pet/small-motion presence-detection gap, and
  what the empty-room baseline capture actually needs (steady vs silent).
  Flags that ADR-135's drift_score/BaselineDrift staleness system is not
  implemented in code — only bank.rs's baseline_id STALE check is real.

- docs/trust-and-engine-errors.md: exact trigger conditions for
  engine_error_count vs the separate, non-sticky `demoted` privacy-class
  flag, where both are exposed (/health/ready and /api/v1/status share a
  handler), the real diagnostic gap (no per-cause breakdown, log line is
  the closest thing), the WDP_GUARD_INTERVAL_US recovery path for
  persistent clock-drift demotion, and an honest "no code path found"
  answer on whether a converted HuggingFace model explains engine errors.

Also adds both docs to the README documentation table. No code changes.
2026-07-28 00:16:17 -04:00

16 KiB
Raw Blame History

Calibration & Room Training Guide

This guide explains what actually happens — and what is actually enforced — when you run wifi-densepose calibrate, enroll, and train-room. It is written for the person setting up a room, not for developers.

Everything below was checked against the real Rust implementation in v2/crates/wifi-densepose-calibration/, v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs, and v2/crates/wifi-densepose-cli/, not just the design ADRs. Where the design documents (ADR-135, ADR-151) describe something that isn't actually built yet, this guide says so explicitly.

The three-step pipeline

wifi-densepose calibrate --port <PORT>      # Stage 1: empty-room baseline (no people)
wifi-densepose enroll --room <NAME>         # Stage 2+3: 8 guided anchors (~4 minutes)
wifi-densepose train-room --room <NAME>     # Stage 4: fit the specialist bank
wifi-densepose room-status --room <NAME>    # check what trained / what's stale
wifi-densepose room-watch --room <NAME>     # live inference

calibrate must run first — enroll refuses to start without a baseline file (--baseline ./baseline.bin by default), and train-room refuses to start without an enrollment file. Each step writes a file the next step reads; there is no way to skip a step.


1. Is there a minimum amount of data required?

Yes, and for the empty-room baseline it is a hard, enforced minimum — not a recommendation.

wifi-densepose calibrate will not produce a baseline file with fewer than 600 recorded frames (the default for every PHY tier: HT20, HT40, HE20, HE40). This is DEFAULT_MIN_FRAMES = 600 in v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs:48, and it is checked in CalibrationRecorder::finalize() (ruvsense/calibration.rs:532-538): if fewer than config.min_frames frames were recorded, finalize() returns CalibrationError::InsufficientFrames { got, need } and calibration fails outright — there is no partial/degraded baseline. This is pinned by a unit test (finalize_requires_min_frames, same file) so it isn't accidental behavior.

Important subtlety: the CLI's --duration-s flag (default 30 seconds) and the 600-frame minimum are checked independently. The capture loop in v2/crates/wifi-densepose-cli/src/calibrate.rs:135-183 stops as soon as either the duration timer expires or 600 frames have been recorded, whichever comes first. If your node streams CSI slower than the assumed 20 Hz (e.g. congested WiFi, a busier ESP32), 30 seconds may not be enough to reach 600 frames, and calibrate will fail with an explicit "insufficient frames: have X, need 600" error rather than silently producing a short baseline. If you hit this, raise --duration-s rather than overriding --min-frames.

You can override the 600-frame floor with --min-frames <N> (0 = use the tier default). The code prints an explicit warning when you do:

[calibrate] WARN: --min-frames=N overrides ADR-135 tier default (600 for ht20). This relaxes the phase-concentration guarantee; do not use in production.

(v2/crates/wifi-densepose-cli/src/calibrate.rs:112-119). Treat this as a debugging escape hatch, not a supported way to shorten setup.

The CLI also independently rejects --duration-s below 10 seconds ("Fewer frames produce unreliable phase-concentration estimates", calibrate.rs:341-348) and prints (but does not block on) a warning above 300 seconds.

Guided enrollment (enroll) has a much lower, per-anchor floor. Each of the 8 guided anchors (empty, stand_still, sit, lie_down, breathe_slow, breathe_normal, small_move, sleep_posture) is captured for a fixed duration baked into the code — 20 seconds for the static/motion anchors, 30 seconds for the two breathing anchors and sleep_posture (AnchorLabel::duration_s(), v2/crates/wifi-densepose-calibration/src/anchor.rs:98-104). This is not a CLI flag — you cannot currently shorten or lengthen an individual anchor capture from the command line.

Underneath that fixed duration, the anchor is only accepted if it clears a quality gate (AnchorQualityGate, v2/crates/wifi-densepose-calibration/src/enrollment.rs:43-53):

Threshold Default What it checks
min_frames 60 frames Anchor is rejected if fewer than 60 frames were captured — mainly catches "the ESP32 stopped streaming" mid-capture, not a real duration requirement (60 frames is a fraction of a second of streaming at typical rates)
min_presence_z 1.5 For anchors that expect a person, the mean amplitude z-score must exceed this or the anchor is rejected as "no person detected"
empty_max_z 1.0 For the empty anchor, the z-score must stay under this or it's rejected as "room not empty"
max_still_motion 0.6 (60%) For still anchors, motion-flagged frame fraction above this is rejected as "too much motion"
min_move_motion 0.3 (30%) For small_move, motion-flagged fraction below this is rejected as "not enough motion"

A rejected anchor is re-prompted, up to --attempts times (default 2). If an anchor is still rejected after all attempts, enroll moves on without it and logs "moving on without '<label>'" — enrollment does not abort; you end up with a partial anchor set.

train-room itself enforces almost nothing. It only bails if the enrollment file has zero accepted anchors at all (v2/crates/wifi-densepose-cli/src/room.rs:246-248, "no accepted anchors … re-run enroll"). There is no minimum anchor count beyond that. What actually happens with a partial anchor set is that individual specialists silently fail to train and are simply absent from the resulting bank — for example (from v2/crates/wifi-densepose-calibration/src/specialist.rs):

  • presence needs the empty anchor plus at least one anchor where a person was expected present — missing either, PresenceSpecialist::train() returns None and presence detection is unavailable in that bank.
  • anomaly needs at least 2 anchors total, of any kind.
  • restlessness needs sleep_posture (or lie_down as a fallback) and small_move.
  • posture needs at least one anchor that establishes a posture (stand_still, sit, lie_down, or sleep_posture).

So a "successful" train-room run can still produce a bank missing one or more specialists if enrollment didn't collect the anchors those specialists need. room-status (v2/crates/wifi-densepose-cli/src/room.rs) is the way to check what actually trained.

What we could not verify

The ADR-151 design document (§2.2) claims total guided enrollment is "~4 minutes of wall-clock" — that arithmetic checks out against the coded per-anchor durations (5 × 20s + 3 × 30s = 190s ≈ 3.2 min, plus a 3-second countdown before each anchor ≈ +24s, so ~3.54 minutes is consistent with the code). But we found no integration test or measurement showing that this duration is sufficient for reliable specialist accuracy — the ADR's own status section says the full baseline → enroll → train-room → infer loop is proven only against deterministic synthetic CSI (tests/full_loop.rs), not yet run start-to-finish on real hardware in an empty room. Treat the default durations as reasonable code defaults, not as a validated minimum for real-world accuracy.


Where a hard minimum does exist (the 600-frame baseline, the 60-frame per-anchor floor), it's documented above. Beyond that:

  • Baseline capture: the CLI default (--duration-s 30) is the number to use; it's what the 600-frame minimum is designed around at the assumed 20 Hz sensing rate. ADR-135 §2.3 argues 30 s is the shortest duration that keeps the phase-concentration estimate's standard deviation under 0.02 rad², citing published circular-statistics error bounds — but this is a paper-derived justification for the default value, not a code-enforced floor beyond the 600-frame check itself.
  • Enrollment anchors: use the built-in per-anchor durations (20s/30s) — there's currently no way to change them from the CLI anyway.

3. Will a pet get classified as "occupied"?

Honest answer: the code has no way to distinguish a pet (or any small/animal-scale motion) from a person. This is a real limitation, not a solved problem — flagging it here rather than guessing.

Presence detection (PresenceSpecialist, v2/crates/wifi-densepose-calibration/src/specialist.rs:100-198) is trained purely from two scalar channels measured during enrollment:

  • variance of the CSI amplitude series, thresholded at the midpoint between the empty anchor's variance and the mean variance of the person-present anchors;
  • mean shift|mean empty_mean|, thresholded at half the empty→occupied mean distance.

Presence fires if either channel crosses its threshold. Both thresholds are learned entirely from the amplitude statistics of your enrollment anchors — there is no body-size, RCS (radar cross-section), Doppler-signature, or any other physical feature in this code that separates "a full-grown adult moved" from "a cat walked past" or "a dog jumped on the couch." If a pet's motion perturbs the CSI amplitude by roughly the same amount as the small_move anchor did during your enrollment, PresenceSpecialist will read it as occupied, because that's mechanically what the threshold measures.

The closest thing to a safeguard is AnomalySpecialist (specialist.rs:386-448), a generic novelty detector that flags a live window as "anomalous" when it's far (in embedding distance) from every enrolled anchor prototype. It is not a validated pet filter — it will flag any statistically unusual signal as anomalous or normal depending on how close it happens to land to your anchors, with no guarantee it distinguishes species or motion source. A pet whose motion pattern happens to resemble the small_move anchor would not be flagged as anomalous at all.

Practical takeaway for a homeowner with pets: expect presence/posture readings to occasionally trigger on pet motion, especially larger animals or motion near the sensor. There is currently no configuration option or code path to suppress this.


4. Does the empty-room baseline need "typical" conditions (HVAC running) or true silence?

The short answer, grounded in how the baseline is actually computed: a stationary, continuously-running interferer (a fan, HVAC blower, humidifier) that is present for the entire capture window becomes part of what "empty" means, and gets subtracted out naturally — that's a direct consequence of how the statistics are computed, not a documented feature you have to configure.

CalibrationRecorder uses Welford's online algorithm to accumulate a running mean and variance per subcarrier over however many frames you feed it (ruvsense/calibration.rs). If a fan is running steadily the whole time you capture the baseline, its contribution is baked into amp_mean/amp_variance for every frame equally, so the resulting baseline already represents "empty room with the fan on" — and at runtime, BaselineCalibration::subtract() removes exactly that reference, so a room in the same steady state reads as quiet. The design intent documented in ADR-135 §1.1 is explicit about this: the whole point of baseline subtraction is to remove "hardware-induced gain bias and environment-fixed multipath" so downstream motion detectors aren't tripped by things that are always there.

What actually matters is consistency, not silence: capture the baseline under whatever background conditions the room will normally be in during real use (HVAC/fans running as usual), and try to keep the room in that same steady state for the entire capture window. What the code cannot correct for is a background condition that changes partway through the capture (e.g. HVAC cycles on 15 seconds into a 30-second capture) — that would bias the Welford mean/variance toward an in-between state that matches neither "HVAC off" nor "HVAC on" well.

There is a real-time guard during capture that can catch gross problems: --abort-z-threshold (default 2.0) aborts the capture if the per-frame amplitude z-score median stays above that threshold for 20 consecutive banner intervals (v2/crates/wifi-densepose-cli/src/calibrate.rs:82-83, 163-178). This is designed to catch someone walking through mid-capture, not necessarily short-duration mechanical noise — we found no test exercising it against an HVAC-cycling scenario specifically, so how it behaves for "appliance turns on mid-capture" is unverified.

What we could not verify — and a design gap worth knowing about

ADR-135 §2.5 describes a much more sophisticated staleness-detection system: a drift_score computed from ongoing z-scores, a BaselineDrift event fired after sustained drift, and a baseline_stale flag published over the sensing WebSocket. We searched the actual calibration.rs implementation and none of that exists in code — there is no drift_score field, no BaselineDrift event, and no baseline_stale flag anywhere in v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs. That part of ADR-135 is aspirational design, not shipped behavior.

What is implemented, at a different layer, is a much simpler check on the trained specialist bank (not the raw baseline): SpecialistBank stores the baseline_id it was trained against, and SpecialistBank::is_stale() (v2/crates/wifi-densepose-calibration/src/bank.rs:102-104) returns true whenever the current baseline's id doesn't match the id the bank was trained on. Re-running calibrate always produces a new baseline id, so any recalibration — whether because of furniture moving, a genuinely stale reference, or just re-running the command — immediately marks every previously trained specialist bank stale, and you'll need to re-run enroll and train-room afterward. There is no partial/graded staleness signal (no "how stale"), only this all-or-nothing id comparison.

Practical guidance:

  1. Calibrate with the room in its normal, steady background state (HVAC, fans, fridge compressor, etc. running as they normally would) and keep that state constant for the whole --duration-s window.
  2. If you significantly change background conditions later (move furniture, add a permanent appliance, change HVAC routine) or notice the sensing quality degrade, re-run calibrate — this is an explicit, operator-driven step; there is no code path that recalibrates for you.
  3. Re-running calibrate invalidates every specialist bank trained against the old baseline (via the baseline_id mismatch above) — plan to re-run enroll and train-room right after.

Quick reference: commands and defaults actually in the code

# Stage 1 — empty-room baseline. Room must be empty for the whole window.
wifi-densepose calibrate \
  --udp-port 5005 --duration-s 30 --tier ht20 --output ./baseline.bin
# Hard requirement: >= 600 recorded frames, or calibration fails.

# Stage 2+3 — guided enrollment (8 fixed anchors, ~4 minutes total)
wifi-densepose enroll --baseline ./baseline.bin --room living-room \
  --output ./enrollment.json --attempts 2

# Stage 4 — train the specialist bank from whatever anchors were accepted
wifi-densepose train-room --enrollment ./enrollment.json \
  --output ./room-bank.json

# Check what actually trained (and whether the bank is stale)
wifi-densepose room-status --room living-room

Source references for everything above:

  • v2/crates/wifi-densepose-cli/src/calibrate.rs
  • v2/crates/wifi-densepose-cli/src/room.rs
  • v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs
  • v2/crates/wifi-densepose-calibration/src/enrollment.rs
  • v2/crates/wifi-densepose-calibration/src/anchor.rs
  • v2/crates/wifi-densepose-calibration/src/specialist.rs
  • v2/crates/wifi-densepose-calibration/src/bank.rs
  • docs/adr/ADR-135-empty-room-baseline-calibration.md
  • docs/adr/ADR-151-room-calibration-specialist-training.md