Phase 2 of ADR-103: trained count head on the existing 1,077 paired
samples (the same data that produced pose_v1 yesterday).
Honest result: 65.1% eval accuracy / 100% within ±1 / MAE 0.349 on
the held-out time-window. Per-class: 100% on "empty room" / 0% on
"1 person". The model overfit by epoch 100 (train_acc → 1.0,
eval_loss climbed 0.67 → 7.8) and the "best" checkpoint is the
snapshot that happened to predict the eval window's class
distribution (140/215 = 65.1%, matches eval_acc exactly). Confidence
head Spearman = 0.023 ⇒ uncalibrated. Same data-bound failure mode
as pose_v1 (#645), bounded by single-session training data; same
fix path (multi-room).
What v0.0.1 still validates end-to-end:
* PyTorch → safetensors → Candle Rust loads cleanly on first try.
`cog-person-count health` reports `backend: candle-cpu` and emits
real per-frame predictions instead of the stub backend's hard-coded
{1 person, 0 confidence}. Architecture parity between train-count.py
and src/inference.rs::CountNet is bit-exact.
* ONNX export bit-clean (16 KB, opset 18, dynamic batch axis).
* Training wall time: 5.6 s for 400 epochs on RTX 5080.
* Binary size unchanged (2.36 MB stripped), model loads via mmap at
runtime.
This commit ships:
* scripts/align-ground-truth.js: extended to emit n_persons_mode +
n_persons_max per window so the training pipeline has count
labels. Backwards-compatible (additive fields).
* scripts/train-count.py: new — mirrors CountNet architecture
exactly, loads paired.jsonl, trains 400 epochs with
CE+BCE+Brier loss, exports safetensors + ONNX + per-epoch JSON.
* v2/.../cog/artifacts/{count_v1.safetensors,count_v1.onnx,
count_train_results.json}: the trained artifacts.
* v2/.../cog/README.md: Status table updated with the v0.0.1 numbers
+ an Honest Caveat section explaining the data-bound result.
* docs/benchmarks/person-count-cog.md: new — full v0.0.1 benchmark
log mirroring the format docs/benchmarks/pose-estimation-cog.md
established. Includes comparison to ADR-103 v0.1.0 acceptance
gates and per-class breakdown.
Still pending:
* `run` subcommand wiring (long-running polling loop, same as pose)
* Cross-compile + sign + GCS upload (mirror of pose cog pipeline)
* Live install on cognitum-v0
* v0.2.0: re-train on multi-room data, LoRA per-room adapters,
Stoer-Wagner min-cut clip in fusion stage
First implementation PR for ADR-103. Same incremental shape that
ADR-101 used: scaffold the cog crate, ship a stub-backend release
that satisfies the runtime contract + 15 tests + measured cold-start,
then follow up with the trained count_v1.safetensors in a separate PR.
What ships:
* v2/crates/cog-person-count/ — new workspace member.
- Cargo.toml: candle-core/candle-nn 0.9 (cpu default, cuda feature
opt-in), safetensors, ureq, sha2 — same dep shape as the pose cog
but minus wifi-densepose-train (this cog has no training-side
consumer, so the dep tree is materially smaller → 2.36 MB
binary vs the pose cog's 4.5 MB).
- src/inference.rs: CountNet (Conv1d 56→64→128→128 encoder + count
head Linear(128→64→8)+softmax + confidence head
Linear(128→32→1)+sigmoid). Stub backend returns
`{1-person, 0-confidence}` honestly when no safetensors present.
- src/fusion.rs: fuse_confidence_weighted() — Bayesian product of
per-node distributions with confidence-weighted log-sum, plus
fuse_with_mincut_clip() hook for the v0.2.0 Stoer-Wagner
upper-bound (`ruvector-mincut` dep lands when min-cut graph
builder is ready). Confidences floored at 1e-3 and probs floored
at 1e-9 before logs — no NaN propagation.
- src/publisher.rs: emits {count, confidence, count_p95_low,
count_p95_high, n_nodes, probs} per ADR-103 §"Output".
- src/main.rs: full ADR-100 four-verb CLI (version|manifest|health
|run). The `run` subcommand explicitly returns "wiring pending
v0.0.1" so the in-process library API is the v0.0.1-clean
integration path.
- tests/smoke.rs (8 tests) + fusion::tests (7 tests, in-lib) — 15
total, all green. Cover stub-backend behaviour, wrong-shape
rejection, fusion math (empty / single / agreement / high-conf
override / normalisation), p95-range correctness, and min-cut
clip semantics.
- cog/{manifest.template.json, config.schema.json, README.md} +
cog/artifacts/ placeholder dir.
* v2/Cargo.toml: registers the new workspace member.
Verified locally:
cargo check -p cog-person-count --no-default-features → clean
cargo test -p cog-person-count --no-default-features → 8/8 pass
cargo test -p cog-person-count --lib → 7/7 pass
cargo build -p cog-person-count --release → 2.36 MB binary
./cog-person-count version → "person-count 0.3.0"
./cog-person-count manifest → JSON skeleton
./cog-person-count health → backend:stub,
count:1, conf:0,
p95:[1,1]
Cold-start: 30 sequential `health` invocations → 53.3 ms/invocation
(vs cog-pose-estimation's 76.2 ms — smaller dep tree)
cog/README.md adds:
* Security section — six-row threat table covering safetensor mmap
trust, non-finite outputs, sensing fetch failures, fusion
divide-by-zero / log-of-zero, min-cut degenerate cases, and stdout
spoofing.
* Performance / optimization section — binary size, release profile
(already opt-level=3 / lto=fat / codegen-units=1 / strip=true at
workspace level), cold-start comparison table, projected warm-path
latency budget.
Still pending (separate PRs, ADR-103 §"Migration"):
* Train count_v1.safetensors on the existing 1,077 paired samples
with `n_persons` labels (Candle on RTX 5080, same script that
produced pose_v1.safetensors yesterday).
* `run` subcommand wiring (long-running polling loop, same shape as
cog-pose-estimation::runtime).
* Cross-compile + sign + GCS upload (mirror of cog-pose-estimation
release pipeline).
* Server-side `csi.rs::score_to_person_count` call-site rewire to
consume this cog when installed; falls back to PR #491's heuristic
when not.
* feat(edge-registry): ADR-102 — surface Cognitum cog catalog via /api/v1/edge/registry
Adds a new sensing-server endpoint that fetches and caches the canonical
Cognitum app registry at
https://storage.googleapis.com/cognitum-apps/app-registry.json (105 cogs
across 11 categories as of v2.1.0). RuView previously had no live
awareness of the catalog — the README's capability table was hand-
curated and went stale as Cognitum shipped new cogs (the registry was
last updated 6 days ago).
ADR:
* docs/adr/ADR-102-edge-module-registry.md — full design, response
shape, configuration flags, failure modes, and a 12-row security
review covering SSRF, response inflation, ?refresh abuse, stale-serve
semantics, TLS, cache poisoning, JSON-panic resistance, etc.
Code:
* v2/.../edge_registry.rs — EdgeRegistry struct + UreqFetcher +
MockFetcher trait + 7 unit tests. RwLock<Option<CachedEntry>> with
stale-on-error fallback. MAX_PAYLOAD_BYTES=8 MiB, 10s wire timeout.
* v2/.../main.rs — constructs Option<Arc<EdgeRegistry>> at startup,
registers GET /api/v1/edge/registry handler, wires Extension layer.
Handler runs the blocking ureq fetch via tokio::task::spawn_blocking
so the async runtime stays free.
* v2/.../cli.rs / main.rs Args — three new flags (per user request to
"allow the registry to be disabled or changed"):
--edge-registry-url <URL> (env RUVIEW_EDGE_REGISTRY_URL)
--edge-registry-ttl-secs <N> (env RUVIEW_EDGE_REGISTRY_TTL_SECS)
--no-edge-registry (env RUVIEW_NO_EDGE_REGISTRY)
When --no-edge-registry is set or the URL is empty, the endpoint
returns 404.
Cargo.toml: adds ureq (rustls), sha2, thiserror as direct deps.
README:
* New collapsed "🧩 Edge Module Catalog" section with the full 105-cog
table generated from the registry, grouped by category with practical
one-line descriptions (e.g. "Spots irregular heartbeats and abnormal
heart rhythms", "Detects walking problems and scores fall risk").
Links to https://seed.cognitum.one/store and the local appliance
/cogs page. Sits between the HF model section and How It Works.
Tests (7/7 pass):
first_call_hits_upstream_and_caches
ttl_expiry_triggers_refetch
force_refresh_bypasses_fresh_cache
stale_serve_on_upstream_failure_after_cached_success
no_cache_no_upstream_returns_error
upstream_invalid_json_is_treated_as_error
upstream_sha256_is_deterministic
Security highlights (full review in ADR-102 §"Security review"):
- The registry is metadata-only; per-cog binary signatures (ADR-100)
remain the trust root for installs. A compromised registry can
mislead a human reader but cannot ship malicious binaries.
- 8 MiB cap + 10s timeout + Option<Arc<...>> via Extension layer means
the endpoint can't be used to exhaust memory or pin tokio threads.
- Stale-on-error responses carry an explicit `stale: true` field so
upstream outages are visible to consumers rather than silently
masked.
- Endpoint sits behind the existing RUVIEW_API_TOKEN bearer gate when
set, otherwise unauthenticated (registry contents are public anyway).
* chore: refresh Cargo.lock for ureq/sha2/thiserror deps added by ADR-102
Issue #640 (PCK gap follow-up) was deleted upstream after the cog v0.0.1
PRs landed today. Re-opened as #645 with the same context plus the
new measured v0.0.1 numbers (PCK@20 3.0%, PCK@50 18.5%, MPJPE 0.093).
This patch updates the three files in main that still pointed at the
dead #640 to point at #645 instead — ADR-101, the cog README, and the
benchmark log.
Adds the x86_64-unknown-linux-gnu binary uploaded to
gs://cognitum-apps/cogs/x86_64/, signed with the same Ed25519
COGNITUM_OWNER_SIGNING_KEY as the arm release. Together with the
already-shipped arm artifact, the cog now ships natively for both
target architectures the Cognitum fleet supports.
x86_64 release:
sha256: a434739a24415b34e1aff50e5e1c3c32e568db96af473bbb3e5ecc9b95fe71fa
signature: pNNuxhgM18PztN8BSZdfw5oAShG2pV3na5T/q2QdlJWX/5FJgo4QTiUCbcTAxI2Uiva8VURSOlRzMU3xoQPqCQ==
size: 4,548,856 bytes
cold-start: 5.4 ms / invocation on ruvultra (RTX 5080, NVMe)
Reorganizes manifests under cog/artifacts/manifests/{arm,x86_64}/
so each arch carries its own manifest with the matching binary_sha256
and signature — same layout the release pipeline will use for the
future hailo8 / hailo10 variants.
Updates docs/benchmarks/pose-estimation-cog.md with the cross-arch
cold-start table:
Windows (x86_64) 76.2 ms
ruvultra (x86_64) 5.4 ms <- this release
Pi 5 (aarch64) 8.4 ms
Verified via anonymous GCS download + SHA round-trip — identical to
local build.
Hailo HEF remains the only pending arch, still blocked on Hailo SDK
provisioning to a self-hosted runner.
* feat(cog-pose-estimation): scaffold first Cog from this repo (ADR-100 + ADR-101)
Adds the foundation for the pose-estimation Cog that ships from this
repo into Cognitum V0 appliances. Companion ADR-225 + crate land in
cognitum-one/v0-appliance.
ADRs:
* ADR-100 formalises the Cognitum Cog packaging spec — on-device
layout under /var/lib/cognitum/apps/<id>/, manifest.json schema
(incl. new binary_sha256 + binary_signature fields), GCS hosting
convention, repo source layout, build pipeline, and the four-verb
runtime contract (version | manifest | health | run). Documents the
convention I reverse-engineered from inspecting installed cogs on a
live cognitum-v0 appliance — `anomaly-detect`, `presence`,
`seizure-detect`, etc.
* ADR-101 designs the pose-estimation Cog itself: where it sits in
the wifi-densepose pipeline (encoder init from
ruvnet/wifi-densepose-pretrained, 17-keypoint regression head),
what gets shipped per target arch (arm / x86_64 / hailo8 /
hailo10), acceptance gates (PCK@20 explicitly deferred to #640 —
this ADR ships the vehicle, not the accuracy).
Crate v2/crates/cog-pose-estimation/:
* Cargo.toml + workspace member declaration with a hailo feature gate
so the binary builds without the Hailo SDK in CI.
* main.rs implements the four-verb CLI exactly per ADR-100.
* config.rs / manifest.rs / publisher.rs / inference.rs / runtime.rs —
small modules, each <100 lines.
* publisher.rs emits ADR-100 structured JSON events.
* inference.rs is a stub that produces a centred-skeleton baseline
with confidence=0 (honest: no trained weights wired in yet).
* runtime.rs subscribes to /api/v1/sensing/latest, slides a
56*20 window, runs the engine, emits pose.frame events.
* cog/manifest.template.json + cog/config.schema.json define the
release artifact + runtime config schemas.
* cog/Makefile holds build / sign / upload targets.
* tests/smoke.rs covers manifest roundtrip + engine I/O surface.
Verified locally:
* cargo check -p cog-pose-estimation: clean.
* cargo test -p cog-pose-estimation: 4/4 pass.
* ./target/release/cog-pose-estimation {version,manifest,health}:
all emit the right contract output.
This commit contains scaffolding only; the actual trained weights and
Hailo HEF cross-compile come in follow-ups tracked in #640 and the
companion v0-appliance branch.
* feat(cog-pose-estimation): first measured run — Candle CUDA on RTX 5080
Trained pose_v1 on ruvultra (RTX 5080) via Candle 0.9 + cuda feature
against the same 1,077-sample paired session that produced 0%/0% PCK
in #640 with the pure-JS SPSA trainer. First real numbers:
PCK@20 = 3.0% (up from 0.0%)
PCK@50 = 18.5% (up from 0.0%)
MPJPE = 0.093 (down from 0.66, ~7x improvement)
400 epochs in 2.1 s wall time, full-batch, ~5 ms/epoch. Loss curve
0.181 -> 0.014 over the run, eval 0.010. Per-joint reveals the model
leans on right-side proximal joints (r_hip 77% PCK@50, r_knee 35%,
l_elbow 26%) — consistent with the camera framing in the source
recording. Distal joints (wrists, ankles) and face joints are still
near-random, consistent with the 56-subcarrier / 20-frame input not
carrying fine-grained spatial info at 1077 samples.
This commit:
* Adds v2/crates/cog-pose-estimation/cog/artifacts/{pose_v1.safetensors,
train_results.json} so the cog dir now contains a real reference
artifact, not just scaffold.
* Updates cog/README.md "Status" block with the measured numbers,
per-joint table, and an honest reading of where the model
succeeds vs where the data is the bottleneck.
* Adds docs/benchmarks/pose-estimation-cog.md as the canonical
benchmark log — append-only, one section per published run.
* Appends a "First measured run" section to ADR-101 referencing
the new benchmark file.
Still pending in the follow-up:
* Wire pose_v1.safetensors into src/inference.rs (replace stub).
* ONNX export (Candle lacks a writer — needs external conversion).
* Hailo HEF cross-compile + cluster deploy.
The data-bound gap to PCK@20 >= 35% is tracked in #640.
* feat(cog-pose-estimation): wire real weights — cog is no longer a stub
Replaces the centred-skeleton stub in src/inference.rs with a real
Candle-based loader that reads cog/artifacts/pose_v1.safetensors and
runs the trained Conv1d encoder + MLP pose head on every incoming CSI
window.
What changes:
* src/inference.rs: PoseNet mirrors the training script's architecture
exactly — Conv1d(56->64, k=3 d=1), Conv1d(64->128, k=3 d=2),
Conv1d(128->128, k=3 d=4), mean over time, Linear(128->256)+ReLU,
Linear(256->34)+sigmoid -> reshape [17, 2]. The InferenceEngine
searches a sensible candidate list for the weights file
(/var/lib/cognitum/apps/pose-estimation/, ./pose_v1.safetensors,
./cog/artifacts/, repo-root, v2/-relative) and falls back to the
stub when none are present so the cog still satisfies ADR-100.
* Cargo.toml: adds candle-core 0.9 + candle-nn 0.9 (no-default-features,
CPU build by default) + safetensors 0.4. New `cuda` feature opt-in
for GPU inference on hosts that have it. Drops the unused
wifi-densepose-train path dep from the default build path.
* src/main.rs + src/publisher.rs: health.ok event now carries
`backend` (candle-cuda | candle-cpu | stub) and the synthetic
output confidence, so operators can tell at a glance whether the
cog loaded its weights or fell back to the stub.
* tests/smoke.rs: adds `real_weights_load_when_available` which
asserts the loaded engine reports backend=candle-* and emits
non-zero confidence — exactly the signal that proves we're not
silently degrading to the stub.
Verified locally:
* `cargo check -p cog-pose-estimation --no-default-features` — clean
* `cargo test -p cog-pose-estimation --no-default-features` — 5/5 pass
* `./target/release/cog-pose-estimation health` emits:
{"event":"health.ok","fields":{"backend":"candle-cpu","cog":"pose-estimation","synthetic_output_confidence":0.185}}
— 0.185 is the published PCK@50 from cog/artifacts/train_results.json,
emitted by the real Candle inference path (would be 0.0 if it had
fallen back to the stub).
The cog now runs the trained pose_v1 model end-to-end. Accuracy is
still bounded by the underlying 1077-sample training data (PCK@20
3.0%, PCK@50 18.5% per docs/benchmarks/pose-estimation-cog.md) — that
gap is data-bound and tracked in #640. ONNX export + Hailo HEF
cross-compile remain follow-ups.
* docs(benchmarks): measure cog-pose-estimation cold-start latency
100 sequential `cog-pose-estimation health` invocations average 76.2 ms
each on a Windows x86_64 host using the `candle-cpu` backend. Each
invocation re-loads pose_v1.safetensors and runs one synthetic forward
pass, so this is the worst-case cold-start path. Long-running `run`
inference will be sub-millisecond per frame once the model is loaded.
Updates the benchmarks doc accordingly.
* feat(cog-pose-estimation): ONNX export — pose_v1.onnx + scripts/export-onnx.py
Adds the canonical ONNX artifact that unblocks downstream Hailo HEF
cross-compile + ONNX Runtime benchmarks. Generated on ruvultra (torch
2.12.0 + CUDA), 12,059 bytes, opset 18, dynamic batch axis.
* scripts/export-onnx.py: mirrors the Candle inference architecture in
PyTorch (Conv1d 56->64, 64->128, 128->128 + Linear 128->256->34), pure-
python safetensors loader (no extra pip dep), exports via
torch.onnx.export, then verifies via onnx.checker.check_model and
numerical parity against the torch reference.
* Verified parity vs torch: max |torch - onnx| = 8.94e-8 (1e-5
threshold). Effectively bit-perfect.
* v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.onnx — the
artifact itself, 12 KB.
* docs/benchmarks/pose-estimation-cog.md — adds an ONNX export
section with the verification numbers.
Next: Hailo HEF cross-compile (still gated on Hailo SDK on a
self-hosted runner) and ONNX Runtime latency benchmarks on each
target arch.
* feat(cog-pose-estimation): release v0.0.1 — signed aarch64 binary on GCS
End-to-end deploy: cross-compiled to aarch64-unknown-linux-gnu on
ruvultra, ran via qemu-aarch64-static, then smoke-tested on a real
cognitum-v0 Pi 5. Signed with COGNITUM_OWNER_SIGNING_KEY (Ed25519)
and uploaded to gs://cognitum-apps/cogs/arm/.
Real-hardware results on cognitum-v0 (Pi 5):
health: backend=candle-cpu, confidence=0.185, real weights loaded
30x sequential `health`: 0.251 s total -> 8.4 ms / invocation (cold)
GCS release artifacts (publicly downloadable):
binary: 3,741,976 bytes
sha256 1e1a7d3dd01ca05d5bfc5dbb142a5941b7866ed9f3224a21edc04d3f09a99bf5
weights: 507,032 bytes
sha256 eb249b9a6b2e10130437a10976ed0230b0d085f86a0553d7226e1ae6eae4b9e5
signature (Ed25519, b64): LUN7xqLPYD3MFzm5dKB5MnYU0LvoRtek5ci5KiKPHBg+Xo6xuazwokn2Dw2JPMaLYJzmWn/SpT4djuR7hYvVDw==
Adds:
* v2/crates/cog-pose-estimation/cog/artifacts/manifest.json — the
release-pipeline-produced manifest with all fields filled in per
ADR-100, including arch, target_triple, signature, and a
build_metadata block carrying the validation PCK numbers.
* docs/benchmarks/pose-estimation-cog.md — new sections covering
the real Pi 5 smoke (8.4 ms cold-start) and the signed GCS
release artifacts.
Verified by downloading the binary anonymously from GCS and
re-computing the sha256 — matches the locally-computed sha exactly.
Signature decoded to the expected 64-byte Ed25519 length.
Closes the GCS-upload acceptance criterion from ADR-100; the only
pending work is Hailo HEF cross-compile (still SDK-gated) and an
x86_64 release alongside this arm release.
* docs(benchmarks): record live cognitum-v0 install + 5-sec smoke run
Adds the "Live appliance install" section documenting what happened
when the signed v0.0.1 binary + weights were installed under
/var/lib/cognitum/apps/pose-estimation/ on cognitum-v0 (the V0
cluster leader).
* Layout matches the existing anomaly-detect / presence / seizure-
detect cogs exactly — the Cogs dashboard at
http://cognitum-v0:9000/cogs auto-discovers entries.
* `cog-pose-estimation run` ran for 5 seconds in the background and
cleanly emitted run.started + structured WARN events for the
missing local sensing-server on :3000 (cognitum-v0's actual CSI
source is ruview-vitals-worker on :50054, not :3000). No crashes,
no NaN, no leaks.
* Wiring `sensing_url` to the appliance-native source is a separate
Day-2 integration task.
#613 fixed adaptive_classifier.rs:94 (the IQR sort) and called the audit
done, but the grep used `partial_cmp(b).unwrap()` as a literal and missed
seven additional production sites that use comparator variants:
adaptive_classifier.rs:205 AdaptiveModel::classify() argmax over softmax
probs — same per-frame hot path as #611.
NaN flows through normalise → logits → softmax
and still reaches this site even after the
IQR fix.
adaptive_classifier.rs:480 train() argmax (training accuracy loop)
adaptive_classifier.rs:500 train() per-class argmax
main.rs:2446, 2449 count_persons_mincut variance source/sink select
csi.rs:602, 605 count_persons_mincut variance source/sink select
(duplicate of main.rs logic in csi.rs)
For the variance-select sites, note that the *outer* `unwrap_or((0, &0))`
only catches an empty iterator — it cannot rescue a panic raised inside
the comparator. A single NaN in `variances[]` still aborts the process.
Same fix as #613: swap `.unwrap()` for `.unwrap_or(std::cmp::Ordering::Equal)`
inside the comparator closure. Pure behavioural change, no API surface.
Re-audit of the remaining `partial_cmp(...).unwrap()` matches in v2/:
they are all inside `#[cfg(test)]` / `#[test]` blocks (spectrogram.rs:269,
depth.rs:234, connectivity.rs:477, vital_signs.rs:737) where inputs are
controlled and panic-on-NaN is acceptable.
Integrating @schwarztim's PR #491 into main on their behalf — their fork has
fallen too far behind for a clean rebase (the PR's commit graph dropped
silently during `git rebase origin/main`), so applying as a merge from the
fork head to preserve the diff cleanly.
What this lands:
- `RollingP95` adaptive normaliser for the person-count feature scaling.
Streaming P95 over a 600-sample / ~30 s sliding window. Cold-start
(<60 samples) falls back to the legacy denominators (variance/300,
motion_band_power/250, spectral_power/500) so day-0 behaviour is
preserved on every deployment.
- `RuntimeConfig` struct + `load_runtime_config` / `save_runtime_config`
persisted to `data/config.json`. Exposes `dedup_factor` via REST so
multi-node deployments can tune cluster-deduplication without a rebuild,
including an auto-tune endpoint that derives optimal dedup from a known
person count (calibration mode).
- `compute_person_score()` now takes &AppStateInner alongside &FeatureInfo
so the adaptive denominators are reachable. All 3 call sites updated.
- New `AppStateInner` fields: `p95_variance`, `p95_motion_band_power`,
`p95_spectral_power`, `dedup_factor`, `data_dir`.
Closes#491. Directly addresses:
- #499 (double skeletons, multi-node) — the slot-clustering problem this
PR's adaptive normaliser was designed to fix
- #519 Bug 1 (ghost person detection on edge-tier 1 & 2 multi-node)
- #496 (person count over-reporting on single-room single-person)
Verified locally:
- cargo check -p wifi-densepose-sensing-server --no-default-features: 1.0s
- cargo test -p wifi-densepose-sensing-server --no-default-features --lib:
233/233 passed in 25.0s
Co-authored-by: @schwarztim
Co-Authored-By: claude-flow <ruv@ruv.net>
Reported by @ArnonEnbar with a complete reproduction.
broadcast_tick_task() re-emits the cached `latest_update` every tick so
pose WS clients keep getting data even when ESP32 pauses between
frames. The `source` field of that cached update was set to "esp32" at
the moment a fresh ESP32 frame was last decoded (main.rs:3885, :4136).
After the ESP32 loses power or network, no fresh frame is decoded —
the cached `latest_update` is still re-broadcast every tick with the
stale source: "esp32" baked in. UI's "Sensing" tab keeps showing
"LIVE — ESP32 HARDWARE Connected" with frozen vitals/features/
classification re-broadcast indefinitely. REST `/health` correctly
reports source: "esp32:offline" (via effective_source(), which checks
last_esp32_frame elapsed time against ESP32_OFFLINE_TIMEOUT=5s) — but
the WS broadcast path was the one consumer that didn't call it.
Fix: clone the cached update per tick, overwrite source with
s.effective_source(), then serialize and broadcast. UI now switches to
"esp32:offline" on the same 5s budget as the REST surface.
cargo build -p wifi-densepose-sensing-server --no-default-features:
17s, no errors (1 pre-existing unused-import warning unchanged).
Reported by @bannned-bit. Five endpoints in
v2/crates/wifi-densepose-sensing-server embedded user-controlled
identifiers in format!() paths with no sanitization:
recording.rs POST /api/v1/recording/start (session_name)
recording.rs GET /api/v1/recording/download/:id (id)
recording.rs DELETE /api/v1/recording/delete/:id (id)
model_manager.rs POST /api/v1/models/load (model_id)
training_api.rs load_recording_frames (dataset_ids[])
Each unauthenticated caller could:
- READ arbitrary files via ../../etc/passwd, ../../.env, etc.
- WRITE attacker-controlled JSONL via recording/start
- LOAD attacker-controlled .rvf model files
- DELETE arbitrary files the server process can touch
New `path_safety` module exports `safe_id(&str) -> Result<&str, PathSafetyError>`
that enforces the rejection envelope BEFORE any user input reaches a
format!() that builds a path:
- Allowed character set: [A-Za-z0-9._-]
- Reject leading '.' (rules out '.', '..', '.env', hidden files)
- Reject empty strings
- Reject anything > 64 bytes
- Reject all whitespace, path separators, null bytes, non-ASCII
Applied at all 5 sites. Errors return 400 Bad Request (download) /
status:"error" JSON (others) — not panics.
9 unit tests in path_safety::tests cover:
- accepts simple alphanumeric / hyphen / underscore / dot
- rejects empty, leading dot, path separators ('/', '\'),
null byte, whitespace, shell specials, non-ASCII (including
fullwidth slash U+FF0F), too-long, boundary at MAX_ID_LEN
test result: ok. 9 passed; 0 failed
cargo build -p wifi-densepose-sensing-server --no-default-features: 33s
Fix-marker RuView#615 in scripts/fix-markers.json prevents removing the
guard at any of the 5 call sites. CHANGELOG entry under [Unreleased] /
Security documents the patched endpoints and the rejection envelope.
Severity: critical per reporter — five remotely-reachable paths to read,
write, or delete arbitrary files. Hot per-request paths, not edge cases.
Reported by @bannned-bit. v2/crates/wifi-densepose-sensing-server/src/
adaptive_classifier.rs:94 did:
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
f64::partial_cmp returns None on NaN, so `.unwrap()` panics. CSI data
from real ESP32 hardware can produce NaN (silent DSP div-by-zero,
empty buffer, etc.), and this code path runs on every frame in the
classify() hot path — a single NaN frame kills the entire sensing
server process.
Fix swaps for unwrap_or(Ordering::Equal), matching the pattern the
same file already uses at lines 149-150 and 155 (those sites were
already NaN-safe; this site was an oversight).
Scoped audit: greped the v2/ tree for `partial_cmp(b).unwrap()`. The
other 3 hits are in #[cfg(test)] blocks (spectrogram.rs:269,
depth.rs:234, connectivity.rs:477) where panic-on-NaN is acceptable
because test inputs are controlled. Only adaptive_classifier.rs:94
was a production-path crash.
Severity: critical per reporter — runtime panic on real-world data.
Patch: 1-line behavioural change + comment.
Each of these crates was a single-line doc-comment placeholder:
v2/crates/wifi-densepose-api/src/lib.rs: //! WiFi-DensePose REST API (stub)
v2/crates/wifi-densepose-db/src/lib.rs: //! WiFi-DensePose database layer (stub)
v2/crates/wifi-densepose-config/src/lib.rs: //! WiFi-DensePose configuration (stub)
with empty [dependencies] in their Cargo.toml and zero references from any
source file or Cargo.toml in the workspace (verified by `grep -rln
wifi-densepose-api/-db/-config` across `v2/`). They were reserved early for
an envisioned REST/database/config split that never materialised.
The functionality these would have provided is covered today by:
- REST/WS: wifi-densepose-sensing-server (Axum)
- Config: per-crate config + CLI args in sensing-server and desktop
- DB: no persistent state; system is real-time
Removal prevents `cargo` from listing dead crates, shipping empty published
artifacts to crates.io, or wasting reviewer attention. If any of these names
is needed in the future, reintroduce them with a real implementation.
Per the issue reporter (@bannned-bit / Matad0r) #578 explicitly listed
"OR be removed from workspace members until implementation starts" as an
acceptable resolution.
Updated:
- `v2/Cargo.toml`: drop the three members (with inline comment explaining why)
- `v2/Cargo.lock`: regenerated by cargo check
- `CLAUDE.md`: drop the three rows from the crate table and the publishing
order list
- `CHANGELOG.md`: add an `[Unreleased] / Removed` entry
Verified:
- `cd v2 && cargo check --workspace --no-default-features` -> finished
in 48s, no errors (warnings unchanged)
* v2: pin Rust 1.89 for sensing-server dependency chain
ruvector-core 2.0.5, hnsw_rs 0.3.4, and mmap-rs 0.7 require newer Cargo/rustc
than 1.82 (edition2024 manifest, is_multiple_of, stable avx512f target_feature
on x86_64). Add v2/rust-toolchain.toml so cargo build -p
wifi-densepose-sensing-server picks a compatible toolchain.
Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* sensing-server: default UI path for cwd v2/ and coalesce fallbacks
The previous default ../../ui resolves to a non-existent directory when
the binary is run from v2/ (common), so /ui/* returned 404 and the
dashboard appeared broken. Default to ../ui and try ../ui, ./ui,
../../ui when the configured path is missing.
Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The sensing-server binds to 127.0.0.1 by default with no `Host` header
validation on either router. A foreign page can lower its DNS TTL,
re-resolve to 127.0.0.1 after the browser has accepted the origin, and
then read live pose + vital signs from /api/v1/* + /ws/sensing as
same-origin against the attacker's hostname. When `RUVIEW_API_TOKEN` is
unset (the documented LAN-mode default from #443/#547) the attacker
can also drive state-mutating POSTs (recording/start, models/load,
adaptive/train, calibration/start, sona/activate).
Defense: a small `host_validation` axum middleware that pins the `Host`
header to a configurable allowlist. The loopback names (`localhost`,
`127.0.0.1`, `[::1]`, each with or without a port) are always in the
set, so default 127.0.0.1 deployments keep working from the local
browser without any configuration change. Operators who bind to a
routable address extend the set with one or more `--allowed-host`
flags or a comma-separated `SENSING_ALLOWED_HOSTS` env var.
Reverse-proxy deployments that already canonicalise `Host` opt out
with `--disable-host-validation`.
The layer is wired into both the dedicated WebSocket router on
`--ws-port` (8765) and the main HTTP router on `--http-port` (8080),
so /ws/sensing on either listener is covered. Rejection responses are
`421 Misdirected Request` (the correct status for a request that
arrived at a server that does not consider the supplied `Host`
authoritative); missing `Host` is `400 Bad Request`.
CWE-346 (Origin Validation Error), CWE-350 (Reliance on Reverse DNS).
Severity: high.
Tests: 13 new unit tests on the middleware (loopback defaults,
case-insensitivity, IPv6 bracketing, port stripping, env-var/CLI
merge, foreign-host rejection on /health + /ws/*, disabled-allowlist
escape hatch). Full suite: 220/220 pass under
`cargo test -p wifi-densepose-sensing-server --no-default-features`.
Co-authored-by: Aeon <aeon@aaronjmars.com>
process_frame computed arithmetic mean + variance on phase values from
atan2(), which are wrapped to (-pi, pi]. Phases close across the +/-pi
discontinuity produced ~pi^2 variance instead of ~1e-6, feeding wrap
noise into the heart-rate FFT buffer.
Replace inline math with a standard circular variance helper
(1 - mean resultant length). Add 4 unit tests, one through the
production path of process_frame.
Closes#593
Three threads in this commit:
1) Per-frame attractor analysis (default analyze_every_n: 8 → 1).
The I5 benchmark put per-frame update at 0.012 ms p99 — 83× under D4's
1 ms budget. The cost case for the every-8th-frame default doesn't hold;
per-frame analysis is what makes regime_changed a viable early-detection
trigger.
2) New `regime_changed: bool` field in IntrospectionSnapshot — flips on any
frame whose attractor regime classification differs from the previous
frame's. Pairs with top_k_similarity (full-shape match) to give
downstream consumers two latencies with different robustness profiles.
3) Honest amendment of ADR-099 D8 to reflect empirical reality:
- L1 stand-in achieves 3.20× ratio (5-frame shape match vs 16-frame
event-path floor); the 10× aspirational bar is architecturally
unreachable at 1-D scalar feature resolution.
- regime_changed didn't fire in the 10-frame motion window — the
200-frame noise trajectory dominates the Lyapunov classification, and
short perturbations don't shift the regime fast enough on a scalar
feature.
- Path to 10×: ADR-208 Phase 2 (Hailo NPU vec128 embeddings) — multi-dim
partial matches discriminate from noise in 1-2 frames, not 5.
- Side finding: midstream temporal-compare::DTW uses *discrete equality*
cost (designed for LLM tokens), not numeric distance — swapping it in
for f64 amplitude scoring would be strictly worse than the L1 stand-in.
A numeric DTW is a separate concern (hand-roll or new crate).
- Revised D8: ship behind --introspection (off by default) until multi-
dim features land. Per-frame update budget IS met (0.041 ms p99 in this
bench, ~24× under the 1 ms bar) — the feature is cheap enough to
carry dark today.
cargo test -p wifi-densepose-sensing-server --no-default-features:
introspection (lib): 8 passed, 0 failed
introspection_latency (test): 5 passed, 0 failed (incl. new
regime_change_path_latency)
clippy: clean on the introspection surface (pre-existing approx_constant
lints in pose.rs / main.rs unchanged).
Co-Authored-By: claude-flow <ruv@ruv.net>
I5. Measures the architectural latency floor of the introspection path
vs. the window-aggregated event path, plus the per-frame update cost.
Result on this run:
ADR-099 D8 floor ratio : 3.20× (16 frames / 5 frames)
D8 target ≥10× — NOT YET MET on the host-side
L1 stand-in scoring; I6 closes the gap.
ADR-099 D4 update p50/p99 : 0.001 ms / 0.012 ms (~83× under the 1 ms
budget on a desktop runner; even with thermal
throttling on a Pi 5 we have orders of
magnitude of headroom).
Regime after 200 frames : Idle, lyapunov=-2.32, confidence=1.0
(attractor analyzer is firing as designed).
The D8 gap is structural to the current scoring: signature_score() uses a
length-normalised L1 over the trailing window, which requires roughly the
full signature length of in-shape frames before crossing
promotion_threshold. Closing it is the I6 work — swap in the real
midstreamer-temporal-compare DTW (partial-match scoring) and/or surface
the attractor's regime-change as an *earlier* trigger than full signature
match.
The latency-ratio test asserts a regression bar (≥3.0×) on the L1 baseline,
prints the D8 ratio + whether it's met, and explicitly defers the ≥10×
target to I6 in the docstring. Better empirical reporting than a flag that
silently fails until tuned.
ESP32 sanity (independent of the benchmark): COM7 device alive at csi_collector
cb #84500 (~30 min uptime), len=128/256 HT20/HT40, ch5, RSSI swings -44 to
-79 (= real motion in the room). UDP target still unreachable from this
host per the earlier diagnosis; that's a deployment fix, not a measurement
gate.
Co-Authored-By: claude-flow <ruv@ruv.net>
I3 (per ADR-099). Three changes in main.rs:
1) AppStateInner: + intro: IntrospectionState + intro_tx: broadcast::Sender<String>
(256-slot ring, same shape as the existing tx).
2) ESP32 frame path: after the global frame_history push, before the
per-node mutable borrow of s.node_states, compute the per-frame derived
feature (mean amplitude across subcarriers), call s.intro.update(ts_ns,
feature), and broadcast the snapshot JSON to s.intro_tx. Placement is
deliberate — between the global state's mutable touch and the per-node
&mut so borrow-checking stays linear; ns is borrowed *after* the tap
completes its s.intro / s.intro_tx access.
3) Routes:
ws_introspection_handler → /ws/introspection
api_introspection_snapshot → /api/v1/introspection/snapshot
Same Axum + tokio::sync::broadcast pattern as ws_sensing_handler,
subscribed against s.intro_tx. Wrapped by the bearer-auth middleware
already on /api/v1/* — orchestrator probes and unauthenticated /ws/sensing
reachers continue to land on the existing topic.
Verified:
cargo build -p wifi-densepose-sensing-server --no-default-features ✓
cargo test -p wifi-densepose-sensing-server --no-default-features
lib: 207 passed, 0 failed (199 pre-tap + 8 introspection)
integration suites: 70, 8, 16, 18 passed, 0 failed
cargo clippy: clean on the introspection surface (pre-existing warnings
on -core / -ruvector / -signal unchanged).
Co-Authored-By: claude-flow <ruv@ruv.net>
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>