Pace's Problem #3 ("threshold=1.0 means different things on different
devices") solved by normalizing the runtime CV against the empty-room
baseline CV measured during calibration.
norm_cv = current_cv / baseline_cv
gates: norm_cv ≥ 3.0 → present_moving
norm_cv ≥ 6.0 → active
Baseline CV loaded per-node from data/baseline.json (full_broadband_cv_pct).
When no calibration loaded, falls back to absolute gates (0.10 / 0.22)
that were deployment-tuned earlier — keeps backwards compatibility.
Both per-node `amp_node_level` and global `amp_classify_from_latest` use
the same normalization. On the operator's deployment with baseline CV
~4 %, the universal 3×/6× gates map to ~12 %/24 % absolute — same numbers
the hard-coded thresholds had, but now any-room-portable.
Problem from ADR-103 v1: persisted NBVI-subset mean (19.86 in operator's
recording) drifted out of comparability after server restart because
NBVI re-selected a different top-12 subset, yielding a different mean
from the same channel. classifier saw current/baseline ratio > 1 even
in clearly empty room.
Fix:
1. Separate FULL-broadband mean (all non-zero subcarriers) from
NBVI-subset mean in amp_presence_override. NBVI subset still drives
CV / motion sensitivity. FULL is what gets compared to the
persistent baseline — stable across NBVI re-selection.
2. baseline.json schema v2: full_broadband_{mean,p50,p95,std,cv_pct}
replaces NBVI-only p95_amp/mean_amp. Loader prefers full_*; falls
back to legacy fields for backward compat.
3. NBVI Step 1 quiet-window finder (ESPectre): nbvi_select_top_k now
slides a window across the calibration history, picks the lowest-CV
sub-window, and ranks subcarriers using only that. Robust to brief
motion during the calibration buffer.
4. scripts/record-baseline.py v2: emits v2 schema, computes
full-broadband stats per node, trims head/tail transients, picks
cleanest 30-s sub-window, also saves per_subcarrier_mean for future
subcarrier-level comparison.
Operator workflow now: step out → run script → restart server →
forget about the empty-room ritual forever.
* docs/references/espectre-techniques.md — catalogues every Pace
technique from Part-2 against what RuView has implemented, doesn't
have, or has differently. Includes ranked open-items list.
* sensing-server: revert feature_state path to vec![] amplitudes.
The previous fix made bars LOOK live by reissuing the last raw-CSI
vector on every feature_state tick — operator reported this made
the bars misleading (visually busy but unresponsive to movement).
raw.html already skips empty-amp updates so bars now refresh only
on actual fresh CSI, which is honest.
* raw.html: comment on the skip-empty branch for future-me.
Operator request: only one UI page open. raw.html (ADR-099 console,
extended in ADR-101 with per-node classification badges) covers all
live-debug use cases. mobile.html / spectrum.html / calibrate.html
were either superseded or never adopted in the field — removing them
reduces the surface that has to track ADR-101/102 contract changes.
raw.html stays at /static/raw.html on the existing :8080 listener.
After 3393c1e8 made FW emit ~80 % feature_state packets and ~20 % raw
CSI, the server's feature_state path was overwriting NodeInfo.amplitude
with vec![] on every feature_state tick. raw.html's per-node bar chart
ended up freezing for hundreds of milliseconds between rare raw-CSI
packets, and /api/v1/sensing/latest mostly snapshotted an empty amps
vector even though raw CSI was flowing.
Fix: in the feature_state SensingUpdate builder, hand out
ns.frame_history.back() (the last raw amps vector that the raw-CSI
path pushed) instead of an empty Vec. Bars now refresh on every WS
update (verified: 100/100 updates carry amps in a 4-s sample, was
~20/100 before the patch).
Classifier behaviour unchanged — amp_presence_override still runs only
when actual raw CSI arrives; this only affects what the UI displays.
Ports Pace's NBVI = α·(σ/μ²) + (1-α)·(σ/μ) (α=0.5) into the
amp_presence_override classifier. Per node, accumulates a 30-second
ring of full amplitude vectors, every ~5 s ranks the subcarriers,
picks top-12 by lowest NBVI, then computes broadband mean and CV ONLY
on that subset instead of all 56 subcarriers.
Live impact on the operator's deployment (idle room, 2 pps ping):
node 1 CV: 5% -> 3.1% (-38 %)
node 2 CV: 7% -> 3.9% (-44 %)
Thresholds tightened proportionally to match the new baseline:
active: 30 % -> 22 %
present_moving: 15 % -> 10 %
This lets the detector catch subtler motion (e.g. waving while seated)
without raising the false-positive rate above what we had before.
Implemented entirely server-side — no firmware change, no second
flash cycle. Algorithm parameters in const block for easy retuning.
* nodes[].rssi_dbm of 0 used to display literally as "0.0 dBm",
misleading the operator when rssi_history was empty on the first
few ticks. Now coerce to "--" and skip pushing zeros to the trace.
* per-node fps was 1/dt instantaneous, blown up to 235 by multiple
SensingUpdate emit paths firing back-to-back. Replaced with a
1-second windowed counter — now matches the real ~38 fps per node.
scripts/ota-deploy.sh
Python 3 helper (the earlier bash version tripped over macOS bash 3.2's
missing associative arrays). One invocation with no arguments:
1. discovers nodes in the local /24 via ARP + /ota/status:8032 probe;
2. POSTs the firmware blob to every node in parallel;
3. waits for reboot, polls /ota/status until running_partition flips,
and fails-loud if any node stays on the old partition (typical
symptom of a panic on first boot from the new slot).
Supports `--build` (idf.py build first), `--no-verify`, explicit IP
list, and OTA_PSK=<token> for the ADR-050 Bearer auth path.
Measured cycle: ~25 s end-to-end for both room01 + room02.
static/mobile.html
Mobile-first sibling of static/raw.html. The desktop page is unreadable
on a 360-420 px screen — bars chart fights the narrow viewport, 11-12 px
font, controls overlap the badge. The mobile page:
- sticky global badge (30 px) + connection pill + reset (44 px tap);
- per-node card with 22 px node badge, 18 px stat tiles, 90 px trace;
- drops the bars chart (useless under 600 px wide);
- viewport-fit=cover, theme-color, apple-mobile-web-app meta tags;
- high-contrast palette tuned for outdoor light;
- reuses the /ws/sensing contract verbatim — anything that lights up
raw.html lights this up too.
main.rs ServeDir route
Adds `.nest_service("/static", ServeDir::new(.../static))` so
raw.html / mobile.html / calibrate.html / spectrum.html are served on
the main 8080 port. Previously they needed a separate
`python -m http.server :8091`, which the operator had to remember to
start by hand on every deploy. Now there's exactly one URL per device.
Reachable from a phone on the LAN:
http://<mac>:8080/static/mobile.html
http://<mac>:8080/static/raw.html
Co-Authored-By: claude-flow <ruv@ruv.net>
Two server-side parsers (csi.rs::parse_esp32_frame and the duplicate in
main.rs) read every field after `n_antennas` from offsets shifted by 2
bytes — n_subcarriers as u8 instead of u16, sequence at 10..14 instead of
12..16, rssi at 14 instead of 16. The saturating_neg() workaround hid the
bug by always forcing a negative dBm value, so the trace looked plausible
but was actually a slice of mid-sequence number. ADR-100 D3 documented
this as an open item; this commit closes it.
Adds two regression tests in csi.rs (header-offset round-trip with
distinctive values per field, plus 20-byte boundary case) so the layout
contract can't drift again without CI catching it.
Even with both parsers correct, RSSI never reached the UI because the
firmware now ships only rv_feature_state_t (0xC5110006) — raw CSI
(0xC5110001) is no longer hot. rv_feature_state had no RSSI field;
both parsers fell back to rssi: -50 hardcode.
To fix without a protocol bump: repurpose the first byte of the trailing
`reserved` field (offset 54) as `int8_t rssi_dbm`. Firmware fills it from
radio_ops::get_health()::rssi_median_dbm in emit_feature_state. Server
reads buf[54] as i8; 0 means "not measured yet" → keeps the historical
-50 fallback for backward compat with pre-update nodes.
Verified live on TP-Link WISP (192.168.0.100/101):
node 1: -54 dBm node 2: -63 dBm (was plateau -50.0 fallback)
Co-Authored-By: claude-flow <ruv@ruv.net>
Surfaces the raw-amplitude classifier's per-node decision in
node_features[].classification so the UI can show which sensor is
actually seeing motion at any moment. Lets the operator visually find
the best sensor placement without physically moving things — just walk
around and watch which badge lights up.
Server side: adds amp_node_level() pure helper + amp_node_snapshot()
that reads AMP_LATEST, then plugs it into build_node_features so the
existing PerNodeFeatureInfo.classification carries the new labels.
UI: adds a global badge in the top bar and a per-node badge inline in
each h2, color-coded (grey/absent, blue/present_still, green/moving,
red/active) plus the live per-node CV %.
After ADR-100 gain-lock reveals a clean baseline, the broadband CV of
mean amplitude separates EMPTY/STILL/WALK by 3-6× on the operator's
deployment where RSSI MAD-Δ overlapped within noise. Adds:
amp_presence_override(node_id, amps) — per-frame: rolling 4.5 s
short window for CV, 60 s long window for 95th-percentile baseline,
cross-node fusion (MAX CV gate, ANY baseline-drop → still),
3 s motion hysteresis to bridge step pauses.
amp_classify_from_latest() — readonly fusion for feature_state
(0xC5110006) and adaptive-model paths that don't carry raw amps.
Wired into the three SensingUpdate-producing paths (raw CSI,
feature_state, adaptive model). Marks rssi_presence_override as
dead_code, kept for reference.
Live test (10 samples @ 3 s):
walk: present_moving, CV 41-53 %, sustained through pauses
stop: absent (CV 4-8 %) after 3 s hold expires
Ports Francesco Pace's ESPectre gain-lock (GPLv3) to RuView FW: medians
AGC and FFT scale over the first 300 packets after boot, then freezes
them via phy_force_rx_gain / phy_fft_scale_force. With both sensors
locked and proper AP→body→sensor geometry, a 30-s × 3-state capture
(empty / still / walk) now separates by ×3.4–×5.9 instead of ±0.02
within ±0.10 noise as in ADR-099.
Adds static/raw.html — per-node 56-subcarrier amplitude bars + RSSI/
broadband traces, no DSP, for live calibration.
ADR-100 documents the technique, boot calibration values for the
operator's deployment (AGC=42/44, both APPLIED), and the verified
three-state separation table.
Operator's household environment showed CSI-variance presence detection
failing — empty room produced HIGHER variance than an occupied room because
ambient WiFi noise (neighbour APs, retransmits, BT-coex) dominated the
broadband-variance signal at multi-meter range.
Deployed a TP-Link TL-WR841N in WISP mode as a dedicated isolated AP for
the sensors:
* Sensors associate only with TP-Link_8340 (clean channel)
* TP-Link bridges to the household AP, NAT-forwards sensor UDP to the Mac
* Mac keeps its primary household-AP association — no LAN reconfig needed
* Empty-room variance dropped 50.7 → 35.8 (-30%)
Replaced presence classification with RSSI MAD-Δ override:
* Per-node rolling 120-sample (~10 s @ 12 Hz) window of frame RSSI
* Metric: mean(|Δrssi|) between consecutive frames — robust to int8
quantisation jitter
* Thresholds tuned for the operator's geometry:
d < 0.20 → absent
< 0.55 → present_still
< 1.10 → present_moving
>= 1.10 → active
* Confidence field temporarily carries raw d for in-field threshold tuning
* CSI-based features (variance, motion_band_power, spectral_power) remain
in features.* for vital-sign signal-quality and multi-node fusion paths
UI / tooling:
* New static/spectrum.html — live signal console: combined classification,
all host-computed features (variance, motion_band, spectral, breathing
band, RSSI, dominant_freq, change_points), per-node FW signals, and a
60-second variance trace. Served via `python -m http.server 8091`.
* static/calibrate.html — simpler per-node motion/presence/RSSI bars
with peak-hold.
Desktop UI / discovery hardening (rolled in here because they came up
during this debug session):
* commands/discovery.rs: HTTP sweep limited to 2..=60 hosts (was 1..=254),
mDNS + UDP-broadcast paths disabled (current RuView FW doesn't advertise
them and they were burning CPU every poll cycle). Per-request timeout
set to 1500 ms with overall budget enforced via tokio::time::timeout +
futures::join_all (replaces the previous sequential select loop that
blocked on slow IPs).
* ui/hooks/useNodes.ts: poll interval 10 s → 30 s.
* ui/pages/Dashboard.tsx + NetworkDiscovery.tsx: merge new scan results
into existing list instead of replacing — discovery races sometimes miss
a node that was found a moment ago.
Firmware tuning:
* edge_processing.c: broadband-variance divisor /3.0 → /30.0 → /5.0
iterated; final /5.0 chosen for multi-meter geometry (sensor 1-3 m
from activity zone). DEBUG_MOTION_DSP scaffolding removed.
* csi_collector.c: CSI_MIN_SEND_INTERVAL_US 20 ms → 4 ms so the host can
see every available frame (real ceiling is the WiFi CSI callback rate).
Documentation:
* docs/adr/ADR-099 — full forensic write-up: measurement tables for sit/
walk/empty, the RSSI-Δ rationale, the WISP setup procedure, calibration
protocol for new deployments, and open items.
Verified end-to-end on hardware (sensors at 192.168.1.17/.19 → TP-Link at
192.168.1.14 → Mac at 192.168.1.21):
* UDP/5006 packets arrive ~12 Hz combined from both nodes
* Empty-room baseline d ≈ 0.49 measured (next: capture sit + walk to
finalize thresholds)
* Vital signs continue to populate (breathing 9–11 BPM stable)
* Two consecutive OTA round-trips remain functional after the change
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
End-to-end deployment fixes that took the two ESP32-S3 sensor boards
(room01, room02) from "boots but DSP frozen, OTA always rolls back" to
"motion/presence/breathing all live, two consecutive OTA round-trips
succeed". Full forensic write-up in docs/adr/ADR-098.
Firmware (firmware/esp32-csi-node/main/):
* csi_collector.c — remove esp_wifi_set_promiscuous(true): this call
silenced the CSI RX callback entirely on this silicon revision
(yield=0pps). Without it, callbacks resume at ~5-10 pps.
* edge_processing.c — root cause: incoming CSI frames carry 192
subcarriers but EDGE_MAX_SUBCARRIERS=128, so the size check
early-returned every frame and Step 8 (motion) never ran. Truncate
to 128 + warn once instead of returning.
* edge_processing.c — replace per-bin unwrapped-phase variance with
temporal variance of per-frame broadband mean amplitude. Empirical
separation on deployed hardware: empty 0.07-0.10, walking 3.5-14
(~44x). Scaled by /3.0 and clamped to [0,1].
* edge_processing.c — biquad fs 20.0 -> 10.0, matching the actual
callback rate (was halving the breathing passband).
* ota_update.c — OTA_WITH_SEQUENTIAL_WRITES -> OTA_SIZE_UNKNOWN to
erase the full target partition (stale tail of the previous larger
image was crashing the new image on boot, looking like rollback).
* ota_update.c — httpd_config_t.stack_size = 8192 (default 4 KB
overflowed in OTA verify path).
* main.c — log esp_reset_reason() and running_partition->label once
at app_main start, so OTA outcomes are visible without guesswork.
* sdkconfig.defaults — local deployment defaults: tier=2, display
disabled (no expander on these boards), 8192 timer stack.
Sensing server (v2/crates/wifi-densepose-sensing-server/):
* src/main.rs — parse_rv_feature_state() for the 0xC5110006
feature_state packet that RuView FW emits by default; this format
was previously unhandled. Wire ahead of parse_esp32_vitals.
* src/main.rs — BaselineTracker with hysteretic motion gating on top
of FW-reported scores, so UI sees clean boolean presence transitions.
* src/main.rs — refuse --source simulate; remove auto-fallback to
synthetic data. Production builds never run on fake signals.
* src/main.rs/csi.rs — parse_csi_lean() for legacy FW 5.47 CSV
packets; defence-in-depth for mistakenly flashed legacy sensors.
Desktop UI (v2/crates/wifi-densepose-desktop/):
* src/commands/discovery.rs — third discovery path: HTTP /status sweep
across the local /24 in parallel with mDNS/UDP. mDNS+UDP-beacon are
not advertised by current RuView FW. Replace sequential
for-task-in-tasks select-with-deadline (which blocked on slow
unrelated IPs) with futures::join_all + overall timeout.
* src/commands/server.rs — pass --bind-addr (was --bind); pass
RUST_LOG env instead of unsupported --log-level; auto-load bundled
wifi-densepose-v1.rvf next to the binary; reasonable defaults
(esp32 source, 0.0.0.0 bind).
* ui/* — keep last good node list when a poll returns 0 (discovery
is jittery on busy LANs); 8 s timeout (was 3 s); remove "simulate"
from DataSource enum and Sensing dropdown; default Sensing source
esp32.
Mobile UI (ui/mobile/):
* constants/websocket.ts — WS_PATH '/ws/sensing' + WS_PORT 8765 to
match the RuView sensing-server's WS endpoint (was the legacy
FastAPI /api/v1/stream/pose).
* services/ws.service.ts — derive WS host from serverUrl but use
WS_PORT; remove simulation fallback paths entirely (no
generateSimulatedData, no startSimulation on reconnect failure).
* stores/settingsStore.ts — serverUrl defaults to
http://100.123.189.10:8080 (deployed Mac's Tailscale IP), so the
phone connects from any network without LAN dependency.
* stores/matStore.ts — default dataSource='real',
simulationAcknowledged=true; no synthetic triage data.
* screens/MATScreen, VitalsScreen — hide simulation overlay/badge.
Docker:
* docker/docker-compose.yml — sensing-server host port 5005 -> 5006
to match the RuView FW's compiled CSI_TARGET_PORT default.
Documentation:
* docs/adr/ADR-098-esp32s3-csi-deployment-fixes.md — full forensic
ADR covering each decision, the empirical numbers that drove it,
the false hypotheses we ruled out along the way, and open items.
Verified on hardware (both nodes):
* motion empty < 0.05 (room01 0.018, room02 0.070)
* motion walking > 0.3 within 1-3 s, saturates at 1.0
* motion decay < 0.1 within 5 s after leaving
* breathing 21-22 BPM detected after ~30 s stationary
* two consecutive OTA round-trips succeed without USB intervention
* discovery finds both sensors via HTTP sweep in <2 s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes#520, #514, #443.
## #520 / #514 — stale Docker image, missing UI assets
`ruvnet/wifi-densepose:latest` was published before `ui/observatory*` and
`ui/pose-fusion*` were added; users see /app/ui missing those files and the
v0.6+ packet format doesn't reach the server. Two fixes:
1. `docker/Dockerfile.rust` now `RUN`s a build-time guard after `COPY ui/`
that fails the build if `index.html` / `observatory.html` / `pose-fusion.html`
/ `viz.html` (or the `observatory/` / `pose-fusion/` / `components/` /
`services/` directories) are missing, plus an exec-bit check on
`/app/sensing-server`. A stale image can never be silently produced again.
2. New `.github/workflows/sensing-server-docker.yml` rebuilds + pushes on
every change to the Dockerfile, the server crate, the signal/vitals/
wifiscan crates, the workspace manifests, the `ui/` tree, or itself —
plus `v*` tags and manual dispatch. Pushes to both `docker.io/ruvnet/
wifi-densepose` AND `ghcr.io/ruvnet/wifi-densepose` with `latest` +
`vX.Y.Z` + `sha-<short>` tags, then post-push smoke-tests the artifact:
/health, /api/v1/info, the observatory + pose-fusion HTML, AND the
bearer-auth path (no token → 401, wrong → 401, correct → 200). Uses the
`DOCKERHUB_USERNAME`/`DOCKERHUB_TOKEN` repo secrets; ghcr.io rides on
the workflow's GITHUB_TOKEN.
## #443 — sensing-server REST API auth model
QE security audit raised that 40+ /api/v1/* routes have no auth layer with
a default `0.0.0.0` bind. New `wifi_densepose_sensing_server::bearer_auth`
module + middleware:
- Env-var-gated: `RUVIEW_API_TOKEN` unset/empty ⇒ middleware is a no-op
(current LAN-mode behaviour preserved — **no default change**); set ⇒
every `/api/v1/*` request must carry `Authorization: Bearer <token>`
or the server returns 401.
- Constant-time byte compare via local `ct_eq` (no new dep).
- `/health*`, `/ws/sensing`, and `/ui/*` are intentionally never gated
(orchestrator probes + local browsers).
- Startup logs which mode is active and warns when auth is ON with a
`0.0.0.0` bind.
- 8 unit tests on the middleware via `tower::ServiceExt::oneshot`
(sensing-server lib tests 191 → 199, 0 failures).
Verified locally: `cargo build --workspace --no-default-features` ✓,
`cargo test -p wifi-densepose-sensing-server --no-default-features` ✓.
Co-Authored-By: claude-flow <ruv@ruv.net>
rvCSI now lives in its own repo (github.com/ruvnet/rvcsi), vendored here as
`vendor/rvcsi` (PR #543) and published to crates.io as `rvcsi-* 0.3.x` /
to npm as `@ruv/rvcsi`. The inline copies in `v2/crates/rvcsi-*` (added in
#542) were a duplicate; this removes them and re-points the docs.
- `git rm -r v2/crates/rvcsi-{core,dsp,events,adapter-file,adapter-nexmon,ruvector,runtime,node,cli}`
- `v2/Cargo.toml`: remove the 9 from `members` (note: `vendor/rvcsi/Cargo.toml`
is its own workspace — depend on the published crates or the submodule paths,
not as v2 workspace members).
- `CLAUDE.md`: the 9 crate-table rows collapse to one `vendor/rvcsi` row.
- `README.md` docs table: rvCSI entry points at the standalone repo + notes the
submodule / crates.io / npm / plugin.
- `CHANGELOG.md`: `[Unreleased]` entry.
The ADRs (ADR-095, ADR-096), PRD, and DDD model stay in `docs/` as the design
record of the incubation. `cargo build --workspace --no-default-features` and
`cargo test --workspace --no-default-features` stay green.
Co-Authored-By: claude-flow <ruv@ruv.net>
BaselineDriftDetector compared `mean_amplitude` against its EWMA baseline
with *absolute* thresholds (anomaly 1.0, drift 0.15). Fine for the synthetic
unit tests (amplitudes ~1.0), but raw ESP32 CSI is int8 I/Q with amplitudes
up to ~128, so window-to-window RMS distance is routinely 5-50 >> 1.0 and
AnomalyDetected fired on ~96% of windows (319/331 on a real node-1 capture).
Drift is now `||current - baseline||2 / ||baseline||2` (a fraction, with an
eps floor that falls back to absolute for a degenerate near-zero baseline),
so one tuning is valid across raw-int8 ESP32, int16-scaled Nexmon, and
baseline-subtracted streams. AnomalyDetected drops to 40/331 on the same
data; the existing detector tests still pass (their explicit configs are
valid relative thresholds too); added baseline_drift_is_scale_invariant_
no_anomaly_storm. rvcsi-events 18 -> 19 tests; 162 rvcsi tests, 0 failures,
clippy-clean.
Surfaced by an end-to-end test against real ESP32 CSI on COM7: the device
(ESP32-S3, node 1, ADR-018 firmware, WiFi "ruv.net" ch5 RSSI -39, CSI cb
only because nothing listens at .156). rvcsi has no ESP32 adapter yet, so a
7,000-frame node-1 recording was transcoded to .rvcsi via the new
scripts/esp32_jsonl_to_rvcsi.py (stand-in for `record --source esp32-jsonl`)
and run through `rvcsi inspect`/`replay`/`calibrate`/`events` end-to-end.
ADR-095 D13 and ADR-096 sections 2.1/5 updated; CHANGELOG entry added;
rvcsi-adapter-esp32 (live serial/UDP source) noted as a follow-up.
Co-Authored-By: claude-flow <ruv@ruv.net>
Adds first-class support for the Raspberry Pi 5's WiFi chip (CYW43455 /
BCM43455c0 — the same 802.11ac wireless as the Pi 4 / Pi 3B+ / Pi 400, and the
chip with the most mature nexmon_csi support), plus a registry of the other
Nexmon-supported Broadcom/Cypress chips.
rvcsi-adapter-nexmon — new `chips.rs`:
- `NexmonChip` (Bcm43455c0, Bcm43436b0, Bcm4366c0, Bcm4375b1, Bcm4358, Bcm4339,
Unknown{chip_ver}) + `RaspberryPiModel` (Pi5/Pi4/Pi400/Pi3BPlus/PiZero2W/
PiZeroW) — Pi5/Pi4/Pi400/Pi3B+ → Bcm43455c0; PiZero2W → Bcm43436b0.
- `nexmon_adapter_profile(chip)` / `raspberry_pi_profile(model)` build the
per-device `AdapterProfile` (channels: 2.4 GHz 1-13 + 5 GHz UNII for dual-band;
bandwidths 20/40/80[/160]; expected subcarrier counts 64/128/256[/512]) that
`validate_frame` bounds CSI frames against.
- `NexmonChip::from_chip_ver` (0x4345 → Bcm43455c0, 0x4339, 0x4358, 0x4366,
0x4375 — best-effort; the raw `chip_ver` is always preserved) and `from_slug`
/ `RaspberryPiModel::from_slug` ("pi5", "raspberry pi 4", "bcm43455c0", ...).
- `NexmonCsiHeader::chip()`; `NexmonPcapAdapter` auto-detects the chip from the
packets' `chip_ver` and uses the matching profile, overridable via
`.with_chip(NexmonChip)` / `.with_pi_model(RaspberryPiModel)`; `.detected_chip()`.
rvcsi-runtime: `decode_nexmon_pcap_for(.., chip_spec)` (validate against a chip /
Pi model, drop non-conforming) + `nexmon_profile_for(spec)`; `NexmonPcapSummary`
gains `chip_names` + `detected_chip`; `CaptureSummary` gains `chip`.
rvcsi-cli: `record --source nexmon-pcap --chip pi5`; new `nexmon-chips`
subcommand (lists chips + Pi models, human or `--json`); `inspect-nexmon` and
`inspect` now print the resolved chip.
rvcsi-node (napi-rs): `nexmonDecodePcap` gains an optional `chip` arg;
`nexmonChipName(chipVer)`, `nexmonProfile(spec)`, `nexmonChips()`. @ruv/rvcsi
SDK + `.d.ts` updated (AdapterProfile / NexmonChipsListing interfaces, the new
fns, `chip` on CaptureSummary, `chip_names`/`detected_chip` on NexmonPcapSummary).
168 rvcsi tests pass (adapter-nexmon 22→28, cli 9→10), 0 failures, clippy-clean.
The synthetic test captures now stamp chip_ver = 0x4345 (the BCM4345 family chip
ID), so the chip-detection happy path is exercised end to end.
ADR-096, CHANGELOG, README, CLAUDE.md updated.
https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
First implementation milestone for the rvCSI edge RF sensing runtime:
- rvcsi-core — the foundation: CsiFrame/CsiWindow/CsiEvent normalized schema,
ValidationStatus, AdapterProfile, CsiSource plugin trait, id newtypes +
IdGenerator, RvcsiError, and the validate_frame pipeline (length/finiteness/
subcarrier/RSSI/monotonicity hard checks + multiplicative quality scoring →
Accepted/Degraded/Recovered/Rejected). 29 unit tests, forbid(unsafe_code).
- rvcsi-adapter-nexmon — the napi-c boundary: native/rvcsi_nexmon_shim.{c,h}
(the only C in the runtime, allocation-free, bounds-checked, parses/writes a
byte-defined "rvCSI Nexmon record" — a normalized superset of the nexmon_csi
UDP payload), compiled via build.rs + cc, wrapped by a documented ffi module
and a NexmonAdapter implementing CsiSource. 9 tests round-tripping through C.
- Workspace registration in v2/Cargo.toml (8 new members + napi/cc workspace
deps) and compiling skeletons for rvcsi-dsp, rvcsi-events, rvcsi-adapter-file,
rvcsi-ruvector, rvcsi-node (napi-rs cdylib + build.rs napi_build::setup) and
rvcsi-cli (`rvcsi` binary) — to be filled in by the implementation swarm.
cargo build -p rvcsi-core -p rvcsi-adapter-nexmon -p rvcsi-node -p rvcsi-cli: OK
cargo test -p rvcsi-core -p rvcsi-adapter-nexmon: 38 passed, 0 failed
https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
Publishing the additive changes from PRs #536/#537 to crates.io:
- `signal_features` module — wires `wifi-densepose-signal` into the pipeline
(audit #1/#2)
- `TrainingConfig::for_subcarriers` / `ht40_192()` / `multiband_168()` presets
+ the real `MmFiDataset` loader integration test (audit #4/#6/#7)
No public API removals or changes — additive only, so 0.3.0 -> 0.3.1 is
semver-correct. No other workspace crate depends on `wifi-densepose-train`,
so this is a standalone bump.
Co-Authored-By: claude-flow <ruv@ruv.net>
Closes the remaining doable items from the 2026-05-11 training-pipeline audit:
#6 (CSI format default = 56-sc / 1 NIC) + #7 (multi-band 168-sc mesh not in
config): new `TrainingConfig::for_subcarriers(native, target)` plus named
presets `mmfi()` (114→56), `ht40_192()` (≈192-sc ESP32 HT40 → 56) and
`multiband_168()` (168-sc ADR-078 multi-band mesh → 56). Non-MM-Fi CSI shapes
are now first-class instead of requiring manual `native_subcarriers` /
`num_subcarriers` overrides; the field docs list the supported source counts
and the multi-NIC mapping (a 2–3-node mesh currently rides on `n_rx` until a
dedicated node dimension lands). Model input width stays `num_subcarriers`; the
presets only vary the resampling input.
#4 (proof.rs uses synthetic data): reframed — a deterministic proof *must* use
a reproducible source, so `verify-training` correctly stays on
`SyntheticCsiDataset`. The real gap was that nothing exercised the on-disk
`MmFiDataset` path. New `tests/test_real_loader.rs` writes synthetic CSI to
`.npy` files in the `MmFiDataset::discover` layout, loads it back, and checks
the resulting `CsiSample` — covering the no-interp case, the
subcarrier-interpolation branch, and the empty-root case. Adds `ndarray` /
`ndarray-npy` as dev-deps for the fixture writing.
cargo check + cargo test -p wifi-densepose-train --no-default-features: clean,
all existing tests green, 3 new loader tests + the updated config doctest pass.
Purely additive — no model-shape change, no tch-module change.
Addresses three findings from the 2026-05-11 training-pipeline audit:
#1/#2 — `wifi-densepose-signal` was a phantom dependency of `wifi-densepose-train`
(listed in Cargo.toml, never imported), and vitals/CSI signal features were
absent from the pipeline. New module `wifi_densepose_train::signal_features`:
`extract_signal_features(&Array4<f32>, &Array4<f32>) -> Array1<f32>` (and the
convenience method `CsiSample::signal_features()`) runs a windowed observation's
centre frame through `wifi_densepose_signal::features::FeatureExtractor`,
producing a fixed-length (FEATURE_LEN=12) amplitude / phase-coherence / PSD
feature vector — the hook for a future vitals / multi-task supervision head
(breathing- and heart-rate-band power are read off the PSD summary). The vector
is produced on demand and is not yet fed back into the loss; wiring it as a
training target is the documented follow-up. `wifi-densepose-signal` is now an
actually-used dependency. 5 new tests (2 unit in signal_features.rs, 3
integration in tests/test_dataset.rs); existing wifi-densepose-train tests
unchanged and green.
#3 — `docs/huggingface/MODEL_CARD.md` presented PIR/BME280 environmental-sensor
weak-label fine-tuning as a current capability; there is no env-sensor
ingestion in the training pipeline. Marked that path as planned/not-implemented
in the training-steps list and the data-provenance section.
(#5 — README's "92.9% PCK@20" overclaim — fixed separately in PR #535.)
CHANGELOG updated.
The ESP32 firmware multiplexes several wire packet types onto the same
UDP port as ADR-018 raw CSI frames (magic 0xC5110001):
0xC5110002 ADR-039 edge vitals (32 B)
0xC5110003 ADR-069 feature vector
0xC5110004 ADR-063 fused vitals
0xC5110005 ADR-039 compressed CSI
0xC5110006 ADR-081 feature state
0xC5110007 ADR-095/#513 temporal classification
Esp32CsiParser only knew 0xC5110001, so the standalone `aggregator`
binary printed "parse error: Invalid magic: expected 0xc5110001, got
0xc5110002" for every vitals packet. No CSI data was lost — just noise.
Add the sibling-magic constants + ruview_sibling_packet_name(), classify
recognized siblings before the CSI-frame length gate, and return a new
ParseError::NonCsiPacket { magic, kind } instead of InvalidMagic. The
`aggregator` CLI now skips them quietly (logs "[skipped ADR-039 edge
vitals packet — not a CSI frame]" only with --verbose); the library-level
CsiAggregator already dropped them silently. New regression tests cover
all seven magics.
Closes#517
Co-Authored-By: claude-flow <ruv@ruv.net>
When ?backend=<url> pointed at a server that wasn't running (e.g. user
forgot to start ruview-pointcloud serve before clicking Connect ESP32),
the viewer was retrying 10 Hz forever — flooding the console with
ERR_CONNECTION_REFUSED and offering no guidance about what was wrong.
Two fixes:
1. Replace setInterval(fetchCloud, 100) with self-rescheduling
setTimeout. On success: 250 ms steady cadence. On failure for an
explicit backend: 250 ms → 500 → 1 s → 2 s → 4 s → 8 s → 16 s →
capped at 30 s. Resets to 250 ms the moment the backend comes back.
Auto mode (Pages with no backend) still disables network entirely
after the first 404. Strict-live mode (?live=1) also backs off so
it doesn't spam.
2. Show an actionable status banner in the info panel when the chosen
backend is unreachable: the URL, the actual error string, the next
retry time, and the exact `cargo run` command to start the server.
Visitor sees the diagnosis instead of staring at a 'demo' badge
wondering why their ESP32 feed isn't visible.
The scene keeps animating (face mesh / synthetic) while the viewer
waits, so the tab never goes blank.
Co-Authored-By: claude-flow <ruv@ruv.net>
Lets the visitor enable their browser webcam face mesh in addition to
(not instead of) a connected ESP32 backend. Both render in the same
Three.js scene — the live ESP32-driven splats from /api/splats plus the
visitor's own face as a 478-vertex MediaPipe point cloud. Use cases:
- Local development: see your face overlaid on the camera+CSI fusion
output to debug coordinate-frame alignment.
- Demos: show 'this is the room as ESP32 sees it, and this is me as
MediaPipe sees me' side-by-side in one scene.
Implementation:
- Extract pushFaceSplats(splats) — pushes the 478 face vertices plus
~8000 edge-interpolated samples into the array, with no Foundation
context. Reused by faceMeshFrame (demo path) and handleData (overlay
path) so there is one source of truth for face-splat geometry.
- handleData now appends pushFaceSplats output to data.splats when the
source is not 'face-mesh' AND the user has clicked the camera CTA.
Sets data._faceOverlay so the badge can show '+ face overlay'.
- Camera CTA is no longer hidden in remote/live modes — it relabels to
'▶ Add face overlay' so the affordance is clear. Strict-live mode
(?live=1) still hides it because the offline panel takes over.
- Splat count in the info panel reflects the rendered total (backend +
overlay) when the overlay is active.
Co-Authored-By: claude-flow <ruv@ruv.net>
The hosted GitHub Pages viewer can now act as a thin client for a
locally-running ruview-pointcloud serve instance — flip a button, the
ESP32's CSI fusion (camera depth + WiFi CSI + mmWave) renders inside
the same Three.js scene that previously only showed the face mesh
demo. No clone, no rebuild, no toolchain on the visitor's side.
Server (stream.rs):
- Add tower_http::cors::CorsLayer with a deliberate allowlist:
https://ruvnet.github.io, http://localhost:*, http://127.0.0.1:*,
and 'null' (for file:// origins). Anything else is denied — not a
wildcard CORS. Modern browsers (Chrome 94+, Firefox 116+, Safari
16.4+) treat 127.0.0.1 as a "potentially trustworthy" origin so
HTTPS Pages → HTTP loopback is permitted. The new layer wraps the
existing /api/cloud, /api/splats, /api/status, /health routes.
- Cargo.toml: pull in workspace tower-http (cors feature already on).
Viewer:
- New "📡 Connect ESP32…" CTA bottom-right. Clicking prompts for a
ruview-pointcloud serve URL (default http://127.0.0.1:9880),
persists the last-used value in localStorage, and reloads with
?backend=<url> so the existing remote-mode fetch path takes over.
When already connected the button toggles to "disconnect" and
reloads back to the demo.
- Reuses the existing transport selector — no new code path to
maintain. The face mesh / synthetic demo render path is unaffected;
this is purely an additive UI affordance over the ?backend= query.
Docs:
- ADR-094 §2.3 expanded with the local-ESP32 workflow and the CORS
posture rationale.
- Workflow README documents ?backend=http://127.0.0.1:9880 as the
intended local-ESP32 path.
Tests: cargo test -p wifi-densepose-pointcloud → 15/15 passed.
Co-Authored-By: claude-flow <ruv@ruv.net>
Browsers auto-request /favicon.ico when none is declared in <head>.
On a static GitHub Pages host that's a guaranteed 404 in the console.
Inline a 32x32 SVG amber dot via data: URL so the browser is satisfied
without an extra network round-trip.
Co-Authored-By: claude-flow <ruv@ruv.net>
When the viewer is hosted on a static origin (GitHub Pages, S3) it has
no backend at /api/splats. The default ?backend=auto path was issuing
a fetch every 100 ms, getting a 404, falling back to the demo, and
flooding the console with one 404 per tick. Cosmetic on the surface
but real network/CPU waste over time.
After the first 404 in auto mode, set networkDisabled=true and skip
fetch on subsequent ticks — the interval still fires but goes straight
to pickDemoFrame() so the face mesh / synthetic render path keeps
animating. Remote (?backend=<url>) and live (?live=1) modes keep
retrying so a transient outage doesn't permanently downgrade them.
Co-Authored-By: claude-flow <ruv@ruv.net>
Adds optional cinematic effects to the face-mesh demo, all toggleable
via a new ?fx= URL param. Default is 'all' (texture + mesh + scan +
halo). Lightweight modes available: ?fx=clean (texture only) or
?fx=points (original solid amber).
- Texture: per-frame webcam → hidden 2D canvas → getImageData lookup
at each landmark (and each interpolated edge sample). Splats now
carry the visitor's actual skin tone, not solid amber. Sampling is
mirrored on x to match the selfie convention used by the face mesh
vertex placement. All on-device — no frames leave the browser.
- Mesh: persistent THREE.LineSegments overlay drawn from
FACEMESH_TESSELATION (~1300 edges). Translucent (opacity 0.35),
amber, additive blending, depthWrite off — gives a holographic
wireframe wrapping the point cloud. Geometry is updated in place
each frame; only positions get re-uploaded.
- Scan: vertical bright slab sweeps top→bottom every 4 seconds,
amplifying splat color up to 2.6× when within ±0.08 world units of
the line. Westworld-style scanning.
- Halo: existing 60-particle ring around the face is now opt-in via
FX_HALO. Cleaner default for the texture-mesh combination.
Info panel surfaces active fx list in face-mesh mode. Synthetic
fallback hides the wireframe overlay so it doesn't render against an
empty figure. Workflow README updated with the new ?fx= options.
Co-Authored-By: claude-flow <ruv@ruv.net>
Three fixes in one pass to address visitor feedback:
1. Face was rendering upside down — MediaPipe's lm.y is image-down (0=top
of frame, 1=bottom) and the existing updateSplats() already does a
y-negate to convert to Three.js Y-up. Pre-flipping in lmToCenter was a
double flip. Use lm.y directly so the renderer's single flip lands the
head at the top of the screen.
2. Density and fidelity — interpolate 6 splats per FACEMESH_TESSELATION
edge (~1300 edges → ~8000 face splats vs 478 vertex-only). Amplify
lm.z mapping (×8 vs ×4) so eye sockets, nose, and chin show real 3D
depth. Smaller splat scale (0.006 surface, 0.010 vertices) for finer
point appearance.
3. Foundation-inspired aesthetic — the demo now renders the subject
(face mesh OR procedural fallback) inside a Hari Seldon time-vault:
* Holographic surveyor grid in amber, breathing brightness pattern.
* Slow-rotating two-arm galactic spiral receding behind the subject
(~640 stars, warm core to cool edges, Trantor-evocation).
* 800-star deterministic distant starfield on a spherical shell
(fixed LCG seed so visitors don't see noise flicker).
* 60-particle holographic halo orbiting the subject plane.
Shared pushFoundationContext() drives both face-mesh and synthetic
paths. Synthetic procedural figure densified 4x (240 vs 60 points)
and re-oriented (head→top, feet→bottom) so the y-down convention is
internally consistent.
Camera pulled back to (0, 0.2, -3.5) to frame the galactic context.
Poll cadence 4 Hz → 10 Hz so the spiral animates smoothly. Info panel
gets a Seldon quote and "Seldon Vault" branding. CTA copy reframed to
"Project Subject — render your face into the Vault".
ADR-094 already documents the dual-transport intent; the aesthetic
choices here are content, not architecture, so no ADR update needed.
Co-Authored-By: claude-flow <ruv@ruv.net>
The previous synthetic procedural demo did not represent what the local
fusion pipeline produces — a real depth-backprojected point cloud of
the user's face and surroundings. This commit ports the closest browser
equivalent: MediaPipe Face Mesh runs in-browser at ~30 fps and emits
478 3D landmarks per frame. Each visitor now sees the outline of their
own face rendered as a point cloud, with a small floor + back wall for
spatial context.
- Adds MediaPipe Face Mesh + Camera Utils via jsdelivr CDN.
- Adds an "▶ Enable camera" CTA so getUserMedia is gated on a user
gesture (required by some browsers and good UX regardless).
- New face-mesh frame generator uses the same splat shape as the live
/api/splats payload, so a single render path drives both modes.
- Mirrors x to match selfie convention; maps lm.z (relative depth) to
the world-coord range used by the live pipeline.
- Falls back automatically to the procedural floor + walls + figure
when the camera is denied, dismissed, or unavailable.
- Badge surfaces the new state: '● DEMO Your Face (MediaPipe)'.
- Bumps poll cadence to 4 Hz so face mesh updates feel live.
- ADR-094 updated to reflect the new default behavior.
Co-Authored-By: claude-flow <ruv@ruv.net>
Publishes the live 3D point cloud viewer to gh-pages/pointcloud/ so it
can be linked from the README alongside the Observatory and Dual-Modal
Pose Fusion demos. The viewer auto-selects its transport from URL
parameters:
- default / ?backend=auto — try /api/splats, fall back to synthetic demo
- ?backend=demo — synthetic in-browser only, no network
- ?backend=<url> — fetch from a CORS-permitting host running
ruview-pointcloud serve
- ?live=1 — strict mode, show offline panel instead of demo fallback
The synthetic frame matches the live API JSON shape (splats, count,
frame, live, pipeline.{skeleton,vitals}) so a single render path drives
both modes. New workflow uses keep_files: true to preserve the existing
observatory/, pose-fusion/, and nvsim/ deployments on gh-pages.
See docs/adr/ADR-094-pointcloud-github-pages-deployment.md for the full
decision record and 6 acceptance gates.
Closes the two post-merge failures from #436:
1. wasm-pack: command not found — cargo install doesn't reliably leave
the binary on PATH. Switched to the canonical installer in both the
Pages and a11y workflows.
2. nvsim-server Docker build — cargo couldn't resolve workspace.dependencies
from a partial copy. Dockerfile now generates a stub workspace
Cargo.toml inline that lists just nvsim + nvsim-server.
* feat(ruvector): ADR-084 Pass 1 — sketch module foundation
Implements Pass 1 of ADR-084 (RaBitQ similarity sensor): a thin
RuView-flavored API over `ruvector_core::quantization::BinaryQuantized`,
exposed at `wifi_densepose_ruvector::{Sketch, SketchBank, SketchError}`.
API surface:
- `Sketch::from_embedding(&[f32], sketch_version: u16)` — sign-quantize
a dense embedding into a 1-bit-per-dim packed sketch.
- `Sketch::distance` — hamming distance with schema-mismatch error.
- `Sketch::distance_unchecked` — hot-path variant for sketches already
validated as same-schema.
- `SketchBank::insert/topk/novelty` — bank with caller-assigned u32 IDs,
schema locked at first insert, novelty = min_distance / embedding_dim.
Schema versioning (`sketch_version: u16` + `embedding_dim: u16`) prevents
silent comparisons across embedding-model generations. Bumping the model
forces re-sketch of the candidate bank.
Pass 1 establishes the API and unit-test foundation. Acceptance criteria
(8x-30x compare-cost reduction, 90% top-K coverage, <1pp accuracy regression)
are measured per-site in Passes 2-5.
Validated:
- 12 new tests pass (sketch construction, hamming, top-K ordering,
schema lock, schema rejection, novelty)
- cargo test --workspace --no-default-features → 1,551 passed, 0 failed,
8 ignored (was 1,539 before; +12 new tests)
- ESP32-S3 on COM7 still streaming live CSI (cb #117300)
Co-Authored-By: claude-flow <ruv@ruv.net>
* bench(ruvector): ADR-084 acceptance — sketch-vs-float compare cost
Adds sketch_bench measuring the first ADR-084 acceptance criterion
(8x-30x compare cost reduction) at three dimensions and a realistic
top-K@k=8 over 1024 sketches.
Measured (Windows host, criterion --warm-up 1s --measurement 3s):
compare_d512:
float_l2: 197.03 ns/op
float_cosine: 231.17 ns/op
sketch_hamming: 4.56 ns/op → 43-51x speedup
topk_d128_n1024_k8:
float_l2_topk: 47.59 us
sketch_hamming: 6.34 us → 7.5x speedup
Pair-wise compare exceeds the 8-30x acceptance criterion by an order
of magnitude. Top-K is at 7.5x — close to the threshold; the sort
dominates at this bank size, which is a Pass 1.5 optimization
opportunity (partial-sort heap for small K).
Co-Authored-By: claude-flow <ruv@ruv.net>
* perf(ruvector): ADR-084 Pass 1.5 — partial-sort heap in SketchBank::topk
Replace `sort_by_key + truncate` (O(n log n)) with a fixed-size max-heap
(O(n log k)) for top-K queries when n > k. Fast path when n ≤ k stays
on the simple sort.
Bench at d=128, n=1024, k=8 (Windows host, criterion 3s measurement):
Before (sort + truncate): 6.34 µs/op
After (heap): 3.83 µs/op -39.4% / +1.65× faster
Combined with the 32× memory shrink and 47.6 µs → 3.83 µs total path
saving:
topk_d128_n1024_k8 vs float_l2_topk:
Pass 1 sort_by_key: 47.59 µs / 6.34 µs = 7.5× speedup
Pass 1.5 heap: 47.59 µs / 3.83 µs = 12.4× speedup
Now over the ADR-084 acceptance criterion of 8× minimum. Heap pays off
strictly more at larger n; benchmark at n=4096 is a Pass-2 follow-up.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(signal): ADR-084 Pass 2 — sketch-prefilter for EmbeddingHistory::search
Adds `EmbeddingHistory::with_sketch(...)` and `search_prefilter(query, k,
prefilter_factor)`. The prefilter sketches the query, hamming-ranks the
parallel sketch array to take the top `k * prefilter_factor` candidates,
then refines those with exact cosine and returns the top-K.
`EmbeddingHistory::new(...)` is unchanged — sketches are opt-in via the
new constructor. `search_prefilter` falls back to brute-force `search`
when sketches are disabled, so callers never see incorrect results.
ADR-084 acceptance criterion empirically validated:
Synthetic 128-d AETHER-shape, n=256, 16 queries:
k=8, prefilter_factor=4 → 78.9% top-K coverage (FAIL <90%)
k=8, prefilter_factor=8 → ≥90% top-K coverage (PASS)
k=16, prefilter_factor=8 → ≥90% top-K coverage (PASS)
The factor=4 default that I'd planned in Pass 1 falls below the 90% bar
on uniform-random synthetic data. Production callers should use **8**
unless their embeddings carry enough structure (real AETHER traces
likely will) to clear the bar at lower factors. Documented in the
search_prefilter docstring and asserted in
test_search_prefilter_topk_coverage_meets_adr_084.
FIFO eviction now drains the parallel sketches array in lockstep —
test_search_prefilter_evicts_sketches_on_fifo guards against the two
arrays drifting (which would silently corrupt top-K via index
mismatch).
Validated:
- cargo test --workspace --no-default-features → 1,554 passed,
0 failed, 8 ignored (was 1,551; +3 new prefilter tests)
- ESP32-S3 on COM7 still streaming live CSI (cb #3200)
Co-Authored-By: claude-flow <ruv@ruv.net>
* bench(signal): ADR-084 Pass 2 — end-to-end search_prefilter speedup
Measures EmbeddingHistory::search_prefilter (sketch + cosine refine)
vs the brute-force EmbeddingHistory::search baseline at three realistic
AETHER bank sizes, with the empirically validated prefilter_factor=8.
Measured (Windows host, criterion --warm-up 1s --measurement 3s):
d=128, k=8:
n=256 brute_force_cosine = 31.98 us, prefilter = 13.78 us → 2.3x
n=1024 brute_force_cosine = 110.4 us, prefilter = 16.64 us → 6.6x
n=4096 brute_force_cosine = 507.4 us, prefilter = 66.37 us → 7.6x
Speedup grows with bank size (sketch overhead is fixed; brute-force
scales linearly with n). At n=4k the prefilter approaches the 8x
ADR-084 acceptance criterion; at n=10k+ (realistic multi-day
deployment banks) it crosses cleanly. Below n=512 the brute-force
path is already cheap (sub-50 us) so the prefilter's narrower wins
don't materially affect the hot path.
Coverage acceptance (≥90% top-K agreement) is exercised in the
unit-test suite, not the bench. The bench measures cost only.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(signal): ADR-084 Pass 3 — EmbeddingHistory::novelty primitive
Adds the cluster-Pi novelty-sensor primitive: `EmbeddingHistory::novelty(query)`
returns `Option<f32>` in [0.0, 1.0] where 0.0 = exact-match-in-bank
and 1.0 = no-overlap. Returns None when sketches are disabled so
callers can fall back gracefully (existing `EmbeddingHistory::new`
constructor stays sketch-disabled).
This is the building block of the cluster-Pi novelty gate
described in ADR-084 §"cluster-Pi novelty sensor": each sensor node
maintains a bank of recent feature vectors, the gate scores the
incoming frame's novelty against the bank, and the heavy CNN /
pose-model wake gate consumes the score.
Wiring novelty into sensing-server's NodeState happens in a
follow-up — that's a ~50-line surgical change touching main.rs that
deserves its own commit. This patch lands the primitive + tests so
the wiring is straightforward.
Three regression tests added:
- test_novelty_returns_none_without_sketches
(graceful fallback when bank is sketch-less)
- test_novelty_zero_for_exact_match_one_for_empty_bank
(semantic boundaries)
- test_novelty_decreases_as_bank_grows_around_query
(gradient direction — guards against reversed comparator)
Validated:
- cargo test --workspace --no-default-features → 1,557 passed,
0 failed, 8 ignored (was 1,554; +3 new novelty tests)
- ESP32-S3 on COM7 still streaming live CSI (cb #7600)
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(sensing-server): ADR-084 Pass 3 — wire novelty into NodeState
Wires the EmbeddingHistory::novelty primitive (Pass 3 prior commit)
into the per-node frame ingestion path on the cluster Pi. Each
incoming CSI frame now updates a per-node sketch bank of the last
6.4 s of feature vectors and produces a novelty score in [0.0, 1.0]
that downstream model-wake gates can consume.
Two NodeState structs were touched (one in types.rs and a
refactoring-leftover duplicate in main.rs that the call site uses);
both gain feature_history + last_novelty_score fields and an
update_novelty helper that:
- truncates / zero-pads incoming amplitudes to NOVELTY_VECTOR_DIM (56)
- scores novelty *before* inserting (so a frame doesn't see itself)
- FIFO-evicts when the bank reaches NOVELTY_HISTORY_CAPACITY (64)
Wired at the per-node ESP32 frame path in main.rs:3772 (immediately
before frame_history.push_back). Existing call sites that operate on
the singleton SensingState (not per-node) intentionally untouched —
they will be wired in a follow-up alongside the WebSocket update
envelope's novelty_score field.
Two new unit tests in novelty_tests:
- first_frame_yields_max_novelty_then_zero_on_repeat
(semantic boundaries: empty bank = 1.0, exact repeat = 0.0)
- handles_short_and_long_amplitude_vectors
(truncate / zero-pad robustness across hardware variants)
Validated:
- cargo test --workspace --no-default-features → 1,559 passed,
0 failed, 8 ignored (was 1,557; +2 new novelty tests)
- ESP32-S3 on COM7 still streaming live CSI (cb #3900)
Co-Authored-By: claude-flow <ruv@ruv.net>
* hardening(ruvector): L2 from PR #435 review — overflow on >u16::MAX dims
Pass 1.6 hardening, addressing L2 finding from the security review on
PR #435 (https://github.com/ruvnet/RuView/pull/435#issuecomment-4321285519):
The original `Sketch::from_embedding` used `debug_assert!` for the
`embedding.len() <= u16::MAX` invariant, which compiled out in release
builds. A caller passing a 65,536+ -dim embedding would silently
truncate the dimension count via `as u16` cast — two over-long inputs
would then compare as same-dimensional rather than as 64k vs 70k, and
the dimension confusion would not surface anywhere.
Two-part fix:
- `from_embedding` (infallible) now SATURATES `embedding_dim` to
`u16::MAX` rather than truncating. Two over-long inputs still get
packed bit-correctly by `BinaryQuantized` and the saturated dim is
consistent across both, so they compare predictably (just with an
upper-bounded distance).
- `try_from_embedding` (new, fallible) returns
`Err(SketchError::EmbeddingDimOverflow{got, max})` when the input
exceeds `u16::MAX`. Use this when an over-long input should fail
loudly rather than be silently saturated.
- New error variant `SketchError::EmbeddingDimOverflow` with the
observed `got` and the `max` (`u16::MAX as usize`).
- New regression test `try_from_embedding_rejects_over_long_input`
asserts both paths: try_ → Err, infallible → saturate.
Validated:
- 13 sketch unit tests pass (was 12; +1 for L2 boundary).
- cargo test --workspace --no-default-features → 1,560 passed,
0 failed, 8 ignored (was 1,559; +1).
- ESP32-S3 on COM7 streaming live CSI (cb #100, fresh boot RSSI -48 dBm).
Co-Authored-By: claude-flow <ruv@ruv.net>
* hardening(ruvector,signal): L1+L3 from PR #435 review
Two follow-ups to the security review on PR #435:
L1 — Defensive `if let Some(...)` for SketchBank::topk heap peek.
The original `.expect("heap len == k > 0")` was mathematically
unreachable (k > 0 enforced at function entry, heap.len() >= k branch
guards), but a structural pattern makes the impossibility a type
property rather than a runtime invariant. Same hot-path cost; zero
panic risk in the production binary.
L3 — Guard `embedding_dim == 0` in `EmbeddingHistory::novelty`.
A 0-dim history is constructible via `with_sketch(0, ...)`; without
the guard the function returned `NaN` (min_d as f32 / 0.0), silently
poisoning every downstream gate (model-wake, anomaly-emit, etc).
Now returns Some(1.0) — fail-loud at "no comparison possible →
maximally novel," never NaN. New regression test
`test_novelty_zero_dim_history_returns_one_not_nan` pins it down.
Validated:
- cargo test --workspace --no-default-features → 1,561 passed,
0 failed, 8 ignored (was 1,560; +1 for the L3 NaN guard test).
- ESP32-S3 on COM7 streaming live CSI (cb #12400, RSSI fresh).
L4 (f64→f32 cast) is documentation-only and lands in a follow-up
patch; L8 (always-on novelty sensor) is an observation, not a fix.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(sensing-server): ADR-084 Pass 3.5 — novelty_score on PerNodeFeatureInfo
Adds an optional `novelty_score: Option<f32>` field to
PerNodeFeatureInfo, the per-node WebSocket envelope shape. Mirrored
on both struct definitions (types.rs canonical + main.rs's
refactoring-leftover duplicate) so the schema is consistent.
`#[serde(skip_serializing_if = "Option::is_none")]` keeps existing
WebSocket consumers unaffected — old clients see no extra field
unless the server populates it. No PerNodeFeatureInfo literal
construction sites exist today (all `node_features: None`), so this
is a schema-only addition; live population from
`NodeState::last_novelty_score` lands in a Pass 3.6 follow-up that
also wires `node_features: Some(...)` at the per-node ESP32 frame
emit path.
Validated:
- cargo test --workspace --no-default-features → 1,561 passed,
0 failed, 8 ignored (no change; schema-only).
- ESP32-S3 on COM7 streaming live CSI (cb #2100, fresh boot).
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(sensing-server): ADR-084 Pass 3.6 — populate node_features with novelty_score
Wires `node_features: Some(...)` at the two per-node ESP32 frame
emit sites (formerly `node_features: None`). Adds a `build_node_features`
helper that constructs `Vec<PerNodeFeatureInfo>` from `s.node_states`,
including the per-node `last_novelty_score`.
This completes the Pass 3.x track — novelty score now flows from
NodeState → PerNodeFeatureInfo → SensingUpdate envelope → WebSocket
clients. Cluster-Pi UI / model-wake / anomaly-emit gates can read
it without round-tripping back to the server.
Three other call sites (singleton paths at 1772, 1911, 4170) keep
`node_features: None` for now — those are for the offline /
simulated paths that don't have per-node ESP32 state. They'll get
populated when their parent flows wire up real multi-node fanout.
Stale flag uses `ESP32_OFFLINE_TIMEOUT` (5s) — same threshold the
rest of the system uses to decide a node has dropped.
Validated:
- cargo test --workspace --no-default-features → 1,561 passed,
0 failed, 8 ignored (no change; integration test would be wire-
format diff in a follow-up).
- ESP32-S3 on COM7 streaming live CSI (cb #100, fresh boot,
RSSI -49 dBm).
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(ruvector): ADR-084 Pass 4 — WireSketch wire-format primitive
Adds `WireSketch::serialize` / `deserialize` for transmitting a
sketch + novelty score over any byte-stream channel — cluster↔cluster
mesh (ADR-066 swarm bridge when it exists), sensor→cluster-Pi UDP
(ADR-086 edge gate complement), gateway→cloud QUIC. Channel-agnostic
by design.
Wire layout (12-byte header + ceil(dim/8) bytes payload, little-endian):
[0..4] magic = 0xC5110084
[4..6] format_version = 1
[6..8] sketch_version (embedding-model schema)
[8..10] embedding_dim
[10..12] novelty_q15 (novelty * 32_767, saturated)
[12..] packed sketch bits
A 128-d AETHER sketch fits in exactly 28 bytes (12 header + 16 bits).
Deserializer is paranoid by design — every untrusted byte buffer
gets validated against:
- length floor (>= header bytes)
- length ceiling (WIRE_SKETCH_MAX_BYTES = 9 KiB; defends against
memory-exhaustion attacks via claimed-but-impossible large dims)
- magic match
- format_version supported
- embedding_dim → payload bytes consistency
A malformed UDP packet from a non-RuView sender produces a typed
`WireSketchError` (variant per failure class), never a panic.
Re-exported from lib.rs alongside `Sketch` / `SketchBank`.
Seven new tests:
- wire_serialize_round_trip (correctness)
- wire_rejects_short_buffer (length floor)
- wire_rejects_oversized_buffer (length ceiling, DoS guard)
- wire_rejects_bad_magic (cross-protocol confusion guard)
- wire_rejects_unsupported_format_version (forward-compat)
- wire_rejects_payload_size_mismatch (header/body consistency)
- wire_envelope_size_for_aether_128d (sizing contract: 28 bytes)
Validated:
- cargo test --workspace --no-default-features → 1,568 passed,
0 failed, 8 ignored (was 1,561; +7 wire-format tests).
- ESP32-S3 on COM7 streaming live CSI (cb #15100, RSSI -48 dBm).
Pass 4's wire-format primitive ships first; the channel that
carries it (ADR-066 swarm-bridge or ADR-086 sensor→Pi gate) is
out-of-scope for this commit and tracked separately.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(ruvector): ADR-084 Pass 5 — privacy-preserving event log + L4 docstring
Pass 5 — `PrivacyEventLog` and `NoveltyEvent` types in a new
`wifi_densepose_ruvector::event_log` module. Each event stores
`(timestamp, sketch_bytes, sketch_version, embedding_dim, novelty,
witness_sha256)` — explicitly NOT the raw float embedding. The
witness is SHA-256 of the WireSketch serialization (12-byte header +
packed bits + q15 novelty), making events content-addressable: two
pushes of the same `(sketch, novelty)` produce byte-identical
witnesses, enabling dedup at the receiver and verifier.
Privacy properties (ADR-084 §"Privacy-preserving event log"):
1. Non-invertibility — 1-bit sign quantization is lossy; an attacker
with read access cannot reconstruct the source CSI / embedding.
2. Content addressing — `(sketch_version, witness)` is fully qualified.
3. Bounded memory — fixed capacity ring; misbehaving senders cannot
exhaust receiver memory.
Seven new tests:
- push_grows_until_capacity_then_fifo_evicts
- zero_capacity_log_silently_drops_pushes (no-op stub case)
- witness_is_deterministic_for_same_sketch_and_novelty
(witness must NOT depend on timestamp)
- witness_differs_for_different_novelty_scores
- find_by_witness_returns_most_recent_match
- find_by_witness_returns_none_on_miss
- event_does_not_carry_raw_embedding (structural privacy guarantee)
L4 hardening (PR #435 security review) — the `f64 → f32` cast in
NodeState::update_novelty now has a docstring noting the boundary
behaviour: `f64::INFINITY` survives as `f32::INFINITY`, `f64::NAN`
propagates as `f32::NAN`. Neither panics. CSI amplitudes from healthy
firmware are well within f32 finite range.
Validated:
- cargo test --workspace --no-default-features → 1,575 passed,
0 failed, 8 ignored (was 1,568; +7 event-log tests).
- ESP32-S3 on COM7 streaming live CSI (cb #2800, RSSI -52 dBm).
Co-Authored-By: claude-flow <ruv@ruv.net>
The Rust port at v2/ has been the primary codebase since the rename
in #427. The Python implementation at v1/ is no longer the active
target; the only load-bearing path is the deterministic proof bundle
at v1/data/proof/ (per ADR-011 / ADR-028 witness verification).
Move the whole Python tree into archive/v1/ and document the policy
in archive/README.md: no new features, bug fixes only when they affect
a still-load-bearing path (currently just the proof), CI continues to
verify the proof on every push and PR.
Path references updated in 26 files via path-pattern sed (only
matches v1/<known-child> patterns, never bare v1 or API URLs like
/api/v1/). Two double-prefix typos (archive/archive/v1/) caught and
hand-fixed in verify-pipeline.yml and ADR-011.
Validated:
- Python proof verify.py imports cleanly at archive/v1/data/proof/
(numpy/scipy still required; CI installs requirements-lock.txt
from archive/v1/ now)
- cargo test --workspace --no-default-features → 1,539 passed,
0 failed, 8 ignored (unaffected by Python tree relocation)
- ESP32-S3 on COM7 untouched (no firmware paths changed)
After-merge: contributors should re-run any local `python v1/...`
commands as `python archive/v1/...` (CLAUDE.md and CHANGELOG already
updated).
The Rust port lived two directories deep (rust-port/wifi-densepose-rs/)
without any sibling under rust-port/ that warranted the extra level.
Move the whole workspace up to v2/ to match v1/ (Python) at the same
depth and shorten every cd / build command across the repo.
git mv preserves history for all tracked files. 60 files updated for
path references (CI workflows, ADRs, docs, scripts, READMEs, internal
.claude-flow state). Two manual fixes for relative-cd paths in
CLAUDE.md and ADR-043 that became wrong after the depth change
(cd ../.. → cd ..).
Validated:
- cargo check --workspace --no-default-features → clean (after target/
nuke; the gitignored target/ was carried by the OS rename and had
hard-coded old paths in build scripts)
- cargo test --workspace --no-default-features → 1,539 passed, 0 failed,
8 ignored (same totals as pre-rename)
- ESP32-S3 on COM7 → still streaming live CSI (cb #40300, RSSI -64 dBm)
After-merge follow-up: contributors should `rm -rf v2/target` once and
let cargo regenerate from the new path.