mirror of
https://github.com/ruvnet/RuView
synced 2026-06-23 12:33:18 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f13ed4886 | |||
| eed1a47f3e | |||
| 075344b023 |
File diff suppressed because one or more lines are too long
@@ -1,92 +0,0 @@
|
||||
# ADR-177: `nvsim` Degenerate-Input Hardening (NV-Diamond Simulator)
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — 2 real MEDIUM bugs fixed + pinned; determinism preserved |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **NVSIM-FAILCLOSED** |
|
||||
| **Reviews** | ADR-089 (`nvsim` NV-diamond magnetometer pipeline simulator) |
|
||||
| **Milestone** | #9 (ungated-crate security sweep) — crate 2 of 4 |
|
||||
|
||||
## Context
|
||||
|
||||
`nvsim` (ADR-089) is a standalone, **WASM-ready** deterministic NV-diamond
|
||||
magnetometer pipeline simulator — a forward-only leaf:
|
||||
`scene → source → propagation → NV ensemble → digitiser → MagFrame + SHA-256
|
||||
witness`. It has no network surface, so the real attack surface is **degenerate
|
||||
physical-parameter input** crossing the external boundary — specifically the
|
||||
WASM `config_json` / `scene_json` entry points.
|
||||
|
||||
Two properties matter for this crate that don't for others: it is billed
|
||||
**deterministic** (a published cross-machine witness must reproduce bit-exactly),
|
||||
and under `panic=abort` WASM any panic **aborts the whole module**. So a
|
||||
config-induced panic is a denial-of-service, and a silent numeric corruption
|
||||
defeats the simulator's entire purpose.
|
||||
|
||||
## Decision
|
||||
|
||||
Fix the two reachable degenerate-input bugs at their funnel points, each pinned
|
||||
by a fails-on-old test, **without perturbing the deterministic happy path** (the
|
||||
guards fire only on non-finite / degenerate input; the published witness is
|
||||
unchanged).
|
||||
|
||||
### Findings fixed (both MEASURED-reproduced)
|
||||
|
||||
| # | Severity | Location | Issue | Fix |
|
||||
|---|----------|----------|-------|-----|
|
||||
| NVSIM-DT-01 | MEDIUM (DoS) | `pipeline.rs:58,95` | `dt = config.dt_s.unwrap_or(1.0 / f_s_hz)`; an external `f_s_hz == 0.0` → `dt = +Inf` → `(dt*1e6) as u64` saturates to `u64::MAX` → `(sample as u64) * dt_us` **panics `attempt to multiply with overflow`** at `sample ≥ 2` (debug/WASM-abort; garbage `t_us` in release). MEASURED: panic at `pipeline.rs:95:30`. | Sanitise `dt` (non-finite/non-positive → 1 µs fallback), cap the `u64` cast at `u64::MAX`, `saturating_mul` the timestamp — no config can overflow it. |
|
||||
| NVSIM-NAN-01 | MEDIUM (silent corruption) | funnel `digitiser.rs::adc_quantise` (root: near-field clamp bypass in `source.rs`) | A non-finite scene param (NaN/Inf dipole position, Inf moment, NaN loop radius) **bypasses the near-field clamp** (`NaN < R_MIN_M == false` → the `1/r³` path runs → NaN field), and at the ADC `NaN as i32 == 0` (Rust saturating cast) emits a frame `b_pt=[0,0,0]` with **`ADC_SATURATED` CLEAR** — indistinguishable from a legitimate zero-field reading. MEASURED: `b=[NaN,NaN,NaN] sat=false` → `b_pt=[0,0,0] flags=0b0000`. | `adc_quantise`: any non-finite input → code `0` **with the saturation flag raised**; the pipeline's existing `adc_sat` OR-reduction propagates `ADC_SATURATED` onto the frame, making the corruption visible downstream. |
|
||||
|
||||
This is the same **NaN-fail-open / NaN-poisoning** family seen across
|
||||
calibration/vitals/geo and ruview-swarm — non-finite input defeating a guard —
|
||||
but bounded here to a single frame (no cross-timestep accumulator).
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
|
||||
1. **Determinism integrity — clean.** One RNG only: `ChaCha20Rng::seed_from_u64(seed)`,
|
||||
fully caller-seeded (grep: one `seed_from_u64`, **zero** `thread_rng`/`getrandom`/
|
||||
`SystemTime`/`Instant`/`HashMap`); `Cargo.toml` pins `rand`/`rand_chacha`
|
||||
`default-features=false` (no OS entropy). Box–Muller draws
|
||||
`gen_range(f64::EPSILON..=1.0)` (avoids `ln(0)=-Inf` by construction). Frame
|
||||
bytes fixed LE; source summation order fixed by `Vec` order. **The published
|
||||
cross-machine witness `cc8de9b0…93b4` (`proof_witness_publishes_a_known_value`)
|
||||
passes UNCHANGED after both fixes** — the happy path is byte-identical; guards
|
||||
touch only degenerate inputs. *Attested caveat (not a finding): libm
|
||||
`cos`/`ln`/`sqrt` could differ x86↔wasm; the witness is documented as
|
||||
x86_64-captured.*
|
||||
2. **Panic-free deserialisation — clean.** `MagFrame::from_bytes` validates
|
||||
len/magic/version, then per-field `buf[a..b].try_into().expect(...)` are over
|
||||
fixed sub-ranges of an already-length-checked 60-byte buffer (provably
|
||||
infallible). No `unsafe`, no `panic!`/`unreachable!` in production; every other
|
||||
`unwrap`/`expect` is `#[cfg(test)]`.
|
||||
3. **Div-by-zero / numerical landmines — clean.** `dipole_field`/`current_loop_field`
|
||||
clamp `r_norm < R_MIN_M` before `1/r³`,`1/r²` (finite inputs); `shot_noise_floor`
|
||||
guards `denom <= 0`; `vec3_normalise` guards `n < 1e-20`. The only hole was the
|
||||
NaN *bypass* of the clamp — closed at the ADC funnel (NVSIM-NAN-01).
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo test -p nvsim --no-default-features` → **50 → 53** passed, 0 failed (+3 pins:
|
||||
`degenerate_zero_sample_rate_does_not_panic`,
|
||||
`non_finite_scene_input_flags_frame_instead_of_silently_zeroing`,
|
||||
`adc_quantise_flags_non_finite_as_saturated`).
|
||||
- `cargo test --workspace --no-default-features` → **exit 0**, 0 failed.
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash
|
||||
`f8e76f21…46f7a` unchanged (nvsim off the signal proof path).
|
||||
- nvsim's own cross-machine witness `cc8de9b0…93b4` reproduces unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- A config-induced WASM-abort DoS and a silent NaN→fake-zero-field corruption are
|
||||
closed at their funnel points, each regression-pinned, with the deterministic
|
||||
witness proven intact.
|
||||
|
||||
### Negative / Neutral
|
||||
- None. Guards affect only degenerate inputs; happy-path output is byte-identical.
|
||||
|
||||
## Links
|
||||
- ADR-089 — `nvsim` NV-diamond magnetometer simulator
|
||||
- ADR-176 — `ruview-swarm` (sibling NaN-fail-open review)
|
||||
- ADR-172 — core/cli (where the NaN-bug-class root was settled NO)
|
||||
@@ -1,87 +0,0 @@
|
||||
# ADR-178: `wifi-densepose-desktop` IPC Injection Fix + Capability Least-Privilege
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — 2 real MODERATE bugs fixed + pinned (MEASURED on Windows) |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **DESK-LOCKDOWN** |
|
||||
| **Reviews** | `wifi-densepose-desktop` (Tauri v2 desktop app) |
|
||||
| **Milestone** | #9 (ungated-crate security sweep) — crate 3 of 4 |
|
||||
|
||||
## Context
|
||||
|
||||
`wifi-densepose-desktop` is the Tauri v2 desktop app (ESP32 discovery, firmware
|
||||
flashing, OTA, provisioning, server control). The real attack surface is the
|
||||
**Tauri IPC boundary** — `#[tauri::command]` handlers that take arguments from the
|
||||
webview/JS — and the **capability/allowlist scope**. The crate **builds and tests
|
||||
on Windows** (Tauri 2.10.3, webview2 path, no GTK), so both findings are MEASURED,
|
||||
not source-analysis-only.
|
||||
|
||||
## Decision
|
||||
|
||||
Fix the two real findings; attest the rest of the surface clean with evidence.
|
||||
|
||||
### Findings fixed (both MEASURED)
|
||||
|
||||
| # | Severity | Location | Issue | Fix |
|
||||
|---|----------|----------|-------|-----|
|
||||
| WDP-DESK-01 | MODERATE | `src/commands/discovery.rs:438` (`configure_esp32_wifi`) | Webview-supplied `ssid`/`password` are concatenated into newline-terminated serial commands (`wifi_config {} {}\r\n`, `set ssid {}\r\n`) with **no validation** → a `\r\n` in either field **injects an arbitrary follow-up firmware command** (`reboot`, `erase_nvs`) across the IPC trust boundary. | `validate_wifi_credentials()` — WPA2 length bounds (SSID 1–32, password 8–63) **+ reject all control chars** (`char::is_control()`), called fail-closed before any serial write. |
|
||||
| WDP-DESK-02 | MODERATE | `capabilities/default.json:7-8` | `shell:allow-execute` + `shell:allow-open` granted to the webview but **unused** (Rust spawns via `std::process::Command`; the UI uses only `dialog.open`). A webview compromise (a UI-dependency XSS) → arbitrary **unscoped host command execution**. | Removed both `shell:` permissions (kept `core:default` + the two in-use `dialog:` perms); regenerated `gen/schemas/capabilities.json` now asserts `["core:default","dialog:allow-open","dialog:allow-save"]`. |
|
||||
|
||||
Both are MODERATE (not HIGH): each requires a webview compromise or a malicious
|
||||
local caller to weaponize. The unifying lesson is **least privilege at the IPC
|
||||
boundary** — validate every webview-supplied argument that reaches a serial/FS/
|
||||
process sink, and grant only the capabilities actually exercised.
|
||||
|
||||
### Tauri-command + capability audit (every handler)
|
||||
|
||||
All 30+ command handlers were mapped. Only `configure_esp32_wifi` lacked input
|
||||
validation on a string that reached a command sink (WDP-DESK-01). Every
|
||||
subprocess uses `Command::new(prog).args([...])` (argv vector — no shell-string
|
||||
interpolation), so `port`/`source`/`chip`/`baud` cannot inject a second command
|
||||
even unvalidated. `tauri.conf.json` ships **no** `fs`/`http` plugin and **no**
|
||||
`"all":true`/`"$HOME/**"` scope; after WDP-DESK-02 the allowlist is minimal.
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
|
||||
1. **Directory traversal / arbitrary file** — path args (`firmware_path`/`wasm_path`)
|
||||
are blobs the local user selects via the native `dialog.open` picker; settings
|
||||
I/O is a fixed filename under `app_data_dir`. No attacker-named path sink.
|
||||
2. **Shell-string injection** — every subprocess is an argv vector; grep found no
|
||||
shell-string interpolation anywhere.
|
||||
3. **SSRF-to-secret** — `node_ip`-built URLs target the local ESP32 mesh and return
|
||||
only device status JSON; no credential returned to the webview.
|
||||
4. **Panic-on-input** — handlers use `.map_err(|e| e.to_string())?`; the one
|
||||
`expect` is guarded by an `is_none()` early-return; provision/discovery
|
||||
deserializers bounds-check every slice index (NVS size capped ≤ 4096).
|
||||
5. **Hardcoded secrets** — `ota_psk` is a per-call `Option<String>`, never embedded;
|
||||
grep for embedded keys/tokens over `src/` is empty.
|
||||
6. **Shell plugin genuinely unused** — `tauri_plugin_shell` is `init()`-ed but its
|
||||
`Command`/`open` API is never invoked from Rust or the TS UI (which imports only
|
||||
`@tauri-apps/plugin-dialog`) — confirming WDP-DESK-02 is safe to remove.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo check -p wifi-densepose-desktop --no-default-features` → `Finished` (Windows, MEASURED).
|
||||
- `cargo test -p wifi-densepose-desktop --no-default-features` → lib **18 → 21** (+3 validator pins:
|
||||
`test_validate_wifi_credentials_rejects_injection` / `_rejects_out_of_range` / `_accepts_valid`),
|
||||
integration 21/21, **0 failed**.
|
||||
- Capability narrowing MEASURED: regenerated `capabilities.json` permission set verified.
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash `f8e76f21…46f7a`
|
||||
unchanged (desktop off the signal proof path).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- An IPC serial-command-injection path and an over-broad shell capability are
|
||||
closed in the desktop app, each pinned / verified, with the rest of the
|
||||
30-command IPC surface attested clean.
|
||||
|
||||
### Negative / Neutral
|
||||
- None. The removed shell capability was unused; the validator rejects only
|
||||
malformed/hostile credentials.
|
||||
|
||||
## Links
|
||||
- ADR-176 / ADR-177 — sibling Milestone-#9 reviews (ruview-swarm, nvsim)
|
||||
- ADR-172 — core/cli review
|
||||
@@ -1,81 +0,0 @@
|
||||
# ADR-179: `wifi-densepose-occworld-candle` Checkpoint-Load Hardening
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — 1 HIGH + 2 LOW bugs fixed + pinned (MEASURED on Windows) |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **OCCWORLD-DTYPE** |
|
||||
| **Reviews** | `wifi-densepose-occworld-candle` (Candle occupancy-world model) |
|
||||
| **Milestone** | #9 (ungated-crate security sweep) — crate 4 of 4 — **CLOSES the milestone** |
|
||||
|
||||
## Context
|
||||
|
||||
`wifi-densepose-occworld-candle` is a Candle-based occupancy-world model
|
||||
(VQ-VAE + transformer over occupancy tokens). The real risk surface for an ML
|
||||
crate is degenerate-input / malformed-weights handling: a `#[forbid(unsafe_code)]`
|
||||
crate can still **panic** (a DoS, and under WASM an abort) when a tensor op hits an
|
||||
inconsistent shape. The crate **builds and tests on Windows**, so all findings are
|
||||
MEASURED.
|
||||
|
||||
## Decision
|
||||
|
||||
Fix the three reachable bugs, each pinned by a fails-on-old test; attest the rest
|
||||
clean with evidence.
|
||||
|
||||
### Findings fixed (all MEASURED)
|
||||
|
||||
| # | Severity | Location | Issue | Fix |
|
||||
|---|----------|----------|-------|-----|
|
||||
| 1 | **HIGH** | `model.rs:95` (`Dtype::I32 => Some(DType::I64)`) | **Crash on any int32-tensor checkpoint.** An I32 byte buffer (4 B/elem) is handed to `from_raw_buffer(.., I64, shape, ..)`; candle derives `elem_count = data.len()/8`, **halving** the count while keeping the original shape → a tensor that claims 2× its storage. Reading it **panics** with a slice-OOB (`range end index 6 out of range for slice of length 3`) inside candle-core. A checkpoint with any int32 tensor (index/buffer tensors are common in PyTorch exports) → **DoS on load**. | Map `I32 → DType::I32`, `I16 → DType::I16` (both first-class candle dtypes). Pinned by `int32_tensor_loads_with_consistent_shape_and_values` (panics on old, passes on new). |
|
||||
| 2 | LOW | `inference.rs::predict` | Frame/batch dims weren't validated (only H/W/D were): `f_in > num_frames*2` over-indexes the temporal embedding → a cryptic candle `InvalidIndex` *error* (not a panic — candle bounds-checks); zero frame/batch feeds a zero-element tensor. | Boundary guard rejects zero / over-capacity frame+batch with a clear `ShapeMismatch`. 5 pins. |
|
||||
| 3 | LOW | `vqvae.rs:141` (`z.elem_count() / last`) | **Divide-by-zero panic** in public `VQCodebook::encode` on a rank-0 / empty-last-dim tensor (`last == 0`). | Fail-closed guard returns a clear error. Pinned by `encode_rejects_scalar_without_panicking`. |
|
||||
|
||||
The HIGH finding is the notable one: the crate's own dtype mapping **defeated**
|
||||
the upstream `safetensors::validate()` byte-length guarantee by misdeclaring the
|
||||
dtype — the one place malformed/widened weights could reach a panicking candle op.
|
||||
|
||||
### Dimensions confirmed clean (with evidence)
|
||||
|
||||
- **Panic surface** — grep for `unwrap()/expect()/panic!/unreachable!` across `src/`
|
||||
→ **zero in production paths**; all ops use `?`/`map_err`; the `last().unwrap_or(&0)`
|
||||
is now guarded. `as` casts operate only on config-bounded/internal values.
|
||||
- **NaN-state-poisoning (the named class) — N/A.** The engine is **stateless between
|
||||
`predict` calls** (no persistent world-model buffer to latch into), and input is
|
||||
`u8` class indices (non-finite input structurally impossible). NaN weights flow to
|
||||
`argmax` (deterministic, bounded to a valid class index) — no panic, no persistence.
|
||||
- **Unbounded alloc / shape-data mismatch from malformed weights** — defended upstream
|
||||
by `safetensors::validate()` (overflow-checked `nelements*dtype.size()` vs declared
|
||||
byte range + contiguous-offset + buffer-length checks), rejected before reaching
|
||||
candle. Finding #1 was the one place the crate defeated that guarantee.
|
||||
- **Model/path loading** — `load`/`load_safetensors` check `path.exists()` → typed
|
||||
`CheckpointNotFound`; corrupt bytes → `CheckpointParse` (pinned). No path-traversal
|
||||
surface (caller-supplied path, opened read-only, never joined with untrusted segments).
|
||||
- **Secrets** — grep clean (only `token_h`/`token_w` config fields match `token`).
|
||||
- **Determinism** — the crate's central honesty claim, verified by the pre-existing
|
||||
`tests/predict_honesty.rs` (3 tests, still pass).
|
||||
- `unsafe_code = "forbid"` in the manifest.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cargo test -p wifi-densepose-occworld-candle --no-default-features` → **31/31**
|
||||
(lib 17, checkpoint_loading 4, input_validation 5, predict_honesty 3, doctests 2),
|
||||
0 failed.
|
||||
- `cargo test --workspace --no-default-features` → 0 failed across every crate (a lone
|
||||
`wifi-densepose-desktop --test api_integration` "Access is denied (os error 5)" was a
|
||||
Windows file-lock/AV flake — re-ran isolated 21/21, unrelated).
|
||||
- `python archive/v1/data/proof/verify.py` → **VERDICT: PASS**, hash `f8e76f21…46f7a`
|
||||
unchanged (occworld off the signal proof path).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- A checkpoint-load DoS (the int32 dtype-widening panic) and two degenerate-input
|
||||
panics are closed in the world-model crate, each pinned. **Milestone #9 (all 4
|
||||
ungated crates) is complete.**
|
||||
|
||||
### Negative / Neutral
|
||||
- None. Guards reject only malformed/degenerate inputs.
|
||||
|
||||
## Links
|
||||
- ADR-176 / ADR-177 / ADR-178 — sibling Milestone-#9 reviews (ruview-swarm, nvsim, desktop)
|
||||
@@ -1,272 +0,0 @@
|
||||
# ADR-180: Through-Wall Camera↔CSI Hand-off Demo ("Behind the Wall")
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **BEHIND-THE-WALL** |
|
||||
| **Builds on** | ADR-079 (camera ground-truth training), ADR-031 (sensing-first RF mode), ADR-134 (CSI→CIR multipath), ADR-029/030 (RuvSense multistatic + persistent field), ADR-024 (AETHER re-ID), ADR-151 (per-room calibration), ADR-173 (metric-locked PCK), ADR-095/096 (rvcsi nexmon) |
|
||||
|
||||
## Context
|
||||
|
||||
### The demo we want
|
||||
A single self-contained **HTML page** that tells one honest, visceral story:
|
||||
|
||||
1. You stand in front of the laptop. The camera tracks your **full skeletal pose**;
|
||||
the WiFi-CSI model, trained on *your* movements moments earlier, infers the **same
|
||||
skeleton** in parallel — a side-by-side "camera vs RF agree" view.
|
||||
2. You **walk out the door and behind the wall**. The camera **goes blind** (you are
|
||||
occluded — it honestly shows "no person in frame"). The CSI model **keeps inferring
|
||||
your skeleton** from the WiFi signal alone — the 3D figure keeps walking, behind the
|
||||
wall, smoothly. A badge flips from `CAMERA` to `RF-INFERRED (through-wall)`.
|
||||
3. You **walk back into view**. The camera **re-acquires**; the badge flips back to
|
||||
`CAMERA`, and the RF-inferred and camera skeletons reconverge.
|
||||
|
||||
This is the "WiFi sees through walls" demo — and the user explicitly wants the **inferred
|
||||
skeleton through the wall**, not just a blob. The project's "prove everything / no AI-slop"
|
||||
bar means we make that claim **only because we measure it**: a second camera on the far side
|
||||
of the wall records ground-truth pose *behind* the wall, so the through-wall skeleton's
|
||||
accuracy is a **reported, reproducible number** — never an unfalsifiable "trust me."
|
||||
|
||||
### Honest capability framing (the load-bearing section)
|
||||
Through-wall **per-joint skeletal inference from WiFi CSI is not a generally-validated
|
||||
capability** in open settings — WiFi-DensePose (CMU) is camera-*co-located*. What makes it
|
||||
defensible *here* is the tightly-controlled regime and the measurement:
|
||||
|
||||
- **Controlled regime:** one room, one subject (you), one doorway, a model **camera-supervised
|
||||
on your exact gait and your exact through-door transition** (ADR-079) minutes earlier. This
|
||||
is in-distribution for *this* demo, not a universal claim.
|
||||
- **Measured, not asserted:** a far-side camera (cognitum-v0 has 17 `/dev/video*` nodes — use
|
||||
one, or a phone) records ground-truth pose behind the wall. The through-wall CSI skeleton is
|
||||
scored against it with the metric-locked PCK harness (ADR-173). **We publish the number.**
|
||||
- **Uncertainty is rendered, not hidden:** the through-wall skeleton is drawn **translucent**,
|
||||
with a live **per-joint confidence** and an explicit `RF-INFERRED` badge. High-confidence
|
||||
joints render solid; low-confidence joints fade. It never masquerades as the camera's
|
||||
ground-truth pose.
|
||||
|
||||
| While… | Camera | WiFi CSI (S3 / Pi5 nexmon, fused) | 60 GHz mmWave (C6 + MR60BHA2) |
|
||||
|--------|--------|-----------------------------------|-------------------------------|
|
||||
| In frame | **Full 17-kpt pose** — ground truth | full skeleton (supervised model) — *agrees with camera* | presence + range + micro-motion |
|
||||
| Behind a **drywall** | nothing (occluded) | **inferred full skeleton** (camera-supervised model + multistatic fusion), confidence-scored, **measured vs far-side camera** | presence + range + breathing — independent through-thin-wall confirm |
|
||||
| Behind **brick/metal** | nothing | degrades to coarse motion/position only — report honestly | blocked |
|
||||
|
||||
**The claim — stated precisely:** *"A WiFi-CSI model, camera-supervised on this subject and
|
||||
room, infers a continuous skeletal pose that tracks the subject through a drywall partition;
|
||||
through-wall accuracy is measured at X% PCK@k against a far-side camera (declared, not
|
||||
claimed)."* If X turns out low, that is the **honest result we report** — the skeleton is still
|
||||
rendered (the user wants it) but flagged with its true confidence, and the headline number is
|
||||
whatever we measured, good or bad.
|
||||
|
||||
### Why multistatic + supervision is the enabler
|
||||
A single node behind a wall sees only "something moved." Three spatially-diverse vantage points
|
||||
around the doorway (RuvSense multistatic + cross-viewpoint fusion, ADR-029/030) triangulate the
|
||||
moving scatterer — drywall attenuates and diffracts 2.4/5 GHz but does not block it — giving the
|
||||
model a rich enough multipath signature to regress a skeleton it was *trained* to associate with
|
||||
your through-door motion. AETHER re-ID embeddings (ADR-024) keep it locked to **you** across the
|
||||
camera→RF→camera hand-off.
|
||||
|
||||
### Available hardware (the user's actual rig)
|
||||
| Role | Device | Where | Stream |
|
||||
|------|--------|-------|--------|
|
||||
| Near ground truth (visible) | Laptop / USB camera | front of workstation (ruvzen) | MediaPipe pose → keypoints |
|
||||
| **Far ground truth (validation)** | cognitum-v0 camera (1 of 17 `/dev/video*`) or a phone | **behind the wall** | MediaPipe pose → keypoints (for MEASURING the through-wall skeleton) |
|
||||
| CSI node A | ESP32-S3 (8 MB) | COM9 (ruvzen) | UDP CSI :5005 |
|
||||
| CSI + mmWave node B | ESP32-C6 + Seeed MR60BHA2 | COM12 (ruvzen) | WiFi CSI + 60 GHz FMCW presence/range |
|
||||
| CSI node C (through-wall vantage) | Pi 5, BCM43455c0 | cognitum-v0 (other room) | nexmon_csi `.pcap` → rvcsi → CsiFrame |
|
||||
| Fusion + serving | sensing-server | ruvzen :3000/:8765 | `/ws/sensing`, `/ws/pose`, new `/ws/handoff` |
|
||||
|
||||
Place **node C (Pi 5) and the far camera on the far side of the wall** — the Pi 5 gives the
|
||||
fuser a vantage the camera lacks, and the far camera turns the through-wall claim into a
|
||||
measurement.
|
||||
|
||||
## Decision
|
||||
|
||||
Build a **camera↔CSI hand-off demo** as a thin, additive layer over existing components (no new
|
||||
heavy crate). Five parts: a multi-source capture plane, a camera-supervised calibration walk
|
||||
that **learns to infer the skeleton through the wall**, a **hand-off state machine**, a
|
||||
**dead-reckoning smoother** so dropped CSI never makes the figure jump, and a single-file HTML
|
||||
viewer that renders the inferred skeleton with honest confidence.
|
||||
|
||||
### 1. Capture plane (reuse, don't rebuild)
|
||||
- **Near camera:** `scripts/collect-ground-truth.py` already does MediaPipe pose + ESP32 CSI
|
||||
paired capture (ADR-079). Extend it to also subscribe to the Pi 5 nexmon stream (rvcsi), the
|
||||
C6 mmWave presence, **and the far camera**, so every frame is
|
||||
`(near_pose|null, far_pose|null, csi_S3, csi_C6, mmwave_C6, csi_Pi5, t)`.
|
||||
- **CSI nodes:** S3 over UDP :5005, Pi 5 via `rvcsi` (vendor/rvcsi nexmon adapter → `CsiFrame`),
|
||||
C6 WiFi CSI + the MR60BHA2 60 GHz presence/range/breathing.
|
||||
- **Fusion:** all CSI sources into the existing `MultistaticFuser`
|
||||
(`signal/src/ruvsense/multistatic.rs`); node positions around the doorway via
|
||||
`--node-positions` (geometric-diversity index drives confidence). **#1049:** with 3
|
||||
independently-clocked nodes set `WDP_GUARD_INTERVAL_US` to the real inter-node spread or
|
||||
fusion demotes.
|
||||
|
||||
### 2. Calibration walk — "it learns my movements **and infers them through the wall**" (ADR-079)
|
||||
A 3–5 minute guided routine. The HTML page scripts the walk: stand, step left/right, walk to the
|
||||
door, **cross fully behind the wall and back**, repeat — covering the visible AND the occluded
|
||||
zone, because **both cameras label ground truth**:
|
||||
- **Visible-zone supervision:** near camera labels pose; synchronized CSI window is the input.
|
||||
- **Through-wall supervision (the key part):** while you are behind the wall, the **far camera**
|
||||
labels your pose. So the CSI→skeleton model is trained on *real behind-wall poses* paired with
|
||||
the *behind-wall multistatic CSI* — the model genuinely learns to infer your skeleton through
|
||||
the wall, supervised by ground truth, not extrapolated blindly.
|
||||
- Train/fine-tune on `ruvultra` (RTX 5080) if available, else the local recipe. Persist as a
|
||||
per-room calibration bank (ADR-151 `baseline → enroll → extract → train`). AETHER re-ID
|
||||
embeddings (ADR-024) bind the track to you across the hand-off.
|
||||
- **Held-out split:** reserve some behind-wall passes for evaluation so through-wall PCK is
|
||||
measured on data the model never trained on (no leakage — the ADR-152 measurement discipline).
|
||||
|
||||
### 3. Hand-off state machine (`sensing-server/src/handoff.rs`, < 300 lines)
|
||||
States: `CAMERA` → `HANDOFF_OUT` → `RF_INFERRED` → `HANDOFF_IN` → `CAMERA` (+ `LOST`).
|
||||
- **`CAMERA`** — near camera has a confident pose → render it; RF-inferred skeleton ghosted
|
||||
alongside for the "they agree" effect.
|
||||
- **`HANDOFF_OUT`** — near-camera confidence drops at the doorway **while** CSI motion stays high
|
||||
and the multistatic track heads into the door zone → cross-fade source camera→RF.
|
||||
- **`RF_INFERRED`** — no camera pose; the CSI model emits a **full 17-kpt skeleton** + per-joint
|
||||
confidence; AETHER confirms it is still you. Render the translucent skeleton + confidence,
|
||||
badge `RF-INFERRED (through-wall)`. (When fusion confidence is too low for a credible skeleton,
|
||||
degrade gracefully to a coarse marker rather than a flailing one — honest fallback.)
|
||||
- **`HANDOFF_IN`** — near camera re-acquires a pose positionally consistent with the last RF
|
||||
skeleton (continuity gate) → cross-fade RF→camera.
|
||||
- **`LOST`** — neither source for N cycles → "no track," never invented.
|
||||
|
||||
Fail-closed: `RF_INFERRED` requires real multistatic motion energy + an AETHER identity match
|
||||
above calibrated floors; absent that → `LOST`, never a phantom. Mirrors the governed-trust gate
|
||||
(ADR-031 / ADR-141).
|
||||
|
||||
### 4. Dead reckoning & smoothing — fluid, never jumpy (the user's requirement)
|
||||
CSI does **not** arrive cleanly: UDP frames drop, nexmon `.pcap` has gaps, the fuser skips
|
||||
cycles when the #1049 guard rejects a spread, and the model's per-frame skeleton jitters. Render
|
||||
only on real frames and the figure teleports and shakes — which also *reads as fake*. A
|
||||
**predict/correct (dead-reckoning) layer** keeps the skeleton continuous and smooth between
|
||||
measurements, with **bounded** extrapolation so we never invent motion that didn't happen:
|
||||
|
||||
- **Per-joint constant-velocity Kalman filter** — reuse `signal/src/ruvsense/pose_tracker.rs`
|
||||
(the project's existing 17-keypoint Kalman tracker with AETHER re-ID). The renderer runs at a
|
||||
**fixed ~30 Hz, decoupled from CSI arrival**:
|
||||
- **Measurement this tick** → Kalman *update* (correct) each joint with the new inferred pose.
|
||||
- **Dropped CSI this tick** → Kalman *predict* only: advance each joint by `x += v·dt`, so the
|
||||
skeleton keeps moving along its trajectory instead of freezing then snapping. **This is the
|
||||
dead reckoning** — the limbs keep their motion through a dropout.
|
||||
- **Confidence decay (honesty governor):** every predict-only tick multiplies confidence and
|
||||
widens covariance. Dead reckoning is trusted for a **bounded** horizon (default ≤ ~500 ms,
|
||||
`WDP_DEADRECKON_MAX_MS`); past it, confidence hits the floor → state machine → `LOST`. **We
|
||||
coast briefly to stay smooth; we never coast forever to fake a track.** Someone who actually
|
||||
stopped behind the wall converges to a still pose then `LOST`, not perpetual phantom walking.
|
||||
- **Re-acquire smoothing:** a returning measurement after a gap is blended in with a
|
||||
critically-damped step (no overshoot) over 2–3 ticks, so the skeleton eases onto truth.
|
||||
- **Client render smoothing (already present):** `ui/observatory/js/figure-pool.js`
|
||||
`applyKeypoints` already `lerp`s joints with a small velocity overshoot for secondary motion;
|
||||
the hand-off viewer reuses it. The camera↔RF cross-fade is an alpha-lerp over ~300 ms.
|
||||
|
||||
**Dead-reckoning honesty invariants (testable):**
|
||||
1. Predicted-only frames carry `"dead_reckoned": true` + `"age_ms"`; the UI dims them —
|
||||
extrapolation is never shown as a fresh measurement.
|
||||
2. Confidence is **monotonically non-increasing** across consecutive predict-only ticks.
|
||||
3. After `WDP_DEADRECKON_MAX_MS` of silence the state **must** become `LOST` (pinned test:
|
||||
measurements then silence → assert transition within the horizon; no perpetual motion).
|
||||
4. Dead reckoning extrapolates an **existing** track only — no measurement ever ⇒ no track ⇒
|
||||
`LOST`, never a phantom from zero.
|
||||
|
||||
### 5. The HTML demo (single file, vanilla — mirrors the Observatory)
|
||||
`ui/through-wall/index.html` (+ a small JS bundle, zero build step, like `ui/observatory/`):
|
||||
- **Left:** near camera feed with the MediaPipe skeleton overlaid while visible; greys to
|
||||
"CAMERA BLIND" when occluded. (Optional second tile: the far camera, shown only in a
|
||||
"validation" view, not the hero view.)
|
||||
- **Right:** a top-down 3D room (Three.js) with the **wall** drawn, the doorway, the three
|
||||
sensor positions, and the figure: a **solid skeleton** in `CAMERA`, a **translucent skeleton
|
||||
with per-joint confidence fade** in `RF_INFERRED`, eased by the dead-reckoning smoother.
|
||||
- **Banner / `BannerState`** (strict, mirrors rufield-viewer): `CAMERA` / `RF-INFERRED — through
|
||||
wall (conf X%, measured Y% PCK@k)` / `DEAD-RECKONED (age N ms)` / `LOST` — mutually exclusive,
|
||||
with a one-line honesty caption. The measured through-wall PCK is shown, not invented.
|
||||
- Consumes a new `GET /ws/handoff` WS/SSE topic of `HandoffFrame`s; `?demo=1` replays a recorded
|
||||
session badged `REPLAY`.
|
||||
|
||||
### Output contract (`HandoffFrame`, JSON)
|
||||
```jsonc
|
||||
{
|
||||
"t_ns": 1718400000000,
|
||||
"state": "RF_INFERRED", // CAMERA | HANDOFF_OUT | RF_INFERRED | HANDOFF_IN | LOST
|
||||
"source": "fused_csi", // camera | fused_csi | mmwave | dead_reckoned
|
||||
"pose": [[x,y,z,conf], …×17], // inferred skeleton WITH per-joint confidence (present in CAMERA/HANDOFF/RF_INFERRED)
|
||||
"pose_confidence": 0.58, // aggregate; the rendered translucency
|
||||
"identity_match": 0.81, // AETHER re-ID — is it still you?
|
||||
"coarse": { "cell":[x,y], "zone":"behind_wall", "heading_deg":95, "node_diversity":0.48 },
|
||||
"dead_reckoned": false, // true on predict-only (extrapolated) ticks
|
||||
"age_ms": 0, // ms since the last real measurement (0 = fresh)
|
||||
"camera_blind": true,
|
||||
"measured_pck": { "k": 20, "value": null }, // filled from the far-camera validation run; null until measured
|
||||
"caption": "RF-inferred skeleton — model camera-supervised on this room; through-wall PCK measured separately"
|
||||
}
|
||||
```
|
||||
|
||||
## Phased plan (each phase independently demoable + falsifiable)
|
||||
- **P1 — wiring (no claim):** 3-source CSI capture (S3+C6+Pi5) + near camera into the multistatic
|
||||
fuser. Gate: `/ws/sensing` shows ≥3 active nodes + a fused position with the camera running.
|
||||
- **P2 — supervised calibration + through-wall training:** the guided walk with **both cameras**;
|
||||
fine-tune CSI→skeleton on visible AND far-camera-labeled behind-wall poses (ADR-079). Gate:
|
||||
while-visible PCK declared (metric-locked, ADR-173) on a held-out segment.
|
||||
- **P3 — MEASURE the through-wall skeleton:** score the RF-inferred skeleton against the far
|
||||
camera on held-out behind-wall passes → **publish the through-wall PCK@k** (good or bad). Gate:
|
||||
a committed eval script reproduces the number; honest negative if low.
|
||||
- **P4 — hand-off + dead reckoning + HTML:** the camera→RF→camera transition renders end-to-end,
|
||||
smooth through dropped CSI. Gate: a recorded live walk where the camera goes blind, the inferred
|
||||
skeleton keeps walking fluidly behind the wall, dead-reckons through dropouts without jumps, and
|
||||
re-acquisition is position-continuous. **This is the demo.**
|
||||
- **P5 — multi-modal corroboration (optional):** overlay C6 60 GHz presence/range as an
|
||||
independent through-thin-wall confirm (two physics, one conclusion).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- A genuinely compelling demo that does what the user asked — **infers and renders the skeleton
|
||||
through the wall** — while staying honest because the through-wall accuracy is **measured**
|
||||
against a far-side camera, not claimed. Reuses the multistatic fuser, ADR-079 supervision, the
|
||||
Kalman pose tracker, AETHER re-ID, the calibration crate, and the Observatory UI: the new code
|
||||
is a hand-off module + dead-reckoning smoother + an HTML page.
|
||||
|
||||
### Negative / Risks
|
||||
- **Through-wall skeletal accuracy may be modest or poor.** That is acceptable *iff* reported
|
||||
honestly — the headline is the measured PCK, whatever it is; the skeleton renders with its true
|
||||
per-joint confidence (low-confidence joints fade), never as fake certainty.
|
||||
- **Material dependence:** drywall good; brick/metal degrades to coarse-only — shoot on drywall
|
||||
and say so.
|
||||
- **3-node clock sync** is the #1049 hazard — tune `WDP_GUARD_INTERVAL_US`.
|
||||
- **Per-room, per-subject:** the model that "learned your movements" does not transfer without
|
||||
re-calibration — stated on the page.
|
||||
- **Over-claiming is the failure mode.** Mitigations baked in: translucent confidence-faded
|
||||
skeleton, `dead_reckoned`/`age_ms` flags, the measured-PCK banner, bounded extrapolation→`LOST`.
|
||||
|
||||
### Neutral
|
||||
- No new heavy crate; signal-path proof (`verify.py`) untouched — capture/fusion/UI orchestration
|
||||
over hardened, already-reviewed components.
|
||||
|
||||
## Acceptance criteria (falsifiable — "prove the haters wrong")
|
||||
On a recorded live session, all must hold:
|
||||
1. A contiguous window where the **near camera reports no person** (verifiable from raw frames)
|
||||
**and** the system renders an `RF_INFERRED` skeleton.
|
||||
2. The inferred skeleton's **gross motion matches reality** — direction of travel and rough gait
|
||||
phase — confirmed against the **far camera** (not eyeballed).
|
||||
3. **Through-wall per-joint accuracy is MEASURED** against the far camera and **reported** as
|
||||
PCK@k from a committed script. Low is fine *if* honestly published; fabricated is not.
|
||||
4. The figure is **smooth through dropped CSI** — no teleports/jitter — and every predicted-only
|
||||
frame is flagged `dead_reckoned`; after `WDP_DEADRECKON_MAX_MS` of silence it goes `LOST`.
|
||||
5. Re-acquisition is **position-continuous** (camera re-detects within a cell of the last RF
|
||||
position), and AETHER confirms identity across the hand-off.
|
||||
6. Every number (visible PCK, through-wall PCK, confidences) is MEASURED and reproducible — no
|
||||
hand-typed metrics.
|
||||
|
||||
A demo that cannot meet (1)–(2) and (4)–(5) on the available hardware is reported as a **negative
|
||||
result** (honest), not dressed up; a poor (3) is published as the real number.
|
||||
|
||||
## Links
|
||||
- ADR-079 — camera ground-truth training (supervision pipeline; extended here to a far camera)
|
||||
- ADR-031 — sensing-first RF mode / coherence gate (fail-closed honesty pattern)
|
||||
- ADR-134 — CSI→CIR multipath (through-wall multipath physics)
|
||||
- ADR-029 / ADR-030 — RuvSense multistatic + persistent field (the localization engine)
|
||||
- ADR-024 — AETHER contrastive re-ID (identity lock across the hand-off)
|
||||
- ADR-151 — per-room calibration crate (bank persistence)
|
||||
- ADR-152 / ADR-173 — measurement discipline + metric-locked PCK (the honest accuracy readout)
|
||||
- ADR-095 / ADR-096 — rvcsi nexmon (Pi 5 BCM43455c0 capture)
|
||||
- `signal/src/ruvsense/pose_tracker.rs` — 17-kpt Kalman tracker reused for dead reckoning
|
||||
- `ui/observatory/` — the vanilla-JS 3D viewer pattern this demo mirrors
|
||||
@@ -39,20 +39,7 @@ pub const DEFAULT_SAMPLE_RATE_HZ: f64 = 10_000.0;
|
||||
pub const DEFAULT_F_MOD_HZ: f64 = 1_000.0;
|
||||
|
||||
/// Quantise one input sample (T) to a signed ADC code. Returns `(code, saturated)`.
|
||||
///
|
||||
/// A **non-finite** input (`NaN` / `±Inf`) is treated as an out-of-range
|
||||
/// condition: it clamps to code `0` and raises the saturation flag. This is
|
||||
/// the funnel point that stops the NaN-state-poisoning class — a non-finite
|
||||
/// physical field (e.g. produced by a degenerate scene with a NaN dipole
|
||||
/// position) would otherwise coerce silently to code `0` *with the saturation
|
||||
/// flag clear*, yielding a frame indistinguishable from a legitimate
|
||||
/// zero-field reading. Flagging it preserves the "every frame is honest about
|
||||
/// its own validity" contract the proof bundle relies on.
|
||||
pub fn adc_quantise(b_in_t: f64) -> (i32, bool) {
|
||||
if !b_in_t.is_finite() {
|
||||
// Non-finite => not representable on the ±FS scale; mark saturated.
|
||||
return (0, true);
|
||||
}
|
||||
let code_f = (b_in_t / ADC_LSB_T).round();
|
||||
let max_code = (1_i32 << (ADC_BITS - 1)) - 1; // 32_767 for 16-bit signed
|
||||
let min_code = -max_code; // symmetric
|
||||
@@ -166,23 +153,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adc_quantise_flags_non_finite_as_saturated() {
|
||||
// Security pinning (NaN-state-poisoning guard): a non-finite field
|
||||
// value must clamp to code 0 AND raise the saturation flag, so the
|
||||
// pipeline can flag the frame rather than emitting it as a silent,
|
||||
// indistinguishable zero-field reading. Pre-fix this returned
|
||||
// (0, false) for NaN — a silent corruption.
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let (code, sat) = adc_quantise(bad);
|
||||
assert_eq!(code, 0, "non-finite input {bad} must clamp to code 0");
|
||||
assert!(sat, "non-finite input {bad} must raise the saturation flag");
|
||||
}
|
||||
// A finite in-range value is unaffected (no false positives).
|
||||
let (_, sat) = adc_quantise(1.0e-7);
|
||||
assert!(!sat, "a finite in-range value must NOT be flagged saturated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adc_saturates_above_full_scale() {
|
||||
let (code_pos, sat_pos) = adc_quantise(20.0e-6);
|
||||
|
||||
@@ -51,28 +51,11 @@ impl Pipeline {
|
||||
/// (sensor × sample) — i.e. `n_samples · scene.sensors.len()` frames
|
||||
/// in scene-major / sample-minor order.
|
||||
pub fn run(&self, n_samples: usize) -> Vec<MagFrame> {
|
||||
// `dt` is derived from caller-supplied config — an external boundary
|
||||
// (e.g. the WASM `config_json`). A degenerate `f_s_hz == 0` makes
|
||||
// `1.0 / f_s_hz == +Inf`; a non-finite or non-positive `dt_s` is
|
||||
// equally hostile. Sanitise before any arithmetic that could panic.
|
||||
let raw_dt = self
|
||||
let dt = self
|
||||
.config
|
||||
.dt_s
|
||||
.unwrap_or(1.0 / self.config.digitiser.f_s_hz);
|
||||
// Fall back to a 1 µs step (the smallest physically meaningful
|
||||
// sample interval here) when `dt` is non-finite or non-positive, so
|
||||
// the run produces well-defined frames instead of garbage / a panic.
|
||||
let dt = if raw_dt.is_finite() && raw_dt > 0.0 {
|
||||
raw_dt
|
||||
} else {
|
||||
1.0e-6
|
||||
};
|
||||
// `dt` is now finite & positive, so `dt * 1e6` is finite. Cap the
|
||||
// `u64` cast defensively (a huge but finite `dt` could still exceed
|
||||
// `u64::MAX`) and use `saturating_mul` for the per-sample timestamp so
|
||||
// a pathological config can never trigger a multiply-with-overflow
|
||||
// panic (debug / WASM panic=abort) or wrap to a garbage timestamp.
|
||||
let dt_us = (dt * 1.0e6).min(u64::MAX as f64) as u64;
|
||||
let dt_us = (dt * 1.0e6) as u64;
|
||||
let nv = NvSensor::new(self.config.sensor);
|
||||
|
||||
let mut out: Vec<MagFrame> =
|
||||
@@ -109,7 +92,7 @@ impl Pipeline {
|
||||
];
|
||||
|
||||
let mut frame = MagFrame::empty(sensor_idx as u16);
|
||||
frame.t_us = (sample as u64).saturating_mul(dt_us);
|
||||
frame.t_us = (sample as u64) * dt_us;
|
||||
frame.b_pt = b_pt;
|
||||
frame.sigma_pt = sigma_pt;
|
||||
frame.noise_floor_pt_sqrt_hz = (reading.noise_floor_t_sqrt_hz * 1.0e12) as f32;
|
||||
@@ -222,62 +205,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_zero_sample_rate_does_not_panic() {
|
||||
// Security pinning (panic / DoS guard): an externally-supplied
|
||||
// `f_s_hz == 0` makes `1/f_s_hz == +Inf`; pre-fix that produced
|
||||
// `dt_us == u64::MAX`, and `sample * dt_us` panicked with
|
||||
// "attempt to multiply with overflow" (debug / WASM panic=abort) at
|
||||
// sample >= 2, or wrapped to a garbage timestamp in release. The
|
||||
// sanitised `dt` + `saturating_mul` must keep the run finite.
|
||||
let scene = fixture_scene();
|
||||
let cfg = PipelineConfig {
|
||||
digitiser: crate::digitiser::DigitiserConfig {
|
||||
f_s_hz: 0.0,
|
||||
f_mod_hz: 1000.0,
|
||||
},
|
||||
..PipelineConfig::default()
|
||||
};
|
||||
let frames = Pipeline::new(scene, cfg, 42).run(8);
|
||||
assert_eq!(frames.len(), 8);
|
||||
for f in &frames {
|
||||
// Timestamps are monotone-well-defined, not garbage.
|
||||
assert!(f.t_us < u64::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_scene_input_flags_frame_instead_of_silently_zeroing() {
|
||||
// Security pinning (NaN-state-poisoning guard): a NaN dipole position
|
||||
// makes `r_norm` NaN, which bypasses the near-field clamp
|
||||
// (`NaN < R_MIN_M` is false) and yields a NaN field. Pre-fix the
|
||||
// digitiser silently coerced that NaN to code 0 with the saturation
|
||||
// flag CLEAR — a frame indistinguishable from a real zero-field
|
||||
// reading. Post-fix the frame must carry ADC_SATURATED so the
|
||||
// corruption is visible downstream.
|
||||
let mut scene = Scene::new();
|
||||
scene.add_dipole(DipoleSource::new([f64::NAN, 0.0, 0.5], [0.0, 0.0, 1.0e-3]));
|
||||
scene.add_sensor([0.0, 0.0, 0.0]);
|
||||
let cfg = PipelineConfig {
|
||||
sensor: NvSensorConfig {
|
||||
shot_noise_disabled: true,
|
||||
..NvSensorConfig::default()
|
||||
},
|
||||
..PipelineConfig::default()
|
||||
};
|
||||
let frames = Pipeline::new(scene, cfg, 0).run(4);
|
||||
for f in &frames {
|
||||
assert!(
|
||||
f.has_flag(flag::ADC_SATURATED),
|
||||
"non-finite field must raise ADC_SATURATED, not emit a silent zero frame"
|
||||
);
|
||||
// And the emitted value is a defined number, not NaN.
|
||||
for b in f.b_pt {
|
||||
assert!(b.is_finite());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adc_saturation_flag_fires_above_full_scale() {
|
||||
// Place a dipole close enough to drive the field above ±10 µT FS.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-open",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save"
|
||||
]
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"RuView default capability set","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open","dialog:allow-save"]}}
|
||||
{"default":{"identifier":"default","description":"RuView default capability set","local":true,"windows":["main"],"permissions":["core:default","shell:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save"]}}
|
||||
@@ -430,35 +430,6 @@ fn is_esp32_compatible(vid: u16, pid: u16) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Validate WiFi credentials before they are interpolated into a
|
||||
/// newline-delimited serial command protocol.
|
||||
///
|
||||
/// The ESP32 firmware accepts line-oriented commands such as
|
||||
/// `wifi_config <ssid> <password>\r\n`. Because the SSID and password
|
||||
/// arrive from the webview (untrusted) and are concatenated directly into
|
||||
/// those command strings, a control character (`\r`, `\n`, or NUL) embedded
|
||||
/// in either field would let a malicious caller terminate the current line
|
||||
/// early and inject an arbitrary follow-up command (e.g. `reboot`, `erase`).
|
||||
///
|
||||
/// Enforce the IEEE 802.11 / WPA2 bounds and reject any control characters:
|
||||
/// - SSID: 1-32 bytes, no control characters
|
||||
/// - Password: 8-63 bytes (WPA2 PSK ASCII range), no control characters
|
||||
fn validate_wifi_credentials(ssid: &str, password: &str) -> Result<(), String> {
|
||||
if ssid.is_empty() || ssid.len() > 32 {
|
||||
return Err("SSID must be 1-32 characters".into());
|
||||
}
|
||||
if password.len() < 8 || password.len() > 63 {
|
||||
return Err("WiFi password must be 8-63 characters".into());
|
||||
}
|
||||
if ssid.chars().any(|c| c.is_control()) {
|
||||
return Err("SSID must not contain control characters".into());
|
||||
}
|
||||
if password.chars().any(|c| c.is_control()) {
|
||||
return Err("WiFi password must not contain control characters".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure WiFi credentials on an ESP32 via serial port.
|
||||
///
|
||||
/// Sends WiFi credentials to the ESP32 using a simple serial protocol.
|
||||
@@ -472,10 +443,6 @@ pub async fn configure_esp32_wifi(
|
||||
use std::io::{Read, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
// Reject control characters / out-of-range lengths before the credentials
|
||||
// are spliced into the line-oriented serial command protocol below.
|
||||
validate_wifi_credentials(&ssid, &password)?;
|
||||
|
||||
tracing::info!("Configuring WiFi on port: {}", port);
|
||||
|
||||
// Open serial port
|
||||
@@ -582,37 +549,6 @@ mod tests {
|
||||
assert_eq!(node.tdm_total, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_wifi_credentials_accepts_valid() {
|
||||
assert!(validate_wifi_credentials("MyNetwork", "password123").is_ok());
|
||||
// Boundary: 32-char SSID, 63-char password are allowed.
|
||||
assert!(validate_wifi_credentials(&"A".repeat(32), &"B".repeat(63)).is_ok());
|
||||
// Boundary: 8-char password (WPA2 minimum) is allowed.
|
||||
assert!(validate_wifi_credentials("net", "12345678").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_wifi_credentials_rejects_injection() {
|
||||
// Newline/CR in SSID would terminate the serial command line early and
|
||||
// let the caller inject a follow-up firmware command. Must be rejected.
|
||||
assert!(validate_wifi_credentials("net\r\nreboot", "password123").is_err());
|
||||
assert!(validate_wifi_credentials("net\ninjected", "password123").is_err());
|
||||
// Same vector via the password field.
|
||||
assert!(validate_wifi_credentials("net", "pass\r\nerase_nvs").is_err());
|
||||
// Embedded NUL.
|
||||
assert!(validate_wifi_credentials("net", "pass\0word1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_wifi_credentials_rejects_out_of_range() {
|
||||
// Empty / over-length SSID.
|
||||
assert!(validate_wifi_credentials("", "password123").is_err());
|
||||
assert!(validate_wifi_credentials(&"A".repeat(33), "password123").is_err());
|
||||
// Too-short / too-long password (WPA2 PSK bounds).
|
||||
assert!(validate_wifi_credentials("net", "short").is_err());
|
||||
assert!(validate_wifi_credentials("net", &"B".repeat(64)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_esp32_compatible() {
|
||||
// CP2102
|
||||
|
||||
@@ -206,27 +206,6 @@ impl OccWorldCandle {
|
||||
)));
|
||||
}
|
||||
|
||||
// Validate the externally-supplied frame and batch counts at this
|
||||
// system boundary. The temporal positional embedding has only
|
||||
// `num_frames * 2` rows, so a larger `f_in` would over-index the
|
||||
// embedding table deep inside the transformer and surface as a cryptic
|
||||
// "gather" index error; a zero frame/batch count would feed a
|
||||
// zero-element tensor into the reshape/conv pipeline. Reject both here
|
||||
// with a clear, domain-level error instead.
|
||||
if f_in == 0 || b == 0 {
|
||||
return Err(OccWorldError::ShapeMismatch(format!(
|
||||
"past_occupancy must have non-zero batch and frame dims, got \
|
||||
batch={b}, frames={f_in}"
|
||||
)));
|
||||
}
|
||||
if f_in > cfg.num_frames * 2 {
|
||||
return Err(OccWorldError::ShapeMismatch(format!(
|
||||
"past_occupancy frame count {f_in} exceeds the temporal embedding \
|
||||
capacity ({} = num_frames*2)",
|
||||
cfg.num_frames * 2
|
||||
)));
|
||||
}
|
||||
|
||||
// ── Step 1: VQVAE encode each past frame ──────────────────────────
|
||||
// Flatten batch*frames: (B, F, H, W, D) → (B*F, H, W, D)
|
||||
let occ_flat = past_occupancy
|
||||
@@ -476,8 +455,4 @@ mod tests {
|
||||
"expected CheckpointNotFound, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The `predict` input-validation boundary guards (zero/over-capacity frame
|
||||
// counts) live in `tests/input_validation.rs` so they exercise only the
|
||||
// public API and keep this file under the 500-line limit.
|
||||
}
|
||||
|
||||
@@ -92,21 +92,8 @@ fn safetensor_dtype_to_candle(dt: safetensors::Dtype) -> Option<candle_core::DTy
|
||||
Dtype::F64 => Some(DType::F64),
|
||||
Dtype::F16 => Some(DType::F16),
|
||||
Dtype::BF16 => Some(DType::BF16),
|
||||
// I32 MUST map to DType::I32, not I64. `Tensor::from_raw_buffer`
|
||||
// derives its element count from `data.len() / dtype.size_in_bytes()`;
|
||||
// handing an int32 byte buffer (4 bytes/elem) to the I64 path
|
||||
// (8 bytes/elem) halves the element count while keeping the original
|
||||
// shape, producing a tensor whose declared shape claims twice as many
|
||||
// elements as its storage holds. That silent shape/storage mismatch
|
||||
// panics (slice OOB) the moment the tensor is read — a crash on any
|
||||
// checkpoint containing an int32 tensor. See
|
||||
// `tests/checkpoint_loading.rs::int32_tensor_loads_with_consistent_shape_and_values`.
|
||||
Dtype::I32 => Some(DType::I32),
|
||||
Dtype::I32 => Some(DType::I64), // widen for Candle compatibility
|
||||
Dtype::I64 => Some(DType::I64),
|
||||
// I16 is also a first-class Candle dtype (2 bytes/elem); map it
|
||||
// directly rather than rejecting it, for the same byte-size-correctness
|
||||
// reason as I32 above.
|
||||
Dtype::I16 => Some(DType::I16),
|
||||
Dtype::U8 => Some(DType::U8),
|
||||
Dtype::U32 => Some(DType::U32),
|
||||
_ => None,
|
||||
|
||||
@@ -137,17 +137,6 @@ impl VQCodebook {
|
||||
let orig_shape = z.shape().clone();
|
||||
let orig_dims = orig_shape.dims().to_vec();
|
||||
let last = *orig_shape.dims().last().unwrap_or(&0);
|
||||
// Guard the divide below: a scalar (rank-0) or empty-last-dim tensor
|
||||
// would make `last == 0` and panic on the `elem_count() / last`
|
||||
// division. `encode` is a `pub fn` on a `pub struct`, so this is a
|
||||
// reachable public boundary — fail closed with a clear error instead.
|
||||
if last == 0 {
|
||||
return Err(candle_core::Error::Msg(format!(
|
||||
"VQCodebook::encode expects a tensor with a non-zero last dim of \
|
||||
size embed_dim={}, got shape {orig_dims:?}",
|
||||
self.embed_dim
|
||||
)));
|
||||
}
|
||||
// Flatten to (N, embed_dim)
|
||||
let n = z.elem_count() / last;
|
||||
let z_flat = z.reshape((n, last))?; // (N, D)
|
||||
@@ -350,21 +339,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_rejects_scalar_without_panicking() {
|
||||
// A rank-0 (scalar) tensor has an empty dims list → `last == 0`.
|
||||
// Before the guard this divided by zero and panicked; now it returns
|
||||
// a clean error. `encode` is public, so this is a reachable boundary.
|
||||
let device = Device::Cpu;
|
||||
let codebook = VQCodebook::dummy(4, 8, &device).unwrap();
|
||||
let scalar = Tensor::from_vec(vec![1.0f32], (), &device).unwrap();
|
||||
let result = codebook.encode(&scalar);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"scalar input must error, not panic; got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fold_unfold_roundtrip() -> candle_core::Result<()> {
|
||||
let device = Device::Cpu;
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
//! Checkpoint-loading robustness tests for `crate::model::load_safetensors`.
|
||||
//!
|
||||
//! Security review (Milestone #9, crate 4/4). These tests pin the behaviour of
|
||||
//! the SafeTensors weight-loading path against malformed / degenerate
|
||||
//! checkpoints — the only externally-controlled file-input surface in the crate.
|
||||
//!
|
||||
//! The headline regression is the **int32 dtype-widening byte-size bug**
|
||||
//! (`security/occworld-candle` finding #1): `model.rs` mapped
|
||||
//! `safetensors::Dtype::I32` → `candle_core::DType::I64` and then handed the
|
||||
//! raw *int32* byte buffer (4 bytes/elem) to `Tensor::from_raw_buffer(.., I64,
|
||||
//! shape, ..)`. Candle's `from_raw_buffer` computes `elem_count =
|
||||
//! data.len() / 8`, producing a tensor whose declared shape claims twice as
|
||||
//! many elements as the backing storage actually holds — a silent
|
||||
//! shape/storage inconsistency on attacker-supplied checkpoints.
|
||||
//!
|
||||
//! `build_safetensors` hand-assembles the binary container
|
||||
//! (`<u64 LE header_len><JSON header><raw data>`) so the test states exactly
|
||||
//! what bytes reach the loader, independent of the `safetensors` writer API.
|
||||
|
||||
use candle_core::Device;
|
||||
use wifi_densepose_occworld_candle::model::load_safetensors;
|
||||
|
||||
/// Hand-build a single-tensor SafeTensors buffer.
|
||||
///
|
||||
/// `dtype` is the safetensors dtype string (e.g. `"I32"`, `"F32"`).
|
||||
/// `shape` is the declared shape. `data` is the raw little-endian tensor bytes
|
||||
/// — the caller is responsible for making `data.len()` consistent with
|
||||
/// `shape × dtype_size` (safetensors itself validates this, so an inconsistent
|
||||
/// pair is rejected before reaching the candle conversion).
|
||||
fn build_safetensors(name: &str, dtype: &str, shape: &[usize], data: &[u8]) -> Vec<u8> {
|
||||
let shape_json: Vec<String> = shape.iter().map(|d| d.to_string()).collect();
|
||||
let header = format!(
|
||||
"{{\"{name}\":{{\"dtype\":\"{dtype}\",\"shape\":[{}],\"data_offsets\":[0,{}]}}}}",
|
||||
shape_json.join(","),
|
||||
data.len()
|
||||
);
|
||||
let header_bytes = header.into_bytes();
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(&(header_bytes.len() as u64).to_le_bytes());
|
||||
buf.extend_from_slice(&header_bytes);
|
||||
buf.extend_from_slice(data);
|
||||
buf
|
||||
}
|
||||
|
||||
fn write_temp(bytes: &[u8], stem: &str) -> std::path::PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"occworld_ckpt_{stem}_{}_{}.safetensors",
|
||||
std::process::id(),
|
||||
// nanosecond-ish disambiguator so parallel tests never collide
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::write(&p, bytes).expect("write temp checkpoint");
|
||||
p
|
||||
}
|
||||
|
||||
/// REGRESSION (finding #1): an int32 tensor in a checkpoint must load into a
|
||||
/// tensor whose element count matches its declared shape.
|
||||
///
|
||||
/// On the OLD code (`I32 -> DType::I64`) the 6-element int32 tensor below was
|
||||
/// handed to `from_raw_buffer(.., I64, [2,3], ..)`, which derived
|
||||
/// `elem_count = 24 bytes / 8 = 3` and built a 3-element storage carrying a
|
||||
/// shape claiming 6 elements — reading it panicked with a slice-OOB
|
||||
/// (`range end index 6 out of range for slice of length 3`). On the FIXED code
|
||||
/// (`I32 -> DType::I32`) the tensor round-trips: dtype I32, 6 elements,
|
||||
/// values `[1,2,3,4,5,6]`.
|
||||
#[test]
|
||||
fn int32_tensor_loads_with_consistent_shape_and_values() {
|
||||
let device = Device::Cpu;
|
||||
let shape = [2usize, 3];
|
||||
let vals: [i32; 6] = [1, 2, 3, 4, 5, 6];
|
||||
let mut data = Vec::with_capacity(24);
|
||||
for v in vals {
|
||||
data.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let bytes = build_safetensors("quantize.embedding.weight", "I32", &shape, &data);
|
||||
let path = write_temp(&bytes, "i32");
|
||||
|
||||
let map = load_safetensors(&path, &device).expect("int32 checkpoint must load");
|
||||
let t = map
|
||||
.get("quantize.embedding.weight")
|
||||
.expect("mapped key present");
|
||||
|
||||
// The declared shape's element count MUST equal the storage's element
|
||||
// count. On the old code these disagreed (6 vs 3).
|
||||
assert_eq!(
|
||||
t.dims(),
|
||||
&[2, 3],
|
||||
"int32 tensor must preserve its declared shape"
|
||||
);
|
||||
assert_eq!(
|
||||
t.elem_count(),
|
||||
6,
|
||||
"element count must match shape — storage/shape consistency"
|
||||
);
|
||||
|
||||
// The dtype must be I32 — the int32 byte buffer is interpreted as int32,
|
||||
// not reinterpreted as half as many int64 lanes.
|
||||
assert_eq!(
|
||||
t.dtype(),
|
||||
candle_core::DType::I32,
|
||||
"int32 checkpoint tensor must load as DType::I32"
|
||||
);
|
||||
|
||||
// And the values must be exactly recovered (no reinterpretation of two
|
||||
// int32 lanes as one int64). This is the strongest proof the dtype is
|
||||
// handled correctly end-to-end.
|
||||
let flat = t.flatten_all().expect("flatten");
|
||||
let got: Vec<i32> = flat.to_vec1::<i32>().expect("to_vec i32");
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![1i32, 2, 3, 4, 5, 6],
|
||||
"int32 values must be recovered exactly"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A well-formed F32 tensor must round-trip unchanged (control case — proves
|
||||
/// the fix does not regress the common float path).
|
||||
#[test]
|
||||
fn f32_tensor_round_trips() {
|
||||
let device = Device::Cpu;
|
||||
let shape = [4usize];
|
||||
let vals: [f32; 4] = [0.5, -1.0, 2.25, 3.0];
|
||||
let mut data = Vec::with_capacity(16);
|
||||
for v in vals {
|
||||
data.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let bytes = build_safetensors("post_quant_conv.bias", "F32", &shape, &data);
|
||||
let path = write_temp(&bytes, "f32");
|
||||
|
||||
let map = load_safetensors(&path, &device).expect("f32 checkpoint must load");
|
||||
let t = map.get("post_quant_conv.bias").expect("key present");
|
||||
assert_eq!(t.dims(), &[4]);
|
||||
let got: Vec<f32> = t.to_vec1::<f32>().expect("to_vec f32");
|
||||
assert_eq!(got, vec![0.5, -1.0, 2.25, 3.0]);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A truncated / corrupt header must produce a parse error, never a panic.
|
||||
/// (Defense-in-depth: the loader is fed an untrusted file.)
|
||||
#[test]
|
||||
fn corrupt_checkpoint_errors_cleanly() {
|
||||
let device = Device::Cpu;
|
||||
// Garbage that is not a valid SafeTensors container.
|
||||
let bytes = vec![0xFFu8; 32];
|
||||
let path = write_temp(&bytes, "corrupt");
|
||||
|
||||
let result = load_safetensors(&path, &device);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"corrupt checkpoint must error, got Ok: {result:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// An int64 tensor must still load correctly (proves the fix narrows only the
|
||||
/// I32 mapping and leaves the genuine I64 path intact).
|
||||
#[test]
|
||||
fn int64_tensor_round_trips() {
|
||||
let device = Device::Cpu;
|
||||
let shape = [3usize];
|
||||
let vals: [i64; 3] = [10, -20, 30];
|
||||
let mut data = Vec::with_capacity(24);
|
||||
for v in vals {
|
||||
data.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let bytes = build_safetensors("transformer.output_head.bias", "I64", &shape, &data);
|
||||
let path = write_temp(&bytes, "i64");
|
||||
|
||||
let map = load_safetensors(&path, &device).expect("i64 checkpoint must load");
|
||||
let t = map.get("transformer.output_head.bias").expect("key present");
|
||||
assert_eq!(t.dims(), &[3]);
|
||||
assert_eq!(t.elem_count(), 3);
|
||||
let got: Vec<i64> = t.to_vec1::<i64>().expect("to_vec i64");
|
||||
assert_eq!(got, vec![10, -20, 30]);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
//! Input-validation boundary tests for `OccWorldCandle::predict`.
|
||||
//!
|
||||
//! Security review (Milestone #9, crate 4/4). `predict` takes an
|
||||
//! externally-supplied occupancy tensor; per the project's "validate input at
|
||||
//! system boundaries" rule it must reject degenerate / out-of-capacity shapes
|
||||
//! with a clear domain error rather than surfacing a cryptic deep-pipeline
|
||||
//! Candle error (over-capacity frame counts over-index the temporal positional
|
||||
//! embedding) or processing a zero-element tensor.
|
||||
//!
|
||||
//! These exercise only the public API and live here (not inline in
|
||||
//! `inference.rs`) to keep that module under the 500-line cap.
|
||||
|
||||
use candle_core::{DType, Device, Tensor};
|
||||
use wifi_densepose_occworld_candle::config::OccWorldConfig;
|
||||
use wifi_densepose_occworld_candle::inference::OccWorldCandle;
|
||||
use wifi_densepose_occworld_candle::error::OccWorldError;
|
||||
|
||||
fn small_cfg() -> OccWorldConfig {
|
||||
OccWorldConfig {
|
||||
grid_h: 8,
|
||||
grid_w: 8,
|
||||
grid_d: 4,
|
||||
num_classes: 4,
|
||||
free_class: 3,
|
||||
base_channels: 8,
|
||||
z_channels: 8,
|
||||
codebook_size: 4,
|
||||
embed_dim: 8,
|
||||
num_frames: 2,
|
||||
token_h: 4,
|
||||
token_w: 4,
|
||||
num_heads: 2,
|
||||
num_layers: 1,
|
||||
ffn_hidden: 16,
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero frames is a degenerate input that would otherwise feed a zero-element
|
||||
/// tensor into the reshape/conv pipeline. Must be rejected at the boundary.
|
||||
#[test]
|
||||
fn predict_rejects_zero_frames() {
|
||||
let device = Device::Cpu;
|
||||
let cfg = small_cfg();
|
||||
let engine = OccWorldCandle::dummy(cfg.clone(), device.clone()).unwrap();
|
||||
let past = Tensor::zeros(
|
||||
(1usize, 0usize, cfg.grid_h, cfg.grid_w, cfg.grid_d),
|
||||
DType::U8,
|
||||
&device,
|
||||
)
|
||||
.unwrap();
|
||||
let result = engine.predict(&past);
|
||||
assert!(
|
||||
matches!(result, Err(OccWorldError::ShapeMismatch(_))),
|
||||
"zero-frame input must be rejected with ShapeMismatch"
|
||||
);
|
||||
}
|
||||
|
||||
/// Zero batch must also be rejected (same zero-element-tensor hazard).
|
||||
#[test]
|
||||
fn predict_rejects_zero_batch() {
|
||||
let device = Device::Cpu;
|
||||
let cfg = small_cfg();
|
||||
let engine = OccWorldCandle::dummy(cfg.clone(), device.clone()).unwrap();
|
||||
let past = Tensor::zeros(
|
||||
(0usize, cfg.num_frames, cfg.grid_h, cfg.grid_w, cfg.grid_d),
|
||||
DType::U8,
|
||||
&device,
|
||||
)
|
||||
.unwrap();
|
||||
let result = engine.predict(&past);
|
||||
assert!(
|
||||
matches!(result, Err(OccWorldError::ShapeMismatch(_))),
|
||||
"zero-batch input must be rejected with ShapeMismatch"
|
||||
);
|
||||
}
|
||||
|
||||
/// More frames than the temporal embedding can index (`> num_frames*2`).
|
||||
///
|
||||
/// On the old code this over-indexed the temporal positional embedding deep in
|
||||
/// the transformer and surfaced as a cryptic Candle "gather" `InvalidIndex`
|
||||
/// error. The boundary guard now rejects it cleanly with `ShapeMismatch`.
|
||||
#[test]
|
||||
fn predict_rejects_too_many_frames() {
|
||||
let device = Device::Cpu;
|
||||
let cfg = small_cfg(); // num_frames = 2 → temporal capacity = 4
|
||||
let engine = OccWorldCandle::dummy(cfg.clone(), device.clone()).unwrap();
|
||||
let too_many = cfg.num_frames * 2 + 1;
|
||||
let past = Tensor::zeros(
|
||||
(1usize, too_many, cfg.grid_h, cfg.grid_w, cfg.grid_d),
|
||||
DType::U8,
|
||||
&device,
|
||||
)
|
||||
.unwrap();
|
||||
let result = engine.predict(&past);
|
||||
assert!(
|
||||
matches!(result, Err(OccWorldError::ShapeMismatch(_))),
|
||||
"over-capacity frame count must be rejected with ShapeMismatch"
|
||||
);
|
||||
}
|
||||
|
||||
/// A frame count exactly at capacity (`num_frames*2`) must still succeed —
|
||||
/// the guard rejects only *over*-capacity, not the boundary value.
|
||||
#[test]
|
||||
fn predict_accepts_frame_count_at_capacity() {
|
||||
let device = Device::Cpu;
|
||||
let cfg = small_cfg();
|
||||
let engine = OccWorldCandle::dummy(cfg.clone(), device.clone()).unwrap();
|
||||
let at_cap = cfg.num_frames * 2;
|
||||
let past = Tensor::zeros(
|
||||
(1usize, at_cap, cfg.grid_h, cfg.grid_w, cfg.grid_d),
|
||||
DType::U8,
|
||||
&device,
|
||||
)
|
||||
.unwrap();
|
||||
let out = engine
|
||||
.predict(&past)
|
||||
.expect("at-capacity frame count must predict");
|
||||
assert_eq!(out.sem_pred.dims()[1], at_cap, "frame dim preserved");
|
||||
}
|
||||
|
||||
/// Wrong spatial geometry (H/W/D) is still rejected — pins the pre-existing
|
||||
/// guard alongside the new frame/batch ones.
|
||||
#[test]
|
||||
fn predict_rejects_wrong_grid_dims() {
|
||||
let device = Device::Cpu;
|
||||
let cfg = small_cfg();
|
||||
let engine = OccWorldCandle::dummy(cfg.clone(), device.clone()).unwrap();
|
||||
let past = Tensor::zeros(
|
||||
(1usize, cfg.num_frames, cfg.grid_h + 1, cfg.grid_w, cfg.grid_d),
|
||||
DType::U8,
|
||||
&device,
|
||||
)
|
||||
.unwrap();
|
||||
let result = engine.predict(&past);
|
||||
assert!(
|
||||
matches!(result, Err(OccWorldError::ShapeMismatch(_))),
|
||||
"wrong grid dims must be rejected with ShapeMismatch"
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ use wifi_densepose_wasm_edge::{
|
||||
host_get_phase, host_get_amplitude, host_get_variance,
|
||||
host_get_presence, host_get_motion_energy,
|
||||
host_emit_event, host_log,
|
||||
sanitize_host_f32,
|
||||
exo_ghost_hunter::GhostHunterDetector,
|
||||
};
|
||||
|
||||
@@ -65,16 +64,14 @@ pub extern "C" fn on_frame(n_subcarriers: i32) {
|
||||
|
||||
for i in 0..max_sc {
|
||||
unsafe {
|
||||
// Sanitize at the boundary: a non-finite host value would otherwise
|
||||
// latch NaN into the detector's persistent anomaly-energy state.
|
||||
phases[i] = sanitize_host_f32(host_get_phase(i as i32));
|
||||
amplitudes[i] = sanitize_host_f32(host_get_amplitude(i as i32));
|
||||
variances[i] = sanitize_host_f32(host_get_variance(i as i32));
|
||||
phases[i] = host_get_phase(i as i32);
|
||||
amplitudes[i] = host_get_amplitude(i as i32);
|
||||
variances[i] = host_get_variance(i as i32);
|
||||
}
|
||||
}
|
||||
|
||||
let presence = unsafe { host_get_presence() };
|
||||
let motion_energy = sanitize_host_f32(unsafe { host_get_motion_energy() });
|
||||
let motion_energy = unsafe { host_get_motion_energy() };
|
||||
|
||||
let detector = unsafe { &mut *core::ptr::addr_of_mut!(DETECTOR) };
|
||||
let events = detector.process_frame(
|
||||
|
||||
@@ -572,35 +572,6 @@ pub mod event_types {
|
||||
pub const HEALING_COMPLETE: i32 = 888;
|
||||
}
|
||||
|
||||
/// Sanitize a raw `f32` read from the host CSI imports.
|
||||
///
|
||||
/// ## NaN-state-poisoning guard (ADR-040 boundary hardening)
|
||||
///
|
||||
/// The `csi_get_phase`/`csi_get_amplitude`/`csi_get_variance`/… host imports
|
||||
/// return raw IEEE-754 `f32`. A single non-finite value (NaN / ±∞) — from a
|
||||
/// firmware DSP bug, an uninitialised buffer, or a hostile host — propagates
|
||||
/// silently into the long-lived per-module accumulators (EMA, Welford,
|
||||
/// phasor sums, baseline means). Once latched, every downstream comparison
|
||||
/// against the poisoned state evaluates `false`, so detectors fail *degraded*
|
||||
/// (stuck gate state, suppressed anomaly checks) rather than recovering.
|
||||
///
|
||||
/// This is the single chokepoint: every one of the ~70 edge modules receives
|
||||
/// its frame data from the `on_frame` boundaries below, so mapping non-finite
|
||||
/// host floats to `0.0` here protects the entire surface without per-module
|
||||
/// churn. Mirrors the M-01 negative-`n_subcarriers` clamp at the same site.
|
||||
///
|
||||
/// `0.0` is the neutral choice: a zero phase/amplitude/variance reads as a
|
||||
/// quiet subcarrier, which the detectors already handle (it cannot, itself,
|
||||
/// trip an anomaly the way a poisoned NaN can permanently disable one).
|
||||
#[inline]
|
||||
pub fn sanitize_host_f32(v: f32) -> f32 {
|
||||
if v.is_finite() {
|
||||
v
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a message string to the ESP32 console (via host_log import).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn log_msg(msg: &str) {
|
||||
@@ -679,10 +650,8 @@ pub extern "C" fn on_frame(n_subcarriers: i32) {
|
||||
|
||||
for i in 0..max_sc {
|
||||
unsafe {
|
||||
// Sanitize at the boundary: a non-finite host value would otherwise
|
||||
// latch NaN into the gesture/coherence/anomaly persistent state.
|
||||
phases[i] = sanitize_host_f32(host_get_phase(i as i32));
|
||||
amps[i] = sanitize_host_f32(host_get_amplitude(i as i32));
|
||||
phases[i] = host_get_phase(i as i32);
|
||||
amps[i] = host_get_amplitude(i as i32);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,71 +677,10 @@ pub extern "C" fn on_frame(n_subcarriers: i32) {
|
||||
pub extern "C" fn on_timer() {
|
||||
// Periodic summary.
|
||||
let state = unsafe { &*core::ptr::addr_of!(STATE) };
|
||||
let motion = sanitize_host_f32(unsafe { host_get_motion_energy() });
|
||||
let motion = unsafe { host_get_motion_energy() };
|
||||
emit(event_types::CUSTOM_METRIC, motion);
|
||||
|
||||
if state.frame_count % 100 == 0 {
|
||||
log_msg("wasm-edge: heartbeat");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boundary-hardening tests (ADR-040) ───────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod boundary_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_passes_finite_values_through() {
|
||||
assert_eq!(sanitize_host_f32(0.0), 0.0);
|
||||
assert_eq!(sanitize_host_f32(-3.5), -3.5);
|
||||
assert_eq!(sanitize_host_f32(1234.5), 1234.5);
|
||||
assert_eq!(sanitize_host_f32(f32::MIN), f32::MIN);
|
||||
assert_eq!(sanitize_host_f32(f32::MAX), f32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_maps_non_finite_to_zero() {
|
||||
// NaN / ±∞ from a buggy or hostile host must not reach module state.
|
||||
assert_eq!(sanitize_host_f32(f32::NAN), 0.0);
|
||||
assert_eq!(sanitize_host_f32(f32::INFINITY), 0.0);
|
||||
assert_eq!(sanitize_host_f32(f32::NEG_INFINITY), 0.0);
|
||||
// A subnormal-resulting NaN (0.0 * inf) is also caught.
|
||||
assert_eq!(sanitize_host_f32(0.0f32 * f32::INFINITY), 0.0);
|
||||
}
|
||||
|
||||
/// Demonstrates the downstream hazard the boundary guard prevents:
|
||||
/// feeding a raw NaN phase into a persistent module permanently latches
|
||||
/// its smoothed state, whereas a boundary-sanitized 0.0 keeps it healthy.
|
||||
#[test]
|
||||
fn coherence_monitor_nan_latches_without_sanitize_but_not_with() {
|
||||
use crate::coherence::CoherenceMonitor;
|
||||
|
||||
// Without sanitize: a single NaN frame poisons the EMA forever.
|
||||
let mut poisoned = CoherenceMonitor::new();
|
||||
poisoned.process_frame(&[0.1, 0.2, 0.3]); // init
|
||||
let _ = poisoned.process_frame(&[f32::NAN, 0.2, 0.3]); // raw host NaN
|
||||
// Subsequent *clean* frames can never restore a finite score.
|
||||
for _ in 0..50 {
|
||||
poisoned.process_frame(&[0.1, 0.2, 0.3]);
|
||||
}
|
||||
assert!(
|
||||
poisoned.coherence_score().is_nan(),
|
||||
"raw NaN should latch the smoothed coherence (documents the hazard)"
|
||||
);
|
||||
|
||||
// With the boundary guard applied (what on_frame now does), the NaN is
|
||||
// mapped to a finite value before it ever reaches the module.
|
||||
let mut guarded = CoherenceMonitor::new();
|
||||
let f = |x: f32| sanitize_host_f32(x);
|
||||
guarded.process_frame(&[f(0.1), f(0.2), f(0.3)]); // init
|
||||
let _ = guarded.process_frame(&[f(f32::NAN), f(0.2), f(0.3)]);
|
||||
for _ in 0..50 {
|
||||
guarded.process_frame(&[f(0.1), f(0.2), f(0.3)]);
|
||||
}
|
||||
assert!(
|
||||
guarded.coherence_score().is_finite(),
|
||||
"boundary-sanitized input keeps the module state finite"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user