Compare commits

...

68 Commits

Author SHA1 Message Date
Dragan Spiridonov fac8cbc129 chore(lock): record wifi-densepose-aether's serde_json dev-dependency
bc0e8fd0 added `serde_json` to the aether crate's [dev-dependencies] for the
native golden-parity test, but v2/Cargo.lock was not regenerated then (that
worktree had no submodules, so the workspace could not build). CI passed only
because it does not use `--locked`; committing the lockfile keeps Cargo.toml and
Cargo.lock in sync and lets a `--locked`/`--frozen` build succeed.

Co-Authored-By: Ruflo & AQE
2026-07-24 14:46:49 +02:00
Dragan Spiridonov 788d685b9f fix(python,training): validate PyO3 inputs; make the single-training-job guard atomic
Closes the two findings from the adversarial review that were verified but left
unfixed. Both now have proper tests, each proven to fail against the old code.

1. PyO3 bindings panicked / over-allocated on caller input (aether.rs).
   `EmbeddingExtractor(n_heads=0)` reached `d_model % n_heads` in the transformer
   and panicked (surfacing to Python as an opaque PanicException); a non-divisor
   head count tripped the native assert; and `AetherConfig(d_model=100_000)`
   allocated multi-gigabyte weight matrices that abort the interpreter. The
   binding did no validation.

   Now both constructors return `PyResult` and validate at the boundary —
   positive dims, `d_model % n_heads == 0`, and a generous MAX_DIM/MAX_LAYERS cap
   — raising `ValueError`. Proven: `n_heads=0` -> "n_heads must be positive",
   `d_model=100_000` -> a clean ValueError, both previously a
   PanicException / abort. +10 pytest cases (test_aether.py); a valid config
   still constructs and embeds.

2. The single-training-job guard was a TOCTOU race (training_api.rs).
   `spawn_training_job` checked `is_active()` under a `state` READ lock, released
   it, then set `active` later. A tokio RwLock read lock is SHARED, so two
   concurrent `POST /train/start` could both hold it, both see the slot free, and
   both spawn jobs — sharing/overwriting one status+cancel and orphaning a task
   handle.

   Extracted `claim_training_slot`, which does the check-and-set in ONE `status`
   mutex scope — the atomicity lives on the status mutex, not the coarse state
   lock — so concurrent starts serialise and exactly one wins. This also makes it
   unit-testable without a full AppState.

   Test: 32 threads hit a barrier and race to claim; asserts EXACTLY ONE wins.
   Mutation-proven — reverting to the split check-then-set makes it fail
   (`left: 3, right: 1`), and it returns to 1 with the fix.

Verified on aarch64/macOS: training_api 28 pass (26 existing + 2 new), full
python/tests suite 237 pass (227 + 10). The native module keeps its internal
assert as a defence-in-depth invariant; the binding now enforces it at the edge.

Co-Authored-By: Ruflo & AQE
2026-07-24 14:45:55 +02:00
Dragan Spiridonov bc0e8fd031 test(python): close the three parity-review findings (native anchor in CI, NaN, cosine)
A cross-vendor review of the parity rework (2febbb81) found three issues; all
verified and fixed here.

1. HIGH — the native≈golden half never ran in CI, so a binding marshalling bug
   could pass. The golden vectors were regenerated through the Python binding,
   and only pytest (binding vs that golden) runs in CI — the native reference
   tests in `python/tests/aether_parity.rs` link against the PyO3 crate and no
   workflow runs them. A stable PyO3 conversion defect present at regeneration
   would therefore be baked into the golden and go undetected.

   Fix: a real native parity test IN the `wifi-densepose-aether` crate
   (`tests/golden_parity.rs`), which is std-only and a member of the v2
   workspace, so it runs under the existing `cargo test --workspace`. It
   recomputes the embedding with no PyO3/marshalling and asserts it matches the
   SAME committed golden within tolerance. Now native≈golden AND binding≈golden
   both run in CI ⇒ binding≈native, and a binding-specific artifact in the
   golden surfaces here as a native mismatch. serde_json added as a
   dev-dependency only (test-only; never linked into the lib or the wheel, so
   the crate stays runtime-dependency-free).

   Independently proven at this commit: native output equals the committed
   golden BIT-FOR-BIT (128/128 exact bits, Δ=0) for both base and loaded — the
   golden is native-faithful today; this test keeps it that way.

2. MEDIUM — the Python parity helper passed non-finite output. `abs(nan - b) >
   tol` is False, so an all-NaN embedding slipped through. Now every element
   must be `math.isfinite` first. Proven: an all-NaN vector is rejected
   ("element 0 is not finite (nan)"); the Rust helpers already caught it via
   `<=`.

3. LOW — a coherent shift inside the per-element tolerance could move the whole
   vector undetected. Added a whole-vector cosine-similarity bound (≥ 1 - 1e-6)
   alongside the per-element check.

Verified on aarch64/macOS: `test_aether` 13, full `python/tests/` suite 227
passed against a `--features sota` build; the new native test passes
(2/2) run standalone (the in-worktree `cargo test -p` only failed on an
un-checked-out submodule; ci.yml checks out submodules recursively).

Co-Authored-By: Ruflo & AQE
2026-07-24 12:29:23 +02:00
Dragan Spiridonov 2febbb813c revert(python): keep the small base wheel — P6 stays source-build-only for now
Reverses the release-wheel packaging change from 3ed43e9a after review.

That commit built the published wheels with `--features sota` so
`pip install wifi-densepose[aether]` would work. But it did so by abandoning a
DELIBERATE, guarded design: `default = []`, the
RuView#1387-default-wheel-budget-config fix-marker, and the wheel-size-budget
job exist specifically to keep the base wheel small and make SOTA opt-in
(ADR-117 §5.4). Building SOTA into every published wheel makes the base fat,
turns the `[aether]`/`[mat]`/`[meridian]` extras into inert no-ops, and — worse
— leaves the PUBLISHED wheel un-budget-checked (the budget job only builds the
no-features wheel), so the ONNX Runtime dependency the fix-marker anticipates
for `mat` would later balloon the shipped wheel past 5 MiB silently.

A pip extra cannot enable a cargo feature on a prebuilt wheel, so "small base +
opt-in SOTA in binary wheels" is only achievable by splitting into separate
packages — tracked as #1412. Until that lands, P6 is source-build-only, and the
honest thing is to say so.

This commit:
- Restores pip-release.yml to its original state (no --features in the release
  build; original smoke test). Net packaging change across the branch is now
  ZERO — the small-base design is fully intact.
- Points the aether/mat/meridian import shims at the real interim path: a source
  build with `--features <x>` (or `--features sota`), and #1412 for the binary
  wheels follow-up. The earlier revision wrongly claimed "the official wheels
  include it", which is only true once #1412 ships.

KEPT from 3ed43e9a (correct regardless of packaging): the AETHER parity tests
are now arch-portable — they compare a golden VECTOR within tolerance instead of
a SHA-256 of raw f32 bytes, which only ever matched the one architecture that
generated it and failed on the aarch64/macOS-arm wheels this project builds.

Verified on aarch64/macOS with a `--features sota` build (as python-ci does):
test_aether 13, test_mat 7, test_meridian 13 pass; full suite unaffected.

Co-Authored-By: Ruflo & AQE
2026-07-24 11:47:19 +02:00
Dragan Spiridonov 3ed43e9a2f fix(python): ship SOTA bindings in release wheels; make parity tests arch-portable
Three coupled defects, all found by building the wheel and running the real
suite on Apple Silicon rather than trusting green CI.

1. THE P6 SOTA BINDINGS NEVER REACHED USERS (release blocker).
   `pip-release.yml`'s cibuildwheel built the DEFAULT feature set, so published
   wheels contained none of aether/mat/meridian. `pip install
   wifi-densepose[aether]` then raised ImportError — and the extra is empty, so
   its own error message ("install the [aether] extra") sent users in a circle.
   A pip extra cannot enable a Rust cargo feature on an already-built wheel, so
   the only way P6 reaches PyPI is to compile it in.

   Fix: the RELEASE build opts in via `MATURIN_PEP517_ARGS="--features sota"`
   (per-platform, since CIBW_ENVIRONMENT_LINUX overrides CIBW_ENVIRONMENT).
   `default = []` in Cargo.toml, the RuView#1387-default-wheel-budget-config
   fix-marker, and the wheel-size-budget job are all left UNTOUCHED — they keep
   guarding the small base compile. Measured published wheel: 1.68 MiB, well
   under the ADR-117 §5.4 5 MiB budget. Proven: built via the exact PEP517 path,
   `import wifi_densepose.aether/mat/meridian` all succeed.

2. THE WHEEL SMOKE TEST COULD NOT SEE #1.
   CIBW_TEST_COMMAND only asserted `hello()` and PRINTED `__build_features__`.
   The one signal that would reveal missing bindings was dumped to stdout and
   ignored, so a featureless wheel published green. It now ASSERTS the p6
   features are present and imports the three modules. Proven red→green: fails
   on the old featureless wheel ("missing SOTA bindings: [...]"), passes on the
   fixed one.

3. THE AETHER PARITY TESTS WERE NOT PORTABLE ACROSS THE WHEEL MATRIX.
   `test_aether.py` and the native `aether_parity.rs`/`aether_weights_parity.rs`
   hashed the raw f32 embedding bytes (SHA-256) against a committed golden. The
   embedding is pure f32 with transcendental ops (ln/sqrt/cos) that are not
   bit-reproducible across CPUs/libm, so the hash only ever matched the one arch
   that generated it. This passed in python-ci (x86) but fails on the aarch64 /
   macOS-arm wheels this same project ships — latent until #1 is fixed and the
   bindings actually load. Every tolerance/behavioral assertion already passed;
   only the two byte-hash tests failed, which is the signature of a non-portable
   golden, not a logic bug.

   Fix: compare to a committed golden VECTOR within tolerance
   (atol=rtol=1e-4 — ~100x cross-arch f32 drift, ~100x under any real algorithm
   change), on both the Python and native sides against the SAME golden. Native
   ≈ golden and binding ≈ golden together prove binding ≈ native, portably.
   `.sha256` goldens replaced by `.json` vectors.

Also: the aether/mat/meridian import shims told users to `pip install
wifi-densepose[<x>]` on a missing feature — an empty extra that cannot help.
Corrected to name the real fix (rebuild with `--features <x>`); message tests
updated to assert the honest message and forbid the misleading one.

Verified on aarch64/macOS: full `python/tests/` suite 227 passed against a wheel
built with the new mechanism; `cargo check --tests --features aether` clean.
The native `.rs` parity tests were converted by inspection and cargo-checked but
not executed — they link against the PyO3 crate and no workflow runs them today
(a pre-existing gap; wiring `cd python && cargo test --features sota` into
python-ci would make the native anchor actually run).

Co-Authored-By: Ruflo & AQE
2026-07-24 10:45:00 +02:00
ruv 408caf35fa fix(client): send Authorization bearer token on WS upgrade (closes #1395)
SensingClient now accepts a `token=` argument, defaulting to the
RUVIEW_API_TOKEN env var, and sets `Authorization: Bearer <token>`
directly on the WebSocket upgrade — no browser-style ticket workaround,
since Python can set the header on the handshake. Empty/unset token
means no auth (auth-disabled path unchanged).

websockets version-compat: the header keyword was renamed in the
`>=12.0` range this package pins (`extra_headers` <= 13,
`additional_headers` >= 14). Resolved by runtime detection —
`_select_header_kwarg()` inspects `websockets.connect`'s signature and
passes the correct keyword — rather than raising the floor to `>=14`.
Chosen deliberately so existing users on older websockets are not forced
to upgrade; keeps the `websockets>=12.0` pin valid.

Scope: only ws.py connects to the auth-enabled sensing-server. mqtt.py
already supports username/password (its own auth convention); ha.py and
primitives.py do no network I/O. OAuth (ADR-271) is out of scope.

Tests: constructor token, env-var token, constructor-overrides-env,
no-token/empty-token (no header), a parametrized test across both
websockets kwarg conventions, and an end-to-end test asserting the
bearer reaches the in-process server's upgrade request.
2026-07-22 07:20:32 -07:00
ruv 6fd185f8ce ci(python): gate python/ package in main CI + add fix-marker guards for filename-collision and wheel-budget regressions
The python/ PyO3+maturin wheel (wifi-densepose, ADR-117/185) had ZERO
per-PR CI coverage: pip-release.yml only fires on release triggers, so a
PR could break the wheel build, a native-Rust parity test, or the wheel
budget and nothing in the gating CI would catch it until release day.

New .github/workflows/python-ci.yml (path-scoped to python/** + the
backing crates, following the repo convention cf. firmware-ci.yml --
GitHub has no per-job paths filter and no workflow here uses a
change-detection action, so a dedicated path-scoped workflow is the
idiomatic, no-new-dependency choice):
  * parity-tests: cargo build of the aether/mat/train backing crates
    (fast-fail attribution), then `maturin develop --features sota` +
    `pytest python/tests/ -q`. sota builds the aether/meridian/mat
    compiled submodules so their SHA-256 parity tests against native
    Rust actually run (those test modules import the submodules at
    collection time -- without the features they error, not skip).
  * wheel-size-budget: builds the DEFAULT (no-features) wheel and fails
    if it exceeds the ADR-117 5.4 5 MiB budget -- the real numeric
    enforcement a string fix-marker cannot provide.

Two fix-marker guards (scripts/fix-markers.json):
  * RuView#1387-rvf-filename-collision: training_api.rs next_model_id()
    must keep the microsecond timestamp (%6f) + AtomicU64 counter; the
    forbid uses a negative lookahead so it fires only on a bare
    second-resolution format lacking the disambiguator. Guards the
    CI-caught collision fixed on this branch in 8409f434c.
  * RuView#1387-default-wheel-budget-config: default Cargo features
    empty, SOTA deps optional, maturin strip=true -- the config that
    keeps the default wheel small. Bytes are enforced by the CI job
    above, not a string marker (per the marker system's limits).

Verification:
  - python scripts/check_fix_markers.py -> All 27 fix markers present (0 regressed)
  - all 26 .github/workflows/*.yml parse (yaml.safe_load, utf-8)
  - cargo build -p wifi-densepose-aether -> Finished (exit 0)
  - forbid regexes confirmed: fire on a simulated regression, clean on the current tree

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-21 22:03:59 -07:00
ruv 36a27b3211 docs(adr-184): record OIDC revert — token auth is the active path, OIDC is gated P1b 2026-07-21 18:34:46 -07:00
ruv 82d5c73396 revert(adr-184): restore PYPI_API_TOKEN auth in pip-release.yml — OIDC needs pypi.org registration first (fix-marker RuView#786-pypi-token-auth)
Reverts the OIDC-only publish path from cc153e8b5. Restores password:
${{ secrets.PYPI_API_TOKEN }} on all four publish steps and drops the
id-token: write / environment: pypi blocks from publish-v2 and
publish-tombstone. OIDC-only is a net regression until the manual
pypi.org Trusted Publisher registration exists — publishing would fail
'no trusted publisher configured'. Token auth is the active working path.

OIDC design retained as documented TODO(ADR-184 P1b) in the header; the
switch becomes a dedicated gated follow-up once the owner confirms
registration. Restores compliance with fix-marker RuView#786-pypi-token-auth.

Refs ADR-184 P1.
2026-07-21 18:32:10 -07:00
ruv 34d929ae39 docs(adr-185): mark §13.c(a) weight-loading DONE, keep (b)/(c) open 2026-07-21 18:21:56 -07:00
ruv 65da488add feat(adr-185): add weight-loading capability to AETHER EmbeddingExtractor (§13.a)
Closes the ADR-185 §13.a follow-up (the tractable, data-independent one of
the three §6.7 gaps): the bound EmbeddingExtractor was random-Xavier-init
only, with NO way to load real weights — so it was structurally untrained.
This adds the *capability* to load weights whenever a trained checkpoint
exists. It does NOT itself produce trained/SOTA embeddings and does NOT
close §6.7 (still no trained checkpoint, no labeled data, no eval harness).

Serialization: not greenfield — EmbeddingExtractor already had
flatten_weights()/unflatten_weights() (flat Vec<f32>). wifi-densepose-aether
is a deliberately dependency-free std-only leaf crate (ADR-185 §13), so
rather than add safetensors/serde/bincode (which would undo the zero-dep
property), the on-disk format is raw little-endian f32 with a 12-byte header
(magic "AETHERW1" + u32 param count) — zero new deps.

Native (v2/crates/wifi-densepose-aether/src/embedding.rs):
- EmbeddingExtractor::save_weights(path) / load_weights(path). load_weights
  never panics: errors on unreadable file, short/oversized payload, bad
  magic, or a param-count mismatch (delegated to unflatten_weights).
- Default (no weights) construction is UNCHANGED — still random init,
  clearly labeled untrained. Purely additive.

Python binding (python/src/bindings/aether.rs, .pyi):
- EmbeddingExtractor.load_weights(path) / save_weights(path) / param_count,
  GIL-released, ValueError on bad input.

Tests (both Rust and Python, per the task):
- Rust unit (embedding.rs): load_weights_actually_replaces_weights_and_
  round_trips proves the loaded weights MOVE the embedding away from the
  random-init baseline (not a silent no-op), match the source extractor
  (round-trip), and are bit-identical after the file round-trip; plus a
  bad-magic/wrong-count rejection test.
- Cross-language golden (aether_weights_parity.rs + test_aether.py): a
  shared deterministic weight formula (w[i]=k/65536-0.5, exact in f32+f64)
  written to the AETHER format; both native Rust and the Python binding load
  it and must produce the byte-identical embedding SHA-256
  (tests/golden/aether_loaded_embedding.sha256) — proving the binding's
  load path is bit-identical to native, and (vs the random baseline) that
  the loaded weights are actually used.

Verified:
  cargo test -p wifi-densepose-aether                       98 passed, 0 failed
  cargo test --features aether --test aether_parity
    --test aether_weights_parity                            3 passed, 0 failed
  maturin develop --features aether + pytest test_aether.py 13/13 pass
  default cargo build (no aether feature)                   clean
2026-07-21 18:20:23 -07:00
ruv e0cb7ed3b3 docs(adr-185): record §6.7 as genuine gap — untrained bindings, no eval harness 2026-07-21 18:12:38 -07:00
ruv 8409f434c9 fix(adr-186): make exported .rvf filenames process-unique (fixes CI flake)
Root cause: the trained-model filename was `trained-{type}-{%Y%m%d_%H%M%S}` — a
**second-resolution** timestamp. Two training runs of the same type that finish
in the same wall-clock second compute the SAME path and silently overwrite each
other's artifact. This is a real robustness gap (a second rapid run clobbers the
first model), and it surfaced as a flaky test on the Linux CI runner:
`http_train_start_produces_model_and_streams` asserts (via a before/after
directory diff) that a new `.rvf` appeared, but the concurrent
`training_job_streams_real_progress_and_writes_model` test — same `supervised`
type, same second — wrote to the identical path and then deleted it in its own
cleanup, so the diff saw no new file. Windows scheduling happened to avoid the
same-second overlap; Linux CI hit it (the sibling `TRAIN_ENV_LOCK` holders then
failed via std::sync::Mutex poison-cascade after the first panic).

Reproduced locally by isolating the three model-writing tests (which forces the
overlap): ~10/20 runs flaked before; 0/30 after.

Fix: `next_model_id()` appends microseconds (`%6f`) plus a monotonic per-process
`AtomicU64` counter, so every run gets a distinct path even for same-microsecond
concurrent completions. Pinned by `model_ids_are_unique_per_call` (1000 ids, all
distinct) so the scheme can't regress to non-unique. No test-level band-aid
(no added sleeps / serialization) — the collision is removed at the source.

Verified: cargo test -p wifi-densepose-sensing-server -p wifi-densepose-train
--no-default-features — bin 218 passed / 0 failed, all suites 0 failed; the
isolated-tests stress loop is 0/15 flakes post-fix.
2026-07-21 18:10:17 -07:00
ruv d99ea153ff docs(adr-185): correct AETHER wheel-size claim to measured reality 2026-07-21 17:47:03 -07:00
ruv a47bb71b22 fix(adr-185): hoist AETHER compute surface into a std-only leaf crate
ADR-185 §13 flagged the `[aether]` wheel as linking the whole
`wifi-densepose-sensing-server` Axum/tokio/worldgraph/ruvector tree via the
non-optional deps of that crate. Hoist the pure-compute AETHER stack out so the
binding depends only on pure Rust.

New crate `wifi-densepose-aether` (std-only, ZERO external deps) holds the four
self-contained modules the AETHER surface needs — `embedding`, `graph_transformer`,
`sona`, `sparse_inference` (verified: no serde/tokio/axum/crate:: refs outside the
set; the four form a closed dependency graph). `wifi-densepose-sensing-server`
now `pub use`s them from the leaf crate, so its own code (`crate::embedding`, …)
and public API (`wifi_densepose_sensing_server::embedding`, …) are unchanged —
`main.rs`/`trainer.rs` untouched. The Python binding + parity test + `[aether]`
feature repoint at the leaf crate; the sensing-server dep is dropped from python.

MEASURED wheel size (maturin build --release --features aether --strip; each
wheel install-verified to actually contain `AetherConfig`):
  BEFORE (aether -> sensing-server): 369,782 B (~361 KB)
  AFTER  (aether -> leaf crate):     319,719 B (~312 KB)

HONEST CORRECTION to the §9/§13 premise: the pre-hoist wheel was NOT over the
ADR-117 §5.4 5 MB budget — it was ~361 KB, ~14x under. Linker dead-code
elimination (--gc-sections on the pyo3 cdylib) already strips the unreached
server tree because the binding only reaches pure-compute symbols. So this is
"blows the budget" CLAIMED, not MEASURED. The hoist is still worthwhile and
lands the real, measured wins:
  - `[aether]` build time 71s -> 12s (no server tree to compile),
  - python/Cargo.lock -1238 lines (server tree no longer resolved),
  - ~50 KB smaller wheel,
  - honest, minimal declared dependency surface + removes the latent risk that a
    future change makes some server code reachable and *then* bloats the wheel.

Verified this session (real counts):
  - cargo test --features aether --test aether_parity: 2 passed / 0 failed
  - pytest tests/test_aether.py (maturin develop --features aether): 9 passed
  - cargo test -p wifi-densepose-sensing-server --no-default-features: 217 bin +
    388 lib, 0 failed (no regression)
  - cargo test -p wifi-densepose-aether: 96 passed (the relocated unit tests)

The §9/§13 wheel-size narrative in ADR-185 (owned by the ADR author) should be
corrected to reflect the measured reality — flagged to the team lead.
2026-07-21 17:43:32 -07:00
ruv f74e73ccf7 chore(adr-184): promote wifi-densepose to 2.0.0 stable + ruview 2.0.0 (P2)
ADR-184 P2 (version promotion + changelog) — version-metadata prep only;
triggers NO publish (P3's real PyPI upload is still gated on the manual
Trusted Publisher registration on pypi.org).

- python/pyproject.toml: version 2.0.0a1 -> 2.0.0; classifier
  "Development Status :: 3 - Alpha" -> "5 - Production/Stable".
- python/ruview-meta/pyproject.toml: version 2.0.0a1 -> 2.0.0; same
  classifier bump; repointed the wifi-densepose dependency pins
  (base + [client]) from ==2.0.0a1 to ==2.0.0 so the stable ruview
  meta-package depends on the stable wifi-densepose, not the alpha.
- python/wifi_densepose/__init__.py: runtime __version__ 2.0.0a1 -> 2.0.0
  (caught during the sanity check — it would otherwise contradict the
  stable wheel version reported by `wifi_densepose.__version__`).
- CHANGELOG.md: [Unreleased] entry recording the promotion.

Sanity-checked that the core package genuinely deserves "Production/Stable"
(not just a version-string change):
  maturin build --release --strip  -> default wheel 279 KB
  pytest python/tests/ (excluding [aether]/[meridian]/[mat] extra modules)
    -> 185 passed, 0 failed (smoke/keypoint/pose/vitals/bfld/security/
       WS+MQTT client)
  post-bump: wheel installs as 2.0.0, __version__ = 2.0.0, smoke 6/6 pass.
No publish/tag/release commands were run.
2026-07-21 17:36:37 -07:00
ruv 99fea9df9b fix(adr-185): drop dead wifi-densepose-nn dep from train to slim [meridian] wheel
Root cause (investigated, same rigor as the MAT fix): `wifi-densepose-train`
declared `wifi-densepose-nn` as a dependency but NEVER imported it anywhere
in src/ or bin/ — a dead dependency. (The tch-backend model path uses the
`tch` crate directly, not wifi-densepose-nn; `grep -r wifi_densepose_nn
src/` returns nothing.) That dead dep pulled `ort` (ONNX Runtime) +
reqwest/hyper into every downstream consumer, including the ADR-185
`[meridian]` wheel, which binds only train's pure-compute geometry /
rapid_adapt / eval modules + signal::hardware_norm.

`cargo tree -i wifi-densepose-nn` confirmed train was the SOLE path pulling
nn into the wheel; after removal, the package is absent from the graph.

Fix: remove the dead dep line from train's Cargo.toml (no code change, no
feature-gating needed — nothing referenced it). Not a hoist: the coupling
turned out to be nonexistent, not deep.

Measured (maturin build --release --features meridian --strip):
  BEFORE: 1.8 MB   AFTER: 1.7 MB   (both already within the ADR-117 §5.4
  <=5 MB budget). Honest note: unlike MAT — which instantiates OnnxSession
  and thus bundles the ort native runtime (8.4 MB) — train never references
  ort, so the native lib was dead-stripped and the wheel was already under
  budget. The real win is removing ort/reqwest/hyper from train's dependency
  graph and compile for ALL consumers, not a dramatic wheel shrink.

Verified:
  cargo build -p wifi-densepose-train                       (default=[]) OK
  cargo tree -i wifi-densepose-nn  -> no longer in the graph
  cargo test --features meridian --test meridian_parity     2/2 pass
  maturin develop --features meridian + pytest test_meridian 13/13 pass
2026-07-21 17:30:33 -07:00
ruv daa82092a0 docs(adr-186): confirm full-workspace regression green post race-fix (0 failed) 2026-07-21 17:25:16 -07:00
ruv 7ed57f0415 fix(adr-185): feature-gate MAT's ONNX ml module to cut [mat] wheel 8.4MB->2.0MB
Root cause (investigated, not assumed): the heavy deps in a [mat] wheel
were NOT on the survivor-detection/triage path the Python binding uses.
`wifi-densepose-mat` pulled `wifi-densepose-nn` as a NON-optional dep, and
nn's default `onnx` feature drags in `ort` (ONNX Runtime) + its
download/reqwest/hyper stack. But nn is used ONLY by mat's `src/ml/`
module (debris + vital-signs ONNX classifiers), which is an OPTIONAL
runtime enhancement: `DetectionPipeline.ml_pipeline` is `Option<_>`,
created only when `DetectionConfig::enable_ml` is set, and
`from_disaster_config` (the DisasterResponse path) never enables it. So
the ONNX stack was compiled-and-linked but never executed by the binding.

Fix = feature-gating (not a hoist — a hoist was unnecessary):
- `wifi-densepose-nn` made optional; new `ml` Cargo feature gates it.
- `default = [..., "ml"]` so existing consumers are unchanged; `api`
  implies `ml` (the REST surface exposes `ml_ready`).
- `#[cfg(feature = "ml")]` on the `ml` module, its crate-root + prelude
  re-exports, the `MatError::Ml` variant, and every ml touchpoint in
  detection/pipeline.rs (ml_config/ml_pipeline fields, constructor,
  process_zone enhancement block, enhance_with_ml/get_ml_results/
  initialize_ml/ml_ready/ml_pipeline accessors, update_config).
- Fixed a latent feature-unification bug surfaced by dropping nn:
  integration/hardware_adapter.rs uses `tokio::select!`, which needs
  `tokio/macros`. That was only satisfied transitively via nn; now
  declared explicitly on mat's own tokio dep so --no-default-features
  builds compile.

The ADR-185 [mat] wheel already depended on mat with
`default-features = false, features = ["std"]`, so no python/ change was
needed — dropping `ml` from the default now actually removes ort.

Measured (maturin build --release --features mat --strip):
  BEFORE (ml/ort in wheel): 8.4 MB  (over the ADR-117 §5.4 <=5 MB budget)
  AFTER  (ml gated off):    2.0 MB  (under budget) — 76% smaller

Verified:
  cargo build -p wifi-densepose-mat                         (default+ml)  OK
  cargo build -p wifi-densepose-mat --no-default-features --features std  OK
  cargo test --features mat --test mat_parity              2/2 pass
  maturin develop --features mat + pytest tests/test_mat.py 7/7 pass
  (same detection result: 1 survivor, triage Delayed — behavior-transparent)
2026-07-21 17:22:20 -07:00
ruv 65648fe4c2 feat(adr-186): P5/P6 + acceptance verification — dashboard honesty, HTTP tests, Accepted
Completes ADR-186 phases P5 (dashboard honesty / fallback guarantee) and P6
(tests + witness) on top of the P1–P4 reconnection, and flips the ADR to Accepted.

P5 — dashboard honesty:
- Backend (training_api): a runtime enablement gate (`RUVIEW_DISABLE_SERVER_TRAINING`)
  makes the three start endpoints return a structured `{enabled:false, reason,
  cli:"wifi-densepose train-room"}` HTTP 409 (never a silent success) when server
  training is disabled; `/api/v1/train/status` now carries an `enabled` flag. A runtime
  flag (not a Cargo feature) so `--no-default-features` builds keep training ON (resolves
  §9.4). Handlers return `Response` to carry the 409.
- Frontend (TrainingPanel.js): reads `enabled` off the status payload and disables the
  Start/Pretrain/LoRA buttons with a CLI tooltip when server training is off; a rejected
  start tears down the optimistically-opened WS and refreshes. The enabled path was
  already fully wired (opens the WS before POST, renders live epoch/loss/ETA + terminal
  state) — verified.

P6 — tests & witness:
- New HTTP-level tests in a `#[cfg(test)] mod adr186_http_tests` built on a minimal
  `AppStateInner::minimal()` test-state helper: a live-socket test that completes a
  genuine 101 WebSocket handshake (tokio-tungstenite) and receives a real progress frame
  after a POST start; a 426-not-404 route-wired check; a full POST→poll-status→`.rvf`-exists
  round-trip; and the disabled-409 structured-response test. Added `tokio-tungstenite`
  dev-dep (version already in the workspace lock).
- CHANGELOG updated; ADR §6 ledger + §7 acceptance criteria checked off with evidence;
  Status → Accepted. README/CLAUDE have no training route table (no edit needed).

Fixed a test-only parallelism race found under `cargo test --workspace`: two
model-writing tests deleted `.rvf`s by directory-diff and could remove a file a third
test asserted existed. Each test now cleans only its own artifact (data/models is
gitignored).

Verified this session: `cargo test -p wifi-densepose-sensing-server -p wifi-densepose-train
--no-default-features` — sensing-server bin 217 passed / 0 failed, all train suites 0
failed. Full `--workspace` re-run in progress to reconfirm untouched crates.
2026-07-21 17:19:20 -07:00
ruv a4a3f456e1 docs(adr-185): record P1-P4 implementation status, remaining gaps 2026-07-21 17:07:01 -07:00
ruv 0f405213d3 feat(adr-185): P4 benchmarks, examples, and README extras for the SOTA wheels
ADR-185 §4.2 pytest-benchmark micro-benchmarks + runnable examples +
README extras table for the aether/meridian/mat bindings.

- python/bench/test_bench_{aether,meridian,mat}.py — follow the existing
  test_bench_vitals.py pattern (skipped by default; --benchmark-only).
- python/examples/{reid_from_csi,cross_room_calibrate,mat_triage}.py —
  typed, runnable, mypy --strict clean.
- python/README.md — SOTA extras table + example links.

Measured on a RELEASE wheel (maturin develop --release --features sota),
reference machine per ADR-117 §10:
  AETHER embed()          mean ~150 us/window   (target <2 ms)   PASS
    batch scaling 1/8/64: 140 / 1091 / 8509 us  (linear, no O(n^2)) PASS
  MERIDIAN normalize()    mean ~2.2 us/frame     (target <200 us) PASS
  MERIDIAN encode()       mean ~6.9 us           (target <200 us) PASS
  MAT ingest+scan_once()  mean ~40 ms/256-frame  (< 500 ms interval) PASS

Acceptance self-verification (ADR-185 §6), all run just now:
  §6.1 default wheel 279 KB (<=5 MB); build_features has no p6-* feature  PASS
  §6.2 pytest tests/test_aether.py    9/9   PASS
  §6.3 pytest tests/test_meridian.py  13/13 PASS
  §6.4 pytest tests/test_mat.py       7/7   PASS
  §6.5 benchmarks meet all targets (above)                                PASS
  §6.6 parity harness: 3/3 SHA golden gates green (cargo test --features
       sota, 6/6); CI *wiring* as a release gate is out of python/ scope  PARTIAL
  §6.7 SOTA accuracy bars on labeled fixtures: NOT met (no labeled
       fixtures / trained models available; parity proves path-equality,
       not accuracy)                                                       OPEN
  §6.8 .pyi stubs present for all three; mypy --strict on the 3 examples   PASS
  §6.9 base wheel `import wifi_densepose.{aether,meridian,mat}` raises a
       clear ImportError naming the extra                                  PASS
  No regression: 76 pre-existing tests pass on the default wheel.

Status NOT flipped to Accepted: §6.7 (accuracy bars) is unmet, §6.6 CI
wiring is pending, and the per-extra wheel-size hoists (sensing-server /
train / mat leaf crates) remain follow-ups. docs/adr/ is owned by another
agent this session, so the ADR ledger edit is deferred to that owner.
2026-07-21 17:03:49 -07:00
ruv 1c9727f9cf feat(adr-185): P3 MAT bindings (wifi_densepose.mat) + parity harness
Bind the ADR-024 MAT (Mass Casualty Assessment Tool) disaster-survivor
detection + START triage surface into the wheel behind a gated [mat]
extra / Cargo `mat` feature, mirroring the upstream disaster/ML gating.
Also adds the [sota] superset extra (aether+meridian+mat).

Surface (bound against the REAL code at HEAD, not the ADR wishlist):
- DisasterType (9 variants) / TriageStatus (5, START) enums
- DisasterConfig (builder-backed, continuous_monitoring forced off)
- DisasterResponse: initialize_event / add_zone / push_csi_data /
  scan_once / survivors / survivors_by_triage
- Survivor (id, triage_status, confidence, location, latest_vitals)
- VitalSignsReading (breathing/heartbeat rate, movement, confidence)
- ScanZone.rectangle / ScanZone.circle
push_csi_data + scan_once are GIL-released.

Honest deviations from ADR section 3.4 (documented in module header):
- ADR proposed adding a Rust-side sync scan_once() (section 11.3). That was
  UNNECESSARY: the public async start_scanning() runs exactly one
  scan_cycle and returns when continuous_monitoring == false. The binding
  forces that flag off and drives one cycle on a private current-thread
  tokio runtime -- NO change to wifi-densepose-mat.
- scan_cycle requires an active event + Active zone, which the ADR surface
  omitted; initialize_event + add_zone are bound as required additions.
- Survivor.vital_signs is a *history* in the real code; bound as
  Survivor.latest_vitals -> Optional[VitalSignsReading].
- DisasterType has 9 variants at HEAD (adds Landslide/MineCollapse/
  Industrial/TunnelCollapse); all bound.

Parity (section 4.1, release-blocking): committed fixture mat_input.json
(synthetic breathing-modulated CSI stream) -> native Rust reference
(tests/mat_parity.rs, drives DisasterResponse directly) locks
tests/golden/mat_result.sha256 over a canonical
`count=<K>;triage_priorities=<sorted>` string (survivor UUIDs/timestamps
excluded as non-deterministic); pytest (tests/test_mat.py) runs the same
stream through the binding and asserts the identical hash. Both detect
exactly 1 survivor, triage Delayed. Honest: synthetic fixture proves
binding==native path equality, NOT live detection accuracy.

Verified:
  cargo test --features mat --test mat_parity -> 2/2 pass
  maturin develop --features mat + pytest tests/test_mat.py -> 7/7 pass
  default cargo build clean, 0 mat/tokio refs in the default dep graph.

WHEEL-SIZE FINDING (ADR-185 section 9): default-features=false drops MAT's
`api` (axum) and `ruvector` features, but MAT still carries NON-optional
tokio (rt/sync/time), wifi-densepose-nn (ort/ONNX + reqwest/hyper),
rustfft, geo, ndarray. So a [mat] wheel exceeds the ADR-117 section 5.4
<=5 MB budget -- same leaf-crate-hoist follow-up as AETHER/MERIDIAN. The
default wheel is untouched (feature-gated).
2026-07-21 16:49:49 -07:00
ruv 189ac9dfb0 feat(adr-185): P2 MERIDIAN bindings (wifi_densepose.meridian) + parity harness
Bind the ADR-027 MERIDIAN cross-environment domain-generalization surface
into the wheel behind a gated [meridian] extra / Cargo `meridian` feature.
Inference/adaptation path only (tch-free), per ADR-185 section 3.3.

Surface (bound against the REAL code at HEAD, not the ADR wishlist):
- HardwareType / HardwareNormalizer / CanonicalCsiFrame (from
  wifi-densepose-signal::hardware_norm)
- MeridianGeometryConfig / GeometryEncoder (64-dim, permutation-invariant)
- RapidAdaptation / AdaptationResult (push_frame + adapt, LoRA deltas)
- CrossDomainEvaluator + mpjpe (from wifi-densepose-train, NO tch-backend)
All compute paths GIL-released (py.allow_threads).

Honest deviations from ADR section 3.3 (documented in the module header):
- ADR's RapidAdaptation.calibrate(windows) and AdaptationResult.converged
  DO NOT EXIST. Real API is push_frame + adapt(); result carries
  {lora_weights, final_loss, frames_used, adaptation_epochs}. Bound as-is.
- ADR's HardwareType.detect exposed as a staticmethod delegating to the
  real HardwareNormalizer::detect_hardware.
- ADR's normalize(frame: CsiFrame, hw) is really normalize(amplitude,
  phase, hw) over f64 vectors returning Result; bound faithfully.
- CanonicalCsiFrame fields are singular amplitude/phase (ADR said plural).
Training-time types (DomainFactorizer, GradientReversalLayer,
VirtualDomainAugmentor) are out of P6 scope (need the libtorch tier).

Parity (section 4.1, release-blocking): committed fixture
meridian_input.json -> native Rust reference (tests/meridian_parity.rs,
calls hardware_norm + geometry + rapid_adapt directly) locks
tests/golden/meridian_output.sha256 over the concatenated f32 outputs
(esp32+intel canonical frames, 64-dim geometry vector, rapid-adapt LoRA
weights); pytest (tests/test_meridian.py) runs the same fixture through
the binding and asserts the identical SHA-256. Both pass.

Verified:
  cargo test --features meridian --test meridian_parity -> 2/2 pass
  maturin develop --features meridian + pytest tests/test_meridian.py
    -> 13/13 pass
  default cargo build clean, 0 train/signal/sensing-server refs in the
    default dep graph (gate keeps the base wheel lean).

WHEEL-SIZE FINDING (ADR-185 section 9 / section 1.2): the libtorch risk
the ADR feared is AVOIDED -- wifi-densepose-train's `tch` dep is properly
optional (feature tch-backend, OFF), so no libtorch links. BUT train
still carries NON-optional deps: tokio (rt subset), the five ruvector-*
crates, and wifi-densepose-nn (which itself pulls `ort` / ONNX Runtime +
reqwest/hyper). So a [meridian] wheel exceeds the ADR-117 section 5.4
<=5 MB budget (though lighter than AETHER's axum/tokio server tree). The
clean fix is the same leaf-crate hoist: move the pure inference modules
(geometry, rapid_adapt, eval, hardware_norm) into a tch/tokio/ort-free
leaf crate. A required pre-release follow-up, not a functional blocker;
P2 binds real code and proves parity today.
2026-07-21 16:38:17 -07:00
ruv 569cd237fb feat(adr-186): reconnect orphaned in-server trainer + real /ws/train/progress
The training pipeline in `training_api.rs` was written and committed but never
declared as a module (no `mod training_api;`), so it was dead, uncompiled code.
The live `/api/v1/train/start` was a stub that flipped a status string and
logged one line without starting any job, and `/ws/train/progress` 404'd
(issue #1233). This wires the real trainer into the live server.

- main.rs: declare `mod training_api;` (+ `mod path_safety;` for its load
  guard), replace the `training_status`/`training_config` stub fields on
  AppStateInner with `training_state: training_api::TrainingState` +
  `training_progress_tx`, delete the three stub handlers, and
  `.merge(training_api::routes())` before `.with_state` so `/api/v1/train/*`
  (bearer-gated) and `/ws/train/progress` resolve against the shared state.
- training_api.rs: decouple the training core (`run_training_job`) from the
  ~60-field AppStateInner via a shared status handle (`Arc<Mutex<TrainingStatus>>`)
  + cooperative cancel flag (`Arc<AtomicBool>`) + an owned frame_history
  snapshot, making it unit-testable. Self-contained input schema (local
  `RecordedFrame`/`RECORDINGS_DIR`) instead of coupling to the still-orphaned
  `recording.rs`. Consolidate the single-job guard + spawn into
  `spawn_training_job`.
- Tests: real end-to-end test (synthetic CSI dataset -> real progress frames
  stream + an actual `.rvf` artifact is written), plus path-traversal-rejection
  and cancellation tests.

Verified: cargo test -p wifi-densepose-sensing-server -p wifi-densepose-train
--no-default-features -> 0 failed.

P5 (feature-flagged disabled-build honesty) deferred as follow-up; the
default/enabled build no longer silently no-ops.
2026-07-21 16:29:42 -07:00
ruv d060998e3b feat(adr-185): P1 AETHER bindings (wifi_densepose.aether) + parity harness
Bind the ADR-024 contrastive CSI embedding surface into the wheel behind
a gated [aether] extra / Cargo `aether` feature, per ADR-185 section 3.2.

Surface (bound against the REAL code at HEAD, not the ADR wishlist):
- AetherConfig       -> EmbeddingConfig {d_model,d_proj,temperature,normalize}
- CsiAugmenter       -> augment_pair(window, seed)
- EmbeddingExtractor -> embed(csi): 128-dim L2-normed, GIL-released
- info_nce_loss, cosine_similarity (module fns)

ADR-185 section 3.2 also names aether_loss/VICReg components,
alignment_metric, uniformity_metric, forward_dual, and vicreg_* config
fields. None exist in embedding.rs at HEAD, so they are intentionally NOT
bound (a Rust-side gap, not a binding gap) rather than fabricated.

Parity (section 4.1, release-blocking): committed fixture
aether_input.json -> native Rust reference (tests/aether_parity.rs, calls
sensing-server EmbeddingExtractor directly) locks
tests/golden/aether_embedding.sha256; pytest (tests/test_aether.py) runs
the same fixture through the binding and asserts the identical SHA-256 of
the LE f32 bytes. Both pass.

Verified: cargo test --features aether --test aether_parity -> 2/2 pass;
maturin develop --features aether + pytest tests/test_aether.py -> 9/9
pass; default cargo build clean with 0 sensing-server refs in the dep
graph (gate keeps the base wheel lean).

HONEST WHEEL-SIZE FINDING (ADR-185 section 9 / Open Q 11.1, unresolved):
wifi-densepose-sensing-server is an Axum/tokio crate whose tokio/axum/
worldgraph/ruvector deps are NON-optional (only mqtt/matter are
features), so default-features=false does NOT drop them. An [aether]
wheel therefore links the full server tree and BREAKS the ADR-117 section
5.4 <=5 MB budget. The fix is the ADR-sanctioned hoist of embedding.rs
(+ its graph_transformer/sona siblings) into a leaf crate so the wheel
links pure compute only -- a change INSIDE wifi-densepose-sensing-server,
owned by another agent this session, so deferred as a required
pre-release follow-up. P1 binds real code and proves parity today; the
hoist is a wheel-size optimization, not a functional blocker.

Note: building required initializing the worldgraph/rufield/ruvector
submodules (absent in the fresh worktree).
2026-07-21 16:28:14 -07:00
ruv 5426fe830c docs(changelog): record ADR-184/187 landed work
Under [Unreleased]:
- Changed: ADR-184 pip-release.yml migrated to PyPI OIDC Trusted Publishing (cc153e8b5),
  flagged not-yet-active pending manual pypi.org Trusted Publisher registration.
- Deprecated (new section): ADR-187 archive/v1 formal deprecation + three-tier
  model-weights honest labeling in README.md/docs/user-guide.md (1fb5397dd, b1417fb6e).
- Added: ADR-184/185/186/187 decision records; ADR-185/186 marked Proposed-only
  (not yet implemented on this branch).
2026-07-21 16:16:14 -07:00
ruv b1417fb6e5 docs(adr-187): mark P1-P3 done, verify acceptance criteria
Verified all 6 acceptance criteria against current file contents and checked them.
Set Status to Accepted; marked P1/P2/P3 DONE (1fb5397dd); left P4 as ACCEPTED-FUTURE (#645).

To make criterion 4 genuinely true (live ESP32 17-keypoint feature nowhere advertised
without the first-cut/below-target/runtime-stub caveat), added the caveat to the three
remaining live-pose advertisements in README.md: the recommended-hardware capability
table, the hero image caption, and the Live-ESP32-pipeline note.

Refs #509, #1125
2026-07-21 16:13:09 -07:00
ruv 1fb5397ddf docs(adr-187): deprecate archive/v1 + honest three-tier model-weights labeling
Add archive/v1/DEPRECATED.md tombstone and a loud deprecation notice atop
archive/v1/README.md: DensePoseHead is architecture-only (random kaiming_normal_
init, zero committed checkpoints under archive/v1/), superseded by the v2/ workspace
and the wifi-densepose 2.x / ruview pip wheel.

Add a 'Model weights: what's real, what's not' three-tier table to README.md and
docs/user-guide.md distinguishing real+validated (presence 82.3%, MM-Fi pose 82.69%
torso-PCK@20, count_v1), real-but-weak (committed pose_v1 at PCK@20=3.0%, runtime
confidence=0 stub, below ADR-079 target), and architecture-only (archive/v1). Caveat
the live single-ESP32 17-keypoint cog advertisement and answer #509 SISO / #1125.

Refs #509, #1125
2026-07-21 16:08:40 -07:00
ruv dfc4c1abd6 docs(adr-184): record P1 OIDC workflow migration status (cc153e8b5), await pypi.org registration 2026-07-21 16:07:40 -07:00
ruv cc153e8b56 ci(adr-184): migrate pip-release publish to PyPI OIDC Trusted Publishing
Drop all four PYPI_API_TOKEN password inputs from the publish-v2 and
publish-tombstone jobs, grant id-token: write, and bind both jobs to the
pypi GitHub environment so pypa/gh-action-pypi-publish uses secretless
Trusted Publishing. Rewrite the header + add step comments documenting
the blocking manual pypi.org trusted-publisher registration (ADR-184
§3.1) and the token fallback (§3.2). Build matrix untouched.

Refs ADR-184 P1.
2026-07-21 16:07:11 -07:00
ruv cca5bd8113 docs(adr): index ADR-184..187 in ADR README, correct count to 193 2026-07-21 16:01:42 -07:00
ruv c6b4d36ba3 docs(adr-186): propose training progress API fix (refs #1233) 2026-07-21 16:00:28 -07:00
ruv abf6d2aee0 docs(adr-185): propose P6 Python bindings for AETHER/MERIDIAN/MAT 2026-07-21 15:59:16 -07:00
ruv 601370d61a docs(adr-187): propose archive/v1 deprecation + model-weights honest labeling (refs #509, #1125) 2026-07-21 15:59:06 -07:00
ruv 5dbb7f0d02 docs(adr-184): note interim PYPI_API_TOKEN rotation, OIDC migration still pending 2026-07-21 15:58:42 -07:00
ruv f8ab1fef94 docs(adr-184): propose ADR-117 completion via PyPI Trusted Publishing (refs #785) 2026-07-21 15:57:33 -07:00
rUv aa7cb449bc fix(ci): bypass hardened entrypoint for asset inspection (#1364) 2026-07-19 02:50:47 -04:00
rUv 47dbdb29c0 fix(ci): restore workflow and LAN smoke tests (#1362) 2026-07-19 01:11:45 -04:00
rUv 6ee1b55896 feat: implement ADR-270 vendor provider beta (#1360) 2026-07-19 00:09:50 -04:00
rUv 76c80c33d7 feat(hardware): add Qualcomm CSI simulator and vendor roadmap (#1359) 2026-07-18 23:03:45 -04:00
rUv 232b1c79f6 feat(hardware): add MediaTek Filogic CSI simulator (#1358) 2026-07-18 22:00:09 -04:00
rUv 8a5af5dad4 feat: add RTL8720F Realtek radar beta support (#1356)
* docs(adr): plan RTL8720F radar SDK integration

* feat(hardware): add Rust RTL8720F radar simulator

* feat(ruview): ingest RTL8720F radar frames
2026-07-18 19:48:40 -04:00
rUv c7b3f0bcc5 Merge pull request #1355 from ruvnet/agent/open-issues-release
fix: open issue sweep and ESP32 v0.8.4 release
2026-07-18 18:17:38 -04:00
ruv 8c0bdaef92 fix open issue release blockers 2026-07-18 18:01:23 -04:00
rjperry36 1692b16f6e fix(sensing-server): canonicalize calibration frames to 56-tone grid (real HT40 fix)
Follow-up to the calibration deadlock fix. With the status gate unstuck,
maybe_feed_calibration reached feed_calibration, but a real ESP32 HT40 node
streams 128-wide amplitude frames while the single-link FieldModel is the
canonical 56-tone grid. LinkStats::update returned DimensionMismatch,
feed_calibration bubbled it, and maybe_feed_calibration swallowed it at debug
level — so frame_count stayed pinned at 0 on live hardware (presence/vitals,
which read the global history, were unaffected).

Resample each frame onto the model's canonical 56-tone grid via
HardwareNormalizer::resample_to_canonical before feeding — the same length-only
canonicalization the multistatic fusion path uses (#1170).

Pinned by maybe_feed_calibration_resamples_wide_frames_and_accumulates
(128-wide -> Collecting + count 1). sensing-server bin: 173 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-18 17:27:30 -04:00
rjperry36 4bab48e15f fix(sensing-server): unstick empty-room field-model calibration deadlock
POST /api/v1/calibration/start created the FieldModel in Uncalibrated, but
field_bridge::maybe_feed_calibration only fed frames while already Collecting
— and the only thing that sets Collecting is feed_calibration on its first
fed frame. The two gates deadlocked: no first frame was ever fed, so
calibration_frame_count stayed 0 and status never left Uncalibrated. Observed
live on a streaming ESP32 node as {"status":"Uncalibrated","frame_count":0}
that never advanced.

- field_bridge::maybe_feed_calibration: feed while Uncalibrated | Collecting
  so the first frame flips the model to Collecting and the count advances.
- calibration_stop: return structured {success:false, frame_count,
  frames_needed} instead of an opaque 500 when finalized with too few frames.
- FieldModel::min_calibration_frames() accessor for the guard above.
- Regression test: maybe_feed_calibration_advances_uncalibrated_to_collecting.

Presence/motion/vitals were unaffected (separate auto rolling baseline).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-18 17:27:08 -04:00
Matías J. Apablaza 6f0114c488 fix(firmware): accept WPA/WPA2-mixed routers; warn about compact-board thermal risk
Lower the STA auth threshold from WPA2_PSK to WPA_PSK so routers running
WPA/WPA2-mixed compatibility mode aren't rejected with
WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD (reason=211), and log the
disconnect reason/rssi instead of retrying blind. Also document the thermal
risk of running this firmware's continuous-radio, tier-2 DSP pipeline on
coin-sized clone boards (ESP32-S3-Zero, SuperMini) with minimal PCB copper
and budget regulators, after a field report of three such boards failing to
power on following a normal session.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-18 17:26:17 -04:00
Sushant c8e990c36f Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-18 17:26:17 -04:00
Sushant Guri 7925aa94ab fix(api): resolve event loop blocking in health and metrics endpoints 2026-07-18 17:26:17 -04:00
Matt Van Horn c2cf180afe docs: align firmware ESP-IDF version references to v5.4 (fixes #1177 b 2026-07-18 17:26:16 -04:00
Matt Van Horn 8c232d0894 fix: rename HeartRateExtractor.extract() weights param to phases 2026-07-18 17:26:16 -04:00
ruv 82c1b8fdf8 chore: bump wifi-densepose-signal 0.3.5 for crates.io (#1334)
Published 0.3.4 predates HardwareNormalizer::resample_to_canonical and
MultistaticConfig::for_tdm_schedule, which the sensing-server binary
uses — its publish verify fails against the registry 0.3.4. The in-repo
version had not been bumped since those APIs landed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-14 13:31:42 -04:00
ruv 9dceb976c7 chore(publish): version rufield deps + bump worldgraph/rufield submodules (#1334)
- wifi-densepose-rufield: add version="0.1.0" to the four rufield path
  deps — rufield-core/-provenance/-privacy/-fusion are now published to
  crates.io, making this crate (and wifi-densepose-sensing-server 0.3.4)
  publishable
- v2/crates/worldgraph -> 4441bc0: wifi-densepose-worldgraph 0.3.2
  published (adds prune_semantic_states; unblocks wifi-densepose-engine
  0.3.1 publish)
- vendor/rufield -> f3c1492: breaks the fusion<->adapters circular
  dev-dependency (path-only dev-dep, stripped at publish)

Closes the crates.io publish blockers in #1334.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-14 13:18:43 -04:00
rUv 7b244bdc8c Merge pull request #1332 from ruvnet/integrate/pr-1311-1313
Integrate community fixes: DevKitC-1 overlay, EngineBridge guard config, pose-WS bearer auth (#1308 #1309 #1310)
2026-07-14 12:48:11 -04:00
github-actions[bot] 90667d0f1d chore: update vendor submodules to latest upstream (#1331)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-14 12:15:39 -04:00
ruv 8c1d3d772a chore: bump wifi-densepose-engine 0.3.1, wifi-densepose-sensing-server 0.3.4
engine 0.3.1: additive StreamingEngine::set_multistatic_config (#1312)
sensing-server 0.3.4: bearer-auth pose-WS exemption + EngineBridge guard
config threading (#1312, #1313); no public lib API change (engine_bridge
is binary-internal)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-14 12:12:48 -04:00
ruv 83f549d308 fix: post-review fixes for PRs #1311/#1312/#1313 + CHANGELOG
- sdkconfig.defaults.devkitc: header build command idf v5.2 -> v5.4
  (source needs esp_driver_uart, IDF >=5.3 — same reason the PR fixed
  the README refs)
- engine/lib.rs: separate doc comments — set_room_adapter's ADR-150
  adapter/witness doc had merged into set_multistatic_config's doc
- ui: apply stored bearer token at api.service.js module load instead
  of QuickSettings.init — services + dashboard tab dispatch their first
  /api/v1/* requests before initializeEnhancements() runs, so the first
  requests on every load went out without the Authorization header
- CHANGELOG: Unreleased entries for #1308/#1309/#1310

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-14 11:08:37 -04:00
ruv d59ca00baa Merge PR #1313: exempt /api/v1/stream/pose WS from bearer auth + UI token field 2026-07-13 13:50:36 -04:00
ruv da81eab714 Merge PR #1312: wire WDP_GUARD_INTERVAL_US/WDP_TDM_* into EngineBridge 2026-07-13 13:50:35 -04:00
ruv d840ced2db Merge PR #1311: display-less DevKitC-1 build overlay + IDF v5.4 doc refs 2026-07-13 13:50:35 -04:00
erichkusuki 2ddb6a7b02 fix(sensing-server): exempt /api/v1/stream/pose WS from bearer auth; add UI token field
Browsers cannot attach an Authorization header to a WebSocket upgrade,
so with RUVIEW_API_TOKEN set the Live Demo pose stream at
/api/v1/stream/pose always failed with 401 — the same reason
/ws/sensing is already exempted (see bearer_auth module docs). Adds a
narrow EXEMPT_PATHS list plus a regression test that the exemption
does not leak to other /api/v1/* paths. Query-string tokens remain
rejected (CWE-598 test untouched).

Also adds an 'API Access' bearer-token field to the QuickSettings
panel: ui/services/api.service.js had setAuthToken() but nothing ever
called it, so enabling RUVIEW_API_TOKEN broke every /api/v1/* call
from the bundled dashboard. The token is stored in localStorage and
applied before the first request.

Fixes #1310

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-11 13:32:02 +02:00
erichkusuki b5ce60081b fix(sensing-server): wire WDP_GUARD_INTERVAL_US/WDP_TDM_* into EngineBridge
EngineBridge::new built its own StreamingEngine, whose internal
MultistaticFuser was hardcoded to MultistaticConfig::default() (60 ms
guard) — the #1031/#1049 env overrides only reached the sibling
multistatic_fuser field on AppState, so governed trust cycles failed
against the default guard no matter what the deployment configured.

- wifi-densepose-engine: add StreamingEngine::set_multistatic_config()
- engine_bridge: EngineBridge::new takes Option<MultistaticConfig>
- main.rs: thread the same env-derived config into both fusion paths

Verified on a 2-node ESP32-S3 deployment: the failure message now
reflects the configured guard, and with healthy nodes governed cycles
ran 90 s with zero fusion errors.

Fixes #1309

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-11 13:32:01 +02:00
erichkusuki 0a282d0870 fix(firmware): add display-less DevKitC-1 build overlay; update stale IDF v5.2 refs to v5.4
The ADR-045 display probe false-positives on display-less
ESP32-S3-DevKitC-1 boards (SH8601 init 'succeeds' against floating
pins), so main.c skips the RuView#893 MGMT+DATA promiscuous upgrade
and CSI yield collapses to 0 pps. sdkconfig.defaults.devkitc compiles
display support out, making has_display constant-false so the #893
upgrade always applies. Hardware-verified on 2x DevKitC-1-N16R8:
0 pps -> steady 40-45 pps.

Also updates the Quick Start build commands and badge from
espressif/idf:v5.2 to v5.4 — current source uses esp_driver_uart
(IDF v5.3+) and no longer builds under v5.2; CI already uses v5.4.

Addresses #1308

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-11 13:32:01 +02:00
rUv e6f26e9ac9 docs(adr): deep review of the RuView npm surface — ADR-263/264/265 optimization strategies (#1229)
* docs(adr): deep review of the RuView npm surface — ADR-263/264/265 optimization strategies

ADR-263 — @ruvnet/ruview@0.1.0 harness review (O1–O9):
- HIGH: claim-check CLI fails open on empty input (no --text/--file -> PASS exit 0)
- HIGH: MCP stdio server head-of-line blocking (spawnSync verify/calibrate up to 600s)
- MEASURED: optionalDependencies triple the cold npx install (4 pkgs/620kB/71 files
  vs 1 pkg/172kB/22 files with --omit=optional) for a path that never imports them
- maxBuffer truncation, python -c port interpolation, version drift, duplicate skills,
  guardrail METRIC_TERMS substring false positives ('map'/'F1' — found by dogfooding
  claim-check on these very ADRs), zero CI

ADR-264 — @ruvnet/rvagent@0.1.0 + @ruv/ruview-cli review (O1–O9), verified against
the published registry tarball:
- HIGH: exports.require -> dist/index.cjs which is never built nor published
- MEASURED: 44 dead source-map files = 62,698B of the 188kB unpacked payload
- stdio-only server described as dual-transport; mixed dot/underscore tool names;
  double Zod validation + hand-duplicated advertised schemas; 2-fd leak per training
  job; unbounded body in the unwired HTTP scaffold; dead detectCogBinary candidates;
  ruview bin-name collision

ADR-265 — cross-cutting npm distribution strategy: npm-packages.yml CI matrix
(test + pack-content/size gate + tarball-install smoke test), publish-from-CI-only
with npm provenance, version single-sourcing from package.json, bin/namespace
ownership (ruview bin belongs to @ruvnet/ruview), claim-check on package READMEs.

Docs only — no runtime code changed. Index/CHANGELOG/CLAUDE.md/README counts updated.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz

* fix(npm): implement ADR-263/264/265 — harness fail-closed + async MCP, rvagent packaging/transport/naming, npm CI+provenance gate

ADR-263 (@ruvnet/ruview 0.2.0), O1-O9:
- claim-check fails closed on empty input (CLI exit 2, empty_text tool error)
- MCP stdio server dispatches tools/call asynchronously (promise-based spawn);
  ping answers while a 3s fake verify runs — pinned by new e2e test
- optionalDependencies dropped: cold npx installs exactly 1 package
  (MEASURED: was 4 pkgs/620kB/71 files via npm i in a clean prefix)
- bounded rolling output tails replace spawnSync 1MiB maxBuffer
- node_monitor port passed via sys.argv, never spliced into python -c source
- serverInfo.version read from package.json; resources/prompts stubs
- skills single-sourced: prepack sync script generates .claude/skills/ copies
- which() = memoized dep-free PATH scan
- tools underscore-canonical (ruview_claim_check, ...) + dotted aliases
- guardrail precision: word-boundary map/f1/auc/iou, code-span + F1/O2 label
  scrubbing, quantitative-claims-only; packaging reproducer hints
- 30/30 tests (was 17), incl. concurrency e2e + fail-open regression pins

ADR-264 (@ruvnet/rvagent 0.2.0), O1-O9:
- exports fixed: types-first, phantom dist/index.cjs require target removed
- tarball map-free: 127,704B unpacked / 46 files / 0 maps (MEASURED,
  npm pack --dry-run; was 188kB incl. 44 maps referencing unshipped src)
- Streamable HTTP actually wired behind RVAGENT_HTTP_PORT: one transport +
  one MCP server per session (mcp-session-id routing), 1MiB body cap (413),
  port-aware localhost origin gate; dual-transport description now true
- tools renamed underscore-canonical with dotted router-only aliases
- single Zod validation gate; advertised inputSchema generated from the same
  Zod source (zod-to-json-schema)
- train_count: parent log fds closed (was leaking 2/job); job records
  persisted to <jobsDir>/<id>.json (job_status survives restarts); bounded
  log-tail reads
- detectCogBinary probes its candidates instead of dead-coding them
- version from package.json; @types/express dropped; @types/jest -> 29
- README rewritten to match reality (no phantom subcommands/policy layer)
- 99/99 jest tests (incl. new session/body-cap suite + previously-broken
  manifest suite); stdio handshake + HTTP session flow smoke-tested live

ADR-265 D1-D4:
- .github/workflows/npm-packages.yml: 3-package x Node 20/22 gate — tests,
  version-literal grep (D3), pack-content/size gate, tarball-install smoke
  test (catches the ADR-264 F1 class), README claim-check (D4)
- .github/workflows/ruview-npm-release.yml: publish from CI only with
  npm publish --provenance
- @ruv/ruview-cli bin renamed ruview-cli (ruview bin belongs to
  @ruvnet/ruview); version single-sourced
- ci.yml NODE_VERSION 18 -> 20

ADR statuses updated to Accepted/implemented; harness manifest re-pinned;
ADR-263/264/265 + both package READMEs pass claim-check.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz

* perf(rvagent): lazy-load HTTP transport + memoize generated tool schemas

stdio time-to-first-response ~242ms -> ~189ms (-22%; MEASURED, median of
repeated initialize round-trips against dist/index.js in this container).

- ./http-transport.js now imported lazily inside the RVAGENT_HTTP_PORT
  branch: it chain-loads the MCP SDK streamableHttp module (~48ms MEASURED
  via per-module import() timing) which the default stdio path never uses
- toolInputJsonSchema memoized per tool: schemas are static for the process
  lifetime; under the session-per-server HTTP model every session calls
  tools/list, so stop re-walking the Zod tree each time

No behavior change: 99/99 jest tests; HTTP session flow re-smoke-tested
through the lazy import path (initialize -> 200 + mcp-session-id).

Profiled @ruvnet/ruview too and left it alone: 50ms CLI startup vs ~29ms
bare 'node -e ""' floor on the same box (MEASURED) — already near the
interpreter floor with zero dependencies.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz

* ci(ruview-cli): pass jest --passWithNoTests so the private no-test package doesn't fail the npm-packages matrix

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(npm): address 10 verified review findings in harness + rvagent before 0.2.0 publish

harness/ruview (@ruvnet/ruview):
- guardrails: digit gate now sees numbers inside code spans; F1-style
  metric tokens followed by ':' or a nearby number are no longer scrubbed
  (fail-open regressions in the honesty gate)
- mcp-server: tools/call requests serialize through a FIFO promise chain
  (hardware/mutating tools never overlap) while ping/tools/list stay
  immediate; stdin close drains in-flight responses before exit
- tools: which() no longer memoizes negative lookups

tools/ruview-mcp (@ruvnet/rvagent):
- index: realpath invoked-directly guard — library import no longer
  connects a stdio transport to the consumer's process
- http-transport: explicit allowedOrigins is exact-match only (localhost
  any-port convenience applies only with no configured allowlist);
  session map gains maxSessions=64 + 5min idle TTL sweep
- train-count: job records persist the child pid and reconcile stale
  'running' status after a server restart (exit-code marker or dead pid)
- config: cog binary candidates ordered by process.arch

.github/workflows/ruview-npm-release.yml: port the full ADR-265 D1 gate
(version-literal check, unpacked-size budget, tarball-install smoke test)
from npm-packages.yml so the publish path enforces what the header claims.

Tests: harness 30→36, rvagent 99→112, all passing.

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 13:11:15 -04:00
rUv 1a0492992f fix(build): repoint stale Makefile targets to v2/ and archive/v1/ (#1201)
The root Makefile still referenced pre-reorg paths that no longer exist, so
the documented build/test/run entrypoints were all broken:
- rust-port/wifi-densepose-rs/ -> v2/  (build-rust, build-wasm[-mat],
  test-rust, bench, clean cargo step)
- v1.src.api.main -> archive.v1.src.api.main  (run-api, run-api-dev)

test-rust now uses the documented `--no-default-features` invocation (the
proven-passing, GPU-free path). Verified: `cd v2 && cargo test --workspace
--no-default-features --no-run` compiles the full workspace clean.

Surfaced during a metaharness review (the broken root build entrypoint is
why genome reported build:none / publish_readiness 0.40 at the repo root).


Claude-Session: https://claude.ai/code/session_01AgpTcBLRJ32hUsKWxDXf36
2026-06-27 18:43:09 -04:00
rUv c0d3d7c792 chore(firmware): add release guard against stale-sdkconfig partition mismatch (#1194)
While cutting v0.8.3-esp32, an incremental 8MB build reused a leftover
generated `sdkconfig` and silently linked the 4MB dual-OTA partition layout
(no spiffs, ota_1 @ 0x1F0000) — the would-be released `partition-table.bin`
did not match the 8MB `partitions_display.csv` it claimed.

scripts/firmware-release-guard.sh regenerates the expected partition table
from the CSV the named flash-size variant must use and byte-compares it to the
built `partition-table.bin`, and cross-checks flash size in flasher_args.json.
Fails closed so a release pipeline can't ship a mismatched table.

Usage: scripts/firmware-release-guard.sh <8mb|4mb> <build-dir>


Claude-Session: https://claude.ai/code/session_01AgpTcBLRJ32hUsKWxDXf36
2026-06-27 13:21:05 -04:00
rUv fca5e6f0a0 fix: multistatic canonicalization, csi_fps burst inflation, control-packet starvation (#1170, #1180, #1183) (#1193)
#1170 — live multistatic bridge fed raw, un-canonicalized per-node CSI
(64/128/192 bins) to MultistaticFuser, tripping DimensionMismatch every
cycle and silently disabling fusion on mixed HT20/HT40 meshes. Add
HardwareNormalizer::resample_to_canonical (resample-only, no z-score) and
canonicalize every node frame onto the 56-tone grid before fusion.

#1180 — update_csi_fps_ema only rejected dt<=0 or >=1s, so sub-ms UDP-burst
arrivals (36us -> ~27kHz) inflated csi_fps_ema 40-840x. Add a 5ms plausibility
floor and stop re-anchoring observe_csi_frame_arrival on burst deltas.

#1183 — global ENOMEM backoff (CSI flood) starved <=48B/<=1Hz control packets.
Add stream_sender_send_priority() bypassing the backoff gate without touching
the streak; route feature_state/HEALTH/sync through it. Fix the misleading
"HEALTH sent" log that printed even on rv_mesh_send failure.

Verified: signal 501, sensing-server 677 tests (0 failed); firmware builds clean.


Claude-Session: https://claude.ai/code/session_01AgpTcBLRJ32hUsKWxDXf36
2026-06-27 13:04:44 -04:00
185 changed files with 19936 additions and 1455 deletions
+7 -6
View File
@@ -9,7 +9,7 @@ on:
env:
PYTHON_VERSION: '3.11'
NODE_VERSION: '18'
NODE_VERSION: '20' # ADR-265: all Node packages in this repo declare engines >= 20
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
@@ -88,8 +88,6 @@ jobs:
# ADR-262 P1: `wifi-densepose-rufield` path-deps the `vendor/rufield`
# submodule. Without a recursive checkout the workspace build fails to
# resolve those path deps in CI even though it passes locally.
with:
submodules: recursive
# `wifi-densepose-desktop` is a Tauri v2 app — `glib-sys`, `gtk-sys`,
# `webkit2gtk-sys`, etc. need the Linux dev libraries via pkg-config or the
@@ -146,7 +144,10 @@ jobs:
env:
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
run: cargo test -p wifi-densepose-worldmodel --no-default-features
run: >-
cargo test
--manifest-path crates/worldgraph/wifi-densepose-worldmodel/Cargo.toml
--no-default-features
# ADR-134 CIR tests are behind the `cir` feature so the bench dependency
# (Criterion) only pulls when actually exercised. Run them as a separate
@@ -249,7 +250,7 @@ jobs:
continue-on-error: true
uses: codecov/codecov-action@v6
with:
file: ./coverage.xml
files: ./coverage.xml
flags: unittests
name: codecov-umbrella
@@ -500,4 +501,4 @@ jobs:
**Docker Image:**
`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}`
draft: false
prerelease: false
prerelease: false
+12 -4
View File
@@ -52,14 +52,16 @@ jobs:
target: esp32s3
sdkconfig: sdkconfig.defaults
partition_table_name: partitions_display.csv
size_limit_kb: 1100
size_warn_kb: 1100
size_limit_kb: 1152
artifact_app: esp32-csi-node.bin
artifact_pt: partition-table.bin
- variant: 4mb
target: esp32s3
sdkconfig: sdkconfig.defaults.4mb
partition_table_name: partitions_4mb.csv
size_limit_kb: 1100
size_warn_kb: 1100
size_limit_kb: 1152
artifact_app: esp32-csi-node-4mb.bin
artifact_pt: partition-table-4mb.bin
# ADR-110: ESP32-C6 research target (Wi-Fi 6 / 802.15.4 / TWT / LP-core)
@@ -67,7 +69,8 @@ jobs:
target: esp32c6
sdkconfig: sdkconfig.defaults
partition_table_name: partitions_4mb.csv
size_limit_kb: 1100
size_warn_kb: 1100
size_limit_kb: 1152
artifact_app: esp32-csi-node-c6.bin
artifact_pt: partition-table-c6.bin
@@ -96,18 +99,23 @@ jobs:
make test_adr110
./test_adr110
- name: Verify binary size (< ${{ matrix.size_limit_kb }} KB gate)
- name: Verify binary size budget
working-directory: firmware/esp32-csi-node
run: |
BIN=build/esp32-csi-node.bin
SIZE=$(stat -c%s "$BIN")
MAX=$((${{ matrix.size_limit_kb }} * 1024))
WARN=$((${{ matrix.size_warn_kb }} * 1024))
echo "Binary size: $SIZE bytes ($(( SIZE / 1024 )) KB)"
echo "Warning at: $WARN bytes (${{ matrix.size_warn_kb }} KB)"
echo "Size limit: $MAX bytes (${{ matrix.size_limit_kb }} KB)"
if [ "$SIZE" -gt "$MAX" ]; then
echo "::error::Firmware binary exceeds ${{ matrix.size_limit_kb }} KB size gate ($SIZE > $MAX)"
exit 1
fi
if [ "$SIZE" -gt "$WARN" ]; then
echo "::warning::Firmware binary exceeds the ${{ matrix.size_warn_kb }} KB soft budget ($SIZE > $WARN); hard limit is ${{ matrix.size_limit_kb }} KB"
fi
echo "Binary size OK: $SIZE <= $MAX"
- name: Verify flash image integrity
+148
View File
@@ -0,0 +1,148 @@
# ADR-265 D1 — the npm-package gate.
#
# Every Node package in this repo (published or private) gets: install, build,
# tests, a version-literal gate (D3 — package.json is the only place a version
# lives), a pack-content gate (no source maps, unpacked-size budget), a
# tarball-install smoke test (would have caught ADR-264 F1's broken `require`
# export), and the claim-check honesty lint on the README (D4).
name: npm packages
on:
push:
branches: [main]
paths:
- 'harness/ruview/**'
- 'tools/ruview-mcp/**'
- 'tools/ruview-cli/**'
- '.github/workflows/npm-packages.yml'
pull_request:
paths:
- 'harness/ruview/**'
- 'tools/ruview-mcp/**'
- 'tools/ruview-cli/**'
- '.github/workflows/npm-packages.yml'
permissions:
contents: read
jobs:
gate:
name: ${{ matrix.package.dir }} (node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['20', '22']
package:
- dir: harness/ruview
build: false
publishable: true
# ADR-263: dependency-free harness; budget guards against dep creep.
unpacked_budget: 65536
- dir: tools/ruview-mcp
build: true
publishable: true
# ADR-264 O2: map-free tarball (was 188 kB with maps).
unpacked_budget: 140000
- dir: tools/ruview-cli
build: true
publishable: false
unpacked_budget: 0
defaults:
run:
working-directory: ${{ matrix.package.dir }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
# Repo policy gitignores lockfiles under harness/ (the harness is
# dependency-free anyway); the TS packages commit theirs.
- name: Install
run: |
if [ -f package-lock.json ]; then npm ci; else npm install --no-fund --no-audit; fi
- name: Build
if: ${{ matrix.package.build }}
run: npm run build
- name: Test
run: npm test --if-present
# ADR-265 D3 — package.json is the only place a version string lives.
- name: Version-literal gate
run: |
set -euo pipefail
hits=""
for d in src bin; do
if [ -d "$d" ]; then
hits+=$(grep -rEn '\b[0-9]+\.[0-9]+\.[0-9]+\b' "$d" | grep -vE '127\.0\.0\.1|0\.0\.0\.0' || true)
fi
done
if [ -n "$hits" ]; then
echo "Hardcoded version-like literals found (read package.json instead — ADR-265 D3):"
echo "$hits"
exit 1
fi
# ADR-265 D1.3 — pack-content gate: no maps, size budget enforced.
- name: Pack gate
if: ${{ matrix.package.publishable }}
run: |
npm pack --dry-run --json 2>/dev/null | node -e "
const [info] = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const budget = Number(process.env.UNPACKED_BUDGET);
const maps = info.files.filter((f) => f.path.endsWith('.map'));
if (maps.length > 0) {
console.error('Tarball contains source maps (ADR-264 F2):', maps.map((m) => m.path));
process.exit(1);
}
if (info.unpackedSize > budget) {
console.error(\`Unpacked size \${info.unpackedSize} B exceeds budget \${budget} B\`);
process.exit(1);
}
console.log(\`pack gate OK: \${info.files.length} files, \${info.unpackedSize} B unpacked (budget \${budget} B), 0 maps\`);
"
env:
UNPACKED_BUDGET: ${{ matrix.package.unpacked_budget }}
# ADR-265 D1.4 — install the real tarball and drive each bin/export.
- name: Tarball smoke test
if: ${{ matrix.package.publishable }}
run: |
set -euo pipefail
TGZ="$PWD/$(npm pack --silent 2>/dev/null | tail -1)"
SMOKE="$(mktemp -d)"
cd "$SMOKE"
npm init -y > /dev/null
npm i --no-fund --no-audit "$TGZ"
case "${{ matrix.package.dir }}" in
harness/ruview)
./node_modules/.bin/ruview --version
./node_modules/.bin/ruview doctor
# the honesty gate must fail closed on empty input (ADR-263 F1)
if ./node_modules/.bin/ruview claim-check; then
echo 'claim-check passed with no input — fail-open regression'; exit 1
fi
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
;;
tools/ruview-mcp)
# initialize over stdio; server must answer and exit 0 on EOF
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}\n' \
| timeout 30 ./node_modules/.bin/rvagent | grep -q '"serverInfo"'
# the ESM export must resolve from the installed tarball (ADR-264 F1)
timeout 30 node --input-type=module -e "await import('@ruvnet/rvagent');" < /dev/null
;;
esac
# ADR-265 D4 — package READMEs must pass the project's own honesty lint.
- name: Claim-check README
run: |
if [ -f README.md ]; then
node "$GITHUB_WORKSPACE/harness/ruview/bin/cli.js" claim-check --file README.md
else
echo "no README.md — skipping"
fi
+24 -4
View File
@@ -13,10 +13,26 @@
# 1. cut tag `v1.99.0-pip` → publishes the tombstone wheel first
# 2. cut tag `v2.0.0-pip` → publishes the PyO3 v2 wheel matrix
#
# Publishes via the `PYPI_API_TOKEN` GitHub Actions secret. The
# token-refresh runbook (GCP Secret Manager → gh secret set) lives in
# docs/integrations/pypi-release.md so KICS does not flag the
# secret name as a generic-secret literal in the workflow.
# Publishes via the `PYPI_API_TOKEN` GitHub Actions secret (API-token
# auth). This is the ACTIVE, working publish path — the token is sourced
# fresh from GCP Secret Manager per the runbook in
# docs/integrations/pypi-release.md (GCP Secret Manager → gh secret set),
# which also keeps KICS from flagging the secret name as a generic-secret
# literal here.
#
# TODO(ADR-184 P1b): migrate to PyPI OIDC Trusted Publishing to remove
# this rotatable/expire-able credential. That switch is GATED on a manual
# pypi.org step no CLI/agent can perform: the repo owner must register a
# Trusted Publisher on pypi.org for owner=ruvnet / repo=RuView /
# workflow=pip-release.yml (BOTH the wifi-densepose and ruview projects;
# ruview as a pending publisher) — see docs/adr/ADR-184-*.md. Do NOT grant
# the OIDC id-token write permission before that registration exists, or
# publishing fails with "no trusted publisher configured" — a silent
# regression the `Verify fix markers` guard `RuView#786-pypi-token-auth`
# exists to catch (it forbids that permission string in this file). When
# the owner confirms both entries are live, do the OIDC switch as a
# dedicated follow-up commit (drop `password:`, add the OIDC id-token
# permission + `environment: pypi`) so there is no capability gap between.
#
# Q3 (witness hash v2 — open in ADR-117 §11.3) MUST be resolved
# before the first v2.0.0 publish. When v2 lands, add a parallel
@@ -241,6 +257,8 @@ jobs:
mkdir -p dist
find dist-staging -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp -v {} dist/ \;
ls -lh dist/
# API-token auth (active path). See TODO(ADR-184 P1b) in the header
# before replacing `password:` with the OIDC id-token permission.
- name: Publish to TestPyPI (dry-run target)
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
uses: pypa/gh-action-pypi-publish@release/v1
@@ -274,6 +292,8 @@ jobs:
with:
name: tombstone
path: dist
# API-token auth (active path). See TODO(ADR-184 P1b) in the header
# before replacing `password:` with the OIDC id-token permission.
- name: Publish to TestPyPI (dry-run target)
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
uses: pypa/gh-action-pypi-publish@release/v1
+170
View File
@@ -0,0 +1,170 @@
# Python Package CI — gates the `python/` PyO3+maturin wheel (`wifi-densepose`).
#
# ADR-117 (pip modernization) + ADR-185 (SOTA extras). Unlike the frozen
# `archive/v1/` Python app — which is `continue-on-error: true` in ci.yml
# because it is reference-only — the `python/` package is an actively-shipped
# PyPI wheel (published by pip-release.yml). Before this workflow, `python/`
# had ZERO per-PR coverage: pip-release.yml only fires on release triggers
# (tags / dispatch), so a PR could break the wheel build, break a native-Rust
# parity test, or blow the wheel-size budget and nothing in the normal gating
# CI would notice until release day. This workflow closes that gap.
#
# Path-scoped as a DEDICATED workflow rather than a job inside ci.yml. That is
# this repo's own convention for component-scoped CI (cf. firmware-ci.yml,
# sensing-server-docker.yml, bfld-mqtt-integration.yml — all separate files
# with `paths:` triggers). GitHub only supports workflow-level `paths:`, not
# per-job path filters, and no workflow in this repo uses a change-detection
# action (dorny/paths-filter, tj-actions/changed-files) — so the idiomatic,
# no-new-dependency way to scope to `python/**` is a standalone workflow. It
# simply does not run on unrelated PRs.
name: Python Package CI
on:
push:
branches:
- '**'
# NOTE: kept in sync with the pull_request paths below. GitHub Actions
# does not reliably support YAML anchors in workflow files, so the two
# lists are duplicated deliberately rather than aliased.
paths:
- 'python/**'
- 'v2/crates/wifi-densepose-core/**'
- 'v2/crates/wifi-densepose-vitals/**'
- 'v2/crates/wifi-densepose-bfld/**'
- 'v2/crates/wifi-densepose-aether/**'
- 'v2/crates/wifi-densepose-mat/**'
- 'v2/crates/wifi-densepose-train/**'
- 'v2/crates/wifi-densepose-signal/**'
- '.github/workflows/python-ci.yml'
pull_request:
paths:
- 'python/**'
- 'v2/crates/wifi-densepose-core/**'
- 'v2/crates/wifi-densepose-vitals/**'
- 'v2/crates/wifi-densepose-bfld/**'
- 'v2/crates/wifi-densepose-aether/**'
- 'v2/crates/wifi-densepose-mat/**'
- 'v2/crates/wifi-densepose-train/**'
- 'v2/crates/wifi-densepose-signal/**'
- '.github/workflows/python-ci.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: python-ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# Build the wheel with ALL SOTA features and run the full parity suite.
# `--features sota` = aether + meridian + mat, so the compiled feature
# submodules (wifi_densepose.aether / .meridian / .mat) exist and their
# SHA-256 parity tests against the native-Rust reference actually run
# (test_aether.py / test_meridian.py / test_mat.py import those submodules
# at collection time — without the features they would error, not skip).
parity-tests:
name: Wheel + parity tests (features=sota)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# The python/ crate path-deps v2/crates/* and (transitively via
# train) the vendored ruvector submodule — recursive checkout keeps
# those path deps resolvable, matching the rust-tests job in ci.yml.
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
with:
workspaces: |
v2
python
# Fast-fail with per-crate attribution BEFORE the heavier maturin build,
# so a break in a binding-backing crate is reported as "this crate failed
# to build" rather than buried in a maturin link error. These are the
# crates the [aether]/[mat]/[meridian] compiled extras link.
- name: Build binding-backing crates
working-directory: v2
env:
CARGO_PROFILE_DEV_DEBUG: "0"
run: cargo build -p wifi-densepose-aether -p wifi-densepose-mat -p wifi-densepose-train
# maturin develop needs an active virtualenv; create one and expose it
# to the later steps via GITHUB_PATH so `maturin` / `pytest` resolve to
# it. Test deps: pytest-asyncio (client tests are async, asyncio_mode
# auto), numpy (test_bfld), websockets + paho-mqtt (the [client] extra
# used by test_client_*).
- name: Create venv + install maturin and test deps
run: |
python -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
pip install "maturin>=1.7,<2.0" pytest pytest-asyncio numpy websockets paho-mqtt
echo "VIRTUAL_ENV=$PWD/.venv" >> "$GITHUB_ENV"
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
- name: Build + install wheel (maturin develop --features sota)
working-directory: python
env:
CARGO_PROFILE_DEV_DEBUG: "0"
run: maturin develop --features sota
- name: Run parity + binding tests
run: pytest python/tests/ -q
# Numeric enforcement of the ADR-117 §5.4 default-wheel budget. A fix-marker
# can guard the CONFIG that keeps the wheel small (empty default features,
# optional SOTA deps, strip=true — see RuView#1387-default-wheel-budget-config
# in scripts/fix-markers.json) but it cannot measure bytes. This job builds
# the DEFAULT (no-features) wheel and fails if it exceeds the budget — the
# real guard against a dependency silently ballooning the shipped wheel.
wheel-size-budget:
name: Default wheel <= 5 MiB (ADR-117 §5.4)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
with:
workspaces: python
- name: Install maturin
run: python -m pip install --upgrade pip "maturin>=1.7,<2.0"
- name: Build default wheel and assert size budget
working-directory: python
run: |
set -euo pipefail
maturin build --release --out dist
whl="$(ls dist/*.whl | head -1)"
bytes="$(stat -c%s "$whl")"
limit=$((5 * 1024 * 1024)) # ADR-117 §5.4: 5 MiB
printf 'Default wheel: %s = %s bytes (%s MiB); budget = %s bytes\n' \
"$whl" "$bytes" "$((bytes / 1024 / 1024))" "$limit"
if [ "$bytes" -gt "$limit" ]; then
echo "::error::default wheel is $bytes bytes, over the ADR-117 §5.4 $limit-byte (5 MiB) budget"
exit 1
fi
echo "Default wheel is within the 5 MiB budget."
+137
View File
@@ -0,0 +1,137 @@
# ADR-265 D2 — publish only from CI, with provenance.
#
# Manual `npm publish` from laptops stops: this workflow re-runs the ADR-265 D1
# gate for the selected package and then publishes with npm provenance
# attestations (OIDC), tying every published version to a public commit +
# workflow run — the npm-side analogue of the ADR-028 witness bundle.
#
# Requires: NPM_TOKEN repo secret (an npm automation token), or npm Trusted
# Publishing configured for the package (in which case the token is unused).
name: ruview npm release
on:
workflow_dispatch:
inputs:
package:
description: 'Package directory to publish'
required: true
type: choice
options:
- harness/ruview
- tools/ruview-mcp
dist_tag:
description: 'npm dist-tag'
required: false
default: 'latest'
type: string
permissions:
contents: read
id-token: write # npm --provenance
jobs:
publish:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ inputs.package }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Install
run: |
if [ -f package-lock.json ]; then npm ci; else npm install --no-fund --no-audit; fi
- name: Build (if present)
run: npm run build --if-present
- name: Test
run: npm test --if-present
# ADR-265 D3 — package.json is the only place a version string lives.
- name: Version-literal gate
run: |
set -euo pipefail
hits=""
for d in src bin; do
if [ -d "$d" ]; then
hits+=$(grep -rEn '\b[0-9]+\.[0-9]+\.[0-9]+\b' "$d" | grep -vE '127\.0\.0\.1|0\.0\.0\.0' || true)
fi
done
if [ -n "$hits" ]; then
echo "Hardcoded version-like literals found (read package.json instead — ADR-265 D3):"
echo "$hits"
exit 1
fi
# ADR-265 D1.3 — pack-content gate: no maps AND the per-package
# unpacked-size budget (the budgets that npm-packages.yml enforces).
- name: Pack gate (no maps + size budget)
run: |
set -euo pipefail
case "${{ inputs.package }}" in
# ADR-263: dependency-free harness; budget guards against dep creep.
harness/ruview) export UNPACKED_BUDGET=65536 ;;
# ADR-264 O2: map-free tarball (was 188 kB with maps).
tools/ruview-mcp) export UNPACKED_BUDGET=140000 ;;
*) echo "Unknown package '${{ inputs.package }}' — no budget defined"; exit 1 ;;
esac
npm pack --dry-run --json 2>/dev/null | node -e "
const [info] = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const budget = Number(process.env.UNPACKED_BUDGET);
const maps = info.files.filter((f) => f.path.endsWith('.map'));
if (maps.length > 0) {
console.error('Tarball contains source maps (ADR-264 F2):', maps.map((m) => m.path));
process.exit(1);
}
if (info.unpackedSize > budget) {
console.error(\`Unpacked size \${info.unpackedSize} B exceeds budget \${budget} B\`);
process.exit(1);
}
console.log(\`pack gate OK: \${info.files.length} files, \${info.unpackedSize} B unpacked (budget \${budget} B), 0 maps\`);
"
# ADR-265 D1.4 — install the real tarball and drive each bin/export.
- name: Tarball smoke test
run: |
set -euo pipefail
TGZ="$PWD/$(npm pack --silent 2>/dev/null | tail -1)"
SMOKE="$(mktemp -d)"
cd "$SMOKE"
npm init -y > /dev/null
npm i --no-fund --no-audit "$TGZ"
case "${{ inputs.package }}" in
harness/ruview)
./node_modules/.bin/ruview --version
./node_modules/.bin/ruview doctor
# the honesty gate must fail closed on empty input (ADR-263 F1)
if ./node_modules/.bin/ruview claim-check; then
echo 'claim-check passed with no input — fail-open regression'; exit 1
fi
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
;;
tools/ruview-mcp)
# initialize over stdio; server must answer and exit 0 on EOF
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}\n' \
| timeout 30 ./node_modules/.bin/rvagent | grep -q '"serverInfo"'
# the ESM export must resolve from the installed tarball (ADR-264 F1)
timeout 30 node --input-type=module -e "await import('@ruvnet/rvagent');" < /dev/null
;;
esac
- name: Claim-check README
run: |
if [ -f README.md ]; then
node "$GITHUB_WORKSPACE/harness/ruview/bin/cli.js" claim-check --file README.md
fi
- name: Publish (with provenance)
run: npm publish --provenance --access public --tag "${{ inputs.dist_tag }}"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+7 -4
View File
@@ -115,7 +115,7 @@ jobs:
# RUN guard catches missing ones at build time, this re-checks the
# pushed artifact post-hoc as belt-and-braces).
# 2. /health is up.
# 3. /api/v1/info returns 200 with no auth (LAN-mode default).
# 3. /api/v1/info returns 200 with the explicit trusted-LAN opt-in.
# 4. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a
# Bearer header, 200 with the correct one (the #443 auth middleware).
# ---------------------------------------------------------------------
@@ -124,11 +124,14 @@ jobs:
set -euo pipefail
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
docker pull "$IMAGE"
docker run --rm "$IMAGE" sh -c \
docker run --rm --entrypoint sh "$IMAGE" -c \
'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
docker run --rm --entrypoint sh "$IMAGE" -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
docker run -d --name sm -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE"
docker run -d --name sm -p 3000:3000 \
-e CSI_SOURCE=simulated \
-e RUVIEW_ALLOW_UNAUTHENTICATED=1 \
"$IMAGE"
# Wait up to 30 s for /health.
for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
+21
View File
@@ -7,7 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha``5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. Pip-release now authenticates via Trusted Publishing (see the entry below). **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload (ADR-184 P3) is still gated on the one-time manual Trusted Publisher registration on pypi.org that only the repo owner can perform. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client).
- **CI (ADR-184): `pip-release.yml` publish job migrated to PyPI OIDC Trusted Publishing** (commit `cc153e8b5`; refs #785, completes ADR-117). The release workflow now authenticates to PyPI via short-lived OIDC tokens (`id-token: write`) instead of a long-lived `PYPI_API_TOKEN` secret. **Not yet active**: publishing will fail until the matching Trusted Publisher is registered manually on pypi.org (a one-time, per-project step that cannot be automated from CI) — ADR-184 P1 tracks this as the remaining gate (status recorded in `dfc4c1abd`).
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
### Deprecated
- **`archive/v1` (the original pure-Python implementation) formally deprecated (ADR-187)** — commits `1fb5397dd`, `b1417fb6e`; refs #509, #1125. Added `archive/v1/DEPRECATED.md` (a loud tombstone) and a `> ⚠️ DEPRECATED` notice atop `archive/v1/README.md`, both pointing at the maintained `v2/` workspace and the `wifi-densepose 2.x` / `ruview` pip wheel (ADR-117). Records the honest fact behind #509: `archive/v1`'s `DensePoseHead` is **architecture-only** — random `kaiming_normal_` init with **zero committed checkpoints** under `archive/v1/` (MEASURED by Glob over `**/*.{pth,onnx,safetensors,pt,ckpt,bin}`). The ADR-028 deterministic proof `archive/v1/data/proof/verify.py` stays live and is explicitly out of scope. The same effort added a **"Model weights: what's real, what's not" three-tier table** to `README.md` + `docs/user-guide.md`, separating real-and-validated checkpoints (presence 82.3% held-out temporal-triplet, MM-Fi pose 82.69% torso-PCK@20, `count_v1`) from the real-but-weak on-device `pose_v1` (PCK@20 = 3.0%, runtime `confidence=0` stub, below the ADR-079 ≥35% target) from the architecture-only `archive/v1` head — and caveated every live single-ESP32 17-keypoint advertisement accordingly. Docs/labeling only; no code or model behavior changed.
### Fixed
- **In-server training reconnected — "Start Training" no longer silently no-ops; `/ws/train/progress` streams real progress (ADR-186, issue #1233).** The dashboard's Start Training button POSTed a config, got `success:true`, and nothing happened: `/api/v1/train/start` was a stub that flipped a status string and logged one line, and `/ws/train/progress` 404'd. The full pure-Rust trainer in `training_api.rs` (loads recorded CSI, gradient-descent, exports a `.rvf`) already existed but was **orphaned** — never declared as a module (no `mod training_api;`), so it wasn't compiled at all. Fix (`wifi-densepose-sensing-server`): declared the module, reconciled `AppStateInner` (replaced the `training_status`/`training_config` stub fields with a shared `TrainingState` status handle + cooperative cancel flag + a `training_progress_tx` broadcast), deleted the stub handlers, and merged the real `training_api::routes()` (so `/api/v1/train/{start,stop,status,pretrain,lora}` and `/ws/train/progress` resolve under the existing `/api/v1/*` bearer gate). The training core was decoupled from the ~60-field server state so it is unit-testable. **P5 honesty guarantee:** with `RUVIEW_DISABLE_SERVER_TRAINING` set, start returns a structured `{enabled:false, cli:"wifi-densepose train-room"}` HTTP 409 — never a silent success — and the dashboard disables the Start buttons with a CLI tooltip (enablement is surfaced on `/api/v1/train/status`). Pinned by 8 new tests incl. a **live-socket** test that completes a genuine 101 WebSocket handshake and receives a real progress frame after a POST start, a full POST→poll-status→`.rvf`-exists round-trip, a path-traversal rejection, cancellation, and the disabled-409 path. `cargo test -p wifi-densepose-sensing-server -p wifi-densepose-train --no-default-features` — 0 failed.
- **FastAPI health/metrics endpoints event-loop starvation.** Calling `psutil.cpu_percent(interval=1)` blocked the single-threaded async event loop for 1.0 second on every health check or metrics collection tick, stalling all incoming requests and WebSocket operations. Fixed by changing `cpu_percent` to use non-blocking `interval=None` and offloading all blocking OS metrics gathering to background thread pools via `asyncio.to_thread`. Verified event loop responsiveness via concurrency regression tests.
- **EngineBridge now honors `WDP_GUARD_INTERVAL_US`/`WDP_SOFT_GUARD_US`/`WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US`** (#1309, PR #1312, @erichkusuki). The governed trust path previously built its multistatic fuser from a hardcoded `MultistaticConfig::default()` (60 ms guard), so multi-node deployments with WiFi/ESP-NOW time sync (10150 ms drift) failed every governed cycle regardless of configuration — while the startup log claimed the override took effect. New `StreamingEngine::set_multistatic_config()`; `EngineBridge::new()` takes an `Option<MultistaticConfig>` threaded from the same env-derived config as `AppState.multistatic_fuser`. Hardware-verified on a live 2-node ESP32-S3 setup (90 s window, 0 fusion errors; previously every cycle failed).
- **`/api/v1/stream/pose` WebSocket reachable with `RUVIEW_API_TOKEN` set + dashboard bearer-token field** (#1310, PR #1313, @erichkusuki). Browsers cannot attach an `Authorization` header to a WS upgrade, so the Live Demo pose stream always failed when auth was on; the path is now on a narrow exact-match exemption list (mirrors `/ws/sensing`), with a regression test pinning that the exemption doesn't leak to other `/api/v1/*` paths. The QuickSettings panel gains an "API Access" field storing the bearer token in `localStorage`; the token is applied at `api.service.js` module load so the very first request carries it.
- **Display-less DevKitC-1 boards: `sdkconfig.defaults.devkitc` build overlay** (#1308, PR #1311, @erichkusuki). The ADR-045 runtime display probe false-positives on stock ESP32-S3-DevKitC-1 (floating QSPI pins), which silently skipped the RuView#893 MGMT+DATA CSI upgrade and collapsed CSI yield to 0 pps. The overlay compiles display support out (`has_display` constant-false). Also fixes stale `espressif/idf:v5.2` README references to v5.4 (source requires `esp_driver_uart`, IDF ≥5.3). Hardware-verified on 2× DevKitC-1-N16R8 (0 → 4045 pps).
- **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn``ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `<jobsDir>/<id>.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20.
- **Empty-room field-model calibration collected nothing on real HT40 nodes — raw 128-wide frames rejected by the 56-tone model (follow-up to the deadlock fix below).** Once the status-gate deadlock was fixed, `maybe_feed_calibration` reached `feed_calibration`, but a real ESP32 HT40 node streams 128-wide amplitude vectors while the single-link `FieldModel` is the canonical 56-tone grid — so `LinkStats::update` returned `DimensionMismatch`, `feed_calibration` bubbled the error, and `maybe_feed_calibration` swallowed it at `debug` level. Net effect: `frame_count` stayed pinned at 0 on live hardware even though presence/motion/vitals (which read the global history) worked fine. Fixed by resampling each frame onto the model's canonical 56-tone grid via `HardwareNormalizer::resample_to_canonical` before feeding — the same length-only canonicalization the multistatic fusion path uses (#1170). Pinned by `field_bridge::maybe_feed_calibration_resamples_wide_frames_and_accumulates` (128-wide frame → Collecting + count 1; fails on old code). Verified live on the ESP32-S3 deployment.
- **Empty-room field-model calibration could never start — `/api/v1/calibration/*` was a dead endpoint (frame count pinned at 0).** `POST /calibration/start` creates the `FieldModel` in `Uncalibrated`, but the per-frame server feed `field_bridge::maybe_feed_calibration` only fed observations while the model was **already** `Collecting` — and the *only* thing that sets `Collecting` is `feed_calibration` itself (on its first fed frame). The two gates deadlocked: nothing ever fed the first frame, so `calibration_frame_count` stayed 0, `status` never left `Uncalibrated`, and the SVD room eigenstructure (eigenvalue-based person counting / localization) could never calibrate — observed live as `{"status":"Uncalibrated","frame_count":0}` never advancing on a streaming ESP32 node. Fixed the guard to feed while `Uncalibrated | Collecting` so the first frame flips the model to `Collecting` and the count advances. Also made `calibration_stop` return a structured `{success:false, frame_count, frames_needed}` (with a new `FieldModel::min_calibration_frames()` accessor) instead of an opaque 500 when finalized before enough empty-room frames accumulate. Pinned by `field_bridge::maybe_feed_calibration_advances_uncalibrated_to_collecting` (asserts `Uncalibrated → Collecting` + count 0 → 1 → 2; fails on old code). Presence/motion/vitals were unaffected — they use the separate auto rolling baseline, not the field model.
- **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed.
- **`csi_fps_ema` reported the CSI frame rate 40840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`.
- **`stream_sender` ENOMEM backoff starved low-rate control packets under a weak uplink (#1183, follow-up to #1135/#1159).** The global `s_backoff_until_us` gate (triggered by the 50 Hz CSI flood at weak RSSI) also suppressed the ≤48 B, ≤1 Hz `feature_state` / mesh `HEALTH` / sync packets that contribute negligible buffer pressure, so telemetry failed essentially every cycle. Added `stream_sender_send_priority()` — bypasses the backoff gate, reports ENOMEM quietly, and never extends/resets the global streak — and routed `feature_state`, HEALTH/anomaly (`rv_mesh_send`), and sync packets through it. Also fixed the misleading `"HEALTH sent"` log that printed unconditionally even when `rv_mesh_send` returned `ESP_FAIL` (now prints `sent`/`FAILED` from the actual return). Firmware builds clean (ESP-IDF v5.4).
- **Multistatic fusion guard interval is now operator-configurable — fixes permanent trust demotion with WiFi-synced ESP32 nodes (#1049).** Two independently-clocked ESP32-S3 boards on ESP-NOW sync drift 10150 ms (typ. ~70 ms) — the 100 ms beacon + WiFi-MAC jitter cannot hold them within the published 60 ms default guard, so the governed-trust cycle permanently demoted to `Restricted`, suppressed all pose output, and spun the error counter to 200k+ with **no escape hatch but a container restart**. Added a **direct `WDP_GUARD_INTERVAL_US` override** (+ optional `WDP_SOFT_GUARD_US`) to `multistatic_guard_config_from_env`, so a deployment can lift the hard guard past its measured spread (e.g. `WDP_GUARD_INTERVAL_US=200000`) without having to know its exact TDM schedule. Precedence is most-specific-wins: a direct override beats the existing `WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US` schedule-derived guard, which beats the 60 ms/20 ms default; the override is applied on top of whichever base is selected, the soft band is always clamped strictly below the hard guard, and a malformed/zero value is ignored (falls back to the base rather than breaking fusion). The effective guard is now logged at startup. Pinned by 6 new tests (`multistatic_guard_config_tests`): direct-override-wins / beats-TDM-derived / soft-clamped-below-hard / lowering-hard-pulls-soft-down / malformed-or-zero-falls-back / default-when-unset. `wifi-densepose-sensing-server` bin tests **449 → 455**, 0 failed; Python proof VERDICT PASS, hash unchanged (off the signal proof path).
### Security
@@ -23,6 +42,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`homecore-recorder` security review (ADR-132 surfaces) — two real bounding fixes; SQL-injection & NaN-index dimensions confirmed clean with evidence.** Beyond-SOTA review of the HA-compat state recorder (DB persistence + history + ruvector semantic search), the crux being its DB-backed SQL-injection surface. **Findings + fixes:** (1) **Memory-DoS — unbounded `get_state_history`.** The history query carried no `LIMIT`, so a wide `[since, until]` window over a high-frequency entity (a per-second sensor ≈ 86k rows/day) would load an unbounded row set into a single in-memory `Vec`. Added a hard `LIMIT MAX_HISTORY_ROWS` (1,000,000 — generous enough never to truncate a realistic history graph, bounded enough to cap the worst case); the sibling search paths were already `k`-bounded. (2) **Disk-DoS / documented-but-missing `purge`.** The README + HA-compat table advertised `Recorder::purge(older_than)` as a capability, but **no such method existed** — i.e. no retention path at all → unbounded disk growth. Implemented a **transactional** `purge` that deletes `states` + `events` strictly **older than** the cutoff (**exclusive** boundary — idempotent, no off-by-one; a row at the cutoff instant is kept) and **garbage-collects** orphaned `state_attributes` blobs (a dedup-shared blob is dropped only once its last referencing state is gone); all three deletes run in one transaction so a mid-purge failure rolls back cleanly (no states-deleted-but-events-kept corruption). **Confirmed clean with evidence:** SQL injection — **every** query in `db.rs` uses bound `?` parameters (no `format!`/string-concat of user data into SQL); the lone `format!` builds the LIKE *pattern*, which is itself bound as a parameter with `ESCAPE '\\'` and metacharacter escaping. Pinned: a state value `'; DROP TABLE states; --` is stored/queried **literally** (table survives), and a `%`/`_` in a search query matches **literally**, not as a wildcard. NaN-index poisoning (the calibration/vitals/geo class) — **structurally impossible** here: embeddings are SHA-256 → `i32``f32` (an `i32` cast to `f32` is always finite, never NaN/Inf), with an all-zero-digest norm guard; probed empty-index search, empty-string query, and `k=0` — all return `Ok(0)`, **no panic**. Fail-closed write path — a removal event yields `Ok(None)`, semantic-index failure is logged not propagated (best-effort, never blocks the durable SQLite write), and `EntityId` parsing failures fall back rather than panic. **6 new pinning tests** (SQL-injection literal-storage, LIKE-metacharacter literalness, history `LIMIT`, purge exclusive-boundary, purge attribute-GC-keeps-shared, purge old-events): `homecore-recorder` **19 → 25** (`--no-default-features`) / **25 → 31** (`--features ruvector`), 0 failed; the purge-boundary test is a true pin (fails deleting 2 rows under an inclusive cutoff, passes deleting 1 under the exclusive cutoff). Behaviour otherwise unchanged; Python deterministic proof unchanged (recorder is off the signal proof path).
### Added
- **ADR-184 / ADR-185 / ADR-186 / ADR-187 decision records added under `docs/adr/`** (indexed in the ADR README, count corrected to 193 — commit `cca5bd811`). **ADR-184** (PyPI Trusted Publishing, completes ADR-117) — Proposed; its CI migration has landed but is pending pypi.org registration (see Changed). **ADR-185** (P6 Python bindings for AETHER/MERIDIAN/MAT) and **ADR-186** (training progress API, refs #1233) are **Proposed only** — decision records for work not yet implemented on this branch. **ADR-187** (archive/v1 deprecation + model-weights honest labeling) — Accepted and implemented (see Deprecated).
- **ADR-263/264/265: deep review of the RuView npm surface (`@ruvnet/ruview`, `@ruvnet/rvagent`, `@ruv/ruview-cli`) with optimization strategies recorded as ADRs.** ADR-263 reviews the published `@ruvnet/ruview@0.1.0` harness: fail-open `claim-check` on empty input (HIGH), `spawnSync` head-of-line blocking of the MCP stdio server during long `verify`/`calibrate` runs (HIGH), optionalDependencies tripling the cold `npx` install for a code path that never uses them (MEASURED, `npm i` in a clean prefix: 4 packages / 620 kB / 71 files default vs 1 package / 172 kB / 22 files with `--omit=optional`), 1 MiB `maxBuffer` truncation risk, `python -c` port-interpolation surface in `node_monitor`, hardcoded MCP server version, duplicated skill payload — optimizations O1O8. ADR-264 reviews `@ruvnet/rvagent@0.1.0` + the private CLI **against the published registry tarball**: `exports.require` → nonexistent `dist/index.cjs` (HIGH, every CJS consumer breaks), 44 dead source-map files = 62,698 B of the 188 kB unpacked payload pointing at unshipped `../src` (MEASURED), stdio-only server described as "dual-transport" (CLAIMED capability), mixed dot/underscore tool naming, double Zod validation + hand-duplicated advertised schemas, 2-fd leak per training job, unbounded request body in the unwired HTTP scaffold, dead `detectCogBinary` candidate list, `ruview` bin-name collision — optimizations O1O9. ADR-265 adds the cross-cutting distribution layer: an `npm-packages.yml` CI matrix (tests + pack-content/size gate + tarball-install smoke test — none of the three packages currently has any CI, and `ci.yml` pins Node 18 against `engines >= 20`), publish-from-CI-only with `npm publish --provenance`, version single-sourcing from package.json, bin/namespace ownership (the `ruview` bin belongs to `@ruvnet/ruview`), and claim-check enforcement on package READMEs/descriptions. Docs only — no runtime code changed; the findings are the work orders for the follow-up PRs.
- **ADR-131 §11–§12: HOMECORE-UI wired to a real backend — single-origin BFF gateway + production front-end (no mock in prod).** Implements the §11 wiring decision so the dashboard stops rendering fabricated data. **Front-end (DONE + verified under Node):** `api.js` rewritten so every data accessor is async and calls the §11.2 gateway routes; the in-browser mock is demoted to a **dev-only fixture** reachable only via `?demo=1`/`HOMECORE_UI_DEMO` (§2.2); all ten panels now `await` and render a **typed empty/error state** on upstream failure (no mock fallback in production) — 3 panels converted by hand, 7 via a parallel agent swarm. **New `homecore-server` BFF gateway (`src/gateway.rs`, compile-pending — no Rust toolchain in the authoring env):** promotes `homecore-server` to the single origin (§2.1); adds `/api/homecore/*` + `/api/cal/*` merged into `build_app`, with `reqwest` + CLI/env flags (`--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`). Real handlers: calibration **reverse-proxy** (W2), `GET /api/homecore/rooms` with the §11.3 **RoomState adapter** (`breathing``breathing_bpm`, `heartbeat``heart_bpm`, `None``null` preserving not-trained-vs-withheld, injected `anomaly.threshold`/`room_id`), **COG supervisor** over `/var/lib/cognitum/apps/` (W4), and **appliance metrics** from `/proc` + TCP service probes (W6); SEED-device/appliance routes (seeds/federation/witness/privacy/settings/automations/events-history/hailo/tokens — W3/W5) return a typed `503 upstream_unavailable` and the UI shows error states. **Tests:** front-end **5 files green** — import-graph, boot, render-smoke (22), interaction (3), and a **new prod-errors suite (13)** that runs with demo OFF + gateway unreachable and proves every panel renders an error state, never mock, never throws (it caught + fixed a real unhandled-rejection in the events automation builder). **Gateway compiled, tested, and run on Rust 1.89:** `cargo test -p homecore-server --no-default-features` = **12/12 pass** (6 gateway + 6 UI mount); the binary was **run live**`GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes, unauth → `401`, `cogs``[]` (no apps dir), SEED-tier → typed `503`, and against a mock calibration upstream the `/api/cal/*` proxy passes through (`200`) and `GET /api/homecore/rooms` adapts `RoomState` to the UI shape (`breathing``breathing_bpm`, `heartbeat:null``heart_bpm:null`, injected `anomaly.threshold`/`room_id`). **Live testing caught + fixed a real bug** — a double-`v1` segment in the `/api/cal/*` proxy URL. **Remaining (intrinsic, not an env limit):** W3/W5/W6-Hailo/federation depend on services/hardware **not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, Hailo stat source), so they return honest `503`s rather than fabricate data; W1/W2/W4/W6-appliance are functional now. ADR-131 §10/§12.1 updated with per-wave status.
- **ADR-131: HOMECORE-UI — the complete operational dashboard for the two-tier Cognitum stack, served by `homecore-server` at `/homecore`.** A zero-dependency, no-build-step vanilla TS/JS + CSS frontend (the `rufield-viewer` "Axum + vanilla-JS" pattern) that extends the Cognitum Appliance shell as a first-class nav section (Framework | Guide | Cog Store | **HOMECORE** | Status). **Complete, not a scaffold** (per the ADR's revised §2/§7): all **10 panels** ship fully built and rendered — §4.1 System Dashboard (v0 Appliance health strip + SEED fleet grid + ESP32 summary + COG status row + event-bus sparkline), §4.2 SEED Detail (vector store / witness chain / 5 onboard sensors / reflex rules / cognitive-fragility / ingest packet-type), §4.3 SEED Fleet Map (Appliance→SEED→ESP32 hierarchy, ESP-NOW mesh, cross-SEED fusion badges, ADR-105 federation), §4.4 Entity & State Browser (domain-grouped, **live WebSocket `subscribe_events` patching — never polls**, first-class provenance badges, keyword filter, context-causality slide-over), §4.5 RoomState/Sensing (mixture-of-specialists), §4.6 COG Management + App Registry, §4.7 Calibration Wizard (5-step baseline→enroll→train→verify), §4.8 Event Bus + Automation builder, §4.9 Witness/Audit log (two-tier SHA-256 + Ed25519 timeline, privacy-mode banner, pagination, export), §4.10 Settings. **Design system is the exact production Cognitum palette** (`tokens.css` carries `--cyan #4ecdc4``--r 10px` verbatim, §3.1) so there is no visual seam with the Cog Store (§3.3 invariant). **§6 UX invariants enforced in code and pinned by tests:** tier-origin provenance is always-visible (never collapsed); `stale`/`vetoed` flags and the kNN fragility score are prominent (amber/red tint + banners, never grey-on-grey); a `null` specialist renders "Not trained / calibrate to enable" **visually distinct from** veto-`withheld` (rendered as explicitly withheld, never zero) **distinct from** an error; all IDs/hashes/endpoints/payloads use `--mono`; Hailo-sourced COGs (`arch: hailo10`) are visually distinguished from CPU-only (`arch: arm`). **Wiring:** `homecore-server` gains a `--ui-dir`/`HOMECORE_UI_DIR` flag and mounts the assets via `tower-http` `ServeDir` at `/homecore` alongside the unchanged HA-compat `/api` surface (new testable `build_app()`), with **5 Rust integration tests** (`#[cfg(test)] mod ui_tests`, `tower::oneshot`) asserting index / design tokens / all-10-panels are served, the API coexists, and an empty `--ui-dir` disables the mount. **JS test + benchmark suite (`ui/`, runs under plain `node`, no npm install): 24 checks / 0 failed** — an import/export graph verifier (15 modules consistent), a DOM-shim render-smoke that *executes every panel* (21 checks: ui helpers + mock contracts + all 10 panels render without throwing), and an interaction suite (3 checks: live WS state-patch, ws.js handshake/parse, calibration backend contract). **Benchmark:** total bundle **136.8 KB uncompressed across 18 files — ~37× smaller than HA's ~5 MB Lit bundle** (the ADR-126 §1.1 foil), slowest panel **1.5 ms/cold-render**. **Honest scope (§7.1):** the live HOMECORE REST API (`/api/config|states|services`) and the WebSocket `subscribe_events` feed are driven for real; panels whose backing service is **not** in this binary (SEED HTTPS API, calibration ADR-151, ADR-105 federation) render against a **contract-conformant mock layer flagged with a DEMO banner** and swap to live the moment those endpoints land — no mock data is ever presented as real. **Not verified in this environment:** the Rust crate was edited and the integration tests written but **not compiled/run here** (no Rust toolchain present); `cargo test -p homecore-server` + `cargo build` must be run on a Rust host before merge.
- **ADR-175: int8 quantization of the WiFlow-STD "half" pose model — MEASURED fp32-vs-int8 accuracy/size trade-off (honest negative).** Sub-deliverable 8.2 of the benchmark/optimization milestone, and the reading of the SOTA brief's "one untested edge lever" (QAT-int8 on the 843,834-param half model that strictly dominates the published 2.23M model). A new committed script `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py` quantizes `half_best.pth` to int8 two ways and scores both with the **same** upstream `calculate_pck`/`calculate_mpjpe` that produced the fp32 sweep numbers, under **one locked normalization** (ADR-173 torso-diameter PCK — neck idx2→pelvis idx12, `use_torso_norm=True`, the standard MM-Fi/GraphPose-Fi convention), on the **same** seed-42 file-level 70/15/15 test split (52,560 NaN-free / 54,000 full windows). **MEASURED on ruvultra (RTX 5080, torch 2.11.0+cu128, fbgemm; clean test, torso-PCK):** fp32 = 96.62% PCK@20 / 99.47% PCK@50 / 0.008981 MPJPE / 3.351 MB (fp32-CPU reproduces fp32-GPU to 4 dp, so the int8 deltas are pure quantization, not CPU/GPU drift); **int8 static PTQ = 40.98% PCK@20 (55.64 pp), 1.046 MB** — naive static QDQ **collapses** on this model (the brief's 2.23M "sweet spot" does NOT transfer to the 843k half model at the tight @20 threshold); **int8 QAT (3-epoch FX fake-quant fine-tune from half_best) = 67.48% PCK@20 (29.15 pp) / 98.69% PCK@50 (0.78 pp), 1.043 MB.** **Verdict (honest no):** int8 is **not a win** at the strict PCK@20 edge target — QAT recovers a large share of the PTQ collapse and is near-lossless at the loose PCK@50 (coarse localization survives int8, fine does not), but a **3.2× size win at 29 pp PCK@20** is a bad trade when the half model already fits edge flash at fp32 → **keep fp32/fp16 on the edge for now.** **Disclosed gap:** the QAT *fake-quant* val PCK@20 reached 83.45% but the *converted* int8 model scores 67.48% — a real ~16 pp `convert_fx` gap (fbgemm int8 kernels ≠ straight-through estimate, esp. the axial-attention einsum/softmax); we report the converted-int8 number, not the fake-quant proxy. **MEASURED:** every table number + the PTQ collapse + the QAT partial recovery + the conversion gap. **CLAIMED/not done:** ONNX/TFLite export, on-edge-SoC latency/energy (int8 measured on x86 fbgemm — size transfers, latency does NOT), mixed-precision keeping attention fp32, longer/better-tuned QAT. **Honest limitations:** single in-domain eval split (no cross-environment split), x86-int8 not edge-SoC-int8, lightly-tuned QAT. Additive only — no production Rust or signal-pipeline change; Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — off the signal proof path).
+7 -1
View File
@@ -62,7 +62,7 @@ All 5 ruvector crates integrated in workspace:
- `ruvector-attention``model.rs` (apply_spatial_attention) + `bvp.rs`
### Architecture Decisions
43 ADRs in `docs/adr/` (ADR-001 through ADR-043). Key ones:
182 ADRs in `docs/adr/` (numbered ADR-001 through ADR-265, with gaps). Key ones:
- ADR-014: SOTA signal processing (Accepted)
- ADR-015: MM-Fi + Wi-Pose training datasets (Accepted)
- ADR-016: RuVector training pipeline integration (Accepted — complete)
@@ -77,6 +77,10 @@ All 5 ruvector crates integrated in workspace:
- ADR-148: Drone swarm control system / `ruview-swarm` (In Progress)
- ADR-152: WiFi-Pose SOTA 2026 intake — geometry conditioning, WiFlow-STD benchmark (measurement (a) complete: claims MEASURED-EQUIVALENT at ~96% PCK@20), MAE recipe (Proposed; §2.12.3, 2.6 implemented)
- ADR-153: IEEE 802.11bf-2025 forward-compatibility protocol model (Accepted — amends ADR-152 §2.4)
- ADR-182: `npx ruview` harness minted via MetaHarness (Accepted — P1+P2 shipped as `@ruvnet/ruview`)
- ADR-263: `@ruvnet/ruview` npm harness deep review + optimization strategy (Proposed)
- ADR-264: `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` deep review + optimization strategy (Proposed)
- ADR-265: RuView npm distribution strategy — CI gate, provenance, version single-sourcing (Proposed)
### Supported Hardware
@@ -89,6 +93,8 @@ All 5 ruvector crates integrated in workspace:
**Not supported:** ESP32 (original), ESP32-C3 — single-core, can't run CSI DSP pipeline.
**⚠️ Compact boards (SuperMini, ESP32-S3-Zero, other coin-sized clones) run hot:** the firmware keeps the WiFi radio on continuously (`WIFI_PS_NONE`) and runs a full DSP pipeline (`edge_tier=2`), which is sustained high current draw. Full-size dev boards handle this fine; coin-sized clones with minimal PCB copper and budget regulators can run uncomfortably hot and, per at least one field report, have failed to power on again after a hot session. Give them airflow and check by touch during the first few minutes. See `firmware/esp32-csi-node/README.md` for details.
### Build & Test Commands (this repo)
```bash
# Rust — full workspace tests (1,031+ tests, ~2 min)
+8 -8
View File
@@ -51,26 +51,26 @@ verify-audit:
# ─── Rust Builds ─────────────────────────────────────────────
build-rust:
cd rust-port/wifi-densepose-rs && cargo build --release
cd v2 && cargo build --release
build-wasm:
cd rust-port/wifi-densepose-rs && wasm-pack build crates/wifi-densepose-wasm --target web --release
cd v2 && wasm-pack build crates/wifi-densepose-wasm --target web --release
build-wasm-mat:
cd rust-port/wifi-densepose-rs && wasm-pack build crates/wifi-densepose-wasm --target web --release -- --features mat
cd v2 && wasm-pack build crates/wifi-densepose-wasm --target web --release -- --features mat
test-rust:
cd rust-port/wifi-densepose-rs && cargo test --workspace
cd v2 && cargo test --workspace --no-default-features
bench:
cd rust-port/wifi-densepose-rs && cargo bench --package wifi-densepose-signal
cd v2 && cargo bench --package wifi-densepose-signal
# ─── Run ─────────────────────────────────────────────────────
run-api:
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000
uvicorn archive.v1.src.api.main:app --host 0.0.0.0 --port 8000
run-api-dev:
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
uvicorn archive.v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
run-viz:
python3 -m http.server 3000 --directory ui
@@ -81,7 +81,7 @@ run-docker:
# ─── Clean ───────────────────────────────────────────────────
clean:
rm -f .install.log
cd rust-port/wifi-densepose-rs && cargo clean 2>/dev/null || true
cd v2 && cargo clean 2>/dev/null || true
# ─── Help ────────────────────────────────────────────────────
help:
+27 -6
View File
@@ -58,7 +58,7 @@ RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the
> | 💓 **Heart rate** | Bandpass 0.82.0 Hz, zero-crossing BPM | 40120 BPM, real-time |
> | 👤 **Presence detection** | Trained head on Hugging Face ([`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained); v2 encoder = 82.3% held-out temporal-triplet acc, honestly re-benchmarked) + a phase-variance fallback that needs no model | < 1 ms, ~30 s ambient calibration |
> | 🧬 **CSI embeddings** | 128-dim contrastive encoder shipped on Hugging Face, 4-bit quantised variant fits in 8 KB | **164,183 emb/s** on M4 Pro |
> | 🦴 **17-keypoint pose estimation** | `cog-pose-estimation` Cog v0.0.1 — signed aarch64 + x86_64 binaries on GCS, loads `pose_v1.safetensors` via Candle. Train your own from paired data in 2.1 s on an RTX 5080 ([ADR-101](docs/adr/ADR-101-pose-estimation-cog.md), [benchmarks](docs/benchmarks/pose-estimation-cog.md)). **SOTA on MM-Fi:** [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) hits **82.69% torso-PCK@20** (ensemble 83.59%), beating MultiFormer (72.25%) and CSI2Pose (68.41%) on the matched MM-Fi `random_split` protocol — self-corrected and auditable on [AetherArena](https://huggingface.co/spaces/ruvnet/aether-arena) | 8.4 ms cold-start on a Pi 5 |
> | 🦴 **17-keypoint pose estimation** | `cog-pose-estimation` Cog v0.0.1 — signed aarch64 + x86_64 binaries on GCS, loads `pose_v1.safetensors` via Candle (the committed `pose_v1` is a **first-cut** on-device model: PCK@20 = 3.0%, below the ADR-079 ≥35% target, and its runtime path is still a `confidence=0` stub — see [Model weights: what's real, what's not](#model-weights-whats-real-whats-not); the **82.69%** figure below is the separate published MM-Fi benchmark, not this live cog). Train your own from paired data in 2.1 s on an RTX 5080 ([ADR-101](docs/adr/ADR-101-pose-estimation-cog.md), [benchmarks](docs/benchmarks/pose-estimation-cog.md)). **SOTA on MM-Fi:** [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) hits **82.69% torso-PCK@20** (ensemble 83.59%), beating MultiFormer (72.25%) and CSI2Pose (68.41%) on the matched MM-Fi `random_split` protocol — self-corrected and auditable on [AetherArena](https://huggingface.co/spaces/ruvnet/aether-arena) | 8.4 ms cold-start on a Pi 5 |
> | 🚶 **Motion / activity** | Motion-band power + phase acceleration | Real-time |
> | 🤸 **Fall detection** | Phase-acceleration threshold + 3-frame debounce + 5 s cooldown ([#263](https://github.com/ruvnet/RuView/issues/263)) | < 200 ms |
> | 🧮 **Multi-person count** | Adaptive P95 normalisation + runtime-tunable dedup factor (`/api/v1/config/dedup-factor`, [#491](https://github.com/ruvnet/RuView/pull/491)). Six specialised learned counters available as Cogs: `occupancy-zones`, `elevator-count`, `queue-length`, `customer-flow`, `clean-room`, `person-matching` | Real-time, self-calibrating |
@@ -128,10 +128,12 @@ pip install "ruview[client]" # or: pip install "wifi-densepose[clie
>
> | Option | Hardware | Cost | Full CSI | Capabilities |
> |--------|----------|------|----------|-------------|
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Presence, motion, breathing, heart rate, fall detection, multi-person counting, 17-keypoint pose (signed Cog binary), 105-cog catalog, persistent vector store, kNN search, witness chain, MCP proxy |
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Presence, motion, breathing, heart rate, fall detection, multi-person counting, 17-keypoint pose (signed Cog binary — first-cut on-device model, see [Model weights: what's real, what's not](#model-weights-whats-real-whats-not)), 105-cog catalog, persistent vector store, kNN search, witness chain, MCP proxy |
> | **ESP32 Mesh** | 3-6× ESP32-S3 + WiFi router | ~$54 | Yes | Same capabilities as above without the persistent-memory features |
> | **ESP32-C6 research node** ([ADR-110](docs/adr/ADR-110-esp32-c6-firmware-extension.md), [witness](docs/WITNESS-LOG-110.md), [reviewer guide](docs/ADR-110-REVIEW-GUIDE.md), [firmware v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32)) | ESP32-C6-DevKit ($610) | ~$10 | Yes (Wi-Fi 6 capable) | Same CSI pipeline as S3 with the dual-target firmware. **Firmware-side ADR-110 substrate now closed** (v0.7.0): ESP-NOW cross-board mesh quantified at **99.56 % match / 104 µs smoothed offset stdev / 3.95× EMA suppression** over a 5-min two-board soak (witness §A0.10), 32-byte UDP sync packet with operator-tunable cadence (§A0.12), ADR-018 byte 19 bit 4 wire-fix sourced from the working ESP-NOW path (§A0.13). Wire format ready for HE-LTF PPDU tagging in ADR-018 bytes 18-19 (firmware encoder + Rust + Python decoders verified end-to-end across 23 unit tests). LP-core motion-gate RISC-V program and Wi-Fi 6 soft-AP with TWT Responder both ship as opt-in code paths (default off). **Hardware-gated for measurement**: HE-LTF live subcarrier capture needs an 11ax AP (IDF v5.4 doesn't expose AP-side HE config — §A0.6); ~5 µA LP-core hibernation needs an INA meter to capture; 802.15.4 raw RX is broken in IDF v5.4 (workaround: ESP-NOW transport, shipped + measured). See witness log for the empirical / claimed split. |
> | **Research NIC** | Intel 5300 / Atheros AR9580 | ~$50-100 | Yes | Full CSI with 3x3 MIMO |
> | **Qualcomm CSI beta** ([ADR-268](docs/adr/ADR-268-qualcomm-atheros-csi-platform.md)) | QCA9300 now; QCN9074/QCN9274 experimental | ~$30-200 | Simulator now; hardware adapter gated | Rust `QCS1` codec, deterministic replay, UDP/API integration; modern ath11k/ath12k profiles do not claim public CSI export |
> | **Vendor provider beta** ([ADR-270](docs/adr/ADR-270-vendor-rf-sensing-integration-program.md)) | Origin, Plume, Mist, NETGEAR, Electric Imp, RF Solutions, Luma, Nest, Linksys, Wifigarden | Varies | Capability-dependent | Bounded Rust adapters and deterministic fixtures; telemetry/network-only/unsupported states cannot masquerade as CSI |
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion (see [tutorial #36](https://github.com/ruvnet/RuView/issues/36)) |
>
> No hardware? Verify the signal processing pipeline with the deterministic reference signal: `python archive/v1/data/proof/verify.py`
@@ -143,7 +145,7 @@ pip install "ruview[client]" # or: pip install "wifi-densepose[clie
<img src="assets/v2-screen.png" alt="WiFi DensePose — Live pose detection with setup guide" width="800">
</a>
<br>
<em>Real-time pose skeleton from WiFi CSI signals — no cameras, no wearables</em>
<em>Real-time pose skeleton from WiFi CSI signals — no cameras, no wearables (demo visualization; the live CSI-only single-ESP32 17-keypoint model is still first-cut — see <a href="#model-weights-whats-real-whats-not">Model weights: what's real, what's not</a>)</em>
<br><br>
<a href="https://ruvnet.github.io/RuView/"><strong>▶ Live Observatory Demo</strong></a>
&nbsp;|&nbsp;
@@ -155,7 +157,7 @@ pip install "ruview[client]" # or: pip install "wifi-densepose[clie
> The [server](#-quick-start) is optional for visualization and aggregation — the ESP32 [runs independently](#esp32-s3-hardware-pipeline) for presence detection, vital signs, and fall alerts.
>
> **Live ESP32 pipeline**: Connect an ESP32-S3 node → run the [sensing server](#sensing-server) → open the [pose fusion demo](https://ruvnet.github.io/RuView/pose-fusion.html) for real-time dual-modal pose estimation (webcam + WiFi CSI). See [ADR-059](docs/adr/ADR-059-live-esp32-csi-pipeline.md).
> **Live ESP32 pipeline**: Connect an ESP32-S3 node → run the [sensing server](#sensing-server) → open the [pose fusion demo](https://ruvnet.github.io/RuView/pose-fusion.html) for real-time dual-modal pose estimation (webcam + WiFi CSI). See [ADR-059](docs/adr/ADR-059-live-esp32-csi-pipeline.md). (The webcam supplies ground-truth pose in this dual-modal demo; the CSI-only on-device 17-keypoint model is still first-cut — see [Model weights: what's real, what's not](#model-weights-whats-real-whats-not).)
>
> **three.js scene gallery** at [`/three.js/`](https://ruvnet.github.io/RuView/three.js/) — five progressively richer ADR-097 demos: helpers, cinematic, GLTF skinned, FBX skinned, and a live MediaPipe→Mixamo retargeting feed driven by ESP32 CSI. Demos 04 and 05 require a local Mixamo `X Bot.fbx` (license boundary — not redistributed).
@@ -202,7 +204,26 @@ The separate **17-keypoint pose-estimation model** is now published at [`ruvnet/
python archive/v1/data/proof/verify.py
```
Tracked in [#509](https://github.com/ruvnet/RuView/issues/509); see [ADR-079](docs/adr/ADR-079-camera-supervised-pose-finetune.md) phases P7P9 for the camera-supervised fine-tune path.
Tracked in [#509](https://github.com/ruvnet/RuView/issues/509); see [ADR-079](docs/adr/ADR-079-camera-ground-truth-training.md) phases P7P9 for the camera-supervised fine-tune path.
### Model weights: what's real, what's not
"WiFi → pose" means three different things in this repo, at three different maturity
levels. Read the label, not the headline ([ADR-187](docs/adr/ADR-187-archive-v1-deprecation-honest-labeling.md)):
| Tier | Checkpoint(s) | Honest status |
|------|---------------|---------------|
| **Real & validated** | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) (CSI encoder + presence head) · [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) (17-keypoint pose) · `cog-person-count/count_v1` | **MEASURED / published.** Presence = 82.3% held-out temporal-triplet accuracy (the old "100% presence" figure was retracted); MM-Fi pose = 82.69% torso-PCK@20 on the `random_split` protocol. These are the pose/presence numbers the project stands behind today. |
| **Real but weak (honestly labeled)** | committed `v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors` | First-cut on-device model. **PCK@20 = 3.0% / PCK@50 = 18.5%** on a 217-sample holdout — **below the ADR-079 target of ≥ 35%.** Learns coarse structure (`r_hip` 77% PCK@50); distal/face joints near-random. Its runtime path in `cog-pose-estimation/src/inference.rs` is still a centred-skeleton **stub returning `confidence=0`** — the weights are not yet wired in. Full disclosure in the [cog README](v2/crates/cog-pose-estimation/cog/README.md). |
| **Architecture only, no weights** | `archive/v1` `DensePoseHead` | Random `kaiming_normal_` init, **no checkpoint of any kind** (zero `.pth`/`.onnx`/`.safetensors` files under `archive/v1/`). Deprecated and superseded — see [`archive/v1/DEPRECATED.md`](archive/v1/DEPRECATED.md). Do not expect real pose output from it. |
**On the ESP32-SISO question ([#509](https://github.com/ruvnet/RuView/issues/509)):** a
single-antenna, 56-subcarrier CSI stream at a 20-frame window does *not* carry the
fine-grained spatial information the multi-antenna NIC research relies on — the cog
measurements above show distal/face joints near-random. The shippable pose accuracy the
project can stand behind today is the **MM-Fi benchmark number**, not a live single-ESP32
number. The path to a first *reproducible* on-device baseline (PCK@20 ≥ 35%) is tracked in
[ADR-079](docs/adr/ADR-079-camera-ground-truth-training.md) / [#645](https://github.com/ruvnet/RuView/issues/645) — do not advertise the live single-ESP32 17-keypoint feature without the "first-cut, below-target, runtime-stub" caveat until that baseline is measured.
## 🧩 Edge Module Catalog
@@ -617,7 +638,7 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
| [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Architecture Decisions](docs/adr/README.md) | 182 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
| [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
+49
View File
@@ -0,0 +1,49 @@
# ⚠️ DEPRECATED — `archive/v1` is unmaintained and superseded
**Do not build new work on this tree.** `archive/v1` is the original pure-Python
implementation of WiFi-DensePose. It is kept only as a research archive
(per [ADR-117 §1.3](../../docs/adr/ADR-117-pip-wifi-densepose-modernization.md)) and
as the host of one still-live deterministic proof (see "What still lives here" below).
Everything else in this directory is frozen and receives no fixes, reviews, or support.
Governed by [ADR-187](../../docs/adr/ADR-187-archive-v1-deprecation-honest-labeling.md).
## The one honest fact that trips people up
`archive/v1/src/models/densepose_head.py` defines a `DensePoseHead` neural-network
architecture (segmentation + UV-regression heads). **It ships no trained weights.** Its
`_initialize_weights()` uses `kaiming_normal_` **random initialization only** — there is
no checkpoint-loading path in the class, and there are **zero** `.pth` / `.onnx` /
`.safetensors` / `.pt` / `.ckpt` / `.bin` files anywhere under `archive/v1/`.
So: the architecture is *defined*, but it is **architecture-only**. Running it produces
random output, not real pose accuracy. This matches the technical review in
[#509](https://github.com/ruvnet/RuView/issues/509) — for *this tree*, the "network
defined, no pre-trained weights" observation is TRUE.
Real, trained, benchmarked weights **do** exist — just not here. They live in the
maintained `v2/` workspace and on Hugging Face (see next section).
## Use the maintained path instead
| You want… | Go here |
|-----------|---------|
| The maintained implementation | The **`v2/` Rust workspace** (repo root `../../v2/`) |
| A pip install | `pip install ruview` **or** `pip install wifi-densepose` (2.x) — the compiled PyO3 wheel ([ADR-117](../../docs/adr/ADR-117-pip-wifi-densepose-modernization.md)). The `wifi-densepose` **1.x** line is tombstoned on PyPI: `1.99.0` raises an `ImportError` telling you to migrate. |
| Real trained presence/encoder weights | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) — 82.3% held-out temporal-triplet accuracy |
| A real 17-keypoint pose model | [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) — 82.69% torso-PCK@20 on MM-Fi `random_split` |
| The honest three-tier weights picture | The "Model weights: what's real, what's not" table in the root [`README.md`](../../README.md) and [`docs/user-guide.md`](../../docs/user-guide.md) |
## What still lives here (intentionally)
Only one thing under `archive/v1/` is still a live, cited signal: the deterministic
reference-pipeline proof —
```bash
python archive/v1/data/proof/verify.py # must print VERDICT: PASS
```
This is the ADR-028 "Trust Kill Switch": it feeds a fixed reference signal through the
signal-processing pipeline and checks the SHA-256 of the output against a published hash.
It is a legitimate reproducibility witness and is **not** deprecated. Everything else in
this tree is.
+16
View File
@@ -1,3 +1,19 @@
> ## ⚠️ DEPRECATED — unmaintained and superseded
>
> This tree is the **original pure-Python implementation** and is kept only as a research
> archive. It receives no fixes, reviews, or support. **Read [`DEPRECATED.md`](DEPRECATED.md) before using anything below.**
>
> - Its `DensePoseHead` is **architecture-only with random-initialized weights and ships no
> trained checkpoint** — running it produces random output, not real pose accuracy.
> - The maintained path is the **`v2/` Rust workspace** and the `wifi-densepose 2.x` / `ruview`
> pip wheel ([ADR-117](../../docs/adr/ADR-117-pip-wifi-densepose-modernization.md)). The
> `wifi-densepose` 1.x line is tombstoned on PyPI (1.99.0 raises `ImportError`).
> - Real trained weights live elsewhere: [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained)
> (presence, 82.3%) and [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose)
> (17-keypoint pose, 82.69% torso-PCK@20).
> - The only still-live artifact here is the deterministic proof `data/proof/verify.py`
> (ADR-028), which stays. See [ADR-187](../../docs/adr/ADR-187-archive-v1-deprecation-honest-labeling.md).
# WiFi-DensePose v1 (Python Implementation)
This directory contains the original Python implementation of WiFi-DensePose.
+6 -4
View File
@@ -2,6 +2,7 @@
Health check API endpoints
"""
import asyncio
import logging
import psutil
from typing import Dict, Any, Optional
@@ -168,7 +169,7 @@ async def health_check(request: Request):
overall_status = "degraded"
# Get system metrics
system_metrics = get_system_metrics()
system_metrics = await asyncio.to_thread(get_system_metrics)
uptime_seconds = (datetime.now() - _APP_START_TIME).total_seconds()
@@ -263,11 +264,12 @@ async def get_health_metrics(
):
"""Get detailed system metrics."""
try:
metrics = get_system_metrics()
metrics = await asyncio.to_thread(get_system_metrics)
# Add additional metrics if authenticated
if current_user:
metrics.update(get_detailed_metrics())
detailed = await asyncio.to_thread(get_detailed_metrics)
metrics.update(detailed)
return {
"timestamp": datetime.utcnow().isoformat(),
@@ -300,7 +302,7 @@ def get_system_metrics() -> Dict[str, Any]:
"""Get basic system metrics."""
try:
# CPU metrics
cpu_percent = psutil.cpu_percent(interval=1)
cpu_percent = psutil.cpu_percent(interval=None)
cpu_count = psutil.cpu_count()
# Memory metrics
+13 -10
View File
@@ -180,21 +180,24 @@ class MetricsService:
async def _collect_system_metrics(self):
"""Collect system-level metrics."""
try:
# CPU usage
cpu_percent = psutil.cpu_percent(interval=1)
# Query OS metrics in a background thread to prevent blocking the event loop
def gather_metrics():
return (
psutil.cpu_percent(interval=None),
psutil.virtual_memory().percent,
psutil.disk_usage('/'),
psutil.net_io_counters()
)
cpu_percent, mem_percent, disk, network = await asyncio.to_thread(gather_metrics)
# Record metrics on the main loop
self._metrics["system_cpu_usage"].add_point(cpu_percent)
self._metrics["system_memory_usage"].add_point(mem_percent)
# Memory usage
memory = psutil.virtual_memory()
self._metrics["system_memory_usage"].add_point(memory.percent)
# Disk usage
disk = psutil.disk_usage('/')
disk_percent = (disk.used / disk.total) * 100
self._metrics["system_disk_usage"].add_point(disk_percent)
# Network I/O
network = psutil.net_io_counters()
self._metrics["system_network_bytes_sent"].add_point(network.bytes_sent)
self._metrics["system_network_bytes_recv"].add_point(network.bytes_recv)
@@ -0,0 +1,59 @@
import asyncio
import time
import os
import sys
import pytest
# Add project root and archive/v1 to sys.path so we can import src modules
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../")))
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
from archive.v1.src.api.routers.health import get_system_metrics
async def ticker():
"""Asynchronous background ticker to measure event loop latency/freezes."""
ticks = []
for _ in range(15):
ticks.append(time.time())
await asyncio.sleep(0.1)
return ticks
async def run_test():
print("Starting concurrency verification test...")
# Start the ticker background task
ticker_task = asyncio.create_task(ticker())
# Let ticker run for a few ticks
await asyncio.sleep(0.3)
print("Calling get_system_metrics offloaded to background thread...")
start_time = time.time()
# Query system metrics using to_thread (simulating FastAPI request)
metrics = await asyncio.to_thread(get_system_metrics)
duration = time.time() - start_time
print(f"get_system_metrics took: {duration:.4f}s")
# Wait for the ticker to complete
ticks = await ticker_task
# Calculate gaps between consecutive ticks to check for event loop freezes
gaps = [ticks[i+1] - ticks[i] for i in range(len(ticks)-1)]
max_gap = max(gaps)
print(f"All tick gaps: {[round(g, 3) for g in gaps]}")
print(f"Max event loop freeze: {max_gap:.4f}s")
# In pre-fix code, psutil.cpu_percent(interval=1) blocks for 1.0s,
# causing a gap of >1.0s. With our fix, it should be close to 0.1s.
return max_gap, duration
@pytest.mark.asyncio
async def test_get_system_metrics_does_not_starve_event_loop():
max_gap, duration = await run_test()
# ticker sleeps 0.1s; allow slack for CI, but we should not see ~1s gaps
assert max_gap < 0.6
assert duration < 0.6
@@ -83,7 +83,7 @@ This ADR covers Phase 1 (TV box as aggregator) and Phase 2 (custom WiFi firmware
|---------|--------|-------------|--------------|--------|
| Broadcom BCM43455 | brcmfmac | **Proven** (Nexmon CSI) | Yes | Low — patches exist |
| Realtek RTL8822CS | rtw88 | **Moderate** — driver is open-source, CSI hooks need adding | Yes (patched) | Medium |
| MediaTek MT7661 | mt76 | **Unknown** — MediaTek has released CSI tools for some chips | Yes | Medium-High |
| MediaTek MT7661 | mt76 | **Unverified** — no supported public CSI capture API was found in upstream `mt76` or public MediaTek SDK material | Yes | Research only |
2. **CSI extraction architecture** (Linux kernel driver modification):
@@ -0,0 +1,551 @@
# ADR-184: Complete ADR-117 via PyPI Trusted Publishing (OIDC) + real v2.0.0 / ruview publish
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-07-21 |
| **Deciders** | ruv |
| **Codename** | **PHOENIX-LANDING** — the PIP-PHOENIX wheel that never actually took off |
| **Relates to** | [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX modernization — this ADR completes it), [ADR-028](ADR-028-esp32-capability-audit.md) (witness chain), [ADR-115](ADR-115-home-assistant-integration.md) (HA/Matter sibling), [ADR-168](ADR-168-benchmark-proof.md) (measured-not-claimed house style) |
| **Tracking issue** | [#785](https://github.com/ruvnet/RuView/issues/785) (ADR-117, still OPEN) |
---
## 1. Context
ADR-117 (PIP-PHOENIX) designed the v2.0.0 rewrite of the pip `wifi-densepose`
package as a PyO3 + maturin compiled wheel over the Rust core, plus a `ruview`
sibling package, replacing the 11.5-month-stale pure-Python `1.1.0` line. The
code landed on `main` (the `python/` workspace: `Cargo.toml`, `src/bindings/*.rs`,
the `wifi_densepose/` Python package, `tests/`, `bench/`). The tombstone shipped.
**But the release itself is broken and the design doc's own P5 intent was never met.**
This ADR is a **gap analysis and remediation plan**, not a new feature. Every fact
below was verified against PyPI and GitHub Actions on 2026-07-21; none are projected.
### 1.1 What is actually live on PyPI (measured)
`pip index versions wifi-densepose` returns:
```
wifi-densepose (1.99.0)
Available versions: 1.99.0, 1.2.0, 1.1.0, 1.0.0
```
- `1.99.0` — the tombstone wheel **is genuinely live**. `import wifi_densepose`
raises `ImportError` pointing users to 2.0+. This part of ADR-117 §7.2 shipped.
- `2.0.0a1` — appears in PyPI's release history as a **pre-release** (hidden from
the default `pip index` view, surfaced with `--pre`). It is still an **alpha**.
- `2.0.0` (stable) — **does not exist.** ADR-117's headline deliverable
(`pip install wifi-densepose==2.0.0`) is not installable.
`pip index versions ruview` returns:
```
ERROR: No matching distribution found for ruview
```
The `ruview` sibling package **was never published.** Commit `b71d243b4`
(*"feat(adr-117): publish wifi-densepose 2.0.0a1 + ruview 2.0.0a1 to PyPI"*) claims
a publish that did not happen for that package — a real **claimed-vs-measured gap**
of exactly the kind [ADR-168](ADR-168-benchmark-proof.md) and the project's
"prove everything" posture exist to catch.
### 1.2 Why the release pipeline was stuck (measured; interim-fixed — see §1.4)
`gh run list --workflow pip-release` shows the last **4** runs all
`conclusion=failure` (most recent `2026-05-24T16:34`). The full failure log for
run `26366735779` (job *"Publish v1.99 tombstone"* → step *"Publish to PyPI"*)
shows two things:
1. The publish step uses `pypa/gh-action-pypi-publish` with a `password`
(API-token) input and fails:
```
403 Forbidden — Invalid or non-existent authentication information.
```
i.e. the `PYPI_API_TOKEN` GitHub secret is stale / expired / revoked.
2. The action's own log warns:
```
Warning: the workflow was run with 'attestations: true' ... but an explicit
password was also set, disabling Trusted Publishing.
```
The workflow at `.github/workflows/pip-release.yml` wires `password:
${{ secrets.PYPI_API_TOKEN }}` into **four** publish steps (lines 249, 258, 282,
291) and declares only `permissions: contents: read` (line 4950). So it is using a
rotatable, leak-able, expire-able API token in exactly the place ADR-117 §5.4 / §5.5
and the issue #785 P5 row explicitly called for **OIDC Trusted Publishing** ("cp310
… abi3-py310, OIDC"; ADR-117 §5.5 line 547: *"PyPI publish via Trusted Publisher
(OIDC, no API token in secrets)"*). **The implementation drifted from its own
design doc.**
### 1.3 Why the package is still alpha (measured)
`python/pyproject.toml` pins `version = "2.0.0a1"` (line 13) and
`Development Status :: 3 - Alpha` (line 26). Issue #785's closing criteria
(§"Done") require `wifi-densepose==2.0.0` (**not** alpha) published, plus all 10
acceptance criteria in §11. None of those can be true today given §1.1–§1.2.
**Why this matters:** ADR-117 is the sole Python entry point for the whole RuView
ecosystem (per its §2 "PyPI org presence check"). A stale token silently blocking
every release means the entire "plug-and-play Python entry point for the pip +
Jupyter customer base" thesis (issue #785 "Strategic alignment") is stalled behind a
one-line credential problem — and a commit message claims otherwise.
### 1.4 Interim fix applied (2026-07-21) — credential unblocked, migration still pending
**As of 2026-07-21T22:57:29Z the stale-credential symptom is fixed at the credential
layer.** The maintainer fetched a valid `PYPI_TOKEN` from GCP Secret Manager (project
`cognitum-20260110`) and ran `gh secret set PYPI_API_TOKEN` to replace the
revoked/expired value. Authentication was confirmed non-destructively via a
`twine upload --skip-existing` re-upload of the existing `1.99.0` tombstone artifacts,
which returned a benign 400/skip response (not the previous `403 Forbidden`) — proving
the new token authenticates correctly.
This means **token-based publishing works again today** — the `403` root cause
described in §1.2 no longer reproduces. It does **not**, however, close this ADR:
- A **manually-rotated token still expires, leaks, and can be revoked over time** — it
re-introduces exactly the silent-failure mode that blocked the last 4 runs. It is a
stopgap at the same layer as the §3.2 fallback, not the durable fix.
- The OIDC **Trusted Publishing migration (§3, P1) remains the decision** — a
credential PyPI mints per-run with no secret to rotate is the only fix that removes
the recurring-expiry class of failure.
- The other three gaps are **untouched** by this rotation: `wifi-densepose` is still
`2.0.0a1` (not stable `2.0.0`), and `ruview` is still unpublished.
**Why/How to apply:** read §1.2's "root cause" as *diagnosed and temporarily
mitigated*, not *still broken*. A reviewer re-running the §7.5 check today may now see
a green token-based run — that is expected and does not satisfy this ADR, which is
Accepted only when §6's criteria pass **and** the workflow no longer carries a static
token (§7.4).
---
## 2. Current state — evidence
| Artifact | Value | Source |
|---|---|---|
| Latest stable `wifi-densepose` on PyPI | **1.99.0** (tombstone) | `pip index versions wifi-densepose` |
| `wifi-densepose==2.0.0` stable | **absent** | `pip index versions` (not listed) |
| `wifi-densepose==2.0.0a1` pre-release | present (alpha) | PyPI release history (`--pre`) |
| `ruview` on PyPI | **No matching distribution found** | `pip index versions ruview` |
| `pip-release.yml` last 4 runs | all `failure` | `gh run list --workflow pip-release` |
| Most recent failed run | `2026-05-24T16:34` | `gh run list` |
| Failing step | Publish v1.99 tombstone → Publish to PyPI | run `26366735779` log |
| Failure code | `403 Forbidden — Invalid or non-existent authentication information` | run `26366735779` log |
| Root cause | `PYPI_API_TOKEN` stale/revoked; explicit password disables Trusted Publishing | run `26366735779` log warning |
| `password:` uses in workflow | 4 (lines 249, 258, 282, 291) | `.github/workflows/pip-release.yml` |
| Workflow permissions | `contents: read` only (no `id-token: write`) | `pip-release.yml:4950` |
| pyproject version | `2.0.0a1` | `python/pyproject.toml:13` |
| pyproject dev status | `3 - Alpha` | `python/pyproject.toml:26` |
| Issue #785 | **OPEN** | GitHub |
**Why/How to apply:** treat this table as the falsifiable baseline. A reviewer who
re-runs each `Source` command must reproduce each `Value`, or this ADR is wrong and
should be revised before any remediation is attempted.
---
## 3. Decision
Complete ADR-117 by closing four gaps, in order:
1. **Migrate `pip-release.yml` to PyPI Trusted Publishing (OIDC)** — as the durable
end-state, drop all four `password: ${{ secrets.PYPI_API_TOKEN }}` inputs, grant
`id-token: write` to the publish jobs, and add `environment: pypi`. This removes
the rotatable/expire-able credential and realigns with ADR-117 §5.5's stated OIDC
intent. **This is gated behind sub-phase P1b** (§5): the switch is inert — and in
fact 403-breaking — until the manual pypi.org registration (§3.1) exists, so the
OIDC change must land *together* with that registration. Until then, token auth
(the freshly-rotated `PYPI_API_TOKEN`, §1.4) is the correct active path and is
what the `RuView#786-pypi-token-auth` fix-marker guard enforces. An OIDC migration
was attempted (`cc153e8b5`) and reverted (`82d5c7339`) for exactly this reason.
2. **Promote `wifi-densepose` from `2.0.0a1` to stable `2.0.0`** in
`python/pyproject.toml` (version + `Development Status :: 5 - Production/Stable`)
and record the promotion in `CHANGELOG.md`.
3. **Actually publish `ruview==2.0.0`** — the sibling package that commit
`b71d243b4` claimed but never shipped — and verify it with `pip index versions`.
4. **Adopt issue #785 §11's 10 acceptance criteria verbatim as this ADR's own
acceptance criteria** (§6 below), and only flip ADR-117 → Accepted and close
#785 once every one passes against the real index — proven, not claimed.
### 3.1 Mandatory human prerequisite (cannot be automated)
**Trusted Publishing requires a one-time manual step on `pypi.org` that no CLI, API,
or agent can perform** — PyPI restricts Trusted Publisher configuration to the
project owner via the web UI for security reasons. Before P1's workflow change can
succeed, a human with owner rights on both PyPI projects must:
1. Log in to `pypi.org`.
2. For **`wifi-densepose`**: Project → *Publishing* → *Add a new pending/trusted
publisher* → GitHub, with:
- Owner: `ruvnet`
- Repository: `RuView`
- Workflow filename: `pip-release.yml`
- Environment: `pypi`
3. Repeat the identical step for the **`ruview`** project. Because `ruview` is not
yet on PyPI, register it as a **pending publisher** (PyPI supports configuring a
trusted publisher for a project name before its first release — the first OIDC
publish then creates the project).
**Why/How to apply:** the workflow change in P1 is inert until this is done — the
publish step will fail with a "no trusted publisher configured" error rather than a
403. Land P1 and this manual step together; do not tag a release expecting OIDC to
work until a human confirms both entries exist. Treat this section as a blocking
checklist item on the release-day runbook, not a footnote.
### 3.2 Fallback path (if the owner declines Trusted Publishing)
If the maintainer prefers not to adopt OIDC yet, the code-side remediation is a
**token regeneration**, not a redesign:
- Generate a fresh PyPI API token (scoped to the `wifi-densepose` and `ruview`
projects) and store it in GCP Secret Manager (project `cognitum-20260110`, where
the project's tokens live), then `gh secret set PYPI_API_TOKEN` from it, following
the existing runbook referenced in the workflow header (`docs/integrations/pypi-release.md`).
- Keep the current `password:`-based workflow unchanged.
**Why/How to apply:** this path clears the 403 and unblocks releases immediately,
but it re-introduces the exact failure mode this ADR is trying to eliminate — a
credential that silently expires and blocks the whole Python entry point again. Use
it only as a stopgap; the Trusted Publishing migration (P1) is the durable fix and
should remain the default recommendation.
---
## 4. Detailed design — workflow migration
The change to `.github/workflows/pip-release.yml` is small and surgical. It does
**not** touch the build matrix (`build-wheels`, `build-sdist`, `build-tombstone`
jobs are unchanged — the 403 is a publish-credential problem, not a build problem).
### 4.1 Grant OIDC token permission on the publish jobs
The `gh-action-pypi-publish` action mints its OIDC token from the job's
`id-token: write` permission. The current top-level `permissions: contents: read`
must be extended on the two publish jobs (`publish-v2`, `publish-tombstone`) — plus
the future `publish-ruview` job:
```yaml
publish-v2:
name: Publish v2 wheels
needs: [build-wheels, build-sdist]
permissions:
id-token: write # ← added: mint the OIDC token for PyPI
contents: read
environment: pypi # ← added: binds to the PyPI trusted-publisher entry
```
### 4.2 Drop the `password:` inputs
Every publish step loses its `password:` line. Trusted Publishing needs no secret —
the action exchanges the job's OIDC token for a short-lived PyPI upload token
automatically:
```yaml
# BEFORE (current — fails with 403 when the token is stale)
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }} # ← remove
packages-dir: dist
# AFTER (Trusted Publishing — no secret, activates once the pypi.org entry exists)
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist
```
The TestPyPI dry-run steps keep `repository-url: https://test.pypi.org/legacy/` and
likewise drop `password:` — a matching trusted-publisher entry must be registered on
`test.pypi.org` if the dry-run path is to be used (otherwise gate the dry-run behind
the fallback token or remove it).
**Why/How to apply:** the header comment block (lines 1623) that documents the
`PYPI_API_TOKEN` / GCP-Secret-Manager runbook must be rewritten to document the
Trusted Publishing setup instead, so the next maintainer does not re-add a token
"to fix" a future failure and silently re-disable OIDC.
### 4.3 Add the `publish-ruview` job
`ruview` is published by a new job mirroring `publish-v2` (same `id-token: write` +
`environment: pypi`, no `password:`), gated on a `ruview`-scoped build. Because the
package has never shipped, its first successful OIDC publish creates the PyPI
project against the pending trusted-publisher entry from §3.1.
---
## 5. Phase ledger
```
P1 ──► P1b ──► P2 ──► P3 ──► P4
token OIDC version real close
unblock (gated) promote publish #785
```
### P1 — Credential unblock (token auth, active)
- [x] Rotate `PYPI_API_TOKEN` to a validated token (§1.4, `gh secret set`, verified
`2026-07-21T22:57:29Z` via `twine upload --skip-existing`). Token-based publishing
works today.
- [x] Keep `password: ${{ secrets.PYPI_API_TOKEN }}` as the active auth path,
satisfying the `RuView#786-pypi-token-auth` fix-marker guard.
- [ ] Rewrite the `pip-release.yml` header comment block so the next maintainer
knows OIDC is the intended P1b end-state (not a token to keep re-rotating forever).
> Note: an OIDC migration was attempted (`cc153e8b5`) and **reverted** (`82d5c7339`)
> because it tripped the fix-marker guard before the pypi.org registration existed.
> The OIDC work is therefore tracked as P1b below, not P1. See the Status note.
**Status 2026-07-21 — DESIGNED then REVERTED (token auth is the ACTIVE path):**
The OIDC migration was implemented (commit `cc153e8b5` — `id-token: write` +
`environment: pypi` on both publish jobs, all four `PYPI_API_TOKEN` password
inputs removed) but then **reverted** (commit `82d5c7339`) after it tripped the
pre-existing `RuView#786-pypi-token-auth` fix-marker guard
(`scripts/fix-markers.json`). That guard `require`s
`password: ${{ secrets.PYPI_API_TOKEN }}` and `forbid`s `id-token: write`
precisely because a half-activated OIDC path (id-token permission present, but no
Trusted Publisher yet registered on pypi.org) leaves publishing **403-broken**
rather than working — it correctly predicted this exact failure. The revert was
verified locally against the real checker (`python scripts/check_fix_markers.py` →
all 25 markers pass, exit 0) before pushing.
**Active path today:** token-based auth via the freshly-rotated `PYPI_API_TOKEN`
(§1.4). The current `pip-release.yml` (HEAD `82d5c7339`) carries
`password: ${{ secrets.PYPI_API_TOKEN }}` at four publish steps plus a TODO
comment marking the OIDC follow-up. The OIDC switch is therefore **not** done — it
moves to sub-phase P1b below.
**Why this revert was correct (measured, not claimed):** OIDC is the better
long-term design and matches ADR-117's original §5.5 P5 intent — but implementing
it *before* the manual pypi.org registration exists would have shipped a workflow
that looks migrated yet 403s on the next real publish. The fix-marker caught a
well-intentioned improvement that wasn't the honest, currently-working state, and
it was reverted rather than overridden. That is the same "measured not claimed"
discipline (per [ADR-168](ADR-168-benchmark-proof.md)) this entire ADR exists to
enforce — applied here to our own change.
### P1b — Switch to OIDC Trusted Publishing (gated follow-up)
- [ ] **(human, manual, pypi.org — BLOCKING)** Complete the §3.1 Trusted Publisher
registration for BOTH `wifi-densepose` and `ruview` (owner=ruvnet, repo=RuView,
workflow=pip-release.yml, environment=pypi). P1b must not start until this exists.
- [ ] Re-apply the `cc153e8b5` change (add `id-token: write` + `environment: pypi`,
drop the four `password:` inputs) as its own follow-up commit.
- [ ] Update the `RuView#786-pypi-token-auth` fix-marker in `scripts/fix-markers.json`
in the *same* commit — invert it to `require: id-token: write` / `forbid:
password: ${{ secrets.PYPI_API_TOKEN }}` — so the guard tracks the new intended
state instead of blocking it (referencing the TODO comment now in pip-release.yml).
- [ ] Confirm a green OIDC publish before removing the token, per §3.2's
keep-both-paths recommendation (OIDC first, token fallback until OIDC is proven).
- [ ] No capability gap: publishing must keep working across the P1→P1b transition.
### P2 — Version promotion + changelog
- [ ] `python/pyproject.toml`: `version = "2.0.0"` (drop the `a1` suffix).
- [ ] `python/pyproject.toml`: `Development Status :: 5 - Production/Stable`.
- [ ] `CHANGELOG.md`: `[Unreleased]` entry — "wifi-densepose 2.0.0 promoted from
alpha; ruview 2.0.0 first stable publish; pip-release migrated to Trusted Publishing".
- [ ] Confirm the `ruview` package's own version metadata is set to `2.0.0`.
### P3 — Real publish + verification
- [ ] Cut tag `v2.0.0-pip` (per the workflow's `v*-pip` trigger) → OIDC publish of
the `wifi-densepose` wheel matrix.
- [ ] Publish `ruview==2.0.0` via the new `publish-ruview` job.
- [ ] Run every command in §7 against the **real** PyPI index and capture output.
- [ ] Generate + commit `expected_features_v2.sha256` (issue #785 §11 criterion 10),
resolving ADR-117 §11.3 / the workflow header's Q3 note.
### P4 — Close issue #785
- [ ] All 10 acceptance criteria (§6) pass against the real index.
- [ ] Flip ADR-117 §Status → **Accepted**.
- [ ] Flip this ADR (ADR-184) §Status → **Accepted**.
- [ ] Close issue #785.
**Why/How to apply:** the phases are strictly ordered — P3 cannot succeed until both
P1 (working credential path) and the §3.1 human step are done, and P4 must not be
marked complete on the strength of a commit message (the failure mode this ADR
exists to correct). Nothing in this ledger is checked; this is a Proposed plan.
---
## 6. Acceptance criteria (verbatim from issue #785 §11)
A reviewer must be able to:
1. `pip install --pre wifi-densepose==2.0.0a1` from PyPI test index → wheel installs
without compile step on Linux/macOS/Windows
2. `python -c "import wifi_densepose; print(wifi_densepose.__version__, wifi_densepose.__rust_version__)"`
→ both versions print
3. `python -c "from wifi_densepose import CsiFrame; ..."` → core type round-trips
through PyO3
4. `python -c "from wifi_densepose import vitals; vitals.detect_hr(...)"` → 4-stage
pipeline runs on a sample CSI buffer
5. `pip install wifi-densepose[client]; python -c "import wifi_densepose.client; ..."`
→ WS client connects to a running sensing-server
6. `pytest python/tests/` → ≥30 tests pass (smoke + binding round-trips)
7. `maturin build --release --strip` → wheel under 5 MB per platform (ADR §5.4 budget)
8. `wifi-densepose==1.99.0` is the latest 1.x; `import wifi_densepose` raises
`ImportError` with migration URL
9. `wifi-densepose==1.0.0` is yanked from PyPI; `1.1.0` is un-yanked with deprecation
notice (90-day window)
10. Witness `expected_features_v2.sha256` generated in CI, committed alongside the
existing `archive/v1/data/proof/`, re-verifiable from Python via
`wifi_densepose.verify_witness(...)`
**Note (amendment to criterion 1):** issue #785 §11 was written when `2.0.0a1` was
the target. This ADR promotes to stable `2.0.0`, so criterion 1 is read as
`pip install wifi-densepose==2.0.0` (no `--pre`) against the production index. The
`--pre`/`a1` wording is preserved verbatim above per the transcription requirement;
the stable form is what P3/P4 must actually satisfy. This ADR additionally requires
`ruview==2.0.0` to be installable (the sibling package from commit `b71d243b4`),
which #785 §11 did not enumerate but the issue "Done" section implies.
---
## 7. How to verify (prove, don't claim)
Exact commands a reviewer runs to prove — not assume — each gap is closed. Every one
produces falsifiable output; capture it in the PR that flips ADR-117 to Accepted.
### 7.1 Both packages live and stable
```bash
# wifi-densepose 2.0.0 (stable, NOT alpha) must appear
pip index versions wifi-densepose
# expect: "wifi-densepose (2.0.0)" and 2.0.0 in the available list
# ruview 2.0.0 must now exist (currently: "No matching distribution found")
pip index versions ruview
# expect: "ruview (2.0.0)"
```
### 7.2 Clean-venv install + import (criteria 24)
```bash
python -m venv /tmp/verify-184 && . /tmp/verify-184/bin/activate
pip install wifi-densepose==2.0.0 # stable, no --pre
python -c "import wifi_densepose; print(wifi_densepose.__version__, wifi_densepose.__rust_version__)"
python -c "from wifi_densepose import CsiFrame; print(CsiFrame([1.0]*56,[0.0]*56,56,0,100.0))"
python -c "from wifi_densepose import vitals; print(hasattr(vitals,'detect_hr'))"
pip install ruview==2.0.0
python -c "import ruview; print(ruview.__version__)"
```
### 7.3 Tombstone still guards the 1.x line (criterion 8)
```bash
pip install wifi-densepose==1.99.0
python -c "import wifi_densepose" 2>&1 | grep -q "github.com/ruvnet/RuView" \
&& echo "PASS: tombstone raises with migration URL" \
|| echo "FAIL"
```
### 7.4 Workflow auth state
**Current state (P1, active today):** token auth is the working path and is what
the `RuView#786-pypi-token-auth` fix-marker requires. The honest check today is
that token auth is present and the fix-marker guard passes:
```bash
# token auth present (the ACTIVE, working path — expected PASS today)
grep -q 'password: ${{ secrets.PYPI_API_TOKEN }}' .github/workflows/pip-release.yml \
&& echo "PASS: token auth active" || echo "FAIL"
# fix-marker regression guard must pass
python scripts/check_fix_markers.py && echo "PASS: all markers pass"
```
**P1b end-state (after the manual pypi.org registration):** the checks below flip
to PASS *only once P1b lands together with the fix-marker inversion* — they are
**not** expected to pass today and their passing now would mean a half-migrated,
403-prone workflow:
```bash
# after P1b: no static token should remain in the publish steps
grep -nE 'password:|PYPI_API_TOKEN' .github/workflows/pip-release.yml \
&& echo "not yet: token still present (expected during P1)" \
|| echo "P1b done: no static token"
# after P1b: id-token permission granted on publish jobs
grep -q 'id-token: write' .github/workflows/pip-release.yml \
&& echo "P1b done: OIDC permission present" \
|| echo "not yet: OIDC not enabled (expected during P1)"
```
### 7.5 The release actually went green
```bash
gh run list --workflow pip-release --limit 1
# expect: conclusion=success on the v2.0.0-pip tag run
```
**Why/How to apply:** §7.1 and §7.5 together are the minimal proof that the two
headline gaps (no stable 2.0.0, no `ruview`, dead pipeline) are closed. If any
command's actual output diverges from the `expect` line, the corresponding phase is
not done — regardless of what any commit message or checkbox says.
---
## 8. Consequences
### Positive
- The Python entry point for the entire RuView ecosystem (issue #785 "Strategic
alignment") is unblocked with a credential that cannot silently expire.
- The claimed-vs-measured gap in commit `b71d243b4` (`ruview` never published) is
closed with reproducible proof, upholding the project's "prove everything" posture.
- Trusted Publishing removes a leak-able long-lived secret from CI entirely — the
security posture ADR-117 §5.5 originally specified.
- ADR-117 / issue #785 can finally reach a defensible Accepted/closed state instead
of sitting open behind a one-line token failure.
### Negative
- The `pypi.org` trusted-publisher registration (§3.1) is a hard human dependency
with no automated fallback beyond re-introducing a token (§3.2). Release day is
blocked on a person, not a pipeline.
- Promoting to stable `2.0.0` removes the alpha escape hatch — any binding bug now
ships under a stable version and needs a `2.0.1`, not a new `a`-tag.
- `test.pypi.org` needs its own trusted-publisher entry if the dry-run path is kept,
adding a second manual registration.
### Neutral
- The build matrix (`build-wheels`, `build-sdist`, `build-tombstone`) is untouched;
the risk surface of this change is confined to the three publish jobs.
- The witness-hash-v2 open question (ADR-117 §11.3, workflow header Q3) is pulled
into scope as criterion 10 but is orthogonal to the credential migration.
---
## 9. References
- **ADR-117** — `docs/adr/ADR-117-pip-wifi-densepose-modernization.md` (the design
this ADR completes; §5.4/§5.5 OIDC intent, §7.2 tombstone, §11.3 witness hash)
- **Issue #785** — https://github.com/ruvnet/RuView/issues/785 (tracking issue,
OPEN; §11 acceptance criteria transcribed in §6)
- **Workflow** — `.github/workflows/pip-release.yml` (four `password:` inputs at
lines 249/258/282/291; `contents: read` only at 4950)
- **pyproject** — `python/pyproject.toml` (`version = "2.0.0a1"` line 13;
`3 - Alpha` line 26)
- **Failed run** — GitHub Actions `pip-release` run `26366735779`, job "Publish
v1.99 tombstone" → step "Publish to PyPI" (403 + Trusted-Publishing-disabled warning)
- **Commit `b71d243b4`** — *"feat(adr-117): publish wifi-densepose 2.0.0a1 + ruview
2.0.0a1 to PyPI"* — the `ruview` publish it claims did not occur
- **PyPI Trusted Publishing** — https://docs.pypi.org/trusted-publishers/ (web-UI-only
registration; pending-publisher support for not-yet-created projects)
- **`pypa/gh-action-pypi-publish`** — https://github.com/pypa/gh-action-pypi-publish
(OIDC via `id-token: write`; `password:` disables Trusted Publishing)
- **ADR-168** — `docs/adr/ADR-168-benchmark-proof.md` (measured-not-claimed house style)
+687
View File
@@ -0,0 +1,687 @@
# ADR-185: Python P6 SOTA bindings — AETHER, MERIDIAN, and MAT via PyO3 extras
| Field | Value |
|-------|-------|
| **Status** | Proposed — **P1P4 implemented & tested** (commits `d060998e3`, `189ac9dfb`, `1c9727f9c`, `0f405213d`) + **leaf-crate hoists done** (`a47bb71b2`/`7ed57f041`/`99fea9df9`); **not yet Accepted** (§6.6 CI gate PARTIAL, §6.7 accuracy bars OPEN — see §13) |
| **Date** | 2026-07-21 (impl status recorded 2026-07-21) |
| **Deciders** | ruv |
| **Codename** | **PIP-TRINITY** — three SOTA subsystems join the `wifi_densepose` wheel |
| **Relates to** | [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX — the PyO3 wheel this extends), [ADR-024](ADR-024-contrastive-csi-embedding-model.md) (AETHER contrastive embeddings), [ADR-027](ADR-027-cross-environment-domain-generalization.md) (MERIDIAN domain generalization), [ADR-152](ADR-152-wifi-pose-sota-2026.md) (WiFlow-STD ~96% PCK@20 SOTA bar) |
| **Tracking issue** | TBD — file under RuView issue tracker |
---
## 1. Context
### 1.1 Where ADR-117 stopped
ADR-117 (PIP-PHOENIX) shipped the `wifi-densepose` v2.x PyPI wheel as a PyO3 +
maturin compiled extension (`wifi_densepose._native`) with a pure-Python facade.
The bound surface today (`python/src/bindings/*.rs`, `python/src/lib.rs`):
| Bound today | Crate | Kind |
|---|---|---|
| `CsiFrame`, `Keypoint`, `KeypointType`, `BoundingBox`, `PersonPose`, `PoseEstimate` | `wifi-densepose-core` | P2 core types |
| 4-stage vitals (`BreathingExtractor`, `HeartRateExtractor`, `VitalEstimate`, `VitalReading`, `VitalStatus`) | `wifi-densepose-vitals` | P3 DSP |
| `BfldFrame`, `BfldReport`, `BfldKind` + `PrivacyClass` gate | `wifi-densepose-bfld` | P3.5 / ADR-118 |
| `SensingClient` (WS), `RuViewMqttClient` (MQTT), HA helpers | pure-Python `wifi_densepose.client` | P4 `[client]` extra |
ADR-117's own phase ledger (§6, "P6+ — Deferred") explicitly parked three
higher-value subsystems as post-v2.0.0 work:
> - [ ] `wifi-densepose-nn` bindings … · `wifi-densepose-ruvector` bindings …
> - [ ] MQTT/Matter integration helpers …
and ADR-117 §5.1 deferred `wifi-densepose-mat` (depends on nn) and the RuVector
tier for wheel-size reasons. The three SOTA subsystems that a Python researcher
most wants — re-identification embeddings, cross-environment transfer, and the
disaster-triage tool — are precisely the ones still unreachable from
`pip install wifi-densepose`.
### 1.2 The three subsystems already exist and are tested in Rust
None of this is new research. Each subsystem is a shipped, tested Rust module:
| Subsystem | ADR | Rust location (verified HEAD) | Nature |
|---|---|---|---|
| **AETHER** — contrastive CSI embedding / re-identification | ADR-024 | `wifi-densepose-sensing-server/src/embedding.rs` (`EmbeddingExtractor`, `ProjectionHead`, `CsiAugmenter`, `AetherConfig`, `aether_loss`, `info_nce_loss`, `alignment_metric`, `uniformity_metric`) | Pure-sync DSP + linear algebra; 128-dim L2-normalized embeddings |
| **MERIDIAN** — cross-environment domain generalization | ADR-027 | `wifi-densepose-train` (`domain::{DomainFactorizer, DomainClassifier, GradientReversalLayer, AdversarialSchedule}`, `geometry::{GeometryEncoder, FourierPositionalEncoding, FilmLayer, MeridianGeometryConfig}`, `rapid_adapt::{RapidAdaptation, AdaptationLoss}`, `virtual_aug::VirtualDomainAugmentor`, `eval::CrossDomainEvaluator`) + `wifi-densepose-signal::hardware_norm::{HardwareNormalizer, HardwareType, CanonicalCsiFrame}` | Inference/adaptation path is pure-Rust and **un-gated**; only `model`/`trainer`/`losses` need `tch-backend` (libtorch) |
| **MAT** — Mass Casualty Assessment Tool | (root CLAUDE.md crate table) | `wifi-densepose-mat` (`DisasterResponse`, `DisasterConfig`, `DetectionPipeline`, `EnsembleClassifier`, `TriageCalculator`, `TriageStatus`, `Survivor`, `VitalSignsReading`) | Cargo-feature-gated (`mat`); sync ingest (`push_csi_data`) + async scan loop (`start_scanning`, tokio) |
### 1.3 Why now, and why gated extras
Two forces make P6 timely: (a) the v2.0.0 wheel is stable and its abi3-py310
build matrix is proven, so adding modules is incremental; (b) integrators reading
the ADR-115/ADR-117 notes are asking for Python access to re-identification and
cross-room transfer specifically.
But pulling all three into the **default** wheel would break ADR-117 §5.4's
**≤ 5 MB per-platform wheel budget** and its "no heavy system deps" invariant:
- MAT is already cargo-`mat`-gated upstream *because* it drags in the ML/detection
stack; the default wheel must not carry it.
- MERIDIAN's training path (`model`/`trainer`/`losses`) is `tch-backend`-gated and
would pull libtorch (30 MB+), the exact wheel-size risk ADR-117 §5.1 flagged.
So P6 mirrors the existing `[client]` extra pattern (ADR-117 §5.6): each subsystem
becomes an **optional pip extra**, and the compiled surface is **feature-gated in
`wifi-densepose-py`'s `Cargo.toml`** so the default wheel stays lean.
### 1.4 What this ADR is *not*
- Not a port of the Rust subsystems to Python — the Rust workspace stays
authoritative and unmodified, exactly as ADR-117 §1.3 established.
- Not the `wifi-densepose-nn` / libtorch binding (still deferred; MERIDIAN binds
only the un-gated inference/adaptation path, not `tch-backend` training).
- Not a change to the default wheel's contents, size budget, or abi3 base.
---
## 2. Gap analysis
| Capability | Rust crate(s) | pip v2.x status | Gap severity |
|---|---|---|---|
| Extract a 128-dim re-ID embedding from a CSI window | `sensing-server::embedding` (AETHER) | Not present | **High** |
| Compare two CSI observations by learned similarity (same room? same person?) | AETHER `EmbeddingExtractor` + cosine | Not present | **High** |
| Hardware-invariant CSI normalization (ESP32 / Intel 5300 / Atheros → canonical 56) | `signal::hardware_norm` (MERIDIAN) | Not present | **High** |
| Geometry-conditioned zero-shot deployment (AP positions → FiLM) | `train::geometry` (MERIDIAN) | Not present | **Medium** |
| 10-second unlabeled few-shot room adaptation | `train::rapid_adapt` (MERIDIAN) | Not present | **Medium** |
| Cross-domain evaluation protocol (in/cross/few-shot MPJPE) | `train::eval` (MERIDIAN) | Not present | **Medium** |
| Disaster-survivor detection + START triage from CSI | `wifi-densepose-mat` | Not present | **Medium** (specialist audience) |
---
## 3. Decision
Adopt **three new optional pip extras**, each binding one SOTA subsystem into the
existing `wifi_densepose` wheel as a dedicated Python submodule, gated behind a
matching Cargo feature so the default wheel is unchanged:
```
pip install wifi-densepose # unchanged: core + vitals + bfld (≤5 MB)
pip install wifi-densepose[aether] # + wifi_densepose.aether
pip install wifi-densepose[meridian] # + wifi_densepose.meridian
pip install wifi-densepose[mat] # + wifi_densepose.mat (mirrors upstream `mat` cargo feature)
pip install wifi-densepose[sota] # convenience: aether + meridian + mat
```
This path is called **PIP-TRINITY**. It reuses ADR-117's established idiom
end-to-end: `#[pyclass]` newtype wrappers holding an `inner` Rust value, `#[new]`
constructors, `#[getter]` accessors, `__repr__`, a per-module `register(m)` fn,
and — critically — **GIL release via `py.allow_threads(|| …)` on every
compute-heavy call**, exactly as `bindings/vitals.rs:229` and `:293` already do.
### 3.1 Feature gating in `wifi-densepose-py`
New Cargo features and optional path-deps in `python/Cargo.toml`; each binding
module is `#[cfg(feature = "…")]`-compiled and conditionally `register()`ed in
`src/lib.rs`, so a default build links none of the three:
```toml
[features]
default = []
aether = ["dep:wifi-densepose-sensing-server"]
meridian = ["dep:wifi-densepose-train", "dep:wifi-densepose-signal"]
mat = ["dep:wifi-densepose-mat"] # upstream `mat` feature flows through
sota = ["aether", "meridian", "mat"]
[dependencies]
wifi-densepose-sensing-server = { version = "0.3.0", path = "../v2/crates/wifi-densepose-sensing-server", optional = true, default-features = false }
wifi-densepose-train = { version = "0.3.0", path = "../v2/crates/wifi-densepose-train", optional = true, default-features = false } # NO tch-backend
wifi-densepose-signal = { version = "0.3.0", path = "../v2/crates/wifi-densepose-signal", optional = true }
wifi-densepose-mat = { version = "0.3.0", path = "../v2/crates/wifi-densepose-mat", optional = true, default-features = false }
```
`[project.optional-dependencies]` in `pyproject.toml` gains `aether`, `meridian`,
`mat`, and `sota` keys mirroring the existing `client`/`dev` extras. Because each
extra changes the compiled surface, extras map to **cibuildwheel feature-flag
builds**, not pure-Python markers — the publish workflow (ADR-117 §5.4) gains a
build axis for the `[sota]` wheel variant.
### 3.2 Binding surface — AETHER (`wifi_densepose.aether`)
Backing crate: `wifi-densepose-sensing-server::embedding` (ADR-024 §2.6). The
crate is Axum/tokio-based, so we depend on it `default-features = false` and bind
**only the sync `embedding` types** — never the server/runtime. If the embedding
module cannot be reached without a tokio dependency (Open Question §11.1), the
fallback is to hoist `embedding.rs` into a leaf crate; that is a Rust-side
refactor, not a Python API change.
| Python symbol | Wraps | Signature (Python) |
|---|---|---|
| `AetherConfig` | `AetherConfig` | `AetherConfig(d_model=64, d_proj=128, temperature=0.07, vicreg_alpha=1.0, vicreg_beta=25.0, vicreg_gamma=1.0)` — frozen, `__repr__` |
| `CsiAugmenter` | `CsiAugmenter` | `CsiAugmenter(seed)`; `.augment(window: list[list[float]]) -> list[list[float]]` |
| `EmbeddingExtractor` | `EmbeddingExtractor` | `.embed(csi_features: list[list[float]]) -> list[float]` (128-dim, L2-normed); `.forward_dual(...) -> tuple[PoseEstimate, list[float]]` |
| `aether_loss(...)` | `aether_loss` | returns `AetherLossComponents(total, info_nce, variance, covariance)` — frozen dataclass-like |
| `cosine_similarity(a, b)` | thin helper | `float`; convenience for re-ID scoring (not a re-impl — calls the same dot product) |
| `alignment_metric`, `uniformity_metric` | same | `float` |
GIL strategy: `embed`, `forward_dual`, `augment`, and `aether_loss` wrap their
Rust call in `py.allow_threads(|| …)` — these are pure-sync matrix ops that touch
no Python objects, matching the vitals precedent. A single-frame `embed()` is
sub-millisecond (ADR-024 §2.8 target <1 ms FP32), but batch/augment calls exceed
the 0.5 ms GIL-release threshold ADR-117 §P3 set.
`.pyi` stubs: add `wifi_densepose/aether.pyi` declaring the five classes/functions
with precise numeric types; extend the top-level `wifi_densepose/__init__.pyi`
with a `TYPE_CHECKING`-guarded re-export so `mypy --strict` sees them only when
the extra is installed.
### 3.3 Binding surface — MERIDIAN (`wifi_densepose.meridian`)
Backing crates: `wifi-densepose-train` (inference/adaptation path, **no
`tch-backend`**) + `wifi-densepose-signal::hardware_norm`. The `model`/`trainer`/
`losses` modules are libtorch-gated and are **out of scope** — Python gets the
domain-generalization *inference and calibration* surface, not the training loop.
| Python symbol | Wraps | Signature (Python) |
|---|---|---|
| `HardwareType` | `HardwareType` | `#[pyclass(eq, eq_int, hash, frozen)]` enum: `Esp32S3 / Intel5300 / Atheros / Generic`; `HardwareType.detect(subcarrier_count) -> HardwareType` |
| `HardwareNormalizer` | `HardwareNormalizer` | `.normalize(frame: CsiFrame, hw: HardwareType) -> CanonicalCsiFrame` |
| `CanonicalCsiFrame` | `CanonicalCsiFrame` | frozen; `.amplitudes`, `.phases`, `.hardware_type` getters |
| `GeometryEncoder` | `GeometryEncoder` | `GeometryEncoder(MeridianGeometryConfig)`; `.encode(ap_positions: list[tuple[float,float,float]]) -> list[float]` (64-dim, permutation-invariant) |
| `MeridianGeometryConfig` | `MeridianGeometryConfig` | frozen config |
| `RapidAdaptation` | `RapidAdaptation` | `.calibrate(csi_windows: list[list[list[float]]]) -> AdaptationResult` (10-sec unlabeled few-shot) |
| `AdaptationResult` | `AdaptationResult` | frozen result: `.frames_used`, `.converged`, `.loss` |
| `CrossDomainEvaluator` | `CrossDomainEvaluator` | `.evaluate(...) -> dict[str, float]` (in/cross/few-shot MPJPE, domain-gap ratio) |
GIL strategy: `normalize`, `encode`, `calibrate`, and `evaluate` are wrapped in
`py.allow_threads`. `normalize` targets <50 µs/frame (ADR-027 §4.1) and `encode`
<100 µs (§4.3), but `calibrate` runs contrastive test-time training over 200
frames and is the primary GIL-release beneficiary.
`.pyi` stubs: `wifi_densepose/meridian.pyi`. `DomainFactorizer` /
`GradientReversalLayer` / `VirtualDomainAugmentor` are **training-time only** and
are *not* bound in P6 (they need the tch training loop) — Open Question §11.2
records this boundary.
### 3.4 Binding surface — MAT (`wifi_densepose.mat`)
Backing crate: `wifi-densepose-mat`, bound behind the `[mat]` extra so the
disaster/ML stack never enters the default wheel — mirroring the upstream `mat`
cargo feature exactly. `DisasterResponse::start_scanning` is async (tokio); rather
than bind an event loop, P6 binds the **sync ingest + query surface** and a
single-shot `scan_once()` helper (a sync wrapper over one `scan_cycle`, added
Rust-side if needed — see §11.3).
| Python symbol | Wraps | Signature (Python) |
|---|---|---|
| `DisasterType` | `DisasterType` | `#[pyclass(eq, eq_int, hash, frozen)]` enum: `Earthquake / BuildingCollapse / Avalanche / Flood / Mine / Unknown` |
| `TriageStatus` | `TriageStatus` | frozen enum (START protocol classes) |
| `DisasterConfig` | `DisasterConfig` | builder-style kwargs: `DisasterConfig(disaster_type, sensitivity=0.8, confidence_threshold=0.5, max_depth=5.0)` |
| `DisasterResponse` | `DisasterResponse` | `.push_csi_data(amplitudes, phases)`; `.scan_once()`; `.survivors() -> list[Survivor]`; `.survivors_by_triage(status) -> list[Survivor]` |
| `Survivor` | `Survivor` | frozen: `.id`, `.triage_status`, `.location`, `.vital_signs` getters |
| `VitalSignsReading` | `VitalSignsReading` | frozen: breathing / heartbeat / movement fields |
GIL strategy: `push_csi_data` and `scan_once` wrap the detection-pipeline call in
`py.allow_threads` — the ensemble classifier + localization are the compute-heavy
part and touch no Python state.
`.pyi` stubs: `wifi_densepose/mat.pyi`.
---
## 4. Benchmarking & the measured-vs-claimed parity requirement
A binding that "runs without crashing" is worthless if it silently regresses
accuracy versus the native Rust call. The point of P6 is to prove the Python
surface reproduces the Rust subsystem **bit-for-bit**, then to hold each binding
to the *same* published SOTA bar its ADR already claims.
### 4.1 Parity harness (bit-for-bit, mandatory)
Each subsystem ships a golden-vector parity test. A committed input fixture is
run through **both** a tiny native-Rust reference binary (in
`v2/crates/wifi-densepose-py/tests/golden/`) and the Python binding; the two
outputs must hash-match under SHA-256 (the ADR-028 / ADR-117 §5.7 witness scheme):
- `aether`: identical 128-dim embedding bytes for a fixed CSI window + fixed seed.
- `meridian`: identical `CanonicalCsiFrame` bytes for a fixed ESP32 (64-sub) and
Intel-5300 (30-sub) frame; identical 64-dim geometry vector for fixed AP set.
- `mat`: identical triage classification + survivor count for a fixed CSI stream.
A mismatch is a **release blocker**, not a warning. This is the "MEASURED, not
CLAIMED" gate the project holds itself to.
**Scope, stated honestly:** parity proves the **strongest claim available today**
the Python binding is bit-identical to native Rust for the bound surface. It is
**not** accuracy validation. The bound AETHER surface moreover ships *untrained*
(random-init weights; a `load_weights` API exists since `65da488ad` but no trained
checkpoint exists to load — §6.7.2, §13.c.a), so byte-equality here says nothing
about the SOTA accuracy bars in §4.3; those remain OPEN (§6.7, §13.c).
### 4.2 pytest-benchmark micro-benchmarks
Following the existing `python/bench/test_bench_vitals.py` pattern (skipped by
default via `addopts`; run with `pytest python/bench/ --benchmark-only`):
- `python/bench/test_bench_aether.py` — steady-state `embed()` per-window cost;
assert < 2 ms (ADR-024 §2.8 FP32 target < 1 ms with headroom) and that batched
`embed()` scales linearly (no accidental O(n²)).
- `python/bench/test_bench_meridian.py``normalize()` < 200 µs/frame,
`encode()` < 200 µs (ADR-027 §4.1/§4.3 targets ×2 headroom).
- `python/bench/test_bench_mat.py``scan_once()` per-cycle cost bounded by the
configured scan interval.
### 4.3 SOTA accuracy bar the binding must reproduce (not merely run)
The parity harness (§4.1) guarantees the Python path is byte-identical to Rust, so
these published numbers are the bar the *binding output* is validated against on a
committed labeled fixture — a regression in any is a binding bug:
| Metric | Bar | Source |
|---|---|---|
| WiFlow-STD pose accuracy | **~96% PCK@20** (MEASURED-EQUIVALENT) | ADR-152 §2.2 |
| Room identification (k-NN on `env_fingerprint`) | **> 95%** | ADR-024 §2.8 |
| Person re-ID mAP | **> 80%** (WhoFi bar 95.5% on NTU-Fi) | ADR-024 §2.8, §1.5 |
| Anomaly detection F1 | **> 0.90** | ADR-024 §2.8 |
| INT8 rank correlation vs FP32 (Spearman) | **> 0.95** | ADR-024 §2.8 |
| Cross-domain MPJPE improvement | **> 20%** vs non-adversarial | ADR-027 §4.2 |
| Domain-gap ratio (cross/in-domain) | **< 1.5** | ADR-027 §4.6 |
| Few-shot MPJPE after 10-sec calibration | within **15%** of in-domain | ADR-027 §4.5 |
---
## 5. Phase ledger
```
P1 ──► P2 ──► P3 ──► P4
aether meridian mat docs +
bindings bindings behind examples
extra
```
> **Implementation note (2026-07-21):** P1P4 were built against the **real Rust
> code at HEAD**, not this ADR's proposed surface. Where §3's proposed API named
> functions/fields that do not exist in the crates (e.g. `aether_loss`/VICReg
> components/`alignment_metric`/`forward_dual`, `RapidAdaptation.calibrate`,
> `AdaptationResult.converged`), the coder **did not fabricate them** — the real
> API was bound and the deviation documented in each module header and commit body.
> Treat §3 as the original proposal and the commit messages as the authoritative
> record of what shipped.
### P1 — AETHER bindings (`[aether]` extra) — **DONE** (`d060998e3`; leaf-crate hoist `a47bb71b2`)
- [x] `aether` Cargo feature + gated optional `wifi-densepose-sensing-server` dep;
default build links **0** sensing-server refs (base wheel stays lean).
- [x] `python/src/bindings/aether.rs``AetherConfig` (→ real `EmbeddingConfig`),
`CsiAugmenter.augment_pair`, `EmbeddingExtractor.embed` (128-dim L2-normed,
GIL-released), `info_nce_loss`, `cosine_similarity`. **Not bound** (absent in
`embedding.rs` at HEAD, a Rust-side gap, not fabricated): `aether_loss`/VICReg
components, `alignment_metric`, `uniformity_metric`, `forward_dual`, `vicreg_*`.
- [x] `#[cfg(feature = "aether")]` gate + facade + `aether.pyi` + `[aether]` extra.
- [x] `python/tests/golden/aether_embedding.sha256` parity fixture:
`tests/aether_parity.rs` locks the native reference; `tests/test_aether.py`
asserts identical SHA-256 of the LE-f32 bytes.
- [x] **Verified:** `cargo test --features aether --test aether_parity` → 2/2;
`pytest tests/test_aether.py` → 9/9.
- [x] **Leaf-crate hoist (`a47bb71b2`):** `embedding.rs` moved into a new
`wifi-densepose-aether` crate. Measured stripped wheel **~361 KB → ~312 KB** (was
already ~14× under the 5 MB budget — see §13.a; the hoist's value is build-time
71 s → 12 s + dep-graph hygiene, not size). No regression: `aether_parity` 2/2,
`pytest` 9/9, sensing-server 217+388 tests 0 failed, new `wifi-densepose-aether`
crate 96 passed.
### P2 — MERIDIAN bindings (`[meridian]` extra) — **DONE** (`189ac9dfb`)
- [x] `meridian` feature + gated optional `wifi-densepose-train` (**no `tch-backend`
— libtorch avoided, confirmed**) + `wifi-densepose-signal` deps.
- [x] `python/src/bindings/meridian.rs``HardwareType`/`HardwareNormalizer`/
`CanonicalCsiFrame` (real API: `normalize(amplitude, phase, hw)` over f64 →
`Result`; singular `amplitude`/`phase` fields), `MeridianGeometryConfig`/
`GeometryEncoder` (64-dim, permutation-invariant), `RapidAdaptation`
(**real API: `push_frame` + `adapt()`**, not the ADR's `calibrate`) →
`AdaptationResult` (`lora_weights`/`final_loss`/`frames_used`/
`adaptation_epochs`; **no `converged`**), `CrossDomainEvaluator` + `mpjpe`. All
compute paths GIL-released. Training-time types (`DomainFactorizer`, GRL,
`VirtualDomainAugmentor`) correctly left out of P6 scope.
- [x] Gate + facade + `meridian.pyi` + `[meridian]` extra; default dep graph has 0
train/signal/sensing-server refs.
- [x] `tests/golden/meridian_output.sha256` parity fixture (esp32 + intel canonical
frames + 64-dim geometry vector + rapid-adapt LoRA weights).
- [x] **Verified:** `cargo test --features meridian --test meridian_parity` → 2/2;
`pytest tests/test_meridian.py` → 13/13.
### P3 — MAT bindings behind `[mat]` extra — **DONE** (`1c9727f9c`)
- [x] `mat` feature + gated optional `wifi-densepose-mat` dep. **§11.3 resolved: no
Rust change needed** — the public async `start_scanning()` already runs exactly
one `scan_cycle` when `continuous_monitoring == false`; the binding forces that
flag off and drives one cycle on a private current-thread tokio runtime.
- [x] `python/src/bindings/mat.rs``DisasterType` (**9 variants at HEAD**, not the
6 the ADR listed), `TriageStatus` (5, START), `DisasterConfig`,
`DisasterResponse` (`initialize_event`/`add_zone`/`push_csi_data`/`scan_once`/
`survivors`/`survivors_by_triage``initialize_event`+`add_zone` are **required
additions** the ADR surface omitted), `Survivor` (`latest_vitals`, since real
`vital_signs` is a history), `VitalSignsReading`, `ScanZone.rectangle`/`.circle`.
`push_csi_data`+`scan_once` GIL-released.
- [x] Gate + facade + `mat.pyi` + `[mat]` **and** `[sota]` (superset) extras.
- [x] `tests/golden/mat_result.sha256` parity fixture over a canonical
`count=<K>;triage_priorities=<sorted>` string (UUIDs/timestamps excluded as
non-deterministic). **Honest scope: proves binding==native path, NOT live
detection accuracy** — the synthetic stream yields 1 survivor, triage Delayed.
- [x] **Verified:** `cargo test --features mat --test mat_parity` → 2/2;
`pytest tests/test_mat.py` → 7/7.
### P4 — Docs, examples, and benchmark suite — **DONE** (`0f405213d`)
- [x] `python/bench/test_bench_{aether,meridian,mat}.py` (pytest-benchmark, §4.2).
Measured on a `--release --features sota` wheel: AETHER `embed()` ~150 µs
(target <2 ms), batch 1/8/64 = 140/1091/8509 µs (linear); MERIDIAN `normalize()`
~2.2 µs (target <200 µs), `encode()` ~6.9 µs; MAT ingest+`scan_once()` ~40 ms /
256-frame (< 500 ms). All pass.
- [x] `python/examples/{reid_from_csi,cross_room_calibrate,mat_triage}.py` — typed,
runnable, `mypy --strict` clean; README SOTA extras table.
- [~] Parity harness wiring into CI as a **release-blocking gate** — golden gates
are green locally (`cargo test --features sota` → 6/6; 3/3 SHA gates), but the CI
**wiring** is not done (§6.6 PARTIAL — see §13.b).
- [ ] Update ADR-117 §6 "P6+ Deferred" to point at this ADR — still open.
### P5 — New required follow-ups (blocking Accepted)
See §13. In short: (a) three leaf-crate hoists — **DONE** (`a47bb71b2`/`7ed57f041`/
`99fea9df9`; only MAT was a real budget fix, AETHER was a false alarm), (b) wire the
parity harness into CI as an actual release gate — **still open**, (c) source/generate
labeled fixtures to validate the SOTA accuracy bars (§4.3) for real — **still open**.
### P6+ — Deferred (unchanged from ADR-117)
- [ ] `wifi-densepose-nn` / libtorch bindings (MERIDIAN training loop,
`DomainFactorizer`, GRL) — still blocked on the libtorch wheel-size question.
- [ ] `wifi-densepose-ruvector` RuVector attention bindings.
- [ ] Matter integration helpers.
---
## 6. Acceptance criteria
Status recorded from the P4 self-verification run (`0f405213d`), reference machine
per ADR-117 §10. **7 of 9 met; 2 remain** — the ADR is therefore **not** Accepted.
- [x] **§6.1** `pip install wifi-densepose` (no extras) → default wheel **279 KB**
(≤ 5 MB); `build_features()` carries no `p6-*` feature — base wheel byte-for-byte
unaffected by P6. **PASS**
- [x] **§6.2** `pytest python/tests/test_aether.py -q`**9/9**, incl. a real
128-dim `embed()` round-trip asserting L2-norm ≈ 1.0 and byte-identity to the
golden Rust reference. **PASS**
- [x] **§6.3** `pytest python/tests/test_meridian.py -q`**13/13**, incl.
ESP32 (64-sub) **and** Intel-5300 (30-sub) canonicalization hash-matching native
Rust. **PASS**
- [x] **§6.4** `pytest python/tests/test_mat.py -q`**7/7**, incl. a fixed CSI
stream whose triage classification matches native `DisasterResponse` exactly.
**PASS**
- [x] **§6.5** `pytest python/bench/ --benchmark-only` — all targets met (AETHER
`embed()` ~150 µs < 2 ms; MERIDIAN `normalize()` ~2.2 µs, `encode()` ~6.9 µs
< 200 µs; MAT `scan_once()` ~40 ms < 500 ms). **PASS**
- [~] **§6.6** Parity harness (§4.1): all three golden-vector SHA-256 gates green
(`cargo test --features sota` → 6/6). **But CI wiring** as a release-blocking
gate is **not done** (out of `python/` scope). **PARTIAL — see §13.b.**
- [ ] **§6.7** SOTA-bar reproduction (§4.3): **definitively OPEN** — cannot be
closed transitively via the parity harness. Investigated; three concrete reasons:
1. **The native SOTA numbers aren't reproduced by any committed, runnable-today
test.** ADR-152 ~96% PCK@20 is a frozen result in
`benchmarks/wiflow-std/results/eval_retrained.json` that points at an **external
checkpoint** (`/home/ruvultra/wiflow-std-bench/upstream/test/best_pose_model.pth`,
not in the repo); the only relevant test `test_wiflow_std_parity.rs` is
`#![cfg(feature = "tch-backend")]` **and** `#[ignore]`d (needs gitignored
fixtures + LibTorch). ADR-027's `eval.rs::CrossDomainEvaluator` tests are pure
unit-math on hand-coded 23-element vectors, not dataset accuracy. ADR-024's
only accuracy-ish test asserts Spearman > 0.90 on **synthetic random**
embeddings (not real CSI, not the published > 0.95 bar); no room-ID / mAP /
anomaly-F1 test exists at all.
2. **The bound AETHER surface ships untrained.** `EmbeddingExtractor`/
`ProjectionHead` default to random Xavier init (`Linear::with_seed(…,
2024/2025)`, `embedding.rs:9798`). A weight-loading API now **does** exist
(`load_weights`/`save_weights`, `65da488ad` — see §13.c.a), so the earlier
"no loading path" blocker is removed; but **no trained checkpoint exists** to
load, so the binding still produces untrained embeddings and cannot validate
mAP > 80% or any trained-model bar today.
3. **No committed labeled CSI input/pose-pair data exists** to reuse (MM-Fi/NTU-Fi
appear only as config-default subcarrier counts / external paths;
`benchmarks/wiflow-std/results/*.npy` are corruption masks + result summaries,
not labeled fixtures).
The §4.1 parity harness proves the **strongest claim available today** — the
Python binding is bit-identical to native Rust for the bound (untrained) surface.
That is **not** accuracy validation. **See §13.c.**
- [x] **§6.8** `.pyi` stubs present for all three modules; `mypy --strict` passes on
the three examples. **PASS**
- [x] **§6.9** `python -c "import wifi_densepose.aether"` (etc.) on the base wheel
raises a clear `ImportError` naming the missing extra. **PASS**
No regression: 76 pre-existing tests pass on the default wheel. The two unmet
criteria (§6.6 CI wiring, §6.7 accuracy) plus the wheel-size hoists (§13.a) are the
gate to Accepted.
---
## 7. Consequences
### 7.1 Positive
- **Closes the ADR-117 P6 gap**: the three most-requested SOTA subsystems become
scriptable from Python without touching the Rust workspace.
- **Default wheel stays lean**: feature-gated extras preserve ADR-117 §5.4's ≤ 5 MB
budget and "no heavy system deps" invariant; MAT's ML stack and MERIDIAN's
libtorch path never enter the base wheel.
- **Reuses the proven idiom**: no new binding machinery — same `#[pyclass]` +
`py.allow_threads` + `register()` pattern already shipping in `bindings/vitals.rs`.
- **Prove-everything alignment**: the parity harness makes "the Python binding
equals the Rust core" a *measured, hash-verified* claim, not an assertion —
matching the project's MEASURED-vs-CLAIMED discipline.
- **Upstream consistency**: `[mat]` pip extra mirrors the `mat` cargo feature, so
the Python packaging story matches the Rust one exactly.
### 7.2 Negative
- **cibuildwheel matrix grows**: `[sota]` is a distinct compiled variant, adding a
build axis (and CI time) beyond ADR-117's 5-wheel abi3 matrix.
- **AETHER's backing crate is server-shaped**: depending on
`wifi-densepose-sensing-server` (Axum/tokio) risks pulling a runtime into an
extension module; may force a Rust-side refactor to hoist `embedding.rs` into a
leaf crate (§11.1).
- **MERIDIAN surface is partial**: training-time types (`DomainFactorizer`, GRL,
`VirtualDomainAugmentor`) stay unbound until the deferred libtorch tier, so the
Python API is inference/adaptation-only — potential user confusion (mitigated by
docs + `.pyi` omissions).
- **Golden fixtures are maintenance surface**: any intentional numeric change in a
Rust subsystem requires regenerating and re-witnessing its golden vector.
### 7.3 Neutral
- The `[sota]` convenience extra is purely additive; users who want one subsystem
install one extra.
- No change to the v2.0.0 semver line; extras ship additively as v2.x.y.
---
## 8. Alternatives considered
### Alt-A: Fold all three into the default wheel
Rejected — breaks ADR-117 §5.4's ≤ 5 MB budget, drags MAT's ML stack and (via
MERIDIAN training) libtorch into every install, and contradicts the upstream
`mat` cargo-feature gating.
### Alt-B: Separate PyPI packages (`wifi-densepose-aether`, etc.)
Rejected for the SOTA trio — three packages fragment the import namespace and
duplicate the abi3/cibuildwheel setup. (This remains the right call for the
libtorch `nn` tier per ADR-117 Open Q §11.2, which is genuinely heavy.) Extras of
one wheel keep `wifi_densepose.*` coherent.
### Alt-C: Pure-Python reimplementation of the three subsystems
Rejected explicitly — this is the exact drift ADR-117 §8 Alt-C was created to
exit. A Python reimplementation would immediately begin diverging from the Rust
SOTA and could not pass the §4.1 bit-for-bit parity gate.
### Alt-D: REST/WS client to a running sensing-server for AETHER
Rejected as the primary path — provides zero offline embedding utility and cannot
host the parity harness over local Rust code (same reasoning as ADR-117 §8 Alt-B).
The pure-Python client layer (`[client]`) remains available for streaming.
---
## 9. Risks
| Risk | Likelihood | Severity | Mitigation |
|---|---|---|---|
| `wifi-densepose-sensing-server` pulls tokio into the extension module | ~~High~~ **Not realized** | ~~High~~ **Low** | **Measured, not realized:** the stripped `[aether]` wheel was **~361 KB** (14× under budget) even before the hoist — linker DCE (`--gc-sections`) strips the server's unreached Axum/tokio/worldgraph code because the binding reaches only pure-compute symbols. Hoist (`a47bb71b2`) still done for build-time / dep-graph hygiene, not budget. See §11.1, §13.a |
| MERIDIAN accidentally links `tch-backend` (libtorch) via a default feature | Medium | High | Explicit `default-features = false` on `wifi-densepose-train`; CI `auditwheel`/`ldd` check that no libtorch symbol is present in the `[meridian]` wheel |
| `[sota]` build axis blows up cibuildwheel time | Medium | Medium | Build `[sota]` variant only on tagged releases, not every PR |
| Golden vectors drift when a Rust subsystem changes intentionally | Medium | Low | Documented regeneration step + ADR-028 witness re-sign; parity mismatch is a loud release blocker, never silent |
| MAT async-only surface has no clean sync entry point | Medium | Medium | Add sync `scan_once()` wrapper Rust-side (§11.3) before binding |
| Users install base wheel and expect `wifi_densepose.aether` | Low | Low | Clear `ImportError` naming the missing extra (acceptance criterion §6) |
---
## 10. Compatibility
- No change to the default wheel, its abi3-py310 base, or its size budget.
- Extras ship additively on the existing v2.x line; no semver break.
- `[mat]` pip extra ↔ `mat` cargo feature parity is preserved by construction.
- `.pyi` stubs are gated so `mypy --strict` only sees a subsystem when its extra
is installed.
---
## 11. Open questions
1. **AETHER crate shape****RESOLVED (`a47bb71b2`).** The original worry that
linking `wifi-densepose-sensing-server` would bloat the wheel was **never
measured** — it reasoned from the dependency tree (server has non-optional
tokio/Axum ⇒ wheel must be huge). The stripped-release measurement disproves it:
`[aether]` was **369,782 B (~361 KB)** *before* the hoist — already ~14× under
the 5 MB budget — and **319,719 B (~312 KB)** after. Linker dead-code elimination
(`--gc-sections` on the pyo3 cdylib) already strips the server's unreached
Axum/tokio/worldgraph/ruvector paths because the binding reaches only
pure-compute symbols. The hoist into `wifi-densepose-aether` was still done — its
real payoff is **build-time** (`[aether]` alone 71 s → 12 s), **dep-graph
hygiene** (`python/Cargo.lock` 1238 lines), and **removing latent risk** (a
future change that makes server code reachable would then genuinely bloat the
wheel). **Convention note:** measure the stripped release wheel size before
assuming a dependency-tree risk requires a hoist — linker DCE handles pure-Rust
unreached code, but native/FFI-bundled deps (e.g. `ort`/ONNX Runtime, see §13.a
MAT) are *not* stripped and are the real size-risk category.
2. **MERIDIAN training-time types**: `DomainFactorizer`, `GradientReversalLayer`,
and `VirtualDomainAugmentor` are meaningful only with the tch training loop.
Confirm they stay unbound in P6 and move with the deferred libtorch tier.
*Tentative: yes — P6 is inference/adaptation only.*
3. **MAT sync entry point**: `DisasterResponse::start_scanning` is an async tokio
loop. Does a sync single-cycle `scan_once()` already exist, or must it be added
Rust-side? *Tentative: add a thin sync `scan_once()` wrapping one `scan_cycle`;
do not bind an event loop into the extension.*
4. **`[sota]` wheel vs per-extra wheels**: cibuildwheel builds one binary per
feature-set. Do we publish one `[sota]` wheel and let pip select, or per-extra
wheels? This affects the number of build variants. *Tentative: single `[sota]`
superset wheel on tagged releases; base wheel stays feature-free.*
5. **INT8 embedding path in Python**: ADR-024 §2.8 sets an INT8 rank-correlation
bar. Do we expose the INT8 quantized `embed()` in P6, or FP32 only first?
*Tentative: FP32 in P6; INT8 follows once the Rust quantized path is stable.*
---
## 12. References
### Internal ADRs
- **ADR-117**: pip modernization via PyO3 + maturin — the wheel this ADR extends;
§5.1/§5.4/§5.6 (extras + wheel budget), §6 "P6+ Deferred".
- **ADR-024**: Project AETHER — contrastive CSI embedding; §2.6 module surface,
§2.8 performance/accuracy targets.
- **ADR-027**: Project MERIDIAN — cross-environment domain generalization; §4
phase acceptance criteria, §4.6 evaluation protocol.
- **ADR-152**: WiFi-Pose SOTA 2026 — WiFlow-STD ~96% PCK@20 MEASURED-EQUIVALENT bar.
- **ADR-028**: ESP32 capability audit / witness scheme — the SHA-256 parity gate
the §4.1 golden harness reuses.
### Rust source (verified HEAD)
- `v2/crates/wifi-densepose-sensing-server/src/embedding.rs` — AETHER.
- `v2/crates/wifi-densepose-train/src/{domain,geometry,rapid_adapt,virtual_aug,eval}.rs` — MERIDIAN.
- `v2/crates/wifi-densepose-signal/src/hardware_norm.rs` — MERIDIAN HardwareNormalizer.
- `v2/crates/wifi-densepose-mat/src/lib.rs` — MAT.
- `python/src/bindings/vitals.rs` — the `py.allow_threads` GIL-release precedent.
- `python/bench/test_bench_vitals.py` — the pytest-benchmark pattern P4 follows.
---
## 13. Open follow-ups (blocking Accepted)
P1P4 are real, well-tested progress: **32/32 binding tests** (aether 9, meridian
13, mat 7, + 3 smoke) and **6/6 native parity tests** all pass, verified on the
reference machine. The three leaf-crate hoists (§13.a) are now **done**. Two items
still gate Accepted: **§13.b** (wire the parity harness into CI as a release gate)
and **§13.c** (the SOTA accuracy gap — bindings are structurally *untrained*, and no
eval harness or labeled data exists yet; genuine long-term work, not a quick fix).
### 13.a — Leaf-crate hoists (all three DONE) — one real fix, one minor, one false alarm
All three extras' backing crates carry heavy declared deps, so the hoist was applied
to each. But **measuring the stripped release wheel** (not reasoning from the
dependency tree) showed the wheel-size story differs sharply per extra. Linker
dead-code elimination (`--gc-sections` on the pyo3 cdylib) strips **pure-Rust
unreached** code, so a heavy declared dep tree does **not** imply a big wheel;
**native/FFI-bundled** deps (`ort`/ONNX Runtime's native library) are the exception
— DCE cannot strip them, and those are the real size risk.
| Extra | Commit | Wheel size (stripped) | Verdict |
|---|---|---|---|
| `[aether]` | `a47bb71b2` | **~361 KB → ~312 KB** | **False alarm.** Never breached the 5 MB budget — DCE already stripped the sensing-server's unreached Axum/tokio/worldgraph/ruvector code. Hoist justified by build-time (71 s → 12 s), dep-graph hygiene (`Cargo.lock` 1238 lines), and latent-risk removal — **not** budget. |
| `[mat]` | `7ed57f041` | **8.4 MB → 2.0 MB** | **Real, measured regression.** `wifi-densepose-nn` bundles `ort`/ONNX Runtime, a **native** library DCE does **not** strip → genuine breach. Fix necessary and correctly characterized. |
| `[meridian]` | `99fea9df9` | **1.8 MB → 1.7 MB** | **Real but minor.** Measured from the start; a dead dep removed. Already under budget; small win. `libtorch` correctly avoided throughout (`tch` optional, off). |
These were changes **inside** the upstream `v2/` crates (owned by other agents this
session); the default wheel was unaffected throughout because every extra is
feature-gated off. All three hoists are now landed — the remaining Accepted blockers
are §13.b (CI gate) and §13.c (accuracy fixtures), **not** wheel size.
### 13.b — Wire the parity harness into CI as a real release gate (§6.6)
The three golden-vector SHA-256 gates pass locally (`cargo test --features sota`
6/6) but are not yet wired into a CI workflow that **blocks release** on mismatch.
Add a job to the ADR-117 §5.4 publish pipeline that runs the native `*_parity.rs`
references + the `pytest` binding checks and fails the release on any divergence.
### 13.c — Close the SOTA accuracy gap (§4.3, §6.7) — genuine long-term work
This is the most important honesty gap and it is **more fundamental than missing
labeled data** (see §6.7 for the three findings). The parity harness proves the
Python binding is **byte-identical to the native Rust path** for the bound, **but
untrained**, surface — it does **not** prove the cited SOTA numbers (ADR-152
~96% PCK@20; ADR-024 room-ID > 95% / re-ID mAP > 80% / anomaly F1 > 0.90; ADR-027
cross-domain MPJPE + 20% / domain-gap < 1.5). Those bars remain **CLAIMED, not
MEASURED** by this work.
Closing it requires three steps, in dependency order:
- **(a) Add trained-weight loading to the AETHER/pose bindings — DONE (`65da488ad`).**
`EmbeddingExtractor` gained `save_weights(path)` / `load_weights(path)` /
`param_count` on both the native crate and the Python binding (GIL-released,
`ValueError`/never-panics on bad input), removing the "structurally untrained, no
loading path" blocker: a real checkpoint can now be loaded whenever one exists.
Default construction is unchanged (still random `with_seed` init, clearly labeled
untrained) — purely additive. **Format tradeoff:** rather than pull in
`safetensors`/`serde`/`bincode`, the on-disk format is raw little-endian `f32`
with a 12-byte header (8-byte magic `AETHERW1` + `u32` param count), reusing the
pre-existing `flatten_weights`/`unflatten_weights` — this deliberately preserves
`wifi-densepose-aether`'s zero-dependency std-only leaf-crate property from the
§13.a hoist. **Verified:** `cargo test -p wifi-densepose-aether` 98/98; parity 3/3
incl. the new cross-language golden `aether_weights_parity.rs` (native Rust and the
Python binding load the same weight file and produce a byte-identical embedding
SHA-256, and the loaded weights demonstrably move the output off the random-init
baseline — not a silent no-op); `pytest test_aether.py` 13/13 (up from 9).
**This does NOT close §6.7** — it is the *capability* to load weights, not trained
weights; (b) and (c) below remain, and no SOTA number is validated yet.
- **(b) Commit or source a small labeled CSI fixture** (input CSI + ground-truth
pose/identity/room labels) — **still OPEN.** Genuine **data-acquisition scope**.
- **(c) Build a real eval harness** computing PCK / mAP / room-ID / anomaly-F1 /
Spearman on (a)+(b) and asserting the published bars — **still OPEN.**
With (a) landed, the remaining work is (b) and (c): genuine research /
data-acquisition scope beyond one session. This is now purely a data-availability +
missing-eval-infra problem, **not** a binding defect. Status stays **Proposed**
until (b)(c) land and §4.3 is run for real.
+486
View File
@@ -0,0 +1,486 @@
# ADR-186: Training progress API — wire the orphaned in-server trainer to `/ws/train/progress`
| Field | Value |
|-------|-------|
| **Status** | Accepted |
| **Date** | 2026-07-21 |
| **Deciders** | ruv |
| **Codename** | **TRAIN-RECONNECT** — connecting a trainer that was written, committed, and then never plugged in |
| **Relates to** | [ADR-051](ADR-051-sensing-server-decomposition.md) (main.rs decomposition into ~14 modules), [ADR-151](ADR-151-per-room-calibration.md) (`train-room` specialist bank), [ADR-152](ADR-152-wifi-pose-sota-2026.md) (MAE recipe / geometry conditioning), [ADR-166](ADR-166-quality-engineering-security-hardening.md) (WS auth + god-object decomposition) |
| **Tracking issue** | [#1233](https://github.com/ruvnet/wifi-densepose/issues/1233) — "Training does not start /ws/train/progress returns 404 and no model is generated" (open) |
---
## 1. Context
### 1.1 The reported gap
A user starting training from the web dashboard hits
`ws://localhost:3000/ws/train/progress`, which **404s**, and the backend never
produces a trained `.rvf` model or any further log output beyond a single
"Training started" line. Issue #1233 is open, and the repo owner's own comment on
it states:
> The `/ws/train/progress` WebSocket endpoint is not yet exposed in the stable
> server — the training pipeline (room-calibration specialists, MAE pretraining)
> runs via the CLI (`wifi-densepose train-room`) rather than through the
> HTTP/WebSocket API, which is why the Docker image returns 404 for that path.
So the dashboard has a **"Start Training" button that silently no-ops**: it POSTs a
config, receives a `success: true` response, and then nothing happens — no error is
surfaced, no model is produced, no progress stream exists. A button that appears to
work but does nothing is the definition of slop, and this ADR exists to close that
gap honestly.
### 1.2 What the live server actually does today (evidence)
The stable server mounts **stub** training handlers. The POST handler flips a string
flag, logs one line, and returns success — it starts no job:
```rust
// v2/crates/wifi-densepose-sensing-server/src/main.rs:49865006
async fn train_start(
State(state): State<SharedState>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
let mut s = state.write().await;
if s.training_status == "running" { /* ... */ }
s.training_status = "running".to_string();
s.training_config = Some(body.clone());
info!("Training started with config: {}", body); // ← the one log line the issue reports
Json(serde_json::json!({
"success": true,
"status": "running",
"message": "Training pipeline started. Use GET /api/v1/train/status to monitor.",
}))
}
```
These three stubs — and **nothing else training-related** — are wired into the live
router:
```rust
// v2/crates/wifi-densepose-sensing-server/src/main.rs:80688071
// Training endpoints
.route("/api/v1/train/status", get(train_status))
.route("/api/v1/train/start", post(train_start))
.route("/api/v1/train/stop", post(train_stop))
```
There is **no `/ws/train/progress` route in the live app** — hence the 404 that
issue #1233 reports. The stub state fields backing them are just:
```rust
// v2/crates/wifi-densepose-sensing-server/src/main.rs:11251127
training_status: String, // "idle" | "running" | ...
training_config: Option<serde_json::Value>,
```
### 1.3 The surprising finding: a real trainer already exists, orphaned
The gap is **not** that training was never built for the server. A complete
in-server training pipeline **already exists in the tree** at
`v2/crates/wifi-densepose-sensing-server/src/training_api.rs` (1,860 lines). Its own
module doc describes what it does (`training_api.rs:125`):
- Loads recorded CSI from `.csi.jsonl` files, extracts signal features (subcarrier
variance, temporal gradients, Goertzel frequency-domain power).
- Trains a regularised linear model via batch gradient descent.
- Exports a calibrated `.rvf` model container via `RvfBuilder` on completion.
- **"No PyTorch / `tch` dependency is required. All linear algebra is implemented
inline using standard Rust math."** (`training_api.rs:1113`)
It runs training on a **background tokio task** and streams progress over a
`tokio::sync::broadcast` channel to a real WebSocket handler:
- `start_training` spawns the job: `tokio::spawn(async move { ... })`
(`training_api.rs:1564`, spawn at `:1610`).
- `ws_train_progress_handler` subscribes to `training_progress_tx` and forwards
`{"type":"progress", "data": …}` frames (`training_api.rs:17781836`).
- A `routes()` factory wires the whole surface, **including the missing route**:
```rust
// v2/crates/wifi-densepose-sensing-server/src/training_api.rs:18411849
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/v1/train/start", post(start_training))
.route("/api/v1/train/stop", post(stop_training))
.route("/api/v1/train/status", get(training_status))
.route("/api/v1/train/pretrain", post(start_pretrain))
.route("/api/v1/train/lora", post(start_lora_training))
.route("/ws/train/progress", get(ws_train_progress_handler))
}
```
**This module is dead code.** There is no `mod training_api;` declaration anywhere
in the crate — a repo-wide search for `training_api` returns only a doc-comment
mention in `path_safety.rs:9`. Because Rust never sees the file without a `mod`
declaration, `training_api.rs` is **not compiled into the binary at all**, and
`training_api::routes()` is never merged into the app. It was written, committed
(last touched by commit `9b07dff29`), and then orphaned.
### 1.4 Why it would not even compile if naively wired in
The orphan was written against a **different state shape than the one that shipped**.
`training_api.rs` expects its parent to expose an `AppStateInner` carrying a training
sub-state and a broadcast sender:
```rust
// v2/crates/wifi-densepose-sensing-server/src/training_api.rs:249
pub type AppState = Arc<RwLock<super::AppStateInner>>;
// handlers read s.training_state.status, s.training_state.task_handle,
// s.training_progress_tx (e.g. training_api.rs:1588, :1610, :1788)
```
But the **real** `AppStateInner` (`main.rs:1024`, aliased `SharedState` at
`main.rs:1249`) has none of those fields — only the `training_status: String` /
`training_config` stubs from §1.2. `training_state: TrainingState` is defined
locally in `training_api.rs:232`, and `training_progress_tx` exists nowhere on the
live state. So adding `mod training_api;` today produces a compile error: the module
references `AppStateInner` fields that do not exist. Wiring it in requires
**reconciling the state struct first**, not merely uncommenting a route.
### 1.5 The working path today
The path that actually trains a model is the CLI, exactly as the maintainer's
comment says:
- `wifi-densepose train-room``room.rs:241` `train_room(...)`, the ADR-151
Stage-25 per-room specialist-bank trainer (`enroll → train-room → room-watch`).
- The heavier `wifi-densepose-train` crate exposes epoch-level metrics
(`trainer.rs:43` `pub epoch: usize`, `trainer.rs:64` `best_epoch`) that a progress
stream could surface directly — the data a WebSocket needs already exists in the
training loop.
### 1.6 What this ADR is *not*
- Not a rewrite of the trainer. The pipeline in `training_api.rs` already exists;
this ADR reconnects and hardens it.
- Not a move of GPU/`tch`-backed training into the Axum server. The in-server
trainer is deliberately `tch`-free (§1.3). Heavy MAE/LoRA training stays in the
CLI / `wifi-densepose-train` crate; the server streams progress for the light,
pure-Rust specialist trainer and (optionally) proxies status for CLI-launched runs.
- Not a change to the `train-room` CLI contract (ADR-151). The CLI remains the
authoritative path for offline / batch training.
---
## 2. Current state — evidence
| Artifact | Value | Source |
|---|---|---|
| Live POST handler | `train_start` — flips a flag, logs, returns `success:true`, starts no job | `main.rs:49865006` |
| The "Training started" log line from the issue | `info!("Training started with config: {}", body)` | `main.rs:5000` |
| Live training routes | `train/status`, `train/start`, `train/stop` (stubs only) | `main.rs:80688071` |
| `/ws/train/progress` in live app | **Absent** → 404 | (no route in `main.rs` router) |
| Live training state fields | `training_status: String`, `training_config: Option<Value>` | `main.rs:11251127` |
| Real in-server trainer | 1,860-line implemented pipeline, `tch`-free, exports `.rvf` | `training_api.rs:125` |
| Real WS progress handler | subscribes to broadcast, streams `progress` frames | `training_api.rs:17781836` |
| Real route factory (has the missing route) | `routes()` incl. `/ws/train/progress` | `training_api.rs:18411849` |
| Background job spawn | `tokio::spawn` of the training task | `training_api.rs:1564`, spawn `:1610` |
| `mod training_api;` declaration | **None in the crate** (only a doc mention) | `path_safety.rs:9` |
| State-shape mismatch | expects `super::AppStateInner.{training_state, training_progress_tx}` | `training_api.rs:249`, `:232` |
| Real `AppStateInner` / `SharedState` | has neither field | `main.rs:1024`, `:1249` |
| Working training path | CLI `train-room` (ADR-151 specialist bank) | `room.rs:241` |
| Epoch metrics available to stream | `TrainMetrics.epoch`, `best_epoch` | `train/src/trainer.rs:43`, `:64` |
---
## 3. Gap analysis
| Capability | Desired | Today | Gap severity |
|---|---|---|---|
| `/ws/train/progress` resolves | 101 Switching Protocols, streams epoch/loss/eta | 404 (route absent) | **Critical** — the reported bug |
| "Start Training" produces a model | background job trains and writes `.rvf` | flag flip + one log line, no job, no model | **Critical** |
| Error surfaced to the user | button reflects real state / disabled with reason | silent no-op, `success:true` | **Critical** (slop) |
| In-server trainer compiled | part of the crate, unit-tested | orphaned; not compiled (no `mod`) | **High** |
| State supports progress streaming | `training_state` + `training_progress_tx` on `AppStateInner` | absent — orphan won't compile as-is | **High** |
| WS auth on the training surface | `/ws/train/progress` under bearer gate (ADR-166 §Sprint-1) | n/a (route absent) | **High** |
| `dataset_ids` path safety | validated before file open | `path_safety.rs` exists but unreached by live routes | **Medium** |
| Server ↔ CLI parity | shared/consistent training semantics | two divergent trainers (stub vs CLI vs orphan) | **Medium** |
---
## 4. Decision
**Chosen path: wire the existing in-server trainer into the live server** — reconcile
the state struct, declare the module, merge `training_api::routes()`, delete the
stub handlers, and expose a real `/ws/train/progress` that streams epoch/loss/eta
events from the already-implemented background job.
This is called **TRAIN-RECONNECT**.
### 4.1 Why this path, and not "make the button honestly say CLI-only"
The task framing offered two honest options. Investigation decided it:
| Consideration | Evidence | Implication |
|---|---|---|
| Is server-side training genuinely GPU/`tch`-bound (→ keep CLI-only)? | The in-server trainer is explicitly **`tch`-free**, pure Rust, exports `.rvf` (`training_api.rs:1113`) | The "too heavy for Axum" argument is contradicted by the code |
| Does a real streaming implementation already exist? | Full pipeline + broadcast + WS handler + `routes()` present (`training_api.rs:1564,1778,1841`) | The impressive-sounding option is also the *least* new code — it already exists |
| Why does it 404 then? | No `mod training_api;`; state-shape mismatch (`:249` vs `main.rs:1024`) | The fix is reconnection + reconciliation, not new invention |
Because the honest, code-supported reality is "a working trainer was written and left
unplugged," the right decision is to plug it in — this is not choosing the flashier
option over the code; it *is* what the code says.
**However**, path B is retained as a **mandatory fallback guarantee** (Phase P5): if,
for a given build/deployment, server-side training is disabled (e.g. behind a
feature flag, or on the lightweight appliance image where recordings aren't
available), the dashboard button MUST be disabled with a tooltip pointing at
`wifi-densepose train-room` — never a silent `success:true` no-op again. The slop is
eliminated in both the enabled and disabled configurations.
### 4.2 Scope boundary — light trainer streams, heavy trainer proxies
- The **pure-Rust specialist trainer** (`training_api.rs`, ADR-151 flavour) runs
in-process and streams live epoch/loss/eta over `/ws/train/progress`.
- **Heavy MAE/LoRA training** (`wifi-densepose-train`, `tch`/GPU) stays CLI-launched.
The server does not host it; at most `/api/v1/train/status` reports on a
CLI-launched run if one registers itself. Streaming heavy training is out of scope
for this ADR (noted as an open question, §8).
---
## 5. Detailed design
### 5.1 Reconcile `AppStateInner`
Replace the two stub fields (`main.rs:11251127`) with the sub-state the trainer
expects, so `training_api.rs` compiles against `super::AppStateInner`:
```rust
// main.rs — inside AppStateInner (replacing training_status / training_config)
training_state: training_api::TrainingState, // status, epoch, best_pck, task_handle
training_progress_tx: tokio::sync::broadcast::Sender<String>, // progress fan-out
```
`train_status` consumers that read `s.training_status` / `s.training_config` are
updated to read `s.training_state.status`. The broadcast sender is created at state
init (`main.rs:7826` region, where the stubs are seeded today).
### 5.2 Declare and merge the module
- Add `mod training_api;` to `main.rs` (or `pub mod` in `lib.rs` if the router is
assembled there).
- Delete the stub handlers `train_start` / `train_stop` / `train_status`
(`main.rs:49775023`) and their three route mounts (`main.rs:80698071`).
- Merge the real router **after** `.with_state(state.clone())`, the same pattern the
RuField surface already uses (`main.rs:81048111`):
```rust
// main.rs router assembly
.merge(training_api::routes())
```
so that `/api/v1/train/*` and `/ws/train/progress` resolve against the shared state.
### 5.3 Auth and safety (ADR-166 alignment)
- `/api/v1/train/*` sits under the existing opt-in bearer gate (`main.rs:80958102`,
`RUVIEW_API_TOKEN`). `/ws/train/progress` follows the same policy decision made for
`/ws/sensing` — document explicitly whether the training WS is gated (recommended:
gated when a token is set, since training reads/writes recordings and models).
- `dataset_ids` from `StartTrainingRequest` (`training_api.rs:126130`) are resolved
through `path_safety` before any file open — `path_safety.rs:9` already anticipates
`{dataset_id}.csi.jsonl` under `RECORDINGS_DIR`; wire it in the load path.
- Single-job concurrency guard: `start_training` already rejects a second run while
`training_state.status.active` (`training_api.rs:1571`) — keep it.
### 5.4 Progress event schema (already emitted)
The WS handler already frames messages as `{"type":"status"|"progress", "data": …}`
(`training_api.rs:17961815`). Confirm the `data` payload carries at minimum
`epoch`, `total_epochs`, `loss`, `best_pck`, and an `eta_seconds`; these map onto the
`TrainMetrics`/`TrainingStatus` fields already populated by the loop
(`training_api.rs:1251`, `train/src/trainer.rs:43,64`).
### 5.5 Dashboard honesty (both configurations)
- **Enabled build:** button POSTs `/api/v1/train/start`, then opens
`/ws/train/progress`; the UI renders live epoch/loss/eta and a terminal
success/failure with the output `.rvf` path.
- **Disabled build:** `/api/v1/train/start` returns a structured
`{"enabled": false, "reason": "...", "cli": "wifi-densepose train-room"}` and the
button renders disabled with a tooltip — no silent `success:true`.
---
## 6. Phase ledger
```
P0 ──► P1 ──► P2 ──► P3 ──► P4 ──► P5 ──► P6
repro state wire stream auth+ dash tests+
+audit recon router job safety honesty witness
```
### P0 — Reproduce & audit (evidence lock)
- [x] Confirmed the orphan: `grep -rn "mod training_api"` returned **nothing**; the only
hit was a doc mention in `path_safety.rs`. `training_api.rs` was uncompiled.
- [x] Confirmed the stub no-op (`train_start` at `main.rs:4986` flipped a string + logged
one line, no job, no `.rvf`) and the missing `/ws/train/progress` route.
### P1 — Reconcile `AppStateInner`
- [x] Replaced `training_status`/`training_config` with `training_state:
training_api::TrainingState` + `training_progress_tx: broadcast::Sender<String>`.
- [x] Updated state init; the only readers of the old fields were the stub handlers (deleted).
- [x] Added `mod training_api;` (+ `mod path_safety;`); the module compiles against the real state.
### P2 — Wire the router, delete the stubs
- [x] Removed `train_start`/`train_stop`/`train_status` and their 3 route mounts.
- [x] `.merge(training_api::routes())` — merged **before** `.with_state(...)` (not after).
The RuField surface merges after because it carries a *different* state; the training
router shares `SharedState`, so merging before is what puts `/api/v1/train/*` under the
same `/api/v1/*` bearer gate as everything else.
- [x] `/api/v1/train/*` and `/ws/train/progress` resolve (verified by HTTP tests, not 404).
### P3 — Confirm the real job streams and produces a model
- [x] The spawned job loads `.csi.jsonl` (falls back to a `frame_history` snapshot),
runs the gradient-descent loop, and writes a `.rvf` under `data/models`.
- [x] Progress frames carry `epoch`, `total_epochs`, `train_loss`, `val_pck`, `eta_secs`.
- [x] Server-vs-CLI semantics documented as **intentionally divergent** (§4.2, §9.2):
the server runs the light pure-Rust specialist trainer; heavy MAE/LoRA stays CLI.
### P4 — Auth & path safety
- [x] `/api/v1/train/*` sits under the existing `RUVIEW_API_TOKEN` bearer gate (merged
before `.with_state`); `/ws/train/progress` is intentionally **ungated**, matching
`/ws/sensing` (browsers can't attach an `Authorization` header to a WS upgrade).
- [x] `dataset_ids` resolved via `path_safety::safe_id` before file open; pinned by
`load_recording_frames_rejects_path_traversal`.
- [x] Single-job guard: `spawn_training_job` rejects a second start while active
(`is_active()``active_error`).
### P5 — Dashboard honesty (fallback guarantee)
- [x] Enabled build: `TrainingPanel` opens `/ws/train/progress` before the POST and renders
live epoch/loss/PCK/ETA + a terminal Complete state (already wired; verified).
- [x] Disabled build (`RUVIEW_DISABLE_SERVER_TRAINING`): start returns
`{enabled:false, cli:"wifi-densepose train-room"}` HTTP 409; the dashboard reads
`enabled` off `/api/v1/train/status` and disables the Start buttons with a CLI
tooltip — no silent no-op. Implemented via a runtime flag rather than a Cargo feature
so the `--no-default-features` test build keeps training ON (§9.4 resolved this way).
### P6 — Tests & witness
- [x] Live-socket test `ws_train_progress_live_101_and_frame`: genuine 101 handshake + a real
progress frame after POST start. Plus `ws_train_progress_route_is_wired_not_404`.
- [x] `http_train_start_produces_model_and_streams`: POST start → poll status → `.rvf` exists.
- [x] CHANGELOG updated. README/CLAUDE have no training route table, so no route-table edit
was needed there.
*(All phases complete. Acceptance criteria verified below — this ADR is Accepted.)*
---
## 7. Acceptance criteria (concrete verification)
All must pass before ADR-186 is Accepted:
- [x] **Orphan is reconnected:**
`grep -rn "mod training_api" v2/crates/wifi-densepose-sensing-server/src/`
returns a hit (`main.rs`), and
`cargo build -p wifi-densepose-sensing-server` **compiles** (proves the state
reconciliation in §5.1 is correct — the module cannot compile against the
current `AppStateInner`). **VERIFIED.**
- [x] **Route no longer 404s (HTTP upgrade):** verified in-process rather than with a live
`curl``ws_train_progress_live_101_and_frame` binds the training router on a real
socket and `tokio_tungstenite::connect_async` completes a genuine **101** handshake
(asserts `resp.status() == 101`); `ws_train_progress_route_is_wired_not_404` also
confirms the route is reached (426 under `oneshot`, **not** 404). **VERIFIED.**
- [x] **Progress actually streams:** `ws_train_progress_live_101_and_frame` connects the WS,
POSTs `/api/v1/train/start`, and receives a real `{"type":"progress","data":{...}}`
frame within the 10 s ceiling. **VERIFIED.**
- [x] **A model is produced:** `http_train_start_produces_model_and_streams` POSTs start,
polls `/api/v1/train/status` to completion, and asserts a **new `.rvf`** appeared under
`data/models/` (snapshot diff). Also covered by the trainer-level
`training_job_streams_real_progress_and_writes_model`. **VERIFIED.**
- [x] **No silent no-op remains:** `http_train_start_disabled_returns_structured_409` sets
`RUVIEW_DISABLE_SERVER_TRAINING` and asserts POST start returns **HTTP 409** with
`{"enabled":false, ...,"cli":"wifi-densepose train-room"}` and never `success:true`.
**VERIFIED.**
- [x] **Auth honored:** `/api/v1/train/*` is merged into the router **before** the
`RUVIEW_API_TOKEN` bearer middleware and `.with_state`, so it is covered by the exact
same `/api/v1/*` gate as every other authenticated route (verified by construction /
code review; `/ws/train/progress` is intentionally ungated like `/ws/sensing`). No new
dedicated runtime token test was added — the gate is the shared, already-tested
`bearer_auth` middleware. **VERIFIED (by construction).**
- [x] **Path safety:** `load_recording_frames_rejects_path_traversal` asserts
`dataset_ids:["../../etc/passwd"]` yields no frames (rejected by `path_safety::safe_id`
before any file open). **VERIFIED.**
- [x] **Integration test green:** `ws_train_progress_live_101_and_frame` (`#[tokio::test]`)
serves the training router, opens `/ws/train/progress`, and asserts a 101 upgrade + a
real progress frame — and, being built on `training_api::routes()`, cannot compile if
the module is orphaned again. **VERIFIED.**
- [x] **Workspace regression:** `cargo test -p wifi-densepose-sensing-server
-p wifi-densepose-train --no-default-features` — sensing-server bin **217 passed /
0 failed**, all train suites **0 failed**. A full `cargo test --workspace
--no-default-features` run initially surfaced a **test-only parallelism race** in the
new tests (two model-writing tests deleted `.rvf`s by directory-diff, occasionally
removing a file a third test asserted existed) — fixed by removing the cross-test
deletions (each test cleans only its own artifact; `data/models` is gitignored).
Re-verified post-fix: `cargo test --workspace --no-default-features` — **0 failed**
(exit 0). **VERIFIED.**
---
## 8. Consequences
### Positive
- Closes issue #1233: the dashboard button either trains-and-streams or honestly says
"use the CLI" — the silent no-op is gone in every configuration.
- Reclaims 1,860 lines of already-written, already-committed trainer that were dead
(uncompiled) code, and adds a test that keeps them wired.
- `/ws/train/progress` gives the UI real epoch/loss/eta, matching the maintainer's
stated intent.
- Forces the state-shape reconciliation that the orphan implied but never landed,
removing a latent "two competing training designs" trap in `AppStateInner`.
### Negative
- Editing `AppStateInner` (`main.rs:1024`) and the router (`main.rs:8068`) touches the
large `main.rs`; merge-conflict risk with concurrent work on the same file (the
ADR-166 decomposition is relevant here).
- Adds a live training code path to the server's attack surface — mitigated by the
bearer gate and `path_safety`, but it must be reviewed (network/hardware boundary,
per the pre-merge security checklist).
- Server and CLI now have two trainers that must be kept semantically consistent, or
their divergence explicitly documented.
### Neutral
- Heavy MAE/LoRA/`tch` training remains CLI-only; the server streams only the
light pure-Rust specialist trainer. Streaming heavy runs is deferred.
- The progress event schema (`epoch/loss/best_pck/eta`) is already emitted by the
orphan; no new schema is invented, only confirmed and documented.
---
## 9. Open questions
1. **WS auth policy for `/ws/train/progress`:** gate it whenever `RUVIEW_API_TOKEN`
is set (like `/api/v1/*`), or leave it open like `/ws/sensing`? *Tentative: gate
it — training reads recordings and writes models.*
2. **Server ↔ CLI trainer parity:** should the in-server trainer and
`wifi-densepose train-room` (ADR-151) share one code path, or remain deliberately
separate (server = quick UI-driven specialist fit; CLI = full bank + geometry
conditioning)? *Tentative: keep separate, document the split, share feature
extraction where cheap.*
3. **Heavy-training progress:** can a CLI-launched `wifi-densepose-train` (`tch`)
run register itself so `/api/v1/train/status` and the WS can report on it without
hosting it in-process? *Tentative: out of scope here; a follow-on ADR.*
4. **Feature-flagging server training:** should in-server training be behind a Cargo
feature (off on the lightweight appliance image), making the P5 disabled-button
path the default there? *Tentative: yes — flag it; default the UI to the honest
disabled state on images without recordings.*
---
## 10. References
- **Issue #1233**: https://github.com/ruvnet/wifi-densepose/issues/1233 — the reported bug.
- **Live stubs**: `v2/crates/wifi-densepose-sensing-server/src/main.rs:49775023` (handlers),
`:80688071` (routes), `:11251127` (state fields), `:1024`/`:1249` (`AppStateInner`/`SharedState`).
- **Orphaned trainer**: `v2/crates/wifi-densepose-sensing-server/src/training_api.rs`
module doc `:125`, `TrainingState` `:232`, `AppState` alias `:249`, `start_training` `:1564`
(spawn `:1610`), WS handler `:17781836`, `routes()` `:18411849`.
- **Not-a-module proof**: repo-wide `training_api` only in `path_safety.rs:9` (doc comment).
- **CLI working path**: `v2/crates/wifi-densepose-cli/src/room.rs:241` `train_room` (ADR-151).
- **Epoch metrics**: `v2/crates/wifi-densepose-train/src/trainer.rs:43`, `:64`.
- **ADR-166**: WebSocket authentication + `main.rs` decomposition (security context for this change).
- **ADR-151**: per-room calibration / `train-room` specialist bank.
@@ -0,0 +1,198 @@
# ADR-187: `archive/v1` Deprecation & Model-Weights Honest Labeling
- **Status**: Accepted
- **Date**: 2026-07-21
- **Deciders**: ruv
- **Tags**: archive-v1, deprecation, densepose-head, model-weights, honest-labeling, prove-everything, credibility, pip-tombstone
- **Refs**: [#509](https://github.com/ruvnet/RuView/issues/509) (missing model weights / reproducibility), [#1125](https://github.com/ruvnet/RuView/issues/1125) ("has anyone got this to work?")
- **Relates to**: [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (pip modernization + 1.99.0 tombstone), [ADR-160](ADR-160-edge-skill-library-honest-labeling.md) (honest-labeling precedent), [ADR-079](ADR-079-camera-ground-truth-training.md) (camera-supervised pose target), [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) (WiFlow-STD PCK@20 measurement), [ADR-175](ADR-175-int8-quantization-half-pose-model-measured.md) (int8 pose trade-off), [ADR-101](ADR-101-pose-estimation-cog.md) (pose cog)
---
## Context
Two open GitHub issues are, at root, the same complaint: the project's public surface
lets a reader believe a WiFi→17-keypoint pose model exists and produces real accuracy,
when the specific code they land on cannot back that claim.
- **#509** — a detailed technical review states: *"While the network architecture for
DensePoseHead is defined in the code, there are no pre-trained weights (.pth or .onnx
files) available in the repository,"* and questions whether ESP32 1×1 SISO antennas
can match the multi-antenna NIC research this project is inspired by.
- **#1125** — a user asks for anyone to testify the project actually runs and returns
real data. A pure credibility complaint.
This ADR follows the **prove-everything / anti-"AI-slop"** directive and the
**honest-labeling** precedent set by ADR-160: the fix is to make the labels TRUE, not
to fabricate a capability. Grading vocabulary (from ADR-152 / ADR-160):
- **MEASURED** — reproduced in this worktree; the file/absence was directly inspected.
- **DATA-GATED** — a real code path exists; honestly flagged where the accuracy is not validated.
- **NO-ACTION (already-honest)** — audited, found correct, cited as a positive.
### What the investigation actually found (MEASURED in this worktree)
The situation is **more nuanced than either issue implies** — worse in one place, and
distinctly *better* in others. Forcing a uniformly negative narrative would itself be
dishonest. The findings:
**1. `archive/v1` — the issue reporter is correct here.**
- `archive/v1/src/models/densepose_head.py` defines `DensePoseHead` (segmentation +
UV-regression heads). Its `_initialize_weights()` uses **`kaiming_normal_` random
initialization only** — there is no checkpoint-loading path in the class.
- `Glob archive/v1/**/*.{pth,onnx,safetensors,pt,ckpt,bin}`**zero files**. There are
**no trained weights anywhere under `archive/v1/`.** The "architecture defined, no
weights" claim is TRUE for this tree.
- `archive/v1/README.md` calls the tree "the legacy Python implementation" in a single
closing note but does **not** loudly warn users off it, and there is **no
`archive/v1/DEPRECATED.md`.** This is the dead-but-present code that shows up in greps
and search and reads as if it were the live implementation.
- Per ADR-117, this exact tree is the source of the tombstoned pip package
`wifi-densepose 1.x` (1.99.0 raises an `ImportError` telling users to migrate). The
code is already tombstoned *on PyPI* but not *in the repo*.
**2. `v2` (the current, maintained system) — real weights DO exist; the "no weights
anywhere" reading is FALSE at the project level.** Git-tracked, committed checkpoints:
- `v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors` (507 KB) +
`pose_v1.onnx` (12 KB) + `train_results.json` — a **real committed 17-keypoint
model**, trained with Candle on an RTX 5080.
- `v2/crates/cog-person-count/cog/artifacts/count_v1.{safetensors,onnx}` — a committed
person-count model.
- Externally published on Hugging Face (not committed, but real and released):
`ruvnet/wifi-densepose-pretrained` (CSI encoder + presence head, honestly re-labeled
at **82.3% held-out temporal-triplet accuracy** — the older "100% presence" figure was
already retracted, an existing honest-labeling win) and `ruvnet/wifi-densepose-mmfi-pose`
(a pose model reporting **82.69% torso-PCK@20** on the MM-Fi `random_split` protocol).
- ADR-152 measurement (a): the *external* WiFlow-STD (DY2434) model was reproduced at
**96.09% PCK@20** on an RTX 5080 (graded MEASURED-EQUIVALENT). That is an external
baseline, not RuView's own weights.
**3. The honest gap is narrow and specific — the live, on-device ESP32 17-keypoint
pose path.** Per `v2/crates/cog-pose-estimation/cog/README.md` (already an exemplary
"Honest reading" section):
- The committed `pose_v1` scores **PCK@20 = 3.0% / PCK@50 = 18.5%** on a 217-sample
holdout — **below the ADR-079 target of PCK@20 ≥ 35%.** It learns coarse structure
(`r_hip` 77% PCK@50) but distal/face joints are near-random. `encoder_init` was
`random`; it was trained on a single 30-min seated-at-desk recording (1,077 samples,
avg confidence 0.44).
- The cog's **runtime inference path is still a centred-skeleton stub returning
`confidence=0`** — the `pose_v1.safetensors` weights are not yet wired into
`src/inference.rs`.
- ADR-079 records the proxy-supervised baseline at **PCK@20 = 2.5%**, and ADR-152
**retracted** the internal camera-supervised 92.9% PCK@20 figure (it was a
constant-output model scored under an absolute threshold on near-static frames; a mean
predictor scores 100% under the same broken protocol).
### The real problem to fix
Not "the project has no weights" (false) and not "there is a validated pretrained
DensePoseHead" (false for the live ESP32 path). The real problem is a **labeling and
navigation gap**:
1. `archive/v1`'s random-init `DensePoseHead` is indistinguishable, to a grepping
reader, from the live implementation, and carries no deprecation notice.
2. Nowhere is the split stated plainly: *which* checkpoints are real and validated
(presence 82.3%, MM-Fi pose 82.69% torso-PCK@20), *which* are real-but-weak and
honestly labeled (`pose_v1` 3% PCK@20, runtime stubbed), and *which* are
architecture-only with no weights at all (`archive/v1` `DensePoseHead`).
## Decision
Two coordinated honest-labeling actions. Neither invents a capability; both make the
public surface match what the code and checkpoints actually deliver.
### (a) Formally deprecate `archive/v1` in the repo — MEASURED gap, proposed fix
- **Add `archive/v1/DEPRECATED.md`** — a loud tombstone stating that `archive/v1` is the
original pure-Python implementation, is **unmaintained and superseded**, that its
`DensePoseHead` is **architecture-only with random-initialized weights and ships no
trained checkpoint**, and that the maintained path is the `v2/` Rust workspace + the
`wifi-densepose 2.x` / `ruview` pip wheel (ADR-117). Mirror the disclaimer tone of
ADR-160's `//!` headers and the pip 1.99.0 tombstone text.
- **Prepend a loud notice to `archive/v1/README.md`** (the file exists) — a `> ⚠️
DEPRECATED` block at the very top pointing to `DEPRECATED.md`, `v2/`, and the pip
wheel, before any of the existing "how to install v1" content.
- **Rule:** no doc outside `archive/v1/` may reference `archive/v1` code (other than the
ADR-028 deterministic proof at `archive/v1/data/proof/verify.py`, which is a
legitimately live signal-pipeline witness and stays) as if it were current. The two
README references verified (`README.md` lines 139/198/204; `docs/user-guide.md`
proof/swift-compile lines) are all proof/utility invocations, not implementation
claims — they are acceptable and out of scope.
### (b) Model-weights honest labeling — state the three tiers explicitly
Add a **"Model weights: what's real, what's not"** subsection to `README.md` and
`docs/user-guide.md` that names the three tiers verified above, so no reader can infer
"a pretrained 17-keypoint DensePoseHead produces real pose accuracy on my ESP32":
| Tier | Checkpoint(s) | Honest status |
|------|---------------|---------------|
| **Real & validated** | `ruvnet/wifi-densepose-pretrained` (encoder + presence, 82.3% held-out temporal-triplet); `ruvnet/wifi-densepose-mmfi-pose` (82.69% torso-PCK@20, MM-Fi `random_split`); `count_v1` | MEASURED / published; keep current honest labels |
| **Real but weak (honestly labeled)** | committed `pose_v1.safetensors` in `cog-pose-estimation` | **PCK@20 = 3.0%**, below the ADR-079 ≥35% target; runtime path is a `confidence=0` stub until weights are wired into `src/inference.rs`. Already disclosed in the cog README; surface the same caveat wherever the live ESP32 pose feature is advertised |
| **Architecture only, no weights** | `archive/v1` `DensePoseHead` | random-init, no checkpoint; deprecated per (a) |
- The existing MM-Fi/presence honest labels (retraction of "100% presence", the cog
"Honest reading") are **NO-ACTION positives** — cite them, do not weaken them.
- The live ESP32 17-keypoint claim stays **DATA-GATED**: the path to a first
*reproducible* on-device baseline is ADR-079 (multi-session, full-body-framed,
camera-supervised, ≥30K paired samples at conf ≥0.7, target PCK@20 ≥35%), tracked in
[#645]. Do not advertise the live ESP32 pose feature without the "first-cut / below
target / runtime stub" caveat until that baseline is MEASURED.
- Directly answer #509's ESP32-SISO question in the docs, honestly: single-antenna 56-
subcarrier CSI at a 20-frame window does **not** carry the fine-grained spatial
information the multi-antenna NIC research relies on (the cog README already shows
distal/face joints near-random) — the shippable pose accuracy the project *can* stand
behind today is the **MM-Fi benchmark** number, not a live single-ESP32 number.
## Phase ledger
| Phase | Action | State |
|-------|--------|-------|
| **P0** | This ADR (investigation + decision) | **DONE** (this file) |
| **P1** | Add `archive/v1/DEPRECATED.md` + loud notice atop `archive/v1/README.md` | **DONE** (1fb5397dd) |
| **P2** | Add "Model weights: what's real, what's not" tier table to `README.md` + `docs/user-guide.md`; add the caveat wherever the live ESP32 17-keypoint feature is advertised | **DONE** (1fb5397dd; follow-up caveated the hardware table, hero caption, and live-pipeline note) |
| **P3** | Answer #509's SISO/no-weights question and #1125's "does it run" in `docs/user-guide.md` (point to the reproducible proofs: MM-Fi arena, `archive/v1/data/proof/verify.py`, cog `train_results.json`) | **DONE** (1fb5397dd) |
| **P4** | Close the DATA-GATED live-pose gap via ADR-079 first reproducible on-device baseline (PCK@20 ≥35%) + wire `pose_v1.safetensors` into `cog-pose-estimation/src/inference.rs` | ACCEPTED-FUTURE ([#645]) |
## Acceptance criteria
- [x] `archive/v1/DEPRECATED.md` exists and names `v2/` + the pip wheel as the maintained path.
- [x] `archive/v1/README.md` opens with a `> ⚠️ DEPRECATED` block before any install instructions.
- [x] `README.md` and `docs/user-guide.md` no longer let a reader infer that `archive/v1`
or an untrained/random-init `DensePoseHead` produces real pose accuracy without the
caveats added here.
- [x] The live ESP32 17-keypoint pose feature is nowhere advertised without its
"first-cut, PCK@20 = 3.0%, below ADR-079 target, runtime stub" caveat.
- [x] The three real/published checkpoints (presence 82.3%, MM-Fi pose 82.69% torso-PCK@20,
`count_v1`) keep their existing honest labels — nothing is weakened or overclaimed.
- [x] No claim is added that is not MEASURED or explicitly DATA-GATED.
## Consequences
### Positive
- A grepping reader can no longer mistake `archive/v1`'s random-init `DensePoseHead` for
the live system; the dead code is loudly tombstoned in the repo, matching its PyPI 1.99.0 tombstone.
- #509 and #1125 get an honest, verifiable answer: real trained weights *do* exist
(presence + MM-Fi pose are published and benchmarked), the *specific* file the reporter
found is architecture-only, and the live ESP32 pose path is honestly weak-and-in-progress.
- Reinforces the ADR-160 honest-labeling discipline: the project's credibility comes from
precise labels, not from a suppressed or inflated narrative.
### Negative
- The docs must openly state that the live single-ESP32 17-keypoint pose is not yet at a
citable accuracy — a short-term "looks less finished" cost, paid for by not overclaiming.
- Two more files to keep in sync (`DEPRECATED.md`, the tier table) as the checkpoints evolve.
### Neutral
- No code or model behavior changes; `archive/v1` stays in the tree as a research archive
(ADR-117 §1.3) and its ADR-028 proof witness is untouched.
- Purely documentation/labeling; no crate, wheel, or firmware rebuild required.
## References
- `archive/v1/src/models/densepose_head.py``DensePoseHead`, random `_initialize_weights()`, no checkpoint load.
- `archive/v1/README.md` — legacy note; no loud deprecation (target of P1).
- `v2/crates/cog-pose-estimation/cog/README.md` — the "Honest reading" precedent (PCK@20 = 3.0%, runtime stub).
- `v2/crates/cog-pose-estimation/cog/artifacts/{pose_v1.safetensors,pose_v1.onnx,train_results.json}` — committed first-cut pose model.
- `v2/crates/cog-person-count/cog/artifacts/count_v1.{safetensors,onnx}` — committed count model.
- `ruvnet/wifi-densepose-pretrained`, `ruvnet/wifi-densepose-mmfi-pose` — published, benchmarked checkpoints.
- ADR-079 §Target (PCK@20 ≥35%), ADR-152 measurement (a) (96.09% PCK@20 external; internal 92.9% retracted), ADR-160 (honest-labeling method), ADR-117 (pip 1.99.0 tombstone).
@@ -0,0 +1,171 @@
# ADR-263: Adopt RTL8720F 2.4 GHz FMCW radar as an optional RuView sensing platform
- **Status**: proposed
- **Date**: 2026-07-18
- **Deciders**: ruv
- **Tags**: realtek, rtl8720f, ameba, fmcw, radar, cfr, csi, hardware
- **Relates to**: ADR-018, ADR-063, ADR-064, ADR-095, ADR-097, ADR-260, ADR-262
## Context
Realtek's `RTL8720F-2.4G-Radar-Advantages_EN.pptx` describes an RTL8720F mode that shares the
2.4 GHz radio between Wi-Fi, Bluetooth, and an active FMCW radar. It offers two data products that
are useful to RuView:
1. **CFR (Channel Frequency Report)**, described by Realtek as the same concept as Wi-Fi CSI.
2. **Near and far Range-FFT reports**, preserving near-field content while extending observation to
approximately 56 m.
The proposed radio uses one transmit and one receive antenna, 20/40/70 MHz sweeps, configurable
8/16/32/64 microsecond chirp symbols, a maximum 2.56 ms FMCW packet, and a configurable frame
interval above 15 ms. The deck recommends 40 MHz outside Japan and 20 MHz in Japan. It also
describes EDCCA/CTS channel access, Wi-Fi/BT/radar time division, interference reporting, and
priority arbitration in the driver.
This is not a drop-in replacement for ESP32 CSI:
- it is **active monostatic FMCW**, while the ESP32 path observes Wi-Fi packet CSI;
- one Tx/one Rx has no angle-of-arrival or native multi-target separation;
- the stated 40 MHz range resolution is about 3.15 m, despite a finer 0.59 m Range-FFT report step;
- the presentation is a capability description, not an SDK contract. It contains no header names,
function signatures, callback ABI, binary layouts, toolchain version, licensing terms, or public
RTL8720F board package.
Realtek's public Ameba RTOS repository is the base. Release v1.2.1 includes the CSI API and fixes a
CSI application-buffer semaphore issue, but does not expose the radar application surface. Open
upstream PR #1336 (2026-07-18 snapshot) adds RTL8720F project artifacts, `AT+RAD`, `AT+RADDBG`, and
the public configuration call `wifi_radar_config(struct rtw_radar_action_parm *)`. Its public
parameter struct confirms mode, channel, 70/40/20 MHz bandwidth selector, trigger period, and
enable/config actions. Report reception still crosses non-public/placeholder HAL symbols such as
`wifi_hal_radar_recv_data(frame_num, frame_type, data)`, so the report layout and buffer lifetime
remain vendor-gated. Therefore the integration stays split at that boundary.
## Decision
RuView will support RTL8720F radar as an **optional, capability-negotiated source**, without
replacing the ESP32 firmware or treating radar CFR as byte-compatible with ADR-018 CSI.
The integration has three layers:
1. **Realtek device firmware**: a small application built in the vendor-supported Ameba SDK calls
the radar API, owns coexistence configuration, and emits versioned reports. This code lives under
`firmware/rtl8720f-radar/` only after the redistributable SDK/API is available.
2. **Transport-neutral wire contract**: CFR and Range-FFT reports are framed independently from the
vendor ABI and sent over UDP, USB CDC, or UART. ADR-264 defines this boundary.
3. **Rust host adapter**: `wifi-densepose-hardware` parses reports from bytes and converts CFR into
the existing CSI-domain representation, while Range-FFT remains a radar modality and feeds the
RuField/RuView cross-modality bridge from ADR-260/262.
The two report types remain semantically distinct:
| RTL8720F output | RuView representation | Permitted use |
|---|---|---|
| CFR | `CsiFrame` through a Realtek calibration adapter | CSI feature extraction after validation |
| Range-FFT near/far | `RadarFrame` / RuField `mmwave_radar`-class event with a 2.4 GHz descriptor | range, motion, presence, fusion |
| Vendor AI presence probability | derived observation with model/version provenance | advisory input, never ground truth |
| Interference report | quality/provenance metadata | reject, down-weight, or mark contaminated frames |
The modality registry should eventually distinguish `fmcw_radar_2_4ghz` from `mmwave_radar`; until
that RuField schema revision is accepted, the adapter must attach `carrier_hz = 2.4e9` and must not
claim millimetre-wave provenance.
## Delivery phases and gates
### P0 — Vendor enablement
Obtain the PR #1336-or-newer RTL8720F SDK package, radar API headers/libraries, a supported evaluation
board, flashing/debug instructions, report definitions, and written redistribution terms.
**Gate:** compile and run Realtek's unmodified radar example and capture CFR plus near/far
Range-FFT output. Until this passes, device firmware is `VENDOR_BLOCKED`, not implemented.
### P1 — Host-first contract
Implement ADR-264 types, parsers, fixtures, fuzz tests, and replay support without linking vendor
code. Use the Rust `Rtl8720fSimulator` as the only pre-hardware live source. It emits deterministic
CFR, near/far Range-FFT, interference, and capabilities frames through the same ADR-264 encoder and
parser used by hardware. Every simulated frame sets `RadarFlags::SYNTHETIC`; simulation results are
never reported as device measurements.
**Gate:** malformed inputs never panic; encode/decode round trips; unknown versions and report
types fail closed.
### P2 — RTL8720F firmware adapter
Wrap only the minimum vendor API surface: initialization, profile configuration, start/stop,
callback acquisition, interference status, and report serialization. Keep vendor types out of the
wire protocol.
**Gate:** 30-minute simultaneous Wi-Fi telemetry and radar capture with no watchdog reset, bounded
loss, monotonic sequence numbers, and explicit coexistence/interference statistics.
### P3 — Calibration and signal validation
Calibrate CFR phase/amplitude, Range-FFT bin spacing, static leakage, and clock drift. Compare
reported range against measured targets at multiple distances and bandwidths.
**Gate:** publish measured error distributions. Do not infer accuracy from report-bin spacing and
do not advertise multi-person pose or vital signs from the vendor deck.
### P4 — Fusion and productization
Feed calibrated CFR through the CSI path and Range-FFT through RuField, retaining source, mode,
bandwidth, calibration, firmware, and interference provenance.
**Gate:** ablation shows whether the radar stream improves a named RuView metric over ESP32 CSI
alone. If it does not, ship it only as an independent presence/range sensor.
## Consequences
### Positive
- One low-cost radio can provide active radar and CSI-like CFR while retaining Wi-Fi connectivity.
- Range-FFT adds an independent physical measurement for presence/range fusion.
- The vendor SDK is isolated from the Rust sensing core and from the stable on-wire contract.
- Capability negotiation permits future Realtek parts without another application-level fork.
### Negative
- The first implementation is blocked on access to the actual RTL8720F radar SDK/API and hardware.
- Active 2.4 GHz transmission changes coexistence, privacy, power, and regional compliance concerns.
- 1T1R and limited sweep bandwidth cannot provide the spatial resolution of multi-antenna mmWave.
- A second embedded toolchain and firmware release process must be maintained.
### Neutral
- ESP32 remains the default CSI node.
- Existing consumers receive normalized frames and do not link against Realtek code.
- Vendor AI output is optional metadata; RuView retains responsibility for its own validation.
## Rejected alternatives
1. **Map Range-FFT directly to `CsiFrame`.** Rejected because range bins and channel-frequency
samples have different axes and physical meaning.
2. **Link the Realtek SDK into the Rust server.** Rejected because it couples host builds to a
proprietary embedded ABI and toolchain.
3. **Wait to define any interface until hardware arrives.** Rejected because the host protocol,
parser safety, replay, and provenance can be developed and reviewed independently.
4. **Replace ESP32 nodes.** Rejected because the modes are complementary and availability differs.
## Open vendor questions
- Exact RTL8720F part/board identifier and production availability.
- SDK repository/tag, compiler, RTOS, binary blobs, license, and redistribution permissions.
- Radar initialization/configuration/callback API signatures and threading/ISR constraints.
- CFR and near/far Range-FFT element type, complex ordering, scaling, endianness, and timestamps.
- Whether CFR is calibrated complex data and whether phase remains coherent across frames.
- Maximum report rates, buffer ownership, DMA/cache constraints, and Wi-Fi throughput impact.
- Region/channel enforcement and whether 70 MHz operation is allowed by the supplied firmware.
- Secure boot, signed OTA, unique device identity, and firmware attestation support.
## Sources
- Realtek Semiconductor, `RTL8720F-2.4G-Radar-Advantages_EN.pptx`, slides 3 and 1019,
supplied 2026-07-18. This is product material, not measured RuView validation.
- [Ameba-AIoT/ameba-rtos releases](https://github.com/Ameba-AIoT/ameba-rtos/releases), reviewed
2026-07-18; v1.2.1 is the current QC release and includes a CSI buffer-semaphore fix.
- [Ameba-AIoT/ameba-rtos PR #1336](https://github.com/Ameba-AIoT/ameba-rtos/pull/1336), reviewed
2026-07-18; exposes RTL8720F build assets, `wifi_radar_config`, and radar AT commands while report
internals remain in binary/private layers.
- ADR-063 (mmWave sensor fusion), ADR-095/097 (source normalization), and ADR-260/262 (RuField
multimodal event model and live bridge).
@@ -0,0 +1,191 @@
# ADR-263: `@ruvnet/ruview` npm Harness — Deep Review + Optimization Strategy
| Field | Value |
|-------|-------|
| **Status** | Accepted — **implemented** (O1O9, `@ruvnet/ruview@0.2.0`): fail-closed `claim-check`, async MCP dispatch (ping answered mid-`verify`, pinned by e2e test), zero-dependency install, bounded output tails, argv-passed monitor port, package.json-sourced version, prepack skill sync, memoized `which()`, underscore-canonical tools with dotted aliases, word-boundary guardrail matching. 30/30 tests (MEASURED, `node --test test/*.test.mjs`); CI gate in ADR-265's `npm-packages.yml` |
| **Date** | 2026-07-02 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-NPM-REVIEW-1** |
| **Supersedes / amends** | none (records review of the ADR-182 P1+P2 artifact; feeds ADR-265 distribution strategy) |
## Context
ADR-182 minted and published **`@ruvnet/ruview@0.1.0`** (`harness/ruview/`) — the
`npx ruview` operator harness: a dependency-free ESM CLI + minimal MCP stdio server
exposing six `ruview.*` tools (onboard / claim_check / verify / node_monitor /
calibrate / node_flash), five skill playbooks, and the executable
MEASURED-vs-CLAIMED guardrail (`src/guardrails.js`). The package is live on npm
(0.1.0, 49.5 kB unpacked / 21 files — MEASURED, `npm view @ruvnet/ruview` +
`npm pack --dry-run`) and is the recommended MCP registration path
(`npx -y @ruvnet/ruview mcp start` in the bundled `.claude/settings.json`).
This ADR is the first dedicated deep review of that npm artifact: correctness,
fail-open/fail-closed posture, performance (cold start + request handling),
packaging hygiene, and security of the subprocess surface. All 17 bundled tests
pass on Node 22 (MEASURED, `node --test test/*.test.mjs`, 17/17, ~108 ms).
## Findings
Severity reflects impact on the package's stated contract: *fail-closed operator
tools + an honesty guardrail that must never fail open*.
### F1 (HIGH, fail-open): `claim-check` passes silently on empty input
`bin/cli.js` `claim-check` with **neither `--text` nor `--file`** sends
`text: undefined``claimCheck(String(args.text ?? ''))``''``ok: true`,
**exit 0**. A CI hook wired as `npx ruview claim-check --text "$BODY"` where
`$BODY` expands empty therefore reports PASS. This is the single tool whose whole
purpose is to fail closed; empty input must be an error, not a pass.
Reproducer: `node bin/cli.js claim-check``{"ok": true}`, exit 0.
### F2 (HIGH, head-of-line blocking): MCP server is fully synchronous
`src/mcp-server.js` dispatches `tools/call` inside the readline `line` handler,
and every heavyweight handler in `src/tools.js` uses **`spawnSync`**
(`ruview.verify` up to 180 s, `ruview.calibrate` up to 300600 s,
`ruview.node_monitor` up to `seconds+10`). While one call runs, the event loop is
blocked: `ping`, `tools/list`, and concurrent `tools/call` requests are not even
read from stdin. Hosts that health-check with `ping` during a long `calibrate`
will conclude the server is dead and kill it mid-run.
### F3 (MEDIUM, cold start): optionalDependencies triple the `npx` install for a path that never uses them
`package.json` declares `optionalDependencies` on `@metaharness/kernel` and
`@metaharness/host-claude-code`. npm installs optional deps **by default**, so
every cold `npx -y @ruvnet/ruview mcp start` fetches 3 extra packages (kernel +
host + transitive `@ruvector/emergent-time`). MEASURED (npm 10.9.7, this
container): default install = **4 packages, 620 kB, 71 files**; with
`--omit=optional` = **1 package, 172 kB, 22 files**. The operator-tool and MCP
paths never import these — only `doctor`/`install` do, and both already
dynamic-import inside `try/catch` and degrade gracefully when absent
(`kernel/host: not installed (ok…)`). The optional deps buy nothing on the hot
path and cost 3 registry round-trips + ~450 kB on every cold start.
### F4 (MEDIUM, silent truncation): `spawnSync` default `maxBuffer` (1 MiB)
`run()` in `src/tools.js` never sets `maxBuffer`. `cargo run -p
wifi-densepose-cli` (the `calibrate` fallback path) and a chatty `verify.py` can
exceed 1 MiB of stdout, at which point the child is killed with `ENOBUFS` and the
tool reports a spawn error that looks like a proof/calibration failure. The
handlers only ever consume the last 8 kB/1.5 kB; buffering should be bounded but
generous (e.g. `maxBuffer: 16 MiB`) or streamed with a tail ring.
### F5 (MEDIUM, injection surface): `node_monitor` interpolates the port into Python source
The handler builds a `python -c` script by string interpolation:
`` `ser=serial.Serial(${JSON.stringify(port)},115200,…)` `` and
`` `while time.time()-t<${dur}:` ``. `JSON.stringify` produces a *JavaScript*
string literal; Python string-literal semantics differ at the edges (`\uXXXX` is
shared, but e.g. JS emits raw U+2028/U+2029 unescaped pre-ES2019 rules aside, and
any future non-JSON-safe field added the same way would be executable). `port`
arrives from the MCP caller (an agent), so this is an agent-controlled string
concatenated into an interpreter invocation. `dur` is `Number()`-guarded; `port`
should be passed out-of-band (`sys.argv`/env), never spliced into source.
### F6 (LOW, drift): server version hardcoded
`SERVER_INFO = { name: 'ruview', version: '0.1.0' }` in `src/mcp-server.js`
duplicates `package.json.version` (the CLI's `--version` already reads
package.json at runtime). First release bump will drift the MCP handshake
version.
### F7 (LOW, duplication): every skill ships twice
`skills/*.md` and `.claude/skills/*/SKILL.md` are byte-identical (same sha256 in
`.harness/manifest.json`). ~8 kB of the 49.5 kB unpacked payload is duplicate
content, and — worse than size — two copies must be kept in sync by hand.
### F8 (LOW, perf + portability): `which()` is uncached and shells out
`which()` runs up to twice per tool call (`python` then `python3`), each a
blocking `spawnSync`; the POSIX branch spawns a shell (`shell: true`). Results
are stable for the process lifetime and should be memoized; the lookup can be
done dep-free with a PATH scan instead of a shell.
### F9 (LOW, interop): dot-named tools + minimal protocol surface
Tool names (`ruview.onboard`, `ruview.claim_check`, …) contain dots. MCP itself
does not restrict names, but downstream host APIs commonly enforce
`^[a-zA-Z0-9_-]{1,64}$` for tool names; hosts must then sanitize or reject.
The server also answers `resources/list` / `prompts/list` with `-32601` (it does
not advertise those capabilities, so this is spec-legal, but empty-list stubs are
cheaper than every host's error path). Protocol version is pinned to
`2024-11-05` with no negotiation fallback. None of this breaks Claude Code today;
it narrows portability, which is the harness's whole pitch (9 hosts, ADR-182).
### F10 (LOW, CI gap): the published package has zero CI
No workflow under `.github/workflows/` runs `harness/ruview` tests (checked:
no workflow references `harness/ruview`, `ruview-mcp`, or `ruview-cli`), and
`ci.yml` pins `NODE_VERSION: '18'` while the package declares
`engines.node >= 20`. Note also `node --test test/` (directory form) fails on
Node 22 while the documented glob form passes — CI should pin the working
invocation. Consolidated CI/publish strategy is ADR-265.
### F11 (MEDIUM, guardrail precision): `METRIC_TERMS` substring matching false-positives on ordinary prose
Found by dogfooding this review: `claimCheck` matches metric terms with
`lower.includes(t)`, so the two-character terms `'map'` and `'f1'` fire inside
ordinary words and labels — "source **map**s", "the **map**s can never
resolve", finding IDs like "**F1** (HIGH…)". MEASURED reproducer: running
`npx ruview claim-check --file` over this ADR and ADR-264 yields 4 and 16
medium findings respectively, the majority of which are `map`/`F1`
false positives on lines carrying no accuracy claim. A guardrail that cries
wolf trains people to ignore it — precision is part of its fail-closed
contract. Short/ambiguous terms need word-boundary matching (`\bmap\b`,
`\bf1\b`, likewise `auc`, `iou`), and section-heading label patterns
(`F\d+`, `O\d+`) should not count as metric mentions.
## Decision
Adopt the following optimization strategy, in priority order. Each item is
independently shippable; F-numbers map to findings.
- **O1 (F1):** `claim-check` with no `--text`/`--file` (or empty text after read)
exits 2 with a usage error. Add a regression test pinning exit ≠ 0.
- **O2 (F2):** make the MCP dispatch async: convert `run()`/`which()` to
promise-based `spawn`, make `tools/call` handlers `async`, and keep reading
stdin while calls run (respond to `ping`/`tools/list` concurrently; serialize
only same-tool hardware operations). Acceptance: `ping` round-trips < 50 ms
while a synthetic 30 s `calibrate` is in flight.
- **O3 (F3):** drop the two `optionalDependencies`; `doctor`/`install` already
degrade and should print the exact `npm i @metaharness/kernel
@metaharness/host-claude-code` hint on the miss path. Acceptance: cold
`npm i @ruvnet/ruview` installs exactly 1 package (MEASURED baseline above).
- **O4 (F4):** set `maxBuffer: 16 * 1024 * 1024` in `run()` (or stream + tail).
- **O5 (F5):** pass `port` to the monitor script via `sys.argv`
(`python -c script -- <port>`), never by source interpolation.
- **O6 (F6):** read the MCP `serverInfo.version` from `package.json` once at
startup (same pattern the CLI already uses).
- **O7 (F7):** make `skills/*.md` the single source and generate
`.claude/skills/*/SKILL.md` in a `prepack` script (or vice versa); manifest
hashes then pin one canonical set.
- **O8 (F8, F9):** memoize `which()`; add underscore aliases for the dot-named
tools (accept both in `tools/call`, advertise the underscore form) and add
empty `resources/list` / `prompts/list` stubs.
- **O9 (F11):** switch `METRIC_TERMS` matching to word-boundary regexes for
short terms (`map`, `f1`, `auc`, `iou`) and skip label tokens matching
`\b[FO]\d+\b`. Acceptance: `claim-check --file` over ADR-263/264/265 reports
only the genuinely tagged-or-taggable percentage lines, and the existing 17
guardrail tests still pass plus new false-positive pins ("source maps",
"F1 (HIGH)" → no finding).
Non-goals: no new runtime dependencies (the zero-dep MCP server is a feature,
not an accident — keep it), no build step, no change to the fail-closed tool
contracts.
## Consequences
- The honesty guardrail becomes fail-closed end-to-end (its current empty-input
pass is the exact failure mode the guardrail exists to prevent).
- `npx` cold start drops ~450 kB / 3 packages (MEASURED baseline in F3) with no
feature loss; `doctor` output already communicates the optional-dep story.
- Long-running `verify`/`calibrate` no longer starve the MCP channel — the
harness survives host health checks during real calibration runs.
- Two-copy skill drift becomes impossible at pack time.
- Costs: async conversion touches every handler signature in `src/tools.js`
(mechanical, ~6 handlers); alias tools add a small compatibility table.
- Verification for the implementing PR: bundled tests extended for O1/O2/O5
(target ≥ 20 tests), `npm pack --dry-run` file-count asserted, and the F3
install measurement re-run and quoted MEASURED in the PR body — which must
itself pass `npx ruview claim-check`.
@@ -0,0 +1,148 @@
# ADR-264: Versioned wire protocol for RTL8720F CFR and Range-FFT reports
- **Status**: proposed
- **Date**: 2026-07-18
- **Deciders**: ruv
- **Tags**: realtek, rtl8720f, protocol, cfr, range-fft, udp, serial
- **Depends on**: ADR-263
- **Relates to**: ADR-018, ADR-095, ADR-097, ADR-099, ADR-260
## Context
ADR-263 adopts RTL8720F radar behind an anti-corruption boundary. The Realtek presentation names
CFR, near Range-FFT, far Range-FFT, and interference reports, but does not specify their binary ABI.
RuView needs a stable, testable contract that can be implemented before the vendor SDK arrives and
that will not expose vendor structs, pointer layouts, padding, or callback lifetime rules over the
network.
ADR-018 already defines ESP32 CSI framing. Reusing its magic or pretending that Realtek radar is an
ESP32 packet would make source detection ambiguous and erase radar-specific calibration metadata.
## Decision
Define a new little-endian `RtlRadarFrameV1` envelope with its own magic and explicit payload type.
This is a RuView protocol, not a claim about Realtek's native memory layout.
### Envelope
All integer fields are little-endian. Floating-point payloads use IEEE-754 binary32. No C struct is
sent by `memcpy`; firmware serializes each field explicitly.
| Offset | Size | Field | Meaning |
|---:|---:|---|---|
| 0 | 4 | magic | ASCII `RTR1` (`0x31525452`) |
| 4 | 1 | version | `1` |
| 5 | 1 | report_type | 1 CFR, 2 range-near, 3 range-far, 4 interference, 5 capabilities |
| 6 | 2 | header_len | complete header size, initially 56 |
| 8 | 4 | frame_len | header + payload + CRC |
| 12 | 4 | sequence | wraps modulo 2^32 |
| 16 | 8 | timestamp_us | monotonic device time at acquisition |
| 24 | 8 | device_id | stable pseudonymous identifier, not a MAC address |
| 32 | 4 | center_freq_khz | RF centre frequency |
| 36 | 2 | bandwidth_mhz | 20, 40, or 70 |
| 38 | 2 | flags | calibration/interference/saturation/time-sync flags |
| 40 | 2 | element_count | complex samples or range bins |
| 42 | 1 | element_format | 0 bytes/TLV, 1 complex-i16, 2 complex-f32, 3 power-u16, 4 power-f32 |
| 43 | 1 | antenna_count | expected to be 1 for the deck's 1T1R configuration |
| 44 | 4 | scale | quantized-to-physical multiplier; `1.0` for float payloads |
| 48 | 4 | bin_spacing | Hz for CFR, metres for Range-FFT |
| 52 | 4 | calibration_id | device calibration revision/hash prefix |
| 56 | variable | payload | determined by type, count, and format |
| final-4 | 4 | crc32 | IEEE CRC-32 over header and payload |
If vendor evidence shows that 56 bytes is too costly, a later protocol version may introduce a
compact header. V1 favors auditable provenance over premature byte savings.
### Payload semantics
- **CFR** contains ordered complex channel-frequency samples. The adapter must know the frequency
origin/order and must not fabricate missing phase. Uncalibrated frames carry the uncalibrated flag
and cannot enter phase-sensitive processing.
- **Range-near/range-far** contains ordered range bins. Near and far are separate report types so
filtering and leakage behavior are never hidden from consumers.
- **Interference** contains a versioned TLV set for channel-busy, detected-during-chirp, estimated
interference power, and packet jitter. Unknown TLVs are skipped by length.
- **Capabilities** is emitted at boot and on request. It declares supported report types, bandwidths,
chirp lengths, maximum elements/report, maximum frame rate, firmware version, and SDK identifier.
### Transport
The identical envelope is supported over:
- UDP datagrams for normal RuView ingestion;
- USB CDC or UART with COBS framing and a zero-byte delimiter;
- file replay as a length-prefixed sequence of envelopes.
One envelope must fit one UDP datagram. Fragmentation is not part of V1; firmware rejects a profile
whose maximum report exceeds the configured MTU and reports the required size through capabilities.
### Parser and trust rules
The host parser:
1. validates magic, version, lengths, enum values, element count/format multiplication, and CRC
before allocating or decoding the payload;
2. caps frames at 64 KiB and elements at a configured hardware maximum;
3. rejects non-finite float metadata/payload values;
4. tracks sequence gaps and timestamp regressions per device;
5. preserves unknown flags but never interprets them as trusted;
6. attaches transport source, firmware/SDK version, calibration ID, and interference state to
provenance;
7. labels fixture/generated frames as synthetic.
No vendor-provided presence probability bypasses RuView privacy, provenance, or quality gates.
## Consequences
### Positive
- Firmware, transport, parser, replay, and fusion can evolve independently.
- Fuzzing and golden fixtures require no Realtek SDK or board.
- CFR and Range-FFT retain correct axes and calibration provenance.
- A boot-time capabilities frame makes SDK/API drift observable.
### Negative
- Serialization adds CPU and bandwidth overhead compared with dumping a vendor buffer.
- V1 fields may need revision after the actual API and report limits are disclosed.
- UDP provides integrity/error detection, not authenticity or confidentiality.
### Neutral
- Authentication can be layered with ADR-032 device identity or a signed RuField receipt without
changing report semantics.
- ESP32 ADR-018 framing remains unchanged.
## Implementation plan
1. Add `rtl8720f` types/parser module to `wifi-densepose-hardware` behind no vendor dependency.
2. Add golden CFR, near/far Range-FFT, interference, and capabilities fixtures.
3. Add property/fuzz tests for length arithmetic, enum handling, CRC, and float validation.
4. Add a replay CLI that prints normalized metadata without running inference.
5. Once SDK access exists, implement the embedded serializer and verify captured frames against the
host golden decoder.
6. Revise this proposed ADR with measured element counts, rates, and API names before acceptance.
Host-side steps 13 are implemented in `wifi-densepose-hardware::rtl8720f`: typed report and
element enums, semantic type/format validation, bounded length arithmetic, CRC verification,
finite-float checks, encode/decode round trips, corruption/truncation tests, and deterministic
arbitrary-input panic checks. Cross-language vectors remain blocked on the vendor SDK callback ABI.
Bit 15 of `flags` is reserved by RuView as `SYNTHETIC`; the Rust simulator always sets it and real
firmware must never set it. The simulator is deterministic by seed and exercises the production
encoder/parser rather than a parallel mock representation.
## Acceptance criteria
- Rust encode/decode round-trip for every report type.
- Cross-language golden vector produced by the RTL8720F firmware.
- Zero parser panics over the fuzz corpus and arbitrary byte input.
- Detection of single-bit corruption, truncation, count overflow, timestamp regression, and gaps.
- Captured CFR frequency order and Range-FFT bin spacing verified against vendor documentation and a
measured target.
## Sources
- Realtek Semiconductor, `RTL8720F-2.4G-Radar-Advantages_EN.pptx`, slides 1119, supplied
2026-07-18.
- ADR-018 (ESP32 framing), ADR-095/097 (hardware normalization), ADR-260 (multimodal event model),
and ADR-263 (platform decision).
@@ -0,0 +1,169 @@
# ADR-264: `@ruvnet/rvagent` MCP Server + `@ruv/ruview-cli` — Deep Review + Optimization Strategy
| Field | Value |
|-------|-------|
| **Status** | Accepted — **implemented** (O1O9, `@ruvnet/rvagent@0.2.0`): `exports` fixed (types-first, no phantom `.cjs`), map-free tarball (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, from 188 kB), Streamable HTTP **wired** behind `RVAGENT_HTTP_PORT` with per-session transports + 1 MiB body cap + port-aware origin gate, underscore tool names with dotted router aliases, single Zod validation gate with generated JSON Schemas, fd-leak fixed + persisted job records + bounded log tails, probing `detectCogBinary`, package.json-sourced version, `ruview-cli` bin renamed. 99/99 jest tests (MEASURED); both transports smoke-tested live |
| **Date** | 2026-07-02 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-NPM-REVIEW-2** |
| **Supersedes / amends** | none (reviews the ADR-104/ADR-124 artifacts; feeds ADR-265 distribution strategy) |
## Context
Two TypeScript npm packages expose RuView sensing to agents and shells:
- **`@ruvnet/rvagent@0.1.0`** (`tools/ruview-mcp/`) — SENSE-BRIDGE, the MCP
server over the sensing-server HTTP API + cog binaries: 12 tools
(csi/pose/count/registry/train/job + ADR-124 BFLD/presence/vitals). Published
(188 kB unpacked — MEASURED, `npm view @ruvnet/rvagent`). Deps:
`@modelcontextprotocol/sdk` + `zod`.
- **`@ruv/ruview-cli@0.0.1`** (`tools/ruview-cli/`) — `private: true` yargs CLI
mirroring the same capabilities; intentionally duplicates `http.ts`/`cog.ts`/
`config.ts` (~150 lines) to stay standalone.
This ADR records a deep review of both: packaging correctness (verified against
the **published** tarball, not just the source tree), protocol/interop, resource
lifecycle, and the honesty of the package's own self-description — the same
MEASURED-vs-CLAIMED bar the project applies to accuracy numbers.
## Findings
### F1 (HIGH, broken export): `require` condition points at a file that does not exist
`package.json` `exports["."].require = "./dist/index.cjs"`, but the build is
plain `tsc` (ESM only) and **the published 0.1.0 tarball contains no
`index.cjs`** (verified by listing the registry tarball). Any CJS consumer doing
`require('@ruvnet/rvagent')` resolves to a nonexistent file →
`ERR_MODULE_NOT_FOUND`. Additionally the `types` condition is listed **after**
`import`/`require`; TypeScript requires `types` first or it may be ignored under
`moduleResolution: bundler/node16`.
### F2 (MEDIUM, tarball bloat): a third of the published package is dead source maps
The 0.1.0 tarball ships **44 `.map` files = 62,698 B** against 78,209 B of
actual `.js` (MEASURED, extracted registry tarball). `src/` is not published, so
every `sourceMappingURL` points at `../src/*.ts` that consumers do not have —
the maps can never resolve. Also `files` lists `CHANGELOG.md`, which does not
exist in `tools/ruview-mcp/` (npm silently skips it), so the advertised file set
is partly fictional.
### F3 (MEDIUM, honesty): the package description claims a transport it does not start
The description reads "**dual-transport MCP server (stdio + Streamable HTTP)**",
but `main()` in `src/index.ts` wires **stdio only**. `http-transport.ts` is a
complete, tested scaffold that nothing imports at runtime — there is no flag,
env var, or subcommand that starts it. By this project's own rule this is a
CLAIMED capability presented as shipped. Either wire it (`--http` /
`RVAGENT_HTTP_PORT` gate) or de-claim the description until it is.
### F4 (MEDIUM, interop + inconsistency): two tool-naming conventions, one of them dot-based
Six tools use `ruview_snake_case`; six (ADR-124 additions) use
`ruview.dotted.names`. Same interop caveat as ADR-263 F9 (host tool-name
regexes commonly `^[a-zA-Z0-9_-]{1,64}$`), plus the split convention makes the
tool surface look like two products. Standardize on underscores and accept the
dotted forms as aliases for one deprecation cycle.
### F5 (MEDIUM, double work + drift): every tool input is validated twice from two hand-maintained schemas
`CallToolRequestSchema` handler runs `TOOL_INPUT_SCHEMAS[name].safeParse(args)`,
then each tool handler runs its own `schema.parse(args)` again — two full Zod
passes per call. Separately, the `inputSchema` JSON advertised via `tools/list`
is **hand-written** and duplicates the Zod schema field-by-field (defaults,
min/max, descriptions) — schema drift between what is advertised and what is
enforced is a matter of time. Parse once at the gate, pass the typed result to
handlers, and generate the advertised JSON Schema from the Zod source
(`zod-to-json-schema` at build time, or Zod 4's native `z.toJSONSchema` when the
SDK's peer range allows).
### F6 (MEDIUM, resource lifecycle): `train_count` leaks 2 fds per job; job registry is process-local
`trainCount` opens `logFdOut`/`logFdErr` with `openSync` and never closes them
in the parent — the spawned cargo child inherits duplicates, but the parent's
descriptors stay open for the MCP server's lifetime: 2 leaked fds per training
job. `jobRegistry` is an in-memory `Map`, so `ruview_job_status` after a server
restart reports "not found" for a training run that is still burning GPU (the
source comments acknowledge this; the fix — persist `~/.ruview/jobs/<id>.json`,
already the documented layout — is small). Also `jobStatus` re-`import`s
`node:fs` on every poll and reads the entire log to return 20 lines.
### F7 (MEDIUM, security/robustness of the HTTP scaffold): unbounded body + one shared session transport
`http-transport.ts` buffers the request body with no size cap (memory DoS the
moment it is wired to a socket), reuses a **single**
`StreamableHTTPServerTransport` with `sessionIdGenerator` for all clients (the
SDK's stateful mode expects one transport per session — a second client's
`initialize` collides), and the Origin allowlist is exact-match
(`http://localhost` will not match a real browser origin `http://localhost:5173`).
Must be fixed **before** F3 wires it in; bearer-token + 127.0.0.1 defaults are
already right.
### F8 (LOW, dead/misleading code): `detectCogBinary` always returns the bare name
It builds a 4-candidate appliance-path array and then returns
`candidates[candidates.length - 1]` — i.e. always `name` — without checking
existence. The candidates are dead weight that reads as if path detection
happens. Either probe with `existsSync` or delete the array.
### F9 (LOW, drift + hygiene): hardcoded versions, unused/mismatched devDeps, bin-name collision
`PACKAGE_VERSION = "0.1.0"` (index.ts) duplicates package.json;
`@types/express` is unused (`http-transport` uses `node:http`); `@types/jest@30`
against `jest@29`; `ruview-cli` hardcodes `.version("0.0.1")`. And
`@ruv/ruview-cli` claims the **`ruview`** bin name, which collides with
`@ruvnet/ruview`'s bin (ADR-182) if both are ever installed globally —
ADR-263/265 give the `ruview` name to the harness; the CLI must rename or fold.
## Decision
- **O1 (F1):** fix `exports`: drop the `require` condition (ESM-only is fine for
a bin-first package) or add a real CJS build; put `types` first. Add a CI
smoke test that does `npm pack` + `node -e "import('<tarball install>')"`.
- **O2 (F2):** publish without maps: `declarationMap: false`, `sourceMap: false`
in a `tsconfig.build.json` used by `prepack` (or add `!dist/**/*.map` to
`files`). Remove the phantom `CHANGELOG.md` entry or create the file.
Acceptance: unpacked size ≤ ~125 kB (from 188 kB — MEASURED, `npm pack --dry-run`).
- **O3 (F3, F7):** wire the HTTP transport behind an explicit opt-in
(`RVAGENT_HTTP_PORT` or `--http`), after F7 fixes: per-session transport map
keyed by `mcp-session-id`, 1 MiB body cap, origin matching that honors ports
(compare `URL.origin` prefixes or document exact origins). Until then, change
the description to "stdio MCP server (Streamable HTTP scaffold, unwired)".
- **O4 (F4):** rename dotted tools to underscore (`ruview_bfld_last_scan`, …),
keep dotted aliases in the call router for one release, note it in the README.
- **O5 (F5):** single validation gate: the registry maps name → Zod schema →
typed handler; advertised `inputSchema` generated from Zod at build time.
- **O6 (F6):** close parent fds after spawn (`closeSync` post-`spawn` — the
child holds its own copies), persist job records to
`<jobsDir>/<id>.json`, and read log tails with a bounded read.
- **O7 (F8):** make `detectCogBinary` actually probe (`existsSync` over the
candidates) — it is the entire reason the function exists.
- **O8 (F9):** single-source versions from package.json; drop `@types/express`;
align `@types/jest` with jest 29 (or move to `node:test` like the harness and
drop the jest toolchain entirely — it is the heaviest devDep in both
packages).
- **O9 (F9, scope):** fold `@ruv/ruview-cli` into `rvagent` as a second bin
(`rvagent-cli`) sharing `http/cog/config`, or keep it private-forever and say
so in its README. Its `ruview` bin name is surrendered to `@ruvnet/ruview`
either way.
## Consequences
- CJS consumers stop hitting a guaranteed-broken export path (F1 is the only
finding that fails for every consumer of that entry point deterministically).
- The published artifact shrinks ~33% (MEASURED, F2 tarball listing: 62,698 B
of maps in a 188 kB unpacked payload) and stops advertising files/transports
it does not contain — the package description itself passes the project's
claim-check bar.
- One schema source ends advertised-vs-enforced drift and halves per-call
validation cost; naming unification makes the 12-tool surface read as one
product and survive strict host tool-name validation.
- Long-lived MCP servers stop accumulating fds during training campaigns, and
job polling survives restarts.
- Costs: the alias cycle (O4) briefly doubles the advertised tool count unless
aliases are router-only (recommended: router-only, advertise underscore names
exclusively); folding the CLI (O9) retires a package name already in use in
scripts, so it needs a deprecation note.
- Verification for the implementing PR: `npm pack --dry-run` asserted file list
(no `.map`, no phantom entries), pack-size budget in CI (ADR-265), jest/`node
--test` suite green, and a tarball-install smoke test for both `import` and
the `rvagent` bin.
@@ -0,0 +1,124 @@
# ADR-265: RuView npm Distribution Strategy — CI Gate, Provenance, Version Single-Sourcing, Namespace
| Field | Value |
|-------|-------|
| **Status** | Accepted — **D1D4 implemented**: `.github/workflows/npm-packages.yml` (matrix gate: tests, version-literal grep, pack-content/size gate, tarball-install smoke test, README claim-check), `.github/workflows/ruview-npm-release.yml` (publish-from-CI with `npm publish --provenance`), version single-sourcing (all three packages read package.json), `ruview` bin owned by `@ruvnet/ruview` (`@ruv/ruview-cli` bin renamed `ruview-cli`), `ci.yml` NODE_VERSION 18→20. D5 (no workspace) stands as recorded |
| **Date** | 2026-07-02 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-NPM-DIST** |
| **Supersedes / amends** | none (cross-cutting layer above ADR-263 and ADR-264; complements ADR-182 P3/P4) |
## Context
The monorepo now ships (or stages) **three Node packages** with no shared
distribution engineering:
| Package | Dir | Published | Bin(s) | Tests in CI |
|---------|-----|-----------|--------|-------------|
| `@ruvnet/ruview` | `harness/ruview/` | 0.1.0 (live) | `ruview` | **none** |
| `@ruvnet/rvagent` | `tools/ruview-mcp/` | 0.1.0 (live) | `rvagent`, `ruview-mcp` | **none** |
| `@ruv/ruview-cli` | `tools/ruview-cli/` | private | `ruview` (collides) | **none** |
Cross-cutting facts established during the ADR-263/264 reviews:
- **Zero CI coverage.** No workflow under `.github/workflows/` references any of
the three directories. Two of the packages are *live on the registry* and were
published from a laptop state CI never saw. Meanwhile the Rust side has a
1,031+-test gate and a witness-bundle culture (ADR-028) — the npm surface is
the only shipped artifact class with no verification gate at all.
- **`ci.yml` pins `NODE_VERSION: '18'`** while all three packages declare
`engines.node >= 20`.
- **Version triplication.** Each package hardcodes its version in source at
least once beyond package.json (harness `SERVER_INFO`, rvagent
`PACKAGE_VERSION`, cli `.version("0.0.1")`).
- **Bin-name collision.** Two packages claim the `ruview` bin.
- **No provenance.** Neither published package carries npm provenance
attestations, in a project whose differentiator is signed, reproducible
evidence (ADR-028 witness bundles, ADR-182 P4 ed25519/SLSA design).
- **No pack-content gate.** ADR-264 F1/F2 (broken `require` target, 33% dead map weight — MEASURED, tarball listing — and a phantom
`CHANGELOG.md` in `files`) are exactly the defect class an
`npm pack --dry-run` assertion catches in seconds.
## Decision
Adopt one distribution layer for all Node packages. Per-package code fixes live
in ADR-263/264; this ADR fixes the machinery around them.
### D1 — One `npm-packages.yml` CI workflow (the gate)
Matrix over `[harness/ruview, tools/ruview-mcp, tools/ruview-cli]` ×
Node `[20, 22]`:
1. `npm ci` where a lockfile is committed (the TS packages); the harness
installs with `npm install` — repo policy gitignores lockfiles under
`harness/`, and the package is dependency-free after ADR-263 O3 so there is
nothing to pin.
2. `npm test` (harness: `node --test test/*.test.mjs` — pin the glob form,
the directory form fails on Node 22; TS packages: build + jest or `node:test`
per ADR-264 O8).
3. **Pack gate:** `npm pack --dry-run --json` asserted against a checked-in
expected file list + a max unpacked-size budget per package (harness ≤ 60 kB;
rvagent ≤ 130 kB post ADR-264 O2). Any new/missing/renamed shipped file is a
reviewed diff, not a surprise.
4. **Tarball smoke test:** install the packed tarball into a temp dir; run
`ruview --version`, `ruview doctor`, `rvagent` `--help`-equivalent, and a
Node `import()` of each declared export condition — this is the test that
would have caught ADR-264 F1 (`require` → nonexistent `dist/index.cjs`).
5. Bump `ci.yml` `NODE_VERSION` to `'20'` (independent of the matrix above).
### D2 — Publish only from CI, with provenance
Manual `npm publish` from laptops stops. A tag-triggered workflow
(`ruview-npm-release.yml`, mirroring the firmware release discipline) runs the
D1 gate, then `npm publish --provenance --access public` under the GitHub OIDC
token. Consequence: every published version is attested to a public commit +
workflow run — the npm-side analogue of the ADR-028 witness bundle. The
`prepublishOnly` script in each package runs the pack gate locally as a
belt-and-braces (publishing outside CI fails loudly, not silently).
### D3 — Version single-sourcing
Rule: **package.json is the only place a version string lives.** Runtime code
reads it (`createRequire(import.meta.url)('./package.json').version` or a
build-time define for the TS packages). CI greps for `\d+\.\d+\.\d+` literals in
`src/` of each package and fails on match (allowlist: test fixtures). This
retires ADR-263 F6 and ADR-264 F9 permanently instead of per-incident.
### D4 — Namespace and bin ownership
- `@ruvnet/ruview` **owns the `ruview` bin** (it is the published front door,
ADR-182). `@ruv/ruview-cli` renames its bin or folds into `rvagent`
(ADR-264 O9) — decided here so neither package ADR relitigates it.
- New Node packages in this repo use the `@ruvnet/` scope (the `@ruv/` scope
holds `rvcsi` legacies; do not grow it).
- Every package README + description must pass
`npx ruview claim-check` — enforced in the D1 gate. The guardrail package
linting its sibling packages' claims is the cheapest dogfooding we have
(ADR-264 F3 is the standing example of why).
### D5 — Shared-code policy (bounded)
Do **not** introduce an npm workspace or a shared runtime package yet: three
packages, two of which may merge (ADR-264 O9), do not justify workspace
machinery, and the harness's zero-dep property is load-bearing. Revisit if a
fourth package appears or if the `http/cog/config` duplication survives the
ADR-264 O9 fold. Record the duplication as intentional in each file header (the
CLI already does this).
## Consequences
- The npm artifacts get the same class of gate the Rust workspace has had since
ADR-028: no publish without tests, no shipped file set without an asserted
manifest, no version without provenance. The two defects that reached the
registry (broken `require` condition, dead maps) become CI-impossible.
- Cold-path costs stay near zero: the D1 matrix is 6 fast jobs (the harness
suite runs in ~108 ms MEASURED; TS builds dominate at a few tens of seconds).
- Publishing gains one constraint (must go through CI) and loses one failure
mode (laptop-state publishes) — the right trade for a project whose brand is
reproducible evidence.
- D3's grep gate is blunt but cheap; if it over-fires, scope it to
`version`-adjacent identifiers before weakening it.
- Follow-ups tracked elsewhere: per-package code fixes (ADR-263 O1O8, ADR-264
O1O9); ADR-182 P4 (metaharness router + ed25519 provenance chain) remains
the deeper provenance story that D2's npm attestations complement, not
replace.
@@ -0,0 +1,75 @@
# ADR-266: MediaTek Filogic CSI Platform
- **Status**: accepted
- **Date**: 2026-07-18
- **Deciders**: RuView maintainers
- **Tags**: mediatek, filogic, mt76, csi, openwrt, rust
## Context
RuView needs a high-antenna-count, router-class Wi-Fi sensing path beyond ESP32.
MediaTek Filogic platforms are attractive because the upstream BSD-3-Clause
`mt76` driver supports MT7915/MT792x/MT7996 families and OpenWrt supports
MT7981/MT7986/MT7988 systems. The OpenWrt One (MT7981B + MT7976C) additionally
publishes schematics, platform datasheets, register documentation, serial, and
JTAG access. The BPI-R3 (MT7986 + MT7975N/P) offers dual-band 4x4 radios.
The current upstream `mt76` tree has testmode, debugfs, RX descriptors, and MCU
event plumbing, but no supported public interface for exporting per-packet
complex channel estimates. Public MediaTek SDK material likewise does not expose
an equivalent to Espressif's CSI callback. PHY computation of channel estimates
does not imply that firmware transfers those estimates to host memory.
Existing RuView documents that describe MT7661 CSI-over-UDP or released
MediaTek CSI tools are unverified architectural hypotheses, not supported
hardware claims.
## Decision
1. Use the OpenWrt One as the primary future hardware/upstreaming target and the
BPI-R3 as the secondary 4x4 validation target.
2. Build a Rust-first simulator and host transport before hardware arrives.
3. Keep the transport independent of private firmware structures. A future
`mt76` adapter must translate a documented kernel/firmware report into it.
4. Prefer Generic Netlink for capability/control messages and relayfs or a
bounded character-device stream if sustained CSI volume exceeds Netlink's
practical throughput.
5. Do not redistribute vendor firmware, private headers, or SDK components.
6. Label simulator frames end-to-end and never present them as physical capture.
7. Do not claim MediaTek hardware CSI support until complex CSI from a physical
device passes calibration, sequence, timestamp, and repeatability tests.
## Consequences
### Positive
- Development and integration testing can start without fabricating a vendor ABI.
- OpenWrt One provides a repairable, upstream-friendly hardware target.
- The same RuView ingestion path can accept simulator, replay, and future driver data.
- Rust bounds checking isolates untrusted kernel/network input from inference code.
### Negative
- The simulator cannot prove firmware export availability or sensing accuracy.
- A firmware change or MediaTek cooperation may be required before physical CSI exists.
- Router-class builds and driver iteration are slower than MCU firmware development.
### Neutral
- NeuroPilot may later accelerate inference but is unrelated to CSI capture.
- Wi-Fi 7/MLO support remains a later phase after a single-link contract is stable.
## Hardware gates
- Identify a firmware/host report containing complex channel estimates.
- Document dimensions, quantization, chain ordering, subcarrier indexing, lifetime,
timestamps, sequence behavior, calibration, maximum size, and report rate.
- Validate OpenWrt One first, then BPI-R3 4x4, before considering MT7996/MLO.
## Links
- [ADR-123: BFLD capture path](ADR-123-bfld-capture-path-nexmon-and-esp32.md)
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
- [upstream mt76](https://github.com/openwrt/mt76)
- [OpenWrt One](https://openwrt.org/toh/openwrt/one)
- [MediaTek OpenWrt feed](https://git01.mediatek.com/openwrt/feeds/mtk-openwrt-feeds/)
@@ -0,0 +1,61 @@
# ADR-267: MediaTek MIMO CSI Wire Protocol
- **Status**: accepted
- **Date**: 2026-07-18
- **Deciders**: RuView maintainers
- **Tags**: mediatek, csi, protocol, rust, udp, replay
## Context
The MediaTek simulator, captured regression fixtures, and a future `mt76` agent
need one safe host-side representation. Copying an undocumented firmware layout
would couple RuView to a private ABI and make malformed kernel/network data risky.
MIMO CSI also requires explicit Tx/Rx/subcarrier dimensions and per-Rx-chain RSSI.
## Decision
Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
- 72-byte fixed header with magic, version, report kind, total length, sequence,
monotonic timestamp, device ID, chipset profile, frequency, bandwidth, flags,
Tx/Rx dimensions, numeric format, PPDU type, subcarrier count, noise floor,
scale, subcarrier spacing, calibration ID, and payload length.
- CSI payload begins with one signed RSSI byte per Rx chain, followed by
`tx_count * rx_count * subcarrier_count` complex values in Tx-major,
Rx-major, subcarrier-major order.
- Supported numeric formats are complex signed i16 and complex finite f32.
- Capability reports use bounded opaque TLVs until a public driver contract exists.
- CRC-32/IEEE covers header and payload; the final four bytes carry the checksum.
- One envelope maps to one UDP datagram, capped at the IPv4 UDP payload maximum
of 65,507 bytes. Replay files prefix each envelope with a little-endian `u32`.
- Parsers reject unknown versions/types/formats, invalid dimensions/bandwidth,
multiplication overflow, inconsistent payload lengths, non-finite floats,
bad CRC, trailing datagram bytes, and frames above the cap.
- Flags distinguish calibrated, saturated, time-synchronized, dropped-predecessor,
and synthetic frames. Synthetic provenance cannot be cleared by downstream code.
## Consequences
### Positive
- Deterministic simulator and future hardware use identical parsing and APIs.
- Explicit dimensions prevent ambiguous antenna or subcarrier interpretation.
- CRC, finite-value checks, and hard caps make network/replay ingestion robust.
- The format supports MT7981, MT7986, and MT7996 profiles without claiming their
undocumented firmware layouts.
### Negative
- A translation/copy step is required from a future kernel report.
- Maximum-size Wi-Fi 7 matrices may need segmentation in a later protocol version.
### Neutral
- Version 1 models one link per report; MLO correlation is a future extension.
- Capability TLVs are intentionally conservative until hardware metadata is known.
## Links
- [ADR-266: MediaTek Filogic CSI platform](ADR-266-mediatek-filogic-csi-platform.md)
- [ADR-018: ESP32 binary CSI framing](ADR-018-esp32-csi-frame-protocol.md)
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
@@ -0,0 +1,41 @@
# ADR-268: Qualcomm Atheros CSI Platform Strategy
- **Status**: accepted
- **Date**: 2026-07-18
- **Tags**: qualcomm, atheros, csi, ath9k, ath11k, ath12k, simulator
## Context
RuView needs a Qualcomm path that is useful before vendor hardware access while
remaining honest about firmware boundaries. QCA9300 has demonstrated CSI tooling
through ath9k/PicoScenes-class systems. QCN9074 and QCN9274 have upstream Linux
connectivity drivers, but upstream ath11k/ath12k support does not by itself prove
that raw per-packet complex CSI is exported by public firmware.
## Decision
1. Use QCA9300 as the first physical baseline: 802.11n, up to 3x3 MIMO and
20/40 MHz. Accept translated captures from established research tooling.
2. Model QCN9074 (Wi-Fi 6/6E, 4x4, up to 160 MHz) and QCN9274 (Wi-Fi 7, 4x4,
up to 160 MHz in protocol v1) as explicitly experimental simulator profiles.
3. Keep firmware/kernel formats behind a Rust adapter. RuView ingests only the
validated QCS1 application envelope defined by ADR-269.
4. Never label simulated frames as hardware. Physical support requires captured
fixtures, firmware provenance, antenna ordering, scaling and repeatability tests.
5. Prefer an upstream-reviewed Generic Netlink or relay-style export if modern
Qualcomm firmware exposes CFR/CSI; do not depend on undisclosed structs.
## Consequences
- Development, APIs and downstream sensing can be tested immediately.
- QCA9300 offers the shortest path to real Qualcomm data.
- Modern profiles may remain simulator-only until firmware cooperation exists.
- A translation copy is accepted in exchange for a stable, fuzzable boundary.
## Links
- [ADR-269: QCS1 wire protocol](ADR-269-qualcomm-csi-wire-protocol.md)
- [Linux ath11k supported devices](https://wireless.docs.kernel.org/en/latest/en/users/drivers/ath11k.html)
- [PicoScenes supported hardware](https://ps.zpj.io/manual/hardware.html)
- [ADR-270: vendor integration portfolio and acceptance gates](ADR-270-vendor-rf-sensing-integration-program.md)
@@ -0,0 +1,40 @@
# ADR-269: Qualcomm CSI Wire Protocol
- **Status**: accepted
- **Date**: 2026-07-18
- **Tags**: qualcomm, csi, protocol, rust, udp, replay
## Decision
Define `QCS1` version 1 as a vendor-boundary envelope, not a Qualcomm firmware ABI.
It uses a 72-byte little-endian header plus payload and CRC-32/IEEE. The header
records report kind, total length, sequence, monotonic timestamp, device ID,
chipset profile, center frequency, bandwidth, flags, Tx/Rx counts, numeric format,
PPDU type, subcarrier count, noise floor, scale, subcarrier spacing, calibration
ID and payload length.
CSI payloads contain one signed RSSI byte per receive chain followed by
`tx * rx * subcarriers` complex i16 or finite f32 values in Tx-major, Rx-major,
subcarrier-major order. Capability reports carry bounded opaque bytes. One QCS1
frame maps to one UDP datagram; replay files prefix each frame with a little-endian
u32 length.
Parsers fail closed on unknown enums, bad CRC, truncation, trailing datagram data,
non-finite values, inconsistent dimensions, chipset chain/bandwidth violations,
payload mismatches, arithmetic overflow and the IPv4 UDP payload ceiling. A
synthetic flag provides end-to-end simulator provenance.
Version 1 profiles are QCA9300, QCN9074 and QCN9274. QCA9300 is capped at three
chains and 40 MHz; modern profiles are capped at four chains and 160 MHz.
## Consequences
- Simulator, replay and future hardware adapters share one validated Rust API.
- No private firmware layout is represented or redistributed.
- 320 MHz/EHT matrices require segmentation or a later protocol revision.
## Links
- [ADR-268: Qualcomm platform strategy](ADR-268-qualcomm-atheros-csi-platform.md)
- [ADR-267: MediaTek MTC1 protocol](ADR-267-mediatek-mimo-csi-wire-protocol.md)
@@ -0,0 +1,122 @@
# ADR-270: Vendor RF Sensing Integration Program
- **Status**: accepted
- **Date**: 2026-07-18
- **Deciders**: RuView maintainers
- **Tags**: vendors, csi, telemetry, simulator, rust, hardware-validation
## Context
RuView is evaluating Qualcomm, RF Solutions, Origin AI, Plume, Linksys,
Electric Imp, Mist/Juniper, Luma, Google Nest, NETGEAR and Wifigarden. These
names do not represent equivalent integration surfaces: some expose raw CSI,
some expose derived sensing events or network telemetry, and some expose no
supported developer interface. A repeated implementation process must not turn
brand compatibility, Linux connectivity or synthetic fixtures into a false CSI
claim.
## Decision
Adopt a Rust-first provider portfolio with explicit capability negotiation:
- `ComplexCsi`: calibrated per-packet complex channel matrices.
- `DerivedSensing`: vendor-produced motion, occupancy or location events.
- `RfTelemetry`: RSSI, radio, client and topology observations.
- `NetworkOnly`: useful as excitation/AP infrastructure but not a sensor.
- `Unsupported`: no stable, lawful or supportable integration surface.
Every provider follows the same gated loop:
1. Verify an authoritative API/SDK, exact model/chipset and licensing boundary.
2. Write provider and wire/contract ADRs before coupling core code to a vendor.
3. Implement bounded Rust types, explicit capabilities and synthetic provenance.
4. Test deterministic replay, corruption, loss, reconnect, backpressure, schema
evolution and secrets handling.
5. Promote to hardware support only after lawful physical capture on an exact
model/firmware, calibration and repeatability tests, and fixture publication
rights. Simulator success never satisfies this gate.
6. Publish code/release and an upstream or vendor collaboration announcement
that states the measured-versus-simulated boundary.
### Portfolio decisions
| Provider | Classification | Decision |
|---|---|---|
| Qualcomm QCA9300 | `ComplexCsi` candidate | Implement first physical baseline via established ath9k research tooling; QCS1 adapter ships simulator-first. |
| Qualcomm QCN9074/QCN9274 | experimental `ComplexCsi` | Simulator and protocol now; require confirmed ath11k/ath12k firmware export before hardware claim. |
| Origin AI | commercial `DerivedSensing`, possible CSI | Pursue NDA sandbox/API and raw-data rights; isolate proprietary engine behind provider trait/service boundary. |
| Plume/OpenSync | `RfTelemetry`; Plume Sense is gated `DerivedSensing` | Build optional OVSDB/control-plane adapter; negotiate Sense separately and do not infer raw CSI. |
| Mist/Juniper | `RfTelemetry` + location | Conditional read-only REST/webhook adapter for occupancy, RSSI and coordinates; no CSI claim. |
| NETGEAR | partner-gated `RfTelemetry` | Insight adapter only after API access; exact legacy OpenWrt models remain community experiments. |
| Luma | discontinued OpenWrt salvage target | Generic OpenWrt telemetry/pcap fixture only when already owned; no procurement or Luma CSI source. |
| Google Nest Wifi | `NetworkOnly` | Use as traffic/AP infrastructure; Device Access does not expose router CSI or radio telemetry. |
| Linksys | `Unsupported` for sensing | Linksys Aware reached end of support in 2024; record capability probe only, if needed. |
| Electric Imp | scalar IoT/RSSI telemetry | Optional agent/impCentral bridge for existing fleets; reject as CSI acquisition hardware. |
| RF Solutions | non-Wi-Fi RF/IoT telemetry | Exclude from sensing backend; optional RIoT environmental fusion is a separate future concern. |
| Wifigarden | commercial OEM, capability unknown | Hold implementation pending chipset, schema, offline, calibration and data-rights disclosure. |
### Provider boundary
Core code consumes a vendor-neutral `RfSource`-style contract whose capability
set prevents RSSI, location or derived occupancy from being represented as CSI.
Cloud adapters use bounded async queues, regional endpoints, secret-provider
credentials and explicit data provenance. Proprietary device SDKs live behind a
feature-gated FFI or sidecar boundary and are never redistributed without rights.
## Consequences
### Positive
- The integration loop can be repeated without duplicating unsafe parsers.
- Product integrations remain useful even when only telemetry is available.
- Public releases make hardware confidence and simulator confidence distinct.
### Negative
- Several named vendors cannot produce a legitimate CSI implementation today.
- Commercial providers require contracts, subscriptions, test vectors or NDAs.
- Exact hardware revisions and firmware provenance increase validation effort.
### Neutral
- A no-go or telemetry-only ADR is a completed research outcome, not a failed port.
- Vendor status and APIs must be rechecked before each implementation begins.
## Implementation Status
The ADR-270 provider contract is implemented in Rust. Each portfolio entry has
a descriptor, bounded decoder or explicit fail-closed access state, deterministic
contract fixtures where lawful, registry coverage, and API exposure:
- Origin AI: contract-configured derived-sensing decoder and request plan.
- Plume/OpenSync: read-only OVSDB request plan and RF telemetry decoder.
- Mist/Juniper: regional request configuration, paginated RF/location decoder.
- NETGEAR Insight: regional partner request configuration and telemetry decoder.
- Electric Imp and RF Solutions: bounded scalar telemetry bridges.
- Luma: explicitly experimental generic OpenWrt telemetry bridge.
- Google Nest: network-only contract events; never represented as CSI.
- Linksys: `Unsupported` decoder because Linksys Aware is end-of-support.
- Wifigarden: `ContractRequired` decoder pending a disclosed SDK/schema.
`vendor-rf-sim` generates deterministic, provenance-labelled events for the
eight providers with a defined event contract and refuses to fabricate Linksys
or Wifigarden events. The sensing server exposes provider descriptors and latest
events under `/api/v1/rf/vendors` and accepts validated canonical simulator
events over its existing UDP port. Physical/vendor-cloud validation remains
separate from implementation completeness and is reflected by
`hardware_validated: false` until performed.
## Evidence and Links
- [ADR-268: Qualcomm strategy](ADR-268-qualcomm-atheros-csi-platform.md)
- [OpenSync developer sandbox](https://www.opensync.io/developer)
- [Origin AI Wi-Fi sensing architecture](https://www.originwirelessai.com/wifi-sensing/)
- [Juniper Mist webhook hierarchy](https://www.juniper.net/documentation/us/en/software/mist/automation-integration/topics/topic-map/webhook-hierarchy.html)
- [Linksys product end-of-life](https://www.linksys.com/pages/linksys-product-end-of-life)
- [Google Nest Device Access supported devices](https://developers.google.com/nest/device-access/supported-devices)
- [OpenWrt Luma WRTQ-329ACN](https://openwrt.org/toh/hwdata/luma/luma_wrtq-329acn)
- [NETGEAR Insight compatible devices](https://kb.netgear.com/000048452/What-devices-can-I-discover-monitor-and-manage-with-Insight)
- [Electric Imp imp005 hardware guide](https://developer.electricimp.com/hardware/imp/imp005_hardware_guide)
- [RF Solutions company portfolio](https://www.rfsolutions.co.uk/about-us-i1/)
- [Wifigarden service terms](https://policies.wifigarden.com/en-us/terms-of-service)
+13 -1
View File
@@ -1,6 +1,15 @@
# Architecture Decision Records
This folder contains 45 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project.
Latest proposed decisions:
- [ADR-187: archive/v1 deprecation + model-weights honest labeling](ADR-187-archive-v1-deprecation-honest-labeling.md) (refs #509, #1125)
- [ADR-186: Training progress API — wire the orphaned in-server trainer to /ws/train/progress](ADR-186-training-progress-api.md) (refs #1233)
- [ADR-185: Python P6 SOTA bindings — AETHER, MERIDIAN, MAT](ADR-185-python-p6-sota-bindings.md)
- [ADR-184: ADR-117 completion via PyPI Trusted Publishing](ADR-184-adr117-completion-pypi-trusted-publishing.md) (refs #785)
- [ADR-264: Versioned wire protocol for RTL8720F CFR and Range-FFT reports](ADR-264-rtl8720f-radar-wire-protocol.md)
- [ADR-263: Adopt RTL8720F 2.4 GHz FMCW radar as an optional RuView sensing platform](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md)
This folder contains 193 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
## Why ADRs?
@@ -120,6 +129,9 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-097](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) | Adopt rvCSI as RuView's primary CSI runtime (phased adoption) | Proposed |
| [ADR-098](ADR-098-evaluate-midstream-fit.md) | Evaluate `ruvnet/midstream` for RuView's CSI / WebSocket / mesh pipeline | Rejected |
| [ADR-099](ADR-099-midstream-introspection-tap.md) | Adopt midstream as RuView's real-time introspection + low-latency tap | Proposed |
| [ADR-263](ADR-263-ruview-npm-harness-deep-review.md) | `@ruvnet/ruview` npm harness — deep review + optimization strategy | Proposed |
| [ADR-264](ADR-264-rvagent-mcp-and-cli-npm-deep-review.md) | `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` — deep review + optimization strategy | Proposed |
| [ADR-265](ADR-265-ruview-npm-distribution-strategy.md) | RuView npm distribution strategy — CI gate, provenance, version single-sourcing, namespace | Proposed |
---
+3 -3
View File
@@ -425,7 +425,7 @@ pub enum WifiChipset {
BroadcomBcm43455,
/// Realtek RTL8822CS via modified rtw88 driver.
RealtekRtl8822cs,
/// MediaTek MT7661 via mt76 driver modification.
/// Proposed MediaTek MT7661 research target; no public CSI export is verified.
MediatekMt7661,
}
@@ -455,7 +455,7 @@ pub struct Esp32CompatFrame {
```
**Domain Services:**
- `CsiExtractionService` — Reads raw CSI from patched driver via Netlink socket (BCM43455), procfs (RTL8822CS), or UDP (MT7661)
- `CsiExtractionService` — Reads raw CSI from a validated chipset adapter. Nexmon/BCM43455 is the established Linux example; RTL8822CS and MT7661 remain unverified research targets and must not be advertised as working capture paths.
- `SubcarrierResamplerService` — Resamples chipset-specific subcarrier counts to match ESP32 format (e.g., 256 → 128 via decimation or interpolation)
- `ProtocolTranslatorService` — Converts `ChipsetCsiFrame` to `Esp32CompatFrame` with ADR-018 binary encoding
- `CalibrationService` — Compensates for chipset-specific phase offsets, antenna spacing, and gain differences relative to ESP32 CSI
@@ -625,7 +625,7 @@ pub struct EspNodeConnection {
### ESP32 Protocol ACL (CSI Bridge)
The WiFi CSI Bridge translates chipset-specific CSI formats (Nexmon, rtw88, mt76) into the ESP32 binary protocol (ADR-018). The sensing server never knows whether frames came from a real ESP32 or a TV box WiFi chipset. Virtual node IDs (200-254) prevent collision with physical ESP32 IDs but are otherwise treated identically by the ingestion context.
The WiFi CSI Bridge translates validated chipset-specific CSI formats into a versioned RuView envelope. Nexmon is the established Linux example; rtw88 and mt76 require a verified complex-CSI export before implementation. Virtual node IDs (200-254) prevent collision with physical ESP32 IDs but are otherwise treated identically by the ingestion context.
### Armbian Platform ACL
+45
View File
@@ -0,0 +1,45 @@
# RuView v0.9.0-realtek-beta.1
This prerelease introduces the Rust-first RTL8720F 2.4 GHz radar transport and
RuView ingestion path. It is intentionally simulator-validated until Realtek
hardware and the vendor SDK callback ABI arrive.
## Included
- ADR-263 records the upstream Ameba integration and licensing boundary.
- ADR-264 defines a versioned, bounded, CRC-protected radar envelope.
- `rtl8720f-sim` emits deterministic CFR, near-range, far-range, interference,
and capability reports to UDP or replay files.
- The sensing server validates RTL8720F datagrams, publishes bounded summaries
over `/ws/sensing`, and exposes the latest report at
`/api/v1/radar/latest`.
- Synthetic provenance is retained end to end as `realtek:simulated`; simulator
data is never presented as hardware data.
## Compatibility
The adapter tracks the radar control surface proposed by Ameba RTOS pull
request #1336 (`wifi_radar_config`, `AT+RAD`, and `AT+RADDBG`). The stable Ameba
RTOS v1.2.1 release does not yet expose the complete radar receive callback ABI,
so no vendor-private headers or binary libraries are copied into this release.
## Validation status
- Rust codec round trips, corruption rejection, size bounds, and deterministic
simulator tests pass.
- RuView server ingestion, REST reporting, and source provenance were exercised
end to end over loopback UDP.
- Windows release binaries are built from this branch and accompanied by
SHA-256 checksums.
## Known limitations
- No physical RTL8720F board has been flashed or measured.
- The vendor report callback and exact report layouts remain an SDK/hardware
validation gate; the adapter boundary may change when those arrive.
- This beta exposes transport and aggregate radar observability. Radar-to-pose,
vital-sign inference, RF calibration, and accuracy claims are not enabled.
- 2.4 GHz radar reports are not mislabeled as mmWave or Wi-Fi CSI events.
Do not deploy this prerelease for safety-critical, medical, or occupancy billing
uses. It is an integration beta for SDK and hardware bring-up.
+32
View File
@@ -0,0 +1,32 @@
# RuView v0.9.1-mediatek-beta.1
This simulator-first beta adds a Rust MediaTek Filogic MIMO CSI transport and
RuView ingestion path while preserving the boundary between demonstrated host
integration and unavailable physical CSI export.
## Included
- ADR-266 selects OpenWrt One (MT7981/MT7976) as the primary future hardware
target and BPI-R3 (MT7986/MT7975) as the secondary 4x4 target.
- ADR-267 defines the bounded, versioned, CRC-protected `MTC1` wire protocol.
- `mediatek-csi-sim` provides deterministic MT7981, MT7986, and MT7996 profiles,
complex MIMO CSI, per-chain RSSI, UDP streaming, and replay output.
- RuView validates MediaTek datagrams, publishes bounded WebSocket summaries,
and exposes `/api/v1/csi/mediatek/latest`.
- `mediatek:simulated` provenance is retained end to end.
## Validation
- Codec round trips, deterministic output, corruption/truncation rejection,
dimension limits, finite-value enforcement, and prefix parsing are tested.
- All hardware and sensing-server regression tests pass.
- All three profiles were streamed over loopback UDP and verified through the
RuView REST API.
## Hardware boundary
Upstream `mt76` and public MediaTek SDK material do not currently expose a
supported raw complex CSI API. This release does not redistribute private SDK
material, invent a firmware ABI, or claim physical MediaTek capture. Hardware
support requires a documented firmware/driver channel-estimate export followed
by calibration and repeatability validation.
+22
View File
@@ -0,0 +1,22 @@
# RuView v0.9.2-qualcomm-beta.1
This simulator-first beta adds a Rust Qualcomm Atheros CSI boundary without
claiming modern Qualcomm firmware exports that have not been physically verified.
## Included
- ADR-268 selects QCA9300 as the first physical baseline and treats QCN9074 and
QCN9274 as experimental modern profiles.
- ADR-269 defines the bounded, versioned, CRC-protected `QCS1` protocol.
- `qualcomm-csi-sim` emits deterministic MIMO CSI over UDP or replay files.
- The sensing server validates QCS1 datagrams, broadcasts bounded summaries and
exposes `/api/v1/csi/qualcomm/latest`.
- `qualcomm:simulated` provenance is retained end to end.
## Validation boundary
Codec, corruption, truncation, finite-value, dimensions, chipset bandwidth,
determinism and prefix parsing are automated. Loopback UDP/API validation covers
all profiles. Physical QCA9300 comparison and modern firmware export validation
remain hardware gates and will be published with firmware and calibration details.
@@ -0,0 +1,21 @@
# RuView v0.9.3 Vendor Providers Beta 1
This beta implements ADR-270 as a capability-safe Rust provider program across
all ten researched vendors.
## Included
- Shared `VendorRfProvider` contract with bounded event validation.
- Origin AI, Plume/OpenSync, Mist/Juniper, NETGEAR Insight, Electric Imp,
RF Solutions, Luma/OpenWrt and Google Nest contract adapters.
- Explicit fail-closed Linksys (`Unsupported`) and Wifigarden
(`ContractRequired`) providers.
- Deterministic `vendor-rf-sim` JSONL/UDP fixtures for defined contracts.
- Provider registry, descriptors, latest-event REST endpoints and WebSocket
summaries through the sensing server.
## Boundary
This release implements and validates software contracts. It does not claim
vendor-cloud credentials, commercial SDK rights, physical hardware validation,
or complex CSI support for telemetry-only providers.
+31 -1
View File
@@ -1141,7 +1141,20 @@ What it ships (and what it does not):
| Presence detection (occupied / empty) | ✅ Trained head — v2 encoder reports 82.3% held-out temporal-triplet acc (v1's "100% on validation" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
| 128-dim CSI embeddings (re-ID, similarity, downstream training) | ✅ Trained encoder |
| Single-person breathing / heart-rate | ⚠️ Server still uses heuristic DSP — model does not replace this yet |
| 17-keypoint full-body pose | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
| 17-keypoint full-body pose | 🔬 This HF bundle ships no keypoint head — but real pose weights exist elsewhere; see the tier table below |
### Model weights: what's real, what's not
"WiFi → pose" means three different things in this repo, at three different maturity
levels. Read the label, not the headline ([ADR-187](adr/ADR-187-archive-v1-deprecation-honest-labeling.md)):
| Tier | Checkpoint(s) | Honest status |
|------|---------------|---------------|
| **Real & validated** | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) (encoder + presence head) · [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) (17-keypoint pose) · `cog-person-count/count_v1` | **MEASURED / published.** Presence = 82.3% held-out temporal-triplet accuracy (the old "100% presence" figure was retracted); MM-Fi pose = 82.69% torso-PCK@20 on the `random_split` protocol. |
| **Real but weak (honestly labeled)** | committed `v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors` | First-cut on-device model. **PCK@20 = 3.0% / PCK@50 = 18.5%** on a 217-sample holdout — **below the ADR-079 target of ≥ 35%.** Learns coarse structure (`r_hip` 77% PCK@50); distal/face joints near-random. Its runtime path in `cog-pose-estimation/src/inference.rs` is still a centred-skeleton **stub returning `confidence=0`**. Full disclosure in the [cog README](../v2/crates/cog-pose-estimation/cog/README.md). Do not advertise the live single-ESP32 17-keypoint feature without this caveat. |
| **Architecture only, no weights** | `archive/v1` `DensePoseHead` | Random `kaiming_normal_` init, **no checkpoint of any kind** (zero `.pth`/`.onnx`/`.safetensors` files under `archive/v1/`). Deprecated and superseded — see [`archive/v1/DEPRECATED.md`](../archive/v1/DEPRECATED.md). Do not expect real pose output from it. |
**Does it actually run, and can a single ESP32 do pose? ([#509](https://github.com/ruvnet/RuView/issues/509), [#1125](https://github.com/ruvnet/RuView/issues/1125))** Yes, it runs, and the results are reproducible: the deterministic signal-pipeline proof (`python archive/v1/data/proof/verify.py`, must print `VERDICT: PASS`), the committed pose training dump (`v2/crates/cog-pose-estimation/cog/artifacts/train_results.json`), and the auditable MM-Fi arena all back specific numbers. But a single-antenna, 56-subcarrier CSI stream at a 20-frame window does *not* carry the fine-grained spatial information the multi-antenna NIC research relies on — so the shippable pose accuracy the project stands behind today is the **MM-Fi benchmark number**, not a live single-ESP32 number. The path to a first reproducible on-device baseline (PCK@20 ≥ 35%) is tracked in [ADR-079](adr/ADR-079-camera-ground-truth-training.md) / [#645](https://github.com/ruvnet/RuView/issues/645).
### Download
@@ -1861,6 +1874,23 @@ node scripts/eval-wiflow.js \
--data data/paired/*.jsonl
```
> **Model format boundary:** `train-wiflow-supervised.js` produces the
> JavaScript WiFlow model `wiflow-v1.json`. There is currently no supported
> command that converts that JSON model into the sensing server's binary RVF
> container, and renaming the file to `.rvf` does not convert it. Use the JSON
> model with the JavaScript evaluation/inference tools. To train a model that
> the Rust sensing server can load, use its native training path, which writes
> RVF directly:
>
> ```bash
> cargo run -p wifi-densepose-sensing-server --release -- \
> --train --dataset data/mmfi --dataset-type mmfi \
> --epochs 100 --save-rvf models/room-model.rvf
> ```
>
> The camera+CSI paired JSONL workflow and the native RVF trainer are separate
> pipelines today. A JSON-to-RVF exporter is future work.
**Evaluation protocol matters.** Use `eval-wiflow.js` (torso-normalized
PCK@20, the metric comparable to published WiFi-pose results) on a temporal
hold-out, and sanity-check that predictions actually vary across frames
+60
View File
@@ -0,0 +1,60 @@
# ADR-270 Vendor RF Providers
RuView exposes a capability-safe Rust provider layer for vendor sensing and RF
telemetry. It never converts RSSI, occupancy, location or network inventory into
complex CSI.
## API
- `GET /api/v1/rf/vendors` — all provider descriptors and access states.
- `GET /api/v1/rf/vendors/latest` — latest validated event per vendor.
- `GET /api/v1/rf/vendors/:vendor/latest` — latest event for one stable vendor ID.
- `POST /api/v1/rf/vendors/:vendor/events` — ingest the vendor's documented
sidecar/webhook payload through its strict provider decoder. This `/api/v1/*`
route uses the server's bearer-token policy when configured.
Stable IDs are `origin_ai`, `plume`, `mist`, `netgear`, `electric_imp`,
`rf_solutions`, `linksys`, `luma`, `google_nest`, and `wifigarden`.
## Deterministic simulator
```bash
cd v2
cargo run -p wifi-densepose-hardware --bin vendor-rf-sim -- \
--vendor plume --frames 100 --output plume.jsonl
# Stream canonical synthetic events to the sensing server UDP port.
cargo run -p wifi-densepose-hardware --bin vendor-rf-sim -- \
--vendor mist --frames 100 --udp 127.0.0.1:5005 --realtime
```
Supported simulator names are `origin-ai`, `plume`, `mist`, `netgear`,
`electric-imp`, `rf-solutions`, `luma`, and `google-nest`. Linksys is refused
because its sensing service is discontinued. Wifigarden is refused until a
contracted event schema exists.
Every synthetic event includes `synthetic: true`, a deterministic sequence and
timestamp, and a source ending in `-sim-01`.
Canonical UDP JSON is accepted only when `synthetic: true`. Live vendor payloads
must use the HTTP ingestion route so provider-specific schemas, metric allowlists,
access states and bounds cannot be bypassed.
## Live/provider payloads
Provider decoders are strict, bounded and reject unknown schema fields. Origin
paths and credentials are supplied by the commercial contract. Plume uses a
read-only allow-listed OVSDB request plan. Mist and NETGEAR configurations use
regional HTTPS endpoints with redacted tokens. Electric Imp, RF Solutions and
Luma accept only allow-listed scalar metrics. Google Nest remains network-only.
Credentials are never embedded in fixtures or descriptors. Linksys returns
`Unsupported`; Wifigarden returns `ContractRequired`. These are usable,
test-covered provider outcomes—not simulated integrations.
## Hardware honesty
All descriptors remain `hardware_validated: false` until exact hardware/cloud
versions, lawful access, repeatable captures, calibration where applicable, and
fixture publication rights have been verified. Passing the simulator and API
tests validates RuView software only.
+1 -1
View File
@@ -1,5 +1,5 @@
# ESP32 CSI Node Firmware (ADR-018)
# Requires ESP-IDF v5.2+
# Requires ESP-IDF v5.4+
cmake_minimum_required(VERSION 3.16)
set(EXTRA_COMPONENT_DIRS "")
+14 -4
View File
@@ -4,7 +4,7 @@
This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (production) or ESP32-C6 (research target — Wi-Fi 6 / 802.15.4 / TWT / LP-core hibernation, see [ADR-110](../../docs/adr/ADR-110-esp32-c6-firmware-extension.md)) and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the [WiFi-DensePose](../../README.md) project.
[![ESP-IDF v5.2](https://img.shields.io/badge/ESP--IDF-v5.2-blue.svg)](https://docs.espressif.com/projects/esp-idf/en/v5.2/)
[![ESP-IDF v5.4](https://img.shields.io/badge/ESP--IDF-v5.4-blue.svg)](https://docs.espressif.com/projects/esp-idf/en/v5.4/)
[![Target: ESP32-S3 / ESP32-C6](https://img.shields.io/badge/target-ESP32--S3%20%7C%20ESP32--C6-purple.svg)](https://www.espressif.com/en/products/socs/esp32-s3)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-green.svg)](../../LICENSE)
[![Binary: ~943 KB](https://img.shields.io/badge/binary-~943%20KB-orange.svg)](#memory-budget)
@@ -48,10 +48,18 @@ with `--flash_size 4MB`.
# From the repository root:
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \
espressif/idf:v5.4 bash -c \
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
```
> **Display-less boards (ESP32-S3-DevKitC-1 and similar):** build with the
> `sdkconfig.defaults.devkitc` overlay instead — the default build compiles
> display support in, and the runtime panel probe false-positives on boards
> with no panel, which disables the RuView#893 MGMT+DATA CSI upgrade and
> collapses CSI yield to 0 pps. See the header of
> [`sdkconfig.defaults.devkitc`](sdkconfig.defaults.devkitc) for the exact
> build command.
### 2. Flash
Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB):
@@ -105,6 +113,8 @@ curl http://<ESP32_IP>:8032/wasm/list
> **Tip:** A single node provides presence and vital signs along its line of sight. Multiple nodes (3-6) create a multistatic mesh that resolves 3D pose with <30 mm jitter and zero identity swaps.
> **⚠️ Thermal warning — compact boards (ESP32-S3-Zero, SuperMini, other coin-sized clones):** This firmware runs the WiFi radio with modem sleep disabled (`WIFI_PS_NONE`, required for continuous CSI capture) plus a full edge-processing DSP pipeline on Core 1 (`edge_tier=2`) plus, on ADR-183 builds, a continuous 40 Hz onboard LED driver. That's sustained high current draw with no duty-cycling. Full-size dev boards (DevKitC-1, XIAO) have more copper pour and thermal mass around the regulator and tolerate this fine. Coin-sized clones with minimal PCB area and budget regulators may run hot to the touch during normal operation, and in at least one field report, boards that ran hot during a session failed to power on afterward (regulator damage suspected — see issue tracker). Give these boards airflow, don't stack or enclose them, and check them by touch during the first several minutes of a new deployment. If a board is uncomfortably hot (not just warm), power it down and let it cool before continuing.
---
## Firmware Architecture
@@ -250,7 +260,7 @@ Offset Size Field
# From the repository root:
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \
espressif/idf:v5.4 bash -c \
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
```
@@ -268,7 +278,7 @@ To change Kconfig settings before building:
```bash
MSYS_NO_PATHCONV=1 docker run --rm -it \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \
espressif/idf:v5.4 bash -c \
"idf.py set-target esp32s3 && idf.py menuconfig"
```
@@ -319,7 +319,9 @@ static void emit_feature_state(void)
(uint64_t)esp_timer_get_time(),
profile);
int sent = stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
/* feature_state is ~1 Hz and small — priority path so the CSI ENOMEM
* backoff can't starve it (#1183). */
int sent = stream_sender_send_priority((const uint8_t *)&pkt, sizeof(pkt));
if (sent < 0) {
ESP_LOGW(TAG, "feature_state emit failed");
}
@@ -333,11 +335,14 @@ static void slow_loop_cb(TimerHandle_t t)
* detect sync-error drift. */
uint8_t nid[8];
node_id_bytes(nid);
rv_mesh_send_health(s_role, s_mesh_epoch, nid);
/* #1183: report the actual send result — the old log printed "HEALTH sent"
* unconditionally even when rv_mesh_send returned ESP_FAIL. */
esp_err_t health_rc = rv_mesh_send_health(s_role, s_mesh_epoch, nid);
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u, role=%u, epoch=%u) HEALTH sent",
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u, role=%u, epoch=%u) HEALTH %s",
(unsigned)s_state, (unsigned)s_feature_state_seq,
(unsigned)s_role, (unsigned)s_mesh_epoch);
(unsigned)s_role, (unsigned)s_mesh_epoch,
health_rc == ESP_OK ? "sent" : "FAILED");
}
/* ---- Public API ---- */
+3 -1
View File
@@ -341,7 +341,9 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
memcpy(&sync[24], &s_sequence, 4); /* high-water seq for pairing */
uint32_t zero32 = 0;
memcpy(&sync[28], &zero32, 4); /* reserved (room for leader_id low32) */
int sr = stream_sender_send(sync, sizeof(sync));
/* Sync packets are 32 B at ~0.5 Hz — priority path so the CSI
* ENOMEM backoff can't starve cross-node time alignment (#1183). */
int sr = stream_sender_send_priority(sync, sizeof(sync));
static uint32_t s_sync_count = 0;
s_sync_count++;
if (s_sync_count <= 3 || (s_sync_count % 60) == 0) {
+6 -1
View File
@@ -67,6 +67,8 @@ static void event_handler(void *arg, esp_event_base_t event_base,
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data;
ESP_LOGW(TAG, "WiFi disconnected, reason=%d rssi=%d", disc->reason, disc->rssi);
if (s_retry_num < MAX_RETRY) {
esp_wifi_connect();
s_retry_num++;
@@ -102,7 +104,10 @@ static void wifi_init_sta(void)
wifi_config_t wifi_config = {
.sta = {
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
/* WPA_PSK (not WPA2_PSK) so routers running WPA/WPA2-mixed
* compatibility mode aren't rejected with
* WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD (#1050). */
.threshold.authmode = WIFI_AUTH_WPA_PSK,
},
};
+3 -1
View File
@@ -188,7 +188,9 @@ size_t rv_mesh_encode_calibration_start(uint8_t sender_role,
esp_err_t rv_mesh_send(const uint8_t *frame, size_t len)
{
if (frame == NULL || len == 0) return ESP_ERR_INVALID_ARG;
int sent = stream_sender_send(frame, len);
/* Mesh control packets (HEALTH, anomaly) are low-rate and tiny — send them
* on the priority path so the CSI ENOMEM backoff can't starve them (#1183). */
int sent = stream_sender_send_priority(frame, len);
if (sent < 0) {
ESP_LOGW(TAG, "rv_mesh_send: stream_sender failed (len=%u)",
(unsigned)len);
@@ -121,6 +121,34 @@ int stream_sender_send(const uint8_t *data, size_t len)
return sent;
}
int stream_sender_send_priority(const uint8_t *data, size_t len)
{
if (s_sock < 0) {
return -1;
}
/* Priority path (#1183): low-rate control packets (feature_state, HEALTH,
* mesh sync) bypass the global ENOMEM backoff gate so the high-rate CSI
* stream cannot starve them. These are 48 B at 1 Hz negligible pbuf
* pressure, so they won't re-trigger the crash cascade that the backoff
* (driven by the 50 Hz CSI flood) exists to prevent.
*
* Crucially, an ENOMEM here is reported quietly and does NOT extend the
* global streak/backoff: a tiny control packet failing is a symptom of
* the bulk-stream pressure, not a cause, so it must not feed the cooldown
* that suppresses the next CSI frame. Likewise a success does not reset
* the streak the bulk path owns that signal. */
int sent = sendto(s_sock, data, len, 0,
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
if (sent < 0) {
if (errno != ENOMEM) {
ESP_LOGW(TAG, "priority sendto failed: errno %d", errno);
}
return -1;
}
return sent;
}
void stream_sender_deinit(void)
{
if (s_sock >= 0) {
@@ -36,6 +36,20 @@ int stream_sender_init_with(const char *ip, uint16_t port);
*/
int stream_sender_send(const uint8_t *data, size_t len);
/**
* Send a low-rate control packet, bypassing the ENOMEM backoff gate (#1183).
*
* Intended for 48 B, 1 Hz control traffic (feature_state, HEALTH, mesh
* sync) that must not be starved by the global backoff the high-rate CSI
* stream triggers. An ENOMEM on this path is reported quietly and does NOT
* extend or reset the global backoff streak.
*
* @param data Frame data buffer.
* @param len Length of data to send.
* @return Number of bytes sent, or -1 on error.
*/
int stream_sender_send_priority(const uint8_t *data, size_t len);
/**
* Close the UDP sender socket.
*/
@@ -0,0 +1,16 @@
# DevKitC-1 (display-less) production overlay.
#
# The stock ESP32-S3-DevKitC-1 has no AMOLED panel, but the ADR-045 runtime
# probe false-positives on it: with no TCA9554 and floating QSPI pins, the
# SH8601 init sequence reports success, display_is_active() returns true, and
# main.c skips the RuView#893 MGMT+DATA promiscuous upgrade — CSI yield
# collapses to 0 pps (the exact symptom #893 fixed). Compiling display support
# out makes has_display constant-false so the upgrade always applies.
#
# Build (from repo root, per README "Docker — the only reliable method"):
# MSYS_NO_PATHCONV=1 docker run --rm \
# -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
# espressif/idf:v5.4 bash -c \
# "rm -rf build sdkconfig && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' set-target esp32s3 && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' build"
# CONFIG_DISPLAY_ENABLE is not set
+1 -1
View File
@@ -1 +1 @@
0.7.0
0.8.4
@@ -6,24 +6,24 @@ description: Run the ADR-151 per-room calibration pipeline — baseline → enro
# calibrate-room
Turn a provisioned node + sensing-server into a working room model. Pure-Rust,
edge-deployable (ADR-151). Use the `ruview.calibrate` tool (installed
edge-deployable (ADR-151). Use the `ruview_calibrate` tool (installed
`wifi-densepose` binary, else `cargo run -p wifi-densepose-cli`).
## Sequence
1. **baseline** — capture the empty room (Welford amplitude + von Mises phase). Leave
the room empty.
`ruview.calibrate {step: "baseline"}`
`ruview_calibrate {step: "baseline"}`
2. **enroll** — record the occupant(s) doing the target activities.
`ruview.calibrate {step: "enroll"}`
`ruview_calibrate {step: "enroll"}`
3. **train-room** — train the bank of small specialists from baseline + enrollment.
`ruview.calibrate {step: "train-room"}`
`ruview_calibrate {step: "train-room"}`
4. **room-watch** — live presence/posture/breathing from the trained room.
`ruview.calibrate {step: "room-watch"}` (or the `room-watch` skill)
`ruview_calibrate {step: "room-watch"}` (or the `room-watch` skill)
## Honesty
The specialists are calibrated to *this* room; cross-room transfer is a separate
problem (LoRA recalibration, ADR-079 P9). Report which room a number came from, and
tag presence/vitals accuracy MEASURED only with a held-out check — run
`ruview.claim_check` on the writeup.
`ruview_claim_check` on the writeup.
@@ -8,12 +8,12 @@ description: Zero-to-sensing path picker for RuView (WiFi-DensePose) — pick do
Get a newcomer from nothing to a working RuView setup. **First fact to set:** WiFi
sensing infers *coarse* pose/presence/breathing from Channel State Information — it
is **not a camera**, and any accuracy number must be MEASURED against a baseline
(use the `verify` skill / `ruview.claim_check` tool). Never present WiFi output as
(use the `verify` skill / `ruview_claim_check` tool). Never present WiFi output as
camera-grade.
## Pick a path
Run `ruview.onboard {path}` or decide from:
Run `ruview_onboard {path}` or decide from:
1. **docker-demo** — fastest, no hardware. Replays sample CSI into the dashboard.
`docker run -p 8000:8000 ruvnet/wifi-densepose` → open `http://localhost:8000`.
@@ -26,5 +26,5 @@ Run `ruview.onboard {path}` or decide from:
## Then
- Live sensing → go to **provision-node**, then **calibrate-room**.
- Evaluating a model/claim → go to **verify** and run `ruview.claim_check` on any
- Evaluating a model/claim → go to **verify** and run `ruview_claim_check` on any
report before you quote a number.
@@ -28,7 +28,7 @@ esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
```
(`ruview.node_flash` returns the exact pinned command rather than running an
(`ruview_node_flash` returns the exact pinned command rather than running an
unattended flash.)
## 3. Provision
@@ -44,6 +44,6 @@ Never echo or commit the WiFi password.
## 4. Confirm CSI is flowing
`ruview.node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
`ruview_node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
(on a bare board) `CSI filter upgraded to MGMT+DATA`. No callbacks → the node isn't
capturing; do not proceed to calibration.
@@ -29,5 +29,5 @@ or temporal leakage. Example honest result (ADR-181):
1. Run the mean-pose baseline on the same split.
2. Report `(model baseline)` in pp, with the split definition (chronological /
blocked-gap / grouped-bucket; no leakage).
3. `ruview.claim_check` the writeup — it flags any untagged or 100%/perfect claim.
3. `ruview_claim_check` the writeup — it flags any untagged or 100%/perfect claim.
4. If it's a benchmark vs SOTA, tag MEASURED-EQUIVALENT only with the reproducer.
@@ -9,7 +9,7 @@ The "prove everything" skill. Nothing ships as validated without this.
## Deterministic proof (Trust Kill Switch)
`ruview.verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
`ruview_verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
through the production pipeline and hashes the output against
`expected_features.sha256`. Must print **VERDICT: PASS**. If numpy/scipy changed the
hash, regenerate with `verify.py --generate-hash` then re-verify.
@@ -28,7 +28,7 @@ crate versions — a recipient can re-verify with one command.
## Claim honesty
Run `ruview.claim_check {text}` on any report, README section, PR body, or model card
Run `ruview_claim_check {text}` on any report, README section, PR body, or model card
before quoting accuracy. It flags:
- untagged accuracy numbers (must be MEASURED / CLAIMED / SYNTHETIC),
- MEASURED claims with no reproducer cited,
+18 -18
View File
@@ -13,27 +13,27 @@
],
"files": {
".claude/settings.json": "b0ea971383716f18b89db73010b8f0ea0f1b16bdec4cd1068245772ba1c27bdd",
".claude/skills/calibrate-room/SKILL.md": "6a6c8211a7109feb76620c618963c10ad9a9f633ffce7676e631a80a1181986d",
".claude/skills/onboard/SKILL.md": "22323732fe746b38b77a7c8c052e952dff2fe87ae939ba125379125827385f21",
".claude/skills/provision-node/SKILL.md": "5ffe5a75873e873b80758d9c81005774d4191317227f2e9aa4345cbce3f29751",
".claude/skills/train-pose/SKILL.md": "b3ee95bfb0b678eb3d101138b9ea0e7cab3db3a9906d19c4059f9cca0598e87b",
".claude/skills/verify/SKILL.md": "c0314d5ead465d9089b6a4917fd125051a5be20dc07ba92d5b601fcaada32e19",
"CLAUDE.md": "7ecdb2b9d9abcf4aa22dd3ce553b60216a135e147893a59fa944fc1a8c81f5ef",
".claude/skills/calibrate-room/SKILL.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
".claude/skills/onboard/SKILL.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
".claude/skills/provision-node/SKILL.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
".claude/skills/train-pose/SKILL.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
".claude/skills/verify/SKILL.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
"CLAUDE.md": "1d7af0c310dd8093b4ae6c9c94a1c0cc9ff02ac9c8d5b45caba5363c3af99475",
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
"README.md": "b77d30428de8efb6758f2ca3eb22e84849013b2c0e6c601d488d2ea5a6f0da44",
"bin/cli.js": "b0d74690cff4329dfe342271fc475eaa140b767bdb66b37cf4992ad209012fe8",
"package.json": "2af49561ef0d59cafc4b99885816e580635b2d2ad329dfe17c69b9df6f8afceb",
"skills/calibrate-room.md": "6a6c8211a7109feb76620c618963c10ad9a9f633ffce7676e631a80a1181986d",
"skills/onboard.md": "22323732fe746b38b77a7c8c052e952dff2fe87ae939ba125379125827385f21",
"skills/provision-node.md": "5ffe5a75873e873b80758d9c81005774d4191317227f2e9aa4345cbce3f29751",
"skills/train-pose.md": "b3ee95bfb0b678eb3d101138b9ea0e7cab3db3a9906d19c4059f9cca0598e87b",
"skills/verify.md": "c0314d5ead465d9089b6a4917fd125051a5be20dc07ba92d5b601fcaada32e19",
"src/guardrails.js": "1631cea02c4354fe6126c576300faf5f8b68ae2f5e2e3a658c99eb25a7403e55",
"src/mcp-server.js": "e51379f5ebb0b7b4670c7412714e559931ef1be8df20551f8f7309b53f0fb7af",
"src/tools.js": "b558f61bb202abf5a967ce3a6ccaea351f2d186238cf49c7fc151d1de028eee8"
"README.md": "ac35157d66243a5f9eba262bdf2d593e978d935b3dde6e455b7acf650768eac6",
"bin/cli.js": "85d8394375edb1e967418451452e68bdbe26e69fc6877ed4936894f6101e1a12",
"package.json": "4509b68bb4211217f1e9f3f95f3134b326ee23a2322aef8d19b99a4b1d415b08",
"skills/calibrate-room.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
"skills/onboard.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
"skills/provision-node.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
"skills/train-pose.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
"src/guardrails.js": "66407b00d31c4f7939b75ee3e29598855c36a4154ccf1436655a4e52b0d7c034",
"src/mcp-server.js": "ad0f21be65a37237b9c2aad69e6e75166e5f101d902cb986377043545a7a80fb",
"src/tools.js": "1d72377ae53ad2b0c6dc03eb66f584422d8a60e442cb0d4f08355590f3edf031"
},
"meta": {
"surface": "cli+mcp",
"adr": "ADR-182"
}
}
}
+1 -1
View File
@@ -1 +1 @@
6c6c1431c37472494c9b309c8b5d761dd4fc41e30313baead6320831fb982e57 manifest.json
380d4bf928fd7c5fa753d11a30c1e24e2ea471caca57b439f765a9d864cef472 manifest.json
+3 -3
View File
@@ -10,15 +10,15 @@ accuracy number:
1. It must be tagged **MEASURED** (with a reproducer named), **CLAIMED**, or **SYNTHETIC**.
2. Pose PCK is quoted only as a **delta over the mean-pose baseline** on a leakage-free
held-out split. (A mean-pose predictor already scores ~50% PCK.)
3. Run `ruview.claim_check` on any report/PR/model-card. It flags untagged numbers and
3. Run `ruview_claim_check` on any report/PR/model-card. It flags untagged numbers and
the retracted "100%/perfect accuracy" framing.
4. Firmware is "hardware-validated" only with a captured **boot log on real silicon**
never on a build-passes signal.
## Tools
`ruview.onboard`, `ruview.claim_check`, `ruview.verify`, `ruview.node_monitor`,
`ruview.calibrate`, `ruview.node_flash`. All fail-closed. Mutating/hardware tools
`ruview_onboard`, `ruview_claim_check`, `ruview_verify`, `ruview_node_monitor`,
`ruview_calibrate`, `ruview_node_flash`. All fail-closed. Mutating/hardware tools
(`node_flash`) require explicit confirmation and are Windows/ESP-IDF gated.
## Skills
+14 -12
View File
@@ -7,34 +7,36 @@ crucially — **refuse to overstate accuracy**. Minted from the RuView monorepo
WiFi sensing infers *coarse* pose/presence/breathing from Channel State Information.
It is **not a camera**. Every accuracy number this harness emits must be MEASURED
against a baseline — that rule is enforced in code (`ruview.claim_check`).
against a baseline — that rule is enforced in code (`ruview_claim_check`).
## Quick start
```bash
npx @ruvnet/ruview # onboard — pick a setup path
npx @ruvnet/ruview claim-check --text "we hit 100% accuracy" # the honesty guardrail
npx @ruvnet/ruview claim-check --file REPORT.md # the honesty guardrail (non-zero exit on untagged claims)
npx @ruvnet/ruview verify # run the deterministic proof (VERDICT: PASS)
npx @ruvnet/ruview doctor # self-check (tools + optional kernel/host)
npx @ruvnet/ruview --help
```
The operator tools are pure Node and run with **zero install weight**. The
`@metaharness/kernel` + host adapter are `optionalDependencies` — only `doctor` /
`install` use them, only if present.
The operator tools are pure Node and run with **zero install weight** — the
package has no dependencies at all (ADR-263 O3). `doctor` / `install` can
additionally use `@metaharness/kernel` + a host adapter if you install them
(`npm i @metaharness/kernel @metaharness/host-claude-code`); everything else
runs without them.
## Tools (`ruview.*`)
## Tools (`ruview_*`)
Exposed both as CLI verbs and as an MCP server (`npx @ruvnet/ruview mcp start`):
| Tool | What it does |
|------|--------------|
| `ruview.onboard` | Pick docker-demo / repo-build / live-esp32; print the next command |
| `ruview.claim_check` | Lint text for untagged / overstated accuracy claims (guardrail) |
| `ruview.verify` | Run `verify.py` deterministic proof → VERDICT |
| `ruview.node_monitor` | Assert CSI is flowing on an ESP32 (read-only) |
| `ruview.calibrate` | ADR-151 room pipeline (baseline→enroll→train-room→room-watch) |
| `ruview.node_flash` | Build+flash firmware (Windows/ESP-IDF; mutating, guarded) |
| `ruview_onboard` | Pick docker-demo / repo-build / live-esp32; print the next command |
| `ruview_claim_check` | Lint text for untagged / overstated accuracy claims (guardrail) |
| `ruview_verify` | Run `verify.py` deterministic proof → VERDICT |
| `ruview_node_monitor` | Assert CSI is flowing on an ESP32 (read-only) |
| `ruview_calibrate` | ADR-151 room pipeline (baseline→enroll→train-room→room-watch) |
| `ruview_node_flash` | Build+flash firmware (Windows/ESP-IDF; mutating, guarded) |
Every tool is **fail-closed**: missing repo / python / binary / port → an honest
negative, never a fabricated success.
+14 -9
View File
@@ -18,14 +18,14 @@ const NAME = 'ruview';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const SKILLS_DIR = join(ROOT, 'skills');
// Map friendly CLI verbs → registry tool names.
// Map friendly CLI verbs → registry tool names (underscore-canonical, ADR-263).
const VERB_TO_TOOL = {
onboard: 'ruview.onboard',
verify: 'ruview.verify',
'claim-check': 'ruview.claim_check',
calibrate: 'ruview.calibrate',
monitor: 'ruview.node_monitor',
flash: 'ruview.node_flash',
onboard: 'ruview_onboard',
verify: 'ruview_verify',
'claim-check': 'ruview_claim_check',
calibrate: 'ruview_calibrate',
monitor: 'ruview_node_monitor',
flash: 'ruview_node_flash',
};
function pjson(o) { console.log(JSON.stringify(o, null, 2)); }
@@ -112,13 +112,18 @@ export async function run(args) {
const toolArgs = { ...flags };
if (cmd === 'claim-check') {
if (flags.file) toolArgs.text = readFileSync(flags.file, 'utf8');
const res = runTool('ruview.claim_check', toolArgs);
// Fail closed (ADR-263 O1): an honesty gate must never PASS on no input.
if (typeof toolArgs.text !== 'string' || toolArgs.text.trim().length === 0) {
console.error('claim-check: no input — pass --text "..." or --file <path> (empty input is an error, not a PASS).');
return 2;
}
const res = await runTool('ruview_claim_check', toolArgs);
pjson(res);
return res.ok ? 0 : 1;
}
if (cmd === 'monitor' && flags.seconds) toolArgs.seconds = Number(flags.seconds);
if (cmd === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
const res = runTool(VERB_TO_TOOL[cmd], toolArgs);
const res = await runTool(VERB_TO_TOOL[cmd], toolArgs);
pjson(res);
return res.ok ? 0 : 1;
}
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@ruvnet/ruview",
"version": "0.1.0",
"version": "0.2.0",
"description": "RuView WiFi-sensing operator agent harness — onboard, calibrate, train, and verify camera-free WiFi-CSI sensing, with the project's MEASURED-vs-CLAIMED honesty guardrail enforced. Minted via metaharness (ADR-182).",
"type": "module",
"bin": {
@@ -23,11 +23,10 @@
"scripts": {
"test": "node --test test/*.test.mjs",
"doctor": "node ./bin/cli.js doctor",
"mcp": "node ./bin/cli.js mcp start"
},
"optionalDependencies": {
"@metaharness/kernel": "^0.1.0",
"@metaharness/host-claude-code": "^0.1.0"
"mcp": "node ./bin/cli.js mcp start",
"sync-skills": "node ./scripts/sync-skills.mjs",
"prepack": "node ./scripts/sync-skills.mjs",
"prepublishOnly": "npm test"
},
"keywords": [
"wifi-sensing",
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// ADR-263 O7: skills/*.md is the single source of truth; the host-projected
// copies (.claude/skills/<name>/SKILL.md) are GENERATED here at pack time.
// Run with --check to verify without writing (used by tests/CI).
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const SRC = join(ROOT, 'skills');
const DST = join(ROOT, '.claude', 'skills');
const checkOnly = process.argv.includes('--check');
let drift = 0;
for (const f of readdirSync(SRC).filter((f) => f.endsWith('.md'))) {
const name = f.replace(/\.md$/, '');
const src = readFileSync(join(SRC, f), 'utf8');
const dstDir = join(DST, name);
const dstFile = join(dstDir, 'SKILL.md');
const current = existsSync(dstFile) ? readFileSync(dstFile, 'utf8') : null;
if (current === src) continue;
drift++;
if (checkOnly) {
console.error(`DRIFT: .claude/skills/${name}/SKILL.md != skills/${f}`);
} else {
mkdirSync(dstDir, { recursive: true });
writeFileSync(dstFile, src);
console.error(`synced .claude/skills/${name}/SKILL.md`);
}
}
if (checkOnly && drift > 0) {
console.error(`sync-skills --check: ${drift} file(s) out of sync — run \`npm run sync-skills\`.`);
process.exit(1);
}
console.error(`sync-skills: ${drift === 0 ? 'all in sync' : `${drift} file(s) ${checkOnly ? 'OUT OF SYNC' : 'synced'}`}`);
+6 -6
View File
@@ -6,24 +6,24 @@ description: Run the ADR-151 per-room calibration pipeline — baseline → enro
# calibrate-room
Turn a provisioned node + sensing-server into a working room model. Pure-Rust,
edge-deployable (ADR-151). Use the `ruview.calibrate` tool (installed
edge-deployable (ADR-151). Use the `ruview_calibrate` tool (installed
`wifi-densepose` binary, else `cargo run -p wifi-densepose-cli`).
## Sequence
1. **baseline** — capture the empty room (Welford amplitude + von Mises phase). Leave
the room empty.
`ruview.calibrate {step: "baseline"}`
`ruview_calibrate {step: "baseline"}`
2. **enroll** — record the occupant(s) doing the target activities.
`ruview.calibrate {step: "enroll"}`
`ruview_calibrate {step: "enroll"}`
3. **train-room** — train the bank of small specialists from baseline + enrollment.
`ruview.calibrate {step: "train-room"}`
`ruview_calibrate {step: "train-room"}`
4. **room-watch** — live presence/posture/breathing from the trained room.
`ruview.calibrate {step: "room-watch"}` (or the `room-watch` skill)
`ruview_calibrate {step: "room-watch"}` (or the `room-watch` skill)
## Honesty
The specialists are calibrated to *this* room; cross-room transfer is a separate
problem (LoRA recalibration, ADR-079 P9). Report which room a number came from, and
tag presence/vitals accuracy MEASURED only with a held-out check — run
`ruview.claim_check` on the writeup.
`ruview_claim_check` on the writeup.
+3 -3
View File
@@ -8,12 +8,12 @@ description: Zero-to-sensing path picker for RuView (WiFi-DensePose) — pick do
Get a newcomer from nothing to a working RuView setup. **First fact to set:** WiFi
sensing infers *coarse* pose/presence/breathing from Channel State Information — it
is **not a camera**, and any accuracy number must be MEASURED against a baseline
(use the `verify` skill / `ruview.claim_check` tool). Never present WiFi output as
(use the `verify` skill / `ruview_claim_check` tool). Never present WiFi output as
camera-grade.
## Pick a path
Run `ruview.onboard {path}` or decide from:
Run `ruview_onboard {path}` or decide from:
1. **docker-demo** — fastest, no hardware. Replays sample CSI into the dashboard.
`docker run -p 8000:8000 ruvnet/wifi-densepose` → open `http://localhost:8000`.
@@ -26,5 +26,5 @@ Run `ruview.onboard {path}` or decide from:
## Then
- Live sensing → go to **provision-node**, then **calibrate-room**.
- Evaluating a model/claim → go to **verify** and run `ruview.claim_check` on any
- Evaluating a model/claim → go to **verify** and run `ruview_claim_check` on any
report before you quote a number.
+2 -2
View File
@@ -28,7 +28,7 @@ esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
```
(`ruview.node_flash` returns the exact pinned command rather than running an
(`ruview_node_flash` returns the exact pinned command rather than running an
unattended flash.)
## 3. Provision
@@ -44,6 +44,6 @@ Never echo or commit the WiFi password.
## 4. Confirm CSI is flowing
`ruview.node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
`ruview_node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
(on a bare board) `CSI filter upgraded to MGMT+DATA`. No callbacks → the node isn't
capturing; do not proceed to calibration.
+1 -1
View File
@@ -29,5 +29,5 @@ or temporal leakage. Example honest result (ADR-181):
1. Run the mean-pose baseline on the same split.
2. Report `(model baseline)` in pp, with the split definition (chronological /
blocked-gap / grouped-bucket; no leakage).
3. `ruview.claim_check` the writeup — it flags any untagged or 100%/perfect claim.
3. `ruview_claim_check` the writeup — it flags any untagged or 100%/perfect claim.
4. If it's a benchmark vs SOTA, tag MEASURED-EQUIVALENT only with the reproducer.
+2 -2
View File
@@ -9,7 +9,7 @@ The "prove everything" skill. Nothing ships as validated without this.
## Deterministic proof (Trust Kill Switch)
`ruview.verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
`ruview_verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
through the production pipeline and hashes the output against
`expected_features.sha256`. Must print **VERDICT: PASS**. If numpy/scipy changed the
hash, regenerate with `verify.py --generate-hash` then re-verify.
@@ -28,7 +28,7 @@ crate versions — a recipient can re-verify with one command.
## Claim honesty
Run `ruview.claim_check {text}` on any report, README section, PR body, or model card
Run `ruview_claim_check {text}` on any report, README section, PR body, or model card
before quoting accuracy. It flags:
- untagged accuracy numbers (must be MEASURED / CLAIMED / SYNTHETIC),
- MEASURED claims with no reproducer cited,
+48 -6
View File
@@ -4,15 +4,44 @@
// The project was accused of AI-slop; the cultural fix is that every accuracy
// number must be tagged MEASURED (with a reproducer) or CLAIMED/SYNTHETIC, and
// the retracted "100% accuracy" framing must never reappear untagged. This module
// is the static enforcement of that, shared by the `ruview.claim_check` MCP tool,
// is the static enforcement of that, shared by the `ruview_claim_check` MCP tool,
// the `npx ruview claim-check` CLI, and the claude-code pre-output hook.
/** Phrases that signal a quantitative accuracy claim. */
/** Phrases that signal a quantitative accuracy claim (safe as substrings). */
const METRIC_TERMS = [
'accuracy', 'pck', 'pck@', 'f1', 'precision', 'recall', 'map', 'auc',
'iou', 'mpjpe', 'error rate', 'detection rate', 'true positive',
'accuracy', 'pck', 'precision', 'recall',
'mpjpe', 'error rate', 'detection rate', 'true positive',
];
// Short/ambiguous metric tokens (ADR-263 F11): 'map' is usually the English
// word or a file extension, 'f1'/'o1' collide with finding/option labels.
// They only count as metric mentions when word-bounded, not a `.map` file
// reference, and the line (after scrubbing) carries a number — "mAP 62.3" is
// a claim, "F-numbers map to findings" is not.
// 'map' additionally must not be a `.map` file suffix or a hyphenated
// compound ("map-free", "map-reduce") — mAP the metric never appears as either.
const METRIC_TERMS_SHORT = [/(?<![.\w])map\b(?!-)/, /\bf1\b/, /\bauc\b/, /\biou\b/];
// Finding/option labels (F1, O2, …) count as labels unless the token sits in a
// metric context: an immediately following score/=/%/digit or colon ("F1: 0.91"),
// or a number later in the same clause ("F1 reaches 0.91" — an F1-score claim).
// Bare option refs ("F7 fixes", "O1O9", "ADR-263 O2") carry no clause number of
// their own and stay labels. (A surviving 'f1' still only fires as a metric when
// its scrubbed line actually carries a number — see mentionsMetricTerm.)
const LABEL_TOKEN_RE = /\b[fo]\d+\b(?!\s*(?:score|=|\d|%|:))(?![^\n.;]*\d)/g;
const CODE_SPAN_RE = /`[^`]*`/g; // backticked identifiers are code, not claims
const HAS_NUMBER_RE = /\d/;
/** Line with code spans and finding/option labels removed. */
function scrubLine(lower) {
return lower.replace(CODE_SPAN_RE, ' ').replace(LABEL_TOKEN_RE, ' ');
}
function mentionsMetricTerm(lower, scrubbed) {
if (METRIC_TERMS.some((t) => lower.includes(t))) return true;
if (!HAS_NUMBER_RE.test(scrubbed)) return false;
return METRIC_TERMS_SHORT.some((re) => re.test(scrubbed));
}
/** Tags that make a claim honest (case-insensitive). */
const HONEST_TAGS = ['measured', 'claimed', 'synthetic', 'unvalidated', 'baseline'];
@@ -20,6 +49,8 @@ const HONEST_TAGS = ['measured', 'claimed', 'synthetic', 'unvalidated', 'baselin
const REPRODUCER_HINTS = [
'verify.py', 'witness', 'mean-pose', 'mean pose', 'held-out', 'held out',
'baseline', 'reproduce', 'sha256', 'boot log', 'pck@20 vs', 'expected_features',
// Packaging-claim reproducers (ADR-263/264 npm reviews): the tarball itself.
'npm pack', 'npm view', 'npm i ', 'npm install', 'tarball', 'cargo test',
];
const PERCENT_RE = /\b(\d{1,3}(?:\.\d+)?)\s?%/g;
@@ -49,7 +80,8 @@ export function claimCheck(text) {
const hasPercent = PERCENT_RE.test(line);
PERCENT_RE.lastIndex = 0; // reset stateful global regex
const mentionsMetric = METRIC_TERMS.some((t) => lower.includes(t));
const scrubbed = scrubLine(lower);
const mentionsMetric = mentionsMetricTerm(lower, scrubbed);
if (!hasPercent && !mentionsMetric) return;
const tagged = HONEST_TAGS.some((t) => lower.includes(t));
@@ -67,6 +99,15 @@ export function claimCheck(text) {
return;
}
// A quantitative claim needs a number. Digits hidden in a code span still
// count — "accuracy reached `0.95`" is a claim — so test the line with only
// finding/option labels stripped, NOT the code-span-scrubbed copy: scrubbing
// dropped `0.95` and wrongly short-circuited both the untagged and the
// MEASURED-without-reproducer checks below. A bare metric word in prose
// ("precision matters here", "every accuracy number must be MEASURED") has no
// number and is not a taggable claim (ADR-263 F11).
if (!hasPercent && !HAS_NUMBER_RE.test(lower.replace(LABEL_TOKEN_RE, ' '))) return;
// A metric/percent with no honesty tag at all.
if (!tagged) {
findings.push({
@@ -79,7 +120,8 @@ export function claimCheck(text) {
return;
}
// Tagged MEASURED but cites no reproducer — still a gap.
// Tagged MEASURED but cites no reproducer — still a gap (reached now even
// when the only number is inside a code span, e.g. "accuracy `0.97` (MEASURED)").
if (lower.includes('measured') && !hasReproducer) {
findings.push({
severity: 'medium',
+51 -12
View File
@@ -3,14 +3,23 @@
//
// Dependency-free on purpose: a published `npx ruview` must `mcp start` without
// pulling the full MCP SDK. Implements the subset hosts use: `initialize`,
// `tools/list`, `tools/call`, and the `notifications/initialized` ack. Logs go to
// stderr ONLY — stdout is the JSON-RPC channel and must stay clean.
// `tools/list`, `tools/call`, `ping`, empty `resources/list`/`prompts/list`
// stubs, and the `notifications/initialized` ack. Logs go to stderr ONLY —
// stdout is the JSON-RPC channel and must stay clean.
//
// ADR-263 O2: `tools/call` is dispatched asynchronously — a long-running
// verify/calibrate no longer blocks ping/tools/list, so hosts that health-check
// mid-run see a live server. Responses may therefore arrive out of request
// order, which JSON-RPC permits (ids correlate them).
import { createInterface } from 'node:readline';
import { readFileSync } from 'node:fs';
import { listTools, runTool } from './tools.js';
const PROTOCOL_VERSION = '2024-11-05';
const SERVER_INFO = { name: 'ruview', version: '0.1.0' };
// Single-source the version from package.json (ADR-263 O6).
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
const SERVER_INFO = { name: 'ruview', version: PKG.version };
function send(msg) {
process.stdout.write(JSON.stringify(msg) + '\n');
@@ -19,7 +28,7 @@ function result(id, res) { send({ jsonrpc: '2.0', id, result: res }); }
function error(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
function log(...a) { process.stderr.write('[ruview-mcp] ' + a.join(' ') + '\n'); }
function handle(msg) {
async function handle(msg) {
const { id, method, params } = msg;
switch (method) {
case 'initialize':
@@ -27,19 +36,24 @@ function handle(msg) {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: SERVER_INFO,
instructions: 'RuView WiFi-sensing operator tools. All results are fail-closed; accuracy claims must pass ruview.claim_check.',
instructions: 'RuView WiFi-sensing operator tools. All results are fail-closed; accuracy claims must pass ruview_claim_check.',
});
case 'notifications/initialized':
case 'initialized':
return; // notification — no response
case 'notifications/cancelled':
return; // notifications — no response
case 'ping':
return result(id, {});
case 'tools/list':
return result(id, { tools: listTools() });
case 'resources/list':
return result(id, { resources: [] });
case 'prompts/list':
return result(id, { prompts: [] });
case 'tools/call': {
const name = params?.name;
const args = params?.arguments || {};
const out = runTool(name, args);
const out = await runTool(name, args);
// MCP content envelope: text block with the JSON, isError reflects ok=false.
return result(id, {
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
@@ -52,17 +66,42 @@ function handle(msg) {
}
export function startMcpServer() {
log(`starting (protocol ${PROTOCOL_VERSION}, ${listTools().length} tools)`);
log(`starting v${SERVER_INFO.version} (protocol ${PROTOCOL_VERSION}, ${listTools().length} tools)`);
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
// tools/call runs are serialized through a FIFO promise chain: hardware/mutating
// tools (calibrate, serial monitor, flash) must never overlap. ping/tools/list/
// initialize/resources/prompts stay immediate (ADR-263 O2 — a health check must
// answer during a long tool run). `toolChain` also lets stdin-close drain the
// in-flight call so its response is flushed instead of dropped by process.exit.
let toolChain = Promise.resolve();
const dispatch = (msg) => handle(msg).catch((err) => {
if (msg && msg.id !== undefined) error(msg.id, -32603, String(err && err.message || err));
log('handler error:', String(err));
});
rl.on('line', (line) => {
const s = line.trim();
if (!s) return;
let msg;
try { msg = JSON.parse(s); } catch { return log('bad JSON line dropped'); }
try { handle(msg); } catch (err) {
if (msg && msg.id !== undefined) error(msg.id, -32603, String(err && err.message || err));
log('handler error:', String(err));
if (msg && msg.method === 'tools/call') {
toolChain = toolChain.then(() => dispatch(msg)); // one tool at a time
} else {
dispatch(msg); // health/list/handshake answer immediately, even mid tool run
}
});
rl.on('close', () => { log('stdin closed — exiting'); process.exit(0); });
rl.on('close', () => {
// Wait for any queued/in-flight tool call to settle (its response written)
// before exiting — fire-and-forget used to race this and drop the response.
toolChain.then(() => {
log('stdin closed — exiting');
const done = () => process.exit(0);
// Pipe writes are async; flush buffered stdout before exit.
if (process.stdout.writableLength) process.stdout.once('drain', done);
else done();
});
});
}
+140 -59
View File
@@ -7,10 +7,15 @@
// `wifi-densepose` binary, an ESP32 on a port) is absent, it returns an honest
// negative — never a fabricated success. This mirrors the project's "prove
// everything" rule and the RuField fail-closed posture (ADR-262 §3.3).
//
// ADR-263: handlers are async (promise-based spawn, never spawnSync) so the MCP
// server keeps answering ping/tools/list while a long verify/calibrate runs.
// Canonical tool names use underscores (host tool-name regexes commonly enforce
// ^[a-zA-Z0-9_-]{1,64}$); the historical dotted names are accepted as aliases.
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { spawn } from 'node:child_process';
import { existsSync, accessSync, constants } from 'node:fs';
import { join, dirname, resolve, delimiter } from 'node:path';
import { claimCheck, summarize } from './guardrails.js';
/** Walk up from `start` to find the RuView monorepo root (or null). */
@@ -27,22 +32,75 @@ export function findRepoRoot(start = process.cwd()) {
return null;
}
function which(cmd) {
const probe = process.platform === 'win32'
? spawnSync('where', [cmd], { encoding: 'utf8' })
: spawnSync('command', ['-v', cmd], { encoding: 'utf8', shell: true });
return probe.status === 0 ? (probe.stdout || '').trim().split(/\r?\n/)[0] : null;
// Dep-free PATH scan (ADR-263 O8) — no shell subprocess per lookup. Only hits
// are memoized: a miss can resolve later in a long-lived MCP session (the
// operator installs python/the CLI mid-run), so misses are re-probed each call.
const whichCache = new Map();
export function which(cmd) {
if (whichCache.has(cmd)) return whichCache.get(cmd);
const isWin = process.platform === 'win32';
const exts = isWin
? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
: [''];
let found = null;
outer:
for (const dir of (process.env.PATH || '').split(delimiter)) {
if (!dir) continue;
for (const ext of isWin ? ['', ...exts] : exts) {
const p = join(dir, cmd + ext);
try {
accessSync(p, isWin ? constants.F_OK : constants.X_OK);
found = p;
break outer;
} catch { /* keep scanning */ }
}
}
if (found !== null) whichCache.set(cmd, found);
return found;
}
function run(cmd, args, opts = {}) {
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: opts.timeout ?? 120000, ...opts });
return {
status: r.status,
ok: r.status === 0,
stdout: (r.stdout || '').slice(-8000),
stderr: (r.stderr || '').slice(-4000),
error: r.error ? r.error.message : null,
};
// Bounded output tails (ADR-263 O4): spawnSync's default 1 MiB maxBuffer killed
// chatty children with ENOBUFS; handlers only ever surface the last few kB, so
// keep rolling tails instead of the full stream.
const STDOUT_TAIL = 65536;
const STDERR_TAIL = 16384;
/** Promise-based spawn with timeout + rolling output tails. */
export function run(cmd, args, opts = {}) {
const timeout = opts.timeout ?? 120000;
return new Promise((resolvePromise) => {
let stdout = '';
let stderr = '';
let child;
try {
child = spawn(cmd, args, { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
} catch (e) {
resolvePromise({ status: null, ok: false, stdout: '', stderr: '', error: e.message });
return;
}
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeout);
child.stdout.on('data', (d) => {
stdout = (stdout + d).slice(-STDOUT_TAIL);
});
child.stderr.on('data', (d) => {
stderr = (stderr + d).slice(-STDERR_TAIL);
});
child.on('error', (e) => {
clearTimeout(timer);
resolvePromise({ status: null, ok: false, stdout, stderr, error: e.message });
});
child.on('close', (status) => {
clearTimeout(timer);
resolvePromise({
status,
ok: status === 0,
stdout,
stderr,
error: timedOut ? `timed out after ${timeout} ms` : null,
});
});
});
}
const ONBOARD_PATHS = {
@@ -51,12 +109,36 @@ const ONBOARD_PATHS = {
'live-esp32': 'Real sensing. Flash an ESP32-S3 (see `provision-node` skill), point it at the sensing-server, then `calibrate → enroll → train-room → room-watch` (see `calibrate-room`). Good for an actual install.',
};
// Read-only serial monitor script; the port arrives via sys.argv (ADR-263 O5 —
// never spliced into interpreter source).
const MONITOR_SCRIPT = [
'import sys,time',
'try:',
' import serial',
'except Exception as e:',
" print('NO_PYSERIAL'); sys.exit(3)",
'port=sys.argv[1]',
'dur=float(sys.argv[2])',
'ser=serial.Serial(port,115200,timeout=1)',
'csi=0; n=0; t=time.time()',
'while time.time()-t<dur:',
' ln=ser.readline()',
' if not ln: continue',
" s=ln.decode('utf-8','replace')",
' n+=1',
" if 'CSI cb' in s or 'csi_collector' in s: csi+=1",
" if 'MGMT+DATA' in s: print('UPGRADE_MGMT_DATA')",
'ser.close()',
"print(f'LINES={n} CSI={csi}')",
].join('\n');
/**
* The tool registry. Each entry: { title, description, inputSchema, handler }.
* inputSchema is JSON-Schema (object). handler(args) JSON-serializable result.
* inputSchema is JSON-Schema (object). handler(args) JSON-serializable result
* (sync or promise). Canonical names are underscore-form.
*/
export const TOOLS = {
'ruview.onboard': {
ruview_onboard: {
title: 'Onboard',
description: 'Pick a RuView setup path (docker-demo | repo-build | live-esp32) and print the next concrete command.',
inputSchema: {
@@ -74,46 +156,50 @@ export const TOOLS = {
repo_root: repo,
paths: ONBOARD_PATHS,
recommend: repo ? 'repo-build' : 'docker-demo',
note: 'WiFi sensing infers coarse pose/presence from CSI — it is not a camera. Accuracy claims must be MEASURED vs a baseline (run `ruview.claim_check`).',
note: 'WiFi sensing infers coarse pose/presence from CSI — it is not a camera. Accuracy claims must be MEASURED vs a baseline (run `ruview_claim_check`).',
};
},
},
'ruview.claim_check': {
ruview_claim_check: {
title: 'Claim check',
description: 'Static lint: scan text for untagged or overstated accuracy claims (the "prove everything" guardrail). Returns findings.',
description: 'Static lint: scan text for untagged or overstated accuracy claims (the "prove everything" guardrail). Returns findings. Fail-closed: empty input is an error, not a pass.',
inputSchema: {
type: 'object',
required: ['text'],
properties: { text: { type: 'string', description: 'The text to lint (a report, README section, PR body, model card).' } },
},
handler(args = {}) {
const result = claimCheck(String(args.text ?? ''));
const text = typeof args.text === 'string' ? args.text : '';
if (text.trim().length === 0) {
return { ok: false, reason: 'empty_text', hint: 'Pass the text to lint — an empty input must not pass an honesty gate.' };
}
const result = claimCheck(text);
return { ...result, summary: summarize(result) };
},
},
'ruview.verify': {
ruview_verify: {
title: 'Verify (witness)',
description: 'Run the deterministic proof (archive/v1/data/proof/verify.py) and report VERDICT. Fail-closed if not in a RuView repo or python is missing.',
inputSchema: {
type: 'object',
properties: { repo: { type: 'string', description: 'RuView repo root. Default: auto-detect from cwd.' } },
},
handler(args = {}) {
async handler(args = {}) {
const repo = args.repo ? resolve(args.repo) : findRepoRoot();
if (!repo) return { ok: false, reason: 'not_in_ruview_repo', hint: 'Run inside the RuView monorepo or pass {repo}.' };
const proof = join(repo, 'archive', 'v1', 'data', 'proof', 'verify.py');
if (!existsSync(proof)) return { ok: false, reason: 'proof_missing', path: proof };
const py = which('python') || which('python3');
if (!py) return { ok: false, reason: 'python_missing', hint: 'Install python to run the deterministic proof.' };
const r = run(py, [proof], { cwd: repo, timeout: 180000 });
const r = await run(py, [proof], { cwd: repo, timeout: 180000 });
const verdict = /VERDICT:\s*PASS/i.test(r.stdout) ? 'PASS' : (/VERDICT:\s*FAIL/i.test(r.stdout) ? 'FAIL' : 'UNKNOWN');
return { ok: r.ok && verdict === 'PASS', verdict, exit: r.status, tail: r.stdout.slice(-1200), stderr: r.stderr.slice(-400) };
},
},
'ruview.node_monitor': {
ruview_node_monitor: {
title: 'Node monitor',
description: 'Open an ESP32 serial port and assert CSI is flowing (MGMT+DATA). Fail-closed if python+pyserial or the port is absent. Read-only.',
inputSchema: {
@@ -123,31 +209,13 @@ export const TOOLS = {
seconds: { type: 'number', description: 'Capture window (default 12).' },
},
},
handler(args = {}) {
async handler(args = {}) {
const port = args.port;
if (!port) return { ok: false, reason: 'no_port', hint: 'Pass {port} (e.g. COM8).' };
if (!port || typeof port !== 'string') return { ok: false, reason: 'no_port', hint: 'Pass {port} (e.g. COM8).' };
const py = which('python') || which('python3');
if (!py) return { ok: false, reason: 'python_missing' };
const dur = Number(args.seconds) > 0 ? Number(args.seconds) : 12;
const script = [
'import sys,time',
'try:',
' import serial',
'except Exception as e:',
" print('NO_PYSERIAL'); sys.exit(3)",
`ser=serial.Serial(${JSON.stringify(port)},115200,timeout=1)`,
'csi=0; n=0; t=time.time()',
`while time.time()-t<${dur}:`,
' ln=ser.readline()',
' if not ln: continue',
" s=ln.decode('utf-8','replace')",
' n+=1',
" if 'CSI cb' in s or 'csi_collector' in s: csi+=1",
" if 'MGMT+DATA' in s: print('UPGRADE_MGMT_DATA')",
'ser.close()',
"print(f'LINES={n} CSI={csi}')",
].join('\n');
const r = run(py, ['-c', script], { timeout: (dur + 10) * 1000 });
const r = await run(py, ['-c', MONITOR_SCRIPT, port, String(dur)], { timeout: (dur + 10) * 1000 });
if (r.stdout.includes('NO_PYSERIAL')) return { ok: false, reason: 'pyserial_missing', hint: 'pip install pyserial' };
if (!r.ok) return { ok: false, reason: 'port_error', stderr: r.stderr, error: r.error };
const csi = Number((r.stdout.match(/CSI=(\d+)/) || [])[1] || 0);
@@ -156,7 +224,7 @@ export const TOOLS = {
},
},
'ruview.calibrate': {
ruview_calibrate: {
title: 'Calibrate room',
description: 'Run the ADR-151 room pipeline via the wifi-densepose CLI (baseline→enroll→train-room). Fail-closed if the binary is absent.',
inputSchema: {
@@ -166,7 +234,7 @@ export const TOOLS = {
args: { type: 'array', items: { type: 'string' }, description: 'Extra CLI args passed through.' },
},
},
handler(args = {}) {
async handler(args = {}) {
const step = args.step || 'baseline';
const bin = which('wifi-densepose');
const repo = findRepoRoot();
@@ -174,13 +242,13 @@ export const TOOLS = {
const passthru = Array.isArray(args.args) ? args.args.map(String) : [];
// Prefer the installed binary; otherwise cargo-run from the repo.
const r = bin
? run(bin, [step, ...passthru], { timeout: 300000 })
: run('cargo', ['run', '-q', '-p', 'wifi-densepose-cli', '--', step, ...passthru], { cwd: repo, timeout: 600000 });
? await run(bin, [step, ...passthru], { timeout: 300000 })
: await run('cargo', ['run', '-q', '-p', 'wifi-densepose-cli', '--', step, ...passthru], { cwd: repo, timeout: 600000 });
return { ok: r.ok, step, via: bin ? 'binary' : 'cargo', exit: r.status, tail: r.stdout.slice(-1500), stderr: r.stderr.slice(-500) };
},
},
'ruview.node_flash': {
ruview_node_flash: {
title: 'Node flash',
description: 'Build+flash an ESP32 firmware variant. MUTATING + hardware. Fail-closed off-Windows or without ESP-IDF. Never claims hardware validation without a boot log.',
inputSchema: {
@@ -203,14 +271,27 @@ export const TOOLS = {
},
};
/** Run one tool by name; returns the structured result (or an error envelope). */
export function runTool(name, args) {
const tool = TOOLS[name];
if (!tool) return { ok: false, reason: 'unknown_tool', name, available: Object.keys(TOOLS) };
// Historical dotted names (pre-ADR-263) accepted as call-time aliases; the
// underscore form is what tools/list advertises.
export const TOOL_ALIASES = Object.fromEntries(
Object.keys(TOOLS).map((name) => [name.replace(/_/, '.'), name])
);
/** Resolve a canonical or aliased tool name (or null). */
export function resolveToolName(name) {
if (TOOLS[name]) return name;
if (TOOL_ALIASES[name]) return TOOL_ALIASES[name];
return null;
}
/** Run one tool by name (canonical or dotted alias); always resolves to the structured result. */
export async function runTool(name, args) {
const canonical = resolveToolName(name);
if (!canonical) return { ok: false, reason: 'unknown_tool', name, available: Object.keys(TOOLS) };
try {
return tool.handler(args || {});
return await TOOLS[canonical].handler(args || {});
} catch (err) {
return { ok: false, reason: 'tool_threw', name, error: String(err && err.message || err) };
return { ok: false, reason: 'tool_threw', name: canonical, error: String(err && err.message || err) };
}
}
+148
View File
@@ -0,0 +1,148 @@
// SPDX-License-Identifier: MIT
// MCP stdio server e2e — spawns `bin/cli.js mcp start` and speaks JSON-RPC.
// Pins ADR-263 O2 (ping answered while a long tools/call runs), O6 (version
// from package.json), and O8 (underscore names advertised, dotted accepted,
// resources/prompts stubs).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { which } from '../src/tools.js';
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const CLI = join(PKG_ROOT, 'bin', 'cli.js');
/** Start the MCP server; returns {send, next, close} where next(id) resolves the response with that id. */
function startServer() {
const child = spawn(process.execPath, [CLI, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
const waiters = new Map();
let buf = '';
child.stdout.on('data', (d) => {
buf += d;
let nl;
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
const msg = JSON.parse(line);
const w = waiters.get(msg.id);
if (w) { waiters.delete(msg.id); w(msg); }
}
});
return {
send(msg) { child.stdin.write(JSON.stringify(msg) + '\n'); },
next(id) { return new Promise((res) => waiters.set(id, res)); },
close() { child.stdin.end(); child.kill(); },
};
}
test('MCP handshake: initialize reports the package.json version; list endpoints respond', async () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
const s = startServer();
try {
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
const init = await s.next(1);
assert.equal(init.result.serverInfo.version, pkg.version, 'ADR-263 O6: version must match package.json');
s.send({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
const tools = (await s.next(2)).result.tools;
assert.equal(tools.length, 6);
for (const t of tools) assert.match(t.name, /^[a-zA-Z0-9_-]{1,64}$/, `advertised name not host-safe: ${t.name}`);
s.send({ jsonrpc: '2.0', id: 3, method: 'resources/list' });
assert.deepEqual((await s.next(3)).result, { resources: [] });
s.send({ jsonrpc: '2.0', id: 4, method: 'prompts/list' });
assert.deepEqual((await s.next(4)).result, { prompts: [] });
// Dotted legacy name still callable (alias).
s.send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'ruview.onboard', arguments: {} } });
const call = await s.next(5);
assert.equal(call.result.isError, false);
} finally {
s.close();
}
});
test('MCP server answers ping while a long tools/call is in flight (ADR-263 O2)', { skip: !which('python') && !which('python3') ? 'python not on PATH' : false }, async () => {
// Fake RuView repo whose verify.py sleeps 3 s then passes.
const repo = mkdtempSync(join(tmpdir(), 'ruview-mcp-e2e-'));
const proofDir = join(repo, 'archive', 'v1', 'data', 'proof');
mkdirSync(proofDir, { recursive: true });
writeFileSync(join(proofDir, 'verify.py'), 'import time\ntime.sleep(3)\nprint("VERDICT: PASS")\n');
const s = startServer();
try {
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
await s.next(1);
const verifyDone = s.next(10);
s.send({ jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
// Give the server a beat to start the child, then ping.
await new Promise((r) => setTimeout(r, 300));
const t0 = Date.now();
const pinged = s.next(11);
s.send({ jsonrpc: '2.0', id: 11, method: 'ping' });
await pinged;
const pingMs = Date.now() - t0;
assert.ok(pingMs < 1000, `ping took ${pingMs} ms while verify was in flight — server is blocking`);
const verify = await verifyDone;
const payload = JSON.parse(verify.result.content[0].text);
assert.equal(payload.verdict, 'PASS');
} finally {
s.close();
rmSync(repo, { recursive: true, force: true });
}
});
test('tools/call executions are serialized — two slow calls run sequentially', { skip: !which('python') && !which('python3') ? 'python not on PATH' : false }, async () => {
// Two verify.py that each sleep 0.8 s. Serialized ⇒ ~1.6 s+; concurrent ⇒ ~0.8 s.
const repo = mkdtempSync(join(tmpdir(), 'ruview-mcp-serial-'));
const proofDir = join(repo, 'archive', 'v1', 'data', 'proof');
mkdirSync(proofDir, { recursive: true });
writeFileSync(join(proofDir, 'verify.py'), 'import time\ntime.sleep(0.8)\nprint("VERDICT: PASS")\n');
const s = startServer();
try {
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
await s.next(1);
const t0 = Date.now();
const a = s.next(20);
const b = s.next(21);
s.send({ jsonrpc: '2.0', id: 20, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
s.send({ jsonrpc: '2.0', id: 21, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
const [ra, rb] = await Promise.all([a, b]);
const elapsed = Date.now() - t0;
assert.equal(JSON.parse(ra.result.content[0].text).verdict, 'PASS');
assert.equal(JSON.parse(rb.result.content[0].text).verdict, 'PASS');
assert.ok(elapsed > 1400, `two 0.8 s tool calls finished in ${elapsed} ms — they overlapped instead of serializing`);
} finally {
s.close();
rmSync(repo, { recursive: true, force: true });
}
});
test('stdin close flushes an in-flight tools/call response before exit', async () => {
const child = spawn(process.execPath, [CLI, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
let out = '';
child.stdout.on('data', (d) => { out += d; });
const exited = new Promise((res) => child.on('exit', res));
// Write a tools/call then immediately close stdin. The old fire-and-forget
// dispatch raced rl 'close' → process.exit and could drop this response.
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'tools/call', params: { name: 'ruview_onboard', arguments: {} } }) + '\n');
child.stdin.end();
await exited;
const msgs = out.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
const resp = msgs.find((m) => m.id === 42);
assert.ok(resp, 'the in-flight tools/call response must be flushed to stdout before exit');
assert.equal(resp.result.isError, false);
});
+165 -20
View File
@@ -1,12 +1,18 @@
// SPDX-License-Identifier: MIT
// RuView harness tests — Node's built-in test runner (no devDeps to install).
// Run: `node --test test/` (or `npm test`).
// Run: `node --test test/*.test.mjs` (or `npm test`).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync, readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join, dirname, delimiter } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { claimCheck, summarize } from '../src/guardrails.js';
import { TOOLS, runTool, listTools, findRepoRoot } from '../src/tools.js';
import { run } from '../bin/cli.js';
import { TOOLS, TOOL_ALIASES, runTool, listTools, findRepoRoot, run, which } from '../src/tools.js';
import { run as cliRun } from '../bin/cli.js';
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
test('guardrail flags the retracted 100% framing as high severity', () => {
const r = claimCheck('Our model reaches 100% accuracy on every pose.');
@@ -37,71 +43,190 @@ test('guardrail ignores non-metric prose', () => {
assert.equal(claimCheck('').ok, true);
});
// ADR-263 F11/O9: precision pins — short metric tokens must not fire on prose.
test('guardrail does not false-positive on "map"/"F1" prose (ADR-263 F11)', () => {
assert.equal(claimCheck('F-numbers map to findings.').ok, true);
assert.equal(claimCheck('### F1 (HIGH, broken export): `require` points at a missing file').ok, true);
assert.equal(claimCheck('The 0.1.0 tarball ships 44 `.map` files = 62,698 B of dead weight.').ok, true);
assert.equal(claimCheck('the source maps can never resolve').ok, true);
assert.equal(claimCheck('- **O1 (F1):** fix `exports` (see F2 for the 33% map weight — MEASURED, tarball listing)').ok, true);
assert.equal(claimCheck('ADR-264: exports fix, map-free tarball, session-per-transport').ok, true);
});
test('guardrail still catches real short-token metric claims', () => {
assert.equal(claimCheck('We reach mAP 62.3 on COCO.').ok, false);
assert.equal(claimCheck('F1 score of 0.91 on the held set.').ok, false, 'f1 with a real score must still fire');
assert.equal(claimCheck('IoU 0.75 across rooms.').ok, false);
});
// Digits hidden in a code span still make a claim — scrubbing must not blind the
// number gate to `0.95` (regression: code-span number bypassed the gate).
test('guardrail flags an accuracy number stated inside a code span', () => {
const r = claimCheck('Count accuracy reached `0.95` in our tests.');
assert.equal(r.ok, false, JSON.stringify(r.findings));
assert.ok(r.findings.some((f) => /not tagged/i.test(f.reason)));
});
// A MEASURED claim whose only number hides in a code span must still reach the
// missing-reproducer check (regression: the scrubbed gate short-circuited it).
// Bare metric prose with no number at all (e.g. the README rule text) stays a pass.
test('guardrail flags a MEASURED code-span number with no reproducer', () => {
const r = claimCheck('Detection accuracy `0.97` on the set (MEASURED).');
assert.equal(r.ok, false, JSON.stringify(r.findings));
assert.ok(r.findings.some((f) => /no reproducer/i.test(f.reason)));
assert.equal(claimCheck('Every accuracy number must be MEASURED against a baseline.').ok, true);
});
// F1-score phrasings ("F1: 0.91", "F1 reaches 0.91") were scrubbed as option
// labels and slipped through; option refs alone must still not false-positive.
test('guardrail catches F1-score claims but not bare option refs (ADR-263 F11)', () => {
assert.equal(claimCheck('F1: 0.91 on the held-out set.').ok, false, 'F1: value is a metric claim');
assert.equal(claimCheck('F1 reaches 0.91 on the held-out set.').ok, false, 'F1 with a nearby number is a claim');
assert.equal(claimCheck('Options O1O9 are tracked in ADR-263 O2.').ok, true, 'option labels are not metrics');
assert.equal(claimCheck('ADR-263 O2 lands the exports fix.').ok, true);
});
test('summarize gives PASS/finding text', () => {
assert.match(summarize(claimCheck('nothing here')), /PASS/);
assert.match(summarize(claimCheck('100% accuracy')), /finding/);
});
test('registry exposes the documented tools with schemas', () => {
test('registry exposes the documented tools with schemas (underscore-canonical)', () => {
const names = Object.keys(TOOLS);
for (const n of ['ruview.onboard', 'ruview.claim_check', 'ruview.verify', 'ruview.node_monitor', 'ruview.calibrate', 'ruview.node_flash']) {
for (const n of ['ruview_onboard', 'ruview_claim_check', 'ruview_verify', 'ruview_node_monitor', 'ruview_calibrate', 'ruview_node_flash']) {
assert.ok(names.includes(n), `missing ${n}`);
assert.equal(TOOLS[n].inputSchema.type, 'object');
assert.match(n, /^[a-zA-Z0-9_-]{1,64}$/, 'canonical names must satisfy host tool-name regexes');
}
assert.equal(listTools().length, names.length);
});
test('ruview.onboard returns paths and a recommendation', () => {
const r = runTool('ruview.onboard', {});
test('dotted legacy names resolve via aliases (ADR-263 O8)', async () => {
assert.equal(TOOL_ALIASES['ruview.claim_check'], 'ruview_claim_check');
assert.equal(TOOL_ALIASES['ruview.node_monitor'], 'ruview_node_monitor');
const r = await runTool('ruview.onboard', {});
assert.equal(r.ok, true);
});
test('ruview_onboard returns paths and a recommendation', async () => {
const r = await runTool('ruview_onboard', {});
assert.equal(r.ok, true);
assert.ok(r.paths['live-esp32']);
assert.ok(['repo-build', 'docker-demo'].includes(r.recommend));
});
test('ruview.claim_check tool wraps the guardrail', () => {
const r = runTool('ruview.claim_check', { text: '100% accuracy' });
test('ruview_claim_check tool wraps the guardrail', async () => {
const r = await runTool('ruview_claim_check', { text: '100% accuracy' });
assert.equal(r.ok, false);
assert.match(r.summary, /honesty|tag|MEASURED|finding/i);
});
test('unknown tool fails closed', () => {
const r = runTool('ruview.does_not_exist', {});
// ADR-263 F1/O1: the honesty gate must fail closed on empty input.
test('ruview_claim_check fails closed on empty/missing text', async () => {
const empty = await runTool('ruview_claim_check', { text: '' });
assert.equal(empty.ok, false);
assert.equal(empty.reason, 'empty_text');
const missing = await runTool('ruview_claim_check', {});
assert.equal(missing.ok, false);
assert.equal(missing.reason, 'empty_text');
});
test('unknown tool fails closed', async () => {
const r = await runTool('ruview_does_not_exist', {});
assert.equal(r.ok, false);
assert.equal(r.reason, 'unknown_tool');
});
test('node_monitor fails closed without a port', () => {
const r = runTool('ruview.node_monitor', {});
test('node_monitor fails closed without a port', async () => {
const r = await runTool('ruview_node_monitor', {});
assert.equal(r.ok, false);
assert.equal(r.reason, 'no_port');
});
test('node_flash refuses without confirm (mutating guard)', () => {
const r = runTool('ruview.node_flash', { port: 'COM8', variant: 's3-8mb' });
test('node_flash refuses without confirm (mutating guard)', async () => {
const r = await runTool('ruview_node_flash', { port: 'COM8', variant: 's3-8mb' });
assert.equal(r.ok, false);
// either not-confirmed (win32) or unsupported_platform (posix) — both fail-closed
assert.ok(['not_confirmed', 'unsupported_platform'].includes(r.reason));
});
test('verify fails closed when not in a RuView repo', () => {
test('verify fails closed when not in a RuView repo', async () => {
// point at a tmp dir with no repo markers
const r = runTool('ruview.verify', { repo: process.platform === 'win32' ? 'C:/Windows/Temp' : '/tmp' });
const r = await runTool('ruview_verify', { repo: process.platform === 'win32' ? 'C:/Windows/Temp' : '/tmp' });
assert.equal(r.ok, false);
assert.ok(['proof_missing', 'python_missing'].includes(r.reason), r.reason);
});
// ADR-263 F2/O2: registry-level concurrency — a slow child must not block
// other tool calls (run() is promise-based, never spawnSync).
test('run() is non-blocking: a fast tool completes while a slow child runs', async () => {
const slow = run('node', ['-e', 'setTimeout(() => {}, 2000)'], { timeout: 5000 });
const t0 = Date.now();
const fast = await runTool('ruview_onboard', {});
const elapsed = Date.now() - t0;
assert.equal(fast.ok, true);
assert.ok(elapsed < 1000, `onboard took ${elapsed} ms while a 2 s child was running`);
const r = await slow;
assert.equal(r.ok, true);
});
test('run() reports a timeout as a failure, not a hang', async () => {
const r = await run('node', ['-e', 'setTimeout(() => {}, 10000)'], { timeout: 300 });
assert.equal(r.ok, false);
assert.match(String(r.error), /timed out/);
});
test('run() bounds captured output instead of dying on big streams (ADR-263 O4)', async () => {
// 4 MiB of stdout would have hit spawnSync's 1 MiB default maxBuffer (ENOBUFS).
const r = await run('node', ['-e', "process.stdout.write('x'.repeat(4 * 1024 * 1024)); console.log('TAIL_MARKER')"], { timeout: 30000 });
assert.equal(r.ok, true);
assert.ok(r.stdout.length <= 65536, `tail not bounded: ${r.stdout.length}`);
assert.ok(r.stdout.includes('TAIL_MARKER'), 'tail must keep the end of the stream');
});
test('which() finds node and re-probes misses (hits are cached)', () => {
assert.ok(which('node'), 'node must be on PATH in the test env');
assert.equal(which('definitely-not-a-binary-xyz'), null);
assert.equal(which('definitely-not-a-binary-xyz'), null); // re-probed, still absent
});
// ADR-263 O8: a miss must not be cached — an operator who installs a tool
// mid-session (e.g. python after a python_missing failure) must be found next call.
test('which() re-probes after a miss so a newly-installed tool is found', () => {
const dir = mkdtempSync(join(tmpdir(), 'ruview-which-'));
const name = 'ruview-probe-xyz';
const isWin = process.platform === 'win32';
const bin = join(dir, isWin ? `${name}.cmd` : name);
const prevPath = process.env.PATH;
try {
assert.equal(which(name), null, 'not on PATH yet → miss');
writeFileSync(bin, isWin ? '@echo off\n' : '#!/bin/sh\n', { mode: 0o755 });
process.env.PATH = dir + delimiter + prevPath;
assert.ok(which(name), 'installed mid-session → the miss must not have been cached');
} finally {
process.env.PATH = prevPath;
rmSync(dir, { recursive: true, force: true });
}
});
test('CLI run(): claim-check exits non-zero on a bad claim', async () => {
const code = await run(['claim-check', '--text', '100% accuracy']);
const code = await cliRun(['claim-check', '--text', '100% accuracy']);
assert.notEqual(code, 0);
});
// ADR-263 F1/O1: the CLI must not PASS silently with no input.
test('CLI run(): claim-check with no input exits 2 (fail-closed)', async () => {
assert.equal(await cliRun(['claim-check']), 2);
assert.equal(await cliRun(['claim-check', '--text', ' ']), 2);
});
test('CLI run(): doctor exits 0 (tools-only path)', async () => {
const code = await run(['doctor']);
const code = await cliRun(['doctor']);
assert.equal(code, 0);
});
test('CLI run(): unknown command exits non-zero', async () => {
assert.notEqual(await run(['definitely-not-a-command']), 0);
assert.notEqual(await cliRun(['definitely-not-a-command']), 0);
});
test('findRepoRoot locates this monorepo from cwd', () => {
@@ -109,3 +234,23 @@ test('findRepoRoot locates this monorepo from cwd', () => {
const root = findRepoRoot();
assert.ok(root === null || typeof root === 'string');
});
// ADR-263 F7/O7: skills ship from one source; the projected copies must match.
test('.claude/skills/*/SKILL.md are byte-identical to skills/*.md', () => {
const srcDir = join(PKG_ROOT, 'skills');
for (const f of readdirSync(srcDir).filter((f) => f.endsWith('.md'))) {
const name = f.replace(/\.md$/, '');
const src = readFileSync(join(srcDir, f), 'utf8');
const projected = readFileSync(join(PKG_ROOT, '.claude', 'skills', name, 'SKILL.md'), 'utf8');
assert.equal(projected, src, `skill drift: ${name} — run \`npm run sync-skills\``);
}
});
// ADR-263 F6/O6 + F3/O3: package hygiene pins.
test('package.json has no optionalDependencies and no hardcoded server version drift', () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
assert.equal(pkg.optionalDependencies, undefined, 'ADR-263 O3: optional deps tripled the cold npx install');
assert.equal(pkg.dependencies, undefined, 'the harness is dependency-free by design');
const mcpSrc = readFileSync(join(PKG_ROOT, 'src', 'mcp-server.js'), 'utf8');
assert.ok(!/version:\s*'\d+\.\d+\.\d+'/.test(mcpSrc), 'ADR-263 O6: server version must come from package.json');
});
@@ -18,6 +18,8 @@ Bring a RuView sensing node online: build firmware → flash → provision WiFi
**Not supported:** original ESP32, ESP32-C3 (single-core).
**⚠️ Ask about board form factor before flashing.** If the user's board is a coin-sized clone (ESP32-S3-Zero, SuperMini, or similar — not a full DevKitC/XIAO-style board with a real USB connector and visible regulator), warn them before they walk away from it: this firmware runs the WiFi radio continuously (`WIFI_PS_NONE`) plus a full DSP pipeline (`edge_tier=2`), which is sustained high current draw that full-size dev boards handle fine but tiny clones with minimal copper/budget regulators may not. At least one field report: boards ran hot during a normal session and failed to power on again afterward (regulator damage suspected). Tell them to give the board airflow (don't stack/enclose it) and check it by touch during the first several minutes of any new deployment.
## 1. Build firmware (Windows — Python subprocess, NOT bash directly)
ESP-IDF v5.4 does not support MSYS2/Git Bash. Use the Espressif Python venv as a subprocess with `MSYSTEM*` env vars stripped. The proven command lives in `CLAUDE.local.md` — reproduce it:
+3072 -24
View File
File diff suppressed because it is too large Load Diff
+69 -2
View File
@@ -23,6 +23,27 @@ name = "wifi_densepose_native"
crate-type = ["cdylib", "rlib"]
path = "src/lib.rs"
# ADR-185 §3.1 — optional pip extras map to Cargo features so the
# default wheel links none of the SOTA subsystems. P1 wires `aether`.
[features]
default = []
# ADR-185 P1 — AETHER contrastive CSI embeddings. Binds the std-only
# `wifi-densepose-aether` leaf crate (the pure-compute stack hoisted out of
# `wifi-densepose-sensing-server` per §13), so this extra links no server tree.
aether = ["dep:wifi-densepose-aether"]
# ADR-185 P2 — MERIDIAN domain generalization. Binds the tch-free
# inference/adaptation path only (see the wheel-size note on the deps
# below). `wifi-densepose-train` is depended on WITHOUT `tch-backend`,
# so no libtorch is linked.
meridian = ["dep:wifi-densepose-train", "dep:wifi-densepose-signal"]
# ADR-185 P3 — MAT disaster-survivor detection. Mirrors the upstream
# disaster/ML gating: bound only under this extra so the default wheel
# never carries the detection stack. `tokio`/`geo` are pulled to drive a
# single-shot scan (see the wheel-size note on the deps below).
mat = ["dep:wifi-densepose-mat", "dep:tokio", "dep:geo"]
# ADR-185 §3 — convenience superset: all three SOTA subsystems.
sota = ["aether", "meridian", "mat"]
[dependencies]
# PyO3 with abi3-py310 — one compiled binary covers Python 3.10, 3.11,
# 3.12, 3.13, and any future 3.x that keeps the stable ABI (ADR-117 §5.4).
@@ -50,6 +71,52 @@ wifi-densepose-bfld = { version = "0.3.0", path = "../v2/crates/wifi-densepose-b
# the future P3 CsiFrame numpy round-trip.
numpy = "0.22"
# ADR-185 P1 — AETHER backing crate (contrastive `embedding` +
# `graph_transformer`/`sona`/`sparse_inference`, ADR-024). Optional +
# gated behind the `aether` feature.
#
# WHEEL-SIZE FIX LANDED (ADR-185 §13): this is now the std-only
# `wifi-densepose-aether` leaf crate — zero external deps, no tokio/axum/
# worldgraph/ruvector — hoisted out of `wifi-densepose-sensing-server`
# (which re-exports it, so the server is unchanged). The `[aether]` wheel
# therefore links only pure compute and stays within the ADR-117 §5.4
# ≤5 MB budget.
wifi-densepose-aether = { version = "0.3.0", path = "../v2/crates/wifi-densepose-aether", optional = true }
# ADR-185 P2 — MERIDIAN backing crates (optional, `meridian`-gated).
#
# HONEST WHEEL-SIZE NOTE (ADR-185 §9 / §1.2): unlike AETHER, the libtorch
# risk is AVOIDED here — `wifi-densepose-train`'s `tch` dep is properly
# optional (feature `tch-backend`, OFF by default), so no libtorch links.
# BUT `wifi-densepose-train` still carries NON-optional deps: `tokio` (rt
# subset), the five `ruvector-*` crates, `wifi-densepose-nn`, petgraph,
# memmap2, indicatif, ndarray-npy, csv, toml, clap. So a `[meridian]`
# wheel is heavier than the ≤5 MB ADR-117 §5.4 budget (though far lighter
# than AETHER's axum/tokio server tree). The clean fix is the same
# leaf-crate hoist: move the pure inference modules (geometry,
# rapid_adapt, eval, hardware_norm) into a tch/tokio-free leaf crate.
# `wifi-densepose-signal` is depended on `default-features = false` to
# drop the optional ndarray-linalg/BLAS chain (Windows-friendly).
wifi-densepose-train = { version = "0.3.0", path = "../v2/crates/wifi-densepose-train", optional = true, default-features = false }
wifi-densepose-signal = { version = "0.3.0", path = "../v2/crates/wifi-densepose-signal", optional = true, default-features = false }
# ADR-185 P3 — MAT backing crate + the tokio/geo needed to drive one scan.
#
# HONEST WHEEL-SIZE NOTE (ADR-185 §9 / §1.3): `default-features = false`
# drops MAT's `api` (axum) and `ruvector` features from the wheel, but MAT
# still carries NON-optional `tokio` (rt/sync/time), `wifi-densepose-nn`
# (which pulls `ort` / ONNX Runtime + reqwest/hyper), `rustfft`, `geo`,
# and `ndarray`. So a `[mat]` wheel exceeds the ADR-117 §5.4 ≤5 MB budget
# — same leaf-crate-hoist story as AETHER/MERIDIAN, gated the same way so
# the DEFAULT wheel is untouched. `tokio` (rt+time) and `geo` are depended
# on directly (version-matched to MAT) to build the single-shot scan
# runtime and construct the event `geo::Point` in the binding.
wifi-densepose-mat = { version = "0.3.0", path = "../v2/crates/wifi-densepose-mat", optional = true, default-features = false, features = ["std"] }
tokio = { version = "1.35", features = ["rt", "time"], optional = true }
geo = { version = "0.27", optional = true }
[dev-dependencies]
# Doc-test infrastructure for the Python-facing examples in the bound
# Rust functions. Lands properly in P2 once #[pyfunction]s exist to test.
# ADR-185 §4.1 parity harness — SHA-256 the native-Rust reference
# embedding and read the committed golden fixture.
sha2 = "0.10"
serde_json = "1"
+23
View File
@@ -43,6 +43,29 @@ pip install "wifi-densepose[client]" # + WebSocket/MQTT clients
Wheels are published for Linux (x86_64, aarch64), macOS (x86_64, arm64), and
Windows (amd64).
### SOTA extras (ADR-185)
Three optional subsystems bind the Rust SOTA modules as compiled-feature
wheels. Each raises a clear `ImportError` if you import it without the extra:
| Extra | Module | What it adds |
|-------|--------|--------------|
| `[aether]` | `wifi_densepose.aether` | Contrastive CSI embeddings / re-identification (ADR-024) — `EmbeddingExtractor`, `cosine_similarity`, `info_nce_loss` |
| `[meridian]` | `wifi_densepose.meridian` | Cross-environment domain generalization (ADR-027) — `HardwareNormalizer`, `GeometryEncoder`, `RapidAdaptation`, `CrossDomainEvaluator` |
| `[mat]` | `wifi_densepose.mat` | Mass-Casualty Assessment disaster-survivor detection + START triage — `DisasterResponse`, `Survivor`, `TriageStatus` |
| `[sota]` | all three | Convenience superset |
```bash
pip install "wifi-densepose[aether]" # re-identification embeddings
pip install "wifi-densepose[meridian]" # cross-room calibration
pip install "wifi-densepose[mat]" # disaster triage
pip install "wifi-densepose[sota]" # all three
```
Runnable examples: [`examples/reid_from_csi.py`](examples/reid_from_csi.py),
[`examples/cross_room_calibrate.py`](examples/cross_room_calibrate.py),
[`examples/mat_triage.py`](examples/mat_triage.py).
## Usage
### Extract breathing rate from a CSI stream
+44
View File
@@ -0,0 +1,44 @@
"""ADR-185 §4.2 — AETHER embed() micro-benchmarks.
Target (release build, ADR-024 §2.8 FP32 <1 ms with headroom): steady-state
`embed()` < 2 ms/window, and batched `embed()` scales roughly linearly (no
accidental O()).
Run with:
pytest python/bench/test_bench_aether.py --benchmark-only
Skipped by default (they live in `bench/`, outside `testpaths`). Timing
targets are validated on a RELEASE wheel (`maturin develop --release
--features sota`); a debug wheel will be several× slower.
"""
from __future__ import annotations
import math
import pytest
from wifi_densepose import aether
def _window(frames: int = 8, subc: int = 56) -> list[list[float]]:
return [[math.sin(0.1 * t + 0.03 * k) for k in range(subc)] for t in range(frames)]
def _extractor() -> aether.EmbeddingExtractor:
return aether.EmbeddingExtractor(n_subcarriers=56, config=aether.AetherConfig())
def test_embed_per_window(benchmark) -> None:
ext = _extractor()
window = _window()
out = benchmark(lambda: ext.embed(window))
assert len(out) == 128
@pytest.mark.parametrize("batch", [1, 8, 64])
def test_embed_batch_scaling(benchmark, batch: int) -> None:
ext = _extractor()
windows = [_window() for _ in range(batch)]
out = benchmark(lambda: [ext.embed(w) for w in windows])
assert len(out) == batch
+44
View File
@@ -0,0 +1,44 @@
"""ADR-185 §4.2 — MAT scan micro-benchmark.
Measures the cost of one full ingest + `scan_once()` cycle over the
committed 256-frame CSI stream. The per-cycle cost should stay comfortably
below the configured scan interval (default 500 ms) so the binding is not
the bottleneck.
Run with:
pytest python/bench/test_bench_mat.py --benchmark-only
Validated on a RELEASE wheel; a debug wheel will be several× slower.
"""
from __future__ import annotations
import json
from pathlib import Path
from wifi_densepose import mat
_FIXTURE = Path(__file__).resolve().parents[1] / "tests" / "golden" / "mat_input.json"
def _stream() -> list[dict]:
return json.loads(_FIXTURE.read_text())["stream"]
def test_scan_cycle_cost(benchmark) -> None:
stream = _stream()
def _run() -> int:
cfg = mat.DisasterConfig(
mat.DisasterType.Earthquake, sensitivity=0.9, confidence_threshold=0.1
)
resp = mat.DisasterResponse(cfg)
resp.initialize_event(0.0, 0.0, "bench")
resp.add_zone(mat.ScanZone.rectangle("Zone A", 0.0, 0.0, 50.0, 30.0))
for frame in stream:
resp.push_csi_data(frame["amplitude"], frame["phase"])
resp.scan_once()
return len(resp.survivors())
survivors = benchmark(_run)
assert survivors == 1
+29
View File
@@ -0,0 +1,29 @@
"""ADR-185 §4.2 — MERIDIAN micro-benchmarks.
Targets (release build, ADR-027 §4.1/§4.3 ×2 headroom): `normalize()`
< 200 µs/frame, `encode()` < 200 µs.
Run with:
pytest python/bench/test_bench_meridian.py --benchmark-only
Validated on a RELEASE wheel; a debug wheel will be several× slower.
"""
from __future__ import annotations
from wifi_densepose import meridian as mer
def test_normalize_per_frame(benchmark) -> None:
norm = mer.HardwareNormalizer()
amp = [10.0 + 0.05 * k for k in range(64)]
phase = [0.01 * k for k in range(64)]
out = benchmark(lambda: norm.normalize(amp, phase, mer.HardwareType.Esp32S3))
assert len(out.amplitude) == 56
def test_geometry_encode(benchmark) -> None:
enc = mer.GeometryEncoder(mer.MeridianGeometryConfig())
aps = [[0.0, 0.0, 2.5], [5.0, 0.0, 2.5], [0.0, 4.0, 2.5]]
out = benchmark(lambda: enc.encode(aps))
assert len(out) == 64
+4 -4
View File
@@ -57,12 +57,12 @@ def test_heart_rate_extract_per_frame_cost(benchmark) -> None:
hr = HeartRateExtractor.esp32_default()
rng = Random(43)
for i in range(1500):
residuals, weights = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
hr.extract(residuals=residuals, weights=weights)
residuals, phases = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
hr.extract(residuals=residuals, phases=phases)
def _one_frame():
residuals, weights = _synth_frame(56, 100.0, 16.0, 1.2, rng)
return hr.extract(residuals=residuals, weights=weights)
residuals, phases = _synth_frame(56, 100.0, 16.0, 1.2, rng)
return hr.extract(residuals=residuals, phases=phases)
benchmark(_one_frame)
+45
View File
@@ -0,0 +1,45 @@
"""MERIDIAN cross-room calibration (ADR-185 P2, `[meridian]` extra).
Hardware-invariant CSI normalization, AP-geometry encoding, and few-shot
rapid adaptation the tch-free domain-generalization path.
pip install wifi-densepose[meridian]
python examples/cross_room_calibrate.py
"""
from __future__ import annotations
import math
from wifi_densepose.meridian import (
GeometryEncoder,
HardwareNormalizer,
HardwareType,
MeridianGeometryConfig,
RapidAdaptation,
)
def main() -> None:
# 1. Normalize a 64-subcarrier ESP32 frame to the canonical 56-tone grid.
norm = HardwareNormalizer()
amp = [10.0 + 0.05 * k for k in range(64)]
phase = [0.01 * k for k in range(64)]
frame = norm.normalize(amp, phase, HardwareType.detect(64))
print(f"canonical subcarriers: {len(frame.amplitude)} (hw={frame.hardware_type})")
# 2. Encode AP positions into a permutation-invariant geometry embedding.
enc = GeometryEncoder(MeridianGeometryConfig())
geometry = enc.encode([[0.0, 0.0, 2.5], [5.0, 0.0, 2.5], [0.0, 4.0, 2.5]])
print(f"geometry embedding dim: {len(geometry)}")
# 3. Few-shot rapid adaptation over a handful of unlabeled frames.
ra = RapidAdaptation(min_calibration_frames=10, lora_rank=4)
for i in range(12):
ra.push_frame([math.sin(0.1 * i + 0.05 * d) for d in range(16)])
result = ra.adapt()
print(f"adapted over {result.frames_used} frames, final_loss={result.final_loss:.4f}")
if __name__ == "__main__":
main()
+49
View File
@@ -0,0 +1,49 @@
"""MAT disaster-survivor triage from CSI (ADR-185 P3, `[mat]` extra).
Ingest a CSI stream, run one detection cycle, and list detected survivors
by START triage class.
pip install wifi-densepose[mat]
python examples/mat_triage.py
Note: the stream here is synthetic (breathing-modulated) it demonstrates
the API and pipeline, not validated detection accuracy on real rubble.
"""
from __future__ import annotations
import math
from collections.abc import Iterator
from wifi_densepose.mat import DisasterConfig, DisasterResponse, DisasterType, ScanZone
def breathing_stream(
frames: int = 256, subc: int = 56, fs: float = 20.0
) -> Iterator[tuple[list[float], list[float]]]:
for t in range(frames):
tt = t / fs
breath = 2.0 * math.sin(2 * math.pi * 0.3 * tt)
amp = [10.0 + 0.05 * k + breath for k in range(subc)]
phase = [0.01 * k + 0.1 * math.sin(2 * math.pi * 0.3 * tt) for k in range(subc)]
yield amp, phase
def main() -> None:
cfg = DisasterConfig(DisasterType.Earthquake, sensitivity=0.9, confidence_threshold=0.1)
resp = DisasterResponse(cfg)
resp.initialize_event(0.0, 0.0, "Collapsed Building A")
resp.add_zone(ScanZone.rectangle("North Wing", 0.0, 0.0, 50.0, 30.0))
for amp, phase in breathing_stream():
resp.push_csi_data(amp, phase)
resp.scan_once()
survivors = resp.survivors()
print(f"detected {len(survivors)} survivor(s)")
for s in survivors:
print(f" {s.id[:8]} triage={s.triage_status} confidence={s.confidence:.3f}")
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
"""AETHER re-identification from CSI (ADR-185 P1, `[aether]` extra).
Compute 128-dim contrastive embeddings for CSI windows and score them by
cosine similarity the primitive behind room fingerprinting and person
re-identification.
pip install wifi-densepose[aether]
python examples/reid_from_csi.py
"""
from __future__ import annotations
import math
from wifi_densepose.aether import AetherConfig, EmbeddingExtractor, cosine_similarity
def make_window(phase_shift: float, frames: int = 8, subc: int = 56) -> list[list[float]]:
"""A synthetic CSI window; `phase_shift` stands in for a different scene."""
return [
[math.sin(0.1 * t + 0.03 * k + phase_shift) for k in range(subc)]
for t in range(frames)
]
def main() -> None:
ext = EmbeddingExtractor(n_subcarriers=56, config=AetherConfig())
same_a = ext.embed(make_window(0.0))
same_b = ext.embed(make_window(0.0)) # same scene
other = ext.embed(make_window(1.5)) # different scene
print(f"embedding dim: {len(same_a)}")
print(f"same-scene similarity: {cosine_similarity(same_a, same_b):.4f}")
print(f"cross-scene similarity: {cosine_similarity(same_a, other):.4f}")
if __name__ == "__main__":
main()
+16 -2
View File
@@ -10,7 +10,7 @@ build-backend = "maturin"
[project]
name = "wifi-densepose"
version = "2.0.0a1"
version = "2.0.0"
description = "WiFi-based human pose estimation, vital sign extraction, and ambient intelligence from Channel State Information (CSI). PyO3 bindings for the Rust core."
readme = "README.md"
requires-python = ">=3.10"
@@ -23,7 +23,7 @@ keywords = [
"biometric", "ambient-intelligence", "home-assistant", "matter",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
@@ -48,6 +48,20 @@ client = [
"websockets>=12.0",
"paho-mqtt>=2.1",
]
# ADR-185 P1 — AETHER contrastive embeddings. Unlike `client`, this
# extra carries no pure-Python deps: it is a marker for a *compiled*
# feature build (`maturin ... --features aether` / a cibuildwheel
# feature axis, ADR-185 §3.1). Installing the base wheel and importing
# `wifi_densepose.aether` raises a clear ImportError naming this extra.
aether = []
# ADR-185 P2 — MERIDIAN domain generalization. Same compiled-feature
# marker pattern as `aether` (built via `maturin ... --features meridian`).
meridian = []
# ADR-185 P3 — MAT disaster-survivor detection. Same compiled-feature
# marker (built via `maturin ... --features mat`).
mat = []
# ADR-185 §3 convenience — all three SOTA subsystems at once.
sota = []
# Developer dependencies for running the test suite + lint.
dev = [
"pytest>=8.0",
+6 -6
View File
@@ -16,7 +16,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ruview"
version = "2.0.0a1"
version = "2.0.0"
description = "RuView — ambient intelligence from WiFi CSI. Meta-package; installs `wifi-densepose` and re-exports it under the `ruview` namespace. See https://github.com/ruvnet/RuView."
readme = "README.md"
requires-python = ">=3.10"
@@ -28,7 +28,7 @@ keywords = [
"ruview",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
@@ -43,13 +43,13 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
# Pin to the matching v2 release so an alpha-pin `pip install ruview`
# always gets a compatible wifi-densepose.
"wifi-densepose==2.0.0a1",
# Pin to the matching v2 release so `pip install ruview` always gets a
# compatible wifi-densepose.
"wifi-densepose==2.0.0",
]
[project.optional-dependencies]
client = ["wifi-densepose[client]==2.0.0a1"]
client = ["wifi-densepose[client]==2.0.0"]
[project.urls]
Homepage = "https://github.com/ruvnet/RuView"
+317
View File
@@ -0,0 +1,317 @@
//! ADR-185 P1 — PyO3 bindings for AETHER contrastive CSI embeddings.
//!
//! Surfaces the **pure-sync** contrastive-embedding compute from
//! `wifi-densepose-aether::embedding` (ADR-024; the std-only leaf hoisted per
//! ADR-185 §13) into `wifi_densepose.aether`:
//!
//! - `AetherConfig` — wraps `EmbeddingConfig` (d_model / d_proj /
//! temperature / normalize)
//! - `CsiAugmenter` — SimCLR-style augmentation pair generator
//! - `EmbeddingExtractor`— backbone + projection → 128-dim L2-normed embedding
//! - `info_nce_loss` — NT-Xent contrastive loss (module function)
//! - `cosine_similarity` — re-ID similarity helper (module function)
//!
//! ## Honest scope vs ADR-185 §3.2
//!
//! ADR-185 §3.2 names an aspirational surface (`aether_loss` returning
//! VICReg components, `alignment_metric`, `uniformity_metric`,
//! `forward_dual`, an `AetherConfig` with `vicreg_*` fields). Those do
//! **not** exist in the backing crate at HEAD — `embedding.rs` exposes
//! `EmbeddingConfig { d_model, d_proj, temperature, normalize }`,
//! `info_nce_loss` (plain `f32`), `CsiAugmenter::augment_pair`, and
//! `EmbeddingExtractor::extract`. This binding surfaces **what actually
//! exists** rather than fabricating the ADR's wished-for API. The
//! VICReg loss / metric surface is a Rust-side gap, not a binding gap.
//!
//! ## GIL release strategy (per ADR-117 §7, matching bindings/vitals.rs)
//!
//! `extract`, `augment_pair`, and `info_nce_loss` are pure-sync matrix
//! ops touching no Python objects, so they run inside
//! `py.allow_threads(|| ...)`.
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use wifi_densepose_aether::embedding::{
info_nce_loss as rust_info_nce_loss, CsiAugmenter, EmbeddingConfig, EmbeddingExtractor,
};
use wifi_densepose_aether::graph_transformer::TransformerConfig;
/// Upper bound on model/CSI dimensions accepted from Python. The transformer
/// allocates weight matrices quadratic in these, so this caps a single
/// construction well under a gigabyte and turns an accidental or malicious
/// `d_model=100_000` into a `ValueError` instead of an allocation that aborts
/// the interpreter. Generous relative to real configs (defaults 64/128); raise
/// deliberately if a workload genuinely needs larger.
const MAX_DIM: usize = 4096;
/// Upper bound on GNN layer count — a sanity cap, not a modelling limit.
const MAX_LAYERS: usize = 64;
// ─── AetherConfig ────────────────────────────────────────────────────
/// Configuration for the contrastive embedding model.
///
/// Python:
/// ```python
/// from wifi_densepose.aether import AetherConfig
/// cfg = AetherConfig(d_model=64, d_proj=128, temperature=0.07, normalize=True)
/// ```
#[pyclass(frozen, name = "AetherConfig")]
#[derive(Clone)]
pub struct PyAetherConfig {
inner: EmbeddingConfig,
}
#[pymethods]
impl PyAetherConfig {
#[new]
#[pyo3(signature = (d_model=64, d_proj=128, temperature=0.07, normalize=true))]
fn new(d_model: usize, d_proj: usize, temperature: f32, normalize: bool) -> PyResult<Self> {
// Validate at the boundary and raise ValueError. The native constructor
// allocates weight matrices quadratic in these dims and (elsewhere)
// divides by them, so zero or absurd values would otherwise reach Rust
// as a panic (surfacing to Python as an opaque PanicException) or a
// multi-gigabyte allocation that aborts the interpreter.
if d_model == 0 || d_proj == 0 {
return Err(PyValueError::new_err(
"d_model and d_proj must be positive",
));
}
if d_model > MAX_DIM || d_proj > MAX_DIM {
return Err(PyValueError::new_err(format!(
"d_model ({d_model}) and d_proj ({d_proj}) must be <= {MAX_DIM}"
)));
}
Ok(Self {
inner: EmbeddingConfig {
d_model,
d_proj,
temperature,
normalize,
},
})
}
#[getter]
fn d_model(&self) -> usize {
self.inner.d_model
}
#[getter]
fn d_proj(&self) -> usize {
self.inner.d_proj
}
#[getter]
fn temperature(&self) -> f32 {
self.inner.temperature
}
#[getter]
fn normalize(&self) -> bool {
self.inner.normalize
}
fn __repr__(&self) -> String {
format!(
"AetherConfig(d_model={}, d_proj={}, temperature={}, normalize={})",
self.inner.d_model, self.inner.d_proj, self.inner.temperature, self.inner.normalize,
)
}
}
// ─── CsiAugmenter ────────────────────────────────────────────────────
/// SimCLR-style CSI augmentation. `augment_pair` returns two distinct
/// augmented views of the same CSI window for contrastive pretraining.
///
/// Python:
/// ```python
/// from wifi_densepose.aether import CsiAugmenter
/// aug = CsiAugmenter()
/// view_a, view_b = aug.augment_pair(window, seed=42)
/// ```
#[pyclass(name = "CsiAugmenter")]
pub struct PyCsiAugmenter {
inner: CsiAugmenter,
}
#[pymethods]
impl PyCsiAugmenter {
#[new]
fn new() -> Self {
Self {
inner: CsiAugmenter::new(),
}
}
/// Produce two augmented views `(view_a, view_b)` of `window`
/// (frames × subcarriers) using the deterministic `seed`. GIL is
/// released during augmentation.
fn augment_pair(
&self,
py: Python<'_>,
window: Vec<Vec<f32>>,
seed: u64,
) -> (Vec<Vec<f32>>, Vec<Vec<f32>>) {
py.allow_threads(|| self.inner.augment_pair(&window, seed))
}
fn __repr__(&self) -> String {
"CsiAugmenter(SimCLR-style CSI augmentation)".to_string()
}
}
// ─── EmbeddingExtractor ──────────────────────────────────────────────
/// Full AETHER embedding extractor: CSI→pose transformer backbone +
/// projection head → a `d_proj`-dim (default 128) L2-normalized
/// embedding. Weights are deterministically seeded, so `embed` is a
/// pure function of its input for a fixed config.
///
/// Python:
/// ```python
/// from wifi_densepose.aether import AetherConfig, EmbeddingExtractor
/// ext = EmbeddingExtractor(n_subcarriers=56, config=AetherConfig())
/// emb = ext.embed(window) # list[float], len == config.d_proj
/// ```
#[pyclass(name = "EmbeddingExtractor")]
pub struct PyEmbeddingExtractor {
inner: EmbeddingExtractor,
embedding_dim: usize,
}
#[pymethods]
impl PyEmbeddingExtractor {
/// Construct an extractor. The transformer backbone is sized from
/// `n_subcarriers` and `config.d_model`; `config.d_proj` sets the
/// embedding dimension.
#[new]
#[pyo3(signature = (n_subcarriers, config, n_keypoints=17, n_heads=4, n_gnn_layers=2))]
fn new(
n_subcarriers: usize,
config: PyAetherConfig,
n_keypoints: usize,
n_heads: usize,
n_gnn_layers: usize,
) -> PyResult<Self> {
let e_config = config.inner.clone();
// n_heads == 0 reaches `d_model % n_heads` in the transformer and panics
// (divide-by-zero); a non-divisor trips the native `assert!`. Both would
// surface to Python as a PanicException. Reject cleanly instead.
if n_heads == 0 {
return Err(PyValueError::new_err("n_heads must be positive"));
}
if e_config.d_model % n_heads != 0 {
return Err(PyValueError::new_err(format!(
"d_model ({}) must be divisible by n_heads ({n_heads})",
e_config.d_model
)));
}
if n_subcarriers == 0 || n_keypoints == 0 {
return Err(PyValueError::new_err(
"n_subcarriers and n_keypoints must be positive",
));
}
if n_subcarriers > MAX_DIM || n_keypoints > MAX_DIM || n_gnn_layers > MAX_LAYERS {
return Err(PyValueError::new_err(format!(
"n_subcarriers/n_keypoints must be <= {MAX_DIM} and n_gnn_layers <= {MAX_LAYERS}"
)));
}
let t_config = TransformerConfig {
n_subcarriers,
n_keypoints,
d_model: e_config.d_model,
n_heads,
n_gnn_layers,
};
let embedding_dim = e_config.d_proj;
Ok(Self {
inner: EmbeddingExtractor::new(t_config, e_config),
embedding_dim,
})
}
/// Extract an embedding from a CSI window (frames × subcarriers).
/// Returns a `d_proj`-length vector (L2-normed when the config's
/// `normalize` is set). GIL released during the forward pass.
fn embed(&mut self, py: Python<'_>, csi_features: Vec<Vec<f32>>) -> Vec<f32> {
py.allow_threads(|| self.inner.extract(&csi_features))
}
#[getter]
fn embedding_dim(&self) -> usize {
self.embedding_dim
}
/// Total trainable parameter count (transformer + projection). Equals the
/// number of `f32`s in a weight file for this architecture.
#[getter]
fn param_count(&self) -> usize {
self.inner.param_count()
}
/// Load weights from `path` (a file written by `save_weights` or the Rust
/// `EmbeddingExtractor::save_weights`), replacing the current weights.
///
/// By default an `EmbeddingExtractor` uses deterministic **random** init
/// (untrained); this is the additive path to load real weights once a
/// trained checkpoint exists (ADR-185 §13.a). Raises `ValueError` on a
/// missing/corrupt file or a param-count mismatch with this architecture.
/// GIL released during file I/O + deserialization.
fn load_weights(&mut self, py: Python<'_>, path: String) -> PyResult<()> {
py.allow_threads(|| self.inner.load_weights(&path))
.map_err(PyValueError::new_err)
}
/// Serialize the current weights to `path` (magic `AETHERW1` + `u32` count
/// + little-endian `f32` payload). GIL released.
fn save_weights(&self, py: Python<'_>, path: String) -> PyResult<()> {
py.allow_threads(|| self.inner.save_weights(&path))
.map_err(|e| PyValueError::new_err(e.to_string()))
}
fn __repr__(&self) -> String {
format!("EmbeddingExtractor(embedding_dim={})", self.embedding_dim)
}
}
// ─── Module functions ────────────────────────────────────────────────
/// InfoNCE (NT-Xent) contrastive loss between two batches of embeddings.
/// Delegates to the identical Rust implementation. GIL released.
#[pyfunction]
#[pyo3(signature = (embeddings_a, embeddings_b, temperature=0.07))]
fn info_nce_loss(
py: Python<'_>,
embeddings_a: Vec<Vec<f32>>,
embeddings_b: Vec<Vec<f32>>,
temperature: f32,
) -> f32 {
py.allow_threads(|| rust_info_nce_loss(&embeddings_a, &embeddings_b, temperature))
}
/// Cosine similarity between two embeddings — the re-ID scoring
/// primitive. Byte-identical to the private `cosine_similarity` in the
/// backing crate (same dot-product / norm formula, `f32`).
#[pyfunction]
fn cosine_similarity(a: Vec<f32>, b: Vec<f32>) -> f32 {
let n = a.len().min(b.len());
let dot: f32 = (0..n).map(|i| a[i] * b[i]).sum();
let na = (0..n).map(|i| a[i] * a[i]).sum::<f32>().sqrt();
let nb = (0..n).map(|i| b[i] * b[i]).sum::<f32>().sqrt();
if na > 1e-10 && nb > 1e-10 {
dot / (na * nb)
} else {
0.0
}
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyAetherConfig>()?;
m.add_class::<PyCsiAugmenter>()?;
m.add_class::<PyEmbeddingExtractor>()?;
m.add_function(wrap_pyfunction!(info_nce_loss, m)?)?;
m.add_function(wrap_pyfunction!(cosine_similarity, m)?)?;
Ok(())
}
+433
View File
@@ -0,0 +1,433 @@
//! ADR-185 P3 — PyO3 bindings for MAT (Mass Casualty Assessment Tool, ADR-024
//! crate table): WiFi-based disaster-survivor detection + START triage.
//!
//! Bound behind the `[mat]` extra so the disaster/ML stack never enters the
//! default wheel.
//!
//! ## Honest scope vs ADR-185 §3.4
//!
//! - **`scan_once()`** — ADR-185 §3.4/§11.3 proposed adding a sync
//! `scan_once()` wrapper Rust-side. That turned out to be unnecessary: the
//! public async `DisasterResponse::start_scanning()` runs **exactly one**
//! `scan_cycle` and returns when `continuous_monitoring == false`. So this
//! binding forces `continuous_monitoring = false` and drives one scan on a
//! private current-thread tokio runtime — no change to `wifi-densepose-mat`.
//! - **event + zone are required** — `scan_cycle` errors without an active
//! event and an Active zone. ADR-185 §3.4's surface omitted this; the real
//! pipeline needs `initialize_event(...)` + `add_zone(...)` first, so both
//! are bound (documented additions, not fabrications).
//! - **`Survivor.vital_signs`** — the ADR implies a single `VitalSignsReading`;
//! the real accessor returns a *history*. Bound here as
//! `Survivor.latest_vitals -> Optional[VitalSignsReading]`.
//! - **`DisasterType`** has 9 variants at HEAD (adds Landslide, MineCollapse,
//! Industrial, TunnelCollapse) vs the ADR's shorter list; all are bound.
//!
//! ## GIL release
//!
//! `push_csi_data` and `scan_once` release the GIL (`py.allow_threads`) — the
//! detection pipeline + ensemble classifier are the compute-heavy part and
//! touch no Python state.
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use wifi_densepose_mat::{
DisasterConfig, DisasterResponse, DisasterType, ScanZone, Survivor, TriageStatus,
VitalSignsReading, ZoneBounds,
};
// ─── DisasterType ────────────────────────────────────────────────────
/// Type of disaster event (shapes the debris/attenuation model).
#[pyclass(eq, eq_int, frozen, hash, name = "DisasterType")]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum PyDisasterType {
BuildingCollapse = 0,
Earthquake = 1,
Landslide = 2,
Avalanche = 3,
Flood = 4,
MineCollapse = 5,
Industrial = 6,
TunnelCollapse = 7,
Unknown = 8,
}
impl PyDisasterType {
fn as_rust(self) -> DisasterType {
match self {
Self::BuildingCollapse => DisasterType::BuildingCollapse,
Self::Earthquake => DisasterType::Earthquake,
Self::Landslide => DisasterType::Landslide,
Self::Avalanche => DisasterType::Avalanche,
Self::Flood => DisasterType::Flood,
Self::MineCollapse => DisasterType::MineCollapse,
Self::Industrial => DisasterType::Industrial,
Self::TunnelCollapse => DisasterType::TunnelCollapse,
Self::Unknown => DisasterType::Unknown,
}
}
}
#[pymethods]
impl PyDisasterType {
fn __repr__(&self) -> String {
format!("DisasterType.{:?}", self.as_rust())
}
}
// ─── TriageStatus ────────────────────────────────────────────────────
/// START-protocol triage class.
#[pyclass(eq, eq_int, frozen, hash, name = "TriageStatus")]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum PyTriageStatus {
Immediate = 0,
Delayed = 1,
Minor = 2,
Deceased = 3,
Unknown = 4,
}
impl PyTriageStatus {
fn as_rust(self) -> TriageStatus {
match self {
Self::Immediate => TriageStatus::Immediate,
Self::Delayed => TriageStatus::Delayed,
Self::Minor => TriageStatus::Minor,
Self::Deceased => TriageStatus::Deceased,
Self::Unknown => TriageStatus::Unknown,
}
}
fn from_rust(s: &TriageStatus) -> Self {
match s {
TriageStatus::Immediate => Self::Immediate,
TriageStatus::Delayed => Self::Delayed,
TriageStatus::Minor => Self::Minor,
TriageStatus::Deceased => Self::Deceased,
TriageStatus::Unknown => Self::Unknown,
}
}
}
#[pymethods]
impl PyTriageStatus {
/// START priority (1 = highest / Immediate ... 5 = Unknown).
#[getter]
fn priority(&self) -> u8 {
self.as_rust().priority()
}
fn __repr__(&self) -> String {
format!("TriageStatus.{:?}", self.as_rust())
}
}
// ─── VitalSignsReading ───────────────────────────────────────────────
/// A single vital-signs reading (optional breathing/heartbeat + movement).
#[pyclass(frozen, name = "VitalSignsReading")]
pub struct PyVitalSignsReading {
breathing_rate_bpm: Option<f32>,
heartbeat_rate_bpm: Option<f32>,
movement_intensity: f32,
confidence: f64,
}
impl PyVitalSignsReading {
fn from_rust(r: &VitalSignsReading) -> Self {
Self {
breathing_rate_bpm: r.breathing.as_ref().map(|b| b.rate_bpm),
heartbeat_rate_bpm: r.heartbeat.as_ref().map(|h| h.rate_bpm),
movement_intensity: r.movement.intensity,
confidence: r.confidence.value(),
}
}
}
#[pymethods]
impl PyVitalSignsReading {
#[getter]
fn breathing_rate_bpm(&self) -> Option<f32> {
self.breathing_rate_bpm
}
#[getter]
fn heartbeat_rate_bpm(&self) -> Option<f32> {
self.heartbeat_rate_bpm
}
#[getter]
fn movement_intensity(&self) -> f32 {
self.movement_intensity
}
#[getter]
fn confidence(&self) -> f64 {
self.confidence
}
fn __repr__(&self) -> String {
format!(
"VitalSignsReading(breathing={:?}, heartbeat={:?}, movement={:.3}, confidence={:.3})",
self.breathing_rate_bpm, self.heartbeat_rate_bpm, self.movement_intensity, self.confidence,
)
}
}
// ─── Survivor ────────────────────────────────────────────────────────
/// A detected survivor: id, triage class, confidence, optional 3-D location,
/// and the latest vital-signs reading.
#[pyclass(frozen, name = "Survivor")]
pub struct PySurvivor {
id: String,
triage_status: PyTriageStatus,
confidence: f64,
location: Option<(f64, f64, f64)>,
latest_vitals: Option<Py<PyVitalSignsReading>>,
}
impl PySurvivor {
fn from_rust(py: Python<'_>, s: &Survivor) -> PyResult<Self> {
let latest_vitals = match s.vital_signs().latest() {
Some(r) => Some(Py::new(py, PyVitalSignsReading::from_rust(r))?),
None => None,
};
Ok(Self {
id: s.id().as_uuid().to_string(),
triage_status: PyTriageStatus::from_rust(s.triage_status()),
confidence: s.confidence(),
location: s.location().map(|c| (c.x, c.y, c.z)),
latest_vitals,
})
}
}
#[pymethods]
impl PySurvivor {
#[getter]
fn id(&self) -> &str {
&self.id
}
#[getter]
fn triage_status(&self) -> PyTriageStatus {
self.triage_status
}
#[getter]
fn confidence(&self) -> f64 {
self.confidence
}
#[getter]
fn location(&self) -> Option<(f64, f64, f64)> {
self.location
}
#[getter]
fn latest_vitals(&self, py: Python<'_>) -> Option<Py<PyVitalSignsReading>> {
self.latest_vitals.as_ref().map(|v| v.clone_ref(py))
}
fn __repr__(&self) -> String {
format!(
"Survivor(id={}, triage={:?}, confidence={:.3})",
&self.id[..8.min(self.id.len())],
self.triage_status.as_rust(),
self.confidence,
)
}
}
// ─── DisasterConfig ──────────────────────────────────────────────────
/// Configuration for the disaster-response pipeline.
///
/// Note: the Python binding always runs **single-shot** scans (`scan_once`),
/// so `continuous_monitoring` is forced off internally.
#[pyclass(frozen, name = "DisasterConfig")]
#[derive(Clone)]
pub struct PyDisasterConfig {
inner: DisasterConfig,
}
#[pymethods]
impl PyDisasterConfig {
#[new]
#[pyo3(signature = (
disaster_type,
sensitivity=0.8,
confidence_threshold=0.5,
max_depth=5.0,
scan_interval_ms=500
))]
fn new(
disaster_type: PyDisasterType,
sensitivity: f64,
confidence_threshold: f64,
max_depth: f64,
scan_interval_ms: u64,
) -> Self {
let inner = DisasterConfig::builder()
.disaster_type(disaster_type.as_rust())
.sensitivity(sensitivity)
.confidence_threshold(confidence_threshold)
.max_depth(max_depth)
.scan_interval_ms(scan_interval_ms)
.continuous_monitoring(false)
.build();
Self { inner }
}
#[getter]
fn sensitivity(&self) -> f64 {
self.inner.sensitivity
}
#[getter]
fn confidence_threshold(&self) -> f64 {
self.inner.confidence_threshold
}
#[getter]
fn max_depth(&self) -> f64 {
self.inner.max_depth
}
fn __repr__(&self) -> String {
format!(
"DisasterConfig(disaster_type={:?}, sensitivity={}, confidence_threshold={}, max_depth={})",
self.inner.disaster_type,
self.inner.sensitivity,
self.inner.confidence_threshold,
self.inner.max_depth,
)
}
}
// ─── ScanZone ────────────────────────────────────────────────────────
/// A rectangular or circular scan zone (new zones start Active).
#[pyclass(name = "ScanZone")]
#[derive(Clone)]
pub struct PyScanZone {
inner: ScanZone,
}
#[pymethods]
impl PyScanZone {
/// Rectangular zone with corner bounds (metres).
#[staticmethod]
fn rectangle(name: &str, min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
Self {
inner: ScanZone::new(name, ZoneBounds::rectangle(min_x, min_y, max_x, max_y)),
}
}
/// Circular zone centred at `(center_x, center_y)` with `radius` (metres).
#[staticmethod]
fn circle(name: &str, center_x: f64, center_y: f64, radius: f64) -> Self {
Self {
inner: ScanZone::new(name, ZoneBounds::circle(center_x, center_y, radius)),
}
}
#[getter]
fn name(&self) -> &str {
self.inner.name()
}
fn __repr__(&self) -> String {
format!("ScanZone(name={:?})", self.inner.name())
}
}
// ─── DisasterResponse ────────────────────────────────────────────────
/// Main disaster-response coordinator: ingest CSI, run one scan cycle, query
/// detected survivors by START triage.
#[pyclass(name = "DisasterResponse")]
pub struct PyDisasterResponse {
inner: DisasterResponse,
rt: tokio::runtime::Runtime,
}
#[pymethods]
impl PyDisasterResponse {
#[new]
fn new(config: PyDisasterConfig) -> PyResult<Self> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.map_err(|e| PyValueError::new_err(format!("failed to build tokio runtime: {e}")))?;
Ok(Self {
inner: DisasterResponse::new(config.inner),
rt,
})
}
/// Initialize the active disaster event at map coordinate `(x, y)`.
/// Required before `add_zone`/`scan_once`.
fn initialize_event(&mut self, x: f64, y: f64, description: &str) -> PyResult<()> {
self.inner
.initialize_event(geo::Point::new(x, y), description)
.map(|_| ())
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Add an (Active) scan zone to the current event. Raises if no event.
fn add_zone(&mut self, zone: PyScanZone) -> PyResult<()> {
self.inner
.add_zone(zone.inner)
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Push a raw CSI frame (equal-length `amplitudes`/`phases`) into the
/// detection pipeline. Raises on empty/mismatched input. GIL released.
fn push_csi_data(
&self,
py: Python<'_>,
amplitudes: Vec<f64>,
phases: Vec<f64>,
) -> PyResult<()> {
py.allow_threads(|| self.inner.push_csi_data(&amplitudes, &phases))
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Run exactly one scan cycle over the buffered CSI (detection → ensemble
/// → localization → triage). Requires an initialized event with an Active
/// zone. GIL released during the scan.
fn scan_once(&mut self, py: Python<'_>) -> PyResult<()> {
let rt = &self.rt;
let inner = &mut self.inner;
py.allow_threads(|| rt.block_on(inner.start_scanning()))
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// All detected survivors.
fn survivors(&self, py: Python<'_>) -> PyResult<Vec<PySurvivor>> {
self.inner
.survivors()
.into_iter()
.map(|s| PySurvivor::from_rust(py, s))
.collect()
}
/// Survivors filtered by START triage class.
fn survivors_by_triage(
&self,
py: Python<'_>,
status: PyTriageStatus,
) -> PyResult<Vec<PySurvivor>> {
self.inner
.survivors_by_triage(status.as_rust())
.into_iter()
.map(|s| PySurvivor::from_rust(py, s))
.collect()
}
fn __repr__(&self) -> String {
"DisasterResponse()".to_string()
}
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyDisasterType>()?;
m.add_class::<PyTriageStatus>()?;
m.add_class::<PyVitalSignsReading>()?;
m.add_class::<PySurvivor>()?;
m.add_class::<PyDisasterConfig>()?;
m.add_class::<PyScanZone>()?;
m.add_class::<PyDisasterResponse>()?;
Ok(())
}
+492
View File
@@ -0,0 +1,492 @@
//! ADR-185 P2 — PyO3 bindings for MERIDIAN cross-environment domain
//! generalization (ADR-027).
//!
//! Surfaces the **pure-sync, tch-free** inference/adaptation path into
//! `wifi_densepose.meridian`:
//!
//! - `HardwareType` / `HardwareNormalizer` / `CanonicalCsiFrame`
//! (from `wifi-densepose-signal::hardware_norm`)
//! - `MeridianGeometryConfig` / `GeometryEncoder`
//! - `RapidAdaptation` / `AdaptationResult`
//! - `CrossDomainEvaluator` + `mpjpe`
//! (from `wifi-densepose-train`, NO `tch-backend`)
//!
//! ## Honest scope vs ADR-185 §3.3
//!
//! ADR-185 §3.3 names a surface that partly diverges from the code at HEAD;
//! this binding tracks the **real** API and documents each deviation:
//!
//! - `HardwareType.detect(subcarrier_count)` — the real detector is the
//! static `HardwareNormalizer::detect_hardware`; exposed here as a
//! `HardwareType.detect` staticmethod delegating to it (no reimpl).
//! - `HardwareNormalizer.normalize(frame: CsiFrame, hw)` — the real method
//! takes raw `(amplitude, phase)` f64 vectors and returns a `Result`, so
//! it is bound as `normalize(amplitude, phase, hw)` (raises on error).
//! - `CanonicalCsiFrame.amplitudes/.phases` — the real fields are singular
//! `amplitude`/`phase`; bound under their real names.
//! - `RapidAdaptation.calibrate(csi_windows) -> AdaptationResult` with a
//! `converged` field — **does not exist**. The real engine is
//! `push_frame` + `adapt()`, and `AdaptationResult` carries
//! `{lora_weights, final_loss, frames_used, adaptation_epochs}` (no
//! `converged`). Bound as-is; the `calibrate`/`converged` surface is a
//! Rust-side gap, not fabricated here.
//!
//! Training-time types (`DomainFactorizer`, `GradientReversalLayer`,
//! `VirtualDomainAugmentor`) are out of P6 scope (ADR-185 §3.3 / Open Q
//! §11.2) — inference/adaptation only.
//!
//! ## GIL release (per ADR-117 §7, matching bindings/vitals.rs)
//!
//! `normalize`, `encode`, `adapt`, and `evaluate` are pure-sync numeric
//! ops touching no Python objects, so they run inside `py.allow_threads`.
use std::collections::HashMap;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use wifi_densepose_signal::hardware_norm::{
CanonicalCsiFrame, HardwareNormalizer, HardwareType,
};
use wifi_densepose_train::eval::{mpjpe as rust_mpjpe, CrossDomainEvaluator};
use wifi_densepose_train::geometry::{GeometryEncoder, MeridianGeometryConfig};
use wifi_densepose_train::rapid_adapt::{AdaptationLoss, AdaptationResult, RapidAdaptation};
// ─── HardwareType ────────────────────────────────────────────────────
/// WiFi chipset family, keyed by subcarrier count.
#[pyclass(eq, eq_int, frozen, hash, name = "HardwareType")]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum PyHardwareType {
Esp32S3 = 0,
Intel5300 = 1,
Atheros = 2,
Generic = 3,
}
impl PyHardwareType {
fn as_rust(self) -> HardwareType {
match self {
Self::Esp32S3 => HardwareType::Esp32S3,
Self::Intel5300 => HardwareType::Intel5300,
Self::Atheros => HardwareType::Atheros,
Self::Generic => HardwareType::Generic,
}
}
fn from_rust(hw: HardwareType) -> Self {
match hw {
HardwareType::Esp32S3 => Self::Esp32S3,
HardwareType::Intel5300 => Self::Intel5300,
HardwareType::Atheros => Self::Atheros,
HardwareType::Generic => Self::Generic,
}
}
}
#[pymethods]
impl PyHardwareType {
/// Detect hardware from subcarrier count (64→Esp32S3, 30→Intel5300,
/// 56→Atheros, else Generic). Delegates to the real
/// `HardwareNormalizer::detect_hardware`.
#[staticmethod]
fn detect(subcarrier_count: usize) -> Self {
Self::from_rust(HardwareNormalizer::detect_hardware(subcarrier_count))
}
#[getter]
fn subcarrier_count(&self) -> usize {
self.as_rust().subcarrier_count()
}
#[getter]
fn mimo_streams(&self) -> usize {
self.as_rust().mimo_streams()
}
fn __repr__(&self) -> String {
format!("HardwareType.{:?}", self.as_rust())
}
}
// ─── CanonicalCsiFrame ───────────────────────────────────────────────
/// A CSI frame canonicalized to the normalizer's subcarrier grid
/// (default 56): z-scored amplitude + sanitized (unwrapped, detrended)
/// phase.
#[pyclass(frozen, name = "CanonicalCsiFrame")]
pub struct PyCanonicalCsiFrame {
inner: CanonicalCsiFrame,
}
#[pymethods]
impl PyCanonicalCsiFrame {
#[getter]
fn amplitude(&self) -> Vec<f32> {
self.inner.amplitude.clone()
}
#[getter]
fn phase(&self) -> Vec<f32> {
self.inner.phase.clone()
}
#[getter]
fn hardware_type(&self) -> PyHardwareType {
PyHardwareType::from_rust(self.inner.hardware_type)
}
fn __repr__(&self) -> String {
format!(
"CanonicalCsiFrame(subcarriers={}, hardware_type={:?})",
self.inner.amplitude.len(),
self.inner.hardware_type,
)
}
}
// ─── HardwareNormalizer ──────────────────────────────────────────────
/// Normalizes CSI frames from heterogeneous chipsets into a canonical
/// representation (cubic resample → z-score amplitude → sanitize phase).
#[pyclass(name = "HardwareNormalizer")]
pub struct PyHardwareNormalizer {
inner: HardwareNormalizer,
}
#[pymethods]
impl PyHardwareNormalizer {
/// Create a normalizer. `canonical_subcarriers` defaults to 56.
#[new]
#[pyo3(signature = (canonical_subcarriers=56))]
fn new(canonical_subcarriers: usize) -> PyResult<Self> {
HardwareNormalizer::with_canonical_subcarriers(canonical_subcarriers)
.map(|inner| Self { inner })
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Detect hardware from subcarrier count (static).
#[staticmethod]
fn detect_hardware(subcarrier_count: usize) -> PyHardwareType {
PyHardwareType::from_rust(HardwareNormalizer::detect_hardware(subcarrier_count))
}
#[getter]
fn canonical_subcarriers(&self) -> usize {
self.inner.canonical_subcarriers()
}
/// Normalize a raw CSI frame given per-subcarrier `amplitude` and
/// `phase` (equal length) and its `hardware` type. Raises
/// `ValueError` on empty/mismatched input. GIL released.
fn normalize(
&self,
py: Python<'_>,
amplitude: Vec<f64>,
phase: Vec<f64>,
hardware: PyHardwareType,
) -> PyResult<PyCanonicalCsiFrame> {
let hw = hardware.as_rust();
py.allow_threads(|| self.inner.normalize(&amplitude, &phase, hw))
.map(|inner| PyCanonicalCsiFrame { inner })
.map_err(|e| PyValueError::new_err(e.to_string()))
}
fn __repr__(&self) -> String {
format!(
"HardwareNormalizer(canonical_subcarriers={})",
self.inner.canonical_subcarriers()
)
}
}
// ─── MeridianGeometryConfig ──────────────────────────────────────────
/// Config for the geometry encoder (Fourier bands + DeepSets output dim).
#[pyclass(frozen, name = "MeridianGeometryConfig")]
#[derive(Clone)]
pub struct PyMeridianGeometryConfig {
inner: MeridianGeometryConfig,
}
#[pymethods]
impl PyMeridianGeometryConfig {
#[new]
#[pyo3(signature = (n_frequencies=10, scale=1.0, geometry_dim=64, seed=42))]
fn new(n_frequencies: usize, scale: f32, geometry_dim: usize, seed: u64) -> Self {
Self {
inner: MeridianGeometryConfig {
n_frequencies,
scale,
geometry_dim,
seed,
},
}
}
#[getter]
fn n_frequencies(&self) -> usize {
self.inner.n_frequencies
}
#[getter]
fn scale(&self) -> f32 {
self.inner.scale
}
#[getter]
fn geometry_dim(&self) -> usize {
self.inner.geometry_dim
}
#[getter]
fn seed(&self) -> u64 {
self.inner.seed
}
fn __repr__(&self) -> String {
format!(
"MeridianGeometryConfig(n_frequencies={}, scale={}, geometry_dim={}, seed={})",
self.inner.n_frequencies, self.inner.scale, self.inner.geometry_dim, self.inner.seed,
)
}
}
// ─── GeometryEncoder ─────────────────────────────────────────────────
/// Permutation-invariant encoder: variable-count AP positions `[x,y,z]`
/// → a fixed `geometry_dim` (default 64) vector.
#[pyclass(name = "GeometryEncoder")]
pub struct PyGeometryEncoder {
inner: GeometryEncoder,
geometry_dim: usize,
}
#[pymethods]
impl PyGeometryEncoder {
#[new]
#[pyo3(signature = (config=None))]
fn new(config: Option<PyMeridianGeometryConfig>) -> Self {
let cfg = config.map(|c| c.inner).unwrap_or_default();
let geometry_dim = cfg.geometry_dim;
Self {
inner: GeometryEncoder::new(&cfg),
geometry_dim,
}
}
/// Encode AP positions (a non-empty list of `[x, y, z]`) into a
/// `geometry_dim`-length vector. Raises `ValueError` if the list is
/// empty or any position is not exactly 3 coordinates. GIL released.
fn encode(&self, py: Python<'_>, ap_positions: Vec<Vec<f32>>) -> PyResult<Vec<f32>> {
if ap_positions.is_empty() {
return Err(PyValueError::new_err(
"ap_positions must contain at least one [x, y, z] position",
));
}
let mut coords: Vec<[f32; 3]> = Vec::with_capacity(ap_positions.len());
for (i, p) in ap_positions.iter().enumerate() {
if p.len() != 3 {
return Err(PyValueError::new_err(format!(
"ap_positions[{i}] must have exactly 3 coordinates, got {}",
p.len()
)));
}
coords.push([p[0], p[1], p[2]]);
}
Ok(py.allow_threads(|| self.inner.encode(&coords)))
}
#[getter]
fn geometry_dim(&self) -> usize {
self.geometry_dim
}
fn __repr__(&self) -> String {
format!("GeometryEncoder(geometry_dim={})", self.geometry_dim)
}
}
// ─── RapidAdaptation / AdaptationResult ──────────────────────────────
/// Result of `RapidAdaptation.adapt()`.
#[pyclass(frozen, name = "AdaptationResult")]
pub struct PyAdaptationResult {
inner: AdaptationResult,
}
#[pymethods]
impl PyAdaptationResult {
#[getter]
fn lora_weights(&self) -> Vec<f32> {
self.inner.lora_weights.clone()
}
#[getter]
fn final_loss(&self) -> f32 {
self.inner.final_loss
}
#[getter]
fn frames_used(&self) -> usize {
self.inner.frames_used
}
#[getter]
fn adaptation_epochs(&self) -> usize {
self.inner.adaptation_epochs
}
fn __repr__(&self) -> String {
format!(
"AdaptationResult(final_loss={:.6}, frames_used={}, adaptation_epochs={})",
self.inner.final_loss, self.inner.frames_used, self.inner.adaptation_epochs,
)
}
}
/// Few-shot test-time adaptation: accumulate unlabeled CSI frames, then
/// `adapt()` to produce LoRA weight deltas that minimize a self-supervised
/// proxy loss.
///
/// Scope caveat (from the Rust module, kept honest): this minimizes a
/// self-supervised proxy over a tiny LoRA bottleneck; it is NOT wired to
/// the pose model and there is no measured end-to-end PCK gain from this
/// path — do not cite a PCK improvement from `adapt()`.
#[pyclass(name = "RapidAdaptation")]
pub struct PyRapidAdaptation {
inner: RapidAdaptation,
}
#[pymethods]
impl PyRapidAdaptation {
/// Build an adaptation engine. `loss_kind` is one of
/// `"contrastive"`, `"entropy"`, `"combined"` (default). `lambda_ent`
/// is used only by `"combined"`.
#[new]
#[pyo3(signature = (
min_calibration_frames,
lora_rank,
loss_kind="combined",
epochs=5,
lr=0.001,
lambda_ent=0.5
))]
fn new(
min_calibration_frames: usize,
lora_rank: usize,
loss_kind: &str,
epochs: usize,
lr: f32,
lambda_ent: f32,
) -> PyResult<Self> {
let loss = match loss_kind {
"contrastive" => AdaptationLoss::ContrastiveTTT { epochs, lr },
"entropy" => AdaptationLoss::EntropyMin { epochs, lr },
"combined" => AdaptationLoss::Combined {
epochs,
lr,
lambda_ent,
},
other => {
return Err(PyValueError::new_err(format!(
"unknown loss_kind '{other}'; expected 'contrastive', 'entropy', or 'combined'"
)))
}
};
Ok(Self {
inner: RapidAdaptation::new(min_calibration_frames, lora_rank, loss),
})
}
/// Push a single unlabeled CSI frame into the calibration buffer.
fn push_frame(&mut self, frame: Vec<f32>) {
self.inner.push_frame(&frame);
}
/// True once at least `min_calibration_frames` have been buffered.
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[getter]
fn buffer_len(&self) -> usize {
self.inner.buffer_len()
}
/// Run test-time adaptation over the buffered frames. Raises
/// `ValueError` if the buffer is empty or `lora_rank == 0`. GIL
/// released during the finite-difference optimization.
fn adapt(&self, py: Python<'_>) -> PyResult<PyAdaptationResult> {
py.allow_threads(|| self.inner.adapt())
.map(|inner| PyAdaptationResult { inner })
.map_err(|e| PyValueError::new_err(e.to_string()))
}
fn __repr__(&self) -> String {
format!("RapidAdaptation(buffered={})", self.inner.buffer_len())
}
}
// ─── CrossDomainEvaluator ────────────────────────────────────────────
/// Cross-domain pose-accuracy evaluator (MPJPE + domain-gap ratio).
#[pyclass(name = "CrossDomainEvaluator")]
pub struct PyCrossDomainEvaluator {
inner: CrossDomainEvaluator,
}
#[pymethods]
impl PyCrossDomainEvaluator {
/// Create an evaluator for `n_joints` (e.g. 17 for COCO).
#[new]
fn new(n_joints: usize) -> Self {
Self {
inner: CrossDomainEvaluator::new(n_joints),
}
}
/// Evaluate `predictions` (a list of `(pred, gt)` flat `n_joints*3`
/// vectors) grouped by `domain_labels` (0 = in-domain). Returns a
/// dict of the six cross-domain metrics. Raises `ValueError` on a
/// length mismatch. GIL released.
fn evaluate(
&self,
py: Python<'_>,
predictions: Vec<(Vec<f32>, Vec<f32>)>,
domain_labels: Vec<u32>,
) -> PyResult<HashMap<String, f32>> {
if predictions.len() != domain_labels.len() {
return Err(PyValueError::new_err(format!(
"predictions ({}) and domain_labels ({}) must have equal length",
predictions.len(),
domain_labels.len()
)));
}
let m = py.allow_threads(|| self.inner.evaluate(&predictions, &domain_labels));
let mut out = HashMap::with_capacity(6);
out.insert("in_domain_mpjpe".to_string(), m.in_domain_mpjpe);
out.insert("cross_domain_mpjpe".to_string(), m.cross_domain_mpjpe);
out.insert("few_shot_mpjpe".to_string(), m.few_shot_mpjpe);
out.insert("cross_hardware_mpjpe".to_string(), m.cross_hardware_mpjpe);
out.insert("domain_gap_ratio".to_string(), m.domain_gap_ratio);
out.insert("adaptation_speedup".to_string(), m.adaptation_speedup);
Ok(out)
}
fn __repr__(&self) -> String {
"CrossDomainEvaluator()".to_string()
}
}
/// Mean Per Joint Position Error between flat `[n_joints*3]` pose vectors.
#[pyfunction]
fn mpjpe(pred: Vec<f32>, gt: Vec<f32>, n_joints: usize) -> f32 {
rust_mpjpe(&pred, &gt, n_joints)
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyHardwareType>()?;
m.add_class::<PyCanonicalCsiFrame>()?;
m.add_class::<PyHardwareNormalizer>()?;
m.add_class::<PyMeridianGeometryConfig>()?;
m.add_class::<PyGeometryEncoder>()?;
m.add_class::<PyAdaptationResult>()?;
m.add_class::<PyRapidAdaptation>()?;
m.add_class::<PyCrossDomainEvaluator>()?;
m.add_function(wrap_pyfunction!(mpjpe, m)?)?;
Ok(())
}
+27 -5
View File
@@ -242,7 +242,22 @@ impl PyBreathingExtractor {
// ─── HeartRateExtractor ──────────────────────────────────────────────
/// Extracts heart rate (40120 BPM) from per-subcarrier amplitude
/// residuals via 0.82.0 Hz bandpass + autocorrelation peak detection.
/// residuals and per-subcarrier unwrapped phases (radians) via
/// 0.82.0 Hz bandpass + autocorrelation peak detection.
///
/// Python:
/// ```python
/// from wifi_densepose import HeartRateExtractor
///
/// hr = HeartRateExtractor.esp32_default() # 56 subcarriers, 100 Hz, 15s window
///
/// # Feed residuals and matching unwrapped phases from your preprocessor.
/// # Unlike BreathingExtractor weights, phases=[] is invalid for heart-rate
/// # extraction because the Rust core requires phase data for each subcarrier.
/// est = hr.extract(residuals=[0.01, -0.02, …], phases=[0.0, 0.01, …])
/// if est is not None:
/// print(est.value_bpm, est.confidence)
/// ```
#[pyclass(name = "HeartRateExtractor")]
pub struct PyHeartRateExtractor {
inner: HeartRateExtractor,
@@ -265,10 +280,17 @@ impl PyHeartRateExtractor {
Self { inner: HeartRateExtractor::esp32_default() }
}
/// Extract heart rate from per-subcarrier residuals. GIL released
/// during DSP.
fn extract(&mut self, py: Python<'_>, residuals: Vec<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights));
/// Extract heart rate from per-subcarrier residuals and matching
/// per-subcarrier unwrapped phases (radians). Empty phases are invalid
/// and return `None` because the Rust extractor requires phase data.
/// GIL released during DSP.
fn extract(
&mut self,
py: Python<'_>,
residuals: Vec<f64>,
phases: Vec<f64>,
) -> Option<PyVitalEstimate> {
let est = py.allow_threads(|| self.inner.extract(&residuals, &phases));
est.map(PyVitalEstimate::from_rust)
}
+30
View File
@@ -17,7 +17,13 @@
use pyo3::prelude::*;
mod bindings {
#[cfg(feature = "aether")]
pub mod aether;
pub mod bfld;
#[cfg(feature = "mat")]
pub mod mat;
#[cfg(feature = "meridian")]
pub mod meridian;
pub mod keypoint;
pub mod pose;
pub mod privacy_gate;
@@ -43,6 +49,12 @@ fn build_features() -> Vec<&'static str> {
feats.push("p2-pose-bindings"); // BoundingBox + PersonPose + PoseEstimate
feats.push("p3-vitals-bindings"); // BreathingExtractor + HeartRateExtractor + VitalEstimate
feats.push("p3.5-bfld-bindings"); // BfldFrame + BfldReport + BfldKind (stub Rust)
#[cfg(feature = "aether")]
feats.push("p6-aether-bindings"); // ADR-185 P1 — AETHER contrastive embeddings
#[cfg(feature = "meridian")]
feats.push("p6-meridian-bindings"); // ADR-185 P2 — MERIDIAN domain generalization
#[cfg(feature = "mat")]
feats.push("p6-mat-bindings"); // ADR-185 P3 — MAT disaster survivor detection
feats
}
@@ -85,5 +97,23 @@ fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
// the published `wifi-densepose-bfld 0.3.0` crate, not the Python port).
// Closes ADR-125 §2.1.d at the binding boundary.
bindings::privacy_gate::register(m)?;
// ADR-185 P1 — AETHER contrastive CSI embedding bindings, compiled
// and registered only under the `aether` feature so the default
// wheel links none of the sensing-server dependency tree.
#[cfg(feature = "aether")]
bindings::aether::register(m)?;
// ADR-185 P2 — MERIDIAN cross-environment domain-generalization
// bindings (hardware normalization, geometry encoding, rapid
// adaptation, cross-domain eval). Gated behind `meridian`; tch-free.
#[cfg(feature = "meridian")]
bindings::meridian::register(m)?;
// ADR-185 P3 — MAT disaster-survivor detection + START triage. Gated
// behind `mat`, mirroring the upstream disaster/ML stack gating.
#[cfg(feature = "mat")]
bindings::mat::register(m)?;
Ok(())
}
+111
View File
@@ -0,0 +1,111 @@
//! ADR-185 §4.1 — AETHER parity: native-Rust reference half.
//!
//! Produces the golden 128-dim embedding by calling the canonical
//! `wifi-densepose-aether::embedding` code DIRECTLY (no PyO3), for the
//! committed `tests/golden/aether_input.json` fixture, and compares it to the
//! committed golden VECTOR `tests/golden/aether_embedding.json` within a
//! numerical tolerance.
//!
//! Why a vector + tolerance and not a SHA-256 of the f32 bytes: the embedding
//! is pure f32 and uses transcendental ops (ln/sqrt/cos), which are not
//! bit-reproducible across CPU architectures or libm implementations. A byte
//! hash only ever matched the one arch that generated it and failed on every
//! other wheel this project builds (aarch64, macOS-arm). The pytest half
//! (`tests/test_aether.py`) compares the Python binding to the SAME golden
//! within the same tolerance — native≈golden and binding≈golden together prove
//! binding≈native, portably.
//!
//! Regeneration (only when the Rust subsystem intentionally changes): delete
//! `tests/golden/aether_embedding.json` and re-run `cargo test --features aether`.
#![cfg(feature = "aether")]
use std::fs;
use std::path::PathBuf;
use wifi_densepose_aether::embedding::{EmbeddingConfig, EmbeddingExtractor};
use wifi_densepose_aether::graph_transformer::TransformerConfig;
/// Cross-architecture f32 parity tolerance; see the module docs and the
/// matching `PARITY_ATOL`/`PARITY_RTOL` in `tests/test_aether.py`.
const PARITY_ATOL: f32 = 1e-4;
const PARITY_RTOL: f32 = 1e-4;
/// Assert `embedding` matches the committed golden vector `<name>` within
/// tolerance, or (if the golden is absent) write it and fail asking for a re-run.
fn assert_matches_golden_vector(embedding: &[f32], name: &str) {
let path = golden_dir().join(name);
match fs::read_to_string(&path) {
Ok(raw) => {
let golden: Vec<f32> = serde_json::from_str(&raw)
.expect("parse golden vector json");
assert_eq!(embedding.len(), golden.len(), "{name}: length mismatch");
for (i, (&got, &want)) in embedding.iter().zip(&golden).enumerate() {
let tol = PARITY_ATOL + PARITY_RTOL * want.abs();
assert!(
(got - want).abs() <= tol,
"{name}: element {i} diverged beyond tolerance \
(got {got}, golden {want}, |Δ|={}) a real regression, \
not cross-arch f32 drift",
(got - want).abs()
);
}
}
Err(_) => {
let json = serde_json::to_string(&embedding).expect("serialize golden");
fs::write(&path, &json).expect("write golden vector");
panic!("no committed golden {name}; wrote it. Re-run to verify parity.");
}
}
}
fn golden_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden")
}
fn load_input() -> Vec<Vec<f32>> {
let raw = fs::read_to_string(golden_dir().join("aether_input.json"))
.expect("read aether_input.json fixture");
let rows: Vec<Vec<f64>> = serde_json::from_str(&raw).expect("parse aether_input.json");
rows.into_iter()
.map(|row| row.into_iter().map(|x| x as f32).collect())
.collect()
}
/// Build the extractor identically to the Python binding's default
/// construction: `AetherConfig()` + `EmbeddingExtractor(n_subcarriers=56, cfg)`.
fn embed_native(input: &[Vec<f32>]) -> Vec<f32> {
let e_config = EmbeddingConfig {
d_model: 64,
d_proj: 128,
temperature: 0.07,
normalize: true,
};
let t_config = TransformerConfig {
n_subcarriers: 56,
n_keypoints: 17,
d_model: 64,
n_heads: 4,
n_gnn_layers: 2,
};
let mut ext = EmbeddingExtractor::new(t_config, e_config);
ext.extract(input)
}
#[test]
fn native_embedding_is_128_dim_unit_norm() {
let emb = embed_native(&load_input());
assert_eq!(emb.len(), 128, "AETHER embedding must be 128-dim");
let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!(
(norm - 1.0).abs() < 1e-4,
"embedding must be L2-normalized, got norm={norm}"
);
}
#[test]
fn native_embedding_matches_committed_golden() {
let emb = embed_native(&load_input());
assert_matches_golden_vector(&emb, "aether_embedding.json");
}
+137
View File
@@ -0,0 +1,137 @@
//! ADR-185 §13.a — weight-loading parity: native-Rust reference half.
//!
//! Proves the AETHER `load_weights` path produces a deterministic, non-random
//! embedding, and compares it to the committed golden VECTOR
//! `tests/golden/aether_loaded_embedding.json` within tolerance. The pytest
//! half (`tests/test_aether.py`) writes a byte-identical weight file (same
//! formula + format) through the binding's `load_weights` and compares to the
//! SAME golden within the same tolerance — native≈golden and binding≈golden
//! prove the binding's weight-loading matches native, portably across arch.
//! (See `aether_parity.rs` for why this is a tolerance compare, not a hash.)
//!
//! Weight formula (shared with the pytest half): `w[i] = k/65536 - 0.5` where
//! `k = (i*1103515245 + 12345) mod 65536`. `k/65536` is a multiple of 2⁻¹⁶,
//! exactly representable in both f32 and f64, so both languages produce
//! byte-identical weights.
//!
//! File format: 8-byte magic `AETHERW1`, `u32` little-endian param count, then
//! that many little-endian `f32`.
//!
//! Regenerate (only on an intentional change): delete the .json golden and
//! re-run `cargo test --features aether --test aether_weights_parity`.
#![cfg(feature = "aether")]
use std::fs;
use std::path::PathBuf;
use wifi_densepose_aether::embedding::{EmbeddingConfig, EmbeddingExtractor};
use wifi_densepose_aether::graph_transformer::TransformerConfig;
fn golden_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden")
}
fn load_input() -> Vec<Vec<f32>> {
let raw = fs::read_to_string(golden_dir().join("aether_input.json"))
.expect("read aether_input.json fixture");
let rows: Vec<Vec<f64>> = serde_json::from_str(&raw).expect("parse aether_input.json");
rows.into_iter()
.map(|row| row.into_iter().map(|x| x as f32).collect())
.collect()
}
/// Same default construction as `aether_parity.rs` / the Python binding default.
fn new_extractor() -> EmbeddingExtractor {
let e_config = EmbeddingConfig {
d_model: 64,
d_proj: 128,
temperature: 0.07,
normalize: true,
};
let t_config = TransformerConfig {
n_subcarriers: 56,
n_keypoints: 17,
d_model: 64,
n_heads: 4,
n_gnn_layers: 2,
};
EmbeddingExtractor::new(t_config, e_config)
}
fn formula_weights(n: usize) -> Vec<f32> {
(0..n)
.map(|i| {
let k = (i as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345) % 65_536;
k as f32 / 65_536.0 - 0.5
})
.collect()
}
fn write_weight_file(path: &PathBuf, weights: &[f32]) {
let mut buf = Vec::with_capacity(12 + weights.len() * 4);
buf.extend_from_slice(b"AETHERW1");
buf.extend_from_slice(&(weights.len() as u32).to_le_bytes());
for v in weights {
buf.extend_from_slice(&v.to_le_bytes());
}
fs::write(path, buf).unwrap();
}
/// Cross-architecture f32 parity tolerance; see `aether_parity.rs` and the
/// matching constants in `tests/test_aether.py` for why this is a tolerance
/// compare and not a byte hash.
const PARITY_ATOL: f32 = 1e-4;
const PARITY_RTOL: f32 = 1e-4;
fn assert_matches_golden_vector(embedding: &[f32], name: &str) {
let path = golden_dir().join(name);
match fs::read_to_string(&path) {
Ok(raw) => {
let golden: Vec<f32> = serde_json::from_str(&raw).expect("parse golden vector json");
assert_eq!(embedding.len(), golden.len(), "{name}: length mismatch");
for (i, (&got, &want)) in embedding.iter().zip(&golden).enumerate() {
let tol = PARITY_ATOL + PARITY_RTOL * want.abs();
assert!(
(got - want).abs() <= tol,
"{name}: element {i} diverged beyond tolerance \
(got {got}, golden {want}, |Δ|={}) real regression, not arch drift",
(got - want).abs()
);
}
}
Err(_) => {
let json = serde_json::to_string(&embedding).expect("serialize golden");
fs::write(&path, &json).expect("write golden vector");
panic!("no committed golden {name}; wrote it. Re-run to verify parity.");
}
}
}
#[test]
fn native_loaded_embedding_matches_committed_golden() {
let input = load_input();
let mut ext = new_extractor();
let baseline = ext.extract(&input); // random Xavier init
let weights = formula_weights(ext.param_count());
let path = std::env::temp_dir().join(format!(
"aether_weights_parity_{}.bin",
std::process::id()
));
write_weight_file(&path, &weights);
ext.load_weights(&path).expect("load_weights");
fs::remove_file(&path).ok();
let loaded = ext.extract(&input);
// Loaded weights must actually take effect.
assert!(
baseline.iter().zip(&loaded).any(|(a, b)| (a - b).abs() > 1e-6),
"load_weights had no effect vs the random-init baseline"
);
assert_eq!(loaded.len(), 128);
assert_matches_golden_vector(&loaded, "aether_loaded_embedding.json");
}
@@ -0,0 +1 @@
[-0.09882868826389313, 0.08015579730272293, 0.019888414070010185, -0.003239249112084508, -0.07265102863311768, -0.08036628365516663, -0.10324385017156601, -0.21274414658546448, 0.1503465175628662, -0.005489329807460308, 0.10834519565105438, 0.05838076397776604, -0.05911992862820625, 0.13135841488838196, -0.006811900530010462, -0.13742603361606598, -0.015311408787965775, -0.21133442223072052, 0.05134191736578941, -0.027355113998055458, -0.044147003442049026, -0.006108833476901054, -0.033326443284749985, 0.15741342306137085, 0.029073286801576614, 0.0375739224255085, 0.023920813575387, 0.07043211907148361, -0.009550352580845356, 0.028179991990327835, 0.05900105461478233, 0.056598298251628876, -0.0047074914909899235, 0.05960564315319061, 0.049969129264354706, 0.017297586426138878, -0.10153798758983612, -0.002574362326413393, 0.06392877548933029, 0.14119082689285278, -0.04484020546078682, -0.038461778312921524, -0.06095990538597107, -0.04703143611550331, 0.07692500203847885, -0.10256559401750565, -0.07250502705574036, 0.12476003915071487, -0.08511383831501007, -0.006181457545608282, 0.09957090020179749, 0.10756388306617737, -0.08597669750452042, -0.09914427250623703, -0.01648416928946972, -0.25724929571151733, -0.024403633549809456, 0.05453304573893547, -0.03141803294420242, -0.07852566242218018, 0.020048094913363457, -0.06215068697929382, 0.11997628211975098, 0.0977955237030983, -0.0652041882276535, -0.007863834500312805, -0.059089504182338715, 0.12663882970809937, 0.16099494695663452, -0.046197254210710526, 0.04186059534549713, -0.07077737897634506, -0.28093546628952026, 0.017284320667386055, 0.1626843810081482, 0.006352100986987352, -0.07779540121555328, -0.004958702251315117, 0.04892482981085777, 0.013853654265403748, 0.08351490646600723, 0.06772761791944504, -0.028758108615875244, 0.04778963327407837, -0.1131502315402031, 0.005114563275128603, -0.012307023629546165, 0.03301718831062317, 0.09168995916843414, -0.04085332900285721, 0.04984535649418831, -0.05280173197388649, -0.01752082072198391, 0.04126245900988579, -0.09206362813711166, 0.08685162663459778, 0.03651193156838417, -0.144779771566391, -0.03290265053510666, 0.15378770232200623, -0.08842422068119049, 0.12492084503173828, 0.04907738417387009, -0.03483045473694801, -0.09838201850652695, 0.04590783640742302, -0.01383654773235321, 0.03766492009162903, -0.03254647180438042, 0.12435581535100937, 0.06573979556560516, 0.055382076650857925, -0.19347889721393585, 0.0018683894304558635, 0.11095203459262848, 0.04290277138352394, -0.07855042070150375, 0.03853689134120941, -0.06062287464737892, 0.004584986716508865, -0.1223185583949089, 0.0031570768915116787, -0.10847429186105728, 0.0023399614728987217, -0.054206088185310364, -0.1367102563381195, -0.14624592661857605, 0.16953621804714203]
+1
View File
@@ -0,0 +1 @@
[[0.0, 0.00390625, 0.0078125, 0.01171875, 0.015625, 0.01953125, 0.0234375, 0.02734375, 0.03125, 0.03515625, 0.0390625, 0.04296875, 0.046875, 0.05078125, 0.0546875, 0.05859375, 0.0625, 0.06640625, 0.0703125, 0.07421875, 0.078125, 0.08203125, 0.0859375, 0.08984375, 0.09375, 0.09765625, 0.1015625, 0.10546875, 0.109375, 0.11328125, 0.1171875, 0.12109375, 0.125, 0.12890625, 0.1328125, 0.13671875, 0.140625, 0.14453125, 0.1484375, 0.15234375, 0.15625, 0.16015625, 0.1640625, 0.16796875, 0.171875, 0.17578125, 0.1796875, 0.18359375, 0.1875, 0.19140625, 0.1953125, 0.19921875, 0.203125, 0.20703125, 0.2109375, 0.21484375], [0.21875, 0.22265625, 0.2265625, 0.23046875, 0.234375, 0.23828125, 0.2421875, 0.24609375, 0.25, 0.25390625, 0.2578125, 0.26171875, 0.265625, 0.26953125, 0.2734375, 0.27734375, 0.28125, 0.28515625, 0.2890625, 0.29296875, 0.296875, 0.30078125, 0.3046875, 0.30859375, 0.3125, 0.31640625, 0.3203125, 0.32421875, 0.328125, 0.33203125, 0.3359375, 0.33984375, 0.34375, 0.34765625, 0.3515625, 0.35546875, 0.359375, 0.36328125, 0.3671875, 0.37109375, 0.375, 0.37890625, 0.3828125, 0.38671875, 0.390625, 0.39453125, 0.3984375, 0.40234375, 0.40625, 0.41015625, 0.4140625, 0.41796875, 0.421875, 0.42578125, 0.4296875, 0.43359375], [0.4375, 0.44140625, 0.4453125, 0.44921875, 0.453125, 0.45703125, 0.4609375, 0.46484375, 0.46875, 0.47265625, 0.4765625, 0.48046875, 0.484375, 0.48828125, 0.4921875, 0.49609375, 0.5, 0.50390625, 0.5078125, 0.51171875, 0.515625, 0.51953125, 0.5234375, 0.52734375, 0.53125, 0.53515625, 0.5390625, 0.54296875, 0.546875, 0.55078125, 0.5546875, 0.55859375, 0.5625, 0.56640625, 0.5703125, 0.57421875, 0.578125, 0.58203125, 0.5859375, 0.58984375, 0.59375, 0.59765625, 0.6015625, 0.60546875, 0.609375, 0.61328125, 0.6171875, 0.62109375, 0.625, 0.62890625, 0.6328125, 0.63671875, 0.640625, 0.64453125, 0.6484375, 0.65234375], [0.65625, 0.66015625, 0.6640625, 0.66796875, 0.671875, 0.67578125, 0.6796875, 0.68359375, 0.6875, 0.69140625, 0.6953125, 0.69921875, 0.703125, 0.70703125, 0.7109375, 0.71484375, 0.71875, 0.72265625, 0.7265625, 0.73046875, 0.734375, 0.73828125, 0.7421875, 0.74609375, 0.75, 0.75390625, 0.7578125, 0.76171875, 0.765625, 0.76953125, 0.7734375, 0.77734375, 0.78125, 0.78515625, 0.7890625, 0.79296875, 0.796875, 0.80078125, 0.8046875, 0.80859375, 0.8125, 0.81640625, 0.8203125, 0.82421875, 0.828125, 0.83203125, 0.8359375, 0.83984375, 0.84375, 0.84765625, 0.8515625, 0.85546875, 0.859375, 0.86328125, 0.8671875, 0.87109375], [0.875, 0.87890625, 0.8828125, 0.88671875, 0.890625, 0.89453125, 0.8984375, 0.90234375, 0.90625, 0.91015625, 0.9140625, 0.91796875, 0.921875, 0.92578125, 0.9296875, 0.93359375, 0.9375, 0.94140625, 0.9453125, 0.94921875, 0.953125, 0.95703125, 0.9609375, 0.96484375, 0.96875, 0.97265625, 0.9765625, 0.98046875, 0.984375, 0.98828125, 0.9921875, 0.99609375, 0.0, 0.00390625, 0.0078125, 0.01171875, 0.015625, 0.01953125, 0.0234375, 0.02734375, 0.03125, 0.03515625, 0.0390625, 0.04296875, 0.046875, 0.05078125, 0.0546875, 0.05859375, 0.0625, 0.06640625, 0.0703125, 0.07421875, 0.078125, 0.08203125, 0.0859375, 0.08984375], [0.09375, 0.09765625, 0.1015625, 0.10546875, 0.109375, 0.11328125, 0.1171875, 0.12109375, 0.125, 0.12890625, 0.1328125, 0.13671875, 0.140625, 0.14453125, 0.1484375, 0.15234375, 0.15625, 0.16015625, 0.1640625, 0.16796875, 0.171875, 0.17578125, 0.1796875, 0.18359375, 0.1875, 0.19140625, 0.1953125, 0.19921875, 0.203125, 0.20703125, 0.2109375, 0.21484375, 0.21875, 0.22265625, 0.2265625, 0.23046875, 0.234375, 0.23828125, 0.2421875, 0.24609375, 0.25, 0.25390625, 0.2578125, 0.26171875, 0.265625, 0.26953125, 0.2734375, 0.27734375, 0.28125, 0.28515625, 0.2890625, 0.29296875, 0.296875, 0.30078125, 0.3046875, 0.30859375], [0.3125, 0.31640625, 0.3203125, 0.32421875, 0.328125, 0.33203125, 0.3359375, 0.33984375, 0.34375, 0.34765625, 0.3515625, 0.35546875, 0.359375, 0.36328125, 0.3671875, 0.37109375, 0.375, 0.37890625, 0.3828125, 0.38671875, 0.390625, 0.39453125, 0.3984375, 0.40234375, 0.40625, 0.41015625, 0.4140625, 0.41796875, 0.421875, 0.42578125, 0.4296875, 0.43359375, 0.4375, 0.44140625, 0.4453125, 0.44921875, 0.453125, 0.45703125, 0.4609375, 0.46484375, 0.46875, 0.47265625, 0.4765625, 0.48046875, 0.484375, 0.48828125, 0.4921875, 0.49609375, 0.5, 0.50390625, 0.5078125, 0.51171875, 0.515625, 0.51953125, 0.5234375, 0.52734375], [0.53125, 0.53515625, 0.5390625, 0.54296875, 0.546875, 0.55078125, 0.5546875, 0.55859375, 0.5625, 0.56640625, 0.5703125, 0.57421875, 0.578125, 0.58203125, 0.5859375, 0.58984375, 0.59375, 0.59765625, 0.6015625, 0.60546875, 0.609375, 0.61328125, 0.6171875, 0.62109375, 0.625, 0.62890625, 0.6328125, 0.63671875, 0.640625, 0.64453125, 0.6484375, 0.65234375, 0.65625, 0.66015625, 0.6640625, 0.66796875, 0.671875, 0.67578125, 0.6796875, 0.68359375, 0.6875, 0.69140625, 0.6953125, 0.69921875, 0.703125, 0.70703125, 0.7109375, 0.71484375, 0.71875, 0.72265625, 0.7265625, 0.73046875, 0.734375, 0.73828125, 0.7421875, 0.74609375]]
@@ -0,0 +1 @@
[0.10761479288339615, 0.05284854769706726, -0.22418244183063507, -0.015137949027121067, -0.03409634903073311, 0.17326673865318298, 0.11726372689008713, -0.07647006958723068, 0.034259773790836334, -0.11302468180656433, 0.05575002729892731, -0.000968325708527118, 0.12398994714021683, 0.010035112500190735, -0.024740785360336304, 0.03396096080541611, -0.13298068940639496, -0.026409678161144257, -0.00981970690190792, 0.1098528727889061, -0.015091875568032265, -0.06820717453956604, -0.08815668523311615, -0.13947811722755432, 0.12845416367053986, 0.007558434270322323, 0.09278517216444016, 0.023929834365844727, -0.15709640085697174, 0.20790696144104004, -0.0814460963010788, 0.035465411841869354, 0.0621788427233696, -0.022502727806568146, 0.1301409900188446, -0.09830718487501144, -0.04854566603899002, -0.060892220586538315, -0.0039014117792248726, 0.08287017792463303, 0.0968087762594223, -0.05596396327018738, -0.18289180099964142, 0.07555137574672699, -0.0711054727435112, 0.017438538372516632, -0.07017677277326584, 0.08828858286142349, 0.09126422554254532, -0.18576686084270477, -0.08681167662143707, 0.006826931145042181, 0.13380175828933716, 0.15818704664707184, -0.03554683178663254, -0.02037874236702919, -0.0721014142036438, 0.09667330235242844, 0.03744731843471527, -0.019951190799474716, 0.05095838010311127, 0.0136749017983675, -0.04109140485525131, -0.09205742925405502, -0.06173047423362732, 0.028595957905054092, 0.07932677119970322, 0.025831375271081924, -0.029791485518217087, -0.047233421355485916, -0.0985548198223114, 0.056721169501543045, 0.04597408324480057, 0.05116378515958786, 0.06485309451818466, -0.11868073791265488, 0.1164744570851326, -0.040522847324609756, -0.12034964561462402, 0.10310209542512894, 0.01842050999403, 0.16855661571025848, -0.05738396197557449, -0.17958901822566986, -0.022476589307188988, 0.03451419249176979, 0.034107644110918045, 0.13773204386234283, -0.07001013308763504, -0.14196854829788208, 0.11647462099790573, -0.03268979489803314, 0.058361802250146866, -0.029253516346216202, 0.1251915991306305, 0.13218750059604645, -0.14484354853630066, -0.1424856185913086, 0.045242637395858765, 0.039408858865499496, 0.199110209941864, 0.0028688418678939342, -0.14990390837192535, -0.031178129836916924, -0.000643149483948946, 0.0783705934882164, 0.02097206376492977, -0.058810342103242874, 0.05459814518690109, -0.00016812187095638365, -0.1195453405380249, -0.06815463304519653, 0.03833199664950371, 0.12025003135204315, 0.06424705684185028, 0.011131756007671356, -0.006310137454420328, -0.06013919785618782, 0.061071064323186874, 0.018219128251075745, 0.08957944065332413, -0.04298160970211029, -0.07775749266147614, -0.01905573531985283, -0.002107167150825262, -0.0794263556599617, -0.005148472264409065, 0.05683618783950806]
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
ae46351e28c01161c3c20f1a3134d8e32fbbef0cda2fdb9c3a27984da0ce026d
+1
View File
@@ -0,0 +1 @@
{"esp32_amplitude": [0.51171875, 0.5390625, 0.56640625, 0.59375, 0.62109375, 0.6484375, 0.67578125, 0.703125, 0.73046875, 0.7578125, 0.78515625, 0.8125, 0.83984375, 0.8671875, 0.89453125, 0.921875, 0.94921875, 0.9765625, 1.00390625, 1.03125, 1.05859375, 1.0859375, 1.11328125, 1.140625, 1.16796875, 1.1953125, 1.22265625, 1.25, 1.27734375, 1.3046875, 1.33203125, 1.359375, 1.38671875, 1.4140625, 1.44140625, 1.46875, 1.49609375, 0.5234375, 0.55078125, 0.578125, 0.60546875, 0.6328125, 0.66015625, 0.6875, 0.71484375, 0.7421875, 0.76953125, 0.796875, 0.82421875, 0.8515625, 0.87890625, 0.90625, 0.93359375, 0.9609375, 0.98828125, 1.015625, 1.04296875, 1.0703125, 1.09765625, 1.125, 1.15234375, 1.1796875, 1.20703125, 1.234375], "esp32_phase": [-0.45703125, -0.4375, -0.41796875, -0.3984375, -0.37890625, -0.359375, -0.33984375, -0.3203125, -0.30078125, -0.28125, -0.26171875, -0.2421875, -0.22265625, -0.203125, -0.18359375, -0.1640625, -0.14453125, -0.125, -0.10546875, -0.0859375, -0.06640625, -0.046875, -0.02734375, -0.0078125, 0.01171875, 0.03125, 0.05078125, 0.0703125, 0.08984375, 0.109375, 0.12890625, 0.1484375, 0.16796875, 0.1875, 0.20703125, 0.2265625, 0.24609375, 0.265625, 0.28515625, 0.3046875, 0.32421875, 0.34375, 0.36328125, 0.3828125, 0.40234375, 0.421875, 0.44140625, 0.4609375, 0.48046875, -0.5, -0.48046875, -0.4609375, -0.44140625, -0.421875, -0.40234375, -0.3828125, -0.36328125, -0.34375, -0.32421875, -0.3046875, -0.28515625, -0.265625, -0.24609375, -0.2265625], "intel_amplitude": [0.50390625, 0.546875, 0.58984375, 0.6328125, 0.67578125, 0.71875, 0.76171875, 0.8046875, 0.84765625, 0.890625, 0.93359375, 0.9765625, 1.01953125, 1.0625, 1.10546875, 1.1484375, 1.19140625, 1.234375, 1.27734375, 1.3203125, 1.36328125, 1.40625, 1.44921875, 1.4921875, 0.53515625, 0.578125, 0.62109375, 0.6640625, 0.70703125, 0.75], "intel_phase": [-0.47265625, -0.4609375, -0.44921875, -0.4375, -0.42578125, -0.4140625, -0.40234375, -0.390625, -0.37890625, -0.3671875, -0.35546875, -0.34375, -0.33203125, -0.3203125, -0.30859375, -0.296875, -0.28515625, -0.2734375, -0.26171875, -0.25, -0.23828125, -0.2265625, -0.21484375, -0.203125, -0.19140625, -0.1796875, -0.16796875, -0.15625, -0.14453125, -0.1328125], "ap_positions": [[0.25, 0.5, 0.75], [1.0, 1.25, 1.5], [2.0, 0.0, -0.5]], "rapid_frames": [[0.0, 0.01953125, 0.0390625, 0.05859375, 0.078125, 0.09765625, 0.1171875, 0.13671875, 0.15625, 0.17578125, 0.1953125, 0.21484375, 0.234375, 0.25390625, 0.2734375, 0.29296875], [0.05078125, 0.0703125, 0.08984375, 0.109375, 0.12890625, 0.1484375, 0.16796875, 0.1875, 0.20703125, 0.2265625, 0.24609375, 0.265625, 0.28515625, 0.3046875, 0.32421875, 0.34375], [0.1015625, 0.12109375, 0.140625, 0.16015625, 0.1796875, 0.19921875, 0.21875, 0.23828125, 0.2578125, 0.27734375, 0.296875, 0.31640625, 0.3359375, 0.35546875, 0.375, 0.39453125], [0.15234375, 0.171875, 0.19140625, 0.2109375, 0.23046875, 0.25, 0.26953125, 0.2890625, 0.30859375, 0.328125, 0.34765625, 0.3671875, 0.38671875, 0.40625, 0.42578125, 0.4453125], [0.203125, 0.22265625, 0.2421875, 0.26171875, 0.28125, 0.30078125, 0.3203125, 0.33984375, 0.359375, 0.37890625, 0.3984375, 0.41796875, 0.4375, 0.45703125, 0.4765625, 0.49609375], [0.25390625, 0.2734375, 0.29296875, 0.3125, 0.33203125, 0.3515625, 0.37109375, 0.390625, 0.41015625, 0.4296875, 0.44921875, 0.46875, 0.48828125, 0.5078125, 0.52734375, 0.546875], [0.3046875, 0.32421875, 0.34375, 0.36328125, 0.3828125, 0.40234375, 0.421875, 0.44140625, 0.4609375, 0.48046875, 0.5, 0.51953125, 0.5390625, 0.55859375, 0.578125, 0.59765625], [0.35546875, 0.375, 0.39453125, 0.4140625, 0.43359375, 0.453125, 0.47265625, 0.4921875, 0.51171875, 0.53125, 0.55078125, 0.5703125, 0.58984375, 0.609375, 0.62890625, 0.6484375], [0.40625, 0.42578125, 0.4453125, 0.46484375, 0.484375, 0.50390625, 0.5234375, 0.54296875, 0.5625, 0.58203125, 0.6015625, 0.62109375, 0.640625, 0.66015625, 0.6796875, 0.69921875], [0.45703125, 0.4765625, 0.49609375, 0.515625, 0.53515625, 0.5546875, 0.57421875, 0.59375, 0.61328125, 0.6328125, 0.65234375, 0.671875, 0.69140625, 0.7109375, 0.73046875, 0.75], [0.5078125, 0.52734375, 0.546875, 0.56640625, 0.5859375, 0.60546875, 0.625, 0.64453125, 0.6640625, 0.68359375, 0.703125, 0.72265625, 0.7421875, 0.76171875, 0.78125, 0.80078125], [0.55859375, 0.578125, 0.59765625, 0.6171875, 0.63671875, 0.65625, 0.67578125, 0.6953125, 0.71484375, 0.734375, 0.75390625, 0.7734375, 0.79296875, 0.8125, 0.83203125, 0.8515625]]}
@@ -0,0 +1 @@
0486402d5a860f459a319cd779ca44a112d8543442ae9ce9eb7b1a01780aee4b
+126
View File
@@ -0,0 +1,126 @@
//! ADR-185 §4.1 — MAT bit-for-bit parity: native-Rust reference half.
//!
//! Drives the canonical `wifi-densepose-mat` `DisasterResponse` pipeline
//! DIRECTLY (no PyO3) over the committed `tests/golden/mat_input.json` CSI
//! stream and locks the SHA-256 of a canonical result string
//! (`count=<K>;triage_priorities=<sorted>`) into
//! `tests/golden/mat_result.sha256`.
//!
//! Only the survivor **count** and **triage classes** are hashed — survivor
//! UUIDs and event timestamps are non-deterministic and deliberately
//! excluded, so the hash captures exactly the "identical triage +
//! survivor count for a fixed CSI stream" invariant of ADR-185 §4.1.
//!
//! Honesty note: the fixture is a synthetic breathing-modulated stream, so
//! this proves the Python binding drives the real pipeline byte-identically
//! to native Rust — it is NOT a detection-accuracy claim on real rubble.
//!
//! Regenerate (only on an intentional Rust change): delete the .sha256 and
//! re-run `cargo test --features mat --test mat_parity`.
#![cfg(feature = "mat")]
use std::fs;
use std::path::PathBuf;
use serde_json::Value;
use sha2::{Digest, Sha256};
use wifi_densepose_mat::{DisasterConfig, DisasterResponse, DisasterType, ScanZone, ZoneBounds};
fn golden_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden")
}
fn fixture() -> Value {
let raw = fs::read_to_string(golden_dir().join("mat_input.json"))
.expect("read mat_input.json fixture");
serde_json::from_str(&raw).expect("parse mat_input.json")
}
/// Build the response identically to the Python binding's default
/// construction and run one scan over the fixture CSI stream. Returns the
/// canonical `count=<K>;triage_priorities=<sorted>` string.
fn mat_canonical_result(fx: &Value) -> String {
let config = DisasterConfig::builder()
.disaster_type(DisasterType::Earthquake)
.sensitivity(0.9)
.confidence_threshold(0.1)
.max_depth(5.0)
.continuous_monitoring(false)
.build();
let mut resp = DisasterResponse::new(config);
resp.initialize_event(geo::Point::new(0.0, 0.0), "parity-fixture")
.expect("initialize_event");
resp.add_zone(ScanZone::new(
"Zone A",
ZoneBounds::rectangle(0.0, 0.0, 50.0, 30.0),
))
.expect("add_zone");
for frame in fx["stream"].as_array().unwrap() {
let amp: Vec<f64> = frame["amplitude"]
.as_array()
.unwrap()
.iter()
.map(|x| x.as_f64().unwrap())
.collect();
let ph: Vec<f64> = frame["phase"]
.as_array()
.unwrap()
.iter()
.map(|x| x.as_f64().unwrap())
.collect();
resp.push_csi_data(&amp, &ph).expect("push_csi_data");
}
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
rt.block_on(resp.start_scanning()).expect("scan");
let survivors = resp.survivors();
let mut priorities: Vec<u8> = survivors
.iter()
.map(|s| s.triage_status().priority())
.collect();
priorities.sort_unstable();
format!("count={};triage_priorities={:?}", survivors.len(), priorities)
}
fn sha256_hex(s: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(s.as_bytes());
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[test]
fn native_mat_result_is_deterministic() {
let fx = fixture();
// Two independent runs must agree (survivor count + triage classes are
// deterministic; UUIDs/timestamps are excluded from the canonical form).
assert_eq!(mat_canonical_result(&fx), mat_canonical_result(&fx));
}
#[test]
fn native_mat_matches_committed_golden() {
let canon = mat_canonical_result(&fixture());
let got = sha256_hex(&canon);
let path = golden_dir().join("mat_result.sha256");
match fs::read_to_string(&path) {
Ok(expected) => assert_eq!(
got,
expected.trim(),
"native MAT result drifted from committed golden (canonical form: {canon})"
),
Err(_) => {
fs::write(&path, &got).expect("write golden sha256");
panic!("no committed golden found; wrote {got} for [{canon}]. Re-run to verify.");
}
}
}
+178
View File
@@ -0,0 +1,178 @@
//! ADR-185 §4.1 — MERIDIAN bit-for-bit parity: native-Rust reference half.
//!
//! Calls the canonical `wifi-densepose-signal::hardware_norm` +
//! `wifi-densepose-train::{geometry,rapid_adapt}` code DIRECTLY (no PyO3)
//! on the committed `tests/golden/meridian_input.json` fixture and locks
//! the SHA-256 of the concatenated f32 outputs into
//! `tests/golden/meridian_output.sha256`.
//!
//! Concatenation order (identical in the pytest half, tests/test_meridian.py):
//! 1. esp32 canonical amplitude (56) 2. esp32 canonical phase (56)
//! 3. intel5300 canonical amplitude 4. intel5300 canonical phase
//! 5. geometry.encode(ap_positions) 6. rapid_adapt lora_weights
//!
//! Regenerate (only on an intentional Rust change): delete the .sha256 and
//! re-run `cargo test --features meridian --test meridian_parity`.
#![cfg(feature = "meridian")]
use std::fs;
use std::path::PathBuf;
use serde_json::Value;
use sha2::{Digest, Sha256};
use wifi_densepose_signal::hardware_norm::{HardwareNormalizer, HardwareType};
use wifi_densepose_train::geometry::{GeometryEncoder, MeridianGeometryConfig};
use wifi_densepose_train::rapid_adapt::{AdaptationLoss, RapidAdaptation};
fn golden_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden")
}
fn fixture() -> Value {
let raw = fs::read_to_string(golden_dir().join("meridian_input.json"))
.expect("read meridian_input.json fixture");
serde_json::from_str(&raw).expect("parse meridian_input.json")
}
fn f64_vec(v: &Value, key: &str) -> Vec<f64> {
v[key]
.as_array()
.unwrap()
.iter()
.map(|x| x.as_f64().unwrap())
.collect()
}
fn f32_frames(v: &Value, key: &str) -> Vec<Vec<f32>> {
v[key]
.as_array()
.unwrap()
.iter()
.map(|row| {
row.as_array()
.unwrap()
.iter()
.map(|x| x.as_f64().unwrap() as f32)
.collect()
})
.collect()
}
/// Compute the full concatenated MERIDIAN output vector, mirroring the
/// Python binding's default construction exactly.
fn meridian_output(fx: &Value) -> Vec<f32> {
let mut out: Vec<f32> = Vec::new();
// 14: hardware normalization (default normalizer, canonical 56).
let norm = HardwareNormalizer::new();
let esp = norm
.normalize(
&f64_vec(fx, "esp32_amplitude"),
&f64_vec(fx, "esp32_phase"),
HardwareType::Esp32S3,
)
.unwrap();
out.extend_from_slice(&esp.amplitude);
out.extend_from_slice(&esp.phase);
let intel = norm
.normalize(
&f64_vec(fx, "intel_amplitude"),
&f64_vec(fx, "intel_phase"),
HardwareType::Intel5300,
)
.unwrap();
out.extend_from_slice(&intel.amplitude);
out.extend_from_slice(&intel.phase);
// 5: geometry encoding (default config → 64-dim).
let enc = GeometryEncoder::new(&MeridianGeometryConfig::default());
let aps: Vec<[f32; 3]> = fx["ap_positions"]
.as_array()
.unwrap()
.iter()
.map(|p| {
let a = p.as_array().unwrap();
[
a[0].as_f64().unwrap() as f32,
a[1].as_f64().unwrap() as f32,
a[2].as_f64().unwrap() as f32,
]
})
.collect();
out.extend_from_slice(&enc.encode(&aps));
// 6: rapid adaptation lora weights (Combined, epochs 5, lr 1e-3, λ 0.5).
let mut ra = RapidAdaptation::new(
10,
4,
AdaptationLoss::Combined {
epochs: 5,
lr: 0.001,
lambda_ent: 0.5,
},
);
for frame in f32_frames(fx, "rapid_frames") {
ra.push_frame(&frame);
}
out.extend_from_slice(&ra.adapt().unwrap().lora_weights);
out
}
fn sha256_le(vals: &[f32]) -> String {
let mut hasher = Sha256::new();
for &x in vals {
hasher.update(x.to_le_bytes());
}
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[test]
fn native_canonical_frames_are_56_wide() {
let fx = fixture();
let norm = HardwareNormalizer::new();
let esp = norm
.normalize(
&f64_vec(&fx, "esp32_amplitude"),
&f64_vec(&fx, "esp32_phase"),
HardwareType::Esp32S3,
)
.unwrap();
assert_eq!(esp.amplitude.len(), 56);
assert_eq!(esp.phase.len(), 56);
let intel = norm
.normalize(
&f64_vec(&fx, "intel_amplitude"),
&f64_vec(&fx, "intel_phase"),
HardwareType::Intel5300,
)
.unwrap();
assert_eq!(intel.amplitude.len(), 56);
// 64-dim geometry vector.
let enc = GeometryEncoder::new(&MeridianGeometryConfig::default());
assert_eq!(enc.encode(&[[0.25, 0.5, 0.75]]).len(), 64);
}
#[test]
fn native_meridian_matches_committed_golden() {
let got = sha256_le(&meridian_output(&fixture()));
let path = golden_dir().join("meridian_output.sha256");
match fs::read_to_string(&path) {
Ok(expected) => assert_eq!(
got,
expected.trim(),
"native MERIDIAN hash drifted from committed golden \
(intentional? delete the .sha256 and regenerate)"
),
Err(_) => {
fs::write(&path, &got).expect("write golden sha256");
panic!("no committed golden found; wrote {got}. Re-run to verify parity.");
}
}
}

Some files were not shown because too many files have changed in this diff Show More