Compare commits

...

142 Commits

Author SHA1 Message Date
ruv e9dc0c726d fix release publishing pipelines 2026-07-24 08:56:42 -07:00
Dragan Spiridonov 99700c7851 Merge #1397: Cognitum OAuth resource server, sign-in, and WebSocket auth (ADR-271/272)
Cognitum OAuth for RuView: resource server, sign-in, and WebSocket authentication (ADR-271/272)
2026-07-24 16:01:06 +02:00
Dragan Spiridonov 89babb00a9 Merge remote-tracking branch 'origin/main' into feat/ruview-auth-cognitum-oauth-verifier
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-07-24 15:26:15 +02:00
Dragan Spiridonov 544b746895 Merge #1387: Python components to SOTA (ADR-184..187) + QE hardening
Python components to SOTA: ADR-184..187 (pip publish, P6 bindings, training API, archive/v1 deprecation)
2026-07-24 15:10:53 +02:00
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
Dragan Spiridonov 56327d0931 decide: browser sessions are read-only permanently; drop the dead step-up client
Decision: browser-side admin is not wanted. `BROWSER_SIGNIN_SCOPE` stays
`sensing:read`, and the escalate-on-demand design sketched while this was open
is not being built. Destructive operations — training, model delete, recording
delete — keep their home in the CLI, where `--admin` is explicit and typed by a
person. Routing them through a browser would mean either asking every user to
consent to delete capability in order to watch a stream, or building a second
consent flow to avoid that.

Consequences, now settled rather than open:

- The UI's admin controls are unreachable from a Cognitum browser session, and
  that is intended. The manual token-paste field is unchanged and still carries
  whatever authority the pasted token has, so nothing that worked before stops
  working.

- REMOVED the client-side step-up redirect from ui/services/api.service.js. It
  caught an RFC 6750 challenge that can never be issued to a browser, and it
  ended in `return new Promise(() => {})` — so if any other 401 had ever grown
  that header, every caller awaiting it would have hung forever with no error
  and no timeout. Dead code with a trap in it is worse than no code.

- KEPT ADMIN_REVERIFY_SECS as a server-side backstop. Fail-closed and free, so
  if the requested scope is ever widened the freshness requirement is already
  in place. Documented at its definition as a backstop specifically so nobody
  reads its passing tests as evidence the control is exercised — the tests
  reach it through a crate-internal seam that mints an admin cookie the real
  flow does not produce.

ADR-271 also stops hedging on the session TTL: chosen is A at one hour. Option B
(server-side refresh-token store) is not built, and the ADR now names the
residual instead of implying it is closed — within one hour a revoked Cognitum
grant still reads sensing data through an existing browser session. There is no
introspection endpoint, so nothing short of B closes that, and one hour is the
size of the hole we accepted.

Adds `the_cookie_max_age_matches_the_session_expiry`, which the previous ADR
revision asked for and nobody had written: Max-Age and the payload's `exp` are
two independent expressions of one lifetime, and drift means either the browser
presents a session we reject or we hold authority the browser discarded.

Verified: workspace 176 suites clean, UI 22, api.service.js parses.

Co-Authored-By: Ruflo & AQE
2026-07-23 14:47:31 +02:00
Dragan Spiridonov 1ed0bc57ef docs(adr): correct a false claim about browser scope; pin the decision in code
A cross-vendor pre-merge sweep found no merge blockers, but did surface an
error in ADR-271 that I wrote — and a coherence gap behind it.

ADR-271 P2 stated that capping the browser session at `sensing:read` was
"considered and rejected, because the dashboard genuinely performs admin
operations". That is wrong. `/oauth/start` (main.rs:9206) already requests
`SENSING_READ` and nothing else, deliberately, with a comment saying so. The
browser session is ALREADY read-only, so the breakage I claimed capping would
cause is simply the current behaviour.

Two consequences, now stated in the ADR instead of left to be discovered:

1. The UI's admin controls do not work from a browser OAuth session.
   `model.service.js:136` issues DELETE /api/v1/models/{id}, which 401s. Admin
   work needs the CLI (`login --admin`) or a pasted admin bearer. A gap in a new
   feature, not a regression — the token-paste path is unchanged.

2. The ADMIN_REVERIFY_SECS step-up control added in the previous commit guards
   a case that cannot currently arise. No browser session holds `sensing:admin`,
   so the freshness branch never fires in production. Its tests pass because the
   crate-internal seam mints an admin cookie the real flow never produces.

That second point is worth being blunt about: it is the same shape as several
defects this branch already fixed — correct code, green tests, unreachable call
site. The difference is that here the guard is deliberately ahead of the need
rather than mistakenly behind it, and saying which one it is matters.

So the constant is now named `BROWSER_SIGNIN_SCOPE` rather than inlined, with
the cost of widening it documented at the definition, and two tests:
`browser_sign_in_stays_read_only_until_someone_decides_otherwise` pins the
value, and `the_authorize_url_actually_carries_that_scope` proves it reaches the
wire — asserting on the constant alone would pass even if `begin` were called
with something else, which is exactly the isolation failure being guarded
against.

The ADR also records the coherent way to add browser-side admin if wanted:
escalate-on-demand via the RFC 6750 challenge, keeping least privilege by
default rather than asking every user to consent to delete capability to watch
a stream. Not bundled here — it needs a scope parameter and a UI affordance.

Sweep verdict: no merge blockers. Two other non-blocking risks it raised are
accurate and unchanged: concurrent JWKS refresh at the stale boundary is not
atomic (a duplicated idempotent GET, already documented as an accepted cost),
and the service worker's SHELL_ASSETS use root-relative paths while the UI
mounts under /ui, so offline shell precaching is incomplete — pre-existing,
unrelated to this branch.

Verified: workspace 176 suites clean.

Co-Authored-By: Ruflo & AQE
2026-07-23 14:31:01 +02:00
Dragan Spiridonov f7cc68bd5c fix(auth): check scope before step-up, so read-only callers are not sent in a circle
Found by an empirical route sweep during the pre-merge pass, not by a test.

The step-up gate fired BEFORE the scope check, so a caller holding only
`sensing:read` who attempted a privileged action with a session older than
ADMIN_REVERIFY_SECS received the RFC 6750 "reauthentication required"
challenge. `api.service.js` acts on that by redirecting through /oauth/start —
and the user returns with exactly the same scopes and is refused again. One
wasted round trip, and a reason for the refusal that was simply untrue: they
were not refused for staleness, they were refused for capability.

Now the scope check comes first, so only a caller who actually HOLDS
`sensing:admin` is ever asked to prove the session is fresh. Not a loop before
(the second refusal is a plain 401 with no challenge) and not a security issue
either way — both paths refuse. It was misleading, and it cost a redirect.

Also confirms the deny-by-default gate does not over-reach. Probed the real
binary with auth off vs on:

  /, /ui/*, /health, /health/{live,ready,metrics,version},
  /oauth/{status,start}    unchanged
  /api/field, /api/v1/*    200 -> 401   (intended)
  /metrics, /favicon.ico   404 -> 401   (never existed; now uniform)

Nothing that returned 2xx before returns 401 now, which is the property that
matters for existing deployments.

Tests: +2. `a_read_only_user_is_not_sent_to_reauthenticate_pointlessly` fails
against the old ordering with the challenge header present; its counterpart
`an_admin_holder_with_a_stale_session_does_get_the_challenge` ensures the fix
does not simply disable the signal.

Verified: workspace 176 suites clean, ruview-auth 62+25+2 --all-features.

Co-Authored-By: Ruflo & AQE
2026-07-23 14:03:54 +02:00
Dragan Spiridonov 89cceaf835 fix(auth): close P1/P2/P3 — JWKS stall, 12h session, cookie shadowing
All three deferred findings from the qe-court round. Each fix is guarded by a
test confirmed to FAIL against the old behaviour.

P1 — JWKS: self-inflicted stall, and a blocking fetch on a tokio worker.

`fetched_at` advances only on SUCCESS, and the only rate limiter sat behind
`if fresh`. So once the TTL elapsed after the last successful fetch, `fresh`
was permanently false, the limiter was never consulted, and EVERY request
performed its own blocking 3s-timeout fetch. A Pi that loses WAN stalled
itself 300s later with no attacker present; an attacker could force the same
state by flooding tokens with an unknown `kid`.

Now: `last_attempt_at` is recorded BEFORE every fetch regardless of outcome,
and gates the stale path too; a stale-but-present key is served rather than
erroring, which is the offline tolerance this module always claimed.
Measured by the new test: 26 outbound fetches before, 1 after.

Kept as TWO independent limiters. Merging them looks tidy and is wrong — a
routine refetch would then suppress the unknown-`kid` path for 30s and delay
pickup of a key rotation inside the TTL. I made that mistake first; two
existing tests caught it.

The blocking call also now runs in `spawn_blocking` at the verify boundary,
matching what `main.rs` already does for the token exchange, where the comment
reads "the same mistake this codebase had to fix in jwks.rs". The hot
verification path had never been given the same treatment. A panicked task
fails closed.

P2 — session lifetime, per decision: 1 hour, plus step-up.

SESSION_TTL_SECS 12h -> 1h, and privileged (`sensing:admin`) actions now
require the user to have authenticated within ADMIN_REVERIFY_SECS (5 min),
tracked by a new `auth_time` claim. Reads ride the full session; only the
routes where a stale session does damage are re-verified, so a dashboard whose
main use is watching a live stream does not re-auth hourly.

`auth_time` is `#[serde(default)]`, so a cookie issued before the field existed
reads as 0 — infinitely stale. Such a session keeps working for reads and
cannot perform privileged actions. Fail-closed and self-healing on next sign-in.

The refusal carries an RFC 6750 `WWW-Authenticate` error code, because the
client's correct response differs from a plain 401: the user IS signed in and
needs to prove it again. `api.service.js` acts on that and redirects through
`/oauth/start` — otherwise a stale-session delete surfaces as a generic
"Request failed" with no hint that signing in again fixes it.

P3 — cookie shadowing.

`read_cookie` returned the FIRST match, and RFC 6265 §5.4 sends longer-`Path`
cookies first. Cookies are not isolated by port or scheme, so any other service
on the host — or a plain-HTTP MITM injecting Set-Cookie — could plant
`ruview_session=<their own validly signed session>; Path=/ui`. The victim sent
both, the attacker's first, and it verified because it genuinely was signed:
silent session takeover, with `/oauth/status` reporting the attacker's account.

The signature was doing its job throughout, which is why "it's signed" never
answered this. `__Host-` would, but requires `Secure`, and RuView is routinely
reached over plain HTTP on a LAN.

So both credential paths now accept only when EXACTLY ONE candidate verifies.
An attacker can still cause a refusal by planting a second valid cookie — a
nuisance — but no longer a takeover. Planting junk changes nothing, so this
does not become a trivial DoS.

Tests: +1 jwks (26-vs-1 fetch amplification), +4 step-up, +4 shadowing, +1
duplicate-name reader. Mutation-verified: reverting the stale-path guard gives
26 fetches; reverting to first-match cookie reads fails
`a_shadowing_cookie_cannot_silently_take_over_the_session`.

Verified: workspace 176 suites clean under CI flags, ruview-auth 62+25+2 with
--all-features, UI 22.

ADR-271: P1/P2/P3 marked RESOLVED with the analysis retained, since it explains
why each fix has the shape it does.

Co-Authored-By: Ruflo & AQE
2026-07-23 13:13:42 +02:00
Dragan Spiridonov 6ce50d5158 test(auth): cover browser sign-in; ADR-271/272 corrections and remediation plans
Browser sign-in was the newest security surface in this PR and had no
executable evidence behind it: browser_session.rs was 534 lines with 13 tests,
every one of which hit a private helper (sign, unsign, cookie, read_cookie,
is_live, has_scope). No test called issue, from_cookie_header, begin,
verifier_for_callback or is_configured, and no test anywhere presented a session
cookie to the gate.

+10 tests in browser_session, +6 in bearer_auth. Three mutants the adversarial
review named, each now verified dead by actually applying the mutation:

  (a) delete the `state` comparison in verifier_for_callback
      -> a_callback_whose_state_does_not_match_is_refused FAILED
      Without it the callback accepts a code from a flow the user never
      started: login CSRF, victim silently lands in the attacker's session.

  (b) `session.is_live().then_some(session)` -> `Some(session)`
      -> an_expired_session_cookie_does_not_authenticate FAILED
      -> an_expired_browser_session_is_refused FAILED
      `is_live` was already unit-tested; nothing asserted the CALLER consults
      it. Same "tested in isolation, call site untested" shape as the earlier
      refresh-never-invoked defect.

  (c) `session.has_scope(required)` -> `true`
      -> a_read_scoped_browser_session_cannot_delete_or_train FAILED
      Without it any browser session could delete models and start training.

Mutant (c) initially appeared to SURVIVE. It did not — there are two
has_scope call sites and the first substitution only hit one. Mutating the
one in `session_or_unauthorized` kills the test. That accident confirmed a
separate finding: the cookie branch inside require_bearer is unreachable when
an Authorization header is present, because the OAuth step returns on both
arms. It fails closed, so it is not a hole, but "try the next credential" is
what the code reads like. Pinned by
a_bad_bearer_beats_a_good_cookie_rather_than_falling_back.

Adds two crate-internal test seams (init_secret_for_tests, test_cookie_value).
test_cookie_value signs through the same path as `issue`, so tests presenting a
cookie exercise real verification rather than a test-only bypass.

ADR-271:
- The "browser cannot obtain an OAuth token" section asserted
  `grep -ril "oauth|cognitum|pkce" ui/` returns nothing. It now returns three
  files, invalidated by commits in this same PR. Marked superseded, original
  retained under a fold, replaced with what actually ships.
- Records the two deferred decisions with designs rather than patches: P1 the
  blocking JWKS fetch on a tokio worker (whose rate limiter is bypassed on
  exactly the stale path that matters, because fetched_at updates only on
  success — so after the TTL every request fetches, and a Pi that loses WAN
  stalls itself with no attacker present); P2 the 12-hour session from a
  15-minute token, with three costed options. Capping the session to
  sensing:read was considered and rejected: the dashboard genuinely issues
  DELETE /api/v1/models/{id}.
- P3 records that dropping `__Host-` costs origin-integrity, not just Secure —
  read_cookie takes the FIRST match and cookies are not port-scoped, so a
  same-host writer can shadow a session. Forgery was never the threat that
  prefix addresses.
- Documents redirect_uri's hardcoded default and the unconsumed CLI credential
  as known-incomplete, per decision to leave both as-is.

ADR-272: corrected a claim that would mislead users into a 401. It stated the
Python client DOES send Authorization: Bearer on the handshake; ws.py passes no
headers at all (zero occurrences of extra_headers or Authorization), so every
published client 401s once auth is enabled. Server-side decision unchanged.

Verified: sensing-server 566 + 179 + 7 + 5 + 8 + 4 + 16 pass under CI flags,
ruview-auth 61 + 25 + 2 with --all-features, auth_wiring 7.

Co-Authored-By: Ruflo & AQE
2026-07-23 12:08:13 +02:00
Dragan Spiridonov c72bbc15dd fix(auth): close /api/field bypass, two fail-opens, and a self-disarming test
Findings from a qe-court adversarial round (4 prosecutors across 2 vendors).
Each was verified against the code before being accepted; the ones below
reproduced, the rest are reported in the PR thread rather than acted on.

FATAL — `/api/field` was reachable with no credential, on both listeners.
The gate protected `/api/v1/*` by prefix. `/api/field` is the REST sibling of
`/ws/field` and serves the same signed FieldEvent stream — live presence, pose,
vitals. `/ws/field` was gated in this PR; its twin one path segment over was
not. Measured with RUVIEW_API_TOKEN set and no credential supplied:

    /api/v1/models  401      (control)
    /ws/field       401      (gated by this PR)
    /api/field      200      on :8080 AND :8765

Fixed by inverting the gate to deny-by-default with an explicit anonymous
allowlist (`/`, `/ui`, `/health`, `/oauth/`). A route added at a new path is
now gated because nobody exposed it, rather than exposed because nobody
protected it — the same inversion already applied to the scope gate.

FATAL — the wiring test disarmed itself exactly when it mattered.
`Server::start` returned an Option that all five tests turned into `return`,
so a server that failed to boot produced "5 passed" with zero assertions run,
and cargo swallows the skip line without --nocapture. The one test that
observes real wiring — the guard against both shipped bypasses — was silent
for any change that breaks startup, including a boot panic in the auth path.
It now panics with the child's stderr.

MAJOR — a malformed client-id list silently disabled the audience check.
An empty allowlist is the opt-out sentinel in verify.rs. `RUVIEW_OAUTH_CLIENT_IDS=","`
is non-empty, passes the guard, then filters to an empty Vec — turning the
audience boundary off with no log and admitting a token minted for any other
Cognitum product. Only a literal `*` may opt out now; anything else that parses
to nothing warns and falls back to the default. Same fail-open shape as the
scope denylist this PR already had to invert.

MAJOR — credentials were world-readable for a window on every refresh.
`fs::write` creates at 0666 & !umask (0644 by default), and both writers
chmodded afterwards. The existing permissions test asserted on the FINAL file
and passed throughout. Affected the CLI refresh token (rotated with reuse
detection — a thief who presents it first takes the session family) and the
browser session secret (the HMAC key for every session; stealing it forges any
account at any scope). Both now create with mode 0600 via OpenOptions.

MAJOR — ui/sw.js cached authenticated API responses.
Closing the /oauth/ leg left the /api/ leg open. `networkFirst` cached every
successful response, keyed by URL alone, purged by nothing at sign-out: sign in
as A, load sensing data, sign out, sign in as B, lose the network, and B is
served A's data with no authorization check. API responses are now network-only
— which is also the correct behaviour for a live sensing dashboard, where
replaying a stale reading can show a room occupied after the person left — plus
a cache purge on sign-out.

CI — 40 of ruview-auth's 87 tests never ran.
The workspace runs --no-default-features, which switches off the `login` and
`pkce` features. Measured: 47 tests vs 87. The whole interactive sign-in path —
credential storage, single-flight refresh, the file lock, the loopback callback
— was green locally and never executed in CI. Added an --all-features step.
(Checked the sensing-server for the same problem and did NOT find it:
bearer_auth's 49 and browser_session's 15 do run under CI flags.)

Tests: +2 wiring tests (one fails against the old gate with
"http port served /api/field to an anonymous caller", passes after), +3 UI
service-worker tests, +3 CLI scope tests, +1 temp-file permission test, +1
client-id parsing test. wifi-densepose-cli/src/auth.rs had zero tests and
builds its own scope string, so the library's least-privilege test said nothing
about what the CLI requests.

Verified: ruview-auth 61+25+2 pass (--all-features), sensing-server bearer_auth
50, browser_session 15, auth_wiring 7, workspace 25 suites clean, UI 22.

Co-Authored-By: Ruflo & AQE
2026-07-23 11:48:59 +02:00
Dragan Spiridonov 9b9754778f fix(ui): service worker cached /oauth/status, freezing browser sign-in
Browser sign-in worked, but the settings panel kept offering "Sign in with
Cognitum" afterwards; only a hard reload showed the true state. The server was
correct throughout.

Root cause: `ui/sw.js` routed cache-first as its CATCH-ALL for every path
outside `/api/` and `/health/`. `/oauth/status` therefore had its first
(signed-out) response stored in the Cache API and replayed to the page forever.
The Cache API is not the HTTP cache and ignores `Cache-Control` entirely, so the
`no-store, no-cache, must-revalidate` the server already sends could not prevent
it. A hard reload bypasses the service worker, which is why that alone appeared
to fix it, and why signing out re-poisoned the entry.

Fixes, in order of blast radius:

- `/oauth/` is never handled by the worker. Not network-first — that still
  writes a copy, which would be replayed the moment the server is briefly
  unreachable, silently reinstating a stale sign-in state.
- Cache-first is now an ALLOWLIST (navigations and static asset extensions)
  rather than the catch-all. This is the underlying defect: any endpoint added
  outside `/api/` was frozen on its first response. Unrecognised paths now go
  to the network untouched.
- `CACHE_NAME` bumped to `ruview-v2`, so `activate` evicts the poisoned
  `ruview-v1` from browsers that already ran the old worker. Verified: the
  cache list went from `[ruview-v1]` to `[ruview-v2]` on update.
- Shell lookups use `ignoreSearch` and store a search-less key, so the
  `?signed_in=<ms>` the callback redirects to does not mint a fresh, never-hit
  cache entry per sign-in.

Also: re-check sign-in state on `pageshow` (bfcache restore, where no script
re-runs) and on `visibilitychange` (signed in or out in another tab). Opening
the panel alone is not sufficient — it may already be open.

Verification, in a real browser driven end-to-end:
- Reproduced with a valid session cookie: server returned `signed_in: true` to
  curl while the page's own fetch got a cached `signed_in: false`, and the
  request never appeared in the server log at all.
- Confirmed the cached entry existed: `caches.open('ruview-v1').match(
  '/oauth/status')` returned the signed-out body.
- After the fix, on a NORMAL (not hard) reload the panel reads
  "Signed in as <account> - sensing:read" with Sign out shown.

Tests: `ui/sw.test.mjs` loads the real `sw.js` with stubbed worker globals and
asserts the routing decision per path. 4 of its 10 tests fail against the
pre-fix worker and pass after; the other 6 pin pre-existing guards (non-GET,
websocket upgrade, cross-origin, API paths, static assets, navigation) and pass
in both, so they track behaviour rather than the rewrite.

CI: adds a `ui-tests` job. Nothing ran the UI JavaScript before, so both this
suite and the ADR-272 ws-ticket suite would have rotted unexecuted — which is
the same blind spot that let this defect ship.

Removes the temporary `/oauth/status` cookie logging and the panel console
diagnostic added while tracking this down.

Co-Authored-By: Ruflo & AQE
2026-07-23 11:20:13 +02:00
Dragan Spiridonov 43737941cb fix(auth): two Set-Cookie headers were collapsing into one; clear the spent transaction
Two bugs, one of which I introduced while fixing the other and caught only by
checking the actual response.

1. THE SPENT TRANSACTION COOKIE WAS NEVER CLEARED ON SUCCESS.
   `clear_transaction` was only used on error paths, so after a successful
   sign-in `ruview_oauth_txn` lingered for its full 10-minute TTL and every
   subsequent request carried a dead cookie. Visible in the logs as
   `names=ruview_oauth_txn,ruview_session`. Now cleared alongside issuing the
   session, and on logout too.

2. THE FIX FOR (1) BROKE SIGN-IN, AND THE TEST CAUGHT IT.
   Axum's array-of-tuples response form REPLACES same-name headers rather than
   appending. Adding a second `Set-Cookie` silently overwrote the first, so the
   logout response emitted only the transaction clear and — had this shipped —
   the CALLBACK would have emitted only the transaction clear too, dropping the
   session cookie and making a successful OAuth round-trip a no-op.

   Caught by looking at the real response headers rather than trusting the
   change: `curl -D-` showed one Set-Cookie where there should have been two.

   Now uses `axum::response::AppendHeaders`. Both cookies verified present.

   Two tests pin this: one DOCUMENTS the footgun (the array form collapses two
   Set-Cookie headers into one) and one asserts AppendHeaders emits both. The
   first exists so the next person to reach for the tidier-looking array form
   finds out here instead of in production.

Also adds a cache-busting query to both redirect targets
(`/ui/?signed_in=<ms>` and `?signed_out=<ms>`). Landing on the same URL let the
browser restore the page from the back/forward cache with a stale panel, which
is why signing in appeared not to work until a hard refresh — the session was
established correctly every time, the page simply was not re-fetched.

Tests: 549 sensing-server lib.

Co-Authored-By: Ruflo & AQE
2026-07-23 10:46:43 +02:00
Dragan Spiridonov 4a704acc02 fix(ui): actually call refreshSignInPanel — VERIFIED IN A REAL BROWSER
`refreshSignInPanel` was exported and never invoked. The Cognitum Account panel
would have rendered "Checking…" forever and the Sign in button would never have
reflected a completed sign-in.

That is the FOURTH defect of this exact shape today — implemented, unit-tested,
and never called. The others: the refresh path no command invoked, scope
resolved only at write time, and a WebSocket listener with no auth layer. The
pattern is consistent: the logic was fine, the caller was missing, and a green
suite could not see it.

Now refreshed on every panel open — not once at construction — because the
session may have been established in another tab or expired since page load.
Buttons are also bound at construction so a click works before the first status
fetch resolves.

VERIFIED END TO END IN A REAL BROWSER (Chrome, http://127.0.0.1:8099/ui/), which
is the gap that has been open all session:

  1. panel showed "This server requires sign-in." + Sign in with Cognitum
  2. clicked -> auth.cognitum.one -> approved
  3. server: "browser sign-in complete" sub=ed3efb51-…
  4. DevTools: ruview_session cookie on 127.0.0.1, Path=/, HttpOnly, SameSite=Lax
  5. after reload the panel reads:
       "Signed in as UyShaIh1B1gL7km9Xli9kCDSdRT2 — sensing:read"  + Sign out

So the browser half of gate G-3 is now proven, not argued: PKCE, consent, the
server-side code exchange, ES256 verification with audience and scope checks,
the signed session cookie, and the UI reading it back.

Diagnosis note worth keeping: the first attempt appeared to fail because the
browser tab had been open since BEFORE the fix — an already-loaded ES module
stays in memory regardless of `Cache-Control: no-store`. The cookie had been
stored correctly the whole time; the panel simply never re-queried. The server
log showed zero /oauth/status probes, which is what distinguished "fetch never
fired" from "fetch got the wrong answer". Temporary cookie diagnostics added to
find that are removed here.

Tests: 547 sensing-server lib, unchanged.

Co-Authored-By: Ruflo & AQE
2026-07-23 10:41:11 +02:00
Dragan Spiridonov 9299a3b137 feat(ui): Sign in with Cognitum, zero-config session secret, executed JS tests
Three loose ends from the browser sign-in work.

--- 1. The last mile: a button ---

The server endpoints existed but nothing in ui/ linked to them, so the feature
was unreachable. QuickSettings gains a "Cognitum Account" panel that renders
from `GET /oauth/status` and offers Sign in / Sign out.

`/oauth/status` is a new endpoint and is deliberately UNGATED — a signed-OUT
browser cannot ask a gated API whether sign-in is available. It returns only
capability flags and, when a session exists, who it belongs to. Never a
credential.

The panel distinguishes four states rather than showing a button that might
404: signed in; sign-in available; auth on but OAuth off (points at the
existing static-token panel); no auth required. A 404 from /oauth/status means
a server predating this work and says so plainly.

Sign-in is a full-page navigation, not fetch(): the server replies 302 and the
browser must follow it carrying the transaction cookie. An XHR would follow the
redirect invisibly and land nowhere.

--- 2. RUVIEW_SESSION_SECRET no longer required ---

Previously `/oauth/start` returned 503 unless an operator invented a secret —
a footgun, since they set RUVIEW_OAUTH_ISSUER, expect sign-in, and get a 503
naming an env var they have never heard of.

Now resolved in order: env var, then `<data_dir>/session-secret`, then generate
one and persist it 0600 (temp file, chmod before rename — the discipline used
for the CLI's credentials). Persisted rather than in-memory so a restart does
not silently sign everyone out.

The env var still wins, which is what a multi-instance deployment needs: several
servers must share a secret or a session issued by one is rejected by the next.
If the file cannot be written we log the reason and continue with an in-memory
secret rather than refusing sign-in outright.

Verified with NO configuration at all: secret generated, file mode 0600,
/oauth/start returns 302, /oauth/status reports browser_signin: true.

--- 3. The JavaScript is now executed by tests ---

`ui/services/ws-ticket.test.mjs`, 9 tests, node:test built-in — no new
dependency and no package.json needed. Run: `node --test ui/services/`.

Covers: no token means no fetch and an unchanged URL; the bearer reaches the
Authorization header and NEVER the URL; `?` vs `&` when a query already exists;
ticket URL-encoding; 404 treated as a pre-ADR-272 server (the property that
lets one UI work against old and new servers, and therefore lets the legacy
escape hatch be removed later); 503 and network failure swallowed rather than
breaking the connect path; and that a fresh ticket is minted per call, since
tickets are single-use and caching one fails on the second reconnect.

STATED PRECISELY, because the distinction matters: this EXECUTES the module in
Node with stubbed fetch/localStorage. It is more than the `node --check` it
replaces and less than a browser — no real WebSocket upgrade, no real cookies,
no page wiring. "The UI JavaScript has never been run" is no longer true of
this module. "Browser-tested" still is not.

Tests: 547 sensing-server lib, 5 wiring integration, 87 ruview-auth, 9 JS.

Co-Authored-By: Ruflo & AQE
2026-07-23 10:07:49 +02:00
Dragan Spiridonov 347698b67c feat(auth): browser sign-in — /oauth/start, /oauth/callback, session cookie
Closes the gap adversarial review found: `wifi-densepose login` writes
~/.ruview/credentials.json, which a BROWSER CANNOT READ. The UI therefore had no
way to obtain a Cognitum token at all, and the WebSocket ticket mechanism
ADR-272 built "for browsers" was only exercisable with the legacy static shared
secret OAuth was meant to replace. The ADRs described a browser story that did
not exist.

Ported from cognitum-one/freetokens (src/auth/oauth.ts, live at
freetokens.cognitum.one), whose shape is not the obvious one and is the whole
point:

  THE BROWSER NEVER HOLDS AN OAUTH TOKEN.

The server generates the PKCE verifier and state, keeps them in an HMAC-signed
cookie, performs the code exchange itself, verifies the token, and issues its
OWN session cookie carrying an assertion — subject, account, scope, expiry — not
a credential. So the access token cannot be read by an XSS, cannot sit in
localStorage, and cannot leak through a URL. A stolen session cookie is useless
against Cognitum or any sibling service.

  GET /oauth/start    -> 302 to auth.cognitum.one + signed transaction cookie
  GET /oauth/callback -> constant-time state check, exchange, verify, session
  GET /oauth/logout   -> clears the local session (not the Cognitum session)

Verified against the running binary: /oauth/start returns the same 302 +
HttpOnly/SameSite=Lax/Max-Age=600 shape freetokens does live; a forged `state`
is refused 400; a forged session cookie is refused 401.

DELIBERATE DEVIATION from freetokens: no `__Host-` cookie prefix. That prefix
REQUIRES `Secure`, and RuView is routinely reached at http://localhost or over
plain HTTP on a LAN, where such a cookie is never sent and sign-in would fail
silently. `Secure` is set only when the request actually arrived over TLS
(direct or via x-forwarded-proto). Every other attribute matches; the HMAC is
what protects the value.

Other decisions worth stating:
- The callback verifies through the SAME `verify_access_token` every other
  request uses — signature, audience (client_id), typ, expiry, scope. A sign-in
  path must not be a softer path.
- The session cookie is checked LAST in the middleware, after bearer and ticket:
  it is the weakest-bound credential, so a presented bearer should win.
- A browser session requests `sensing:read` only. Admin work goes through the
  CLI's explicit `--admin`.
- The token exchange runs in `spawn_blocking` — `ureq` is blocking, and parking
  an async worker is the mistake this codebase just had to fix in jwks.rs.
- `/oauth/*` sits outside `/api/v1/*` on purpose: gating the routes you use to
  obtain a credential would deadlock.

PKCE moved out from behind the `login` feature into its own light `pkce` feature
(rand + sha2 + base64, no HTTP stack), so the server can build an authorize URL
without pulling in the client-side login machinery. `login` now implies `pkce`.

Tests: 13 new browser_session unit tests — signature round-trip, tampered
payload, wrong secret, malformed cookie values, HttpOnly/SameSite/Secure
attributes, exact scope matching with no implied escalation, a cookie name that
merely ends with the target not matching, multi-scope URL encoding, and the
core property that a session cookie never contains the access token.
Totals: 547 sensing-server lib + 5 wiring integration, 87 ruview-auth.

Co-Authored-By: Ruflo & AQE
2026-07-23 09:57:15 +02:00
Dragan Spiridonov 705a167ffe test(auth): boot the real binary and probe BOTH listeners (the wiring gap)
Two authentication bypasses shipped in this PR and 526 green unit tests could
not see either, because every auth test in the crate builds its OWN Router with
a hand-picked subset of routes. A synthetic router cannot observe how the real
one is assembled — and both defects were assembly, not logic:

  1. the dedicated --ws-port listener had no `require_bearer` at all
  2. `/ws/field` was .merge()d AFTER the auth layer, which in axum exempts it

This spawns the actual `sensing-server` binary (via CARGO_BIN_EXE) on ephemeral
ports and speaks raw HTTP/1.1 and real WebSocket upgrades to BOTH listeners. No
mocks, no synthetic router, no in-process shortcuts.

PROVEN TO HAVE TEETH, which matters more than it passing: with main.rs reverted
to eb68e07a (the vulnerable commit), the suite fails —

  assertion `left != right` failed: http port ACCEPTED an unauthenticated
  upgrade to /ws/field — this is the bypass that shipped twice

and passes again once restored. A regression test that has never been shown to
fail is a comment.

Five cases:
- with auth ON, NO listener accepts an unauthenticated upgrade — all four WS
  paths x both ports, with a REST 401 control first so the WS assertions cannot
  pass for the wrong reason
- a bearer on the upgrade is accepted on both listeners (native clients are not
  browser-constrained and must not need the ticket round-trip)
- with auth OFF both listeners stay open — the compatibility promise
- the legacy escape hatch opens WebSockets WITHOUT weakening REST — scoped, or
  it is a bypass wearing a migration label
- /health stays anonymous on both listeners — a documented exemption, pinned so
  it stays a decision rather than an accident

The child inherits no RUVIEW_* variables (env_remove), or a developer's local
export would silently change what the test proves. Ports are reserved by
binding :0 and releasing, so a collision surfaces as a boot failure rather than
a false pass.

Tests: 5 new integration, 534 lib, 87 ruview-auth.

Co-Authored-By: Ruflo & AQE
2026-07-23 09:50:43 +02:00
Dragan Spiridonov b09625ece7 fix(auth): close the four residual findings from the adversarial review
(a) Test fixture still invented an `iss` claim.
    `bearer_auth.rs`'s `token_with_scope` added `"iss": ISSUER` to its tokens.
    Harmless today because the verifier ignores `iss` — but it is the same
    fixture-invents-reality pattern that hid the original `iss` bug for a day,
    sitting in the second-largest auth test suite. Removed, with a pointer to
    ruview-auth's regression test.

(b) Cross-process refresh race -> session revocation.
    The single-flight guarantee was per-PROCESS. Every CLI invocation is a new
    process with its own Session and mutex over one shared credential file, so
    two commands run close together inside the 60s refresh window would each
    present the same rotating refresh token — and the second is replay, which
    identity answers by revoking the whole session family. The user gets logged
    out for running two commands at once.

    Now guarded by an advisory file lock, taken NON-BLOCKING. A busy lock means
    another process is already refreshing, so we wait and re-read its result
    rather than race it (20 x 150ms, then proceed anyway — the lock is advisory,
    not a correctness barrier, and a dead holder must not wedge us). Blocking on
    the lock would have parked the async executor, which is the exact mistake
    just fixed in jwks.rs.

    Unix only. On other platforms it is a documented no-op — a lock that does
    nothing while claiming to protect is worse than none.

(c) One principal could exhaust the global ticket pool.
    The 512 cap was global with no per-caller quota, so a single authenticated
    `sensing:read` client looping on POST /api/v1/ws-ticket could hold every
    slot for 30s and 503 everyone else — denial of service by the
    lowest-privilege account the product issues. Added a 16-ticket
    per-principal cap; a page needs a handful. Test asserts a noisy user hits
    its own cap while a second user is still served and the global pool is
    never exhausted.

(d) Debug impls printed live credentials.
    `AuthState` derived Debug over the raw RUVIEW_API_TOKEN and
    `StoredCredentials` over both OAuth tokens. Not leaking today — I checked
    every call site — but this PR had already hand-written redacting Debug for
    `OAuthState` and `TicketStore` for exactly this reason, and the two types
    actually holding secrets were the ones that missed out. Both now redact.

Tests: 87 ruview-auth (2 new: lock exclusivity + non-blocking, Debug
redaction), 534 sensing-server (1 new: per-principal quota).

Co-Authored-By: Ruflo & AQE
2026-07-23 09:48:09 +02:00
Dragan Spiridonov 0547fd7344 fix(auth): enforce client_id as the audience — Cognitum's stand-in for aud
Found by reading cognitum-one/freetokens, a live sibling service whose browser
OAuth landed while this PR was open. Its integration contract states the
platform rule outright:

  "Cognitum access tokens intentionally use custom `client_id` rather than a
   registered JWT `aud` claim."  -- freetokens docs/AUTH_INTEGRATION.md

and `src/auth/oauth.ts` enforces it on every sign-in:

  payload.client_id !== config.OAUTH_CLIENT_ID  -> reject

RuView did not. An earlier revision here removed the `client_id` check and kept
it only for logging, reasoning that clients borrow one another's registrations
(musica shipped as `meta-proxy` while its own was pending) and that scope alone
must therefore carry the boundary. That reasoned from a TRANSITIONAL state:
RuView has its own registered client (identity migration 0017), and the platform
does have an audience mechanism — it is simply spelled `client_id`.

Consequence of the old behaviour: a Cognitum access token minted for ANY product
— meta-proxy, musica, metaharness, freetokens — was accepted by a RuView server
provided it carried a sensing scope. Scope was the only thing standing between
another product's token and this one. Now there are two boundaries, audience and
capability, which is what the platform intends.

- `VerifierConfig.allowed_client_ids`; empty = accept any (explicit opt-out).
- `RUVIEW_OAUTH_CLIENT_IDS` env, default `ruview`, `*` to disable with a loud
  warning naming what is being given up. Comma-separated for the migration case
  where a borrowed registration must be accepted alongside our own.
- New `VerifyError::WrongAudience`, checked BEFORE scope, so the failure names
  the real reason rather than blaming the scope.

The existing cross-product test now asserts `WrongAudience` rather than
`MissingScope` — the token is refused for the stronger reason. Three new tests:
a correctly-scoped token from another product is still refused; the empty-list
opt-out accepts anything (pinned so it stays deliberate); multiple allowed
clients work.

This also corrects the module docs and ADR-271, which claimed "scope is the ONLY
capability boundary" — true of the code as written, but not of the platform.

Tests: 85 ruview-auth, 533 sensing-server.

Co-Authored-By: Ruflo & AQE
2026-07-23 09:31:24 +02:00
Dragan Spiridonov f67a880a1a docs: correct three doc-vs-code contradictions found by adversarial review
All three are my own drift — the exact failure this session criticised in other
repos' ADRs and then reproduced.

1. ADR-271 §2 still listed "`iss` matches the configured issuer verbatim" in the
   accept-rule. That rule was removed from the code in 12635a85 because Cognitum
   issues no `iss` and it rejected every real token. The ADR's own "Facts about
   the tokens" section 30 lines above already said tokens carry no `iss` — the
   document contradicted itself for a day. Also removed a claim that tests cover
   "issuer mismatch including a trailing-slash-only difference"; that test was
   deleted with the rule and no longer exists.

2. `ruview-auth/src/lib.rs:59` said "**No login flow.** ... this crate only
   verifies" — 15 lines above `pub mod login;`. Now states the accurate thing:
   the login flow is behind the non-default `login` feature, so a verifying
   server never compiles it.

3. ADR-271's scope table still described the old prefix-denylist admin set.
   Replaced with the fail-closed rule that actually ships, including why:
   `/api/v1/adaptive/train` was reachable with `sensing:read`.

Also records a KNOWN INCOMPLETE that the ADRs previously implied was done: the
browser cannot obtain an OAuth token at all. `wifi-densepose login` writes
~/.ruview/credentials.json, which a browser cannot read; the UI reads
localStorage['ruview-api-token'], populated only by the manual-paste
QuickSettings panel. `grep -ril "oauth|cognitum|pkce" ui/` returns nothing.

So the WebSocket ticket mechanism ADR-272 introduces "for browsers" is today
only exercisable with the legacy static shared secret OAuth was meant to
replace. Server-side gating is correct and complete; the browser half of the
story these ADRs tell is not built. Recorded rather than left implied.

Co-Authored-By: Ruflo & AQE
2026-07-23 09:20:31 +02:00
Dragan Spiridonov 7a05417493 fix(auth): scope gate was fail-OPEN; JWKS lock held across a blocking fetch
Two charges from an adversarial multi-vendor review (qe-court). Both verified
in source before fixing.

--- 1. AUTHORIZATION BYPASS: POST /api/v1/adaptive/train reachable with read ---

`required_scope_for` enumerated admin routes by prefix (`/api/v1/train/`, plus
DELETE on models/recording) and let EVERYTHING ELSE fall through to
`sensing:read`.

`POST /api/v1/adaptive/train` (main.rs:8112 -> handler main.rs:5028) calls
`adaptive_classifier::train_from_recordings()`, writes the model to disk with
`model.save()`, and swaps `state.adaptive_model` used by the live inference
pipeline. It does not start with `/api/v1/train/`, so it landed on
`sensing:read` — the scope `wifi-densepose login` requests BY DEFAULT. Exactly
the blast radius the read/admin split exists to prevent, reachable by the
lowest-privilege token the product issues.

Fixed by inverting the polarity, which is the real defect — a denylist for a
security gate keeps missing routes as routes keep being added:

  GET/HEAD/OPTIONS            -> sensing:read
  any other method            -> sensing:admin
  ...unless the exact path is in READ_SAFE_MUTATIONS (an explicit allowlist of
     mutations that change runtime state but destroy nothing: ws-ticket,
     model load/unload/activate, calibration start/stop, recording start/stop,
     vendor event ingest)

A mutating route added tomorrow is now admin-gated by default. Pinned by
`an_unknown_mutating_route_defaults_to_admin`. `POST /api/v1/ws-ticket` is
allowlisted deliberately and has its own test: were it admin, the read scope
could never open a stream from a browser at all.

Enumerated all 17 mutating /api/v1 routes to build the allowlist rather than
guessing. `POST /api/v1/config/ground-truth` now requires admin — a deliberate
tightening, it writes config.

--- 2. DoS: blocking JWKS fetch under std::sync::Mutex in async middleware ---

Filed INDEPENDENTLY by two prosecutors, which is why it gets fixed rather than
argued about.

`decoding_key_for` locked a `std::sync::Mutex` and held it across a blocking
`ureq` call (3s timeout, longer on a dead link), invoked from inside the async
`require_bearer`. `Mutex::lock()` in an async fn is a blocking syscall, not a
yield point — so one slow JWKS fetch blocked EVERY concurrent request on that
mutex, including ones carrying already-cached valid tokens, and parked the
tokio workers running them. On Pi-class hardware with few workers that stalls
the whole server. It fires on the routine 300s TTL rollover whenever the link
is degraded — the exact offline case the module exists to tolerate. Reachable
by anyone able to send a syntactically valid ES256 header with an unknown kid,
since kid lookup precedes signature verification.

Now three phases: read under the lock, RELEASE, network, re-take only to
install. Cost is a possible duplicated idempotent GET during a rollover, which
is strictly better than serialising every request behind one socket. The
codebase already uses spawn_blocking for outbound I/O elsewhere
(main.rs:2490, 5272); moving verification fully onto spawn_blocking remains a
follow-up — this removes the amplification, not every blocking millisecond.

Tests: 533 sensing-server (7 new scope-polarity), 82 ruview-auth.

Co-Authored-By: Ruflo & AQE
2026-07-23 09:18:33 +02:00
Dragan Spiridonov 3347e258e6 fix(ws): gate the dedicated WS port and the merged field routes (ADR-272 was incomplete)
The previous commit claimed WebSocket upgrades were gated. They were not. Found
by an adversarial cross-vendor review, reproduced, and fixed here.

MEASURED BEFORE (auth ON via RUVIEW_API_TOKEN, no credential presented):

  HTTP port :3991  /ws/sensing  -> 401   (what the previous fix covered)
  HTTP port :3991  /ws/field    -> 101   HOLE
  WS   port :3990  /ws/sensing  -> 101   HOLE
  WS   port :3990  /ws/field    -> 101   HOLE
  control   :3991  /api/v1/models -> 401

Two independent defects, both from the same root cause — routes registered
AFTER an axum `.layer()` are silently exempt from it:

1. The dedicated WebSocket server on `--ws-port` was built with ONLY
   `host_validation::require_allowed_host`. `require_bearer` was never applied
   to it at all. My earlier verification only ever probed the HTTP port and I
   generalised from it.

   This is the worse of the two, because it is the port the UI actually uses:
   `ui/services/sensing.service.js` maps HTTP 8080 -> WS 8765. So the previous
   fix protected a path the browser never takes, while the path it does take
   stayed open.

2. On the HTTP router, `/ws/field` (ADR-262) was `.merge()`d AFTER the
   `require_bearer` layer, so it bypassed authentication entirely. The auth
   layer is now applied after the merge, and the comment says why the ordering
   is load-bearing.

MEASURED AFTER, same conditions:

  :3989 /ws/sensing -> 401 · :3989 /ws/field -> 401
  :3988 /ws/sensing -> 401 · :3988 /ws/field -> 401
  with bearer on the upgrade: 101 on both ports
  ticket minted at POST /api/v1/ws-ticket on the HTTP port and redeemed on the
    WS port: 101  (AuthState shares its TicketStore via Arc)
  auth OFF: 101 — unchanged, no regression

526 sensing-server tests still pass.

TEST GAP, stated rather than papered over: no automated test covers this. The
defect is in router WIRING in main.rs, not in logic a unit test reaches — both
holes were invisible to 526 green tests and to the ws_gate_tests suite, which
builds its own Router and therefore cannot see how the real one is assembled.
Catching this class needs an integration test that boots the binary and probes
BOTH ports; that is the honest follow-up.

Co-Authored-By: Ruflo & AQE
2026-07-23 09:14:07 +02:00
Dragan Spiridonov eb68e07a2c feat(ui): fetch WebSocket tickets, prefix-match WS paths, and write ADR-272
Completes ADR-272. Three parts.

1. PREFIX MATCHING, not an allowlist. Anything under `/ws/` is a WebSocket path,
   plus `/api/v1/stream/pose` which lives outside it. An allowlist means every
   WebSocket route added later ships ungated until someone remembers to extend
   it — the same bug reintroduced on a delay. Not hypothetical:
   `/ws/train/progress` (ADR-186, arriving with PR #1387) is ALREADY referenced
   by ui/services/training.service.js and would have shipped unauthenticated.
   Pinned by a test that asserts it is gated before it exists.

2. UI wiring. A shared `withWsTicket()` helper mints a ticket immediately before
   each connection attempt — never cached, because a ticket is single-use and
   expires in seconds, so reusing one across reconnects fails on the second
   attempt. Wired into the three sites that open gated sockets:
   sensing.service.js, websocket-client.js, observatory/js/main.js.

   It degrades in both directions on purpose: no stored token means auth is off
   and no ticket is needed; a 404 from /api/v1/ws-ticket means a server
   predating this ADR, which still exempts WebSockets, so connecting without a
   ticket is correct there. The same UI therefore works against old and new
   servers, which is what makes the escape hatch removable later rather than
   permanent.

   The long-lived bearer token is still never put in a URL — only the ticket is.

3. ADR-272 itself. Previously cited in five places without existing; the same
   dangling-reference mistake made with ADR-271 earlier today, so it is written
   before this lands rather than after someone notices.

   It records the measured before/after, why a credential in a URL is
   acceptable here specifically (single use, seconds-long, not the credential),
   why the escape hatch exists and why it is deliberately uncomfortable, and
   what is deliberately NOT done — including that /health/metrics stays ungated,
   with the caveat that this should be revisited if metrics ever carry
   occupancy-derived values, since that would make them sensing data wearing an
   ops label.

Tests: 526 sensing-server (4 new path-matching), 82 ruview-auth. JS
syntax-checked with `node --check`; there is no UI test suite to extend.

Co-Authored-By: Ruflo & AQE
2026-07-22 19:15:20 +02:00
Dragan Spiridonov 6300b1cbd2 feat(ws): gate WebSocket upgrades behind bearer-or-ticket (ADR-272)
Closes the hole measured in 7d6d6694. Before, with RUVIEW_API_TOKEN set, a real
handshake carrying no credential:

  /ws/sensing 101 · /ws/introspection 101 · /api/v1/stream/pose 101
  (/api/v1/models correctly 401)

After, same server, same handshake:

  /ws/sensing 401 · /ws/introspection 401 · /api/v1/stream/pose 401
  bearer on the upgrade                         -> 101
  POST /api/v1/ws-ticket then ?ticket=<value>   -> 101
  the same ticket replayed                      -> 401
  a bogus ticket                                -> 401
  POST /api/v1/ws-ticket unauthenticated        -> 401

Two ways in, matching what each client can actually do:

- Native clients (Python, Rust CLI, TS MCP) send a normal Authorization header
  on the upgrade. They were never browser-constrained; forcing them through a
  ticket would add a round-trip and a second credential path for nothing.
- Browsers cannot set that header, so they exchange their credential at
  POST /api/v1/ws-ticket — an ordinary request, where they can — for a 30s
  single-use ticket passed as ?ticket=.

A ticket inherits the issuing principal's scopes, so a sensing:read session
cannot mint one that outranks itself, and it is not a REST credential: pinned
by a test that `?ticket=` on /api/v1/models is still 401.

ESCAPE HATCH (RUVIEW_WS_LEGACY_UNAUTHENTICATED=1) restores the old behaviour
for deployments that cannot update server and UI in lockstep. It is a migration
aid, not a supported configuration, and it says so on every boot in a warning
that names the actual exposure ("the live sensing stream — presence, pose and
vital signs — is readable by anyone who can reach this port"). Its blast radius
is exactly the WebSocket paths: a test pins that it does not weaken REST.

The flag is read once at construction, so a mid-flight environment change
cannot silently open these paths on a running server.

SUPERSEDES the PR #1313 test `enabled_exempts_pose_stream_websocket`, which
asserted the old exemption. Its reasoning about browsers was correct; the
conclusion was not. Renamed and inverted rather than deleted, with the history
in the doc comment — and the half that still matters (the WebSocket rule must
not leak to other /api/v1/* paths) is kept.

Deployments with auth OFF see no change at all — pinned by a test.

Tests: 522 passed in the sensing server (9 new WS-gating, 12 ticket-store).

STILL OUTSTANDING for ADR-272: the browser UI does not yet fetch a ticket, so
it needs the escape hatch until ui/services/api.service.js is updated. That is
the next commit, not a permanent state.

Co-Authored-By: Ruflo & AQE
2026-07-22 19:04:44 +02:00
Dragan Spiridonov 7d6d66941a feat(ws): single-use WebSocket ticket store (ADR-272 groundwork)
Audit finding this exists to close — verified empirically, not inferred. With
RUVIEW_API_TOKEN set (operator believes auth is ON), a real WebSocket handshake
carrying NO credential:

  /ws/sensing         -> 101 Switching Protocols
  /ws/introspection   -> 101 Switching Protocols
  /api/v1/stream/pose -> 101 Switching Protocols
  /api/v1/models      -> 401   (control: REST is correctly gated)

The REST control plane is locked while the DATA plane — live presence, pose and
vitals — is open to anyone who can reach the port. Two of those paths sit
outside PROTECTED_PREFIX entirely; the third is the documented EXEMPT_PATHS
entry. The exemption was reasonable when added (a browser cannot set
Authorization on an upgrade) but its blast radius is larger than it looks, and
phase 3 sharpened the contrast by making REST genuinely strong.

(To be precise about what was proven: the handshake is accepted. A payload
frame was not captured in that window, so this is "the connection is
established without a credential", not "data was read".)

This commit adds only the store; wiring it into the middleware is a breaking
change for browser clients and lands with the UI update.

Design notes:
- Single use. `consume` REMOVES the entry, so a replay of the same URL fails
  even inside the TTL. This is what makes a credential-in-a-query tolerable:
  by the time it reaches an access log or a Referer header, it is spent.
- 30-second TTL. Long enough for a page to open a socket; too short to harvest.
- It is not the credential. It authorizes one WebSocket. It cannot be replayed
  against /api/v1/*, cannot be refreshed, and carries no reusable identity.
- The grant captures the ISSUING principal's scopes, so a WebSocket inherits
  exactly the authority of the credential that asked for it — a sensing:read
  session cannot mint a ticket that outranks itself.
- Capped at 512 outstanding, self-healing as tickets expire, so an
  authenticated but misbehaving caller cannot grow the map without bound.
- In-memory because that is correct, not merely convenient: a ticket surviving
  a restart would outlive the server that vouched for it.

Native clients (Python, Rust CLI, TS MCP) are NOT browsers and will send a
normal Authorization header on the upgrade instead — tickets would add a
round-trip and a second credential path for no benefit.

12 tests: single-use enforced, replay refused, expiry refused AND pruned,
unknown ticket refused, 256-bit unpredictability, grant carries issuer scopes,
cap enforced and self-healing, and query parsing including `?myticket=x` not
being read as `?ticket=x`.

Co-Authored-By: Ruflo & AQE
2026-07-22 18:41:34 +02:00
ruv d9dfea2dac docs: swap in musica promo image + fix alt text to Cognitum Musica 2026-07-22 09:34:12 -07:00
Dragan Spiridonov 6d3fb88677 fix(cli): resolve scope from the token on read, and add whoami --refresh
Two gaps that only appear with a credential file written by an earlier build —
i.e. exactly the case a fresh-login test never exercises.

1. `whoami` printed "Scope: (not reported)" for an existing file. The previous
   commit resolved scope from the token claim at WRITE time only, so files
   written before it stayed blank forever. `effective_scope()` now falls back at
   READ time, which fixes existing files and any client that stored only what
   the token response carried. The token is authoritative either way; the stored
   field is a convenience copy.

2. `whoami` said the token "will refresh on next use" — a promise nothing kept,
   because no command called `ensure_fresh`. The refresh path was implemented
   and unit-tested but never actually run. `--refresh` exercises it, and goes
   through `Session::ensure_fresh` rather than reimplementing a second, subtly
   different refresh, so it inherits the single-flight guarantee and the
   persist-before-return ordering.

   It is a flag, not silent behaviour: refreshing rotates the stored refresh
   token — identity spends the old one — so it is a state change, not a read.

VERIFIED AGAINST PRODUCTION, end to end:
  whoami on a pre-existing file        -> Scope: sensing:read (was "not reported")
  whoami --refresh                     -> both tokens rotated
                                          refresh sha256[:12] 50cfa06b -> 2d37617a
                                          access  sha256[:12] 61eebe8d -> 14d8e8de
                                          status: expired -> valid
  refreshed token GET  /api/v1/models      -> 200
  refreshed token POST /api/v1/train/start -> 401 (still read-scoped)

That exercises the rotating-refresh path against identity's real reuse
detection — the one place a bug costs the user their session rather than a
retry — and confirms the scope gate survives a refresh.

Tests: 82 with --features login, 44 default, 501 sensing-server.

Co-Authored-By: Ruflo & AQE
2026-07-22 18:21:49 +02:00
Dragan Spiridonov 12635a85b2 fix(auth): stop requiring an iss claim Cognitum never issues (G-3 blocker)
The verifier called `Validation::set_issuer` and listed `iss` in
`set_required_spec_claims`. Cognitum access tokens have **no `iss` claim** — the
real claim set is typ, sub, account_id, org_id, workspace_id, client_id, scope,
family_id, jti, iat, exp, setup, workload. So the verifier rejected every
genuine token with a flat 401.

The whole 41-test suite was green throughout, because the test fixtures included
an `iss` the real thing does not have. The tests validated my assumption instead
of the platform. Found only by pointing a real token at a real server; no amount
of additional unit testing against the same wrong fixture would have caught it.

`valid_claims()` now mirrors production exactly, with a comment saying why, so
the next person cannot reintroduce the drift by "fixing" an incomplete-looking
fixture.

What binds a token to its issuer, then: the JWKS. We accept only signatures made
by a key served from the configured jwks_uri, so a valid signature IS proof of
issuer. meta-llm's verifier — the org's only other resource-side verifier —
checks neither `iss` nor `aud`, for the same reason. `VerifierConfig.issuer`
stays, now documented as JWKS-derivation and logging rather than a claim
assertion.

Three tests replace the two that asserted issuer behaviour that cannot exist:
a real-shaped (iss-less) token verifies; an unrelated `iss` changes nothing (so
identity adding one later cannot silently start failing tokens); and a token
signed by a different key is still refused — the complement that proves removing
the issuer check did not remove the boundary.

Also: report the granted scope from the token's `scope` claim when the
/oauth/token envelope omits it, which identity's does. `login` and `whoami` said
"(not reported by the server)" while the authoritative answer sat inside the
token they had just stored. The claim-peek helper is display-only and documented
at length as NOT verification — a client reading its own freshly issued token is
a different situation from a server reading a stranger's.

VERIFIED END TO END against production (gate G-3, previously open):
  no credential            GET  /api/v1/models        -> 401
  garbage bearer           GET  /api/v1/models        -> 401
  real sensing:read token  GET  /api/v1/models        -> 200
  real sensing:read token  GET  /api/v1/recording/list-> 200
  real sensing:read token  POST /api/v1/train/start   -> 401
  real sensing:read token  POST /api/v1/train/stop    -> 401
Token obtained by a human through `wifi-densepose login` against
auth.cognitum.one; server ran with RUVIEW_OAUTH_ISSUER set, JWKS fetched live
at boot (key_count=1).

Tests: 82 with --features login (58 unit + 22 matrix + 2 doctests).

Co-Authored-By: Ruflo & AQE
2026-07-22 18:11:21 +02:00
ruv 88bd88ed0f docs: swap Cognitum Seed badge for the musica marketplace listing 2026-07-22 09:10:04 -07:00
Dragan Spiridonov 714dae9a2c docs(adr): amend ADR-271 — the login flow lives in ruview-auth behind a feature
The ADR said the login flow was "not in this crate". It is now, gated behind a
non-default `login` feature. The original line existed to keep the sensing
server lean; a feature gate achieves that without forcing the Tauri desktop app
to grow a second copy of a PKCE + rotating-refresh implementation — the kind of
duplication that drifts and then disagrees about something subtle.

Recording the change rather than letting the ADR quietly go stale, which is the
exact failure this session hit twice in other repos' ADRs.

Co-Authored-By: Ruflo & AQE
2026-07-22 17:54:13 +02:00
Dragan Spiridonov 31fb3d53f6 feat(cli): wifi-densepose login — Cognitum sign-in (ADR-271 phase 2)
Phase 1 could verify a token and phase 3 could gate on one, but there was no
way for a user to OBTAIN one. This closes that: sign in with a Cognitum account
and get a token a RuView sensing server accepts, instead of everyone sharing
one static RUVIEW_API_TOKEN string.

Lives in `ruview-auth` behind a non-default `login` feature rather than in the
CLI, so the Tauri desktop app can reuse it instead of growing a second copy.
A server built with default features still gets the verifier and nothing else —
no reqwest, no tokio net, no browser launcher. (This amends ADR-271's "no login
flow in this crate" note; the reason for that line was to keep the server lean,
and a feature gate achieves it without duplication.)

Ported from meta-proxy's src/oauth/, cross-checked against musica's
cognitum_provider.rs — two independent implementations against this same AS.
Where they agree, this follows both: redirect path EXACTLY /oauth/callback,
60-second refresh skew, OOB fallback on SSH/CONTAINER//.dockerenv.

Refresh is the part with teeth. Identity rotates refresh tokens with reuse
detection, so presenting a spent one revokes the whole session family. Both
obvious implementations are wrong: refreshing concurrently looks like replay,
and retrying a failed refresh with the same token IS the replay. So
`Session::ensure_fresh` holds an async mutex across the await, re-checks expiry
after acquiring it (the waiter usually finds the work already done), persists
the rotated token BEFORE returning it, and never retries. A missing expires_at
counts as expired rather than being given a guessed default.

Least scope by default: `login` requests `sensing:read`. `--admin` adds
`sensing:admin` explicitly, and requests both because there is no scope
hierarchy server-side. A session that streams poses should not casually hold
the capability to delete the model it streams through.

Credentials are written atomically and 0600 (temp file, chmod BEFORE rename) —
the same discipline the seed applies to its cloud key. `logout` is local-only
and says so: it makes this machine unable to act as you, but revoking the
session everywhere is an account-level action.

Also `whoami`, which reports whether the stored token is live — an
expired-looking session is the most common reason a command starts 401ing, and
it should be visible directly rather than inferred from a failure elsewhere.

Verified against PRODUCTION, not just locally: authorize URLs built by this
exact code path return HTTP 200 from auth.cognitum.one for both
`sensing:read` and `sensing:read sensing:admin`, which exercises the real
client_id, scope encoding, PKCE parameters and redirect_uri shape.

Tests: 74 with --features login (51 unit + 21 verifier matrix + 2 doctests),
including the RFC 7636 Appendix B vector, multi-scope URL encoding (a space
that is hand-formatted rather than encoded silently truncates the request), a
real TCP callback round-trip, callback timeout, 0600 permissions asserted on
disk, atomic-save leaving no temp file, and refresh-window boundaries.
Unchanged: 43 with default features, 501 in the sensing server.

Co-Authored-By: Ruflo & AQE
2026-07-22 17:53:42 +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
Dragan Spiridonov c2bd33e649 feat(sensing-server): accept Cognitum OAuth on /api/v1/*, scope-gated (ADR-271 phase 3)
Wires `ruview-auth` into `bearer_auth.rs`. `RUVIEW_OAUTH_ISSUER` enables it;
unset, nothing changes.

Layering, in order:
  1. `RUVIEW_API_TOKEN` set and the bearer matches exactly -> allow. Byte-for-
     byte today's behaviour.
  2. Otherwise, if OAuth is configured, verify the bearer as a Cognitum access
     token and require the scope the route needs.
  3. Otherwise 401.

The static compare goes first for compatibility, not security: a matching
static token is not a JWT and a JWT never matches the static token. It means an
existing deployment behaves identically even with OAuth switched on.

Scope gate (`required_scope_for`), split by blast radius per ADR-060 —
"can this destroy something", not how many routes it covers:
  sensing:admin  /api/v1/train/* (hours of Pi CPU, writes models)
                 DELETE /api/v1/models/{id}      (irreversible)
                 DELETE /api/v1/recording/{id}   (irreversible)
  sensing:read   everything else
Deliberately NOT admin: model load/unload and recording start. They mutate
server state but destroy nothing, and gating them would push routine dashboard
use into requesting delete capability — the opposite of least privilege.

The legacy static token stays un-scope-gated. It predates scopes and carries no
claims, so narrowing it would be a silent breaking change to deployments using
it; migrating to OAuth is how an operator opts into the finer split.

FAIL CLOSED at boot. If OAuth is requested but cannot work — empty issuer, or a
JWKS we cannot fetch — the server logs why and exits rather than serving.
Starting anyway would silently downgrade an operator who asked for OAuth to
either an open API or a shared-secret one, with no signal it happened. The JWKS
is warmed eagerly for the same reason: a bad `jwks_uri` should die at boot with
a legible message, not surface as a puzzling 401 an hour later.

The verified `Principal` is attached to request extensions, so handlers and
audit logs can attribute a request (`sub`, `account_id`, `org_id`,
`workspace_id`, `jti`) instead of knowing only "someone had the secret". That
is the point of moving off a shared bearer.

Verification failures are logged with the reason and returned as a flat 401 —
the reason is useful to an operator and equally useful to an attacker probing
for which claim to forge next.

Also aligns `ruview_auth::extract_bearer` to match the scheme
case-insensitively (RFC 7235 §2.1). The sensing server has always done this
deliberately, with a comment saying why; the two layers disagreeing about what
a valid header looks like would be a latent bug.

Tests: 16 new in `bearer_auth::oauth_tests`, driving a real Router end to end
(request -> middleware -> verifier -> handler) with ES256 tokens signed by a
runtime-generated key. Covers the scope policy as a pure function, read-scoped
tokens refused on delete and train, admin-scoped tokens allowed, an
`inference`-only token from another Cognitum product refused on every route,
garbage and absent bearers, both legacy-token layering directions, the
principal reaching a handler, and the unset case remaining a no-op.

`cargo test -p wifi-densepose-sensing-server --lib --no-default-features`:
501 passed, 0 failed. `ruview-auth`: 43 passed across both feature configs.

Co-Authored-By: Ruflo & AQE
2026-07-22 15:33:22 +02:00
Dragan Spiridonov 92cbeb0c34 docs(adr): ADR-271 — RuView as a Cognitum OAuth resource server
The previous commit referenced ADR-271 in five places without the ADR existing.
This writes it.

Records the decision and, more importantly, the direction — RuView verifies
tokens users present, it does not obtain tokens to call Cognitum. Every other
Cognitum OAuth integration in the org is the client side of a plane RuView does
not have; the sole relevant precedent is meta-llm's oauthBearer.ts.

Covers: why offline verification is a requirement rather than an optimisation
(Pi-class hosts lose WAN, and no introspection endpoint exists); why the
accept-rule is a port and not a design; why long-lived setup/workload
credentials are refused (no database to check revocation); why scope — not
`aud`, not `client_id` — is the capability boundary; and the alternatives,
including the `cog_`-minting approach that was tried org-wide and found broken
against production.

Co-Authored-By: Ruflo & AQE
2026-07-22 15:10:48 +02:00
Dragan Spiridonov 499cec7914 feat(auth): ruview-auth — offline Cognitum OAuth access-token verification (ADR-271)
RuView's `/api/v1/*` is gated today by `RUVIEW_API_TOKEN`: one shared secret,
no expiry, no per-user attribution. This adds the verifier half of replacing
that with Cognitum identity — RuView as an OAuth **resource server**, so a user
signs in to their own sensing server with their Cognitum account.

Note the direction: RuView does not call Cognitum. It never obtains a token for
its own use; it verifies tokens users present. Every other Cognitum OAuth
integration in the org (meta-proxy, musica, metaharness, the dashboard CLI) is
the client side of a plane RuView doesn't have. The one existing resource-server
verifier is `meta-llm/src/auth/oauthBearer.ts`, and this crate is a port of its
accept-rule — divergence would be a bug, not a preference: a token meta-llm
rejects must not be one RuView accepts.

Verification is OFFLINE, and that is a requirement rather than an optimisation.
RuView runs on Pi-class hardware that loses WAN, and identity publishes no
introspection endpoint even when the network is up. So: ES256 over identity's
published JWKS, cached by `kid`.

What it accepts, mirroring oauthBearer.ts:
  typ == "access" AND NOT setup AND NOT workload AND account_id non-empty AND
  exp in the future AND iss matches verbatim AND the required scope is held.

Long-lived setup (365-day) and workload credentials are refused outright for
identity's own stated reason: their revocation lives in `oauth_setup_tokens`,
and RuView — like meta-llm — has no database to check it. A 15-minute access
token needs no revocation round-trip because it expires faster than revocation
propagates; a 365-day one does.

Scope is the capability boundary, and it has to be. Cognitum access tokens carry
no `aud`, and `client_id` cannot substitute because clients borrow each other's
registrations (musica ships DEFAULT_CLIENT_ID = "meta-proxy"). `sensing:read`
covers streams and inference; `sensing:admin` covers training, model delete and
recording delete. No hierarchy — admin does not imply read; consent means what
it said. Registered in identity migration 0016 (cognitum-one/dashboard#116).

Design notes:
- `ureq`, not `reqwest`: the sensing server deliberately chose ureq as "the
  smallest" HTTP client, and pulling reqwest in here would reverse that for the
  whole graph. Transport sits behind a `JwksFetcher` trait, so a host can supply
  its own and take no HTTP dependency at all (feature `ureq-transport`, default).
- A JWKS refetch failure is survivable while a key set is already cached: a key
  that verified a minute ago has not stopped being valid because the network
  blipped, and failing closed there logs every user out of their own sensing
  server whenever their internet wobbles. We fail closed in exactly one case —
  no key set has ever been fetched.
- Unknown `kid` forces one refetch so rotation is picked up without waiting out
  the TTL, rate-limited so junk-kid tokens can't become a request amplifier
  aimed at identity.
- Expiry is reported distinctly from a bad signature: on an RTC-less Pi that is
  usually a clock-sync fault, and an operator must be able to tell them apart.
- Test keypairs are generated at runtime, never committed. This repo tracks zero
  `.pem` files and committed key material shouldn't start here — it also means
  no fixture can drift out of sync with the JWKS it's served by.

Tests: 41 green (19 unit + 21 matrix + 1 doctest) under BOTH
`cargo test --no-default-features` (the repo's canonical gate) and default
features. The matrix signs real ES256 tokens rather than asserting on strings,
and covers alg:none, forged signature, spliced payload, unknown kid, expired,
leeway boundaries both sides, wrong issuer, issuer differing only by a trailing
slash, missing/!=access typ, setup and workload smuggled onto typ=access,
missing and empty account_id, and scope escalation.

The highest-value case is `g2_a_genuinely_valid_token_from_another_cognitum_
product_cannot_reach_the_sensing_surface`: a correctly signed, unexpired,
right-issuer, right-typ token with client_id=meta-proxy and scope=inference is
rejected. Nothing about its signature or identity claims distinguishes it — only
scope does. A naive verifier accepts it, and an `inference` token becomes a key
to someone's home sensor.

No wiring into the sensing server yet; this crate is verification only, with no
login flow and no outbound Cognitum calls.

Co-Authored-By: Ruflo & AQE
2026-07-22 15:08:40 +02: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
rUv 7831f29436 fix(firmware): phantom LD2410 detection + ENOMEM backoff (#1135) (#1159)
Bug #2 (root cause): LD2410 probe-detection matched only the 4-byte head
0xF4F3F2F1, so a floating UART at 256000 baud could phantom-detect a sensor
and spawn a UART task. Now requires a full validated report frame (head +
sane length + tail 0xF8F7F6F5), extracted to mmwave_detect.h and shared with
a host unit test (test_mmwave_detect.c, 8 vectors) so firmware and test can't
diverge. Matches the validate-before-trust approach used for MR60 in #1107.

Bug #1: sendto ENOMEM used a fixed 100 ms backoff too short to drain sustained
lwIP/WiFi buffer pressure, so a node could stay stuck. Now exponential
(100->200->...->2000 ms per consecutive ENOMEM, reset on first successful
send). Removing the phantom LD2410 task (bug #2) also removes the extra load
that tipped the reporter's tier-2 node into the stuck state.

Validated on ESP32-S3 QFN56 rev v0.2 (the reporter's silicon): tier-2 streams
~100 frames/s with no stuck ENOMEM and correctly reports no mmWave (no
phantom). LD2410 predicate truth table proven (head-without-tail REJECTED).
Could not reproduce the reporter's environment-specific floating-pin noise, so
the deterministic proof is the host unit test.
2026-06-22 12:31:21 -04:00
rUv 4bf88e1283 feat(firmware): gate LED gamma viz behind CONFIG_LED_GAMMA_VIZ (ADR-183 follow-up) (#1129)
The 40 Hz gamma flicker is now Kconfig-gated (default y, unchanged
behaviour). Set CONFIG_LED_GAMMA_VIZ=n for a dark, lower-power boot (the
LED is simply cleared) — important for photosensitive deployments, no
source edit needed. The colormap saturation point is now operator-tunable
via CONFIG_LED_MOTION_FULLSCALE_MILLI (default 250 = 0.25).

Build + flash confirmed on ESP32-S3 N16R8 (COM8): default y still arms the
gamma timer, CSI flows. ADR-183 updated (gate moved from follow-up to done).
2026-06-17 22:22:20 -04:00
rUv a4c2935a2f feat(firmware): onboard LED 40 Hz gamma stimulus + CSI-motion colour (ADR-183) (#1127)
* chore(deps): bump ruv-neural submodule — ColorMap no_std for ESP32

Points to ruvnet/ruv-neural#3 (c9638fa): ruv-neural-viz::ColorMap now
builds no_std, so it can run on the ESP32. Unblocks driving the onboard
WS2812 from the viridis/cool-warm colormap.

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

* feat(firmware): onboard LED as 40 Hz gamma stimulus, colour from live CSI motion (ADR-183)

The S3 onboard WS2812 (GPIO 48, #962) now runs a GENUS-style 40 Hz gamma
square wave (12.5 ms on/off, 50% duty). The ON-phase colour is live CSI
motion (edge motion_energy) mapped through a 60-step viridis LUT generated
from ruv-neural-viz::ColorMap::viridis() — still=purple, moving=yellow.

Uses the now-no_std ColorMap (ruvnet/ruv-neural#3 / #1126). Hardware-
confirmed on ESP32-S3 N16R8 (COM8): boot log shows the timer armed, CSI
keeps flowing (27-38 pps). Honesty + photosensitivity notes + a Kconfig-gate
follow-up are in ADR-183.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-17 21:04:02 -04:00
rUv 315d7df09e chore(deps): bump ruv-neural submodule — ColorMap no_std for ESP32 (#1126)
Points to ruvnet/ruv-neural#3 (c9638fa): ruv-neural-viz::ColorMap now
builds no_std, so it can run on the ESP32. Unblocks driving the onboard
WS2812 from the viridis/cool-warm colormap.
2026-06-17 20:18:35 -04:00
rUv bdd1eaf927 chore: untrack ruvector.db runtime artifacts + gitignore at any depth (#1124)
These are hook/runtime-generated databases (ruvector/intelligence store)
that kept showing as dirty and don't belong in version control. Removed
from the index (files kept on disk) and ignored globally.
2026-06-17 17:49:47 -04:00
rUv 4001e9e178 feat(harness): npx @ruvnet/ruview operator harness + ADR-182 (#1123)
A host-portable RuView agent harness minted via MetaHarness and hardened
per ADR-182. Published as @ruvnet/ruview@0.1.0 (bare `ruview` blocked by
npm's typosquat filter → scoped fallback).

What it does:
- 6 fail-closed `ruview.*` tools (onboard, claim_check, verify,
  node_monitor, calibrate, node_flash) exposed as CLI verbs + a
  dependency-free MCP stdio server.
- The "prove everything" rule made executable: `ruview.claim_check`
  flags untagged accuracy claims and the retracted "100%" framing.
- 5 host-neutral skills (onboard/provision-node/calibrate-room/
  train-pose/verify) + bundled .claude/ config + provenance manifest.

Validated: 17/17 unit tests, live MCP handshake, `ruview.verify` ran the
real verify.py to VERDICT: PASS, clean `npx @ruvnet/ruview` from registry.
Packs to 16.7 kB / 21 files; kernel+host are optionalDependencies so the
operator tools install lightweight.

README: documented as the portable, multi-host companion to the in-repo
plugins/ruview/ Claude Code plugin (not a replacement).
2026-06-17 17:46:31 -04:00
rUv 65e29ef47a fix(display): no false display-detect on bare DevKit → CSI starves at MGMT-only (#1000) (#1121)
The SH8601 QSPI panel is write-only, so display_hal_init_panel() 'succeeds' even on a
bare display-less board — display_is_active() then returned true and main.c skipped the
#893/#906 MGMT->MGMT+DATA CSI filter upgrade (yield=0pps). Gate on the FT3168 touch I2C
readback (always present on the Touch-AMOLED board, absent on a bare DevKit): if touch is
absent, the panel 'success' was a false-positive — bail to headless before the display
task starts, so display_is_active() stays false and CSI captures.

Co-authored-by: ruv <ruvnet@gmail.com>
2026-06-17 11:24:53 -04:00
rUv cb30988cf9 fix(mmwave): require validated MR60 header on probe — no false detect on empty UART (#1107) (#1119)
probe_at_baud counted bare 0x01 (SOF) bytes and declared MR60BHA2 on a single one.
A floating UART1 with no sensor reads noise full of 0x01s → false 'Detected MR60BHA2
(caps=0x000f)'. Now a candidate must be a full 8-byte header with a valid header
checksum (bytes 0..6) AND a known frame type (0x0A__ / 0x0F09), and clear the ≥3
threshold; removed the weak single-hit fallback. Real sensors stream valid frames
continuously, so detection of present hardware is unaffected.

Co-authored-by: ruv <ruvnet@gmail.com>
2026-06-17 11:24:23 -04:00
rUv 128b129474 Merge pull request #1120 from ruvnet/fix/1007-paired-data-pipeline
fix(paired-data): 4 bugs in CSI recorder + ground-truth aligner (#1007)
2026-06-17 10:26:14 -04:00
ruv 15a983b555 fix(paired-data): 4 bugs corrupting/blocking camera-supervised training data (#1007)
1. record-csi-udp.py stamped LOCAL time with a 'Z' (UTC) suffix → camera/CSI disagreed
   by the UTC offset → 0 aligned pairs. Now writes true UTC via datetime.now(timezone.utc).
2. align-ground-truth.js kept empty-keypoint (non-detection) records at confidence 0,
   collapsing window avgConf below threshold → all windows rejected. Now skipped at load.
3. extractCsiMatrix silently zero-padded/truncated mixed-subcarrier frames. Now frames
   are filtered to the session's modal subcarrier count before windowing — never padded.
4. CSI/feature matrices are filled frame-major but were labeled shape [nSc, nFrames] —
   transposed. Labels corrected to [nFrames, nSc] / [nFrames, dim].

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-17 10:17:12 -04:00
rUv c6e7667676 Merge pull request #1104 from ruvnet/fix/issue-1049-configurable-guard
fix(sensing-server): make multistatic guard interval configurable (closes #1049)
2026-06-17 09:53:23 -04:00
rUv d639c747df Merge pull request #1114 from ruvnet/examples/through-wall-tools
examples(through-wall): ESP32 sensor auto-detection + WiFlow analysis tools
2026-06-16 17:02:38 -04:00
ruv 42c764652d examples(through-wall): ESP32 sensor auto-detection + WiFlow analysis tools
- wiflow_browser.html: auto-detect live ESP32 nodes from the /ws/sensing stream and lock
  them as the model schema (NODE_IDS/CSI_DIM dynamic), persisted + restorable
- wiflow_ab.py: leakage-controlled A/B (chronological/random/blocked-gap/grouped-bucket,
  multi-seed) — the honest CSI→pose evaluation harness
- wiflow_capture.py / wiflow_train.py / wiflow_infer.py: camera-paired capture + train + infer
- pose.html: live WiFi-inferred skeleton viewer; serve.py: static server
- gitignore the regenerable 1.5MB model.npz artifact

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 17:00:57 -04:00
rUv db02956c22 Merge pull request #1113 from ruvnet/chore/bump-ruv-neural-submodule
chore: bump ruv-neural submodule to current main
2026-06-16 17:00:56 -04:00
ruv c84ea39e62 chore: bump ruv-neural submodule → current main (web console, closed-loop neuromod, ruvn mention)
Advances the vendored ruv-neural submodule from the stale 'Initial' pin (1ece3af) to
current main (81be9e1): the static web console UI, the closed-loop neuromodulation
platform, repositioned README, and the @ruvnet/ruvn companion-tool mention.
ruv-neural is not a v2 workspace member, so this does not affect the workspace build
(cargo metadata resolves clean).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 17:00:13 -04:00
rUv 760d05026c Merge pull request #1112 from ruvnet/chore/extract-swarm-worldgraph-submodules
Extract ruview-swarm → ruvnet/ruv-drone and world crates → ruvnet/worldgraph (submodules)
2026-06-16 16:49:58 -04:00
ruv a784546918 ci(ruview-swarm): drop removed itar-unrestricted feature from test matrix
The industrial rescope (ruv-drone) removed the itar-unrestricted feature flag —
formation/allocation/raft/flight-control are now default capabilities. Update the
'ruflo+itar' matrix entry to just '--features ruflo' so CI matches the new feature set.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 16:34:06 -04:00
ruv 9c751d0d92 chore(worldgraph): bump submodule — README + metadata polish
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:52:34 -04:00
ruv a13e9b66cb chore: bump ruv-drone + worldgraph submodules (LICENSE + CI polish)
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:43:10 -04:00
ruv 6db183bf3e chore(swarm): bump ruv-drone submodule — README cleanup
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:35:06 -04:00
ruv f65d0f79e7 chore(swarm): bump ruv-drone submodule (rescope + stray-file cleanup)
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:28:30 -04:00
ruv 7fb3b88061 chore(swarm): bump ruv-drone submodule — industrial rescope (drop ITAR/USML gating)
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:27:24 -04:00
ruv aeac5f5543 chore(worldgraph): extract geo+worldgraph+worldmodel to ruvnet/worldgraph submodule
- published as github.com/ruvnet/worldgraph (3-crate workspace, history via git-filter-repo)
- replace the 3 in-tree crates with one submodule at v2/crates/worldgraph
- parent workspace: drop the 3 members, exclude the submodule (it is its own workspace),
  repoint workspace.dependencies(worldmodel) + engine/sensing-server path-deps into it
- cargo metadata resolves clean (geo/worldgraph/worldmodel consumed from the submodule)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:14:34 -04:00
ruv c257e67c3d chore(swarm): extract ruview-swarm to ruvnet/ruv-drone submodule
- ruview-swarm published as github.com/ruvnet/ruv-drone (history preserved via subtree split)
- replace the in-tree crate with a submodule at v2/crates/ruview-swarm (branch main)
- standalone repo dropped the unused wifi-densepose-core path-dep; export-control NOTICE added there
- workspace member path unchanged; cargo metadata resolves ruview-swarm from the submodule

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:03:56 -04:00
ruv a4d5ea88f3 feat(examples): in-browser WiFlow trainer + camera-supervised pipeline + ADR-180/181/181A
Tonight's real WiFlow work, all honest:
- examples/through-wall/: live 2-node CSI demo (index.html), the WiFlow
  camera-supervised pipeline (wiflow_capture/train/infer.py — proven +9.4pp
  over mean-pose baseline on ruvultra), the live pose viewer (pose.html),
  and the COMPLETE in-browser trainer (wiflow_browser.html): 4-stage
  calibrate->capture->train->infer, TF.js WebGPU/WASM/WebGL, MediaPipe
  camera supervision, IndexedDB persistence, mean-pose-baseline honesty.
- ADR-180 (through-wall hand-off demo), ADR-181 (full browser WiFlow,
  WASM+WebGPU, calibration phase, mobile/secure-context matrix),
  ADR-181A (binary CSI framing protocol).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 17:31:19 -04:00
ruv ebe217569b feat(examples): real live WiFi-CSI through-wall sensing demo
Self-contained Three.js r128 demo at examples/through-wall/ that renders
ONLY genuine data streamed from the running sensing-server over
ws://localhost:8765/ws/sensing. No simulation, no fabricated frames, no
fake skeleton.

Renders, driven by real /ws/sensing frames:
- 20x20 signal_field floor heatmap (real values)
- coarse RF-localization puck from persons[0].position (labeled coarse,
  NOT pose; peak signal_field cell as fallback)
- live motion/breathing/variance/rssi bars + motion sparkline
- presence/confidence/estimated_persons/active-node/tick/Hz meters
- 3D room with wall + doorway dividing office (node 9) / hallway (node 13)
- honest mutually-exclusive banner: LIVE (source=esp32) / SIMULATED /
  NO SERVER, showing the real source verbatim
- optional webcam tile (ground-truth-when-visible, separate from CSI)

Reuses scene/lights/bloom/CSS + webcam path from
examples/three.js/demos/05-skinned-realtime.html, the floor-heatmap idea
from ui/observatory/js/, and the threaded no-cache server from
examples/three.js/server/serve-demo.py (serve.py on :8080).

Verified against the live server: real frame source=esp32, nodes [9,13],
400 signal_field values, persons[0].position present. Python proof PASS.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 16:20:49 -04:00
ruv c27d6cc98e fix(sensing-server): make multistatic guard interval operator-configurable (#1049)
Two ESP32-S3 nodes on WiFi/ESP-NOW sync drift 10-150 ms (~70 ms typ.), exceeding
the 60 ms default guard → permanent trust demotion to Restricted, all pose output
suppressed, 200k+ errors, no escape but a container restart.

Add a direct WDP_GUARD_INTERVAL_US override (+ optional WDP_SOFT_GUARD_US) to
multistatic_guard_config_from_env. Precedence (most-specific wins): direct
override > WDP_TDM_SLOTS+WDP_TDM_SLOT_US schedule-derived > 60ms/20ms default.
Soft band always clamped strictly below hard; malformed/zero ignored (falls back,
never breaks fusion). Effective guard logged at startup.

Pinned by 6 tests (multistatic_guard_config_tests). sensing-server bin tests
449 -> 455, 0 failed. Python proof PASS, hash unchanged (off signal path).

Closes #1049.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 13:41:43 -04:00
rUv cafbeb1e81 fix(wasm-edge): sanitize non-finite host floats at the WASM↔host frame boundary (#1102)
Closing beyond-SOTA security review of wifi-densepose-wasm-edge (ADR-040,
~70 edge modules). The two WASM↔host boundaries (lib.rs::on_frame/on_timer
and bin/ghost_hunter.rs::on_frame) read raw IEEE-754 f32 from the csi_get_*
imports with no finiteness check — the crate had zero is_finite/is_nan
guards and its clamp helpers propagate NaN. A single non-finite host value
latches NaN into long-lived per-module accumulators (EMA / Welford / phasor
sums / anomaly baselines), after which detectors fail degraded (stuck gate
state, silently-disabled checks) — silent corruption, not a crash.

Add sanitize_host_f32() (non-finite -> 0.0, core-only for no_std) applied at
every host_get_* float read: one chokepoint covering all downstream modules,
mirroring the existing M-01 negative-n_subcarriers boundary clamp. LOW /
defense-in-depth (the Tier-2 DSP firmware supplies the imports, a semi-trusted
boundary).

Pinned by boundary_tests::{sanitize_passes_finite_values_through,
sanitize_maps_non_finite_to_zero,
coherence_monitor_nan_latches_without_sanitize_but_not_with} — the last
asserts on the current CoherenceMonitor that a raw NaN frame latches the
smoothed score while the sanitized path stays finite.

Other review dimensions attested clean with evidence (see CHANGELOG): no
hot-path panics (all unwrap/expect are test-only or std-gated RVF builder),
all bounds min()-clamped, all index-by-cast const-bounded or guarded, no
leaking closures (no move||/forget/leak), no secrets.

Verified: host `cargo test --features std,medical-experimental` 672 passed /
0 failed (+3 new tests); all three wasm32-unknown-unknown release artifacts
build clean (lib default no_std/panic=abort, ghost_hunter standalone-bin,
medical-experimental); Python proof VERDICT PASS, hash unchanged.
2026-06-15 13:06:46 -04:00
rUv c859f6f743 security(occworld-candle): int32-checkpoint crash + degenerate-input guards + ADR-179 (closes Milestone #9) (#1101)
* fix(occworld-candle): security review fixes — int32 checkpoint crash + predict input validation

Beyond-SOTA security + correctness review of wifi-densepose-occworld-candle
(Milestone #9, crate 4/4 — the last ungated crate).

Findings fixed:

1. HIGH (MEASURED) — checkpoint-load crash on any int32 tensor.
   model.rs mapped safetensors I32 -> candle DType::I64 and passed the raw
   int32 byte buffer (4 bytes/elem) to Tensor::from_raw_buffer(.., I64, ..).
   Candle derives elem_count = data.len() / dtype.size(), so the I64 path
   halved the count while keeping the original shape -> a tensor whose shape
   claims 2x its storage. Reading it PANICS (slice OOB: "range end index 6
   out of range for slice of length 3") on any checkpoint containing an int32
   tensor. Fixed: I32 -> DType::I32, I16 -> DType::I16 (both first-class
   candle dtypes). Reproduced on old code; pinned in tests/checkpoint_loading.rs.

2. LOW (MEASURED) — predict() lacked frame/batch validation at the input
   boundary. f_in > num_frames*2 over-indexed the temporal embedding (cryptic
   candle "gather" error); zero frame/batch fed a zero-element tensor in. Now
   rejected with a clear ShapeMismatch. Pinned in tests/input_validation.rs.

3. LOW (MEASURED) — divide-by-zero panic in the public VQCodebook::encode on a
   rank-0 / empty-last-dim tensor (last == 0). Now fails closed with a clear
   error. Pinned in vqvae.rs unit tests.

Dimensions confirmed clean with evidence: panic surface (no unwrap/expect/
panic in prod paths), NaN-state-poisoning (N/A — stateless engine, u8 input),
unbounded-alloc/shape-data mismatch (defended upstream by safetensors::
validate), secrets (none). unsafe_code = forbid.

Validation (MEASURED, Windows): crate 31/31 pass; workspace 0 failed (lone
desktop api_integration "Access is denied" file-lock flake passes 21/21 in
isolation); Python proof VERDICT PASS, hash f8e76f21…446f7a unchanged.

Warrants ADR slot 179 (parent to author).

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

* docs(adr): ADR-179 — occworld-candle checkpoint-load hardening (closes Milestone #9)

Records the HIGH int32-checkpoint crash fix (I32→I64 dtype-widening → slice-OOB
panic on load = DoS) + 2 LOW degenerate-input fixes from 5e77f47e5. Stateless
engine (NaN-poisoning N/A), unsafe forbidden, safetensors validate() defends
malloc upstream. occworld 31/31. Final ungated crate — Milestone #9 complete.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 12:35:29 -04:00
rUv 10c813fde3 security(desktop): IPC serial-command-injection + over-broad shell capability + ADR-178 (#1100)
* fix(security): desktop IPC serial-command-injection + over-broad shell capability (ADR-178)

Beyond-SOTA security review of wifi-densepose-desktop (Tauri v2). Two real
findings, each MEASURED on Windows (crate builds + tests under
--no-default-features):

WDP-DESK-01 (MODERATE) — serial command injection via configure_esp32_wifi.
The #[tauri::command] handler concatenated webview-supplied ssid/password into
newline-terminated serial commands with no validation; a \r\n let a compromised
webview inject an arbitrary follow-up firmware command (reboot/erase). Added
validate_wifi_credentials() enforcing WPA2 length bounds and rejecting all
control characters, called fail-closed before any serial write. Pinned by 3
new tests (rejects \r\n / \n / NUL injection, rejects out-of-range, accepts
valid boundaries).

WDP-DESK-02 (MODERATE) — removed unused shell:allow-execute / shell:allow-open
from capabilities/default.json. The Rust backend spawns processes via
std::process::Command (bypassing the allowlist) and the UI only uses
dialog.open; the shell perms were unused privilege granting the webview
arbitrary host command execution on compromise. Regenerated capabilities.json
confirms only core:default + dialog perms remain.

lib tests 18 -> 21 (+3 pins), integration 21 -> 21, 0 failed. Python
deterministic proof unchanged (f8e76f21...46f7a; desktop off the signal path).

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

* docs(adr): ADR-178 — desktop IPC injection fix + capability least-privilege

Records the 2 MEASURED MODERATE fixes in feddcde9d: WDP-DESK-01 (webview
ssid/password \r\n-injected arbitrary firmware serial commands → validated
fail-closed) and WDP-DESK-02 (unused shell:allow-execute/open capability
granted to the webview → removed). 30-command IPC surface + capability scope
audited; 6 dimensions clean-with-evidence. desktop 18→21.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 12:01:17 -04:00
rUv 20ad75f30c feat(ADR-131): HOMECORE-UI dashboard + BFF gateway — review-fixed (supersedes #1082) (#1099)
* feat(ADR-131): HOMECORE-UI operational dashboard + BFF gateway

Complete two-tier Cognitum operator dashboard (ADR-131), served by
homecore-server at /homecore, plus the single-origin BFF gateway that
wires it to real backends.

Front-end (zero-dep vanilla TS/JS + CSS, exact Cognitum design tokens):
- All 10 panels (§4.1-4.10): dashboard, SEED fleet + detail, fleet map,
  entities (live WS subscribe_events, never polls), rooms, COGs,
  calibration wizard, events + automation builder, witness/audit, settings.
- §6 UX invariants in code: first-class provenance, prominent stale/veto/
  fragility, null(not-trained) vs withheld vs error, --mono everywhere,
  Hailo vs CPU COG distinction.
- api.js calls the gateway routes in production; mock demoted to a
  dev-only ?demo=1 fixture (no mock in prod); typed error states.
- Tests under plain node: import-graph, boot, render-smoke (22),
  interaction (3), prod-errors (13) — 5 files green; bundle ~137 KB
  (~37x smaller than HA), <2 ms/cold-render.

BFF gateway (homecore-server/src/gateway.rs, compiled + tested on Rust 1.89):
- /api/cal/* reverse-proxy to the calibration API (ADR-151).
- GET /api/homecore/rooms with the RoomState adapter (breathing->breathing_bpm,
  heartbeat:null->heart_bpm:null, injected anomaly.threshold/room_id).
- GET /api/homecore/cogs supervisor over /var/lib/cognitum/apps/.
- GET /api/homecore/appliance from /proc + TCP service probes.
- SEED-device/appliance routes return typed 503 upstream_unavailable.
- cargo test -p homecore-server = 12/12; run live (curl-verified);
  fixed a real double-v1 proxy-URL bug found during live testing.

Honest scope: W1/W2/W4/W6-appliance functional; W3/W5/W6-Hailo/federation
return typed 503 (depend on services/hardware not in this repo).

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

* fix(homecore-ui): resolve code-review findings — SSRF guard, CORS/trace coverage, §6 honesty, crash guards

Addresses the high-effort review of PR #1082:
- SECURITY: cal_proxy rejects path-traversal/confused-deputy SSRF (`.`/`..`
  segments, backslash, %2e%2e/%2f, absolute) on raw+decoded forms → 400,
  before attaching the server-side calibration bearer.
- CORRECTNESS: /api/homecore/* + /api/cal/* now covered by the shared CORS
  allowlist (build_cors_layer, exported from homecore-api) + TraceLayer —
  previously merged outside router()'s layers (no CORS, no tracing).
- §6 HONESTY (no fabricated data): dashboard renders '—' for null metrics
  (not "null%"/"null°C"); cogs Hailo pill reflects the REAL appliance probe
  (not hardcoded "connected"); room anomaly threshold passed through / null,
  not a fabricated 0.5.
- ROBUSTNESS: cogs asArray(hef) guards a non-array manifest field; calibration
  progress guards target<=0 (no NaN%/Infinity%); restart clears the poll timer.
- CLEANUP: mock.js is now a cached DYNAMIC import (demo-only) — never bundled
  in production (§2.2).
- New ui/tests/unit-fixes.mjs pins the above; ADR-131 + CHANGELOG updated.

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

---------

Co-authored-by: Nick Ruest <127058086+nicholas-ruest@users.noreply.github.com>
2026-06-15 11:11:19 -04:00
rUv 1df6d1e1ee security(nvsim): guard degenerate input — config panic + NaN silent-corruption + ADR-177 (#1098)
* fix(nvsim): guard degenerate input — config-induced panic + NaN-state poisoning

Beyond-SOTA security review of the ADR-089 NV-diamond simulator (milestone #9,
crate 2 of 4). Two real degenerate-input findings, each pinned fails-on-old:

NVSIM-DT-01 (config panic/DoS, pipeline.rs): an external f_s_hz == 0 made
dt == +Inf, dt_us saturated to u64::MAX, and `sample * dt_us` panicked with
"attempt to multiply with overflow" at sample >= 2 (debug/WASM panic=abort;
garbage t_us in release). Fix: sanitise dt (non-finite/non-positive -> 1 µs
fallback), cap the u64 cast, and saturating_mul the timestamp.

NVSIM-NAN-01 (NaN-state poisoning, digitiser.rs): a non-finite scene parameter
(NaN dipole position / Inf moment / NaN loop radius) bypasses the near-field
clamp (NaN < R_MIN_M is false) and yields a NaN field; at the ADC `NaN as i32`
== 0 silently emitted b_pt=[0,0,0] with ADC_SATURATED CLEAR — indistinguishable
from a legit zero-field reading. Fix at the funnel: adc_quantise treats any
non-finite input as out-of-range -> clamps to code 0 AND raises the saturation
flag, so the corruption is visible downstream.

Determinism integrity, panic-free MagFrame deserialisation, and RNG seeding
confirmed clean with evidence. The published cross-machine witness
(cc8de9b0…93b4) is unchanged — guards only affect degenerate inputs.

cargo test -p nvsim --no-default-features: 50 -> 53 passed, 0 failed.
Workspace green; Python deterministic proof unchanged (f8e76f21…46f7a,
nvsim off the signal proof path). Needs ADR slot 177.

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

* docs(adr): ADR-177 — nvsim degenerate-input hardening

Records the 2 MEASURED MEDIUM fixes in 37764be55 (NVSIM-DT-01 config-induced
overflow panic / WASM-abort DoS; NVSIM-NAN-01 non-finite scene param →
silent fake zero-field reading with saturation flag clear) + 3 pins, and the
clean-with-evidence determinism/deser/div-by-zero verdict. Cross-machine
witness cc8de9b0…93b4 reproduces unchanged.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 10:55:04 -04:00
rUv 4a083999e5 security(ruview-swarm): fail-closed on NaN/Inf at the swarm-comm trust boundary + ADR-176 (#1096)
* fix(ruview-swarm): fail-closed on NaN/Inf at swarm-comm trust boundary (ADR-148)

Beyond-SOTA security review of the ADR-148 drone swarm control plane found
four IEEE-754 NaN/Inf fail-open / DoS bugs on data crossing the untrusted
swarm-comm boundary (receive_peer_state / receive_peer_detection accept full
DroneState/CsiDetection whose f64/f32 fields deserialize with no finite-check).

- HIGH: failsafe::tick collision-avoidance + battery checks fail-open on NaN
  (NaN < threshold == false silently disabled collision avoidance / kept a
  NaN-battery drone Nominal). Now fails closed to EmergencyDiverge / RTH.
- MED: geofence::check NaN-altitude bypass returned Safe through the
  point-in-polygon path. Now leading non-finite-coordinate guard -> HardBreach.
- MED/DoS: antijamming FhssRadio panicked with "% 0" on an empty deserialized
  channels_mhz. Now len==0 early-returns (benign 0.0 sentinel).
- LOW: multiview::fuse propagated a NaN victim_position into the fused
  "confirmed victim" location. Now requires finite confidence + position.

Each fix pinned by a fails-on-old / passes-on-new test (MEASURED: old code
returned Nominal/Safe or panicked). cargo test -p ruview-swarm
--no-default-features: 117 -> 123 passed, 0 failed. Workspace green; Python
deterministic proof unchanged (f8e76f21...46f7a, off the signal path).

Documented-not-fixed (ADR slot 176): Raft AppendEntries lacks Log-Matching
consistency check (topology/raft.rs); MavlinkSigner::verify uses non-constant
-time tag compare + no replay-window rejection (already doc-flagged).

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

* docs(adr): ADR-176 — ruview-swarm NaN-fail-open safety review

Records the 4 MEASURED fail-open safety bugs fixed in f671000d7 (collision
avoidance, battery RTH, geofence, anti-jamming %0 panic — all NaN/Inf
defeating a safety comparison at the swarm-comm trust boundary) + 6 pins,
5 clean-with-evidence dimensions, and the 2 genuine issues deferred to a
focused follow-up (Raft AppendEntries log-matching; MAVLink signer
constant-time + replay window).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 09:55:40 -04:00
rUv 0f64d23516 feat(bench): int8 quantization of WiFlow-STD half pose model — MEASURED trade-off (ADR-175, honest negative) (#1095)
Sub-deliverable 8.2 of the benchmark/optimization milestone. Quantizes the
843,834-param "half" WiFlow-STD pose model (half_best.pth) to int8 two ways and
MEASURES the accuracy/size trade-off vs fp32 under ONE locked normalization
(ADR-173 torso-diameter PCK, upstream calculate_pck use_torso_norm=True), on the
same seed-42 file-level 70/15/15 test split that produced the fp32 sweep numbers.

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
  int8 PTQ static  40.98% pck@20  94.98% pck@50  0.038262 mpjpe  1.046 MB  (-55.64pp)
  int8 QAT (3 ep)  67.48% pck@20  98.69% pck@50  0.026548 mpjpe  1.043 MB  (-29.15pp)

Verdict (honest no): int8 is NOT a win at the strict PCK@20 edge target. Static
PTQ collapses; QAT recovers a large share but still loses 29 pp @20 for a 3.2x
size win — keep fp32/fp16 on the edge. Disclosed: QAT fake-quant val pck@20 was
83.45% but converted int8 scores 67.48% (~16pp convert_fx gap, reported honestly).

Deliverables:
- v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py (reproducible:
  header carries the exact ssh command + run date; QAT primary, static PTQ fallback)
- docs/adr/ADR-175-int8-quantization-half-pose-model-measured.md (MEASURED table,
  locked normalization, QAT-vs-PTQ labeling, verdict, reproduction, limitations)
- CHANGELOG [Unreleased] ### Added entry

No production Rust or signal-pipeline change. Python deterministic proof unchanged
(f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a, bit-exact).
2026-06-15 09:16:22 -04:00
rUv b209b8b778 ci(bench): compile-verify regression gate for v2 criterion benches + ADR-174 (#1094)
* ci(bench): wire v2 criterion benches into CI as a compile-verify regression gate

Sub-deliverable 8.3 of the benchmark/optimization milestone (needs ADR slot 174).

The v2/ workspace ships 26 criterion benches across 18 crates, but benches are
not part of `cargo test`, so nothing in CI compiled them and they silently rot
when a public API they call changes.

Add `.github/workflows/bench-regression.yml`:
  - bench-compile (HARD GATE): `cargo bench --workspace --no-default-features
    --no-run` compiles + links every default-feature bench (no measurement) plus
    the cir-gated cir_bench — a real, deterministic regression guard against
    bench bit-rot.
  - bench-fast-run (INFORMATIONAL, continue-on-error, never gates): runs a
    curated pure-CPU subset (nvsim, ruvector sketch/fusion) in criterion
    quick-mode and uploads logs as an artifact.

No timing-regression gate, by design: wall-clock on shared GitHub runners varies
2-3x run-to-run, so a hard threshold or cross-runner `criterion --baseline`
compare would manufacture false failures. The honest scope is compile-verify +
informational-run; the workflow header documents the self-hosted-runner
condition under which true timing-gating becomes honest. The crv-gated crv_bench
is excluded because its crates.io dep ruvector-crv 0.1.1 fails to build upstream.

Running the gate immediately caught one already-bit-rotted bench:
wifi-densepose-mat/detection_bench failed to compile (E0063: missing field
last_rssi in SensorPosition). Fixed (last_rssi: None) and re-verified.

Validation (MEASURED): mat detection_bench + cir_bench + nvsim + ruvector +
vitals + swarm benches compile under --no-default-features; fast subset runs;
`cargo test -p wifi-densepose-mat --no-default-features` 174 passed / 0 failed;
Python proof PASS, hash f8e76f21...46f7a unchanged.

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

* docs(adr): ADR-174 — CI bench-regression compile-verify gate

Records sub-deliverable 8.3 (bench-regression.yml, committed c4c59e085):
a hard compile-verify gate over all 26 v2 criterion benches (caught + fixed
one real bit-rotted bench, mat/detection_bench E0063) + an informational
fast-run. Documents the honest scope — no timing-regression gate, since
shared-runner wall-clock varies 2-3x; states the self-hosted-runner condition
under which timing gating becomes honest.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 08:26:38 -04:00
rUv 90a88ada9a feat(train): metric-locked PCK/MPJPE accuracy harness + ADR-173 (resolve PCK-definition ambiguity) (#1092)
* feat(train): metric-locked PCK/MPJPE accuracy harness — resolve PCK-definition ambiguity

The SOTA brief (docs/research/sota-nn-train-benchmark-brief.md §1/§3.1/§4)
identifies metric ambiguity as the single biggest threat to any beyond-SOTA
claim: three PCK@20 numbers (96.09% WiFlow-STD image-normalized, 81.63%
AetherArena torso-PCK, 61.1% GraphPose-Fi standard PCK) cannot be lined up
because each silently uses a different normalization. The project was retracted
twice over this (a withdrawn 92.9% used absolute pixels, not torso).

New src/accuracy.rs makes the normalizer explicit, selectable, and carried with
every reported number:
- PckNormalization enum: TorsoDiameter (standard MM-Fi/GraphPose-Fi hip↔hip),
  BoundingBoxDiagonal (looser WiFlow-STD image-normalized), AbsolutePixels(t)
  (retracted convention, reproducible + clearly non-comparable).
- pck_at(pred, gt, vis, k, normalization) — one canonical PCK reusing the
  metrics_core geometric primitives (no duplicate kernel).
- mpjpe(pred, gt, vis) — 2D/3D, mm.
- PoseAccuracy { pck_at: BTreeMap<u8,f32>, mpjpe, normalization, n_keypoints,
  n_frames } via accuracy_report(frames, ks, normalization) — an unlabeled PCK
  number is structurally impossible.

17 hand-computed deterministic tests (no GPU, no datasets) prove the harness
arithmetic, including the key proof that identical predictions score
0.50 / 1.00 / 0.75 under the three normalizations, plus graceful degenerate
handling (zero torso, empty frames, NaN coords — no panic, never false-perfect).

This is measurement infrastructure, NOT an accuracy claim. Public API worth an
ADR — needs ADR slot 173 (parent to write).

wifi-densepose-train lib 191→206, test_metrics 12→14, 0 failed; full workspace
green (exit 0); Python deterministic proof unchanged
(f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a).

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

* docs(adr): ADR-173 — metric-locked PCK/MPJPE accuracy harness

Documents the accuracy harness (committed 3a8b2ed13) that resolves the
PCK-definition ambiguity flagged as the #1 beyond-SOTA risk in the SOTA brief
(#1090): three historical numbers (96/81.6/61) used three unstated
normalizations. The harness makes normalization explicit + selectable
(PckNormalization enum) and every reported number carries its definition.
Key proof: identical predictions → 0.50/1.00/0.75 under torso/bbox/abs.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 00:41:02 -04:00
rUv cfd0ad76cf security(core,cli): pin CSI-deserialiser DoS-resistance + ADR-172 (clean-with-evidence) (#1091)
* test(core,cli): pin DoS-resistance of CSI deserialisers (ADR-127 security review)

Beyond-SOTA security review of wifi-densepose-core + wifi-densepose-cli.
Load-bearing-question verdict: the NaN-state-poisoning bug class does NOT
originate in core — core exposes no stateful accumulator (no Welford,
von-Mises, IIR, voxel grid, running mean); each downstream crate rolls its
own, so each fix is correctly local. Both crates confirmed clean on every
reviewed dimension (panic-on-adversarial-input, NaN handling, unbounded
memory, path traversal, secrets) — no production code changed.

Adds 4 regression pins locking in two existing-but-untested DoS guards:
- core: from_canonical_bytes shape guard (Vec::with_capacity bound) — proven
  to fail with `capacity overflow` when the saturating-mul guard is removed.
- core: canonical decoder never panics on arbitrary/truncated bytes.
- cli: parse_csi_packet rejects an oversized n_antennas*n_subcarriers claim
  before Array2 allocation (33 MB claim in a 2 KB datagram -> None).
- cli: parse_csi_packet never panics on arbitrary UDP bytes.

core: 35 -> 37 lib tests; cli: 24 -> 26 tests; 0 failed. Python proof
unchanged (f8e76f21…46f7a — off the signal path).

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

* docs(adr): ADR-172 — wifi-densepose-cli + core CSI-deserialiser security review

Records the clean-with-evidence verdict + 4 DoS-resistance regression pins
(test-only, committed in a1051607d). Documents the load-bearing finding:
the NaN-state-poisoning bug class does NOT originate in a shared core
primitive (core exposes no stateful accumulator — MEASURED via grep), so
the 3 prior downstream-local fixes are complete. Gives the wifi-densepose-cli
review its own ADR slot (core portion cross-refs ADR-127 §9).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 23:58:09 -04:00
rUv 71e8756051 docs(research): SOTA evidence brief for nn/train benchmark ADR (#1090) 2026-06-14 23:32:58 -04:00
rUv 5287497a4a security(homecore-migrate): redact secret value from malformed secrets.yaml error (#1089)
* fix(homecore-migrate): redact secret value from malformed secrets.yaml error (secret-leak)

`read_secrets` wrapped serde_yaml's parse error into `MigrateError::YamlParse {
source }`. serde_yaml's message for a typed-tag coercion failure embeds the
offending scalar verbatim, e.g. `invalid value: string "<the-secret-value>"`.
That error propagates out of `read_secrets`, is `?`-returned by the
`InspectSecrets` CLI path in main.rs, and printed to stderr by anyhow — leaking
a secret value despite the CLI's deliberate `<redacted>` design.

Fix: secrets.yaml parse failures now map to a new redacting variant
`MigrateError::SecretsParse { path, line, column }` that carries only the file
path and a coarse location (from `serde_yaml::Error::location()`), never the
scalar content. Other (non-secret) YAML files keep `YamlParse`.

Pinned by `secrets::tests::malformed_secrets_error_never_contains_secret_value`
(asserts the rendered error AND its full #[source] chain never contain the
secret value; fails on the old `YamlParse` path) plus
`malformed_secrets_error_reports_location` (still fail-closed + locatable).

ADR-165 secret-handling rule: a secret value must never appear in output.

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

* docs(homecore-migrate): record secret-leak fix in ADR-165 + CHANGELOG

Note the secrets.yaml error-redaction fix and the review's clean dimensions
(read-only source / no traversal / no panic / fail-closed versioning / no
injection) in ADR-165 §2.4, bump the test-evidence count 19→21 in §2.6, and add
an [Unreleased] Security entry to CHANGELOG.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 23:09:55 -04:00
rUv bf1dfe79fd fix(homecore core): TOCTOU race dropped/reordered state_changed events under concurrent writers (~93k→0) + 2 fail-closed hardenings (#1087)
* fix(homecore): atomic state set — close TOCTOU lost/reordered state_changed events

StateMachine::set did get() (release shard lock) → compute next + no-op
decision → insert() (re-acquire lock) → send(). The read-modify-write was
not atomic w.r.t. a concurrent writer on the same entity: a writer that
read a stale `old` could mis-classify a real transition as a no-op and drop
its state_changed event (a missed automation trigger) or fire an event whose
new_state duplicated the previously delivered one (a spurious trigger for any
automation keyed on old_state != new_state). ADR-127 §2.1 promises "writer
atomically replaces the map entry"; the implementation did not.

Fix: hold the DashMap shard write-lock across the whole read→decide→insert→
fire sequence via entry()/insert_entry(). tx.send is non-blocking, non-async,
and never re-enters the map, so firing under the shard lock cannot deadlock
and keeps global event order in lock-step with global commit order.

Pinned by concurrent_set_fires_no_duplicate_adjacent_events: 4 writers
toggling one entity A/B; asserts no two consecutive fired events carry the
same new_state (impossible under correct serialisation). Fails reliably on
the old code (~365-476 duplicate-adjacent events on the first trial), passes
on the fix across repeated runs.

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

* harden(homecore): bound entity_id length — close memory-DoS at the REST boundary

homecore-api/src/rest.rs parses untrusted path segments straight through
EntityId::parse (get/delete/set_state). With no length cap, an otherwise-valid
id like "a." + many MB of [a-z0-9_] was accepted; a POST /api/states/<giant>
would persist it into the DashMap state store, permanently growing memory
(amplification across distinct ids).

Fix: reject ids longer than MAX_ENTITY_ID_LEN (255, HA-compatible) up front in
parse(), before any per-char scan, with a new EntityIdError::TooLong. Fails
closed at the boundary type so every caller (REST, registry deserialize,
automation) is protected.

Pinned by entity_id_length_boundary: exactly-MAX accepted, MAX+1 rejected,
4 MiB id rejected as TooLong. Fails on old code (oversized parses Ok).

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

* harden(homecore): isolate panicking service handlers (catch_unwind)

ServiceRegistry::call already ran handlers outside the registry lock (the
Arc<dyn ServiceHandler> is cloned out of the read guard first), so a panic
could never poison the RwLock or block other callers — good. But a panicking
handler unwound through call() into the caller's task; the task driving the
engine (e.g. an axum request handler invoking a service) could be aborted by
one buggy integration.

Fix: wrap the handler future in AssertUnwindSafe + FutureExt::catch_unwind and
convert a panic into ServiceError::HandlerPanicked. Mirrors HA isolating
service-handler exceptions. The registry stays fully usable afterwards.

Pinned by panicking_handler_is_isolated_and_registry_survives: the panicking
call returns HandlerPanicked (not an unwind), a sibling healthy service still
returns its value, and the bad service remains registered. Fails on old code
(the await point panics instead of returning Err).

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

* test(homecore): pin event-bus lag safety (bounded broadcast, no DoS)

Documents-with-evidence that the core EventBus does NOT have the homecore-api
WS broadcast-lag failure: with EVENT_CHANNEL_CAPACITY=4096, firing 3x capacity
while a subscriber never drains keeps fire_* non-blocking (publisher never
waits on slow receivers), gives the slow receiver a recoverable Lagged(n)
(drop-oldest + re-sync) rather than a closed channel, and leaves the bus live
for a fresh fast subscriber. No code change — pins the clean dimension.

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

* docs(homecore): record ADR-127 §9 security+concurrency review + CHANGELOG

Documents the three pinned fixes (HC-RACE-01 state-set TOCTOU, HC-EID-LEN-01
entity_id memory-DoS, HC-SVC-PANIC-01 service-handler isolation) and the
clean dimensions (bounded event-bus lag handling, lock discipline / no
lock-across-await, no panic-on-input) with their evidence.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 22:28:05 -04:00
rUv 9b126e927e harden(assist security): bound untrusted utterance (DoS); cmd-injection/ReDoS/NaN/fail-open all proven clean with evidence (#1086)
* fix(homecore-assist): bound untrusted utterance length, fail closed (ADR-133 security)

The intent recognizers accept utterances from untrusted callers (voice
transcripts, the WebSocket `assist` command). Neither the regex nor the
semantic path bounded utterance length, so a pathological multi-megabyte
utterance forced an unbounded `to_lowercase()` clone plus a per-registered-
pattern scan (and, in the semantic path, full tokenisation + feature-hash
embedding) — an allocation/CPU amplification on attacker-controlled input.
The `regex` crate is linear-time (no catastrophic backtracking), so this was
a throughput/memory DoS rather than a hang, but it was still unbounded.

Fix: introduce MAX_UTTERANCE_BYTES (4 KiB — far above any real spoken
command) and check it at both recognizer boundaries BEFORE any allocation or
scan. An over-length utterance fails closed: Ok(None) (no intent, no action),
identical to an unrecognised phrase. No legitimate command is affected.

Pinned by fails-on-old tests:
  - recognizer::over_length_utterance_fails_closed — an over-length utterance
    that contains a valid command resolves to None (would have matched before)
  - semantic_recognizer::over_length_utterance_fails_closed_semantic

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

* test(homecore-assist): pin clean security dimensions with evidence (ADR-133)

Adds regression tests documenting the dimensions reviewed and found clean,
so the properties cannot silently regress:

  - runner: no subprocess surface exists. RufloRunnerOpts.{script_path,env}
    are inert and never executed; even a hostile script_path/env spawns
    nothing. And the entity_id capture class [a-z0-9_ .] strips every shell
    metacharacter, so a resolved slot can never carry ; | & $ ` / etc into a
    (future) argv — sanitisation by construction.
    (shell_metachars_never_survive_into_a_resolved_slot,
     runner_opts_are_inert_no_process_spawned)
  - recognizer: the regex crate is a linear-time finite automaton; a classic
    catastrophic-backtracking shape (a+)+$ on adversarial input completes in
    bounded time — no ReDoS.
    (pathological_backtracking_pattern_completes_in_bounded_time)
  - embedding: embeddings are structurally finite (FNV feature-hash + guarded
    L2 normalise, no external float input, no unguarded division), so a crafted
    utterance cannot inject NaN/Inf to poison cosine k-NN; cosine against the
    zero vector is a finite 0.0, never NaN.
    (embeddings_are_structurally_finite, cosine_with_zero_vector_is_finite_not_nan,
     empty_utterance_against_empty_index_no_panic_no_match)
  - pipeline: injection-shaped utterances never deliver a metacharacter into a
    service call; the worst case resolves to a clean entity token, and an
    unrecognised utterance fails closed to not_understood (no action).
    (pipeline_injection_shaped_utterance_carries_no_metachars_to_service)

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

* docs(homecore-assist): record ADR-133 security review (HC-ASSIST-01 + clean dims)

CHANGELOG [Unreleased] Security entry + ADR-133 section 6 review notes for the
homecore-assist voice/intent pipeline review.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 21:34:38 -04:00
429 changed files with 42988 additions and 16424 deletions
+199
View File
@@ -0,0 +1,199 @@
name: Bench Regression Guard
# Sub-deliverable 8.3 of the benchmark/optimization milestone.
#
# HONEST SCOPE (read this before assuming this gates on timing):
# * The `bench-compile` job is a REAL, HARD-FAILING regression gate. It runs
# `cargo bench --no-default-features --no-run`, which type-checks and links
# EVERY criterion bench in the v2/ workspace without running a single
# measurement. Benches are not part of `cargo test`, so they silently
# bit-rot when a public API they call changes — this job catches that the
# moment it happens. This is the part of this workflow that can fail a PR.
#
# * The `bench-fast-run` job runs a small, curated subset of pure-CPU benches
# in criterion "quick mode" (short warm-up / measurement / 10 samples) and
# is INFORMATIONAL ONLY (`continue-on-error: true`). It does NOT gate on
# timing. Wall-clock timings on shared GitHub-hosted runners vary by
# 2-3x run-to-run (noisy neighbours, CPU throttling, no pinned frequency),
# so a hard ">X ms" threshold here would flake constantly and teach
# everyone to ignore it. We deliberately do not pretend to do timing
# regression-gating we cannot deliver reliably. The numbers are surfaced in
# the job log + uploaded as an artifact for humans to eyeball trends.
#
# WHY NO criterion --baseline COMPARE GATE:
# criterion's `--save-baseline` / `--baseline` compare is the textbook
# regression mechanism, but it only produces a trustworthy verdict when the
# baseline and the candidate were measured on the SAME hardware under the SAME
# conditions. GitHub-hosted runners give neither (the baseline commit and the
# PR commit land on different physical machines). Committing a baseline JSON
# measured on one runner and comparing a different runner against it would
# manufacture false regressions. If/when these benches run on a dedicated,
# frequency-pinned self-hosted runner, a `--baseline` compare with a generous
# (>2x) noise floor becomes honest and can be added then. Until then,
# compile-verify + informational-run is the honest gate.
on:
push:
branches: [ main, develop, 'feat/*' ]
paths:
- 'v2/crates/**/benches/**'
- 'v2/crates/**/Cargo.toml'
- 'v2/crates/**/src/**'
- 'v2/Cargo.toml'
- 'v2/Cargo.lock'
- '.github/workflows/bench-regression.yml'
pull_request:
paths:
- 'v2/crates/**/benches/**'
- 'v2/crates/**/Cargo.toml'
- 'v2/crates/**/src/**'
- 'v2/Cargo.toml'
- 'v2/Cargo.lock'
- '.github/workflows/bench-regression.yml'
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Debuginfo is useless in CI and the 38-crate workspace target dir otherwise
# exhausts the runner disk (mirrors ci.yml's rust-tests job). The bench
# profile inherits release + debug = true (v2/Cargo.toml [profile.bench]);
# force it off so the link step does not run out of space.
CARGO_PROFILE_BENCH_DEBUG: "0"
CARGO_PROFILE_RELEASE_DEBUG: "0"
jobs:
# ── HARD GATE: every bench must still compile + link ─────────────────────
bench-compile:
name: bench compile-verify (--no-run)
runs-on: ubuntu-latest
steps:
- name: Checkout (recursive — wifi-densepose-rufield path-deps vendor/rufield)
uses: actions/checkout@v4
with:
# The workspace includes `wifi-densepose-rufield`, which path-deps the
# `vendor/rufield` submodule crates. Without a recursive checkout the
# whole workspace fails to resolve before any bench is built.
submodules: recursive
# The workspace pulls in `wifi-densepose-desktop` (Tauri v2) whose -sys
# crates need the GTK/WebKit/serial dev libraries via pkg-config, exactly
# as ci.yml's rust-tests job documents. A `--workspace` bench build links
# the whole graph, so these are required here too.
- name: Install Tauri / GTK / serial system dev libraries
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libglib2.0-dev \
libgtk-3-dev \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libxdo-dev \
libudev-dev \
libdbus-1-dev \
libssl-dev \
pkg-config
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
with:
workspaces: v2
# Distinct cache scope from ci.yml's rust-tests so the bench profile
# artifacts (release+opt) do not evict the test profile cache.
key: bench-regression
# The core regression guard. `--no-run` compiles + links every bench
# target in the workspace's DEFAULT feature set but runs no measurement,
# so it is deterministic and fast-ish (build only). A bench that no longer
# compiles — because a type/signature it calls changed and nobody updated
# the bench — fails the build here. `--no-default-features` is the
# workspace's standard gate flag (openblas/tch/ort/onnx stay opt-out).
- name: Compile all workspace benches (default features)
working-directory: v2
run: cargo bench --workspace --no-default-features --no-run
# Feature-gated benches are skipped by the default build above because
# their `[[bench]]` entries carry `required-features`. Compile the ones we
# can guard so they are also covered against bit-rot.
# * cir → wifi-densepose-signal/benches/cir_bench.rs (ADR-134). The
# `cir` feature is pure-Rust (`cir = []`), so it builds on the stock
# runner and is a real, hard-failing guard like the step above.
#
# NOT guarded here (honest scope):
# * crv → wifi-densepose-ruvector/benches/crv_bench.rs. The `crv` feature
# pulls the crates.io dependency `ruvector-crv 0.1.1`, which currently
# FAILS to compile on stable (E0308 type mismatch in its own
# `stage_iii.rs` — an UPSTREAM bug, unrelated to bench bit-rot).
# Adding a hard `--features crv` compile step would make this workflow
# red for a reason this gate is not meant to police. Re-add this step
# once `ruvector-crv` ships a fixed release. (mqtt/onnx benches are
# likewise left to their own crate workflows.)
- name: Compile feature-gated benches (cir)
working-directory: v2
run: cargo bench -p wifi-densepose-signal --no-default-features --features cir --bench cir_bench --no-run
# ── INFORMATIONAL: run a curated fast subset (never gates) ───────────────
bench-fast-run:
name: bench fast-run (informational, non-gating)
runs-on: ubuntu-latest
# NEVER fail the workflow on this job — timings are noise-prone on shared
# runners (see header). It exists to surface trends for humans, not to gate.
continue-on-error: true
needs: [bench-compile]
steps:
- name: Checkout (recursive)
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
with:
workspaces: v2
key: bench-regression
# Curated subset = pure-CPU, fast, dependency-light criterion benches that
# finish in seconds under quick-mode flags. Each is targeted by `--bench`
# (NOT a bare `cargo bench -p`) because the crates' lib targets use the
# libtest harness, which rejects criterion's CLI flags (--warm-up-time
# etc.) and aborts the run. Quick-mode: 1s warm-up, 2s measure, 10 samples.
- name: nvsim pipeline_throughput (quick)
working-directory: v2
run: |
mkdir -p ../bench-out
cargo bench -p nvsim --no-default-features --bench pipeline_throughput -- \
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
| tee ../bench-out/nvsim_pipeline_throughput.txt
- name: ruvector sketch_bench (quick)
working-directory: v2
run: |
cargo bench -p wifi-densepose-ruvector --no-default-features --bench sketch_bench -- \
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
| tee ../bench-out/ruvector_sketch_bench.txt
- name: ruvector fusion_bench (quick)
working-directory: v2
run: |
cargo bench -p wifi-densepose-ruvector --no-default-features --bench fusion_bench -- \
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
| tee ../bench-out/ruvector_fusion_bench.txt
- name: Upload informational bench logs
if: always()
uses: actions/upload-artifact@v4
with:
name: bench-fast-run-logs
path: bench-out/
if-no-files-found: warn
+34 -22
View File
@@ -1,14 +1,10 @@
name: Continuous Deployment
on:
push:
branches: [ main ]
tags: [ 'v*' ]
workflow_run:
workflows: ["Continuous Integration"]
workflows: ["wifi-densepose sensing-server → Docker Hub + ghcr.io"]
types:
- completed
branches: [ main ]
workflow_dispatch:
inputs:
environment:
@@ -19,6 +15,11 @@ on:
options:
- staging
- production
image_tag:
description: 'Existing ghcr.io/ruvnet/wifi-densepose tag to deploy'
required: true
default: 'latest'
type: string
force_deploy:
description: 'Force deployment (skip checks)'
required: false
@@ -27,7 +28,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
IMAGE_NAME: ruvnet/wifi-densepose
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }}
jobs:
@@ -35,7 +36,9 @@ jobs:
pre-deployment:
name: Pre-deployment Checks
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch'
if: |
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') ||
github.event_name == 'workflow_dispatch'
outputs:
deploy_env: ${{ steps.determine-env.outputs.environment }}
image_tag: ${{ steps.determine-tag.outputs.tag }}
@@ -43,6 +46,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
submodules: recursive
- name: Determine deployment environment
@@ -50,14 +54,12 @@ jobs:
env:
# Use environment variable to prevent shell injection
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_REF: ${{ github.ref }}
PUBLISHED_REF: ${{ github.event.workflow_run.head_branch }}
GITHUB_INPUT_ENVIRONMENT: ${{ github.event.inputs.environment }}
run: |
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
echo "environment=$GITHUB_INPUT_ENVIRONMENT" >> $GITHUB_OUTPUT
elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
echo "environment=staging" >> $GITHUB_OUTPUT
elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then
elif [[ "$PUBLISHED_REF" == v* ]]; then
echo "environment=production" >> $GITHUB_OUTPUT
else
echo "environment=staging" >> $GITHUB_OUTPUT
@@ -65,16 +67,23 @@ jobs:
- name: Determine image tag
id: determine-tag
env:
GITHUB_EVENT_NAME: ${{ github.event_name }}
PUBLISHED_REF: ${{ github.event.workflow_run.head_branch }}
PUBLISHED_SHA: ${{ github.event.workflow_run.head_sha }}
INPUT_IMAGE_TAG: ${{ github.event.inputs.image_tag }}
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
echo "tag=$INPUT_IMAGE_TAG" >> $GITHUB_OUTPUT
elif [[ "$PUBLISHED_REF" == v* ]]; then
echo "tag=$PUBLISHED_REF" >> $GITHUB_OUTPUT
else
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
echo "tag=sha-${PUBLISHED_SHA:0:7}" >> $GITHUB_OUTPUT
fi
- name: Verify image exists
run: |
docker manifest inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.determine-tag.outputs.tag }}
docker manifest inspect "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.determine-tag.outputs.tag }}"
# Deploy to staging
deploy-staging:
@@ -129,7 +138,10 @@ jobs:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [pre-deployment, deploy-staging]
if: needs.pre-deployment.outputs.deploy_env == 'production' || (github.ref == 'refs/tags/v*' && needs.deploy-staging.result == 'success')
if: |
always() &&
needs.pre-deployment.result == 'success' &&
needs.pre-deployment.outputs.deploy_env == 'production'
environment:
name: production
url: https://wifi-densepose.com
@@ -210,7 +222,7 @@ jobs:
# kubectl scale rs -n wifi-densepose -l app=wifi-densepose,version!=green --replicas=0
- name: Upload deployment artifacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: production-deployment-${{ github.run_number }}
path: |
@@ -260,7 +272,7 @@ jobs:
post-deployment:
name: Post-deployment Monitoring
runs-on: ubuntu-latest
needs: [deploy-staging, deploy-production]
needs: [pre-deployment, deploy-staging, deploy-production]
if: always() && (needs.deploy-staging.result == 'success' || needs.deploy-production.result == 'success')
steps:
- name: Monitor deployment health
@@ -281,7 +293,7 @@ jobs:
done
- name: Update deployment status
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
script: |
const deployEnv = '${{ needs.pre-deployment.outputs.deploy_env }}';
@@ -300,7 +312,7 @@ jobs:
notify:
name: Notify Deployment Status
runs-on: ubuntu-latest
needs: [deploy-staging, deploy-production, post-deployment]
needs: [pre-deployment, deploy-staging, deploy-production, post-deployment]
if: always()
steps:
- name: Notify Slack on success
@@ -332,7 +344,7 @@ jobs:
- name: Create deployment issue on failure
if: needs.deploy-production.result == 'failure'
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
script: |
github.rest.issues.create({
@@ -355,4 +367,4 @@ jobs:
**Logs:** Check the workflow run for detailed error messages.
`,
labels: ['deployment', 'production', 'urgent']
})
})
+42 -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
@@ -170,6 +171,41 @@ jobs:
- name: ADR-135 calibration witness proof (determinism guard)
run: bash scripts/verify-calibration-proof.sh
# The workspace runs with --no-default-features, which switches OFF
# ruview-auth's `login` and `pkce` features. That silently excluded 40 of
# its 87 tests — the whole interactive sign-in path: credential storage,
# single-flight refresh, the advisory file lock, the loopback callback, and
# PKCE generation. They were green locally and never executed here.
# Measured: 47 tests with --no-default-features, 87 with --all-features.
- name: Run ruview-auth tests with all features (ADR-271 login path)
working-directory: v2
env:
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
run: cargo test -p ruview-auth --all-features
# Browser-facing JavaScript.
#
# These run the dashboard's own modules in Node with stubbed browser globals.
# They exist because the Rust suite cannot see them at all: two ADR-271/272
# defects (a service worker caching /oauth/status, and the WebSocket ticket
# helper) lived entirely in `ui/` and were invisible to a fully green
# workspace. Blocking, and fast — no browser, no install step.
ui-tests:
name: UI JavaScript Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Run UI unit tests
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
# Unit and Integration Tests
# Python pytest matrix — runs against the archived v1 Python tree.
# `continue-on-error: true` for the same reason as code-quality above:
@@ -249,7 +285,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 +536,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
+83 -12
View File
@@ -13,14 +13,29 @@
# 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.
#
# 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
# step that verifies the v2 hash against the Rust pipeline.
# 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.
#
# Production publishing fails closed until the ADR-117 §11.3 v2 witness
# hash exists. TestPyPI remains usable to validate release artifacts.
name: pip-release
@@ -67,7 +82,7 @@ jobs:
arch: x86_64
- os: ubuntu-latest
arch: aarch64
- os: macos-13 # x86_64 runner
- os: macos-15-intel # x86_64 runner
arch: x86_64
- os: macos-14 # arm64 runner
arch: arm64
@@ -136,6 +151,46 @@ jobs:
path: sdist/*.tar.gz
if-no-files-found: error
build-ruview:
name: Build ruview meta-package
if: |
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
startsWith(github.ref, 'refs/tags/v2.')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Verify lock-step package versions
shell: python
run: |
import pathlib
import tomllib
root = pathlib.Path("python")
core = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
meta = tomllib.loads((root / "ruview-meta" / "pyproject.toml").read_text(encoding="utf-8"))
core_version = core["project"]["version"]
meta_version = meta["project"]["version"]
expected_dependency = f"wifi-densepose=={core_version}"
if meta_version != core_version:
raise SystemExit(
f"package versions differ: wifi-densepose={core_version}, ruview={meta_version}"
)
if expected_dependency not in meta["project"]["dependencies"]:
raise SystemExit(f"ruview must depend on {expected_dependency}")
print(f"lock-step version: {core_version}")
- name: Build ruview wheel and sdist
run: |
python -m pip install --upgrade pip build
python -m build python/ruview-meta --outdir ruview-dist
- uses: actions/upload-artifact@v4
with:
name: ruview
path: ruview-dist/*
if-no-files-found: error
# ────────────────────────────────────────────────────────────────
# v1.99.0 — tombstone wheel (pure Python, single sdist + wheel)
# ────────────────────────────────────────────────────────────────
@@ -220,18 +275,29 @@ jobs:
# ────────────────────────────────────────────────────────────────
publish-v2:
name: Publish v2 wheels
needs: [build-wheels, build-sdist]
name: Publish wifi-densepose + ruview
needs: [build-wheels, build-sdist, build-ruview]
if: |
always() &&
needs.build-wheels.result == 'success' &&
needs.build-sdist.result == 'success' &&
needs.build-ruview.result == 'success' &&
(
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
startsWith(github.ref, 'refs/tags/v2.')
)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enforce production witness gate
if: |
startsWith(github.ref, 'refs/tags/v2.') ||
(github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi')
run: |
test -s archive/v1/data/proof/expected_features_v2.sha256 || {
echo "::error::ADR-117 §11.3 release gate is incomplete: archive/v1/data/proof/expected_features_v2.sha256 is missing or empty"
exit 1
}
- name: Gather all artifacts into dist/
uses: actions/download-artifact@v4
with:
@@ -241,12 +307,14 @@ 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
with:
repository-url: https://test.pypi.org/legacy/
password: ${{ secrets.PYPI_API_TOKEN }}
password: ${{ secrets.TESTPYPI_API_TOKEN }}
packages-dir: dist
skip-existing: true
- name: Publish to PyPI
@@ -257,6 +325,7 @@ jobs:
with:
password: ${{ secrets.PYPI_API_TOKEN }}
packages-dir: dist
verbose: true
publish-tombstone:
name: Publish v1.99 tombstone
@@ -274,12 +343,14 @@ 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
with:
repository-url: https://test.pypi.org/legacy/
password: ${{ secrets.PYPI_API_TOKEN }}
password: ${{ secrets.TESTPYPI_API_TOKEN }}
packages-dir: dist
skip-existing: true
- name: Publish to PyPI
+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 }}
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
features:
- { label: 'default', flags: '--no-default-features' }
- { label: 'train', flags: '--features train' }
- { label: 'ruflo+itar', flags: '--features ruflo,itar-unrestricted' }
- { label: 'ruflo', flags: '--features ruflo' }
- { label: 'full+train', flags: '--features full,train' }
steps:
- uses: actions/checkout@v4
+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
+14
View File
@@ -277,3 +277,17 @@ aether-arena/staging/
# MM-Fi benchmark dataset archives — large data, fetch separately, never commit
assets/MM-Fi/E0*.zip
assets/MM-Fi/*.zip
# through-wall demo: regenerable trained model artifact
examples/through-wall/model/
# RuView harness (npx ruview) build artifacts — ADR-182
harness/**/node_modules/
harness/**/*.tgz
harness/**/package-lock.json
harness/**/.claude-flow/
harness/**/ruvector.db
# ruvector runtime/hook DB — never tracked (any depth)
ruvector.db
**/ruvector.db
+8
View File
@@ -21,3 +21,11 @@
[submodule "vendor/rufield"]
path = vendor/rufield
url = https://github.com/ruvnet/rufield
[submodule "v2/crates/ruview-swarm"]
path = v2/crates/ruview-swarm
url = https://github.com/ruvnet/ruv-drone.git
branch = main
[submodule "v2/crates/worldgraph"]
path = v2/crates/worldgraph
url = https://github.com/ruvnet/worldgraph.git
branch = main
+39
View File
File diff suppressed because one or more lines are too long
+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:
+32 -8
View File
@@ -6,8 +6,8 @@
</a>
</p>
<p align="center">
<a href="https://cognitum.one/seed">
<img src="assets/seed.png" alt="Cognitum Seed" width="100%">
<a href="https://cognitum.one/marketplace/musica">
<img src="assets/musica-promo.png" alt="Cognitum Musica" width="100%">
</a>
</p>
@@ -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
@@ -601,6 +622,8 @@ claude --plugin-dir ./plugins/ruview
Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full details: [`plugins/ruview/README.md`](plugins/ruview/README.md).
**Portable harness — `npx @ruvnet/ruview`:** a lighter, host-portable companion to the in-repo plugin, minted via [MetaHarness](https://www.npmjs.com/package/metaharness) and hardened per [ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md). It runs **without cloning this repo** and on more hosts (Claude Code, Codex, Copilot, opencode, …), exposing the RuView operator tools (`onboard`, `verify`, `node_monitor`, `calibrate`, `node_flash`) over an MCP server — plus the project's **MEASURED-vs-CLAIMED honesty guardrail enforced in code** (`ruview.claim_check` flags untagged or retracted-"100%" accuracy claims). v0.1: the onboarding/verify/claim-check paths are tested (17/17, `verify.py` → PASS); the hardware tools are fail-closed wrappers. Try `npx @ruvnet/ruview` to onboard, or `npx @ruvnet/ruview claim-check --text "…"`. Source: [`harness/ruview/`](harness/ruview/README.md).
---
## 📖 Documentation
@@ -614,7 +637,8 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
| [**SENSE-BRIDGE — rvagent MCP server**](tools/ruview-mcp/README.md) | Dual-transport MCP server (`@ruvnet/rvagent`) bridging the RuView sensing stack to AI agents (Claude Code, Cursor, ruflo swarms). 6 tools wired: `ruview.presence.now`, `ruview.vitals.get_{breathing,heart_rate,all}`, `ruview.bfld.last_scan`, `ruview.bfld.subscribe`. stdio + Streamable HTTP (`POST /mcp`, Origin-validated, bearer-token auth, `127.0.0.1` bind). Full 20-tool Zod schema barrel + 5 RUVIEW-POLICY governance tools. 93 tests. [ADR-124](docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md). Try: `npx @ruvnet/rvagent stdio`. |
| [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 |
| [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [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) | 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
@@ -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):
@@ -190,4 +190,78 @@ The entity registry is a `RwLock<HashMap<EntityId, EntityEntry>>` backed by an a
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum + Tokio architecture pattern used throughout the existing server stack
- `docs/adr/ADR-126-ruview-native-ha-port-master.md` — HOMECORE master; §5.5 crate naming; §6 compatibility contract; §5.1 RUVIEW-POLICY
---
## 9. Security & concurrency review (P1 core, beyond-SOTA sweep)
Foundational review of the `homecore` crate — the state store + event bus +
service/entity registries every other HOMECORE module trusts. Same rigor as
the ADR-129/130/132/133/161 sibling reviews. **Three real fixes (one
concurrency, two hardening), each pinned by a fails-on-old test; the bus-lag
and lock-discipline dimensions confirmed clean with evidence.**
- **HC-RACE-01 (state-set TOCTOU — lost / reordered `state_changed`, the
crux). FIXED.** `StateMachine::set` did `get()` (releasing the DashMap
shard lock) → compute the next snapshot + the no-op / `last_changed`
decision → `insert()` (re-acquiring the lock) → `send()`. The
read-modify-write was **not atomic** w.r.t. a concurrent writer on the
same entity, contradicting §2.1's promise that "the writer atomically
replaces the map entry." A writer that read a stale `old` could
mis-classify a genuine transition as a no-op and **drop its
`state_changed` event** (a missed automation trigger) or fire an event
whose `new_state` duplicated the previously delivered one (a spurious
trigger for any automation keyed on `old_state != new_state`). **Fix:**
hold the shard write-lock across the entire read→decide→insert→fire
sequence via `entry()`/`insert_entry()`; `tx.send` is non-blocking,
non-async, and never re-enters the map, so firing under the shard lock
cannot deadlock and keeps global event order in lock-step with global
commit order. Pinned by `concurrent_set_fires_no_duplicate_adjacent_events`
(4 writers toggling one entity A/B; asserts no two consecutive fired
events carry an identical `new_state` — impossible under correct
serialisation; a probe observed ~93k such duplicate-adjacent events across
200 trials on the racy code, zero on the fix).
- **HC-EID-LEN-01 (unbounded `entity_id` — memory-DoS at the REST boundary).
FIXED.** `homecore-api/src/rest.rs` parses untrusted path segments
straight through `EntityId::parse`; with no length cap, an
otherwise-valid id (`a.` + many MB of `[a-z0-9_]`) was accepted and a
`POST /api/states/<giant>` would persist it into the DashMap state store
(permanent growth across distinct ids). **Fix:** reject ids longer than
`MAX_ENTITY_ID_LEN` (255, HA-compatible) up front in `parse()`, before any
per-char scan, with a new `EntityIdError::TooLong`; fail-closed at the
boundary type protects every caller. Pinned by `entity_id_length_boundary`
(exactly-MAX accepted, MAX+1 and a 4 MiB id rejected — fails on old code).
- **HC-SVC-PANIC-01 (service-handler panic not isolated). HARDENED.**
`ServiceRegistry::call` already ran handlers outside the registry lock (no
`RwLock` poisoning, no blocking of other callers — clean), but a
panicking handler unwound through `call()` into the caller's task. **Fix:**
wrap the handler future in `AssertUnwindSafe` + `catch_unwind`, converting
a panic to `ServiceError::HandlerPanicked`; the registry stays fully
usable. Pinned by `panicking_handler_is_isolated_and_registry_survives`.
**Dimensions confirmed clean (with evidence):**
- **Event-bus bounds / lag (same class as the homecore-api WS lag-DoS).**
Both `StateMachine` and `EventBus` use bounded `tokio::sync::broadcast`
(capacity 4,096). A slow subscriber gets a recoverable `Lagged(n)`
(drop-oldest + re-sync); `fire_*` is non-blocking and **never waits on
slow receivers**, so a lagging subscriber cannot block the publisher, grow
the channel without bound, or take down a fast subscriber. Evidenced by
`slow_subscriber_does_not_block_publisher_or_kill_the_bus` (fire 3×
capacity at an idle subscriber; publisher unblocked, bus stays live).
- **Lock ordering / lock-across-await (deadlock).** No code path holds two
of `{state DashMap, registry RwLock, service RwLock}` simultaneously, so
no inconsistent-ordering deadlock can exist. Every `tokio::sync::RwLock`
guard in `registry.rs`/`service.rs` is used in a single synchronous
statement and dropped before any `.await`; `call` explicitly scopes the
read guard out before awaiting the handler. The only guard held across a
send is the DashMap shard lock in `set`, across a synchronous
(non-await) broadcast send — safe.
- **Panic-on-input.** No reachable `unwrap`/`expect`/index in non-test code
beyond the safe `send().unwrap_or(0)` and the dead-but-harmless
`split_once(...).unwrap_or(...)` fallbacks on already-validated ids.
`cargo test -p homecore --no-default-features`: **20 → 24 passed, 0 failed**
(+4 pins). Workspace green; Python deterministic proof unchanged
(`f8e76f21…46f7a`, bit-exact — `homecore` is off the signal proof path).
- `docs/adr/ADR-028-esp32-capability-audit.md` — witness chain pattern (Ed25519 per state transition)
@@ -0,0 +1,444 @@
# ADR-131: HOMECORE-UI — Operational dashboard for the two-tier Cognitum stack
| Field | Value |
|-------|-------|
| **Status** | Accepted — UI implemented (§10); full backend wiring specified (§11–§12) |
| **Date** | 2026-06-14 |
| **Deciders** | ruv |
| **Codename** | **HOMECORE-UI** — first-class operator dashboard inside the Cognitum Appliance shell |
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE state machine), [ADR-128](ADR-128-homecore-integration-plugin-system.md) (HOMECORE-PLUGINS), [ADR-129](ADR-129-homecore-automation-engine.md) (automation engine), [ADR-130](ADR-130-homecore-rest-websocket-api.md) (HOMECORE-API), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (recorder/semantic search), [ADR-151](ADR-151-room-calibration-specialist-training.md) (room calibration HTTP API), [ADR-100](ADR-100-cog-packaging-specification.md) (Cog packaging), [ADR-116](ADR-116-cog-ha-matter-seed.md) (cog-ha-matter), [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) (SEED RVF ingest), [ADR-105](ADR-105-federated-csi-training.md) (federated CSI training) |
| **Tracking issue** | TBD |
| **Parent** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (sub-ADR, HOMECORE-127…134 family) |
---
## 1. Context
HOMECORE (ADR-126 through ADR-134) is the native Rust + WASM + TypeScript port of Home Assistant running as the hub on the Cognitum v0 Appliance. As of P2, the state machine ([ADR-127](ADR-127-homecore-state-machine-rust.md)), API ([ADR-130](ADR-130-homecore-rest-websocket-api.md)), and COG runtime ([ADR-128](ADR-128-homecore-integration-plugin-system.md)) are in place. What is missing is a first-class dashboard UI that operators, integrators, and residents can use to manage the full two-tier hardware stack that HOMECORE coordinates.
### 1.1 The two-tier hardware model this UI must represent
This is the most important architectural constraint the UI must carry through every panel:
- **Cognitum SEED** — a Pi Zero 2 W-based edge node. It has its own RVF vector store (8-dim, content-addressed, with kNN queries), Ed25519 witness chain, SHA-256 ingest audit trail, onboard environmental sensors (BME280 temperature/humidity/pressure, PIR motion, reed switch, ADS1115 4-channel ADC, vibration), 13 drift detectors, an MCP proxy (114 tools, JSON-RPC 2.0, default-deny policy), 98 HTTPS API endpoints, and epoch-based swarm sync for multi-SEED deployments. SEEDs sit close to the ESP32 sensing nodes and receive feature vectors from them at 1 Hz. Multiple SEEDs can form a peer mesh. **This is the sensing and memory tier.**
- **Cognitum v0 Appliance** — a Pi 5 + Hailo-10H hub, running at `:9000`. It hosts the COG runtime (`/var/lib/cognitum/apps/`), the HOMECORE state machine and event bus, the calibration service, `ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`, and acts as the fleet coordinator for multi-room correlation and federated training. The Appliance is where HOMECORE runs, and it is what the dashboard user is sitting in front of. **This is the computation and orchestration tier.**
SEEDs are **subordinate nodes that the Appliance supervises** — they are not peers. The UI navigation hierarchy must reflect this: the Appliance is the root, SEEDs are children, ESP32 nodes are leaves.
### 1.2 What the UI is not
HOMECORE-UI is **not** a re-skin of the existing Cognitum Cog Store. It is a full operational dashboard that **extends** the Cognitum platform's shell — the Cog Store, API Explorer, and Guide already exist and must remain intact, with the HOMECORE dashboard added as a first-class navigation section alongside them.
---
## 2. Decision
Build HOMECORE-UI as a **complete** TypeScript + Rust→WASM frontend (per this ADR's §3 and the HOMECORE-127…134 family) that:
1. Lives at `http://cognitum-v0:9000/homecore` (or as a dedicated nav item in the Cognitum Appliance shell).
2. Is visually and stylistically seamless with the existing Cognitum platform — same dark theme, same design tokens, same component patterns as `https://seed.cognitum.one/store`.
3. Drives the HOMECORE REST + WebSocket API ([ADR-130](ADR-130-homecore-rest-websocket-api.md)) and the calibration HTTP API ([ADR-151](ADR-151-room-calibration-specialist-training.md)) for all data.
4. Updates in real-time via the homecore `subscribe_events` WebSocket channel. **The UI must never poll for entity state.**
**This is a decision to deliver the complete operational dashboard — every panel in §4.1 through §4.10, every navigation section in §5, fully wired to live data — not a design-system scaffold or a partial first cut.** A static layout shell with placeholder data is explicitly **out of scope as a deliverable**: the design system (§3) is a means to the complete UI, not an end in itself. The acceptance bar for this ADR is that an operator can drive the full two-tier stack — fleet, entities, rooms, COGs, calibration, events, audit, and settings — from the dashboard, against real APIs, with no panel left as a stub.
### 2.1 `homecore-server` is the single backend-for-frontend (BFF) gateway
The data the dashboard needs is spread across **three backend tiers that are not one process**: (a) `homecore-api` (`/api/*` REST + `/api/websocket`, mounted in `homecore-server`); (b) the **calibration API** (`/api/v1/*`, served by a *separate* binary — `wifi-densepose calibrate-serve` / `wifi-densepose-sensing-server`); and (c) the **SEED device tier + appliance daemons** (RVF vector store, witness chain, onboard sensors, reflex rules, COG supervisor, federation), which are physically separate HTTPS services on the SEED nodes and the appliance.
The browser must talk to **exactly one origin.** Therefore `homecore-server` is promoted to the **single BFF / API gateway** for HOMECORE-UI: it serves the static assets at `/homecore`, serves `homecore-api` at `/api/*`, and **adds a new `/api/homecore/*` namespace** that proxies and aggregates the calibration API and the SEED/appliance tiers server-side. The UI only ever issues same-origin requests; cross-service auth (SEED bearer tokens, calibration tokens) is held by the gateway and **never exposed to the browser**. This collapses the CORS/multi-port problem and gives one place to enforce the long-lived-access-token auth (§4.10).
### 2.2 No mock data in production
The in-browser mock layer that the first UI cut shipped behind DEMO banners (§7.1, prior revision) is **demoted to a dev-only fixture** gated behind an explicit `?demo=1` / `HOMECORE_UI_DEMO=1` flag. The production build wires **every** panel to a real gateway endpoint. The full endpoint contract and the backend work each panel needs are specified in **§11**; the staged path to get there is **§12**. A panel may show an empty/typed-error state when its upstream is down, but it must never silently render fabricated data.
---
## 3. Design system — Cognitum platform conventions
The implementor **must study `https://seed.cognitum.one/store` as the definitive design reference before writing a single line of CSS.** The existing platform's design tokens, extracted from production, are:
### 3.1 Colour palette (CSS custom properties)
| Token | Value | Role |
|---|---|---|
| `--bg` | `#0a0e1a` | page background (very dark navy) |
| `--bg2` | `#111627` | secondary background / nav strip |
| `--card` | `#171d30` | card / panel surface |
| `--card-h` | `#1e2540` | card hover state |
| `--border` | `#252d45` | all border strokes (≈0.67px, subtle) |
| `--t1` | `#e0e4f0` | primary text (near-white) |
| `--t2` | `#8890a8` | secondary / muted text |
| `--t3` | `#505872` | tertiary / disabled text |
| `--cyan` | `#4ecdc4` | primary action colour (Install buttons, live indicators, accents) |
| `--cyan-d` | `rgba(78,205,196,0.15)` | cyan tint background for status badges |
| `--green` | `#6bcb77` | success / online / healthy states |
| `--green-d` | `rgba(107,203,119,0.15)` | green tint background |
| `--amber` | `#d4a574` | warning / stale / degraded states |
| `--amber-d` | `rgba(212,165,116,0.15)` | amber tint background |
| `--red` | `#e06060` | error / offline / veto states |
| `--red-d` | `rgba(224,96,96,0.15)` | red tint background |
| `--purple` | `#a78bfa` | informational / epoch / chain indicators |
| `--purple-d` | `rgba(167,139,250,0.15)` | purple tint background |
| `--r` | `10px` | standard border radius on all cards and panels |
### 3.2 Typography
- `--font`: `'Segoe UI', system-ui, -apple-system, sans-serif` — all body and heading text.
- `--mono`: `'Cascadia Code', 'Fira Code', Consolas, monospace` — all entity IDs, API endpoints, hex values, JSON payloads, COG binary hashes.
### 3.3 Component patterns (from the live Cog Store and API Explorer)
- **Cards**: `background: var(--card)`, `border: 0.67px solid var(--border)`, `border-radius: var(--r)`, `padding: 24px`.
- **Category pills / status badges**: small `border-radius: 46px`, uppercase text, coloured background tint (e.g. `background: var(--cyan-d); color: var(--cyan)` for `RUNNING`; `background: var(--amber-d); color: var(--amber)` for `STALE`).
- **Primary action buttons**: `background: var(--cyan)`, `color: var(--bg)`, no border — matching the existing "Install" button style exactly.
- **Secondary / ghost buttons**: transparent background, `border: 1px solid var(--border)`, `color: var(--t1)` — matching the existing "Details" button style.
- **Nav strip**: `background: var(--bg2)`, text items in `--t2`, active item highlighted in `--cyan` with a bottom underline.
- **Featured card gradient borders**: top-edge linear gradient from `var(--cyan)` to `var(--purple)` — replicate for HOMECORE section headers.
- **Live metric cards** (API Explorer status page): icon + large numeric value in `--cyan` or `--green`, label in `--t2` below, on a `var(--card)` background.
- **Method badge pills** on the API Explorer (`GET` in green, `POST` in amber, `AUTH` in purple) — reuse this same pill system for COG status indicators.
The implementor **must not introduce new colours, typefaces, or border radii.** Every component should feel like it was built by the same team that built the Cog Store and the API Explorer. A user navigating from the Cog Store into the HOMECORE dashboard should not notice a visual seam.
---
## 4. UI sections — required panels
### 4.1 System Dashboard (the "home screen")
The always-visible overview panel. Modelled on the API Explorer's live metric cards. All values update in real-time.
- **v0 Appliance health strip** — reuse the exact metric-card pattern from `seed.cognitum.one/status`: one card each for CPU %, RAM usage, Hailo-10H inference load (% utilisation), Hailo temperature, uptime, and the running services (`ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`). Values in `--cyan`, labels in `--t2`. This strip is always at the top — it represents the machine the user is looking at.
- **SEED Fleet overview** — a grid of SEED node cards (one per paired SEED) on the `var(--card)` surface with `var(--border)`. Each card shows: online/offline status pill (green/red), firmware version, epoch number, current vector count, last ingest timestamp, and witness-chain validity badge. A collapsed row shows the SEED's 5 onboard sensors in summary (PIR: yes/no, door: open/closed, temperature from BME280). Offline SEEDs render the entire card with a `--red-d` background tint. Clicking a SEED card navigates to the SEED Detail view (§4.2).
- **ESP32 Node summary** — count of active ESP32 nodes per SEED, current frame rate (target: 100 Hz CSI + 1 Hz feature vectors), and a compact warning list for nodes with known issues (presence_score normalisation anomaly, stale firmware version).
- **COG Runtime status row** — a horizontal strip of status pills for each installed COG on the v0 Appliance. Pill colours follow the existing badge convention: `--green-d`/`--green` for running, `--red-d`/`--red` for failed, `--t3`/`--t2` for stopped. COG name in `--mono`. Clicking a pill navigates to COG Management (§4.6).
- **Event Bus activity indicator** — a small real-time sparkline showing the homecore broadcast channel event rate (events/sec). Indicate channel lag if a subscriber is falling behind the 4,096-event capacity.
### 4.2 SEED Detail View (per-SEED drill-down)
Accessible from the fleet grid. Full-page panel for a single SEED node, using the card + section-header pattern from the Cog Store's detail views.
- **SEED identity header** — `device_id` in `--mono`, firmware version, paired status in green, USB vs WiFi connection mode. A section-header gradient border (cyan → purple, matching the featured card style) visually separates this from Appliance content.
- **Vector Store panel** — current vector count, dimension (8), last kNN query latency, current epoch number, a small sparkline of ingest rate over the last hour, and a storage budget bar showing usage against the 100K working-set target. A "Compact now" button (`POST /api/v1/store/compact`) in ghost style. When usage exceeds 80%, the bar renders in `--amber`.
- **Witness Chain panel** — chain length (SHA-256 entries), last verification timestamp, a one-click "Verify chain" button (`POST /api/v1/witness/verify`), and an "Export attestation bundle" button for regulated deployments. The Ed25519 custody attestation (device-bound keypair, epoch + vector count + witness head) renders here. Chain length in `--purple`, following the existing epoch/chain colour convention.
- **Onboard Sensors panel** — live readings from all 5 sensors in individual sub-cards: BME280 (temperature °C, humidity %, pressure hPa), PIR (motion boolean with last-triggered timestamp), reed switch (open/closed with last-changed timestamp), ADS1115 (4 analog channels with configurable labels), vibration (boolean with last-triggered). These are ground-truth validators against CSI readings and are critical for diagnosing false positives in the mixture-of-specialists. Sensor values in `--cyan`; sensor names in `--t2`.
- **Reflex Rules panel** — the 3 pre-configured rules with current state: `fragility_alarm` (threshold 0.3 → relay actuator), `drift_cutoff` (threshold 1.0), `hd_anomaly_indicator` (threshold 200 → PWM brightness). Show last-fired time for each. The `fragility_alarm` threshold is the most commonly adjusted field and should be editable inline. Rules that have recently fired render with a `--amber-d` background tint.
- **Cognitive Analysis panel** — boundary fragility score (0.01.0, from Stoer-Wagner min-cut on the kNN graph) rendered as a progress bar: green below 0.3, amber 0.30.6, red above 0.6. High fragility (>0.3) indicates a regime change in the environment and should be visually prominent. Temporal coherence phase boundaries shown as a labelled timeline of detected environment state transitions. kNN graph rebuild cadence indicator (every 10 s).
- **Ingest pipeline status** — which ESP32 nodes feed this SEED, the packet type each is sending (`0xC5110003` native feature vectors vs `0xC5110002` vitals fallback path — distinguished visually since native is preferred), current ingest batch size, flush interval, and bridge path topology (direct vs host-laptop hop). The bridge-hop warning (known architectural limitation) renders in `--amber` since it adds a network hop.
### 4.3 SEED Fleet Map (multi-SEED topology)
For deployments with more than one SEED, a topology view showing the mesh:
- **Node hierarchy diagram** — v0 Appliance at root, SEEDs as second tier (grouped by room/zone), ESP32 nodes as leaves under each SEED. Lines represent active data flows. ESP-NOW mesh sync links between SEEDs shown as dashed lines. Connection health shown via line colour (green/amber/red). All labels in `--mono`.
- **Cross-SEED event deduplication indicator** — for events that span multiple SEEDs (one fall detected by two rooms; one occupant tracked through room A → hallway → room B), show a fusion badge indicating how many SEEDs contributed to the composite event.
- **Federation config** ([ADR-105](ADR-105-federated-csi-training.md)) — federated-learning round coordinator role (which SEED is the round coordinator), current round number, K healthy nodes selected, delta exchange status. **Model deltas only — never raw CSI** is a design invariant that must be labelled explicitly in the UI.
### 4.4 Entity & State Browser
The homecore state machine (`DashMap<EntityId, Arc<State>>`) is the authoritative source of truth. Every COG running on the v0 Appliance contributes entities.
- **Entity list by domain** — grouped by the `domain.` prefix of `EntityId`, using collapsible section headers. The 21 entities per ESP32 node (11 raw + 10 semantic primitives from `cog-ha-matter`) are the most important set. For each entity: current state string (in `--t1`), last-changed timestamp (in `--t3`), attribute map as collapsible JSON in `--mono`, and the Context (`user_id` + `parent_id` causality chain, critical for care/audit deployments). Entity IDs always in `--mono`.
- **SEED provenance badge** — each entity carries a small badge showing its data lineage: which ESP32 node → which SEED → which COG → homecore state machine. This trace is invaluable for debugging false positives and is a **first-class UI element, not a collapsed detail.**
- **Domain filter + semantic search** — filter by domain prefix and, once [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (homecore-recorder) lands, ruvector-backed semantic search: "when did the living room anomaly score last correlate with a door-open event?" A keyword filter across entity IDs and attribute keys ships in the initial release regardless of [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) status, given entity density; the semantic search layers on top once the recorder lands.
- **Real-time WebSocket feed** — entity states update live via the homecore `subscribe_events` WebSocket command ([ADR-130](ADR-130-homecore-rest-websocket-api.md)). The UI must never poll. Show a broadcast-channel lag indicator; warn visually if the subscriber is falling behind the 4,096-event channel capacity.
- **StateChanged detail panel** — clicking any entity opens a slide-over panel showing the full `StateChangedEvent`: `old_state`, `new_state`, `context.id`, `context.user_id`, and the `context.parent_id` chain rendered as a breadcrumb trail.
### 4.5 RoomState / Sensing Panel
Surfaces the mixture-of-specialists output from the calibration service — the highest-level per-room sensing result. Data comes from `GET /api/v1/room/state?bank=<room_id>` on the v0 Appliance.
- **Per-room cards** — one card per `room_id` on the `var(--card)` surface. Each card shows live `RoomState` JSON fields as sub-rows: presence (occupied/absent chip in green/red with confidence bar), posture (standing/sitting/lying chip with confidence), breathing BPM (numeric in `--cyan` with range indicator 630), heart rate BPM (numeric in `--cyan` with range indicator 40120), restlessness score (01 progress bar), and anomaly score (01 with normal/anomalous label, bar turns red above a configurable threshold).
- **STALE warning** — when `stale: true` (the specialist bank was trained against a different baseline), render the entire room card with a `--amber-d` background tint and a prominent amber banner reading "Bank stale — baseline has changed" with a direct "Recalibrate room" link into the calibration wizard (§4.7). This is the most common real-world failure mode and **must never be subtle.**
- **VETO indicator** — when `vetoed: true` (anomaly veto suppressed vitals/posture because the window was physically implausible), render the affected specialist slots in `--red` with a "Veto active" label. Values suppressed by veto **must not render as zeros** — they must render as explicitly withheld.
- **Null specialist placeholders** — specialists not yet trained (`null` in the specialist bank) render as "Not trained" placeholders in `--t3` with a small "Calibrate to enable" prompt in ghost style. They are **not** errors.
- **Confidence bars** — each specialist output has a confidence float, shown as a small inline bar (`--cyan` fill) next to the reading. Low confidence (< 0.4) renders the bar in `--amber`.
- **Multi-SEED fusion indicator** — for rooms served by multiple SEEDs, show a small badge indicating how many SEED nodes contributed to the `MultiNodeMixture` for this room's reading.
### 4.6 v0 Appliance COG Management
The v0 Appliance hosts COGs at `/var/lib/cognitum/apps/`. This panel is the operational companion to the existing Cog Store (`seed.cognitum.one/store`). It must match the Cog Store's visual conventions precisely — same card layout, same category pills, same install/detail button pair — because operators will move between the two surfaces.
- **Installed COGs list** — for each COG: `id` and `version` in `--mono`, architecture badge (`arm`/`hailo10` etc., category-pill pattern), status pill (running/stopped/failed/updating in green/grey/red/amber), `binary_sha256` verified badge (Ed25519 signature verification shown as a shield icon in `--green` or `--red`), and PID from the pid file. Actions: start, stop, restart (ghost style), and view `output.log` / `error.log` in a monospace drawer using `--mono`. Edit `config.json` inline with syntax highlighting.
- **COG Store / App Registry** — browsable `app-registry.json` listing. This panel should visually mirror `seed.cognitum.one/store` as closely as possible — same featured-card hero layout, same icon + title + description + category pill + action button structure. One-click install downloads the binary from GCS, verifies `binary_sha256` + `binary_signature`, writes the manifest, and starts the COG. Show which new homecore entities will appear in the state machine after install, as a preview list before confirming.
- **OTA Updates** — a badge count on installed COGs with available updates, matching the "Installed (N)" tab badge convention from the existing Cog Store. Show a diff panel (version change, new entities, config schema changes) before confirming the update.
- **Hailo HEF status** — for COGs with `arch: hailo10`: loaded HEF files on the Hailo-10H, current inference throughput, and `ruvector-hailo-worker:50051` connection status. The RF Foundation Encoder ([ADR-150](ADR-150-rf-foundation-encoder.md)) and neural pose head display here once available.
### 4.7 Calibration Wizard
The full baseline → enroll → train → verify pipeline runs via HTTP against the v0 Appliance ([ADR-151](ADR-151-room-calibration-specialist-training.md)). This is a multi-step guided flow — not a raw API panel. Use a stepped wizard layout with a progress indicator at the top (steps 15 as numbered pills, active step in `--cyan`, completed in `--green`, pending in `--t3`).
- **Step 1 — Select room and SEED** — enter a `room_id` name (validated against `[A-Za-z0-9_-]{1,64}`) and select which SEED(s) and ESP32 nodes serve this room from a dropdown populated from the live fleet. Show current CSI ingest health for the selected nodes inline — if frames are not arriving at the expected rate, display an amber warning **before** allowing the operator to proceed. A broken ingest pipeline will silently fail calibration.
- **Step 2 — Baseline capture** — `POST /api/v1/calibration/start`. A large full-width animated progress bar (cyan fill) reads from `GET /api/v1/calibration/status`: frames recorded vs target, ETA in seconds, `z_median` value. If `motion_flagged` is true, overlay an amber banner: "Room must be empty — movement detected." The baseline UUID produced here is the anchor for all future STALE detection for this room — display it in `--mono` once complete so operators can record it.
- **Step 3 — Anchor enrollment** — the 8 anchor labels in enforced order: `empty`, `stand_still`, `sit`, `lie_down`, `breathe_slow`, `breathe_normal`, `small_move`, `sleep_posture`. For each: a human-readable instruction with an illustration, a countdown timer rendered as a circular progress ring in `--cyan`, and an immediate quality-gate result (accepted in green, retry in amber with a reason string). Drive via `POST /api/v1/enroll/anchor` + `GET /api/v1/enroll/status`. After each accepted anchor, show the extracted feature values (mean, variance, breathing_score, heart_score) in a small `--mono` data row so operators can sanity-check the capture. Show overall progress as "N / 8 anchors accepted."
- **Step 4 — Train** — a single `POST /api/v1/room/train` call. Show the 6 specialist results as a checklist: presence (threshold + occupied_var), posture (prototype count), breathing (min_score), heartbeat (min_score), restlessness (calm/active motion values), anomaly (prototype count + scale). Specialists that returned non-null render in `--green`. Null specialists (insufficient anchor data) render in `--amber` with a "Re-enroll missing anchors" prompt linking back to Step 3 for the specific missing labels.
- **Step 5 — Verify live** — display the live `RoomState` for the just-trained room using the same per-room card layout as §4.5. Prompt the operator to stand in the room and verify presence is detected, try sitting/lying to confirm posture, and breathe normally to confirm vitals are in plausible range. A "Confirm and save" button (cyan, primary) closes the wizard; a "Something's wrong — re-enroll" button (ghost) loops back to Step 3.
### 4.8 Event Bus & Automation Feed
- **Live event stream panel** — a virtualized scrolling list of `SystemEvent` variants (`StateChanged`, `EntityRegistered`, `ConfigReloaded`) and notable `DomainEvent`s from the homecore Tokio broadcast channel. Each row shows: event-type pill (coloured by variant), `entity_id` in `--mono`, old state → new state arrow, timestamp, and `context.user_id`. The stream is filterable by entity domain, event type, or source SEED/COG. The filter bar uses the same search-input style as the Cog Store's search field.
- **Context causality breadcrumb** — expanding any event row shows the full Context chain (`context.id``parent_id``grandparent_id`) as a breadcrumb trail in `--mono`. This is how automation loops become visible without any separate debugging tool.
- **Automation builder** ([ADR-129](ADR-129-homecore-automation-engine.md) scope) — a trigger → condition → action editor on the card surface. The most important RuView-specific trigger types to support are: `state_changed` on `RoomState` entities with a threshold expression (e.g. `anomaly.value > 0.8`), SEED reflex-rule firing events (`fragility_alarm`, `hd_anomaly_indicator`), and custom `domain_event` topics. Actions include calling services in the homecore service registry and firing domain events. The condition expression editor uses `--mono`.
### 4.9 Witness / Audit Log
- **Unified witness timeline** — a chronological merged view of events from both tiers: the SEED's SHA-256 ingest chain (every RVF store write attested) and homecore's Ed25519 state-transition chain (biometric crossings, BFLD identity-risk elevations). Each row: `entity_id` in `--mono`, old/new state, timestamp, source SEED `device_id`, signing key fingerprint (first 8 chars in `--mono`). Pagination uses the same "Showing XY of Z" convention from the Cog Store's cog grid.
- **Privacy mode banner** — a persistent top-of-panel banner showing current privacy mode: `--green-d`/green text for full-publish mode; `--amber-d`/amber text for audit-only mode (SHA-256 digests on-SEED only, no MQTT state messages). Show the per-SEED privacy mode state, since SEEDs can be individually configured. Toggling privacy mode is a high-stakes action — require an explicit "Confirm" step with a summary of what will change.
- **Export bundle** — an "Export attestation bundle" button (ghost) that packages the SEED witness chain + homecore Ed25519 chain as a downloadable archive for regulated-deployment (care home, hotel, shared office) compliance handoff.
### 4.10 Settings & Integration Config
- **SEED fleet management** — add, remove, and reprovision SEEDs. Show the USB-only pairing requirement prominently (the pairing window only opens via `169.254.42.1`, not WiFi — a security invariant). Per-SEED: `device_id` in `--mono`, firmware version, bearer token status, and a "Rotate token" action (ghost) that walks the operator through the secure token rotation flow.
- **ESP32 node provisioning** — per-node NVS config display (target IP, target port, node_id), last-seen firmware version, and a link to the provisioning script. The `node_id` → room/zone assignment is editable here and persists to the room calibration system's `room_id` mapping.
- **MQTT / cog-ha-matter config** ([ADR-116](ADR-116-cog-ha-matter-seed.md)) — broker URL, credentials (masked), MQTT topic prefix, mDNS advertisement status (`_ruview-ha._tcp`), and a live connection indicator (green dot for connected, red for unreachable). The 21 HA-DISCO entities per node are listed here with their `via_device` assignments showing which SEED they belong to in HA's device registry.
- **Long-lived access tokens** — for homecore-api companion-app connections (HA 2025.1 wire-compat, [ADR-130](ADR-130-homecore-rest-websocket-api.md)). Token creation, last-used timestamp, and revocation. The HA companion-app pairing QR-code flow surfaces here.
- **Federation config** — for multi-SEED deployments: ESP-NOW mesh sync status, cross-SEED epoch alignment values, and federated-learning round settings (coordinator SEED, round cadence, Krum aggregation parameters per [ADR-105](ADR-105-federated-csi-training.md)). The design invariant **"model deltas only, never raw CSI"** must be labelled explicitly in this panel.
---
## 5. Navigation structure
HOMECORE-UI must integrate into the existing Cognitum Appliance nav shell. The top nav should read:
```
Framework | Guide | Cog Store | HOMECORE | Status
```
— inserting **HOMECORE** as a first-class nav item between the existing "Cog Store" and "Status" entries, using the same nav-item style (text in `--t2`, active state in `--cyan` with bottom underline).
Within the HOMECORE section, a left sidebar (or top sub-nav on narrow viewports) provides section navigation:
```
Dashboard | SEED Fleet | Entities | Rooms | COGs | Calibration | Events | Audit | Settings
```
The COG Store panel within HOMECORE (§4.6) links out to `seed.cognitum.one/store` for the full catalog view, ensuring the existing Cog Store remains the canonical browsing experience.
---
## 6. Key UX invariants
These must be maintained across every panel:
1. **Always make the tier origin of any data explicit.** A `RoomState` reading traces to an ESP32 node → SEED → COG → v0 Appliance state machine. The provenance badge (§4.4) must appear wherever entity states are displayed.
2. **The `stale` and `vetoed` flags from `RoomState` and the kNN fragility score from SEED cognitive analysis are meaningful diagnostic signals** — they must never be silently hidden, styled grey-on-grey, or collapsed behind an expand toggle. They represent system health operators need to act on.
3. **Values that are `null` because a specialist has not been trained must be visually distinct from values that are unavailable due to an error.** The distinction is operationally important: `null` means "calibrate to enable," unavailable means "investigate."
4. **All entity IDs, hashes, API endpoints, binary signatures, device UUIDs, and JSON payloads must use `--mono` font.** This is already the convention in the API Explorer and must be consistent throughout HOMECORE-UI.
5. **The v0 Appliance Hailo HAT is a separate subsystem from the SEED's edge compute.** Inference results tagged as Hailo-sourced (COGs with `arch: hailo10`) must be visually distinguished from results from CPU-only COGs (`arch: arm`) so operators can triage hardware-specific failures.
---
## 7. Scope — complete UI delivery
The deliverable is the **entire** dashboard. Every panel below ships fully implemented and wired to its live data source — there is no scaffold-only milestone and no panel left as a placeholder. The table records each panel's authoritative backing API so the build can proceed in whatever order best fits the dependency graph; it is a dependency map, **not** a sequence of partial releases.
| Panel | Section | Backing API / source |
|---|---|---|
| System Dashboard | §4.1 | [ADR-130](ADR-130-homecore-rest-websocket-api.md) WebSocket + appliance health endpoints |
| SEED Detail View | §4.2 | SEED HTTPS API (vector store, witness, sensors, reflex, cognitive analysis) |
| SEED Fleet Map | §4.3 | fleet topology + federation ([ADR-105](ADR-105-federated-csi-training.md)) |
| Entity & State Browser | §4.4 | [ADR-127](ADR-127-homecore-state-machine-rust.md) state machine via [ADR-130](ADR-130-homecore-rest-websocket-api.md) `subscribe_events`; semantic search via [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) |
| RoomState / Sensing | §4.5 | [ADR-151](ADR-151-room-calibration-specialist-training.md) `GET /api/v1/room/state` |
| COG Management | §4.6 | [ADR-128](ADR-128-homecore-integration-plugin-system.md) plugin runtime + [ADR-100](ADR-100-cog-packaging-specification.md) app registry |
| Calibration Wizard | §4.7 | [ADR-151](ADR-151-room-calibration-specialist-training.md) calibration HTTP API |
| Event Bus & Automation | §4.8 | [ADR-130](ADR-130-homecore-rest-websocket-api.md) broadcast channel + [ADR-129](ADR-129-homecore-automation-engine.md) automation engine |
| Witness / Audit Log | §4.9 | SEED SHA-256 ingest chain + homecore Ed25519 chain |
| Settings & Integration | §4.10 | SEED provisioning, [ADR-116](ADR-116-cog-ha-matter-seed.md) MQTT/Matter, LLAT, federation |
### 7.1 Build sequencing within the complete deliverable
The complete UI depends on backing services that mature on their own timelines. Each panel is built against the **real gateway endpoint** defined in §11; where the upstream is not yet available the panel renders a typed empty/error state, **not** fabricated data (the dev-only `?demo=1` fixture of §2.2 exists for offline development only and is never the shipped behaviour). Concretely, the hard contract dependencies are: [ADR-130](ADR-130-homecore-rest-websocket-api.md) (REST + WebSocket), [ADR-127](ADR-127-homecore-state-machine-rust.md) (state machine), [ADR-151](ADR-151-room-calibration-specialist-training.md) (calibration), [ADR-128](ADR-128-homecore-integration-plugin-system.md) (plugin runtime), [ADR-129](ADR-129-homecore-automation-engine.md) (automation), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (event history + semantic search), [ADR-116](ADR-116-cog-ha-matter-seed.md) (SEED/Matter), [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) (SEED ingest), and [ADR-105](ADR-105-federated-csi-training.md) (federation). The keyword entity filter (§4.4) ships immediately; semantic search layers on once [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) lands. The exact panel→endpoint→upstream map and the new gateway code each requires are §11; the staged delivery is §12.
---
## 8. Consequences
### 8.1 Positive
- Operators, integrators, and residents get a single coherent surface for the full two-tier stack, replacing the need to SSH into SEEDs or hand-craft API calls.
- The dashboard reuses the proven Cognitum design tokens and component patterns verbatim, so it ships visually consistent with no separate design effort and no perceptible seam between surfaces.
- Diagnostic signals that today are invisible (`stale`/`vetoed` flags, kNN fragility, provenance lineage, channel lag) become first-class, surfacing the system's most common real-world failure modes directly to operators.
### 8.2 Negative / risks
- The UI hard-depends on the wire-compat guarantees of ADR-130 and the calibration contract of ADR-151; schema drift in either breaks panels silently. Integration tests against every backing contract in §7 are required.
- Committing to the complete UI in one deliverable is a larger up-front effort and couples the UI's readiness to the maturity of multiple backing services (§7.1, §11). The mitigation is the BFF gateway (§2.1): each panel targets one same-origin endpoint, and the gateway absorbs upstream churn behind a stable contract.
- Promoting `homecore-server` to a gateway means it now **proxies cross-tier traffic** (calibration API, SEED HTTPS, appliance daemons). This adds a network hop, a place for upstream timeouts/partial failures to surface, and a server-side store of SEED bearer tokens that must be protected (§11.10). Each proxied route needs an explicit timeout + typed error mapping so one slow SEED cannot stall the dashboard.
- Several panels depend on data that only exists on **real hardware or new daemons** (SEED device tier, appliance host metrics, COG supervisor). Until those upstreams exist the corresponding gateway routes return `503 upstream_unavailable`; this is honest but means the dashboard is only as "live" as the tiers behind it (§11 classifies every endpoint by what it depends on).
- Faithfully mirroring `seed.cognitum.one/store` couples HOMECORE-UI to the external Cog Store's evolving design; token drift there must be tracked and re-synced.
- The two-tier mental model (Appliance root, SEED children, ESP32 leaves) must be enforced consistently; any panel that flattens or peers the tiers undermines the core architectural constraint.
---
## 9. References
- `https://seed.cognitum.one/store` — primary design reference for all visual conventions.
- `https://seed.cognitum.one/status` — reference for live metric-card layout.
- [ADR-126](ADR-126-ruview-native-ha-port-master.md) — HOMECORE master ADR.
- [ADR-127](ADR-127-homecore-state-machine-rust.md) — HOMECORE-CORE state machine and entity registry.
- [ADR-128](ADR-128-homecore-integration-plugin-system.md) — HOMECORE-PLUGINS WASM COG substrate.
- [ADR-129](ADR-129-homecore-automation-engine.md) — HOMECORE automation engine.
- [ADR-130](ADR-130-homecore-rest-websocket-api.md) — HOMECORE-API REST + WebSocket wire-compat.
- [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) — homecore-recorder, history + semantic search.
- [ADR-100](ADR-100-cog-packaging-specification.md) — Cognitum Cog packaging specification (manifest.json, status values, on-device layout).
- [ADR-116](ADR-116-cog-ha-matter-seed.md) — cog-ha-matter (SEED cog, HA-DISCO entity surface, mDNS).
- [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) — ESP32 CSI → Cognitum SEED RVF ingest pipeline (SEED architecture detail).
- [ADR-105](ADR-105-federated-csi-training.md) — Federated CSI training (multi-SEED federation).
- [ADR-151](ADR-151-room-calibration-specialist-training.md) — Per-room calibration specialist training (calibration HTTP API).
- `v2/crates/homecore/src/` — state machine, entity, event, registry source.
- `docs/integration/calibration-appliance-integration.md` — calibration API contract and RoomState schema.
---
## 10. Implementation status
Implemented as a zero-dependency, no-build-step vanilla TS/JS + CSS frontend served by `homecore-server` at `/homecore` (the `rufield-viewer` "Axum + vanilla-JS" pattern). The complete deliverable per §2/§7 — all ten panels, fully rendered, wired to live data where the backing service exists and to a contract-conformant DEMO-flagged mock layer (§7.1) where it does not.
**Location:** `v2/crates/homecore-server/ui/``css/tokens.css` (the §3.1 palette, verbatim) + `css/app.css` (§3.3 components); `js/{ui,api,ws,mock,app}.js` (shared helpers, REST client, `subscribe_events` WS client, mock layer, shell+router); `js/panels/*.js` (one module per §4 panel). Mounted via `tower-http` `ServeDir` in `homecore-server::build_app`, gated by `--ui-dir`/`HOMECORE_UI_DIR`.
**Verification:**
- **Rust** — `#[cfg(test)] mod ui_tests` in `homecore-server/src/main.rs`: 5 integration tests (`tower::oneshot`) covering index, design tokens, all ten panel modules served, API coexistence, and mount-disable. *Written but not compiled in the authoring environment (no Rust toolchain present); run `cargo test -p homecore-server` on a Rust host before merge.*
- **Frontend** — `ui/` test suite under plain `node` (no npm install): `npm test` → import/export graph verifier (15 modules) + render-smoke (executes every panel against a DOM shim; 21 checks) + interaction suite (live WS patch, ws.js handshake/parse, calibration contract; 3 checks). **24/24 green.**
- **Benchmark** — `npm run bench`: total bundle **136.8 KB** uncompressed (**~37× smaller** than HA's ~5 MB Lit bundle, the ADR-126 §1.1 foil); slowest panel **1.5 ms/cold-render**.
**Honest scope — current vs. target.** *Earlier cut:* the front-end was complete but only §4.4 Entities was wired to a real backend; the rest rendered from an in-browser mock. *This revision implements the §11 wiring:*
- **Front-end (§11.11) — DONE and verified.** `api.js` rewritten: all data accessors are async and call the §11.2 gateway routes; the mock layer is demoted to a dev-only fixture reachable **only** under `?demo=1` / `HOMECORE_UI_DEMO` (§2.2); every panel `await`s and renders a typed empty/error state on failure (no mock fallback in production). All ten panels converted (3 by hand, 7 via parallel agents). Verified under Node: 5 test 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 asserts every panel renders an error state, never mock, never throws** (it caught and fixed a real unhandled-rejection in the events panel).
- **Gateway (§11.1–§11.6) — IMPLEMENTED, COMPILED, TESTED, RUN.** New `homecore-server/src/gateway.rs` (+`reqwest` dep, +CLI/env flags `--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`, merged into `build_app` via `gateway_router`). Real handlers: `/api/cal/*` reverse-proxy (W2), `GET /api/homecore/rooms` with the §11.3 RoomState adapter (W2), `GET /api/homecore/cogs` supervisor over the apps dir (W4), `GET /api/homecore/appliance` from `/proc` + port probes (W6). SEED-device/appliance-daemon routes (seeds, federation, witness, privacy, settings, automations, events-history, hailo, tokens — W3/W5) return a typed `503 upstream_unavailable` per §11.2. **Verified on Rust 1.89: `cargo test -p homecore-server --no-default-features` = 12/12 pass** (6 gateway + 6 UI mount). **Run live:** `GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes; unauth → `401`; `cogs``[]` with 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` correctly adapts `RoomState` to the UI shape (`breathing``breathing_bpm`, `heartbeat:null``heart_bpm:null`, injected `anomaly.threshold`/`room_id`, `stale` passthrough). **Live testing caught + fixed one real bug** — a double-`v1` path in the `/api/cal/*` proxy URL.
The endpoint-by-endpoint contract is **§11**; the staged plan and which endpoints depend on real SEED/appliance hardware vs. pure software is **§12**.
---
## 11. Backend wiring — making every panel real
This section is the authoritative contract for full functionality. It removes the mock layer from the production path (§2.2) by routing every panel through the `homecore-server` BFF gateway (§2.1). Each endpoint is classified by what it depends on:
- **EXISTS** — backend code already in this repo; gateway only proxies/adapts.
- **NEW-GW** — pure software the gateway itself implements (filesystem, `/proc`, process control, recorder query) — no new external service.
- **NEW-API** — a small HTTP wrapper to add to an existing in-repo crate (`homecore-api`, `homecore-automation`).
- **SEED-DEV** — depends on a SEED node's on-device HTTPS API (separate hardware/firmware).
- **APPLIANCE** — depends on an appliance daemon / accelerator stat source.
### 11.1 Gateway shape
`homecore-server` already mounts `homecore-api` at `/api/*` and the UI at `/homecore`. It gains a new **`/api/homecore/*`** namespace (the dashboard-specific aggregation surface) plus a **`/api/cal/*`** reverse-proxy to the calibration service. The browser issues only same-origin requests; the gateway fans out server-side, holding all upstream credentials (§11.10). Every proxied route has an explicit timeout and maps upstream failure to a typed body (`503 upstream_unavailable`, `504 upstream_timeout`) so one slow tier never stalls the dashboard.
### 11.2 Master endpoint contract (panel → gateway route → upstream → status)
| Panel | UI method (`api.js`) | Gateway route | Upstream / source | Class |
|---|---|---|---|---|
| §4.4 Entities | `states()` | `GET /api/states` | `homecore` state machine | **EXISTS** ✅ wired |
| §4.4/§4.8 live feed | WS | `GET /api/websocket` (`subscribe_events`) | `homecore` event bus | **EXISTS** ✅ wired |
| §4.8 Event history | `eventHistory(q)` | `GET /api/events?since=…` | `homecore-recorder` ([ADR-132](ADR-132-homecore-recorder-history-semantic-search.md)) | **NEW-API** |
| §4.8 Automations | `automations()` / `saveAutomation()` | `GET/POST/DELETE /api/homecore/automations` | `homecore-automation` ([ADR-129](ADR-129-homecore-automation-engine.md)) | **NEW-API** |
| §4.5 Rooms | `roomStates()` | `GET /api/homecore/rooms` → per-room `GET /api/cal/v1/room/state?bank=` | `calibrate-serve` ([ADR-151](ADR-151-room-calibration-specialist-training.md)) | **EXISTS** (proxy + adapter) |
| §4.7 Calibration | `calibration.*` | `POST /api/cal/v1/calibration/{start,stop}`, `GET …/status`, `POST …/enroll/anchor`, `GET …/enroll/status`, `POST …/room/train` | `calibrate-serve` | **EXISTS** (proxy) |
| §4.6 COGs | `cogs()` / `cogAction()` / `cogLogs()` | `GET /api/homecore/cogs`, `POST …/cogs/:id/{start,stop,restart}`, `GET …/cogs/:id/logs`, `GET/PUT …/cogs/:id/config` | COG supervisor over `/var/lib/cognitum/apps/` ([ADR-100](ADR-100-cog-packaging-specification.md)/[ADR-128](ADR-128-homecore-integration-plugin-system.md)) | **NEW-GW** |
| §4.6 Hailo HEF | `hailo()` | `GET /api/homecore/hailo` | `ruvector-hailo-worker:50051` | **APPLIANCE** |
| §4.1 Appliance health | `appliance()` | `GET /api/homecore/appliance` | host `/proc` + Hailo stats + service probes | **NEW-GW** (+APPLIANCE for Hailo) |
| §4.1/§4.2 Fleet + SEED detail | `seeds()` / `seed(id)` | `GET /api/homecore/seeds`, `GET …/seeds/:id` | SEED device HTTPS API ([ADR-069](ADR-069-cognitum-seed-csi-pipeline.md)) via registry | **SEED-DEV** |
| §4.2 SEED actions | `seedCompact()` / `seedVerify()` | `POST …/seeds/:id/{compact,witness/verify}` | SEED device API | **SEED-DEV** |
| §4.3 Federation | `federation()` | `GET /api/homecore/federation` | federation coordinator ([ADR-105](ADR-105-federated-csi-training.md)) | **SEED-DEV/APPLIANCE** |
| §4.9 Witness/Audit | `witnessLog(p,s)` | `GET /api/homecore/witness?page=…` | merge: `homecore` Ed25519 chain + per-SEED SHA-256 chains | **NEW-API + SEED-DEV** |
| §4.9 Privacy mode | `privacyModes()` / `setPrivacy()` | `GET/POST /api/homecore/privacy` | SEED privacy control plane ([ADR-141](ADR-141-bfld-privacy-control-plane-modes-attestation.md)) + cog-ha-matter | **SEED-DEV** |
| §4.9 Export bundle | `exportAttestation()` | `GET /api/homecore/witness/export` | gateway packages both chains | **NEW-GW** |
| §4.10 Tokens (LLAT) | `tokens()` / `createToken()` / `revokeToken()` | `GET/POST/DELETE /api/homecore/tokens` | `homecore-api` `LongLivedTokenStore` | **NEW-API** |
| §4.10 MQTT/Matter | `mqttConfig()` | `GET /api/homecore/integrations/mqtt` | cog-ha-matter config ([ADR-116](ADR-116-cog-ha-matter-seed.md)) | **NEW-GW/SEED-DEV** |
| §4.10 ESP32 provisioning | `nodes()` / `assignRoom()` | `GET/PUT /api/homecore/nodes` | SEED ingest config ([ADR-069](ADR-069-cognitum-seed-csi-pipeline.md)) | **SEED-DEV** |
| §4.10 SEED mgmt | `pairSeed()` / `rotateToken()` | `POST /api/homecore/seeds/{pair,:id/rotate-token}` | SEED pairing (USB `169.254.42.1`) | **SEED-DEV** |
### 11.3 Calibration proxy + RoomState adapter
The calibration service is real but on a different binary/port; the gateway reverse-proxies it under `/api/cal/*` (upstream base from `HOMECORE_CALIBRATION_URL`). Its `RoomState` (`wifi-densepose-calibration/src/runtime.rs`) does **not** match the UI's shape, so the gateway adapts it in `GET /api/homecore/rooms`:
| Real field (`RoomState`) | UI field | Adapter rule |
|---|---|---|
| `breathing: Option<SpecialistReading>` | `breathing_bpm: {value,confidence}\|null` | rename; `value`=`reading.value`, `confidence`=`reading.confidence`; `None``null` (preserves "not trained") |
| `heartbeat: Option<…>` | `heart_bpm: {…}\|null` | rename `heartbeat``heart_bpm` |
| `presence/posture/restlessness` | same names `{value,confidence}\|null` | `posture.value`=`reading.label` (class), else numeric |
| `anomaly: Option<…>` | `anomaly: {value,confidence,threshold}` | inject `threshold`=`MixtureOfSpecialists.veto_threshold` (0.5) |
| `vetoed` / `stale` | `vetoed` / `stale` | pass through (drives the §4.5/§6 banners) |
| *(absent)* | `room_id`, `seeds[]` | injected by the gateway from the **room registry** |
A **room registry** (config or derived from `GET /api/cal/v1/calibration/baselines`) maps each `room_id` → bank name + serving SEED ids, so `GET /api/homecore/rooms` returns one adapted record per room. `Option::None` → JSON `null` keeps the null-vs-withheld distinction (§6 invariant 3) intact end-to-end.
### 11.4 SEED registry & device-API proxy
The gateway holds a **SEED registry** (`device_id` → base URL + bearer token + zone), populated by pairing (§4.10) and persisted server-side. `GET /api/homecore/seeds[/:id]` fans out to each SEED's on-device API and shapes the result to the §4.2 card/detail model. Expected SEED-side endpoints (the contract the SEED firmware must satisfy — a subset of its 98 endpoints): health; vector-store stats (`vector_count`, `dim`, `epoch`, `knn_latency_ms`, ingest rate); witness (`len`, `last_verify`, `valid`) + `POST verify`; onboard sensors (BME280/PIR/reed/ADS1115/vibration); reflex rules + thresholds; cognitive analysis (fragility, coherence phases); ingest feeders (ESP32 node ids + packet type `0xC5110003`/`0xC5110002` + rate). Offline/unreachable SEEDs surface as `online:false` (drives the §4.1 red tint) rather than failing the whole list.
### 11.5 Appliance metrics collector (§4.1)
`GET /api/homecore/appliance`, implemented in the gateway: CPU/RAM/uptime from `/proc`; Hailo load + temperature from the Hailo runtime/sysfs (or `ruvector-hailo-worker` stats); service health by probing `ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`; event-bus rate from the `homecore` broadcast channel + its lag counter (already exposed for §4.1/§4.4).
### 11.6 COG supervisor (§4.6)
`GET /api/homecore/cogs`: read each `/var/lib/cognitum/apps/*/manifest.json` ([ADR-100](ADR-100-cog-packaging-specification.md)), the pid file, and verify `binary_sha256` + `binary_signature` (Ed25519) → status/shield. `POST …/cogs/:id/{start,stop,restart}` performs supervised process control; `GET …/cogs/:id/logs` tails `output.log`/`error.log`; `GET/PUT …/cogs/:id/config` reads/writes `config.json`. Hailo-arch COGs join the §11.5 Hailo stats. The Cog Store/App-Registry **browsing** panel was removed per product decision; this is operational management only.
### 11.7 Witness aggregation + privacy (§4.9)
`GET /api/homecore/witness` merges two chains chronologically: the `homecore` Ed25519 state-transition chain (exposed by a small `homecore-api` route over its witness log) and each paired SEED's SHA-256 ingest chain (proxied via the registry), paginated server-side. `GET/POST /api/homecore/privacy` reads/sets per-SEED privacy mode via the SEED privacy control plane ([ADR-141](ADR-141-bfld-privacy-control-plane-modes-attestation.md)) — the POST is the high-stakes confirmed toggle (§4.9). `GET /api/homecore/witness/export` packages both chains into the downloadable attestation bundle.
### 11.8 Event history + automation CRUD (§4.8)
`homecore-api` adds `GET /api/events?since=…` backed by `homecore-recorder` ([ADR-132](ADR-132-homecore-recorder-history-semantic-search.md)) for history (live updates continue over the existing WS). The automation builder persists through `GET/POST/DELETE /api/homecore/automations`, a thin HTTP wrapper over the `homecore-automation` engine's register/list/remove ([ADR-129](ADR-129-homecore-automation-engine.md)). RuView-specific triggers (RoomState thresholds, SEED reflex events) map onto the engine's trigger types.
### 11.9 Entity provenance convention (§4.4/§6)
The first-class provenance badge requires each entity to carry its lineage. Convention: every integration writes `attributes.source` (and, where known, `attributes.seed` / `attributes.cog`) when it sets state; `cog-ha-matter` ([ADR-116](ADR-116-cog-ha-matter-seed.md)) populates these from the ESP32 node → SEED → COG path and HA `via_device`. The gateway/UI resolves node→seed→cog from these attributes (no fabrication; missing lineage renders as "unknown", not invented).
### 11.10 Auth, credentials, config
- **Browser → gateway:** one long-lived access token (the §4.10 LLAT), sent as `Authorization: Bearer`; validated by `homecore-api`'s `LongLivedTokenStore`. The dev default (`allow_any_non_empty`) stays for local runs; production provisions `HOMECORE_TOKENS`.
- **Gateway → upstreams:** SEED bearer tokens and the calibration token live **only** server-side (SEED registry + `HOMECORE_CALIBRATION_TOKEN`); never sent to the browser. This is the reason the gateway exists.
- **Config:** `HOMECORE_CALIBRATION_URL`, SEED registry store path, per-proxy timeout (default 2 s), `HOMECORE_UI_DEMO` (dev fixture). No browser CORS needed (same origin); gateway→upstream is server-to-server.
### 11.11 Front-end changes
`api.js`: drop the mock fallback from the production path — methods call the §11.2 gateway routes; `this.base` stays same-origin; the mock layer is reachable only under `?demo=1`/`HOMECORE_UI_DEMO`. Every panel renders a **typed empty/error state** (not mock) when its route returns `503/504`. `mock.js` moves to a dev fixture (kept for the offline test harness, excluded from the production bundle). The §10 frontend tests are re-pointed at the gateway contract (and gain contract tests per §11.2 route).
---
## 12. Delivery plan to full functionality
Staged so each wave is independently shippable behind the gateway, lands real data for a coherent set of panels, and has an explicit acceptance gate. "Class" reuses §11's tags.
| Wave | Scope | Class | Acceptance gate |
|---|---|---|---|
| **W1 — Gateway foundation** | `/api/homecore/*` scaffold in `homecore-server`; auth passthrough; per-proxy timeout + typed errors; `api.js` base + remove prod mock (`?demo=1` only); panels get typed empty/error states | NEW-GW | Entities + live WS still green; with no upstreams, every other panel shows "upstream unavailable", **never** mock (unless `?demo=1`); Rust + JS suites pass |
| **W2 — Rooms + Calibration** | `/api/cal/*` reverse-proxy; `GET /api/homecore/rooms` with the §11.3 RoomState adapter + room registry; wire §4.5 + the §4.7 wizard to real endpoints; delete the in-browser calibration stub | EXISTS (proxy+adapter) | Against a running `calibrate-serve` (replayed CSI), the wizard drives a real baseline→enroll→train→verify and §4.5 shows real `RoomState` with correct stale/veto/null mapping; contract test on the adapter |
| **W3 — Events + Automations** | `GET /api/events` over `homecore-recorder`; `/api/homecore/automations` over `homecore-automation` | NEW-API | §4.8 history loads from recorder; an automation created in the UI persists and fires via the engine |
| **W4 — COG management** | `/api/homecore/cogs*` supervisor over `/var/lib/cognitum/apps/` (manifest + pid + sig verify + logs + config) | NEW-GW | §4.6 lists real installed COGs; start/stop/restart works; sha256/signature shield reflects real verification; logs tail |
| **W5 — SEED tier** | SEED registry + pairing; `/api/homecore/seeds*` device proxy; witness merge + privacy control; ESP32 provisioning | SEED-DEV | Against a real or emulated SEED API, §4.2/§4.3/§4.9/§4.10 show real vector-store/witness/sensor/reflex/cognition data; SEED tokens stay server-side; offline SEED → red tint, not a failed page |
| **W6 — Appliance + federation + Hailo** | `/api/homecore/appliance` (host metrics + service probes); `/api/homecore/hailo`; `/api/homecore/federation` ([ADR-105](ADR-105-federated-csi-training.md)) | NEW-GW + APPLIANCE | §4.1 health is real; §4.6 Hailo HEF/throughput real; §4.3 federation round/coordinator/Krum real |
**Definition of done (full functionality):** with W1W6 merged and the upstream tiers running, loading `/homecore` with **no** `?demo=1` flag shows live data on all ten panels, `api.anyDemo()` is false, and no panel renders fabricated values. Panels whose tier is offline show typed empty/error states. The mock layer is reachable only as the `?demo=1` developer fixture.
### 12.1 Wave status (this revision)
| Wave | Status |
|---|---|
| **W1 — Gateway foundation** | ✅ DONE — `gateway.rs`, auth passthrough, typed `503/504`, merged into `build_app`; front-end mock removed from prod path + `?demo=1` fixture; typed error states. **Compiled + 12/12 Rust tests + JS suite green + run live.** |
| **W2 — Rooms + Calibration** | ✅ DONE — `/api/cal/*` reverse-proxy + `GET /api/homecore/rooms` RoomState adapter; front-end calibration stub deleted (now proxies the real API). **Proven live against a calibration upstream** (proxy 200 + adapted shape); null-preservation unit-tested. |
| **W3 — Events + Automations** | ⏳ gateway returns typed `503` (recorder/automation HTTP wrappers pending); front-end handles it gracefully (history note, builder still usable). |
| **W4 — COG management** | ✅ supervisor DONE — lists `/var/lib/cognitum/apps/` manifests + pid liveness (returns `[]` live with no apps dir); start/stop/log/config control is the remaining follow-up. |
| **W5 — SEED tier** | ⏳ gateway returns typed `503` (SEED registry + device proxy pending real/emulated SEED hardware). |
| **W6 — Appliance + federation + Hailo** | ◑ appliance host metrics from `/proc` + port probes DONE (live `/proc` data verified); Hailo stats + federation remain `503` (need the accelerator stat source / coordinator). |
**Status:** the gateway is **compiled and tested on Rust 1.89** (`cargo test -p homecore-server` = 12/12) and was **run live** (curl proof in §10). The one remaining caveat is intrinsic, not an environment limit: **W3/W5/W6-Hailo/federation depend on services/hardware that are not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, the Hailo stat source), so they return honest typed `503`s and the UI shows error states — exactly as §2.2/§11.2 prescribe. W1/W2/W4/W6-appliance are functional now.
### 12.2 Security review (PR #1082)
A high-effort public-PR review of the merged gateway + front-end surfaced the following, all fixed and pinned by tests (`cargo test -p homecore-server` is now **18/18**):
| # | Severity | Finding | Fix |
|---|---|---|---|
| 1 | **HIGH** | **Path-traversal / confused-deputy SSRF** in the `/api/cal/*` reverse-proxy. The wildcard path was interpolated into the upstream URL while `proxy()` attaches the privileged server-side calibration bearer, so `/api/cal/v1/../../x` (or `..%2f`, `%2e%2e`, leading `/`, `\`, double-encoded `%252e`) could escape the `…/api/` scope **with the token**. | `validate_proxy_path()` decode-then-checks and rejects absolute / backslash / dot-segment / encoded-traversal paths with a typed **400 before the URL is built** (GET **and** POST); legit `v1/...` paths still pass. |
| 2 | Correctness | **CORS + tracing didn't cover gateway routes**`/api/homecore/*` + `/api/cal/*` were `.merge()`d outside `homecore-api::router()`'s layers. | The audited HC-05 `build_cors_layer()` + `TraceLayer` are now applied to the whole merged app in `main.rs`. |
| 3 | Honesty (§6) | **Fabricated data** — hardcoded `anomaly.threshold: 0.5` in the adapter; dashboard rendered `"null%"`/`"null°C"`; COG Hailo pill hardcoded `"connected"`; `rooms.js` defaulted a null threshold to `0.8`. | Threshold passes through the real upstream value or emits `null` (withheld); dashboard renders `—`; the Hailo pill reflects the real appliance probe; the UI treats a null threshold as withheld. |
| 4 | Robustness | A string `hef` (forwarded verbatim) threw on `.forEach`/`.join`; `frames/target` could be `NaN%`/`Infinity%`; calibration Restart leaked the baseline `setTimeout` poll. | `asArray()` coercion; `target > 0` guard; cancellable poll cleared on Restart / panel teardown. |
| 5 | Perf | Sequential per-bank RoomState fetches; blocking `std::net::TcpStream::connect_timeout` probes on an async handler; `mock.js` statically bundled. | Concurrent `futures::join_all`; async `tokio::net::TcpStream` + `timeout`; demo-only dynamic `import()` of `mock.js`. |
**Known limitations carried forward (not regressions):**
- **`reqwest` rustls-only is a workspace-wide concern.** `homecore-server` opts into `rustls-tls` only, but cargo feature-unification means any sibling crate enabling the default `native-tls` re-introduces OpenSSL into the final binary. A true "no OpenSSL on the appliance" guarantee requires aligning **every** reqwest-pulling crate on rustls-only — out of scope for this PR; documented at the dependency in `Cargo.toml`.
- **DEV-mode auth.** When `HOMECORE_TOKENS` is unset, the token store falls back to `allow_any_non_empty()` (any non-empty bearer accepted) on `0.0.0.0`. This is pre-existing and intentionally **unchanged** here; the loud boot `warn!` is retained. Provision real tokens (`HOMECORE_TOKENS=…`) before exposing the server to a network.
+68
View File
@@ -174,3 +174,71 @@ vs. an in-memory array at compile time), which intersects with ADR-084 (RabitQ)
| **P1** (this ADR) | `intent`, `recognizer` (regex), `handler` (5 built-ins), `runner` (trait + noop), `pipeline` (end-to-end wiring), 1015 tests |
| **P2** | Real `tokio::process::Child` runner with Windows-safe teardown; `SemanticIntentRecognizer` with ruvector HNSW |
| **P3** | STT/TTS bridge, satellite protocol, cloud fallback |
---
## 6. Security review (beyond-SOTA, untrusted-input → action path)
A focused security review of the Assist pipeline — `utterance → recognizer →
intent → handler → action`, plus `RufloRunner` — treating the utterance as
untrusted input (voice transcripts, the WebSocket `assist` command). This
surface was not covered by the ADR-154159 sweep.
### 6.1 Finding fixed — HC-ASSIST-01 (unbounded-utterance DoS, LOW)
Both `RegexIntentRecognizer::recognize` and the semantic `recognize_scored`
accepted utterances of **unbounded length** and ran `to_lowercase()` (a full
clone) + a per-registered-pattern scan (and, in the semantic path, full
tokenisation + feature-hash embedding) before any bound — an allocation/CPU
amplification on attacker-controlled input. The `regex` crate is **linear-time**
(RE2-style finite automaton, no catastrophic backtracking), so this was a
throughput/memory DoS, not a hang.
**Fix:** `MAX_UTTERANCE_BYTES = 4096` (far above any real spoken command),
checked at **both** recognizer boundaries *before* any allocation/scan. An
over-length utterance **fails closed** to `Ok(None)` — no intent, no action,
identical to an unrecognised phrase — so it can never be coerced into firing a
handler. Pinned by `over_length_utterance_fails_closed` (an over-length
utterance that *contains* a valid command resolves to `None`, which would have
matched on the old code) and `over_length_utterance_fails_closed_semantic`.
### 6.2 Dimensions confirmed clean (with evidence)
- **Command / argument injection — NO SUBPROCESS SURFACE.** The `RufloRunner`
has exactly two impls: `NoopRunner` (no process) and `LocalRunner` (runs the
local recognizer, no process). There is **no** `std::process` / `tokio::process`
/ `Command` / process `.spawn()` anywhere in the crate — the trait `spawn` is
only a `started: bool` lifecycle flag — and `RufloRunnerOpts.{script_path,env}`
are **inert data, never consumed**. The live `node ruflo-agent.js` runner is
genuinely data-gated/future (P2). Defence-in-depth: the `entity_id` capture
class `[a-z_][a-z0-9_ .]*` **excludes every shell/SQL metacharacter**, so even
when an injection-shaped utterance resolves (the regex is not exact-anchored),
the captured slot is a clean token — sanitisation by construction. Pins:
`shell_metachars_never_survive_into_a_resolved_slot`,
`runner_opts_are_inert_no_process_spawned`,
`pipeline_injection_shaped_utterance_carries_no_metachars_to_service`.
- **ReDoS — STRUCTURALLY IMPOSSIBLE.** `regex 1.12.3` (no `fancy-regex` in the
dependency tree) is linear-time; a classic `(a+)+$` shape on adversarial input
completes in bounded time. Pin:
`pathological_backtracking_pattern_completes_in_bounded_time`. Patterns are
operator-registered, not user-supplied, in any case.
- **NaN-poisoning — EMBEDDINGS STRUCTURALLY FINITE.** The embedding path takes
only `&str` and produces values via FNV feature-hashing + a guarded L2
normalise (`norm > 1e-12`); no external float input, no unguarded division, so
a crafted utterance cannot inject NaN/Inf to poison the cosine k-NN. Cosine
against the zero vector is a finite `0.0`; an empty index `max_by` returns
`None` (no panic); the NaN-safe `partial_cmp().unwrap_or(Equal)` is already in
place. Pins: `embeddings_are_structurally_finite`,
`cosine_with_zero_vector_is_finite_not_nan`,
`empty_utterance_against_empty_index_no_panic_no_match`.
- **Intent confusion / fail-closed.** An unrecognised utterance → `not_understood()`
(no service call); a recognised intent with no registered handler →
`not_understood()`; semantic below-threshold / empty-index → regex fallback.
No default high-privilege intent, no fail-open path.
- **Panic-on-input.** No `unwrap`/`expect`/index reachable from a crafted
utterance; the one `exemplars[id]` index uses an `id` from `enumerate()` over
the append-only exemplar `Vec` (no remove API), so it is always in bounds.
`cargo test -p homecore-assist --no-default-features`: **29→36, 0 failed** (+7);
default/`semantic`: **39→48, 0 failed** (+9). Python deterministic proof
unchanged (homecore-assist is off the signal proof path).
@@ -78,6 +78,23 @@ converts the entity registry; full conversion of the remaining artifacts is defe
- `MigrateError` carries context (`path`, line/field) for I/O, JSON, YAML, missing-field,
unsupported-schema-version, and entity-id parse failures (`src/lib.rs`).
- **Secret-leak hardening (security review, 2026-06).** `secrets.yaml` parse failures must
NOT use the generic `MigrateError::YamlParse { source }` variant: `serde_yaml`'s message
for a typed-tag coercion error (e.g. `port: !!int <value>`) embeds the offending scalar
verbatim (`invalid value: string "<the-secret-value>"`), and that error propagates through
the `InspectSecrets` CLI path to stderr — leaking a secret value despite the CLI's
deliberate `<redacted>` design. `read_secrets` now maps such failures to a dedicated
redacting variant `MigrateError::SecretsParse { path, line, column }` that carries only the
file path and a coarse location (`serde_yaml::Error::location()`), never the scalar content.
Pinned by `secrets::tests::malformed_secrets_error_never_contains_secret_value` (asserts the
rendered error **and its full `#[source]` chain** never contain the secret value).
**Review dimensions confirmed clean with evidence:** source is never mutated (no
`fs::write`/`remove`/`create` anywhere — P1 reads source, writes nothing); paths are
user-supplied dirs joined with fixed filenames (no `..`/absolute traversal beyond the
user's own privileges); malformed/typed/truncated `.storage` JSON and YAML **error, never
panic** (every production `unwrap`/`expect` is test-only); unknown schema `minor_version`
hard-errors fail-closed; no SQL/shell/path injection surface (the tool emits diagnostics
only, persists nothing in P1).
### 2.5 Deferred to P2+ (NOT built — honestly labelled)
@@ -89,7 +106,9 @@ converts the entity registry; full conversion of the remaining artifacts is defe
### 2.6 Test evidence (as shipped)
- 19 tests (`cargo test -p homecore-migrate`), per the crate README badge.
- 21 tests (`cargo test -p homecore-migrate`) — 19 as originally shipped plus 2 added by the
2026-06 security review (`secrets::tests::malformed_secrets_error_never_contains_secret_value`,
`malformed_secrets_error_reports_location`).
## 3. Consequences
@@ -0,0 +1,117 @@
# ADR-172: `wifi-densepose-cli` + `wifi-densepose-core` CSI-Deserialiser Security Review
| Field | Value |
|-------|-------|
| **Status** | Accepted — clean-with-evidence, 4 regression pins added |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **CSI-DESERIALISER-HARDENING** |
| **Supersedes / amends** | none (records review; references ADR-127 §9 for the `core` portion, ADR-136 for the pre-existing DoS ACs) |
## Context
The beyond-SOTA security sweep (branch `feat/v2-beyond-sota-sweep`) reviewed each
`v2/` crate for real, reproducible defects. Two crates had no prior dedicated
security ADR:
- **`wifi-densepose-core`** — the dependency root for all 12 downstream crates
(types, traits, error types, CSI frame primitives). A defect here is a
force-multiplier: every consumer inherits it.
- **`wifi-densepose-cli`** — the user-facing entrypoint
(`calibrate`/`calibrate-serve`/`enroll`/`train-room`/`room-watch` + MAT-gated),
which parses untrusted UDP CSI packets and operator-supplied paths.
A **specific hypothesis** motivated the core review. Three earlier reviews in
this campaign found a systemic **NaN-state-poisoning bug class** in crates that
depend on core (`wifi-densepose-calibration`, `-vitals`, `-geo`): a non-finite
(NaN/Inf) input latched into persistent filter/accumulator state (IIR `y1/y2`,
running mean, Welford/von-Mises accumulator, voxel grid) → silent **permanent**
feature failure. The load-bearing question for this review: **does that bug class
originate in a shared `wifi-densepose-core` primitive** (making the right fix a
single root fix), or was it independently re-implemented in each downstream
crate (making the three existing local fixes complete)?
## Decision
Record the review outcome and lock in the existing DoS guards with regression
tests. **No production code is changed** — both crates were already hardened
(ADR-136 acceptance criteria + `sanitize_room_id`); the gap was *untested*
guards, which a future refactor could silently remove.
### Load-bearing question — VERDICT: **NO** (the NaN class does not live in core)
`wifi-densepose-core` exposes **no stateful accumulator of any kind** — no
Welford/running-mean, no von-Mises/circular-mean, no IIR/biquad filter state, no
voxel grid.
- **MEASURED:** `grep` over `core/src` for
`welford|von_mises|biquad|y1|y2|running_mean|accumulat|voxel|self.*+=` matched
only the `InvalidState` *error* enum variant, "reset state" doc comments, and a
test-only LCG — **zero** stateful logic. The only float math in core is
construction-time projection (`CsiFrame::new` → amplitude/phase via `mapv`) and
pure stateless `utils` functions; nothing persists across frames.
- **Corroboration:** `wifi-densepose-calibration::Features::from_series`
(`extract.rs:103133`) already filters non-finite samples → `Features::ZERO`.
The downstream fixes are independently re-implemented, confirming each crate
rolls its own accumulator and each local fix is correct and complete. **A fix
in core would be a no-op (there is nothing to fix).**
Consequence: the NaN-state-poisoning class is a *downstream-local* pattern, not a
core-rooted defect. No hidden fourth instance exists in the shared primitive.
### Findings (all pins — guards already present, now tested)
| # | Location | Guard (pre-existing) | Regression pin | Evidence (MEASURED) |
|---|----------|----------------------|----------------|---------------------|
| 1 | `core` `types.rs:801` `from_canonical_bytes` | `saturating_mul` shape-vs-length check before `Vec::with_capacity(rows*cols)` | `canonical_decode_oversized_shape_is_bounded_not_allocated` | With guard removed: **panics `capacity overflow` at `types.rs:801`**; with guard: passes |
| 2 | `core` `types.rs` decoder | typed `CanonicalDecodeError`, never panics | `canonical_decode_never_panics_on_arbitrary_bytes` (fuzz sweep) | panic-free on arbitrary bytes |
| 3 | `cli` `calibrate.rs:276291` | length check `buf.len() < 20 + n_pairs*2` before `Array2::zeros(n_antennas*n_subcarriers)` | `test_parse_csi_packet_oversized_claim_is_rejected_not_allocated` | 255×65535 claim in a 2 KB packet → `None` (no allocation) |
| 4 | `cli` `calibrate.rs` parser | `None`-returning on malformed input | `test_parse_csi_packet_never_panics_on_arbitrary_bytes` (fuzz sweep) | panic-free on arbitrary UDP bytes |
### Dimensions confirmed clean (with evidence)
1. **Panic-on-adversarial-input = 0**`from_canonical_bytes` returns a typed
error for every malformed class; `parse_csi_packet` returns `None`. Both
fuzz-swept panic-free.
2. **NaN handling**`Confidence::new` rejects NaN
(`!(0.0..=1.0).contains(&NaN)``Err`); `compute_bounding_box` /
`to_flat_array` are NaN-tolerant (f32 min/max ignore NaN).
3. **Empty-frame safety**`amplitude_variance` / `mean_amplitude` are
panic-free on an empty `Array2` (ndarray 0.17 returns finite / `None`).
4. **Unbounded-memory DoS** — bounded in both deserialisers (findings 1 & 3).
5. **Path traversal**`calibrate-serve` defends every client-supplied
`room_id`/`bank`/`baseline` via `sanitize_room_id` (`[A-Za-z0-9_-]`, 64-char
cap) with existing tests; bearer-auth gate + non-loopback-bind warning present.
`mat export` writes to an operator-supplied `PathBuf` (acceptable CLI behavior).
6. **Secrets**`--token` is read from `CALIBRATE_TOKEN` env, never embedded.
## Validation
- `cargo test -p wifi-densepose-core`**35 → 37** lib passed, 0 failed (+3 doctests)
- `cargo test -p wifi-densepose-cli --no-default-features`**24 → 26** passed, 0 failed
- `cargo test --workspace --no-default-features`**exit 0**, 0 failed
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash
`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a` **unchanged**
(core/cli are off the signal proof path — confirms no pipeline alteration)
## Consequences
### Positive
- Two CSI deserialisers (the untrusted-input boundary of both the library root
and the network-facing CLI) now have their DoS guards pinned against
regression — a future refactor that drops a length check fails CI.
- The NaN-state-poisoning class is settled as downstream-local; reviewers no
longer need to suspect a shared-root defect, and the three prior local fixes
are confirmed complete.
### Negative
- None. Test-only change; no behavior or API change.
### Neutral
- The `core` portion is also noted in ADR-127 §9 (shared security-review log);
this ADR is the canonical record for the `wifi-densepose-cli` review.
## Links
- ADR-127 — HOMECORE state machine (shared security-review log, §9)
- ADR-136 — pre-existing CSI deserialiser DoS acceptance criteria
- ADR-151 — per-room calibration (`calibrate`/`calibrate-serve` surfaces)
@@ -0,0 +1,123 @@
# ADR-173: Metric-Locked PCK/MPJPE Accuracy Harness
| Field | Value |
|-------|-------|
| **Status** | Accepted — implemented, deterministically tested |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **METRIC-LOCK** |
| **Amends** | ADR-155 (generalizes the torso-only `metrics_core::pck_canonical` to a selectable normalization) |
| **Motivated by** | `docs/research/sota-nn-train-benchmark-brief.md` (PR #1090) |
## Context
The beyond-SOTA SOTA-research brief (PR #1090) identified the single biggest
threat to any "beyond-SOTA" accuracy claim this project makes: **metric
ambiguity**. Three PCK@20 numbers circulate, computed under three *different and
unstated* normalizations, so they cannot be compared:
- **96.0996.61%** — WiFlow-STD reproduction, **image/bounding-box-normalized** PCK (the looser convention).
- **81.63%** — an internal MM-Fi number reported as **"torso-PCK"** (tighter).
- **61.1%** — GraphPose-Fi (arXiv 2511.19105), **standard torso-diameter** PCK on the MM-Fi random split (the academic frontier).
The project has been burned by this twice: a previously-published 92.9% was
retracted because it used **absolute-pixel** normalization, not torso. Until
there is *one canonical, documented, tested* PCK definition — and every reported
number carries the definition it was computed under — no accuracy comparison is
credible, and the "prove everything" bar cannot be met for the benchmark half of
the work.
This is measurement infrastructure, not an accuracy claim. The deliverable's job
is to make the metric **unambiguous and reproducible**, so future numbers are
comparable and an unlabeled PCK is structurally impossible.
## Decision
Add a metric-locked accuracy harness as a new module
`v2/crates/wifi-densepose-train/src/accuracy.rs` (404 non-test lines; inline
deterministic tests bring the file to 708), re-exported at the crate root. It
**extends, not duplicates** — it reuses `metrics_core`'s geometric primitives
(`bounding_box_diagonal`, canonical hip indices `CANON_LEFT_HIP/RIGHT_HIP`), so
there remains exactly one implementation of each geometric reference; the
existing ADR-155 `pck_canonical` (torso-only) is unchanged and this generalizes
it.
### Public API
- `enum PckNormalization { TorsoDiameter, BoundingBoxDiagonal, AbsolutePixels(f32) }`
— the three conventions the three historical numbers used, now **explicit and
selectable**. `.label()` / `.tolerance(...)`.
- `pck_at(pred, gt, vis, k, norm) -> (correct, total, pck)` — PCK@k =
fraction of *visible* keypoints whose predicted-vs-GT distance ≤ the tolerance,
where tolerance = `k%` of the chosen normalizer (or an absolute threshold for
`AbsolutePixels`).
- `mpjpe(pred, gt, vis) -> f32` — mean per-joint position error (2D/3D, coordinate
units; mm for mm inputs). Re-exported crate-root as `pck_mpjpe` to avoid
colliding with the existing `eval::mpjpe`.
- `struct PoseAccuracy { pck_at: BTreeMap<u8,f32>, mpjpe, normalization, n_keypoints, n_frames }`
**a reported number always carries its `normalization`**; an unlabeled PCK is
structurally impossible to produce through this surface.
- `struct PoseFrame { pred, gt, visibility }` + `accuracy_report(frames, ks, norm) -> PoseAccuracy`
(micro-averaged over keypoints).
### Correctness is proven by hand-computed deterministic tests (no GPU, no data)
The tests construct synthetic keypoint sets whose PCK/MPJPE can be computed by
hand, and assert the harness matches. Highlights (all pass):
| Test | Construction | Expected |
|------|--------------|----------|
| perfect_prediction | pred==gt | PCK=1.0 (all 3 norms), MPJPE=0 |
| all_just_outside | every error just past τ@20 | PCK=0.0 |
| half_in_half_out | 2 exact, 2 just outside | PCK=0.5 |
| **three_normalizations (KEY PROOF)** | identical pred; nose err .06, shoulder .10, hips exact | torso=**0.50**, bbox=**1.00**, abs(.08)=**0.75** |
| mpjpe_2d / mpjpe_3d | (3,4)→5 / (1,2,2)→3 | 2.5 / 3.0 |
| mpjpe_excludes_invisible | invisible joint err 100 ignored | 5.0 |
| zero_torso_unscoreable | coincident hips | `(0,0,0.0)`, **not** false-perfect |
| no_visible_keypoints | vis=∅ | `(0,0,0.0)` |
| nan_coords | one NaN pred coord | counted wrong, **no panic** |
| empty report | no frames | 0.0, **not** NaN |
| bbox≥torso ordering | same frames | bbox-PCK ≥ torso-PCK |
### The key proof (the ambiguity is real and quantified)
Identical predictions, three declared normalizations → **0.50 / 1.00 / 0.75**.
Mechanism: the bbox diagonal `√(0.20² + 0.80²) = 0.825` is ~4× the hip-span torso
`0.20`, so τ@20 is 0.165 (bbox) vs 0.040 (torso) — the looser image-normalized
convention passes joints the strict torso convention rejects. This is *exactly*
why 96% / 81.6% / 61% cannot be lined up without declaring the enum, demonstrated
in-code.
## Validation
- `cargo test -p wifi-densepose-train --no-default-features` → lib **191 → 206**
(+15), `test_metrics` **12 → 14** (+2), doc-tests 8 — **0 failed**.
- `cargo test --workspace --no-default-features`**exit 0**, 0 failed.
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash
`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a` **unchanged**
(off the signal proof path — confirms no pipeline alteration).
## Consequences
### Positive
- The three historical PCK numbers can now be **recomputed under one declared
definition** and compared honestly. The retracted-number class of error
(silent normalization mismatch) is structurally prevented going forward.
- Establishes the measurement substrate for the beyond-SOTA target: GraphPose-Fi
cross-environment **PCK@20 = 12.9%** (standard torso PCK) is now a number this
harness can produce comparably.
### Negative
- None functional. The harness is additive; no existing metric path changed.
### Neutral
- Producing actual model numbers under this harness requires the trained models +
datasets (MM-Fi) and, for cross-domain splits, is the next sub-deliverable of
the benchmark/optimization milestone — out of scope here (this ADR is the
*instrument*, not the *reading*).
## Links
- ADR-155 — metric core (`pck_canonical`, torso-only) — generalized here
- ADR-152 — WiFi-Pose SOTA 2026 intake / WiFlow-STD benchmark
- `docs/research/sota-nn-train-benchmark-brief.md` — the motivating gap analysis
- GraphPose-Fi — arXiv 2511.19105 (verified cross-env PCK@20 = 12.9% anchor)
@@ -0,0 +1,110 @@
# ADR-174: CI Bench-Regression Gate (Compile-Verify)
| Field | Value |
|-------|-------|
| **Status** | Accepted — implemented, caught one real bit-rotted bench |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **BENCH-GATE** |
| **Milestone** | benchmark/optimization re-balance — sub-deliverable 8.3 |
| **Motivated by** | `docs/research/sota-nn-train-benchmark-brief.md` (target 3: criterion benches as CI regression baselines) |
## Context
The v2/ workspace ships **26 criterion benches across 18 crates** (e.g.
`nvsim/pipeline_throughput`, `wifi-densepose-ruvector/{ann,sketch,fusion}_bench`,
`wifi-densepose-signal/{signal,dsp_perf,features,calibration,cir,…}_bench`,
`wifi-densepose-mat/detection_bench`, `wifi-densepose-nn/{inference,native_conv}_bench`,
`wifi-densepose-engine/engine_cycle`, …). Because **benches are not part of
`cargo test`**, nothing in CI compiled them — so they bit-rot silently the moment
a public API they call changes, and the rot is invisible until someone manually
runs `cargo bench` months later.
The SOTA brief named "wire existing criterion benches into CI as regression
baselines" as a concrete benchmark-hygiene target. The honest difficulty: true
*timing*-regression gating on shared GitHub runners is unreliable — wall-clock
varies 23× run-to-run (a captured 10-sample run showed `float_l2/512` ranging
307444 ns), so a hard threshold or a cross-runner `criterion --baseline` compare
(baseline and PR land on different physical machines) would manufacture false
regressions. A gate that cries wolf gets disabled.
## Decision
Add `.github/workflows/bench-regression.yml` with **two jobs of explicitly
different authority** — and do NOT pretend to gate on timing.
### `bench-compile` — HARD GATE (real regression detection)
`cargo bench --workspace --no-default-features --no-run` compiles + links every
default-feature bench (no measurement → fully deterministic), plus a
`--features cir` compile of the gated `cir_bench`. Benches aren't in `cargo test`,
so this is the genuine guard: **the build fails the moment a bench stops
compiling.**
### `bench-fast-run` — INFORMATIONAL (`continue-on-error: true`, never gates)
Runs a curated pure-CPU subset (`nvsim/pipeline_throughput`,
`ruvector/{sketch,fusion}_bench`) in criterion quick-mode (1 s warm-up / 2 s
measure / 10 samples), targeted per-`--bench`, and uploads logs as an artifact.
Every number it produces is **informational only** — explicitly stated in the
workflow header.
### What is NOT done, and why (honest scope)
No timing-regression gate, no committed baseline JSON. The workflow header
documents the exact condition under which true timing-gating becomes honest: a
frequency-pinned **self-hosted** runner with a generous (>2×) floor. A
cross-runner baseline would be dishonest, so none is committed.
### Proof it matters (MEASURED)
Running the new gate on the current tree immediately caught
`wifi-densepose-mat/detection_bench` failing to compile:
`error[E0063]: missing field last_rssi in initializer of SensorPosition` — the
struct gained a field; the bench was never updated. **Fixed** in the same change
(`last_rssi: None`, the simulated-zone convention) and re-verified
(`cargo bench -p wifi-densepose-mat --no-default-features --bench detection_bench --no-run`
`Finished`). The gate paid for itself on its first run.
### Exclusions (documented in-workflow)
- `ruvector/crv_bench` — its crates.io dep `ruvector-crv 0.1.1` fails to build on
stable (upstream `E0308` in `stage_iii.rs`); excluded with a re-add condition.
- `onnx_bench` / `mqtt_throughput` — feature-gated (ort / mqtt), left to their
crates' own workflows. `wasm-edge/process_frame_bench` — workspace-excluded.
Conventions mirror existing workflows: `submodules: recursive` (the workspace
path-deps `vendor/rufield`), Swatinem/rust-cache `workspaces: v2`, Tauri/GTK apt
deps (a `--workspace` bench link pulls the whole graph), path-filtered triggers.
## Validation
- **Bit-rot caught + fixed** (above), re-verified `--no-run`.
- **MEASURED locally** (`--no-default-features`, Windows): nvsim, ruvector
(sketch/fusion/ann), signal/cir_bench, mat/detection_bench (post-fix),
vitals, ruview-swarm/swarm_bench all compile; fast subset runs (`nvsim
pipeline_run/d1/256` ≈ 55 µs; `ruvector sketch_hamming` ≈ 37 ns vs `float_l2`
≈ 63371 ns).
- `cargo test -p wifi-densepose-mat --no-default-features` → 166/6/2 passed, 0 failed.
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash
`f8e76f21…46f7a` unchanged.
- **Honest limitation:** the full `--workspace --no-run` could not be
end-to-end validated on this Windows box (`desktop` needs GTK, `candle-core`
fails on MSVC, `swarm_bench` LTO-links OOM under parallel pressure — all
Windows-env artifacts; each affected bench compiles standalone here). **The
first green Linux CI run on the PR is the authoritative proof of the
`--workspace` step.**
## Consequences
### Positive
- Bench bit-rot is now a hard CI failure, not a silent surprise — the 26 benches
stay compilable as the APIs they exercise evolve.
- The benchmark-infrastructure half of the DoD (step 5) is satisfied honestly,
setting up the next sub-deliverable (QAT-int8 measurement) to be
regression-protected.
### Negative / Neutral
- No automated timing-regression detection (deliberate — see scope). Revisit only
with a frequency-pinned self-hosted runner.
- One bench (`crv_bench`) excluded pending an upstream dep fix.
## Links
- ADR-173 — metric-locked accuracy harness (sub-deliverable 8.1)
- `docs/research/sota-nn-train-benchmark-brief.md` — motivating target
- ADR-134 (CIR), ADR-135 (calibration), ADR-154 (signal DSP benches) — benched paths
@@ -0,0 +1,172 @@
# ADR-175: int8 Quantization of the WiFlow-STD "half" Pose Model — MEASURED accuracy/size trade-off
| Field | Value |
|-------|-------|
| **Status** | Accepted — MEASURED, reproducible (honest negative) |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **EDGE-INT8** |
| **Sub-deliverable** | 8.2 of the benchmark/optimization milestone |
| **Metric lock** | ADR-173 (one declared PCK normalization for every reported number) |
| **Motivated by** | `docs/research/sota-nn-train-benchmark-brief.md` (§edge int8) |
## Context
The SOTA brief characterized the int8 edge story for the WiFlow-STD pose net as
"fully characterized" for PTQ on the **published 2.23M** model (static QDQ
conv-only = the sweet spot; dynamic int8 ≈ no-op on this all-conv net), and named
**QAT-int8 on the strictly-dominating 843,834-param "half" model** as "the one
untested edge lever." This ADR is the reading of that lever — a MEASURED
fp32-vs-int8 trade-off for the half model, not a claim.
The half model (`half_best.pth`, 843,834 params) is the efficiency-sweep winner
from ADR-152 (`run_sweep.py` VARIANTS[0]: `tcn=[270,220,170,120]`,
`conv=[4,8,16,32]`, `attn_groups=4`). Its fp32 accuracy was recorded in the sweep;
this ADR re-measures it under the locked normalization and quantizes it.
**The whole point of this deliverable is reproducibility.** Every number below was
produced by running `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py`
on host `ruvultra` (RTX 5080, torch 2.11.0+cu128) against the real checkpoint and
the real seed-42 test split. The script + the exact command + the recorded stdout
**is** the proof artifact. Nothing here is estimated.
## Decision
Quantize the half model to int8 with **both** levers and report both honestly:
1. **QAT (primary target)** — FX graph-mode quantization-aware training, fbgemm
backend, 3 epochs of fake-quant fine-tuning from `half_best.pth` (AdamW lr 2e-5,
the existing `PoseLoss`), then `convert_fx` to a true int8 graph.
2. **PTQ static QDQ (the brief's "sweet spot", measured as the honest fallback)**
FX graph-mode static PTQ, fbgemm, calibrated on 64 train batches.
### Locked normalization (ADR-173)
**Torso-diameter PCK** — neck (keypoint idx 2) → pelvis (idx 12) distance — the
standard MM-Fi/GraphPose-Fi convention. This is exactly the default
`use_torso_norm=True` path of the upstream harness's `utils/metrics.calculate_pck`.
The **same** `calculate_pck`/`calculate_mpjpe` that produced the sweep's fp32
numbers scores **both** fp32 and int8 here, so the comparison is metric-locked: no
normalization is mixed, and the fp32 baseline reproduces the sweep's recorded
`half` test numbers bit-for-bit (PCK@20 clean = 96.62%), confirming the harness is
the same one.
### Device note (why int8 is CPU)
PyTorch int8 quantized kernels execute on CPU (fbgemm/x86), not CUDA. So int8 eval
is CPU. To keep the accuracy delta device-matched (not confounding int8-vs-fp32
with CPU-vs-GPU), the script measures an **fp32-CPU** baseline too. fp32-CPU and
fp32-GPU agree to 4 decimals (PCK@20 clean 0.96623 vs 0.96623), so CPU/GPU
introduces no drift — the int8 deltas below are pure quantization effect.
## MEASURED results (clean test subset = 52,560 NaN-free windows; torso-PCK)
Source: stdout of the run below + `~/wiflow-std-bench/sweep/int8/int8_results.json`.
| model | quant | size (MB) | PCK@20 | PCK@50 | MPJPE | Δ PCK@20 | Δ PCK@50 | size win |
|-------|-------|-----------|--------|--------|-------|----------|----------|----------|
| **fp32** (cpu) | — | **3.351** | **96.62%** | **99.47%** | **0.008981** | — | — | 1.00× |
| int8 PTQ static | PTQ | 1.046 | 40.98% | 94.98% | 0.038262 | **55.64 pp** | 4.49 pp | 3.20× smaller |
| int8 QAT (3 ep) | **QAT** | 1.043 | 67.48% | 98.69% | 0.026548 | **29.15 pp** | 0.78 pp | 3.21× smaller |
Full-test-set (54,000 windows incl. NaN-zero-filled files 487499) tracks the
clean subset: fp32 96.10% / int8-PTQ 41.11% / int8-QAT 67.48% PCK@20 — same shape,
recorded in the JSON.
### Verdict
**int8 is NOT a win for this model at the tight PCK@20 edge target — honest no.**
- **PTQ static collapses** (55.64 pp PCK@20). Naive static QDQ destroys the half
model. The "sweet spot" characterization from the brief does not transfer from
the 2.23M model to this 843k model at the strict torso-PCK@20 threshold.
- **QAT recovers a large share of the relative gap** (PTQ 40.98% → QAT 67.48%) but
still **loses 29.15 pp** at PCK@20 for a 3.21× size reduction. At the loose
PCK@50 threshold QAT is nearly lossless (0.78 pp), i.e. coarse-localization
survives int8 but fine-localization does not.
- The size win is real and consistent (3.2× smaller, 3.351 MB → ~1.04 MB), but
**3.2× compression at 29 pp PCK@20 is a bad trade** when the half model already
fits comfortably in edge flash at fp32. Recommendation: **keep fp32 (or fp16)
for the half model on the edge**; do not ship this int8 variant as-is.
### Observed fake-quant → int8 conversion gap (disclosed, not hidden)
During QAT the **fake-quant** model's val PCK@20 reached 83.45% (epoch 3), but the
**converted int8** model scores 67.48% on test. A ~16 pp drop on `convert_fx` is a
real effect — the fbgemm int8 kernels are not bit-identical to the fake-quant
simulation (per-tensor activation quant + the axial-attention `einsum`/softmax path
quantize worse than the straight-through estimate predicts). This gap is the honest
reason QAT did not close the loss, and it is exactly the kind of number that would
be invisible if one only reported the fake-quant proxy. We report the **converted
int8** number as the deliverable, not the fake-quant proxy.
## Reproduction
```bash
ssh ruvultra 'cd ~/wiflow-std-bench && source venv/bin/activate && \
python ~/quantize_half_int8.py --mode both --qat-epochs 3 2>&1'
```
- Script (committed): `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py`
(scp'd to `~/quantize_half_int8.py` on ruvultra for the run).
- Inputs (on ruvultra, unmodified): `~/wiflow-std-bench/sweep/half_best.pth`,
`~/wiflow-std-bench/preprocessed_csi_data/` (seed-42 file-level 70/15/15 split),
upstream `models`/`dataset`/`utils/metrics`/`losses` (DY2434/WiFlow @ 06899d29,
Apache-2.0), and `sweep/model_compact.py` (the half-model definition).
- Outputs (written, non-destructive): `~/wiflow-std-bench/sweep/int8/`
`half_int8_qat.pth`, `half_int8_ptq_static.pth`, `int8_results.json`,
`int8_run.log`. **No existing file under `~/wiflow-std-bench` was modified.**
- Run metadata: host `ruvultra`, GPU RTX 5080, torch `2.11.0+cu128`, fbgemm engine,
`date_utc 2026-06-15T12:35:06Z`, QAT ≈ 97 s/epoch.
## What is MEASURED vs CLAIMED
- **MEASURED:** every PCK/MPJPE/size number in the table; the fp32 baseline (which
reproduces the recorded sweep `half` numbers); the PTQ collapse; the QAT partial
recovery; the fake-quant→int8 conversion gap; the 3.2× size reduction.
- **CLAIMED / not done here:** ONNX/TFLite export; on-real-edge (ESP32/Pi/Hailo)
latency or energy (int8 here is measured on x86 fbgemm, the dev box, **not** an
edge SoC — the size number transfers, a latency number does **not**); a
per-layer mixed-precision search that might keep the attention block in fp32; QAT
beyond 3 epochs or with learned-quant-range schedules. Those are the obvious next
levers if int8 is revisited; none is asserted as a result.
## Honest scope / limitations
- **Single eval split** — one seed-42 file-level test partition; no cross-room /
cross-environment generalization split (the GraphPose-Fi frontier from ADR-173 is
a separate, harder split and is not what is measured here).
- **In-domain only** — these are in-distribution test numbers; they say nothing
about the cross-environment robustness gap.
- **x86 int8, not edge-SoC int8** — accuracy and size transfer to an edge int8
runtime; the runtime/latency does not (different kernels, different SoC). No
latency claim is made.
- **QAT lightly tuned** — 3 epochs, single LR, default fbgemm qconfig. A longer /
better-tuned QAT might narrow the 29 pp, but on the evidence here int8 does not
reach fp32 at PCK@20, and that is the reportable result today.
## Consequences
### Positive
- The "one untested edge lever" (QAT-int8 on the half model) is now MEASURED. The
edge int8 question for the half model is answered with reproducible numbers: at
the strict PCK@20 target it loses, and we can say so with a committed script.
- Establishes a reusable, metric-locked quantization+eval harness
(`quantize_half_int8.py`) for any future int8 attempt on these compact variants.
### Negative
- None to the codebase (additive script + ADR + CHANGELOG only; no production Rust
or signal-pipeline change; Python deterministic proof hash
`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a` unchanged).
### Neutral
- The negative verdict means the half model stays fp32/fp16 on the edge for now.
int8 for these compact pose nets is parked pending the next-lever work above.
## Links
- ADR-173 — metric-locked PCK/MPJPE harness (the locked normalization used here)
- ADR-152 — WiFi-Pose SOTA 2026 intake / WiFlow-STD benchmark / efficiency sweep
(produced `half_best.pth`)
- `docs/research/sota-nn-train-benchmark-brief.md` — §edge int8 (the "one untested
lever" this ADR measures)
- Script: `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py`
@@ -0,0 +1,103 @@
# ADR-176: `ruview-swarm` NaN-Fail-Open Safety Review
| Field | Value |
|-------|-------|
| **Status** | Accepted — 4 real safety bugs fixed + pinned; 2 issues documented for follow-up |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **SWARM-FAILCLOSED** |
| **Reviews** | ADR-148 (`ruview-swarm` drone swarm control plane) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 1 of 4 |
## Context
`ruview-swarm` (ADR-148) is the drone swarm control plane — hierarchical-mesh
topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4 command
dispatch. It is the highest-stakes of the four never-reviewed v2 crates: a defect
here can produce an **unsafe physical drone command**. It had no prior security
ADR.
### Trust-boundary map
Untrusted input enters via `SwarmOrchestrator::receive_peer_state` /
`receive_peer_detection`, which accept full `DroneState` / `CsiDetection` serde
structs with **f64/f32 fields and no finite-check**, and via
`SwarmConfig`/`FhssConfig`/`Geofence` deserialization. The MAVLink wire formats in
`mavlink_messages.rs` are **integer-encoded** (i32 mm / u8) and provably cannot
carry NaN — so the NaN class is reachable through the **serde struct path, not the
MAVLink decode path**. Commands flow out to a `FlightController` (PX4/ArduPilot).
The unifying bug class found: **IEEE-754 NaN/Inf silently defeating a safety
comparison** (`NaN < threshold` evaluates to `false`), causing safety logic to
**fail OPEN**. This is distinct from — but rhymes with — the NaN-state-poisoning
class found earlier in calibration/vitals/geo (there, NaN latched into persistent
state; here, NaN slips through a one-shot guard). Both are "non-finite input
defeats logic," and the fix discipline is the same: **reject non-finite at the
trust boundary, fail CLOSED.**
## Decision
Fix the four reachable fail-open bugs by making each safety predicate
non-finite-aware and fail-closed, each pinned by a fails-on-old test. Document
two further genuine issues that need larger, riskier changes rather than churning
them in a security pass.
### Findings fixed (all MEASURED fails-on-old)
| # | Severity | File:line | Issue | Fix | Pin (old behavior) |
|---|----------|-----------|-------|-----|--------------------|
| F1a | **HIGH** | `failsafe/mod.rs:51` | `nearest_neighbor_dist < collision_dist_m` fails open on a NaN peer position → **collision avoidance silently disabled** | `!is_finite() ||``EmergencyDiverge` | `test_nan_neighbor_distance_fails_closed_to_diverge` (old → `Nominal`) |
| F1b | **HIGH** | `failsafe/mod.rs:75` | NaN `battery_pct` bypasses every battery check → drone stays Nominal on unknown battery | `!is_finite() ||``ReturnToHome` | `test_nan_battery_fails_closed_to_rth` (old → `Nominal`) |
| F2 | **MEDIUM** | `security/geofence.rs:33` | NaN `z` altitude skips the altitude-breach check and point-in-polygon returns `Safe` → silent geofence bypass | leading non-finite coord → `HardBreach` | `test_nan_altitude_fails_closed` (old → `Safe`) |
| F3 | **MEDIUM/DoS** | `security/antijamming.rs:65,71,102` | empty deserialized `channels_mhz``% 0` **panic** in `next_hop`/`current_channel_mhz`/`evasive_hop`/`tick`, crashing the radio task | `len == 0` early-return (`0.0` sentinel) | `test_empty_channels_does_not_panic` (old → panic `divisor of zero`) |
| F4 | **LOW** | `sensing/multiview.rs:70` | NaN `victim_position` passes the `is_some()` filter and propagates into the fused "confirmed victim" location dispatched to the swarm | require finite confidence + position (drop) | `test_nan_victim_position_dropped_from_fusion` (old → non-finite fused position) |
### Dimensions confirmed clean (with evidence)
- **MAVLink decode panic-safety**`SwarmNodeState::decode(&[u8;20])` `try_into().unwrap()`s are over fixed const ranges of a fixed-size array → provably infallible; no arbitrary-length `&[u8]` decode path exists.
- **UWB/GPS anti-spoofing NaN-safe**`(gps_dist - uwb_dist).abs() <= tol` already fails CLOSED on a NaN range (counts as inconsistent → spoof rejected); covered by `test_spoofed_gps_invalid`.
- **Bounded grid / no allocate-from-length-field**`ProbabilityGrid` bounds-checks `cx/cy`; `pos_to_cell` uses saturating `as u32` (no UB).
- **Mesh `nearest_k` NaN-safe sort**`partial_cmp(..).unwrap_or(Equal)` cannot panic on NaN.
- **No hardcoded secrets**`MavlinkSigner` key is constructor-injected `[u8;32]`; grep-confirmed nothing embedded.
### Documented, not fixed (genuine — deferred to avoid churn/regression risk)
1. **Raft `AppendEntries` lacks the Log-Matching consistency check**
(`topology/raft.rs:187`). A follower appends a leader's entries when
`term >= current_term` **without validating `prev_log_index`/`prev_log_term`**,
so a malformed/byzantine leader can corrupt a follower's log — a genuine
consensus-safety gap. A correct fix reworks the log-append plus the
caller-side vote-tally contract (the existing `handle_message` delegates
tallying to the caller) — a larger change with test-rewrite risk, so it is
recorded here rather than rushed in a security pass.
2. **`MavlinkSigner::verify` uses a non-constant-time tag `==` and has no
replay/timestamp-window rejection** (`security/mavlink_signing.rs:64`). The
module doc already flags the replay limitation as a demo/test simplification.
Hardening (constant-time compare + monotonic timestamp window) is a focused
follow-up.
These two are the recommended scope of the next `ruview-swarm` hardening pass.
## Validation
- `cargo test -p ruview-swarm --no-default-features`**117 → 123** passed, 0 failed (+6 pins).
- All 6 new tests MEASURED fails-on-old (2× `Nominal`, `Safe`, panic `divisor of zero`, non-finite fused position); pass on the fix.
- `cargo test --workspace --no-default-features`**exit 0**, 0 failed.
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash
`f8e76f21…46f7a` unchanged (ruview-swarm off the signal proof path).
## Consequences
### Positive
- Four reachable fail-open paths in a *physical-safety* control plane (collision
avoidance, battery RTH, geofence, anti-jamming radio task) now fail CLOSED on
hostile/degenerate input, each regression-pinned.
- Extends the "non-finite input defeats logic" defense from the state-poisoning
variant (calibration/vitals/geo) to the fail-open-comparison variant.
### Negative / Neutral
- Two genuine issues (Raft log-matching, MAVLink signer) remain open by choice —
see Documented-not-fixed; they define the next hardening pass.
## Links
- ADR-148 — `ruview-swarm` drone swarm control system
- ADR-172 — core/cli review (where the NaN bug-class root question was settled NO)
- ADR-127 — homecore review (sibling NaN/concurrency hardening)
@@ -0,0 +1,92 @@
# ADR-177: `nvsim` Degenerate-Input Hardening (NV-Diamond Simulator)
| Field | Value |
|-------|-------|
| **Status** | Accepted — 2 real MEDIUM bugs fixed + pinned; determinism preserved |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **NVSIM-FAILCLOSED** |
| **Reviews** | ADR-089 (`nvsim` NV-diamond magnetometer pipeline simulator) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 2 of 4 |
## Context
`nvsim` (ADR-089) is a standalone, **WASM-ready** deterministic NV-diamond
magnetometer pipeline simulator — a forward-only leaf:
`scene → source → propagation → NV ensemble → digitiser → MagFrame + SHA-256
witness`. It has no network surface, so the real attack surface is **degenerate
physical-parameter input** crossing the external boundary — specifically the
WASM `config_json` / `scene_json` entry points.
Two properties matter for this crate that don't for others: it is billed
**deterministic** (a published cross-machine witness must reproduce bit-exactly),
and under `panic=abort` WASM any panic **aborts the whole module**. So a
config-induced panic is a denial-of-service, and a silent numeric corruption
defeats the simulator's entire purpose.
## Decision
Fix the two reachable degenerate-input bugs at their funnel points, each pinned
by a fails-on-old test, **without perturbing the deterministic happy path** (the
guards fire only on non-finite / degenerate input; the published witness is
unchanged).
### Findings fixed (both MEASURED-reproduced)
| # | Severity | Location | Issue | Fix |
|---|----------|----------|-------|-----|
| NVSIM-DT-01 | MEDIUM (DoS) | `pipeline.rs:58,95` | `dt = config.dt_s.unwrap_or(1.0 / f_s_hz)`; an external `f_s_hz == 0.0``dt = +Inf``(dt*1e6) as u64` saturates to `u64::MAX``(sample as u64) * dt_us` **panics `attempt to multiply with overflow`** at `sample ≥ 2` (debug/WASM-abort; garbage `t_us` in release). MEASURED: panic at `pipeline.rs:95:30`. | Sanitise `dt` (non-finite/non-positive → 1 µs fallback), cap the `u64` cast at `u64::MAX`, `saturating_mul` the timestamp — no config can overflow it. |
| NVSIM-NAN-01 | MEDIUM (silent corruption) | funnel `digitiser.rs::adc_quantise` (root: near-field clamp bypass in `source.rs`) | A non-finite scene param (NaN/Inf dipole position, Inf moment, NaN loop radius) **bypasses the near-field clamp** (`NaN < R_MIN_M == false` → the `1/r³` path runs → NaN field), and at the ADC `NaN as i32 == 0` (Rust saturating cast) emits a frame `b_pt=[0,0,0]` with **`ADC_SATURATED` CLEAR** — indistinguishable from a legitimate zero-field reading. MEASURED: `b=[NaN,NaN,NaN] sat=false``b_pt=[0,0,0] flags=0b0000`. | `adc_quantise`: any non-finite input → code `0` **with the saturation flag raised**; the pipeline's existing `adc_sat` OR-reduction propagates `ADC_SATURATED` onto the frame, making the corruption visible downstream. |
This is the same **NaN-fail-open / NaN-poisoning** family seen across
calibration/vitals/geo and ruview-swarm — non-finite input defeating a guard —
but bounded here to a single frame (no cross-timestep accumulator).
### Dimensions confirmed clean (with evidence)
1. **Determinism integrity — clean.** One RNG only: `ChaCha20Rng::seed_from_u64(seed)`,
fully caller-seeded (grep: one `seed_from_u64`, **zero** `thread_rng`/`getrandom`/
`SystemTime`/`Instant`/`HashMap`); `Cargo.toml` pins `rand`/`rand_chacha`
`default-features=false` (no OS entropy). BoxMuller draws
`gen_range(f64::EPSILON..=1.0)` (avoids `ln(0)=-Inf` by construction). Frame
bytes fixed LE; source summation order fixed by `Vec` order. **The published
cross-machine witness `cc8de9b0…93b4` (`proof_witness_publishes_a_known_value`)
passes UNCHANGED after both fixes** — the happy path is byte-identical; guards
touch only degenerate inputs. *Attested caveat (not a finding): libm
`cos`/`ln`/`sqrt` could differ x86↔wasm; the witness is documented as
x86_64-captured.*
2. **Panic-free deserialisation — clean.** `MagFrame::from_bytes` validates
len/magic/version, then per-field `buf[a..b].try_into().expect(...)` are over
fixed sub-ranges of an already-length-checked 60-byte buffer (provably
infallible). No `unsafe`, no `panic!`/`unreachable!` in production; every other
`unwrap`/`expect` is `#[cfg(test)]`.
3. **Div-by-zero / numerical landmines — clean.** `dipole_field`/`current_loop_field`
clamp `r_norm < R_MIN_M` before `1/r³`,`1/r²` (finite inputs); `shot_noise_floor`
guards `denom <= 0`; `vec3_normalise` guards `n < 1e-20`. The only hole was the
NaN *bypass* of the clamp — closed at the ADC funnel (NVSIM-NAN-01).
## Validation
- `cargo test -p nvsim --no-default-features`**50 → 53** passed, 0 failed (+3 pins:
`degenerate_zero_sample_rate_does_not_panic`,
`non_finite_scene_input_flags_frame_instead_of_silently_zeroing`,
`adc_quantise_flags_non_finite_as_saturated`).
- `cargo test --workspace --no-default-features`**exit 0**, 0 failed.
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash
`f8e76f21…46f7a` unchanged (nvsim off the signal proof path).
- nvsim's own cross-machine witness `cc8de9b0…93b4` reproduces unchanged.
## Consequences
### Positive
- A config-induced WASM-abort DoS and a silent NaN→fake-zero-field corruption are
closed at their funnel points, each regression-pinned, with the deterministic
witness proven intact.
### Negative / Neutral
- None. Guards affect only degenerate inputs; happy-path output is byte-identical.
## Links
- ADR-089 — `nvsim` NV-diamond magnetometer simulator
- ADR-176 — `ruview-swarm` (sibling NaN-fail-open review)
- ADR-172 — core/cli (where the NaN-bug-class root was settled NO)
@@ -0,0 +1,87 @@
# ADR-178: `wifi-densepose-desktop` IPC Injection Fix + Capability Least-Privilege
| Field | Value |
|-------|-------|
| **Status** | Accepted — 2 real MODERATE bugs fixed + pinned (MEASURED on Windows) |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **DESK-LOCKDOWN** |
| **Reviews** | `wifi-densepose-desktop` (Tauri v2 desktop app) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 3 of 4 |
## Context
`wifi-densepose-desktop` is the Tauri v2 desktop app (ESP32 discovery, firmware
flashing, OTA, provisioning, server control). The real attack surface is the
**Tauri IPC boundary** — `#[tauri::command]` handlers that take arguments from the
webview/JS — and the **capability/allowlist scope**. The crate **builds and tests
on Windows** (Tauri 2.10.3, webview2 path, no GTK), so both findings are MEASURED,
not source-analysis-only.
## Decision
Fix the two real findings; attest the rest of the surface clean with evidence.
### Findings fixed (both MEASURED)
| # | Severity | Location | Issue | Fix |
|---|----------|----------|-------|-----|
| WDP-DESK-01 | MODERATE | `src/commands/discovery.rs:438` (`configure_esp32_wifi`) | Webview-supplied `ssid`/`password` are concatenated into newline-terminated serial commands (`wifi_config {} {}\r\n`, `set ssid {}\r\n`) with **no validation** → a `\r\n` in either field **injects an arbitrary follow-up firmware command** (`reboot`, `erase_nvs`) across the IPC trust boundary. | `validate_wifi_credentials()` — WPA2 length bounds (SSID 132, password 863) **+ reject all control chars** (`char::is_control()`), called fail-closed before any serial write. |
| WDP-DESK-02 | MODERATE | `capabilities/default.json:7-8` | `shell:allow-execute` + `shell:allow-open` granted to the webview but **unused** (Rust spawns via `std::process::Command`; the UI uses only `dialog.open`). A webview compromise (a UI-dependency XSS) → arbitrary **unscoped host command execution**. | Removed both `shell:` permissions (kept `core:default` + the two in-use `dialog:` perms); regenerated `gen/schemas/capabilities.json` now asserts `["core:default","dialog:allow-open","dialog:allow-save"]`. |
Both are MODERATE (not HIGH): each requires a webview compromise or a malicious
local caller to weaponize. The unifying lesson is **least privilege at the IPC
boundary** — validate every webview-supplied argument that reaches a serial/FS/
process sink, and grant only the capabilities actually exercised.
### Tauri-command + capability audit (every handler)
All 30+ command handlers were mapped. Only `configure_esp32_wifi` lacked input
validation on a string that reached a command sink (WDP-DESK-01). Every
subprocess uses `Command::new(prog).args([...])` (argv vector — no shell-string
interpolation), so `port`/`source`/`chip`/`baud` cannot inject a second command
even unvalidated. `tauri.conf.json` ships **no** `fs`/`http` plugin and **no**
`"all":true`/`"$HOME/**"` scope; after WDP-DESK-02 the allowlist is minimal.
### Dimensions confirmed clean (with evidence)
1. **Directory traversal / arbitrary file** — path args (`firmware_path`/`wasm_path`)
are blobs the local user selects via the native `dialog.open` picker; settings
I/O is a fixed filename under `app_data_dir`. No attacker-named path sink.
2. **Shell-string injection** — every subprocess is an argv vector; grep found no
shell-string interpolation anywhere.
3. **SSRF-to-secret**`node_ip`-built URLs target the local ESP32 mesh and return
only device status JSON; no credential returned to the webview.
4. **Panic-on-input** — handlers use `.map_err(|e| e.to_string())?`; the one
`expect` is guarded by an `is_none()` early-return; provision/discovery
deserializers bounds-check every slice index (NVS size capped ≤ 4096).
5. **Hardcoded secrets**`ota_psk` is a per-call `Option<String>`, never embedded;
grep for embedded keys/tokens over `src/` is empty.
6. **Shell plugin genuinely unused**`tauri_plugin_shell` is `init()`-ed but its
`Command`/`open` API is never invoked from Rust or the TS UI (which imports only
`@tauri-apps/plugin-dialog`) — confirming WDP-DESK-02 is safe to remove.
## Validation
- `cargo check -p wifi-densepose-desktop --no-default-features``Finished` (Windows, MEASURED).
- `cargo test -p wifi-densepose-desktop --no-default-features` → lib **18 → 21** (+3 validator pins:
`test_validate_wifi_credentials_rejects_injection` / `_rejects_out_of_range` / `_accepts_valid`),
integration 21/21, **0 failed**.
- Capability narrowing MEASURED: regenerated `capabilities.json` permission set verified.
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash `f8e76f21…46f7a`
unchanged (desktop off the signal proof path).
## Consequences
### Positive
- An IPC serial-command-injection path and an over-broad shell capability are
closed in the desktop app, each pinned / verified, with the rest of the
30-command IPC surface attested clean.
### Negative / Neutral
- None. The removed shell capability was unused; the validator rejects only
malformed/hostile credentials.
## Links
- ADR-176 / ADR-177 — sibling Milestone-#9 reviews (ruview-swarm, nvsim)
- ADR-172 — core/cli review
@@ -0,0 +1,81 @@
# ADR-179: `wifi-densepose-occworld-candle` Checkpoint-Load Hardening
| Field | Value |
|-------|-------|
| **Status** | Accepted — 1 HIGH + 2 LOW bugs fixed + pinned (MEASURED on Windows) |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **OCCWORLD-DTYPE** |
| **Reviews** | `wifi-densepose-occworld-candle` (Candle occupancy-world model) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 4 of 4 — **CLOSES the milestone** |
## Context
`wifi-densepose-occworld-candle` is a Candle-based occupancy-world model
(VQ-VAE + transformer over occupancy tokens). The real risk surface for an ML
crate is degenerate-input / malformed-weights handling: a `#[forbid(unsafe_code)]`
crate can still **panic** (a DoS, and under WASM an abort) when a tensor op hits an
inconsistent shape. The crate **builds and tests on Windows**, so all findings are
MEASURED.
## Decision
Fix the three reachable bugs, each pinned by a fails-on-old test; attest the rest
clean with evidence.
### Findings fixed (all MEASURED)
| # | Severity | Location | Issue | Fix |
|---|----------|----------|-------|-----|
| 1 | **HIGH** | `model.rs:95` (`Dtype::I32 => Some(DType::I64)`) | **Crash on any int32-tensor checkpoint.** An I32 byte buffer (4 B/elem) is handed to `from_raw_buffer(.., I64, shape, ..)`; candle derives `elem_count = data.len()/8`, **halving** the count while keeping the original shape → a tensor that claims 2× its storage. Reading it **panics** with a slice-OOB (`range end index 6 out of range for slice of length 3`) inside candle-core. A checkpoint with any int32 tensor (index/buffer tensors are common in PyTorch exports) → **DoS on load**. | Map `I32 → DType::I32`, `I16 → DType::I16` (both first-class candle dtypes). Pinned by `int32_tensor_loads_with_consistent_shape_and_values` (panics on old, passes on new). |
| 2 | LOW | `inference.rs::predict` | Frame/batch dims weren't validated (only H/W/D were): `f_in > num_frames*2` over-indexes the temporal embedding → a cryptic candle `InvalidIndex` *error* (not a panic — candle bounds-checks); zero frame/batch feeds a zero-element tensor. | Boundary guard rejects zero / over-capacity frame+batch with a clear `ShapeMismatch`. 5 pins. |
| 3 | LOW | `vqvae.rs:141` (`z.elem_count() / last`) | **Divide-by-zero panic** in public `VQCodebook::encode` on a rank-0 / empty-last-dim tensor (`last == 0`). | Fail-closed guard returns a clear error. Pinned by `encode_rejects_scalar_without_panicking`. |
The HIGH finding is the notable one: the crate's own dtype mapping **defeated**
the upstream `safetensors::validate()` byte-length guarantee by misdeclaring the
dtype — the one place malformed/widened weights could reach a panicking candle op.
### Dimensions confirmed clean (with evidence)
- **Panic surface** — grep for `unwrap()/expect()/panic!/unreachable!` across `src/`
**zero in production paths**; all ops use `?`/`map_err`; the `last().unwrap_or(&0)`
is now guarded. `as` casts operate only on config-bounded/internal values.
- **NaN-state-poisoning (the named class) — N/A.** The engine is **stateless between
`predict` calls** (no persistent world-model buffer to latch into), and input is
`u8` class indices (non-finite input structurally impossible). NaN weights flow to
`argmax` (deterministic, bounded to a valid class index) — no panic, no persistence.
- **Unbounded alloc / shape-data mismatch from malformed weights** — defended upstream
by `safetensors::validate()` (overflow-checked `nelements*dtype.size()` vs declared
byte range + contiguous-offset + buffer-length checks), rejected before reaching
candle. Finding #1 was the one place the crate defeated that guarantee.
- **Model/path loading**`load`/`load_safetensors` check `path.exists()` → typed
`CheckpointNotFound`; corrupt bytes → `CheckpointParse` (pinned). No path-traversal
surface (caller-supplied path, opened read-only, never joined with untrusted segments).
- **Secrets** — grep clean (only `token_h`/`token_w` config fields match `token`).
- **Determinism** — the crate's central honesty claim, verified by the pre-existing
`tests/predict_honesty.rs` (3 tests, still pass).
- `unsafe_code = "forbid"` in the manifest.
## Validation
- `cargo test -p wifi-densepose-occworld-candle --no-default-features` → **31/31**
(lib 17, checkpoint_loading 4, input_validation 5, predict_honesty 3, doctests 2),
0 failed.
- `cargo test --workspace --no-default-features` → 0 failed across every crate (a lone
`wifi-densepose-desktop --test api_integration` "Access is denied (os error 5)" was a
Windows file-lock/AV flake — re-ran isolated 21/21, unrelated).
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash `f8e76f21…46f7a`
unchanged (occworld off the signal proof path).
## Consequences
### Positive
- A checkpoint-load DoS (the int32 dtype-widening panic) and two degenerate-input
panics are closed in the world-model crate, each pinned. **Milestone #9 (all 4
ungated crates) is complete.**
### Negative / Neutral
- None. Guards reject only malformed/degenerate inputs.
## Links
- ADR-176 / ADR-177 / ADR-178 — sibling Milestone-#9 reviews (ruview-swarm, nvsim, desktop)
@@ -0,0 +1,279 @@
# ADR-182: `npx ruview` — A RuView Agent Harness Minted via MetaHarness
| Field | Value |
|-------|-------|
| **Status** | Accepted — **P1+P2 implemented & validated** (`harness/ruview/`, 17/17 tests, MCP handshake + `ruview.verify` PASS against the real repo, packs to 16.7 kB / 21 files) · P3 publish-ready (name decision pending) · P4 (router + provenance) designed |
| **Date** | 2026-06-17 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-HARNESS** |
| **Builds on** | MetaHarness (`metaharness@0.1.15`, `@metaharness/kernel`, `@metaharness/host-*`, `@metaharness/router`), the `ruview-*` Claude Code subagents (`ruview-onboarding-guide`, `ruview-config-engineer`, `ruview-training-engineer`), the `wifi-densepose` CLI (`calibrate`/`enroll`/`train-room`/`room-watch`), the sensing-server, ADR-028 (witness verification), ADR-095/096 (rvCSI runtime), ADR-260/262 (RuField bridge) |
| **Supersedes** | none |
## Context
RuView (WiFi-DensePose) is a deep stack — 15 Rust crates, an ESP32 firmware line,
a sensing-server, a CLI, ~180 ADRs, a calibration pipeline, training recipes, and a
hard cultural rule that **every claim must be independently reproducible** (the
"prove everything" ethos, after the project was accused of AI-slop). The barrier to
entry is correspondingly steep: a newcomer who wants to "set up WiFi sensing" must
discover the right firmware variant, provision an ESP32 over a Windows-only Python
subprocess, point it at the sensing-server, run `calibrate``enroll`
`train-room`, and know which numbers are MEASURED vs CLAIMED. We already encode this
knowledge as **Claude Code subagents** (`ruview-onboarding-guide`,
`ruview-config-engineer`, `ruview-training-engineer`) — but those only exist inside
*this* repo's `.claude/agents/`, only on Claude Code, and only for someone who has
already cloned the monorepo.
Separately, this session shipped **MetaHarness** (`metaharness@0.1.15`): a tool that
*"mints a custom AI agent harness from any repo"*, runnable on **9 hosts**
(claude-code, codex, pi-dev, hermes, openclaw, rvm, copilot, opencode,
github-actions) over a wasm-primary / NAPI-RS-fallback **kernel**, with a
**cost-optimal model router** (`@metaharness/router`, the productized DRACO Phase-2
k-NN finding) and ed25519/SLSA/SBOM provenance baked in. Crucially, MetaHarness
**already ships a `vertical:ruview` template** in its template list. That template
is generic scaffolding; it is not wired to RuView's actual tools, agents, or the
"prove everything" guardrails.
The gap: **there is no single, host-portable, provenance-signed entry point that
gives any user an AI agent that actually knows how to operate RuView.** A user
should be able to run one command —
```bash
npx ruview
```
— in an empty directory (or alongside an ESP32) and get an agent harness that can
onboard them, configure firmware, drive a live capture, train a room model, and
**refuse to overstate accuracy** — on whichever coding host they already use.
## Decision
**Mint a first-class RuView agent harness from this repo using MetaHarness, harden
its `vertical:ruview` template into a RuView-specific harness with a real MCP tool
surface and the project's honesty guardrails, and publish it as `npx ruview`.**
`npx ruview` is *not* a new runtime. It is a **thin, versioned distribution** of a
MetaHarness harness: the kernel + host adapters + a RuView "genome" (skills, agents,
MCP tools, guardrails) generated from and pinned against this monorepo. The harness
is the product; `npx ruview` is the front door.
### Why mint-from-repo instead of hand-writing a harness
MetaHarness's value here is exactly the work we would otherwise hand-roll across 9
hosts: host-specific config (`.claude/settings.json` MCP + hooks for claude-code,
the codex/copilot/opencode equivalents), the kernel that abstracts wasm-vs-native,
the cost router, and the provenance chain. We write the **RuView knowledge once** as
host-neutral genome assets; MetaHarness projects them onto each host adapter. This
also keeps the harness regenerable: when the CLI or an ADR changes, re-mint and
re-pin rather than maintaining 9 divergent copies.
### What the harness contains (the RuView genome)
1. **Skills / playbooks** (host-neutral markdown, projected to each host's skill
format):
- `onboard` — zero-to-sensing path picker (Docker demo / repo build / live
ESP32), the physics caveats, the hardware table. Port of
`ruview-onboarding-guide`.
- `provision-node` — ESP-IDF v5.4 Windows-subprocess build/flash/provision flow
(the exact MSYSTEM-stripped invocation from `CLAUDE.local.md`), firmware
variant selection (8MB display / 4MB no-display / C6), NVS + WiFi + channel /
MAC-filter overrides (ADR-060).
- `calibrate-room``baseline → enroll → extract → train` via the
`wifi-densepose` CLI (`calibrate`/`calibrate-serve`/`enroll`/`train-room`/
`room-watch`, ADR-151).
- `train-pose` — camera-supervised + camera-free training, the MEASURED-vs-CLAIMED
discipline, the mean-pose baseline check (ADR-079, ADR-152, ADR-181).
- `verify` — run the witness bundle + Python proof (`verify.py` → VERDICT: PASS),
ADR-028.
- Ports of `ruview-config-engineer` and `ruview-training-engineer`.
2. **MCP tool surface** (`@metaharness/kernel`-hosted MCP server, one schema per
capability — see "MCP tools" below). This is what makes the harness *operate*
RuView, not just talk about it.
3. **Guardrails** (the differentiator): the harness's system prompt and a
pre-output hook enforce the "prove everything" rule — accuracy numbers must be
tagged MEASURED (with a reproducer) or CLAIMED; the agent must run the mean-pose
baseline before quoting PCK; firmware fixes are never presented as
hardware-validated without a real boot log (the exact discipline this session
followed for `v0.8.1-esp32`).
4. **Host adapters** — claude-code first (P1), then codex / opencode / copilot /
pi-dev / hermes / rvm / github-actions (P3+), each via the published
`@metaharness/host-*` package.
5. **Router**`@metaharness/router` routes each step to the cheapest adequate
model (e.g. a var-rename or a log-grep → Haiku; calibration-math reasoning or a
security review → Sonnet/Opus), mirroring the repo's 3-tier routing (ADR-026).
### MCP tools (the operational surface)
| Tool | Wraps | Purpose |
|------|-------|---------|
| `ruview.onboard` | docs + agent | Pick a setup path, print the next concrete command |
| `ruview.node.flash` | ESP-IDF subprocess (ADR `CLAUDE.local.md`) | Build + flash a firmware variant to a COM port |
| `ruview.node.provision` | `provision.py` | Set SSID/password/target-ip/channel/MAC-filter over serial |
| `ruview.node.monitor` | pyserial | Stream boot log; assert CSI is flowing (MGMT+DATA) |
| `ruview.server.up` | sensing-server | Start the Axum sensing-server (`:3000`/`:5005`/`:8765`) |
| `ruview.calibrate` | `wifi-densepose calibrate`/`enroll`/`train-room` | Run the ADR-151 room pipeline |
| `ruview.room.watch` | `wifi-densepose room-watch` | Live presence/vitals from a trained room |
| `ruview.verify` | `scripts/generate-witness-bundle.sh` + `verify.py` | Produce/verify the witness bundle (must be N/N PASS) |
| `ruview.claim.check` | static lint | Scan output for untagged accuracy claims; flag MEASURED-vs-CLAIMED |
Each tool returns structured JSON and is fail-closed: a tool that cannot prove its
result (e.g. `ruview.node.monitor` sees no CSI callbacks) returns an honest negative,
never a fabricated success — consistent with the RuField `map_privacy` fail-closed
posture (ADR-262 §3.3).
### The mint + pin flow (how the harness is produced)
```bash
# P1 — mint from this repo, claude-code host, RuView vertical
npx metaharness ruview --template vertical:ruview --host claude-code \
--from-existing . --description "RuView WiFi-sensing operator agent" \
--target ./harness/ruview
# readiness + fit/cost/safety scorecards (ADR-041) — gate before publish
npx metaharness genome . # 7-section repo readiness
npx metaharness score . --json # fit / cost / safety
npx metaharness analyze . # recommended harness plan (no-exec)
```
The minted harness is committed under `harness/ruview/` and **pinned** (kernel +
host-adapter + router versions locked) so `npx ruview` is reproducible. Re-minting on
a CLI/ADR change is a reviewed PR, not an implicit regeneration.
### Distribution: `npx ruview`
A small published package whose `bin` boots the pinned harness via the kernel:
- **Preferred name:** `ruview` (currently **free** on npm — verified 2026-06-17).
- **Risk:** npm's typosquat filter may reject `ruview` as too close to `review` /
`preview` (this session hit exactly that on `ruvn``levn`/`raven` and
`worldgraph``world-graph`). **Fallback:** publish scoped `@ruvnet/ruview` (also
free) and/or `npx ruvnet/ruview` straight from GitHub. Decide at publish time;
do not unpublish to rename (the 24-h name-lock lesson from `worldgraphs`).
- `bin: { "ruview": "bin/cli.js" }` — note **`bin/cli.js`, not `./bin/cli.js`** (npm
strips the `./` form; this broke `ruvn@0.1.0` this session).
- `npx ruview` with no args → `onboard` skill (interactive path picker).
`npx ruview <skill> [...]` → run a specific skill. `npx ruview --host codex`
install the harness into an existing repo for that host.
## Architecture
```
npx ruview (thin bin — boots the pinned harness)
@metaharness/kernel (wasm primary · NAPI-RS native fallback)
├── host adapter ── claude-code | codex | opencode | copilot | pi-dev | hermes | rvm | github-actions
├── @metaharness/router (k-NN cost-optimal model routing — DRACO P2 / ADR-026)
└── RuView genome (pinned)
├── skills onboard · provision-node · calibrate-room · train-pose · verify
├── mcp tools ruview.node.* · ruview.calibrate · ruview.room.watch · ruview.verify · ruview.claim.check
└── guardrails MEASURED-vs-CLAIMED · mean-pose baseline · no-unvalidated-firmware-claims
RuView assets (the real system the agent drives)
├── wifi-densepose CLI calibrate / enroll / train-room / room-watch
├── sensing-server :3000 / :5005 / :8765
├── ESP-IDF subprocess build / flash / provision / monitor (COM8/COM9/COM12)
└── witness bundle + verify.py
```
Provenance: the harness ships an **ed25519 witness + SBOM (SPDX) + SLSA** chain
(MetaHarness already does this for minted harnesses), so a recipient can verify the
RuView harness was built from a specific monorepo commit — the agentic analogue of
the firmware witness bundle (ADR-028).
## Phases
- **P1 — Mint & pin (claude-code).** `npx metaharness ruview --template
vertical:ruview --from-existing . --host claude-code`. Port the three `ruview-*`
subagents into host-neutral genome skills. Commit under `harness/ruview/`, pin
versions. Acceptance: `npx metaharness score .` ≥ threshold; the harness can run
`onboard` and `verify` end-to-end locally.
- **P2 — MCP tool surface.** Implement the `ruview.*` MCP tools over the kernel
(start with `onboard`, `verify`, `claim.check`, `node.monitor` — the read-only /
proving tools), then the mutating ones (`node.flash`, `provision`, `calibrate`).
Acceptance: `ruview.verify` returns the witness bundle PASS as structured JSON;
`ruview.claim.check` flags a seeded untagged "100% accuracy" string.
- **P3 — Publish `npx ruview` + multi-host.** Publish the bin package (name decision
per Distribution). Add codex / opencode / copilot / pi-dev / hermes / rvm /
github-actions adapters. Acceptance: `npx ruview` cold-starts on ≥3 hosts and runs
`onboard`; provenance verifies.
- **P4 — Router + guardrail hardening.** Wire `@metaharness/router`; calibrate the
3-tier routing on a RuView task set. Make the MEASURED-vs-CLAIMED guardrail a hard
pre-output gate. Acceptance: a benchmark of RuView tasks shows cost reduction vs
all-Opus with no quality regression; the guardrail blocks an untagged accuracy
claim in a red-team prompt.
## Consequences
**Positive**
- One reproducible, signed entry point (`npx ruview`) that operates RuView on the
host the user already has — onboarding goes from "clone a 15-crate monorepo" to a
single `npx`.
- The "prove everything" ethos becomes **executable**, not just documentation: the
harness *enforces* MEASURED-vs-CLAIMED and the mean-pose baseline.
- Knowledge written once (host-neutral genome) instead of 9× per host; regenerable
from the repo as the system evolves.
- Dogfoods MetaHarness on a hard real vertical, surfacing bugs back to
`agent-harness-generator` (this session already filed #9#13 there).
**Negative / risks**
- **Drift:** a pinned harness goes stale as the CLI/ADRs move; mitigated by a
re-mint-on-change PR ritual and a CI check that the genome's referenced
CLI flags still exist.
- **Surface area:** mutating MCP tools (`node.flash`, `provision`) touch hardware and
the network — must be permission-gated and fail-closed; the firmware-flash tool
must never claim hardware validation without a captured boot log.
- **Name/typosquat:** `ruview` may be rejected at publish; scoped fallback decided in
P3. Do not unpublish-to-rename.
- **Host parity:** not all 9 hosts support MCP + hooks equally; the guardrail gate
may degrade to advisory on weaker hosts — must be disclosed in the badge, not
hidden (same honesty principle as ADR-181's backend badge).
- **Windows-coupled tooling:** the ESP-IDF flow is Windows-subprocess-specific
today; the `node.*` tools are gated to that environment until a cross-platform
path exists.
## Alternatives considered
1. **Keep the `ruview-*` subagents repo-local (status quo).** Zero new surface, but
stays Claude-Code-only and clone-gated; no portable front door. Rejected — it's
the gap this ADR exists to close.
2. **Hand-write a bespoke `npx ruview` harness (no MetaHarness).** Full control, but
re-implements the kernel, 9 host adapters, the router, and the provenance chain
we already ship — months of duplicated work and 9 divergent configs to maintain.
Rejected.
3. **Use the generic `vertical:ruview` template as-is.** It's scaffolding with no
real tools or guardrails — it would *talk about* RuView without being able to
*operate* it or enforce honesty. Rejected as insufficient; P2 is precisely the
hardening that makes it real.
4. **Ship only an MCP server (no harness/host adapters).** Covers tools but not the
skills, routing, guardrails, or multi-host projection — a strictly smaller subset
of this design. Folded in as the P2 layer rather than the whole.
## Open questions
- Final published name: bare `ruview` vs scoped `@ruvnet/ruview` vs GitHub-only
`npx ruvnet/ruview` — resolve against the typosquat filter at P3.
- Does the harness bundle the `wifi-densepose` binary, shell out to a user-installed
one, or offer both? (Leaning: shell out; print install guidance if absent.)
- Where do the `node.*` hardware tools live for non-Windows users — defer, or wrap
the rvCSI runtime (ADR-095/096) which is cross-platform Rust?
- Should `ruview.verify` gate `npx ruview` self-tests in CI (harness can't publish if
the witness bundle regresses)?
- Relationship to the RuField MFS harness surface (ADR-260/262) — one harness with a
RuField skill, or a sibling `npx rufield`?
## References
- MetaHarness: `metaharness@0.1.15` (`npx metaharness`, templates incl.
`vertical:ruview`; hosts: claude-code/codex/pi-dev/hermes/openclaw/rvm/copilot/
opencode/github-actions), `@metaharness/kernel`, `@metaharness/router`,
`@metaharness/host-*`, repo `github.com/ruvnet/agent-harness-generator`.
- RuView subagents: `ruview-onboarding-guide`, `ruview-config-engineer`,
`ruview-training-engineer` (`.claude/agents/`).
- ADR-026 (3-tier model routing), ADR-028 (witness verification), ADR-041
(MetaHarness scorecards), ADR-060 (channel / MAC-filter overrides), ADR-079
(camera ground-truth training), ADR-095/096 (rvCSI runtime), ADR-151 (per-room
calibration), ADR-152/181 (WiFlow / browser pose), ADR-260/262 (RuField bridge).
@@ -0,0 +1,98 @@
# ADR-183: Onboard LED as a 40 Hz Gamma Stimulus, Colour-Mapped from Live CSI via `ruv-neural-viz`
| Field | Value |
|-------|-------|
| **Status** | Accepted — implemented & hardware-confirmed on ESP32-S3 N16R8 (COM8) |
| **Date** | 2026-06-17 |
| **Deciders** | ruv |
| **Codename** | **GAMMA-VIZ** |
| **Builds on** | `ruv-neural-viz::ColorMap` (now `no_std` — ruvnet/ruv-neural#3 / RuView#1126), the ESP32 edge `motion_energy` metric (`edge_processing.c`), PR #962 (WS2812 on GPIO 48) |
## Context
Two threads converged. (1) `ruv-neural-viz::ColorMap` — the viridis/cool-warm
palette the rUv-Neural stack uses to render brain-topology graphs — was `std`-only,
so it couldn't run on the ESP32. (2) The onboard WS2812 on the S3 CSI node was dead
weight: the firmware only cleared it on boot (and on the wrong pin for N16R8 — GPIO
38 vs the actual 48, see #962).
The ask: make the LED do something real and honest, using the project's own visual
capability — not a decorative blink. The natural fit is a **40 Hz gamma stimulus**
(the GENUS gamma-entrainment frequency from Alzheimer's light-therapy research)
whose **colour is driven by live sensed motion**, so the node's front panel is both
a known bio-stimulus waveform and a truthful readout of what the CSI is detecting.
## Decision
### Part A — make `ColorMap` `no_std`
`colormap.rs` is self-contained (no cross-crate deps), so expose it on `no_std`
targets. The only blockers were two `std`-only `f64` ops:
- `f64::round` / `f64::abs` → replaced with `core`+`alloc`-safe helpers `fround`
(round via `f64 as i64` truncation — a `core` cast, no `libm`) and `fabs`.
- `Vec`/`String`/`format!` → from `alloc`.
The graph-bound modules (`animation`/`ascii`/`export`/`layout`) and their heavy deps
move behind a default `std` feature; `--no-default-features` builds the crate `no_std`
and exposes only `colormap`. Output is **byte-identical** (8/8 colormap tests pass with
the same RGB values), so this is a pure portability change.
### Part B — the LED stimulus (firmware)
`firmware/esp32-csi-node/main/main.c`, on boot:
- WS2812 on **GPIO 48** (N16R8 / DevKitC-1 v1.1; GPIO 8 on C6).
- An `esp_timer` periodic at **12 500 µs toggles a square wave → 40 Hz, 50 % duty**
(full-on / full-off — a *perceptible* gamma flicker, not a colour drift).
- **ON-phase colour = live CSI motion.** Each ON phase reads `edge_get_vitals().motion_energy`,
normalises it (`/ LED_MOTION_FULLSCALE`, clamped `[0,1]`), and indexes a **60-step
viridis LUT generated from `ColorMap::viridis().map()`** — still = dark purple,
strong motion = yellow.
The LUT is baked from the real crate (Part A makes the same `ColorMap` embeddable
for a future direct FFI path once the ESP Rust toolchain is in CI). The colours are
therefore provably `ruv-neural-viz`'s, and the motion is provably real.
## Honesty (what it is and is not)
- **40 Hz is a real square-wave stimulus** (12.5 ms on / 12.5 ms off), not a label on
a colour sweep. It is *not* tied to any measured 40 Hz brain rhythm — it is an
*output* stimulus at the gamma frequency, not a readout of neural gamma.
- **Colour is a real CSI readout**`motion_energy` is the on-device phase-variance
motion metric the node already computes; no fabrication. At rest the LED sits at the
purple (low) end and flickers there.
- No therapeutic claim is made. 40 Hz GENUS entrainment is cited as the *origin of the
frequency choice*, not as a validated medical effect of this device.
## Consequences
**Positive**
- The LED is now an honest front-panel: gamma-frequency flicker + a live motion readout.
- `ColorMap` is embeddable (`no_std`), unblocking on-device use of the rUv-Neural
palette beyond this LED.
- Confirms #962's GPIO-48 fix visually (the LED lights on N16R8).
**Negative / risks**
- Changes the *default* firmware behaviour: the onboard LED animates instead of staying
off. Now **gated by `CONFIG_LED_GAMMA_VIZ`** (default `y`); set it `n` for a dark,
lower-power boot (the LED is just cleared) — no source change needed.
- A 40 Hz flicker can be an issue for photosensitive users; document on the enclosure
and disable `CONFIG_LED_GAMMA_VIZ` in those deployments.
- The saturation point is now `CONFIG_LED_MOTION_FULLSCALE_MILLI` (default 250 = 0.25),
operator-tunable; still not auto-calibrated per-environment.
- The colour uses a baked LUT, not the live Rust `ColorMap` (FFI path deferred — needs
the ESP Rust/xtensa toolchain, not yet in CI).
## Validation
- `ruv-neural-viz`: `cargo build` (std) ✓, `cargo test colormap` 8/8 ✓ (identical RGB),
`cargo build --no-default-features` compiles `no_std` ✓.
- Firmware: built (1.13 MB), flashed to ESP32-S3 N16R8 (COM8). Boot log:
`Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO 48`;
CSI continues (2738 pps), `motion=0.00` at rest → purple flicker as designed.
- Full on-device (xtensa) Rust build of `ColorMap` not run — ESP Rust toolchain absent.
## References
- ruvnet/ruv-neural#3 (ColorMap no_std), RuView#1126 (submodule bump), #962 (GPIO 48).
- Singer/Tsai GENUS 40 Hz gamma entrainment (origin of the frequency, not a device claim).
@@ -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)
@@ -0,0 +1,487 @@
# ADR-271: RuView as a Cognitum OAuth resource server
- **Status**: accepted
- **Date**: 2026-07-22
- **Deciders**: RuView maintainers
- **Tags**: auth, oauth, cognitum, security, sensing-server
- **Related**: ADR-055 (integrated sensing server), ADR-102 (edge module registry), ADR-066 (ESP32 seed pairing), cognitum-one/dashboard ADR-060 (OAuth scopes beyond `inference`), cognitum-one/meta-llm ADR-045 (Bearer at completions)
## Context
`/api/v1/*` on `wifi-densepose-sensing-server` is gated by `RUVIEW_API_TOKEN`
(`bearer_auth.rs`): a single shared secret, compared in constant time, with no
expiry, no rotation and no per-user attribution. `homecore-api` has a second,
unrelated scheme (`LongLivedTokenStore` over `HOMECORE_TOKENS`) whose own doc
comment describes it as "no expiry, no rotation, no per-user attribution yet".
That is proportionate for the ADR-055 topology — server bundled in the desktop
app, spawned as a child, localhost only. It is not proportionate for the other
deployment RuView actually has: a sensing server on a Pi or hub, reachable on a
LAN, potentially serving more than one person, exposing live presence, pose,
breathing and heart-rate data plus destructive operations (model training,
model delete, recording delete).
Cognitum operates a live OAuth 2.1 authorization server at `auth.cognitum.one`.
Users of RuView are already Cognitum account holders. The obvious question is
whether RuView can accept that identity instead of a shared string.
### The direction of the integration is the thing most likely to be misread
Every existing Cognitum OAuth integration in the org — meta-proxy, musica,
metaharness, the dashboard CLI — is an OAuth **client**: it obtains a token so
the application can *call* a Cognitum service (the completions plane).
RuView is the opposite. It makes **no authenticated calls to any Cognitum API**.
Its only outbound Cognitum dependency is the ADR-102 registry fetch, which is an
anonymous GET against a public GCS bucket. What RuView wants is to be a
**resource server**: a user signs in to their *own* RuView instance with their
Cognitum identity, and RuView verifies the token they present.
So the client-side prior art in the org, while useful for a future `ruview
login` command, addresses a plane RuView does not have. The only relevant
precedent is `meta-llm/src/auth/oauthBearer.ts` (ADR-045) — the org's sole
resource-server-side verifier of these tokens. It is TypeScript; **RuView is the
first Rust one.**
### Facts about the tokens, verified against a live production token
- **ES256 JWT**, signed by a single P-256 key published at
`https://auth.cognitum.one/.well-known/jwks.json`.
- **15-minute lifetime**, with an opaque refresh token that **rotates with reuse
detection** (presenting a spent one ends the session).
- Claims: `typ`, `sub`, `account_id`, `org_id`, `workspace_id`, `client_id`,
`scope`, `family_id`, `jti`, `iat`, `exp`, `setup`, `workload`.
- **No `aud` claim.** No `/oauth/introspect`. No `/userinfo`. It is an OAuth 2.1
authorization server, not an OpenID Provider, deliberately.
## Decision
Verify Cognitum access tokens **offline**, in a new `ruview-auth` crate, and
gate RuView's own API surface on the **scope** they carry.
### 1. Offline verification is a requirement, not an optimisation
RuView runs on Pi-class hardware that loses WAN, and there is no introspection
endpoint to call even when the network is up. Verification is therefore an
ES256 signature check against a `kid`-indexed JWKS cache. Two consequences we
accept explicitly:
- **Revocation window = token lifetime.** A compromised access token stays
usable until `exp`. This is the same position meta-llm takes, for the same
reason, and it is why §3 refuses long-lived credentials.
- **A JWKS refetch failure is survivable while a key set is cached.** A key that
verified a minute ago has not stopped being valid because the network blipped;
failing closed there would log every user out of their own sensing server
whenever their internet wobbled. We fail closed in exactly one case: no key
set has *ever* been fetched.
### 2. The accept-rule is ported from meta-llm, not designed
```
typ == "access" AND NOT setup AND NOT workload
AND account_id is a non-empty string
AND exp is in the future
AND the scope required by the route is held
```
**Note there is no `iss` check.** An earlier revision of this section listed
"`iss` matches the configured issuer verbatim" — that rule was implemented,
shipped, and rejected EVERY real token, because Cognitum access tokens carry no
`iss` claim (see §"Facts about the tokens" above, which contradicted this
paragraph for a day). Removed in the code; removed here. The JWKS is the issuer
binding.
Divergence from `oauthBearer.ts` would be a bug rather than a preference: a
token meta-llm rejects must not be one RuView accepts. The algorithm is **fixed
to ES256 by our code** — the header's `alg` is only ever compared against that
allowlist, never used to select an algorithm.
### 3. Long-lived setup and workload credentials are refused outright
Identity also issues 365-day *setup* and machine *workload* credentials. Their
revocation state lives in identity's `oauth_setup_tokens` table. RuView — like
meta-llm — has no database and no way to check it, so accepting one would mean
honouring a credential that may already have been revoked. A 15-minute token
needs no revocation round-trip because it expires faster than revocation
propagates; a 365-day one does.
### 4. Scope is the capability boundary, because nothing else can be
Tokens carry no `aud`, so RuView cannot verify a token was minted *for* RuView.
`client_id` cannot substitute: clients borrow each other's registrations when
their own has not been deployed (musica ships `DEFAULT_CLIENT_ID = "meta-proxy"`).
This is not a defect to route around. Cross-product **identity** is intended —
one Cognitum account, every Cognitum product. Cross-product **capability** is
not, and scope is what carries the difference.
RuView registers two scopes (dashboard ADR-060, identity migration `0016`):
| Scope | Grants |
|---|---|
| `sensing:read` | sensing/pose streams, one-shot inference, reading model and recording metadata |
| `sensing:admin` | every mutating route not explicitly allowlisted as read-safe — training (`/api/v1/train/*` AND `/api/v1/adaptive/train`), model and recording deletion, config writes |
**The gate is fail-closed for writes, and that polarity is load-bearing.** An
earlier revision enumerated admin routes by prefix and let everything else fall
through to `sensing:read`. `POST /api/v1/adaptive/train` — which trains a
classifier, overwrites the on-disk model and swaps the live one — does not match
`/api/v1/train/`, so it was reachable with `sensing:read`, the scope
`wifi-densepose login` requests by default. Found by adversarial review. Now:
reads are open, writes require admin unless the exact path is on a short
allowlist of non-destructive mutations. A route added tomorrow is admin-gated
until someone classifies it.
**No hierarchy**: `sensing:admin` does not imply `sensing:read`. Consent means
exactly what it said, and a token needing both must have consented to both.
`client_id` is retained on the principal for logging and attribution only —
never as an authorization input.
### 5. Additive and fail-closed, never a silent downgrade
`RUVIEW_API_TOKEN` and `HOMECORE_TOKENS` deployments keep working unchanged.
OAuth is opt-in; with it unconfigured, behaviour is byte-identical to today.
When OAuth *is* configured but unusable (JWKS unreachable at boot, required
scope not registered), the server must refuse to serve `/api/v1/*` rather than
fall through to an open or single-secret state.
### 6. `ureq`, and a transport seam
`wifi-densepose-sensing-server` deliberately chose `ureq` as "the smallest" HTTP
client. Introducing `reqwest` for a JWKS fetch would silently reverse that for
the whole dependency graph. The fetch sits behind a `JwksFetcher` trait — the
`ureq` implementation is a default-on feature, and a host may supply its own and
take no HTTP dependency at all.
## Consequences
- Requests become attributable: `sub`, `account_id`, `org_id`, `workspace_id`,
`jti`. This closes the gap `homecore-api`'s `tokens.rs` has been deferring as
"P3", using claims rather than new RuView machinery.
- Destructive operations can be separated from observation for the first time.
- **The 15-minute lifetime is the main operational cost.** A long-running client
must refresh, and because refresh tokens rotate with reuse detection, a
concurrent or naively retried refresh **ends the session** — single-flight is a
correctness requirement, not an optimisation. This lands with the login flow,
not this crate.
- Hosts without a battery-backed clock will fail `exp`/`iat` until NTP lands.
The verifier reports that distinguishably so it is diagnosable rather than
presenting as a generic 401.
- A new dependency, `jsonwebtoken` — the same crate, same major version, that
identity itself uses to sign these tokens.
## ~~Known incomplete: the browser cannot obtain an OAuth token~~ — CLOSED 2026-07-23
> **Superseded within this same PR.** The text below described the state when
> this ADR was first written. It is retained because the reasoning still
> explains *why* the browser half was built, but every factual claim in it is
> now false — in particular `grep -ril "oauth|cognitum|pkce" ui/` now returns
> `ui/sw.js`, `ui/sw.test.mjs` and `ui/utils/quick-settings.js`. An adversarial
> review caught the ADR still asserting the old state; see "Browser sign-in"
> below for what actually ships.
<details>
<summary>Original text (no longer accurate)</summary>
`wifi-densepose login` writes to `~/.ruview/credentials.json` — a file a browser
cannot read. The UI's `ws-ticket.js` reads a bearer from
`localStorage['ruview-api-token']`, which is populated **only** by the
QuickSettings manual-paste panel. There is no "Sign in with Cognitum" control,
no redirect flow, and `grep -ril "oauth|cognitum|pkce" ui/` returns nothing.
So a user who signs in via the CLI gets **no benefit in the browser UI**, and
the WebSocket ticket mechanism this ADR's sibling (ADR-272) introduces "for
browsers" is today only exercisable with the legacy static shared secret that
OAuth was meant to replace. The server-side gating is correct and complete; the
browser half of the story these ADRs tell is not built.
</details>
## Browser sign-in
`/oauth/start`, `/oauth/callback`, `/oauth/logout` and `/oauth/status`, plus a
"Cognitum Account" panel in QuickSettings. The server runs the authorization
code + PKCE flow itself and hands the browser a **signed session cookie**
never the access token. The browser gets an assertion that this server already
verified a token, which is nothing replayable anywhere else.
Three things about it are load-bearing and were each found the hard way:
- **The cookie carries the granted scope**, and the gate re-checks it per
request. A `sensing:read` session cannot delete a model.
- **`__Host-` is deliberately NOT used.** That prefix requires `Secure`, and
RuView is routinely reached over plain HTTP on a LAN; a cookie the browser
refuses to set is worse than one without the prefix. The cost is real and is
recorded as P3 under "Open problems" below.
- **The service worker must never cache `/oauth/*` or authenticated `/api/*`.**
The Cache API is not the HTTP cache and ignores `Cache-Control` entirely, so
a cached `/oauth/status` froze sign-in until a hard reload, and cached API
responses could be replayed to a different user after sign-out. `ui/sw.js` is
now deny-by-default with an allowlist.
### Still incomplete
`redirect_uri` defaults to `http://127.0.0.1:8080/oauth/callback` and is
overridden only by `RUVIEW_PUBLIC_BASE_URL`. Browser sign-in therefore works
only on a host reached at exactly that origin: an operator browsing
`http://localhost:8080` or `http://192.168.1.50:8080` cannot complete the flow
(PKCE keeps the code unexchangeable, so this is a broken flow, not a token
leak). Deriving it from the request is the fix; deferred deliberately, since
deriving a redirect URI from attacker-controllable headers is its own class of
bug and deserves its own decision.
The credential `wifi-densepose login` stores is also **not yet consumed by any
shipped client** — no CLI subcommand, MCP server or Python client reads
`~/.ruview/credentials.json`. The token is obtainable and verifiable; wiring the
clients to send it is separate work.
## Open problems — RESOLVED 2026-07-23
Three findings from the 2026-07-23 adversarial review. All three are now
**fixed**; the analysis is retained because it explains why each fix has the
shape it does, and each is guarded by a test that was confirmed to fail against
the old behaviour.
### P1 — the JWKS fetch blocks a tokio worker, and the stale path is unbounded — **FIXED**
`verify.rs:182` calls `JwksCache::decoding_key_for`, which performs a blocking
`ureq` request (`jwks.rs:181`, 3s connect + 3s read) directly on the async
worker running `require_bearer`. The same codebase already knows this is wrong:
`main.rs:9265` wraps the token exchange in `spawn_blocking`, commenting "the
same mistake this codebase had to fix in `jwks.rs`". The hot verification path
did not get the same treatment.
Worse, the rate limiter does not cover the case that matters.
`state.fetched_at` is updated **only on success** (`jwks.rs:188`); the error arm
leaves it untouched. So once the TTL elapses after the last *successful* fetch,
`fresh` is permanently `false`, the `may_force` guard at `:170` is never
consulted, and **every** request performs its own blocking fetch attempt.
This fires with no attacker present. On a Pi that loses WAN — the documented
deployment reality — 300 seconds later every API call and every UI poll starts a
blocking outbound attempt, and with few tokio workers the whole server stalls,
including `/health`. An attacker can reach the same state deliberately by
flooding tokens carrying an unknown `kid`.
**Proposed fix, in dependency order:**
1. **Rate-limit attempts, not successes.** Add `last_attempt_at`, recorded
before the fetch regardless of outcome, and consult it on the stale path too.
This alone converts "every request fetches" into "one request per interval".
2. **Get the blocking call off the runtime.** Either wrap the call in
`spawn_blocking` at the `verify` boundary, or give `JwksCache` an async
transport behind the existing transport seam. The seam already exists —
`JwksCache::new` takes a boxed transport — so this is an added
implementation, not a redesign.
3. **Single-flight the refresh.** Concurrent misses for the same `kid` should
await one shared fetch rather than each issuing their own.
4. **Refresh ahead of expiry** from a background task, so the request path
normally never fetches at all.
Steps 1 and 2 are the ones that remove the stall; 3 and 4 are optimisations.
The test that must accompany this: a transport whose fetch blocks on a barrier,
asserting that a second concurrent verification is not serialised behind it —
the current suite is entirely single-threaded and could not observe a
reintroduction (`jwks::tests` contains no concurrency primitive at all).
### P2 — a 15-minute access token becomes a 12-hour session — **FIXED**
`issue()` sets `exp: now() + SESSION_TTL_SECS` with `SESSION_TTL_SECS = 12 *
3600`, deliberately not inheriting the access token's ~15-minute lifetime. The
session cookie is an assertion that this server verified a token, so it is not
*wrong* for it to outlive the token — but 12 hours is a long time to hold an
authority that cannot be revoked. Cognitum publishes no introspection endpoint
(see "Facts about the tokens"), so RuView has no way to ask whether the grant
behind a session still stands. A disabled account keeps sensing access, and
`sensing:admin` if it had it, until the cookie expires on its own.
**Correction.** An earlier revision of this section said capping the session at
`sensing:read` was "considered and rejected, because the dashboard genuinely
performs admin operations". That was wrong, and a cross-vendor pre-merge sweep
caught it: `/oauth/start` (`main.rs:9206`) already requests `SENSING_READ` and
nothing else, deliberately — "admin work goes through the CLI, which requires an
explicit `--admin`". So a browser session is **already** read-only, and the
consequence I claimed capping would cause is simply the current behaviour.
Two things follow, and both are stated here rather than left for the next reader
to trip over:
1. **The UI's admin controls do not work from a browser OAuth session.**
`model.service.js:136` issues `DELETE /api/v1/models/{id}`; from a
Cognitum-signed-in browser that returns 401. Admin work requires either the
CLI (`wifi-densepose login --admin`) or a manually pasted admin bearer in the
QuickSettings token field. This is a gap in the browser feature, not a
regression — browser sign-in is new here, and the token-paste path still
carries whatever authority the pasted token has.
2. **The step-up control below is therefore a guard ahead of need, not an active
one.** No browser session currently holds `sensing:admin`, so
`session.has_scope(SENSING_ADMIN)` is false and the freshness branch never
fires in production. Its tests pass because the crate-internal test seam
mints an admin cookie the real flow does not produce. That is worth naming
plainly: it is correct code guarding a case that cannot yet arise, and it
becomes load-bearing the moment anyone widens the requested scope — which is
the right time for the guard to already exist, but it is not evidence that
the control is exercised today.
### Decision, 2026-07-23: the browser is read-only, permanently
**Browser-side admin is not wanted.** `BROWSER_SIGNIN_SCOPE` stays
`sensing:read`, and the escalate-on-demand design sketched while this was still
open is **not** being built.
The reasoning holds up on its own terms rather than being a concession to
scope: the destructive operations — training, model delete, recording delete —
already have a home in the CLI, where `--admin` is explicit, typed by a person,
and scoped to the session that needed it. Routing them through a browser would
mean either asking every user to consent to delete capability in order to watch
a stream, or building a second consent flow to avoid that. Neither is worth it
for operations that are administrative by nature and rare by frequency.
What this settles:
- **The UI's admin controls are unreachable from a Cognitum browser session**
and that is now intended, not a gap. `model.service.js` issuing
`DELETE /api/v1/models/{id}` returns 401. The manual token-paste field still
works and carries whatever authority the pasted token has, so nothing that
worked before this change stops working.
- **The client-side step-up redirect has been removed** from
`ui/services/api.service.js`. It caught a challenge that can never be issued,
and it ended in a promise that never settles — so had any other 401 ever grown
that header, every caller would have hung forever. Dead code with a trap in it
is worse than no code.
- **`ADMIN_REVERIFY_SECS` stays as a server-side backstop.** It is fail-closed
and costs nothing, so if the requested scope is ever widened the freshness
requirement is already there rather than something to remember. It is
documented at its definition as a backstop, so nobody mistakes its passing
tests for evidence that it is exercised.
**Three options, with the tradeoff each carries:**
| Option | Effect | Cost |
|---|---|---|
| **A. Shorten the TTL** (e.g. 12h → 4h) | Bounds exposure by a factor of 3, one constant | Re-auth is a full-page navigation, which interrupts a live streaming dashboard. Mostly silent while the Cognitum session is alive, but not free. |
| **B. Server-side session store** with the refresh token, revalidated periodically | Real revocation: a disabled grant fails at the next refresh | The server now stores refresh tokens — a new and higher-value secret at rest — and refresh rotates with reuse detection, so a bug logs users out. |
| **C. Re-verify on privileged operations only** | `sensing:admin` requires a fresh token; reads keep the long session | Best blast-radius-per-unit-cost, but needs a UI affordance for step-up auth that does not exist. |
**Chosen: A, at one hour** — `SESSION_TTL_SECS` is 3600, down from 12 hours.
C was implemented too, and then the browser-read-only decision above made it a
backstop rather than an active control: with no browser session holding
`sensing:admin`, there is no privileged operation to re-verify. It is kept
because it is fail-closed and free, not because it is doing work today.
B is not built. It is only worth its cost — storing refresh tokens at rest,
against an authorization server that rotates them with reuse detection — if
RuView later needs true cross-device sign-out. Shortening the window addresses
the same risk for a fraction of the exposure.
That leaves a residual this ADR should not pretend away: **within one hour, a
revoked Cognitum grant still reads sensing data through an existing browser
session.** Cognitum publishes no introspection endpoint, so nothing short of B
closes that, and one hour is the size of the hole we accepted.
### P3 — dropping `__Host-` costs cookie origin-integrity, not just `Secure` — **FIXED**
The decision above frames omitting `__Host-` as trading away a `Secure`
requirement that RuView cannot meet on a plain-HTTP LAN. That framing is
incomplete: `__Host-` also guarantees the cookie was set by *this* origin with
`Path=/` and no `Domain`. Without it, cookies are not port-scoped and are not
integrity-protected against a same-host writer.
`read_cookie` returns the **first** match in the header, and RFC 6265 §5.4 sends
longer-`Path` cookies first. So an attacker who can set a cookie on the same
host — any other service on any port on that appliance, or a plain-HTTP MITM
injecting `Set-Cookie` — can plant `ruview_session=<their own validly signed
session>; Path=/ui`. The victim's browser then sends both, the attacker's first,
and it verifies correctly because it *is* genuinely signed. The victim ends up
operating inside the attacker's session; `/oauth/status` reports the attacker's
account, and anything the victim records is attributed to them.
Note the shape: the signature is doing its job. Forgery was never the threat
`__Host-` addresses, so "the signature is what protects the value" does not
answer this.
**Proposed fix (cheap, no prefix needed):** have `read_cookie` collect *all*
values for the name and accept only if exactly one verifies — or, more strictly,
reject outright when more than one `ruview_session` is present, since a browser
should never legitimately send two. Add `Secure` and the `__Host-` prefix
conditionally when the server knows it is behind TLS, keeping the plain-HTTP LAN
case working.
## Alternatives considered
**Keep `RUVIEW_API_TOKEN` only.** Zero work, and adequate for a single-user
localhost install. Rejected because it cannot express who did what, cannot be
revoked without a restart, and cannot separate "watch the stream" from "delete
the model" — all of which matter the moment the server is on a LAN.
**Exchange the OAuth token for a `cog_` key.** The pattern ADR-316 (meta-proxy)
and ADR-119 (metaharness) originally described. Rejected: it cannot work.
`/v1/me/keys` requires a *Firebase* ID token, not an OAuth token — meta-proxy
hit the resulting 401 in production, replaced the approach with Bearer-direct
under ADR-045, and deleted `mint.rs` as dead code.
**Call identity to introspect each token.** Rejected: no introspection endpoint
exists, and a network round-trip per request would be wrong for an edge sensing
server regardless.
**Wait for an `aud` claim before shipping.** Rejected as sequencing. `aud` would
touch every issued token and every verifier in the org; scope is additive and
independently correct. Tracked separately; adding `aud` later strengthens this
design rather than invalidating it.
**Use OAuth for the ESP32 device plane too.** Rejected as a category error.
Devices have no browser, no user and no human present; they already pair with a
`seed_token` bearer (ADR-066) plus a device-bound PSK. Cognitum OAuth is for the
API plane only.
## Implementation
`v2/crates/ruview-auth``jwks` (fetch, TTL cache, `kid` index, one
rate-limited forced refetch on an unknown `kid` so rotation is picked up without
waiting out the TTL), `verify` (the §2 accept-rule), `principal` (the verified
caller and its scopes).
41 tests pass under both `cargo test --no-default-features` (the repo's
canonical gate) and default features. The matrix signs real ES256 tokens with a
runtime-generated key — no key material is committed — and covers `alg:none`,
forged signatures, spliced payloads, unknown `kid`, expiry on both sides of the
leeway, `typ` confusion, `setup`/`workload` smuggled onto a `typ=access` token, missing and
empty `account_id`, and scope escalation.
The load-bearing case is
`g2_a_genuinely_valid_token_from_another_cognitum_product_cannot_reach_the_sensing_surface`:
a correctly signed, unexpired, right-issuer, right-`typ` token bearing
`client_id=meta-proxy` and `scope=inference` is rejected. Nothing about its
signature or identity claims distinguishes it — only scope does. A naive
verifier accepts it, and an `inference` token becomes a key to someone's home
sensor.
**Not in this crate**: WebSocket authentication (ADR-272) and any outbound
Cognitum call.
### Amendment, 2026-07-22 — the login flow lives here after all, behind a feature
The paragraph above originally also excluded the login flow. That was written
to keep the sensing server lean, which is the right goal but not a reason to put
the code somewhere else: the Tauri desktop app needs the same flow, and a second
copy of a PKCE + rotating-refresh implementation is exactly the kind of
duplication that drifts apart and then disagrees about something subtle.
So `login` is a **non-default feature** of this crate. A server built with
default features gets the verifier and nothing more — no `reqwest`, no tokio
networking, no browser launcher. The CLI opts in with
`features = ["login"]`, and the desktop app can do the same.
Shipped as `wifi-densepose login` / `logout` / `whoami`. Two properties worth
restating because they are easy to get wrong:
* **Refresh is serialised and never retried.** Identity rotates refresh tokens
with reuse detection, so a concurrent refresh looks like replay and a retry
*is* replay — either revokes the session family. `Session::ensure_fresh`
holds an async mutex across the network call, re-checks expiry after
acquiring it, and persists the rotated token before returning it.
* **Least scope by default.** `login` requests `sensing:read`; `--admin` is an
explicit escalation and requests both scopes, since there is no hierarchy
server-side.
@@ -0,0 +1,185 @@
# ADR-272: WebSocket authentication tickets
- **Status**: accepted
- **Date**: 2026-07-22
- **Deciders**: RuView maintainers
- **Tags**: auth, websocket, security, sensing-server
- **Related**: ADR-271 (Cognitum OAuth resource server), ADR-055 (integrated sensing server), PR #1313 (the exemption this supersedes), cognitum-one/dashboard ADR-060
## Context
`bearer_auth` gates `/api/v1/*`. WebSocket upgrade endpoints were exempt, for a
real reason: a browser's `WebSocket` constructor cannot attach an
`Authorization` header to the handshake, so a gated socket is simply
unreachable from page JavaScript. `/ws/sensing` and `/ws/introspection` sat
outside `PROTECTED_PREFIX` entirely; `/api/v1/stream/pose` was added to an
explicit `EXEMPT_PATHS` list by PR #1313.
The reasoning was sound. The consequence was not, and it was measured rather
than argued. On a server with `RUVIEW_API_TOKEN` set — an operator who believes
authentication is ON — a real WebSocket handshake carrying **no credential at
all**:
```
/ws/sensing -> 101 Switching Protocols
/ws/introspection -> 101 Switching Protocols
/api/v1/stream/pose -> 101 Switching Protocols
/api/v1/models -> 401 Unauthorized (control)
```
**The control plane was locked and the data plane was open.** `/ws/sensing`
carries the live sensing output — presence, pose, breathing and heart rate.
`/ws/introspection` exposes internal pipeline state. For the ADR-055 desktop
topology (server bundled in the app, loopback only) that is bounded. For the
LAN/hub deployment RuView also supports, anyone who can reach the port can
watch the sensor.
ADR-271 sharpened the contrast rather than causing it: the REST surface is now
genuinely strong — offline-verified Cognitum tokens, scope-separated
destructive routes — which makes an ungated data plane the obvious way in.
*Precision about the evidence:* the handshake completing was verified. A
payload frame was not captured in that window, so the finding is "the
connection is established without a credential", not "data was read".
## Decision
Gate every WebSocket upgrade. Accept **either** of two credentials, chosen to
match what each kind of client can actually do.
### 1. Native clients send a bearer on the upgrade
The Python client, the Rust CLI and the TypeScript MCP client are not browsers
and have never been subject to the header limitation. They **can** send a normal
`Authorization: Bearer` on the handshake, so the server accepts one there;
routing them through a ticket would add a round-trip and a second credential
path for no benefit.
> **Correction, 2026-07-23.** This section previously stated that those clients
> **do** send a bearer. The published Python client does not:
> `python/wifi_densepose/client/ws.py` calls `websockets.connect(url,
> ping_interval, ping_timeout, max_size)` and passes no headers at all — the
> file contains zero occurrences of `extra_headers` or `Authorization`. So every
> `wifi-densepose[client]` consumer **401s the moment an operator enables
> auth**, and this ADR told them they would be fine.
>
> The server side of the decision stands — a bearer on the upgrade is accepted,
> and that is the right contract for a non-browser client. What is missing is
> the client implementing it, tracked as ruvnet/RuView#1395. Until then the only
> remedy available to those users is
> `RUVIEW_WS_LEGACY_UNAUTHENTICATED=1`, which reopens the exposure this ADR
> exists to close — so it is a migration aid with a deadline, not an answer.
### 2. Browsers exchange their credential for a single-use ticket
`POST /api/v1/ws-ticket` is an ordinary authenticated request — where headers
*do* work — and returns an opaque ticket the page appends as
`?ticket=<value>` on the socket URL.
**A credential in a URL is normally a mistake.** URLs reach access logs,
`Referer` headers and browser history. Three properties bound this one, and all
three are load-bearing:
| Property | Why it matters |
|---|---|
| **Single use** — consumed on the first upgrade attempt, valid or not | A ticket found in a log is already spent |
| **~30 second TTL** | Long enough to open a socket; not long enough to harvest |
| **Not the credential** — authorizes one WebSocket | Cannot be replayed against `/api/v1/*`, cannot be refreshed, carries no reusable identity |
The long-lived bearer token is still never placed in a URL.
A ticket **inherits the issuing principal's scopes**, so a `sensing:read`
session cannot mint one that outranks itself, and a ticket from a token without
`sensing:read` is refused at the upgrade.
### 3. WebSocket paths are matched by **prefix**, not by an allowlist
Anything under `/ws/` is treated as an upgrade path, plus the one endpoint that
lives outside it (`/api/v1/stream/pose`).
This is the most important detail in the ADR. An allowlist means every
WebSocket route added later is ungated until someone remembers to extend it —
the same bug, reintroduced on a delay. It is not hypothetical:
`/ws/train/progress` (ADR-186, arriving with PR #1387) is already referenced by
`ui/services/training.service.js` and would have shipped unauthenticated under
an allowlist. Prefix matching gates it on arrival.
New WebSocket routes should live under `/ws/` and inherit gating for free.
### 4. A migration escape hatch, deliberately uncomfortable
`RUVIEW_WS_LEGACY_UNAUTHENTICATED=1` restores the previous behaviour. Gating
these paths **breaks a browser UI that has not yet been updated to fetch a
ticket**, and not every deployment can update server and UI in lockstep.
It is a migration aid, not a supported configuration:
- It logs a warning on every boot naming the actual exposure — "the live
sensing stream — presence, pose and vital signs — is readable by anyone who
can reach this port" — rather than something an operator can skim past.
- Its blast radius is exactly the WebSocket paths. A test pins that it does not
weaken `/api/v1/*`.
- It is read **once at construction**, so changing the environment cannot
silently open the paths on a running server.
The alternative — a clean break with no hatch — was considered and rejected as
sequencing, not principle: a hard break tempts an operator into turning auth off
entirely, which is strictly worse than a narrow, loudly-announced exception.
The hatch should be removed once the shipped UI fetches tickets.
### 5. Deployments with auth off are unchanged
No credential configured ⇒ the middleware is the same no-op it has always been.
Pinned by a test.
## Consequences
- The measured hole is closed: all three paths now return `401` to a
credential-less handshake, while a bearer or a valid ticket returns `101`.
- Browser UIs need updating. Shipped in the same change for
`sensing.service.js`, `websocket-client.js` and `observatory/js/main.js` via
a shared `withWsTicket()` helper; a ticket is minted per connection attempt
and never cached, because it is single-use and short-lived.
- A UI running against a server that predates this ADR still works: the helper
treats `404` from `/api/v1/ws-ticket` as "no ticket needed".
- One more round-trip before a browser opens a socket. Negligible against a
stream that then runs for minutes.
- Tickets live in memory, capped at 512 outstanding and self-healing as they
expire, so an authenticated but misbehaving caller cannot grow the store
without bound. In-memory is correct rather than convenient: a ticket
surviving a restart would outlive the server that vouched for it.
## Supersedes
PR #1313's `enabled_exempts_pose_stream_websocket`, which asserted the
exemption. Its premise about browsers was correct and is preserved here; its
conclusion is replaced. The test was renamed and inverted rather than deleted,
with the history in its doc comment, and the half that still matters — the
WebSocket rule must not leak to other `/api/v1/*` paths — is kept.
## Deliberately not done
- **`/health*` stays ungated.** Orchestrator probes hit it anonymously, and
that is the point of a liveness endpoint. `/health/metrics` is included in
that exemption; if metrics ever carry occupancy-derived values this should be
revisited, because that would make them sensing data wearing an ops label.
- **`/ui/*` stays ungated.** It is static assets; the data behind them is
gated.
- **No revocation of an issued ticket.** It expires in seconds and is
single-use; a revocation path would be more machinery than the exposure
justifies.
- **No ticket for native clients.** They can send a header, so they should.
## Implementation
`v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs` (store),
`src/bearer_auth.rs` (gating), `src/main.rs` (`POST /api/v1/ws-ticket`),
`ui/services/ws-ticket.js` plus the three call sites.
Tests: 12 store, 9 gating, 4 path-matching. Store coverage includes single-use
enforcement, replay refusal, expiry refusal *and* pruning, 256-bit
unpredictability, cap enforcement and self-healing, and `?myticket=x` not being
read as `?ticket=x`. Gating coverage includes every known WS path refusing an
unauthenticated upgrade, bearer acceptance, ticket single-use, a ticket being
useless against REST, the escape hatch working *and* not weakening REST, and
auth-off behaviour unchanged.
+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
+16 -10
View File
@@ -4,9 +4,12 @@ Operations doc for the `.github/workflows/pip-release.yml` CI workflow.
## Auth
The workflow uses one GitHub Actions secret named `PYPI_API_TOKEN`.
It's a project-token issued by the rUv PyPI account with upload
scope for both `wifi-densepose` and `ruview`.
Production uses the GitHub Actions secret `PYPI_API_TOKEN`. It is a
project token issued by the rUv PyPI account with upload scope for both
`wifi-densepose` and `ruview`.
TestPyPI uses a separate `TESTPYPI_API_TOKEN` secret issued by
test.pypi.org. PyPI and TestPyPI accounts and tokens are independent.
## Refreshing the token
@@ -47,16 +50,19 @@ Per ADR-117 §7.3, the tombstone publishes first so it claims the
tombstone live at `https://pypi.org/project/wifi-densepose/1.99.0/`
2. Verify: `pip install wifi-densepose==1.99.0; python -c "import
wifi_densepose"` → ImportError with migration URL.
3. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → v2 wheel
matrix live at `https://pypi.org/project/wifi-densepose/2.0.0/`.
4. (Optional, in lock-step) build + publish a matching `ruview`
release from `python/ruview-meta/` so the meta-package version
stays pinned to the same wifi-densepose version.
3. Confirm `archive/v1/data/proof/expected_features_v2.sha256` is
committed and non-empty. Production publishing fails closed without it.
4. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → the v2
`wifi-densepose` wheel matrix and matching `ruview` wheel/sdist are
published together. Their versions and dependency pin are checked in CI.
5. Verify both `https://pypi.org/project/wifi-densepose/2.0.0/` and
`https://pypi.org/project/ruview/2.0.0/`.
## Off-loop manual gates
- **Q3** (ADR-117 §11.3) — generate `expected_features_v2.sha256`
from the v2 Rust pipeline before any v2 publish.
- **Q3** (ADR-117 §11.3) — generate
`archive/v1/data/proof/expected_features_v2.sha256` from the v2 Rust
pipeline before a production v2 publish. The workflow enforces this gate.
- **OIDC Trusted Publisher** — not used. The workflow is token-based;
this is a deliberate choice to keep the secret refresh entirely in
GCP. If the project migrates to OIDC later, remove `password:`
+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.
@@ -0,0 +1,147 @@
# SOTA Evidence Brief — `wifi-densepose-nn` / `wifi-densepose-train` Benchmark ADR Seed
| Field | Value |
|-------|-------|
| **Date** | 2026-06-14 |
| **Author** | deep-research (Opus) |
| **Purpose** | Seed a future benchmark/optimization ADR for the NN-inference (`wifi-densepose-nn`) and training (`wifi-densepose-train`) crates |
| **Scope** | The DELTA beyond what ADR-152 / ADR-150 / ADR-015 already establish — current published WiFi-CSI pose SOTA, winning architectures, edge-quantization SOTA, and a defensible benchmark-suite design |
| **Ethos** | Every claim graded PEER-REVIEWED / PREPRINT / VENDOR-CLAIM / BLOG, with MEASURED-on-public-benchmark distinguished from marketing. Numbers that could not be verified are flagged. No fabricated citations. |
> **Citation discipline carried in from ADR-152 §2.2:** preprint accuracy numbers are CLAIMED until reproduced on our hardware. The project has already retracted its own "92.9% PCK@20" and "shipped-WiFlow-STD 97.25%" figures after measurement; this brief inherits that bar.
---
## 1. Executive summary
**Where the project stands vs the 2026 frontier.** The repo is, by the evidence already in-tree, *ahead of most academic groups on benchmark hygiene* and roughly *at parity on capability* — but the two are measured on incompatible yardsticks, which is the single biggest risk to any "beyond-SOTA" claim.
- The project's headline reproductions (`benchmarks/wiflow-std/RESULTS.md`) are MEASURED and rigorous: WiFlow-STD retrained to **96.0996.61% PCK@20** on the authors' own 360k-window 2D dataset (RTX 5080), shipped checkpoint REFUTED, dataset/code defects documented. This is a genuinely strong, reproducible result.
- **But that number is not on a standard public benchmark.** WiFlow-STD's dataset is self-collected (5 subjects, 15 keypoints, 2D, in-domain random split, hardware unspecified). The academic frontier on the *standard* public 3D benchmark (MM-Fi) reports **PCK@20 ≈ 61% / MPJPE ≈ 161 mm random-split** (GraphPose-Fi, Nov 2025) — a *harder* metric (3D, mm-scale, standard PCK normalization). The project's own AetherArena MM-Fi number (**81.63% torso-PCK@20 in-domain**, ADR-150) uses a *torso-normalized PCK* that is looser than GraphPose-Fi's standard PCK, so the three numbers (96% / 81.6% / 61%) **cannot be lined up** without a unified harness. Making them comparable IS the highest-value work item.
- The deployment frontier — **cross-subject / cross-environment generalization** — is where everyone collapses, the project included (ADR-150: 81.63% in-domain → ~11.6% leakage-free cross-subject). GraphPose-Fi independently confirms the cliff (61.1% random → 12.9% cross-environment PCK@20). This is the real research target, not in-domain PCK.
**Top 3 highest-value optimization/benchmark targets:**
1. **A unified, metric-locked accuracy harness in `wifi-densepose-train`** that scores any model under *one* explicit PCK definition (normalization, keypoint convention, split) so WiFlow-STD-repro, AetherArena/MM-Fi, and GraphPose-Fi numbers become directly comparable. Without this, no "beyond-SOTA" claim survives the "prove it" bar — the project has already been burned twice by metric ambiguity (the retracted 92.9% used absolute, not torso-normalized, PCK).
2. **A QAT path for the WiFlow-STD-class edge model.** The in-tree edge work (`RESULTS.md`) has *fully characterized PTQ* (static QDQ conv-only is the int8 sweet spot; dynamic int8 is a no-op on this all-conv architecture) and found the **half model (843k params) strictly dominates the published 2.23M** and **tiny (56k, 295 KB ONNX fp32) holds 94.1% PCK@20**. The one untested lever is **quantization-aware training**, which the general literature says recovers most of the PTQ accuracy gap. That is the next defensible edge win.
3. **Criterion-backed regression benches wired into CI** for the real Candle/ONNX forward path. The benches *exist* (`wifi-densepose-nn/benches/{inference,onnx,native_conv}_bench.rs`, `wifi-densepose-train/benches/training_bench.rs`) and `benchmarks/edge-latency/RESULTS.md` shows the methodology is sound (host≠ESP32 caveat made explicit). The gap is turning point-in-time captures into committed regression baselines.
---
## 2. Findings per research question
### RQ1 — Latest WiFi-CSI pose SOTA (20242026): published PCK@20 / MPJPE on the standard public benchmarks
The crucial framing: **"WiFi pose SOTA" splits into two non-comparable tracks** — 3D pose on MM-Fi/Person-in-WiFi-3D (mm-scale MPJPE, standard PCK) vs 2D pose on self-collected sets (image-normalized PCK). The project's flagship reproduction lives in the second track; the academic frontier lives in the first.
| Method | Venue / Year | Benchmark + split | PCK@20 | MPJPE | Grade |
|---|---|---|---|---|---|
| **GraphPose-Fi** (arXiv [2511.19105](https://arxiv.org/abs/2511.19105)) | PREPRINT, Nov 2025 | MM-Fi P1, **random split** | **61.1%** | **160.6 mm** (PA-MPJPE 105.0) | numbers MEASURED-in-study (preprint); beats MetaFi++, HPE-Li, DT-Pose |
| GraphPose-Fi | same | MM-Fi P1, **cross-subject** | 44.2% | 210.5 mm | same |
| GraphPose-Fi | same | MM-Fi P1, **cross-environment** | 12.9% | 302.7 mm | same — the generalization cliff |
| **DT-Pose** (arXiv [2501.09411](https://arxiv.org/abs/2501.09411)) | PREPRINT (ICLR'25 OpenReview [aPnLQ6WfQQ](https://openreview.net/forum?id=aPnLQ6WfQQ)), Jan 2025; code [cseeyangchen/DT-Pose](https://github.com/cseeyangchen/DT-Pose) | MM-Fi (domain-gap + topology focus) | not cleanly extractable from abstract | reports MPJPE; self-supervised masked pretrain + topology decode | numbers NOT verified at exact-table level here — flagged |
| **Person-in-WiFi-3D** (CVPR 2024, [openaccess](https://openaccess.thecvf.com/content/CVPR2024/html/Yan_Person-in-WiFi_3D_End-to-End_Multi-Person_3D_Pose_Estimation_with_Wi-Fi_CVPR_2024_paper.html)) | **PEER-REVIEWED**, CVPR 2024 | own 97k-frame multi-person set | — (multi-person, not single-PCK) | **91.7 mm (1p) / 108.1 (2p) / 125.3 (3p)** 3D joint error | MEASURED (peer-reviewed); own dataset, not MM-Fi |
| **WiFlow-STD** (arXiv [2602.08661](https://arxiv.org/abs/2602.08661), [DY2434 repo](https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling)) | PREPRINT, Apr 2026 | self-collected, 5-subj, **2D, in-domain random** | 97.25% (claimed) | 0.007 m (image-norm) | claimed CLAIMED; **project reproduced 96.0996.61% (MEASURED, RTX 5080)** after repairing dataset/code |
| **PerceptAlign** (arXiv [2601.12252](https://arxiv.org/abs/2601.12252)) | PREPRINT + MobiCom'26 acceptance | own 7-layout cross-domain 3D set | — | 222.4 mm (Scene4) / 317.1 (Scene5), claims 54% cross-env vs SOTA | CLAIMED (preprint); failure mode corroborated |
| **Project AetherArena** (ADR-150, [issue #876](https://github.com/ruvnet/RuView/issues/876)) | internal | MM-Fi, **random split**, **torso-PCK** | **81.63% torso-PCK@20** | — | MEASURED-internal; **torso-PCK ≠ GraphPose-Fi standard PCK** |
| **Project WiFlow-STD repro** (`benchmarks/wiflow-std/RESULTS.md`) | internal | their data, their split | **96.0996.61%** | 0.00940.0098 m | MEASURED-internal (RTX 5080) |
**How the project's ~96% compares to the frontier:** It is *not directly comparable*. The 96% is on an easier task (2D, in-domain, image-normalized PCK, single-environment, 5 subjects) than GraphPose-Fi's 61.1% (3D, standard PCK, mm-scale). The project's own MM-Fi-track number (81.63% torso-PCK@20) *appears* to beat GraphPose-Fi's 61.1%, **but only because torso-PCK is a looser normalization** — the project explicitly flags this (ADR-150 cites beating "MultiFormer's 72.25%" under the *same* torso metric, not GraphPose-Fi's). The honest statement: **the project is competitive on in-domain MM-Fi under its own torso metric, and collapses cross-subject exactly as the published frontier does.** No public number lets the project claim "beyond-SOTA" today.
### RQ2 — What's winning architecturally now (20252026)
The clear trend across the verified 20252026 papers:
- **Graph / skeleton-aware decoders are the current academic SOTA on MM-Fi.** GraphPose-Fi (PREPRINT, Nov 2025) wins by injecting anatomical graph structure into the decoder — exactly the `GraphPose-Fi-style skeleton-aware graph head` ADR-150 §2.2 already names as the planned decoder. *The project's architecture direction matches the frontier.*
- **Self-supervised masked pretraining (MAE) is the cross-domain lever, not capacity.** UNSW MAE study (arXiv [2511.18792](https://arxiv.org/abs/2511.18792), PREPRINT, Nov 2025): cross-domain gains scale **log-linearly with pretraining data, unsaturated at 1.3M samples**; ViT-Base adds only 0.40.9% over ViT-Small. Recipe: **80% masking, (30,3) small patches**. DT-Pose (arXiv 2501.09411) independently uses masked pretraining + topology constraints for the domain gap. *Caveat (MEASURED in ADR-152 §2.3): UNSW's downstream tasks are classification, not pose — pose transfer remains a hypothesis. The project's own measurement (b) found WiFlow-STD pretrained features give optimization transfer but NOT feature transfer to ESP32 CSI.*
- **Spatio-temporal decoupling is the efficiency lever.** WiFlow-STD's whole contribution is decoupling spatial and temporal CSI processing to hit 2.23M params. The project verified the params/FLOPs (MEASURED) and then **beat it**: the half-model (843k) matches accuracy with 0.38× params (`RESULTS.md` efficiency sweep).
- **Geometry/layout conditioning is the cross-layout lever.** PerceptAlign (MobiCom'26): fusing transceiver-position embeddings + two-checkerboard calibration, claimed 60% cross-domain. ADR-152 §2.1 already adopted this (`NodeGeometry`, geometry embeddings).
- **NOT winning / absent:** diffusion models for CSI pose did not surface in the verified frontier. Full DensePose-UV regression from commodity WiFi remains undemonstrated (ADR-152 F5, MEASURED by full-text screening). No 20252026 paper was found that *beats the project's current direction* — the project is tracking, not trailing, the architecture frontier.
**Verdict RQ2:** the winning stack (MAE pretrain → graph/skeleton decoder → geometry conditioning, ViT-Small-class capacity) is *already the planned ADR-150/152 stack*. The gain available is not a new architecture; it's (a) more heterogeneous pretraining data and (b) honest cross-domain measurement.
### RQ3 — Edge/quantized inference SOTA for small CSI pose models
The in-tree edge work (`benchmarks/wiflow-std/RESULTS.md` "Edge optimization" + "Static PTQ" + "Efficiency sweep") is already at or beyond what the public literature offers for this specific model class, and is MEASURED. Key findings to carry forward:
- **Dynamic INT8 is a trap on all-conv CSI models.** WiFlow-STD has **zero `nn.Linear` layers** (21 Conv1d + 22 Conv2d + BatchNorm). `torch.quantize_dynamic` quantizes 0% of params (dynamic int8 has no conv kernels). MEASURED.
- **Static QDQ conv-only PTQ is the int8 sweet spot.** PCK@20 96.6096.63% (vs fp32 96.68%, dynamic 96.52%), 2.53 MB. All-ops QDQ is strictly worse (1.4 pt). MEASURED.
- **ONNX Runtime fp32 is the real CPU latency win**: 3.2 ms/window batch-1 vs torch 11.0 ms (~3.4×) at parity (2.4e-7). int8 is ~2× *slower* than ONNX fp32 at batch-1 (ConvInteger kernels). MEASURED.
- **Smaller-than-published dominates.** half (843k) ≥ full on accuracy; **tiny (56k, 295 KB ONNX fp32, 0.66 ms/win, 94.1% PCK@20)** is the smallest deployable artifact. At tiny scale int8 is a *bad* trade (1.43 pt for 47 KB). MEASURED.
- **General QAT-vs-PTQ context (BLOG/VENDOR):** [NVIDIA TensorRT QAT blog](https://developer.nvidia.com/blog/achieving-fp32-accuracy-for-int8-inference-using-quantization-aware-training-with-tensorrt/), [Ultralytics QAT glossary](https://www.ultralytics.com/glossary/quantization-aware-training-qat), [ONNX Runtime quantization docs](https://onnxruntime.ai/docs/performance/model-optimizations/quantization.html): QAT "almost always" recovers accuracy PTQ loses on sensitive models; ONNX Runtime does NOT retrain (QAT must happen in PyTorch, then export QDQ). The [Onboard Optimization survey, arXiv 2505.08793](https://arxiv.org/pdf/2505.08793) (PREPRINT) covers on-device optimization broadly. These are *general* claims, not CSI-pose-specific — grade accordingly.
- **Hailo / Pi target (CLAUDE.local.md):** the 4× Pi+Hailo cluster (Hailo-8 @ 26 TOPS / Hailo-10 @ 40 TOPS) needs a **HEF** compile path, which is its own toolchain (not ONNX/Candle). No in-tree HEF benchmark exists yet — this is a genuine gap for the edge-inference claim.
**Actionable for an inference-speed benchmark:** the honest comparand set is `{torch fp32, ONNX fp32, ONNX static-QDQ-conv-only int8, candle fp32}` × `{full, half, tiny}` on a fixed host, with the **host≠ESP32 / host≠Hailo caveat stated up front** (the `edge-latency/RESULTS.md` template already does this correctly). The one new datapoint worth producing: **QAT-int8 on the half model** to test whether QAT closes the PTQ 0.16 pt gap *and* keeps the size win.
### RQ4 — Rigorous, reproducible benchmark methodology
The repo already demonstrates the right methodology in three places — the ADR should codify it, not invent it:
- **`benchmarks/wiflow-std/RESULTS.md`** — the gold standard already in-tree: pinned upstream commit, seed-42 file-level split documented, corruption masks committed as ground truth, every forced deviation recorded, mean-pose honesty baseline, MEASURED-vs-CLAIMED grading.
- **`benchmarks/edge-latency/RESULTS.md`** — criterion 0.5, explicit host machine, low/median/high brackets, contention caveat, host≠ESP32 separation, steady-state-vs-cold-start distinction.
- **Rust micro-bench:** criterion benches already exist in both crates (`wifi-densepose-nn/benches/`, `wifi-densepose-train/benches/`).
What a credible "beyond-SOTA" claim requires (the bar that survives "prove it"):
1. **One locked accuracy definition** — PCK normalization (torso vs absolute vs bbox), keypoint convention (15 vs 17 COCO), and split (random / cross-subject / cross-environment) declared *before* the run. The retracted 92.9% died exactly because PCK normalization was unstated.
2. **A mean-pose / constant-output honesty baseline** on every split (already done in measurement (b) — a single-subject near-static set scored 95.9% torso-PCK@20 with a *constant* pose). Any claim must beat this.
3. **MEASURED-vs-CLAIMED grading** per number, with the exact command and raw-JSON path committed.
4. **Cross-domain, not just in-domain.** In-domain PCK is saturated and uninformative; the defensible claim is on cross-subject/cross-environment, where the frontier is 1244% PCK@20.
---
## 3. Proposed benchmark-suite design
A two-part suite (`wifi-densepose-train` accuracy harness + `wifi-densepose-nn` latency harness), both committing raw JSON + a graded RESULTS.md.
### 3.1 Accuracy harness (`wifi-densepose-train`)
- **Metric module with one canonical PCK** (parameterized: `{torso, bbox, absolute}` normalization × threshold × keypoint-map), so a single function scores WiFlow-STD-repro, MM-Fi/AetherArena, and a GraphPose-Fi re-run identically. Lock the default to **torso-PCK@20 on 17-kp COCO** and *always* also print standard-PCK to expose the gap.
- **Fixed datasets/splits:** (i) WiFlow-STD cleaned 360k (their split, for repro parity), (ii) MM-Fi P1 random + cross-subject + cross-environment (to line up against GraphPose-Fi 61.1/44.2/12.9 and the project's 81.63), (iii) ESP32 paired eval set when ≥2k multi-subject windows exist.
- **Mandatory honesty baselines** emitted every run: mean-pose, constant-output, and (for cross-domain) source-only.
- **Output:** raw JSON + a RESULTS.md table with MEASURED/CLAIMED grades, mirroring `benchmarks/wiflow-std/RESULTS.md`.
### 3.2 Latency/size harness (`wifi-densepose-nn`)
- **Matrix:** `{torch fp32 (ref), ONNX fp32, ONNX static-QDQ-conv-only int8, candle fp32}` × `{full 2.23M, half 843k, tiny 56k}` × `{batch 1, 64}`, criterion-timed, host declared.
- **Report:** disk size, batch-1 + batch-64 ms/window (median + low/high), and PCK@20 on the locked 10k-window subset, so latency and accuracy never get cited apart.
- **Caveat block up front:** host ≠ ESP32-S3/WASM3, host ≠ Hailo HEF. No host number is presented as the edge number.
- **CI gate:** commit the current medians as regression baselines; fail PRs that regress latency >X% or accuracy >Y pt.
### 3.3 What counts as a defensible "beyond-SOTA" result
A claim is citable only if **all** hold: (1) scored under a pre-declared metric/split, (2) beats the relevant published frontier number *on the same metric definition* (e.g. >61.1% standard-PCK@20 on MM-Fi random, or >12.9% on cross-environment), (3) beats the mean-pose honesty baseline, (4) raw JSON + exact command committed, (5) graded MEASURED. The single most valuable "beyond-SOTA" target is **cross-environment MM-Fi**, where the published bar (12.9% PCK@20) is low enough that a real win is both achievable and unambiguous.
---
## 4. Gap table
| Capability | Project current (graded) | Published SOTA (graded) | Proposed target | Data / hardware needed |
|---|---|---|---|---|
| In-domain 2D PCK@20 (self-collected) | 96.0996.61% (MEASURED, RTX 5080, WiFlow-STD repro) | 97.25% claimed (WiFlow-STD, CLAIMED) | match within noise + own architecture | cleaned 360k dataset (have); already met |
| In-domain MM-Fi PCK@20 (torso-norm) | 81.63% torso-PCK (MEASURED-internal) | GraphPose-Fi 61.1% *standard*-PCK (PREPRINT) — **not comparable** | re-score both under **one** PCK def | MM-Fi P1 (have); unified metric harness (gap) |
| **Cross-subject MM-Fi PCK@20** | ~11.6% torso (MEASURED, the cliff) | GraphPose-Fi 44.2% standard (PREPRINT) | close gap via MAE pretrain + graph decoder | 1.3M heterogeneous CSI corpus (ADR-150/152 §2.3), ViT-Small encoder |
| **Cross-environment MM-Fi PCK@20** | untested-internal | GraphPose-Fi 12.9% standard (PREPRINT) | **beat 12.9% → cleanest beyond-SOTA win** | MM-Fi cross-env split + geometry conditioning (ADR-152 §2.1) |
| ESP32 CSI→pose (17-kp) | no run beats mean-pose baseline (MEASURED, measurement b) | n/a (no public ESP32 pose benchmark) | beat mean-pose on temporal split | ≥2k multi-subject/multi-position paired windows (gap) |
| Edge int8 size/accuracy | static QDQ conv-only 96.61% @ 2.53 MB; tiny 94.1% @ 295 KB fp32 (MEASURED) | no model-matched public number | **QAT-int8 on half model** (untested lever) | PyTorch QAT + QDQ export; RTX 5080 (have) |
| Edge CPU latency | ONNX fp32 3.2 ms/win b1 host (MEASURED) | n/a (model-specific) | committed criterion regression baseline | host bench (have); ESP32/Hailo on-hardware (gap) |
| Hailo HEF edge inference | none in-tree (gap) | n/a | first MEASURED HEF latency | Hailo compile toolchain + Pi cluster (have hardware, CLAUDE.local.md) |
| Foundation encoder (MAE) | recipe adopted, untrained (ADR-152 §2.3) | UNSW: log-linear cross-domain scaling on *classification* (PREPRINT) | pose-transfer validation (hypothesis today) | 1.3M-sample corpus aggregation (priority per F3) |
---
## 5. Sources (graded)
| Source | Type | Grade | Used for |
|---|---|---|---|
| GraphPose-Fi, arXiv [2511.19105](https://arxiv.org/abs/2511.19105) | preprint | PREPRINT; table numbers MEASURED-in-study (fetched + quoted) | RQ1 MM-Fi frontier (61.1/44.2/12.9 PCK@20, 160.6/210.5/302.7 mm) |
| WiFlow-STD, arXiv [2602.08661](https://arxiv.org/abs/2602.08661) + [DY2434 repo](https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling) | preprint+code | numbers CLAIMED; artifacts MEASURED; **project repro 96% MEASURED** | RQ1/RQ2/RQ3 |
| PerceptAlign, arXiv [2601.12252](https://arxiv.org/abs/2601.12252) | preprint + MobiCom'26 acceptance | CLAIMED numbers; failure mode corroborated | RQ1/RQ2 geometry conditioning |
| UNSW MAE, arXiv [2511.18792](https://arxiv.org/abs/2511.18792) | preprint | ablations MEASURED-in-study; pose transfer = hypothesis | RQ2 MAE recipe |
| DT-Pose, arXiv [2501.09411](https://arxiv.org/abs/2501.09411), OpenReview [aPnLQ6WfQQ](https://openreview.net/forum?id=aPnLQ6WfQQ), [code](https://github.com/cseeyangchen/DT-Pose) | preprint+code (ICLR'25) | exact MPJPE table NOT verified here — flagged | RQ2 masked-pretrain + topology |
| Person-in-WiFi-3D, [CVPR 2024](https://openaccess.thecvf.com/content/CVPR2024/html/Yan_Person-in-WiFi_3D_End-to-End_Multi-Person_3D_Pose_Estimation_with_Wi-Fi_CVPR_2024_paper.html) | peer-reviewed | MEASURED (91.7/108.1/125.3 mm); own dataset | RQ1 3D multi-person frontier |
| ONNX Runtime quantization [docs](https://onnxruntime.ai/docs/performance/model-optimizations/quantization.html) | vendor docs | VENDOR | RQ3 PTQ/QAT mechanics |
| NVIDIA TensorRT QAT [blog](https://developer.nvidia.com/blog/achieving-fp32-accuracy-for-int8-inference-using-quantization-aware-training-with-tensorrt/), [Ultralytics](https://www.ultralytics.com/glossary/quantization-aware-training-qat) | vendor/blog | BLOG/VENDOR; general, not CSI-specific | RQ3 QAT>PTQ context |
| Onboard Optimization survey, arXiv [2505.08793](https://arxiv.org/pdf/2505.08793) | preprint | PREPRINT | RQ3 on-device optimization landscape |
| In-tree `benchmarks/wiflow-std/RESULTS.md`, `benchmarks/edge-latency/RESULTS.md`, ADR-150, ADR-152, ADR-015 | internal MEASURED | MEASURED-internal | grounding, all RQs |
**Unverified / flagged:** DT-Pose exact MM-Fi MPJPE table not extracted at primary-source precision (abstract-level only). GraphPose-Fi parameter count not reported in the paper. WiFlow-STD/PerceptAlign accuracy numbers are author-self-reported preprints. No CSI-pose-specific QAT benchmark exists in the public literature — the QAT recommendation rests on general (non-CSI) vendor/blog evidence.
+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.
+135
View File
@@ -0,0 +1,135 @@
# WiFlow Browser Trainer (`wiflow_browser.html`)
A **single self-contained HTML page** that does the entire camera-supervised
WiFi-pose loop **in your browser, in your laptop camera's coordinate frame**, as
a **4-stage gated flow** with a progress stepper (each stage unlocks the next):
0. **CALIBRATE** *(ADR-151 empty-room baseline)* — you step OUT of the space; the
page captures ~10 s of the quiescent CSI and computes a per-feature running
**mean + std (Welford)** over the 410-d vector. Every CSI vector afterwards is
expressed as **deviation from baseline**
(`x_norm = (x base_mean) / (base_std + ε)`), so a body's perturbation stands
out from the static channel. Persisted to IndexedDB. *Can't capture without it.*
1. **CAPTURE** — MediaPipe Pose runs on your laptop camera → 17 COCO keypoints
(the *label*), paired with the **baseline-normalized** 410-d ESP32 CSI vector
(the *input*). A **guided, balanced routine** cycles big on-screen prompts
(stand / turn / walk / arms / crouch / sit / reach) with a countdown, and a
**per-pose coverage meter** so you build a balanced dataset, not 2 000 frames
of standing.
2. **TRAIN** — a TensorFlow.js MLP learns `CSI → pose` in-browser. Honest
held-out PCK@0.10 / PCK@0.05 / MPJPE, plus a **mean-pose baseline** the model
must beat (the project's whole ethos — no baseline-beating signal, it says so).
*Can't train with <200 samples.*
3. **INFER** — the trained model drives a skeleton **from WiFi CSI only**
(baseline-normalized → standardized → model), drawn over the **same** camera
frame it trained in — so the inferred skeleton **aligns** with the camera
image. That alignment is the entire point of doing this in-browser instead of
with a separate Python camera. *Can't infer without a model.*
## Why in-browser
The Python pipeline (`wiflow_capture.py``wiflow_train.py``wiflow_infer.py`)
proved the signal is real (held-out PCK@0.10 ≈ 59.5% vs a 50% mean-pose baseline
= +9.4 pp). But it trained in a *different* camera's frame, so the inferred
skeleton never lined up with the laptop camera. Doing capture + train + infer all
in the browser with the **same** camera makes the training frame and the
inference frame identical → the skeleton aligns.
## Compute backends (WebGPU / WASM / WebGL)
Training and inference run on TensorFlow.js. The page selects the backend at
startup, preferring the fastest available:
- **WebGPU** (Chrome / Edge, secure context — `localhost` qualifies) — GPU compute.
- **WASM-SIMD** fallback (`tfjs-backend-wasm`, SIMD enabled, `.wasm` from the CDN).
- **WebGL** last-resort fallback (ships inside tfjs core).
The **active backend is shown as a badge in the header** (`compute: WebGPU` /
`WASM-SIMD` / `WebGL`) so it's honest about what's actually running. The model
code is backend-agnostic — tf.js abstracts the device.
## Honesty (baked in)
- The **CAPTURE** skeleton (blue) is the camera = ground truth, labeled as such.
- The **INFER** skeleton (green) is **CSI-only**, labeled, and **coarse** — the
real measured held-out PCK is shown, not a marketing number.
- The **mean-pose baseline** is always computed and shown in TRAIN; the verdict
states plainly whether the model **beats** it (real signal) or **does not**
(no usable signal). This guards against the project's retracted 92.9% that
failed exactly this check.
- Status banner is strict and mutually exclusive:
**LIVE** (real `source: "esp32"`) / **SIMULATED — not real** (any other source)
/ **NO-CSI-SERVER**. The page never invents frames.
## How to run
### 1. Start the real sensing-server (provides the CSI WebSocket on :8765)
```bash
cd v2
cargo build -p wifi-densepose-sensing-server
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005
```
A real ESP32-S3 must be provisioned and streaming for `source` to read `esp32`
(see `CLAUDE.local.md` for the firmware build/provision steps). The page expects
the verified live endpoint **`ws://localhost:8765/ws/sensing`** with
`source:"esp32"`, nodes `[9, 13]`, `features.*`, `node_features[].features.*`,
and `signal_field.values` (400 floats).
### 2. Serve this page over localhost (camera + WebGPU need a localhost/secure origin)
Any static localhost server works. For example:
```bash
python -m http.server 8099
# then open: http://localhost:8099/examples/through-wall/wiflow_browser.html
```
(8099 is just the static file server — 8765 is a separate process, the CSI
WebSocket.) Allow camera access when the browser prompts.
Point at a CSI server on another host with `?ws=`:
```
http://localhost:8099/examples/through-wall/wiflow_browser.html?ws=ws://192.168.1.20:8765/ws/sensing
```
### 3. Use it
1. **CAPTURE** tab → *enable laptop camera**start recording*. Follow the guided
routine (stand / turn / walk / arms / crouch / sit). A pair is stored only when
a confident pose AND a fresh live `esp32` CSI frame coexist. Aim for a few
thousand samples. Samples persist in IndexedDB across refreshes.
2. **TRAIN** tab → *train model*. Watch the live loss curve, held-out PCK, and the
baseline verdict. The model saves to IndexedDB.
3. **INFER** tab → the green skeleton is now driven by WiFi CSI only, aligned over
your camera. Toggle *hide camera* to see the CSI-only skeleton on black.
## The 410-d CSI vector (matches the Python pipeline exactly)
```
[ mean_rssi, variance, motion_band_power, breathing_band_power ] # 4 (features.*)
+ for node 9 then node 13: [ mean_rssi, variance, motion_band_power ] # 6 (node_features[].features.*)
+ signal_field.values, padded / truncated to 400 # 400
= 410-d
```
Verified against a real live frame: the in-browser `csiVector()` produces the
identical 410 vector as `wiflow_capture.py`'s `csi_vector()` (node 9 first, then
node 13; field zero-padded).
## Libraries (CDN only, no bundler)
| Library | CDN |
|---|---|
| TensorFlow.js core | `@tensorflow/tfjs@4.22.0/dist/tf.min.js` |
| TF.js WebGPU backend | `@tensorflow/tfjs-backend-webgpu@4.22.0/dist/tf-backend-webgpu.min.js` |
| TF.js WASM backend | `@tensorflow/tfjs-backend-wasm@4.22.0/dist/tf-backend-wasm.min.js` |
| MediaPipe Pose 0.5 (legacy solutions) | `@mediapipe/pose@0.5/pose.js` |
## Scope / honesty caveats
Same person, same room, same session. **Not** validated cross-day, cross-room, or
through-wall. The inferred pose is coarse (PCK@0.05 is typically weak). If the
model does not beat the mean-pose baseline, the page says so — that is a feature.
+644
View File
@@ -0,0 +1,644 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RuView · Through-Wall WiFi Sensing · LIVE CSI (no skeleton, no simulation)</title>
<!--
THROUGH-WALL WiFi-CSI SENSING DEMO — honest, real-data-only.
Renders ONLY what the running sensing-server actually streams over
ws://localhost:8765/ws/sensing :
- the 20x20 `signal_field` floor heatmap (real values)
- a coarse RF-localization puck from persons[0].position (NOT pose)
- live motion / presence / rssi / confidence meters
- the real `source` ("esp32" = LIVE) verbatim in the banner
It deliberately does NOT draw a skeleton. The server's
persons[].keypoints carry confidence:0.0 (image-pixel garbage, not
real 3D joints) so we never render them. WiFi CSI gives
motion/presence/coarse-position — that is the honest wow, and it
penetrates drywall. See README.md.
-->
<style>
:root {
--bg: #050507; --bg-panel: rgba(8,10,14,0.80);
--amber: #ffb840; --amber-hot: #ffe09f;
--cyan: #4cf; --magenta: #ff4cc8;
--text: #d8c69a; --text-mute: #6b6155;
--green: #4f4; --red: #f64;
--border: rgba(255,184,64,0.18);
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
-webkit-font-smoothing: antialiased; font-size: 12px;
}
canvas { display: block; }
.overlay-frame {
position: fixed; inset: 0; pointer-events: none; z-index: 5;
background:
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
}
.scanlines {
position: fixed; inset: 0; pointer-events: none; z-index: 6;
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
mix-blend-mode: overlay; opacity: 0.5;
}
.panel {
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
box-shadow: 0 1px 0 rgba(255,184,64,0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
}
.panel h2 {
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
}
/* ---- Honest status banner (top-center, mutually exclusive states) ---- */
#banner {
position: fixed; top: 0; left: 0; right: 0; z-index: 30;
text-align: center; padding: 7px 12px; font-size: 12px; letter-spacing: 1px;
font-weight: 600; border-bottom: 1px solid rgba(0,0,0,0.4);
transition: background 0.3s, color 0.3s;
}
#banner.live { background: rgba(40,255,80,0.12); color: var(--green); border-bottom-color: rgba(80,255,120,0.4); }
#banner.sim { background: rgba(255,120,40,0.16); color: #ffae5a; border-bottom-color: rgba(255,140,60,0.5); }
#banner.noserver { background: rgba(255,80,80,0.16); color: var(--red); border-bottom-color: rgba(255,90,90,0.5); }
#banner .src { opacity: 0.8; font-weight: 400; }
#banner-caption {
position: fixed; top: 30px; left: 0; right: 0; z-index: 29;
text-align: center; font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px;
pointer-events: none; padding-top: 2px;
}
#info { top: 64px; left: 20px; min-width: 270px; }
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
#info .row .k { color: var(--text-mute); font-size: 11px; }
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
#info .row .v.amber { color: var(--amber); }
#info .row .v.cyan { color: var(--cyan); }
#info .row .v.green { color: var(--green); }
#info .row .v.red { color: var(--red); }
#info .row .v.mag { color: var(--magenta); }
#info .row .v.mute { color: var(--text-mute); }
#csi { top: 64px; right: 20px; min-width: 270px; }
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
#csi .bar-row .label { width: 86px; color: var(--text-mute); }
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
#csi .bar-row .bar-fill {
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
box-shadow: 0 0 6px var(--amber); transition: width 0.1s linear;
}
#csi .bar-row .val { width: 44px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
#csi .spark { margin-top: 8px; }
#csi canvas { width: 100%; height: 38px; display: block; border: 1px solid var(--border); border-radius: 3px; background: rgba(0,0,0,0.3); }
#csi .legend { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 10px; color: var(--text-mute); line-height: 1.5; }
/* ---- waiting / no-server overlay ---- */
#waiting {
position: fixed; inset: 0; z-index: 25; display: none;
flex-direction: column; align-items: center; justify-content: center;
background: rgba(5,5,7,0.94); color: var(--amber); text-align: center; padding: 24px;
}
#waiting.show { display: flex; }
#waiting .big { font-size: 22px; letter-spacing: 2px; color: var(--red); margin-bottom: 16px; text-transform: uppercase; }
#waiting code {
display: block; text-align: left; max-width: 640px; margin: 8px auto;
background: rgba(255,184,64,0.06); border: 1px solid var(--border); border-radius: 4px;
padding: 10px 14px; color: var(--amber-hot); font-size: 12px; white-space: pre-wrap;
}
#waiting .pulse { animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 0.55; } 50% { opacity: 1; } }
/* ---- optional webcam ground-truth tile ---- */
#cam-tile {
position: absolute; bottom: 20px; right: 20px; width: 240px; z-index: 12;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 8px; backdrop-filter: blur(8px);
}
#cam-tile h2 { margin: 0 0 6px 0; font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px;
color: var(--cyan); font-weight: 600; }
#cam-tile .gt-note { font-size: 9px; color: var(--text-mute); margin-top: 4px; line-height: 1.4; }
#cam-video { width: 100%; border-radius: 3px; display: none; background: #000; }
#cam-tile button {
width: 100%; margin-top: 6px; padding: 5px 8px; font-family: inherit; font-size: 11px;
background: transparent; color: var(--cyan); border: 1px solid var(--cyan); border-radius: 3px; cursor: pointer;
}
#cam-tile button:hover { background: rgba(68,204,255,0.12); }
#cam-tile button:disabled { opacity: 0.5; cursor: not-allowed; }
#legend-nodes {
position: absolute; bottom: 20px; left: 20px; min-width: 220px;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
}
#legend-nodes h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
#legend-nodes .lr { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 11px; }
#legend-nodes .dot { width: 9px; height: 9px; border-radius: 50%; box-shadow: 0 0 6px currentColor; flex: 0 0 auto; }
#legend-nodes .muted { color: var(--text-mute); }
</style>
<!-- three.js r128 + addons (same CDN set as examples/three.js/demos/05) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
</head>
<body>
<div id="banner" class="noserver">NO SERVER — start the sensing-server <span class="src"></span></div>
<div id="banner-caption">Real WiFi CSI motion / presence / coarse-localization — penetrates drywall. Not skeletal pose.</div>
<div class="overlay-frame"></div>
<div class="scanlines"></div>
<div class="panel" id="info">
<h1>THROUGH-WALL WiFi SENSING</h1>
<div class="sub">Live CSI · ws://localhost:8765/ws/sensing</div>
<div class="row"><span class="k">source</span><span class="v amber" id="m-source"></span></div>
<div class="row"><span class="k">presence</span><span class="v" id="m-presence"></span></div>
<div class="row"><span class="k">motion level</span><span class="v" id="m-motion"></span></div>
<div class="row"><span class="k">confidence</span><span class="v cyan" id="m-conf"></span></div>
<div class="row"><span class="k">est. persons</span><span class="v amber" id="m-persons"></span></div>
<div class="row"><span class="k">active nodes</span><span class="v" id="m-nodes"></span></div>
<div class="row"><span class="k">tick</span><span class="v" id="m-tick"></span></div>
<div class="row"><span class="k">update rate</span><span class="v cyan" id="m-fps"></span></div>
</div>
<div class="panel" id="csi">
<h2>Live RF features</h2>
<div class="bar-row"><span class="label">motion</span><div class="bar-track"><div class="bar-fill" id="bar-motion"></div></div><span class="val" id="v-motion"></span></div>
<div class="bar-row"><span class="label">breathing</span><div class="bar-track"><div class="bar-fill" id="bar-breath"></div></div><span class="val" id="v-breath"></span></div>
<div class="bar-row"><span class="label">variance</span><div class="bar-track"><div class="bar-fill" id="bar-var"></div></div><span class="val" id="v-var"></span></div>
<div class="bar-row"><span class="label">mean rssi</span><div class="bar-track"><div class="bar-fill" id="bar-rssi"></div></div><span class="val" id="v-rssi"></span></div>
<div class="spark"><canvas id="spark" width="252" height="38"></canvas></div>
<div class="legend">motion sparkline (last ~6s of real motion_band_power)</div>
</div>
<div id="legend-nodes">
<h2>Sensor nodes</h2>
<div class="lr"><span class="dot" style="color:#4cf"></span><span>ESP32-S3 office <span class="muted">(node 9)</span></span></div>
<div class="lr"><span class="dot" style="color:#ff4cc8"></span><span>ESP32-S3 hallway <span class="muted">(node 13)</span></span></div>
<div class="lr" style="margin-top:6px"><span class="dot" style="color:#4f4"></span><span>RF localization <span class="muted">(coarse)</span></span></div>
<div class="lr"><span class="muted" style="font-size:10px;line-height:1.4">Office &amp; hallway split by a wall + doorway. WiFi motion still shows through drywall.</span></div>
</div>
<div id="cam-tile">
<h2>camera — ground truth when visible</h2>
<video id="cam-video" autoplay muted playsinline></video>
<button id="cam-btn">▶ enable webcam (optional)</button>
<div class="gt-note">Independent of the CSI sensing. The WiFi works in the dark and through walls; the camera does not.</div>
</div>
<div id="waiting" class="show">
<div class="big pulse">Waiting for live sensing-server</div>
<div>No connection to <b>ws://localhost:8765/ws/sensing</b>. Start the real server, then this page connects automatically.</div>
<code>cd v2
cargo build -p wifi-densepose-sensing-server
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005</code>
<div style="margin-top:10px; color:var(--text-mute); font-size:11px;">This demo renders ONLY real data. It never invents frames.</div>
</div>
<script>
"use strict";
// =====================================================================
// Config + WS endpoint (allow ?ws= override)
// =====================================================================
const params = new URLSearchParams(location.search);
const WS_URL = params.get('ws') || 'ws://localhost:8765/ws/sensing';
const ROOM_HALF = 5; // half-extent of the floor plane in metres
const GRID_N = 20; // signal_field is 20 x 20
// Known node anchor positions (server sends node 9 @ [2,0,1.5]; node 13
// joins later from the hallway side once its firmware is flashed). These
// are anchors for the room model + labels, NOT fabricated sensing data.
const NODE_ANCHORS = {
9: { pos: [ 2.0, 0.0, 1.5], color: 0x44ccff, label: 'office (node 9)' },
13: { pos: [-2.0, 0.0, -3.0], color: 0xff4cc8, label: 'hallway (node 13)' },
};
// =====================================================================
// Three.js scene (reused pattern from demos/05-skinned-realtime.html)
// =====================================================================
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050507);
scene.fog = new THREE.FogExp2(0x050507, 0.045);
const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.05, 100);
camera.position.set(4.5, 4.2, 6.0);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.85;
renderer.outputEncoding = THREE.sRGBEncoding;
document.body.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.4, -0.5);
controls.enableDamping = true; controls.dampingFactor = 0.06;
controls.minDistance = 3; controls.maxDistance = 18;
controls.maxPolarAngle = Math.PI * 0.49;
scene.add(new THREE.HemisphereLight(0x553a18, 0x080606, 0.7));
const keyLight = new THREE.DirectionalLight(0xffc070, 0.9);
keyLight.position.set(3, 6, 4);
scene.add(keyLight);
// Post-processing — gentle bloom so the heatmap + puck glow.
const composer = new THREE.EffectComposer(renderer);
composer.addPass(new THREE.RenderPass(scene, camera));
const bloom = new THREE.UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight), 0.55, 0.45, 0.82);
composer.addPass(bloom);
// =====================================================================
// Room: floor grid + wall + doorway dividing office / hallway
// =====================================================================
const gridHelper = new THREE.GridHelper(2*ROOM_HALF, GRID_N, 0x554a32, 0x2a2418);
gridHelper.position.y = 0.002;
scene.add(gridHelper);
// Dividing wall runs along world X near z = -1 (office z>-1, hallway z<-1),
// with a doorway gap. Two wall segments leave a gap in the middle.
const wallMat = new THREE.MeshStandardMaterial({
color: 0x1b2330, transparent: true, opacity: 0.55, roughness: 0.9,
side: THREE.DoubleSide,
});
const wallH = 1.4, wallZ = -1.0;
function addWallSeg(cx, w) {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, 0.08), wallMat);
m.position.set(cx, wallH/2, wallZ);
scene.add(m);
// top edge highlight
const edge = new THREE.Mesh(new THREE.BoxGeometry(w, 0.02, 0.10),
new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0.5 }));
edge.position.set(cx, wallH, wallZ);
scene.add(edge);
}
// left segment, doorway gap (-0.7..0.7), right segment
addWallSeg(-3.15, 3.7);
addWallSeg( 3.15, 3.7);
// Room labels (sprite text) for OFFICE / HALLWAY
function makeLabel(text, color) {
const c = document.createElement('canvas'); c.width = 256; c.height = 64;
const ctx = c.getContext('2d');
ctx.fillStyle = color; ctx.font = 'bold 30px Consolas, monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, 128, 34);
const tex = new THREE.CanvasTexture(c);
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
spr.scale.set(2.0, 0.5, 1);
return spr;
}
const officeLbl = makeLabel('OFFICE', '#ffb840'); officeLbl.position.set(2.6, 0.06, 2.6); scene.add(officeLbl);
const hallLbl = makeLabel('HALLWAY', '#ff4cc8'); hallLbl.position.set(-2.6, 0.06, -3.2); scene.add(hallLbl);
// =====================================================================
// Node markers (office / hallway). The hallway node is dimmed until it
// actually appears in the live `nodes` list.
// =====================================================================
const nodeMeshes = {};
function buildNode(id) {
const a = NODE_ANCHORS[id];
const g = new THREE.Group();
const post = new THREE.Mesh(
new THREE.CylinderGeometry(0.05, 0.07, 0.9, 12),
new THREE.MeshStandardMaterial({ color: a.color, emissive: a.color, emissiveIntensity: 0.4, roughness: 0.4 }));
post.position.y = 0.45; g.add(post);
const orb = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 20, 16),
new THREE.MeshBasicMaterial({ color: a.color }));
orb.position.y = 0.95; g.add(orb);
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.18, 0.24, 32),
new THREE.MeshBasicMaterial({ color: a.color, transparent: true, opacity: 0.6, side: THREE.DoubleSide }));
ring.rotation.x = -Math.PI/2; ring.position.y = 0.01; g.add(ring);
const lbl = makeLabel('ESP32-S3 ' + a.label, '#' + a.color.toString(16).padStart(6,'0'));
lbl.scale.set(2.6, 0.65, 1); lbl.position.set(0, 1.25, 0); g.add(lbl);
g.position.set(a.pos[0], 0, a.pos[2]);
g.userData.parts = { post, orb, ring };
scene.add(g);
return g;
}
Object.keys(NODE_ANCHORS).forEach(id => { nodeMeshes[id] = buildNode(+id); });
function setNodeActive(id, active) {
const g = nodeMeshes[id]; if (!g) return;
const o = active ? 1.0 : 0.22;
const parts = g.userData.parts;
parts.orb.material.opacity = o; parts.orb.material.transparent = true;
parts.ring.material.opacity = 0.6 * o;
parts.post.material.emissiveIntensity = active ? 0.5 : 0.12;
}
setNodeActive(9, false); setNodeActive(13, false);
// =====================================================================
// signal_field 20x20 floor heatmap — instanced colored tiles.
// Driven ONLY by real `signal_field.values` (400 floats ~0..1).
// =====================================================================
const TILE = (2*ROOM_HALF) / GRID_N;
const heatGeo = new THREE.PlaneGeometry(TILE * 0.96, TILE * 0.96);
const heatMat = new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.85, side: THREE.DoubleSide });
const heatMesh = new THREE.InstancedMesh(heatGeo, heatMat, GRID_N * GRID_N);
heatMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
const heatColor = new THREE.InstancedBufferAttribute(new Float32Array(GRID_N * GRID_N * 3), 3);
heatMesh.instanceColor = heatColor;
const _m = new THREE.Matrix4();
const _q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0), -Math.PI/2);
const _s = new THREE.Vector3(1,1,1);
const _p = new THREE.Vector3();
// gridCell (gx,gz) -> world (x,z). gx,gz in [0,GRID_N).
function cellToWorld(gx, gz) {
return [ (gx + 0.5) * TILE - ROOM_HALF, (gz + 0.5) * TILE - ROOM_HALF ];
}
for (let gz = 0; gz < GRID_N; gz++) {
for (let gx = 0; gx < GRID_N; gx++) {
const i = gz * GRID_N + gx;
const [wx, wz] = cellToWorld(gx, gz);
_p.set(wx, 0.012, wz);
_m.compose(_p, _q, _s);
heatMesh.setMatrixAt(i, _m);
heatColor.setXYZ(i, 0.02, 0.02, 0.03);
}
}
heatMesh.instanceMatrix.needsUpdate = true;
scene.add(heatMesh);
// amber→white heat ramp for a value in [0,1]
function heatRamp(v, out) {
v = Math.max(0, Math.min(1, v));
// dark -> amber -> hot white
const r = Math.min(1, 0.05 + 1.6 * v);
const g = Math.min(1, 0.02 + 1.1 * v * v);
const b = Math.min(1, 0.04 + 0.9 * Math.pow(v, 3));
out.set(r, g, b);
return out;
}
const _c = new THREE.Color();
let lastFieldPeak = { gx: GRID_N/2|0, gz: GRID_N/2|0, v: 0 };
function updateHeatmap(field) {
if (!field || !Array.isArray(field.values)) return;
const vals = field.values;
// grid_size is [20,1,20]; values are row-major 400 floats.
let peakV = -1, peakGx = lastFieldPeak.gx, peakGz = lastFieldPeak.gz;
const n = Math.min(vals.length, GRID_N * GRID_N);
for (let i = 0; i < n; i++) {
const v = vals[i];
heatRamp(v, _c);
heatColor.setXYZ(i, _c.r, _c.g, _c.b);
if (v > peakV) { peakV = v; peakGx = i % GRID_N; peakGz = (i / GRID_N) | 0; }
}
heatColor.needsUpdate = true;
lastFieldPeak = { gx: peakGx, gz: peakGz, v: peakV };
}
// =====================================================================
// RF-localization puck — from persons[0].position (coarse, NOT pose).
// Falls back to the signal_field peak cell when no person is present.
// =====================================================================
const puck = new THREE.Group();
const puckCore = new THREE.Mesh(
new THREE.SphereGeometry(0.16, 24, 18),
new THREE.MeshBasicMaterial({ color: 0x66ff88 }));
puckCore.position.y = 0.16; puck.add(puckCore);
const puckRing = new THREE.Mesh(
new THREE.RingGeometry(0.28, 0.36, 40),
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.7, side: THREE.DoubleSide }));
puckRing.rotation.x = -Math.PI/2; puckRing.position.y = 0.02; puck.add(puckRing);
const puckBeam = new THREE.Mesh(
new THREE.CylinderGeometry(0.03, 0.03, 1.2, 8),
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.35 }));
puckBeam.position.y = 0.6; puck.add(puckBeam);
puck.visible = false;
scene.add(puck);
const puckTarget = new THREE.Vector3(0, 0, 0);
function updatePuck(frame) {
let wx = null, wz = null, present = false;
const persons = frame.persons || [];
if (persons.length && Array.isArray(persons[0].position)) {
// server position is [x, 0, z] in metres, origin at room centre
wx = persons[0].position[0];
wz = persons[0].position[2];
present = true;
}
// If no person but the field has clear energy, show the peak cell
// (coarse) so the puck honestly tracks "where the RF energy is".
if (!present && lastFieldPeak.v > 0.55) {
const peak = cellToWorld(lastFieldPeak.gx, lastFieldPeak.gz);
wx = peak[0]; wz = peak[1]; present = true;
}
if (present && wx !== null) {
// clamp into the room so it never flies off the floor
wx = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wx));
wz = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wz));
puckTarget.set(wx, 0, wz);
puck.visible = true;
} else {
puck.visible = false;
}
}
// =====================================================================
// HUD updates
// =====================================================================
const $ = id => document.getElementById(id);
function clamp01(x){ return Math.max(0, Math.min(1, x)); }
function setBar(barId, valId, frac, text) {
$(barId).style.width = (clamp01(frac) * 100).toFixed(0) + '%';
$(valId).textContent = text;
}
// motion sparkline ring buffer
const sparkCtx = $('spark').getContext('2d');
const SPARK_N = 120;
const sparkBuf = new Array(SPARK_N).fill(0);
function pushSpark(v) {
sparkBuf.push(v); if (sparkBuf.length > SPARK_N) sparkBuf.shift();
const w = sparkCtx.canvas.width, h = sparkCtx.canvas.height;
sparkCtx.clearRect(0,0,w,h);
let maxV = 40; for (const x of sparkBuf) if (x > maxV) maxV = x;
sparkCtx.strokeStyle = '#ffb840'; sparkCtx.lineWidth = 1.5; sparkCtx.beginPath();
for (let i = 0; i < sparkBuf.length; i++) {
const x = (i / (SPARK_N-1)) * w;
const y = h - (sparkBuf[i] / maxV) * (h - 3) - 1.5;
i === 0 ? sparkCtx.moveTo(x, y) : sparkCtx.lineTo(x, y);
}
sparkCtx.stroke();
}
// =====================================================================
// Honest status banner (strict, mutually exclusive)
// =====================================================================
const banner = $('banner');
function setBannerLive(source, nodeCount) {
if (source === 'esp32') {
banner.className = 'live';
banner.innerHTML = 'LIVE — real ESP32 CSI <span class="src">(source=' + source + ', ' + nodeCount + ' node' + (nodeCount === 1 ? '' : 's') + ')</span>';
} else {
// anything not esp32 = explicitly NOT real, badged
banner.className = 'sim';
banner.innerHTML = 'SIMULATED — not real <span class="src">(source=' + source + ' — start an ESP32 for live CSI)</span>';
}
}
function setBannerNoServer() {
banner.className = 'noserver';
banner.innerHTML = 'NO SERVER — start the sensing-server <span class="src">(ws://localhost:8765/ws/sensing unreachable)</span>';
}
// =====================================================================
// WebSocket — render ONLY real frames. Reconnect; never fabricate.
// =====================================================================
let ws = null, gotFrame = false;
let frameTimes = []; // for measured update rate (fps)
let lastFrame = null; // most recent real frame (render loop interpolates puck)
function connect() {
setBannerNoServer();
try { ws = new WebSocket(WS_URL); }
catch (e) { scheduleReconnect(); return; }
ws.onopen = () => { /* wait for first frame before claiming LIVE */ };
ws.onmessage = (ev) => {
let d; try { d = JSON.parse(ev.data); } catch (e) { return; }
if (!d || d.type !== 'sensing_update') return;
onFrame(d);
};
ws.onclose = () => { gotFrame = false; $('waiting').classList.add('show'); setBannerNoServer(); scheduleReconnect(); };
ws.onerror = () => { try { ws.close(); } catch (e) {} };
}
let reconnectT = null;
function scheduleReconnect() {
if (reconnectT) return;
reconnectT = setTimeout(() => { reconnectT = null; connect(); }, 1500);
}
function onFrame(d) {
gotFrame = true;
lastFrame = d;
$('waiting').classList.remove('show');
const source = d.source || 'unknown';
const nodes = Array.isArray(d.nodes) ? d.nodes : [];
setBannerLive(source, nodes.length);
// measured update rate
const now = performance.now();
frameTimes.push(now);
while (frameTimes.length && now - frameTimes[0] > 2000) frameTimes.shift();
const fps = frameTimes.length > 1 ? (frameTimes.length - 1) / ((frameTimes[frameTimes.length-1] - frameTimes[0]) / 1000) : 0;
const cls = d.classification || {};
const feat = d.features || {};
// info panel
$('m-source').textContent = source.toUpperCase();
$('m-source').className = 'v ' + (source === 'esp32' ? 'green' : 'red');
const presence = !!cls.presence;
$('m-presence').textContent = presence ? (cls.motion_level === 'present_moving' ? 'PRESENT · MOVING' : 'PRESENT') : 'CLEAR';
$('m-presence').className = 'v ' + (presence ? 'green' : 'mute');
$('m-motion').textContent = cls.motion_level || '—';
$('m-conf').textContent = (cls.confidence != null) ? cls.confidence.toFixed(2) : '—';
$('m-persons').textContent = (d.estimated_persons != null) ? d.estimated_persons : '—';
$('m-nodes').textContent = nodes.length + ' (' + nodes.map(n => n.node_id).join(', ') + ')';
$('m-tick').textContent = (d.tick != null) ? d.tick : '—';
$('m-fps').textContent = fps ? fps.toFixed(1) + ' Hz' : '—';
// feature bars (real values, scaled into 0..1 for the bar width only)
const motion = feat.motion_band_power || 0;
const breath = feat.breathing_band_power || 0;
const variance = feat.variance || 0;
const rssi = feat.mean_rssi != null ? feat.mean_rssi : -100;
setBar('bar-motion', 'v-motion', motion / 100, motion.toFixed(1));
setBar('bar-breath', 'v-breath', breath / 100, breath.toFixed(1));
setBar('bar-var', 'v-var', variance / 80, variance.toFixed(1));
// rssi: map -90..-30 dBm -> 0..1
setBar('bar-rssi', 'v-rssi', (rssi + 90) / 60, rssi.toFixed(0));
pushSpark(motion);
// node activity
const activeIds = new Set(nodes.map(n => n.node_id));
[9, 13].forEach(id => setNodeActive(id, activeIds.has(id)));
// heatmap + puck
updateHeatmap(d.signal_field);
updatePuck(d);
}
// =====================================================================
// Optional webcam ground-truth tile (reused from demos/05). Camera is
// separate from CSI sensing — labeled "ground truth when visible".
// =====================================================================
let camStream = null;
$('cam-btn').addEventListener('click', async () => {
const btn = $('cam-btn');
if (camStream) { // toggle off
camStream.getTracks().forEach(t => t.stop());
$('cam-video').style.display = 'none'; camStream = null;
btn.textContent = '▶ enable webcam (optional)';
return;
}
btn.disabled = true; btn.textContent = 'requesting camera…';
try {
camStream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' }, audio: false,
});
const v = $('cam-video'); v.srcObject = camStream; v.style.display = 'block';
btn.textContent = '■ stop webcam'; btn.disabled = false;
} catch (e) {
btn.textContent = '✗ camera unavailable'; btn.disabled = false; console.error(e);
setTimeout(() => { if (!camStream) btn.textContent = '▶ enable webcam (optional)'; }, 2000);
}
});
// =====================================================================
// Render loop — smooth the puck toward its real target; pulse rings.
// =====================================================================
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
controls.update();
if (puck.visible) {
puck.position.lerp(puckTarget, 0.18);
const pulse = 0.8 + 0.25 * Math.sin(t * 3.0);
puckRing.scale.set(pulse, pulse, pulse);
puckRing.material.opacity = 0.5 + 0.25 * Math.sin(t * 3.0);
}
// node rings breathe when active
[9,13].forEach(id => {
const g = nodeMeshes[id]; if (!g) return;
const r = g.userData.parts.ring;
const s = 1 + 0.08 * Math.sin(t * 2 + id);
r.scale.set(s, s, s);
});
composer.render();
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
// kick off
connect();
</script>
</body>
</html>
+159
View File
@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WiFlow · live WiFi-inferred pose</title>
<style>
:root{--bg:#0a0c10;--panel:#11151c;--amber:#ffb840;--green:#46e08a;--red:#ff5a5a;--mute:#7d8796;--line:#1d2430}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:#dfe6ee;font:14px/1.5 'JetBrains Mono',ui-monospace,Menlo,monospace}
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
h1{font-size:15px;margin:0;letter-spacing:1px;text-transform:uppercase;font-weight:600}
h1 span{color:var(--amber)}
#banner{margin-left:auto;padding:5px 12px;border-radius:5px;font-weight:600;font-size:12px;letter-spacing:.5px}
.live{background:rgba(70,224,138,.15);color:var(--green);border:1px solid var(--green)}
.sim{background:rgba(255,184,64,.15);color:var(--amber);border:1px solid var(--amber)}
.down{background:rgba(255,90,90,.15);color:var(--red);border:1px solid var(--red)}
main{display:flex;gap:18px;padding:18px;flex-wrap:wrap}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
canvas{background:#070a0e;border-radius:8px;display:block}
.label{font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--mute);margin-bottom:8px}
.stats{min-width:240px}
.row{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed var(--line)}
.row .k{color:var(--mute)} .row .v{color:var(--amber);font-variant-numeric:tabular-nums}
.v.green{color:var(--green)}
.note{margin-top:12px;font-size:11px;color:var(--mute);line-height:1.6;max-width:300px}
.note b{color:#dfe6ee}
</style>
</head>
<body>
<header>
<h1>WiFlow · <span>live WiFi-inferred pose</span></h1>
<div id="banner" class="down">CONNECTING…</div>
</header>
<main>
<div class="card">
<div class="label">CSI → pose (skeleton) overlaid on your laptop camera</div>
<div id="stage" style="width:420px;height:560px;border-radius:8px;overflow:hidden;background:#070a0e">
<video id="cam" autoplay muted playsinline style="position:absolute;width:2px;height:2px;opacity:0;pointer-events:none"></video>
<canvas id="cv" width="420" height="560"></canvas>
</div>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button id="camBtn" style="background:var(--amber);color:#0a0c10;border:0;border-radius:6px;padding:7px 14px;font:inherit;font-weight:600;cursor:pointer">enable laptop camera</button>
<select id="camSel" style="display:none;background:var(--panel);color:#dfe6ee;border:1px solid var(--line);border-radius:6px;padding:6px;font:inherit;max-width:220px"></select>
</div>
<div id="camStatus" style="margin-top:6px;font-size:11px;color:var(--mute)">camera: off</div>
<div class="note" style="margin-top:8px">Camera is a <b>visual reference only</b> — it is NOT fed to the model. Overlay alignment is approximate (model trained in a different camera's frame).</div>
</div>
<div class="card stats">
<div class="label">live</div>
<div class="row"><span class="k">CSI source</span><span class="v" id="src"></span></div>
<div class="row"><span class="k">nodes</span><span class="v" id="nodes"></span></div>
<div class="row"><span class="k">presence</span><span class="v" id="pres"></span></div>
<div class="row"><span class="k">motion</span><span class="v" id="motion"></span></div>
<div class="row"><span class="k">pose fps</span><span class="v" id="fps"></span></div>
<div class="note">
This skeleton is inferred <b>from WiFi CSI only</b> — no camera in the loop here. A model was
trained on paired (camera-pose, CSI) data in this room (ADR-079/180).
<br/><br/>
<b>Honest accuracy:</b> ~<b>59.5% PCK@0.10</b> on held-out data (vs a 50% mean-pose baseline →
<b>+9.4 pp real signal</b>). It captures <b>coarse</b> pose; fine detail is weak (PCK@0.05 ≈ 24%).
Same person / room / session — not validated cross-day or through-wall.
</div>
</div>
</main>
<script>
const POSE_WS = (new URLSearchParams(location.search)).get('ws') || `ws://${location.hostname||'localhost'}:8770/pose`;
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const $ = id => document.getElementById(id);
let edges = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]];
let last = null, frames = 0, t0 = performance.now();
function banner(state, txt){ const b=$('banner'); b.className=state; b.textContent=txt; }
// per-joint smoothing (EMA) so dropped/jittery CSI frames render fluidly (ADR-180 dead-reckoning, lite)
let sm = null;
function smooth(kps){
if(!sm){ sm = kps.map(p=>[p[0],p[1]]); return sm; }
const a=0.35; for(let i=0;i<kps.length;i++){ sm[i][0]+=a*(kps[i][0]-sm[i][0]); sm[i][1]+=a*(kps[i][1]-sm[i][1]); }
return sm;
}
const camEl=document.getElementById('cam');
function draw(p){
const W=cv.width, H=cv.height;
// paint the live camera frame onto the canvas (robust — no z-index/overlay tricks)
if(camEl && camEl.videoWidth>0){
ctx.save(); ctx.globalAlpha=0.9;
// cover-fit the camera frame into the canvas
const vr=camEl.videoWidth/camEl.videoHeight, cr=W/H;
let dw=W, dh=H, dx=0, dy=0;
if(vr>cr){ dh=H; dw=H*vr; dx=(W-dw)/2; } else { dw=W; dh=W/vr; dy=(H-dh)/2; }
ctx.drawImage(camEl, dx, dy, dw, dh); ctx.restore();
} else {
ctx.fillStyle='#070a0e'; ctx.fillRect(0,0,W,H);
}
if(!p || !p.kps){ return; }
const s = smooth(p.kps);
const k = s.map(([x,y])=>[x*W, y*H]);
ctx.lineWidth=5; ctx.strokeStyle=p.presence?'rgba(70,224,138,.95)':'rgba(125,135,150,.8)'; ctx.lineCap='round';
ctx.shadowColor='rgba(70,224,138,.6)'; ctx.shadowBlur=8;
for(const [a,b] of edges){ ctx.beginPath(); ctx.moveTo(k[a][0],k[a][1]); ctx.lineTo(k[b][0],k[b][1]); ctx.stroke(); }
ctx.shadowBlur=0;
for(const [x,y] of k){ ctx.beginPath(); ctx.arc(x,y,5,0,7); ctx.fillStyle=p.presence?'#ffb840':'#667'; ctx.fill(); }
}
// ---- laptop webcam (visual reference only; NOT fed to the model) ----
let camStream=null;
async function startCam(deviceId){
if(camStream){ camStream.getTracks().forEach(t=>t.stop()); }
const constraints = deviceId ? {video:{deviceId:{exact:deviceId}}} : {video:true};
const st=document.getElementById('camStatus');
try{
st.textContent='camera: requesting…';
camStream = await navigator.mediaDevices.getUserMedia(constraints);
const v=document.getElementById('cam'); v.muted=true; v.srcObject=camStream;
v.onloadedmetadata=()=>{ v.play().catch(err=>st.textContent='camera: play() blocked '+err.name); };
await v.play().catch(()=>{});
const tr=camStream.getVideoTracks()[0]; const ss=tr.getSettings();
// live readout: shows if real frames are flowing (videoWidth>0) and which device
const tick=()=>{ st.textContent = `camera: "${tr.label}" ${v.videoWidth}x${v.videoHeight} ${tr.readyState} ${v.paused?'PAUSED':'playing'}`; };
tick(); setInterval(tick, 1000);
document.getElementById('camBtn').textContent='switch camera ↻';
// populate the picker now that we have permission (labels need permission)
const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d=>d.kind==='videoinput');
const sel=document.getElementById('camSel'); sel.style.display = devs.length>1?'inline-block':'none';
sel.innerHTML = devs.map((d,i)=>`<option value="${d.deviceId}">${d.label||('camera '+(i+1))}</option>`).join('');
const cur = camStream.getVideoTracks()[0].getSettings().deviceId; if(cur) sel.value=cur;
}catch(e){
document.getElementById('camBtn').textContent = 'camera error: '+e.name+(e.name==='NotReadableError'?' (in use by Zoom/Teams?)':'');
console.error('getUserMedia', e);
}
}
document.getElementById('camBtn').addEventListener('click', ()=>startCam());
document.getElementById('camSel').addEventListener('change', e=>startCam(e.target.value));
function connect(){
banner('down','CONNECTING…');
const ws = new WebSocket(POSE_WS);
ws.onopen = ()=> banner('sim','WAITING FOR POSE…');
ws.onmessage = ev => {
const d = JSON.parse(ev.data);
if(d.type==='meta'){ edges = d.edges; return; }
if(d.type!=='pose') return;
last=d; frames++;
if(d.src==='esp32') banner('live','LIVE — WiFi-inferred pose (real ESP32 CSI)');
else banner('sim','SIMULATED CSI — not real ('+d.src+')');
$('src').textContent=d.src; $('src').className = d.src==='esp32'?'v green':'v';
$('nodes').textContent=(d.nodes||[]).join(', ')||'—';
$('pres').textContent=d.presence?'PRESENT':'—';
$('motion').textContent=(d.motion!=null?Math.round(d.motion):'—');
};
ws.onclose = ()=>{ banner('down','NO BRIDGE — start wiflow_infer.py'); setTimeout(connect,1500); };
ws.onerror = ()=> ws.close();
}
function loop(){ draw(last); const now=performance.now(); if(now-t0>1000){ $('fps').textContent=frames; frames=0; t0=now; } requestAnimationFrame(loop); }
connect(); loop();
</script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
"""Tiny threaded static server for the through-wall WiFi-CSI sensing demo.
Adapted from examples/three.js/server/serve-demo.py. Serves the
`examples/through-wall/` page so a browser can fetch index.html, then the
page connects directly to the LIVE sensing-server WebSocket at
ws://localhost:8765/ws/sensing (NOT proxied through here).
Why a threaded server (not `python -m http.server`)?
The stdlib SimpleHTTPServer is single-threaded; a browser opens several
parallel connections (HTML + the three.js CDN tags fetch in parallel),
the first eats the worker, the rest can stall. ThreadingHTTPServer fixes it.
IMPORTANT: this serves on port 8080 port 8765 is taken by the
sensing-server's WebSocket. They are two different processes.
Usage:
# 1) start the REAL sensing-server (separate terminal):
# cd v2
# cargo build -p wifi-densepose-sensing-server
# ./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005
# 2) start this static server:
python examples/through-wall/serve.py
# 3) open:
# http://localhost:8080/examples/through-wall/index.html
Override the WS endpoint with a query param, e.g.:
http://localhost:8080/examples/through-wall/index.html?ws=ws://192.168.1.20:8765/ws/sensing
"""
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
import os
import sys
PORT = int(os.environ.get("PORT", 8080))
# Serve from the repo root regardless of where this script is launched.
# This file lives at examples/through-wall/serve.py — two levels deep.
os.chdir(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
class NoCacheHandler(SimpleHTTPRequestHandler):
def end_headers(self):
# Aggressive no-cache so the browser ALWAYS fetches the latest
# index.html after edits, even on a soft refresh.
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def log_message(self, fmt, *args): # quieter logs
sys.stderr.write("[serve] " + (fmt % args) + "\n")
PAGE = "examples/through-wall/index.html"
with ThreadingHTTPServer(("127.0.0.1", PORT), NoCacheHandler) as srv:
print(f"serving {os.getcwd()} on http://127.0.0.1:{PORT}/")
print(f" open http://localhost:{PORT}/{PAGE}")
print("")
print(" The page connects to the LIVE sensing-server at")
print(" ws://localhost:8765/ws/sensing (start it first — see README.md).")
print(" Override with ?ws=ws://HOST:PORT/ws/sensing")
try:
srv.serve_forever()
except KeyboardInterrupt:
sys.exit(0)
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Rigorous A/B for WiFlow CSI->pose: is the held-out PCK real signal or split leakage?
For a dataset of {csi:[D], kps:17x[x,y,vis]} pairs, train the SAME small MLP under
several train/val SPLITS and report held-out PCK@0.10 vs the mean-pose baseline:
- chronological_80_20 : last 20% in time (val temporally ADJACENT to train -> leaks
via CSI/pose autocorrelation; this is what gave us +9.4)
- random_80_20 : shuffled (val frames interleaved with train -> MAX leak)
- blocked_gap : hold out a contiguous MIDDLE block with a time GAP buffer on
each side so val is NOT adjacent to any train frame -> the
honest, leakage-controlled test
If the model beats baseline on chronological/random but COLLAPSES to ~baseline on
blocked_gap, the apparent signal was temporal leakage, not generalizable CSI->pose.
Usage (ruvultra venv): python wiflow_ab.py --data ~/wiflow-room/dataset.jsonl
"""
import argparse, json, sys
import numpy as np, torch, torch.nn as nn
def _rec(r, X, Y, V, B):
X.append(r["csi"]); kp=r["kps"]
if kp and isinstance(kp[0], (list,tuple)): # 17 x [x,y(,vis)]
Y.append([c for k in kp for c in (k[0],k[1])]); V.append([(k[2] if len(k)>2 else 1.0) for k in kp])
else: # flat 34 (browser export, no vis)
Y.append(list(kp)); V.append([1.0]*17)
B.append(r.get("bucket"))
def load(path):
X,Y,V,B=[],[],[],[]
txt=open(path).read().strip()
if txt[:1] in "[{": # JSON (browser export: dict{samples:[]} or bare array)
d=json.loads(txt)
rows = d if isinstance(d,list) else d.get("samples", d.get("data", []))
for r in rows: _rec(r,X,Y,V,B)
else: # JSONL (python capture)
for line in txt.splitlines():
if line.strip(): _rec(json.loads(line),X,Y,V,B)
return np.array(X,np.float32), np.array(Y,np.float32), np.array(V,np.float32), B
class Net(nn.Module):
def __init__(s,din,dout):
super().__init__()
s.n=nn.Sequential(nn.Linear(din,384),nn.ReLU(),nn.Dropout(.35),
nn.Linear(384,192),nn.ReLU(),nn.Dropout(.35),
nn.Linear(192,96),nn.ReLU(),nn.Linear(96,dout),nn.Sigmoid())
def forward(s,x): return s.n(x)
def pck(pred,gt,vis,thr=0.10):
p=pred.reshape(-1,17,2); g=gt.reshape(-1,17,2)
d=np.linalg.norm(p-g,axis=2); m=vis>0.5
return float((d[m]<thr).mean()) if m.any() else 0.0
def split_idx(n, kind, B=None):
idx=np.arange(n)
if kind=="chronological_80_20":
c=int(n*.8); return idx[:c], idx[c:]
if kind=="random_80_20":
rng=np.random.default_rng(0); p=rng.permutation(n); c=int(n*.8); return p[:c], p[c:]
if kind=="blocked_gap":
# val = contiguous middle 20%; a WIDE 10% time gap each side guarantees no train
# frame is temporally adjacent to a val frame (kills frame-autocorrelation leakage).
v0=int(n*.4); v1=int(n*.6); gap=int(n*.10)
val=idx[v0:v1]; train=np.concatenate([idx[:max(0,v0-gap)], idx[min(n,v1+gap):]])
return train, val
if kind=="grouped_bucket":
# hold out ENTIRE activity buckets -> val poses/activities never seen in train.
# the strictest leakage-free test (only when bucket labels exist).
b=np.array([x if x is not None else -1 for x in B])
uniq=[u for u in sorted(set(b.tolist())) if u!=-1]
if len(uniq)<3: raise ValueError("too few buckets")
hold=set(uniq[::max(1,len(uniq)//3)][:max(1,len(uniq)//3)]) # ~1/3 of activities held out
val=idx[np.isin(b,list(hold))]; train=idx[~np.isin(b,list(hold))]
return train, val
raise ValueError(kind)
def run(X,Y,V,tr,va,epochs=250,seed=0):
torch.manual_seed(seed); np.random.seed(seed) # seed weight init + batch shuffle
dev="cuda" if torch.cuda.is_available() else "cpu"
mu,sd=X[tr].mean(0),X[tr].std(0)+1e-6
Xtr=torch.tensor((X[tr]-mu)/sd).to(dev); Ytr=torch.tensor(Y[tr]).to(dev)
Xva=torch.tensor((X[va]-mu)/sd).to(dev)
net=Net(X.shape[1],Y.shape[1]).to(dev)
opt=torch.optim.Adam(net.parameters(),lr=1e-3,weight_decay=1e-4); lf=nn.MSELoss()
best=(1e9,None)
for ep in range(epochs):
net.train(); perm=torch.randperm(len(Xtr),device=dev)
for i in range(0,len(Xtr),64):
j=perm[i:i+64]; opt.zero_grad(); loss=lf(net(Xtr[j]),Ytr[j]); loss.backward(); opt.step()
net.eval()
with torch.no_grad(): pv=net(Xva).cpu().numpy()
vl=float(((pv-Y[va])**2).mean())
if vl<best[0]: best=(vl,pv)
base=np.tile(Y[tr].mean(0),(len(va),1))
return pck(best[1],Y[va],V[va]), pck(base,Y[va],V[va])
def main():
ap=argparse.ArgumentParser(); ap.add_argument("--data",required=True)
ap.add_argument("--epochs",type=int,default=250); ap.add_argument("--seeds",type=int,default=3)
a=ap.parse_args()
X,Y,V,B=load(a.data); n=len(X)
has_buckets=any(x is not None for x in B)
print(f"[ab] {n} samples, X={X.shape}, buckets={'yes' if has_buckets else 'no'}, "
f"seeds={a.seeds}, epochs={a.epochs}\n")
print(f"{'split':<22}{'model PCK@0.10':>16}{'baseline':>11}{'delta (mean±sd)':>20} verdict")
print("-"*86)
splits=["chronological_80_20","random_80_20","blocked_gap"]+(["grouped_bucket"] if has_buckets else [])
for kind in splits:
try:
tr,va=split_idx(n,kind,B)
ms=[]; bs=[]
for s in range(a.seeds):
m,b=run(X,Y,V,tr,va,a.epochs,seed=s); ms.append(m); bs.append(b)
ms=np.array(ms)*100; bs=np.array(bs)*100; ds=ms-bs
dm,dsd=ds.mean(),ds.std()
# REAL only if the mean delta minus 1 sd still clears the 1.5pp threshold (robust to seed variance)
verdict = "REAL signal" if dm-dsd>1.5 else ("weak/uncertain" if dm>1.5 else "no signal (==baseline)")
print(f"{kind:<22}{ms.mean():>13.1f}±{ms.std():>3.1f}{bs.mean():>10.1f}%{dm:>+12.1f}±{dsd:>4.1f}pp {verdict}")
except Exception as e:
print(f"{kind:<22} skipped: {e}")
print(f"\nmean±sd over {a.seeds} seeds (weight init + batch order). blocked_gap = 10% time gap each")
print("side; grouped_bucket holds out ENTIRE activities (strictest). If only the LEAKY splits")
print("(chronological/random) beat baseline, the apparent signal is leakage, not generalizable pose.")
if __name__=="__main__": main()
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""WiFlow-style camera-supervised capture (ADR-079 / ADR-180).
Runs on a box with BOTH a camera (ground truth) and reachable live CSI:
- opens a camera, runs MediaPipe Pose -> 17 COCO keypoints (the LABEL),
- subscribes to the sensing-server /ws/sensing (the INPUT: CSI features +
20x20 signal-field),
- writes timestamp-aligned (csi -> pose) pairs to a JSONL dataset.
This is the *collect* phase of camera-supervised CSI->pose training. The camera
and the CSI nodes MUST see the same person in the same space at the same time,
or the pairs are meaningless. Honest by construction: we only emit a pair when
BOTH a confident camera pose AND a live (source=esp32) CSI frame are present in
the same ~100 ms window.
Usage (on ruvultra, with the CSI tunneled to localhost:8765):
python3 wiflow_capture.py --ws ws://localhost:8765/ws/sensing \
--cam 0 --out ~/wiflow-room/dataset.jsonl --seconds 180
"""
import argparse, asyncio, json, time, threading, sys, os
from collections import deque
import urllib.request
import cv2
import numpy as np
import mediapipe as mp
from mediapipe.tasks.python import BaseOptions
from mediapipe.tasks.python.vision import PoseLandmarker, PoseLandmarkerOptions, RunningMode
import websockets
_MODEL_URL = ("https://storage.googleapis.com/mediapipe-models/pose_landmarker/"
"pose_landmarker_lite/float16/latest/pose_landmarker_lite.task")
def ensure_model(path: str) -> str:
if not os.path.exists(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
print(f"[capture] downloading pose model -> {path}", flush=True)
urllib.request.urlretrieve(_MODEL_URL, path)
return path
# MediaPipe Pose (33 landmarks) -> 17 COCO keypoints (same mapping as
# scripts/collect-ground-truth.py, ADR-079).
COCO_FROM_MP = [0, 2, 5, 7, 8, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28]
COCO_NAMES = ["nose","l_eye","r_eye","l_ear","r_ear","l_sho","r_sho","l_elb",
"r_elb","l_wri","r_wri","l_hip","r_hip","l_knee","r_knee","l_ank","r_ank"]
# ---- shared state between the CSI (async) thread and the camera (sync) loop ----
_latest_csi = {"t": 0.0, "frame": None}
_csi_lock = threading.Lock()
_stop = threading.Event()
def csi_thread(ws_url: str):
"""Background thread: keep the most recent LIVE csi frame in _latest_csi."""
async def run():
while not _stop.is_set():
try:
async with websockets.connect(ws_url, open_timeout=8, ping_interval=20) as ws:
while not _stop.is_set():
msg = await asyncio.wait_for(ws.recv(), timeout=8)
d = json.loads(msg)
with _csi_lock:
_latest_csi["t"] = time.time()
_latest_csi["frame"] = d
except Exception as e:
print(f"[csi] reconnect ({e})", flush=True)
await asyncio.sleep(1.0)
asyncio.new_event_loop().run_until_complete(run())
def csi_vector(frame: dict):
"""Flatten a csi frame to a fixed-length input vector: features + field."""
f = frame.get("features", {}) or {}
feats = [f.get("mean_rssi", 0.0), f.get("variance", 0.0),
f.get("motion_band_power", 0.0), f.get("breathing_band_power", 0.0)]
# per-node mean_rssi/variance/motion for up to the 2 nodes (9, 13)
pernode = {nf.get("node_id"): (nf.get("features") or {}) for nf in (frame.get("node_features") or [])}
for nid in (9, 13):
nf = pernode.get(nid, {})
feats += [nf.get("mean_rssi", 0.0), nf.get("variance", 0.0), nf.get("motion_band_power", 0.0)]
field = (frame.get("signal_field", {}) or {}).get("values") or []
field = (field + [0.0] * 400)[:400]
return feats + field # 4 + 6 + 400 = 410-d
def main():
ap = argparse.ArgumentParser(description="WiFlow camera-supervised CSI<->pose capture (ADR-180).")
ap.add_argument("--ws", default="ws://localhost:8765/ws/sensing")
ap.add_argument("--cam", type=int, default=0)
ap.add_argument("--out", default=os.path.expanduser("~/wiflow-room/dataset.jsonl"))
ap.add_argument("--seconds", type=int, default=180)
ap.add_argument("--min-vis", type=float, default=0.5, help="min mean landmark visibility to accept a pose label")
ap.add_argument("--max-skew-ms", type=float, default=150, help="max csi/pose time skew to pair")
ap.add_argument("--require-esp32", action="store_true", default=True,
help="only pair when csi source==esp32 (real). Default on.")
args = ap.parse_args()
os.makedirs(os.path.dirname(args.out), exist_ok=True)
th = threading.Thread(target=csi_thread, args=(args.ws,), daemon=True)
th.start()
cap = cv2.VideoCapture(args.cam)
if not cap.isOpened():
print(f"ERROR: cannot open camera {args.cam}", file=sys.stderr); sys.exit(2)
W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 640
H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 480
model_path = ensure_model(os.path.expanduser("~/wiflow-room/pose_landmarker_lite.task"))
landmarker = PoseLandmarker.create_from_options(PoseLandmarkerOptions(
base_options=BaseOptions(model_asset_path=model_path),
running_mode=RunningMode.IMAGE, min_pose_detection_confidence=0.5))
n_pairs = 0; n_nopose = 0; n_nocsi = 0; n_skew = 0; n_sim = 0
t0 = time.time()
print(f"[capture] camera {args.cam} {W}x{H} -> {args.out} for {args.seconds}s")
print("[capture] stand in view AND in the CSI field; move/walk so poses vary. Ctrl-C to stop.")
with open(args.out, "a") as out:
try:
while time.time() - t0 < args.seconds:
ok, frame = cap.read()
if not ok:
continue
now = time.time()
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
res = landmarker.detect(mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb))
if not res.pose_landmarks:
n_nopose += 1; continue
lm = res.pose_landmarks[0]
kps = [[lm[i].x, lm[i].y, lm[i].visibility] for i in COCO_FROM_MP]
vis = float(np.mean([k[2] for k in kps]))
if vis < args.min_vis:
n_nopose += 1; continue
with _csi_lock:
ct = _latest_csi["t"]; cf = _latest_csi["frame"]
if cf is None:
n_nocsi += 1; continue
if (now - ct) * 1000.0 > args.max_skew_ms:
n_skew += 1; continue
if args.require_esp32 and cf.get("source") != "esp32":
n_sim += 1; continue
rec = {"t": now, "vis": round(vis, 3),
"kps": [[round(x, 4), round(y, 4), round(v, 3)] for x, y, v in kps],
"csi": csi_vector(cf),
"src": cf.get("source"),
"nodes": sorted(n.get("node_id") for n in cf.get("nodes", []) if n.get("node_id") is not None)}
out.write(json.dumps(rec) + "\n")
n_pairs += 1
if n_pairs % 30 == 0:
out.flush()
el = int(now - t0)
print(f"[capture] t+{el:3d}s pairs={n_pairs} (skip: nopose={n_nopose} nocsi={n_nocsi} skew={n_skew} sim={n_sim})", flush=True)
except KeyboardInterrupt:
print("\n[capture] stopped by user")
_stop.set(); cap.release()
print(f"[capture] DONE. wrote {n_pairs} paired samples to {args.out}")
print(f"[capture] skipped: no-pose={n_nopose} no-csi={n_nocsi} skew={n_skew} simulated={n_sim}")
if n_pairs == 0:
print("[capture] WARNING: 0 pairs — check camera sees you AND csi source==esp32 (live).")
if __name__ == "__main__":
main()
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Live CSI->pose inference bridge (ADR-180).
Runs on the box with the live CSI. Loads the camera-supervised model (numpy,
no torch needed), subscribes to /ws/sensing, runs a forward pass per frame, and
broadcasts the predicted 17-keypoint pose to HTML clients on ws://:8770/pose.
python wiflow_infer.py --model model/model.npz \
--in ws://localhost:8765/ws/sensing --port 8770
"""
import argparse, asyncio, json, os
import numpy as np
import websockets
# COCO skeleton edges (for the client; sent once in 'meta')
EDGES = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],
[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]]
def csi_vector(frame):
f = frame.get("features", {}) or {}
feats = [f.get("mean_rssi",0.0), f.get("variance",0.0),
f.get("motion_band_power",0.0), f.get("breathing_band_power",0.0)]
pernode = {nf.get("node_id"): (nf.get("features") or {}) for nf in (frame.get("node_features") or [])}
for nid in (9,13):
nf = pernode.get(nid,{}); feats += [nf.get("mean_rssi",0.0), nf.get("variance",0.0), nf.get("motion_band_power",0.0)]
field = (frame.get("signal_field",{}) or {}).get("values") or []
field = (field + [0.0]*400)[:400]
return np.array(feats + field, np.float32)
class Model:
def __init__(self, path):
z = np.load(path)
self.mu, self.sd = z["mu"], z["sd"]
self.W = [z["net_0_weight"], z["net_3_weight"], z["net_6_weight"], z["net_8_weight"]]
self.b = [z["net_0_bias"], z["net_3_bias"], z["net_6_bias"], z["net_8_bias"]]
def __call__(self, x):
h = (x - self.mu) / self.sd
for i in range(3):
h = np.maximum(0.0, h @ self.W[i].T + self.b[i]) # Linear+ReLU
out = 1.0/(1.0+np.exp(-(h @ self.W[3].T + self.b[3]))) # Linear+Sigmoid -> 34
return out.reshape(17,2)
CLIENTS = set()
LATEST = {"pose": None}
async def serve_client(ws):
CLIENTS.add(ws)
try:
await ws.send(json.dumps({"type":"meta","edges":EDGES}))
async for _ in ws: # client is read-only; just keep alive
pass
except Exception:
pass
finally:
CLIENTS.discard(ws)
async def infer_loop(model, in_url):
while True:
try:
async with websockets.connect(in_url, open_timeout=8, ping_interval=20) as ws:
async for msg in ws:
d = json.loads(msg)
kp = model(csi_vector(d))
cls = d.get("classification",{})
payload = {"type":"pose","src":d.get("source"),
"presence":bool(cls.get("presence")),
"motion":(d.get("features",{}) or {}).get("motion_band_power"),
"kps":[[round(float(x),4),round(float(y),4)] for x,y in kp],
"nodes":sorted(n.get("node_id") for n in d.get("nodes",[]) if n.get("node_id") is not None)}
LATEST["pose"]=payload
if CLIENTS:
dead=[]
for c in list(CLIENTS):
try: await c.send(json.dumps(payload))
except Exception: dead.append(c)
for c in dead: CLIENTS.discard(c)
except Exception as e:
print(f"[infer] reconnect ({e})", flush=True); await asyncio.sleep(1.0)
async def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default=os.path.join(os.path.dirname(__file__),"model","model.npz"))
ap.add_argument("--in", dest="in_url", default="ws://localhost:8765/ws/sensing")
ap.add_argument("--port", type=int, default=8770)
args = ap.parse_args()
model = Model(args.model)
print(f"[infer] model {args.model} loaded; serving predicted poses on ws://0.0.0.0:{args.port}/pose")
async with websockets.serve(serve_client, "0.0.0.0", args.port):
await infer_loop(model, args.in_url)
if __name__ == "__main__":
asyncio.run(main())
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Train a CSI->pose model on the camera-supervised dataset (ADR-079/180).
Input : 410-d CSI vector (4 global feats + 6 per-node + 400 signal-field).
Target : 17 COCO keypoints (x,y), normalized 0..1 from the camera (ground truth).
Reports HONEST held-out PCK@k + MPJPE on a chronological val split (the last
20% of the session never trained on), so the number is not leaked.
Usage (ruvultra venv):
python wiflow_train.py --data ~/wiflow-room/dataset.jsonl --out ~/wiflow-room/model.pt
"""
import argparse, json, math, os, sys
import numpy as np
import torch, torch.nn as nn
def load(path):
X, Y, V = [], [], []
with open(path) as f:
for line in f:
r = json.loads(line)
X.append(r["csi"]) # 410
kp = r["kps"] # 17 x [x,y,vis]
Y.append([c for k in kp for c in (k[0], k[1])]) # 34
V.append([k[2] for k in kp]) # 17 visibilities
return np.array(X, np.float32), np.array(Y, np.float32), np.array(V, np.float32)
class Net(nn.Module):
def __init__(self, din, dout):
super().__init__()
self.net = nn.Sequential(
nn.Linear(din, 512), nn.ReLU(), nn.Dropout(0.3),
nn.Linear(512, 256), nn.ReLU(), nn.Dropout(0.3),
nn.Linear(256, 128), nn.ReLU(),
nn.Linear(128, dout), nn.Sigmoid()) # coords in 0..1
def forward(self, x): return self.net(x)
def pck(pred, gt, vis, thr):
# pred/gt: [N,34] -> [N,17,2]; PCK@thr in normalized image units, visible kps only
p = pred.reshape(-1, 17, 2); g = gt.reshape(-1, 17, 2)
d = np.linalg.norm(p - g, axis=2) # [N,17]
m = vis > 0.5
return float((d[m] < thr).mean()) if m.any() else 0.0, float(d[m].mean()) if m.any() else float("nan")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--data", required=True)
ap.add_argument("--out", default=os.path.expanduser("~/wiflow-room/model.pt"))
ap.add_argument("--epochs", type=int, default=300)
ap.add_argument("--bs", type=int, default=64)
args = ap.parse_args()
X, Y, V = load(args.data)
n = len(X)
print(f"[train] {n} samples, X={X.shape} Y={Y.shape}")
if n < 200:
print("[train] too few samples"); sys.exit(2)
# chronological split (NOT shuffled) so val is a held-out time segment -> honest
cut = int(n * 0.8)
mu, sd = X[:cut].mean(0), X[:cut].std(0) + 1e-6 # standardize on train only
Xn = (X - mu) / sd
dev = "cuda" if torch.cuda.is_available() else "cpu"
Xtr = torch.tensor(Xn[:cut]).to(dev); Ytr = torch.tensor(Y[:cut]).to(dev)
Xva = torch.tensor(Xn[cut:]).to(dev); Yva = Y[cut:]; Vva = V[cut:]
# mean-pose baseline (predict the train-mean pose for everything) — the bar to beat
mean_pose = Y[:cut].mean(0)
base_pck, base_mpjpe = pck(np.tile(mean_pose, (len(Yva), 1)), Yva, Vva, 0.10)
net = Net(X.shape[1], Y.shape[1]).to(dev)
opt = torch.optim.Adam(net.parameters(), lr=1e-3, weight_decay=1e-4)
lossf = nn.MSELoss()
best = (1e9, None)
for ep in range(args.epochs):
net.train(); perm = torch.randperm(len(Xtr), device=dev)
for i in range(0, len(Xtr), args.bs):
idx = perm[i:i+args.bs]
opt.zero_grad(); out = net(Xtr[idx]); loss = lossf(out, Ytr[idx]); loss.backward(); opt.step()
if (ep + 1) % 20 == 0 or ep == args.epochs - 1:
net.eval()
with torch.no_grad(): pv = net(Xva).cpu().numpy()
p10, mpj = pck(pv, Yva, Vva, 0.10); p05, _ = pck(pv, Yva, Vva, 0.05)
vloss = float(((pv - Yva) ** 2).mean())
print(f"[train] ep{ep+1:3d} val_mse={vloss:.4f} PCK@0.10={p10*100:.1f}% PCK@0.05={p05*100:.1f}% MPJPE={mpj:.4f}")
if vloss < best[0]: best = (vloss, {"sd": net.state_dict(), "p10": p10, "p05": p05, "mpj": mpj})
torch.save({"model": best[1]["sd"], "mu": mu, "sd": sd, "din": X.shape[1]}, args.out)
print("\n==================== HONEST RESULT (held-out 20%, never trained) ====================")
print(f" MEAN-POSE BASELINE : PCK@0.10 = {base_pck*100:.1f}% MPJPE = {base_mpjpe:.4f} (the bar to beat)")
print(f" CSI->POSE MODEL : PCK@0.10 = {best[1]['p10']*100:.1f}% PCK@0.05 = {best[1]['p05']*100:.1f}% MPJPE = {best[1]['mpj']:.4f}")
delta = (best[1]['p10'] - base_pck) * 100
print(f" VERDICT: model {'BEATS' if delta>1 else 'does NOT beat'} mean-pose baseline by {delta:+.1f} pp "
f"-> {'real CSI->pose signal' if delta>1 else 'NO usable CSI->pose signal (honest negative)'}")
print(f" saved -> {args.out}")
if __name__ == "__main__":
main()
+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"
```
@@ -468,3 +468,29 @@ menu "Mock CSI (QEMU Testing)"
depends on CSI_MOCK_ENABLED
default n
endmenu
menu "Onboard LED (ADR-183)"
config LED_GAMMA_VIZ
bool "Onboard WS2812: 40 Hz gamma flicker + CSI-motion colour"
default y
help
Drive the onboard WS2812 as a GENUS-style 40 Hz gamma square wave
(12.5 ms on / 12.5 ms off, 50% duty). The ON-phase colour is live
CSI motion (edge motion_energy) mapped through the ruv-neural-viz
viridis colormap (still=purple, moving=yellow).
Disable to leave the LED off at boot — lower power, no flicker.
NOTE: a 40 Hz flicker can affect photosensitive users; disable or
shield the LED in those environments. Not a medical device.
config LED_MOTION_FULLSCALE_MILLI
int "Motion value (x1000) that saturates the colormap to yellow"
depends on LED_GAMMA_VIZ
default 250
range 1 100000
help
edge motion_energy that maps to the top (yellow) of the viridis
colormap, in milli-units (250 = 0.25). Lower = more sensitive
(reaches yellow with less motion).
endmenu
@@ -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) {
@@ -114,6 +114,19 @@ esp_err_t display_task_start(void)
/* Init touch (optional) */
esp_err_t touch_ret = display_hal_init_touch();
/* The SH8601 QSPI panel is write-only — display_hal_init_panel() above "succeeds"
* even on a bare board with no panel attached, so it cannot detect absence. The
* FT3168 touch controller is an I2C device with readback and is always present on
* the Touch-AMOLED board. If touch is absent, the panel "success" was a false-
* positive on a display-less DevKit: bail to headless so display_is_active() stays
* false and CSI upgrades to MGMT+DATA capture instead of starving at MGMT-only
* (RuView#1000). */
if (touch_ret != ESP_OK) {
ESP_LOGW(TAG, "No FT3168 touch readback — SH8601 probe was a false-positive on a "
"display-less board; running headless so CSI captures (#1000)");
return ESP_OK;
}
/* Initialize LVGL */
lv_init();
+77 -6
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,
},
};
@@ -144,6 +149,54 @@ static void wifi_init_sta(void)
}
}
#if CONFIG_LED_GAMMA_VIZ
/* Viridis colormap (60 steps), generated from ruv-neural-viz::ColorMap::viridis()
* the rUv-Neural brain-topology colormap, now no_std (ruvnet/ruv-neural#3 /
* RuView#1126). Used as the ON-phase colour of the 40 Hz gamma flicker below:
* dark-purple (still) -> teal -> green -> yellow (strong motion). */
static const uint8_t VIRIDIS_LUT[60][3] = {
{ 68, 1, 84},{ 67, 6, 88},{ 67, 12, 91},{ 66, 17, 95},{ 66, 23, 99},
{ 65, 28,103},{ 64, 34,106},{ 64, 39,110},{ 63, 45,114},{ 63, 50,118},
{ 62, 56,121},{ 61, 61,125},{ 61, 67,129},{ 60, 72,132},{ 59, 78,136},
{ 59, 83,139},{ 57, 87,139},{ 55, 92,139},{ 53, 96,139},{ 52,100,139},
{ 50,104,139},{ 48,109,139},{ 46,113,139},{ 44,117,140},{ 43,122,140},
{ 41,126,140},{ 39,130,140},{ 37,134,140},{ 36,139,140},{ 34,143,140},
{ 35,147,139},{ 39,151,136},{ 43,154,133},{ 47,158,130},{ 52,162,127},
{ 56,166,124},{ 60,170,121},{ 64,173,119},{ 68,177,116},{ 72,181,113},
{ 76,185,110},{ 81,189,107},{ 85,192,104},{ 89,196,102},{ 93,200, 99},
{102,203, 95},{113,205, 91},{124,207, 87},{134,209, 82},{145,211, 78},
{156,213, 74},{167,215, 70},{178,217, 66},{188,219, 62},{199,221, 58},
{210,223, 54},{221,225, 49},{231,227, 45},{242,229, 41},{253,231, 37},
};
static led_strip_handle_t s_viz_led;
/* motion_energy that saturates the colormap to yellow (CONFIG, milli-units). */
#define LED_MOTION_FULLSCALE ((float)CONFIG_LED_MOTION_FULLSCALE_MILLI / 1000.0f)
/* GENUS-style 40 Hz gamma flicker: full on/off square wave, 50% duty (toggled
* every 12.5 ms 40 Hz). The ON colour is live CSI motion (edge motion_energy)
* mapped through the ruv-neural-viz viridis LUT still=purple, moving=yellow.
* So the LED is a real 40 Hz gamma stimulus whose hue tracks sensed motion. */
static void led_gamma_40hz_cb(void *arg)
{
static bool on = false;
on = !on;
if (on) {
edge_vitals_pkt_t v;
float m = edge_get_vitals(&v) ? v.motion_energy : 0.0f;
float norm = m / LED_MOTION_FULLSCALE;
if (norm < 0.0f) norm = 0.0f;
if (norm > 1.0f) norm = 1.0f;
int idx = (int)(norm * 59.0f + 0.5f);
const uint8_t *c = VIRIDIS_LUT[idx];
led_strip_set_pixel(s_viz_led, 0, c[0], c[1], c[2]); /* R,G,B (driver maps to GRB) */
} else {
led_strip_set_pixel(s_viz_led, 0, 0, 0, 0); /* off phase */
}
led_strip_refresh(s_viz_led);
}
#endif /* CONFIG_LED_GAMMA_VIZ */
void app_main(void)
{
/* Initialize NVS */
@@ -173,15 +226,16 @@ void app_main(void)
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
target_name, app_desc->version, g_nvs_config.node_id);
/* Turn off onboard WS2812 LED.
* S3 dev boards put the LED on GPIO 38; C6 dev boards on GPIO 8.
* On C6, GPIO 38 doesn't exist (only 0-30) gate the init by target. */
/* Onboard WS2812. C6 wires the LED to GPIO 8; S3 to GPIO 38 (DevKitC-1 v1.0)
* or GPIO 48 (DevKitC-1 v1.1 / N16R8 see #962). On S3 we drive 48 (the
* common module). On C6, GPIO 38/48 don't exist (only 0-30) gate by target.
* Behaviour is set by CONFIG_LED_GAMMA_VIZ (ADR-183): on = 40 Hz gamma flicker
* coloured by CSI motion; off = clear the LED at boot. */
#if defined(CONFIG_IDF_TARGET_ESP32C6)
const int led_gpio = 8;
#else
const int led_gpio = 38;
const int led_gpio = 48;
#endif
led_strip_handle_t led_strip;
led_strip_config_t strip_config = {
.strip_gpio_num = led_gpio,
.max_leds = 1,
@@ -193,9 +247,26 @@ void app_main(void)
.resolution_hz = 10 * 1000 * 1000, // 10MHz
.flags.with_dma = false,
};
#if CONFIG_LED_GAMMA_VIZ
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &s_viz_led) == ESP_OK) {
const esp_timer_create_args_t viz_args = {
.callback = &led_gamma_40hz_cb,
.name = "led_gamma_40hz",
};
esp_timer_handle_t viz_timer;
if (esp_timer_create(&viz_args, &viz_timer) == ESP_OK) {
esp_timer_start_periodic(viz_timer, 12500); // 12.5 ms toggle → 40 Hz square wave
ESP_LOGI(TAG, "Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO %d", led_gpio);
}
}
#else
/* Viz disabled — clear the onboard LED at boot and release the RMT channel. */
led_strip_handle_t led_strip;
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip) == ESP_OK) {
led_strip_clear(led_strip);
led_strip_del(led_strip);
}
#endif /* CONFIG_LED_GAMMA_VIZ */
/* ADR-110 P4: 802.15.4 mesh time-sync (C6 only).
* Initialized BEFORE WiFi so it's available even when WiFi STA can't
@@ -0,0 +1,37 @@
/**
* @file mmwave_detect.h
* @brief Pure (host-testable) mmWave frame-validation predicates for probe-time
* sensor detection. No ESP-IDF deps safe to #include in a host unit test.
*
* Detection must validate a *full* frame, never a bare header byte/pattern: a
* floating UART with no sensor reads line noise that can contain header-looking
* bytes, which the old loose checks mistook for a real sensor (#1107 MR60,
* #1135 LD2410). These predicates are the validate-before-trust gate.
*/
#ifndef MMWAVE_DETECT_H
#define MMWAVE_DETECT_H
#include <stdint.h>
#include <stdbool.h>
/**
* True iff buf[i..] begins a *validated* LD2410 report frame within [0,len):
* F4 F3 F2 F1 | len(LE,2) | data[len] | F8 F7 F6 F5
* Requires the head magic, a sane intra-frame length, AND the matching tail at
* head+6+len. Pure noise that merely contains 0xF4F3F2F1 fails the tail check.
*/
static inline bool mmwave_ld2410_valid_at(const uint8_t *buf, int i, int len)
{
if (i < 0 || i + 5 >= len) return false;
if (!(buf[i] == 0xF4 && buf[i+1] == 0xF3 && buf[i+2] == 0xF2 && buf[i+3] == 0xF1))
return false;
uint16_t flen = (uint16_t)buf[i+4] | ((uint16_t)buf[i+5] << 8);
/* Real LD2410 report frames are small (basic=13, engineering=35). */
if (flen < 1 || flen > 64) return false;
int tail = i + 6 + (int)flen;
if (tail + 3 >= len) return false;
return buf[tail] == 0xF8 && buf[tail+1] == 0xF7
&& buf[tail+2] == 0xF6 && buf[tail+3] == 0xF5;
}
#endif /* MMWAVE_DETECT_H */
+22 -10
View File
@@ -26,6 +26,7 @@
*/
#include "mmwave_sensor.h"
#include "mmwave_detect.h"
#include <string.h>
#include <math.h>
@@ -387,14 +388,26 @@ static mmwave_type_t probe_at_baud(uint32_t baud)
if (len <= 0) continue;
for (int i = 0; i < len; i++) {
/* MR60BHA2: SOF = 0x01, followed by valid-looking frame_id bytes */
if (buf[i] == MR60_SOF && baud == MMWAVE_MR60_BAUD) {
mr60_sof_seen++;
/* MR60BHA2: require a *validated* 8-byte header — SOF (0x01) + a valid
* header checksum (over bytes 0..6) + a known frame type (0x0A__ or
* 0x0F09) NOT a bare 0x01 byte. A floating UART1 with no sensor reads
* noise full of 0x01s, which the old `buf[i] == MR60_SOF` check mistook
* for a real sensor (false "Detected MR60BHA2", #1107). */
if (buf[i] == MR60_SOF && baud == MMWAVE_MR60_BAUD && i + 7 < len) {
const uint8_t *h = &buf[i];
if (mr60_calc_checksum(h, 7) == h[7]) {
uint16_t type = ((uint16_t)h[5] << 8) | h[6];
if ((type >> 8) == 0x0A || type == 0x0F09) {
mr60_sof_seen++;
}
}
}
/* LD2410: 4-byte header 0xF4F3F2F1 */
if (i + 3 < len && buf[i] == 0xF4 && buf[i+1] == 0xF3
&& buf[i+2] == 0xF2 && buf[i+3] == 0xF1
&& baud == MMWAVE_LD2410_BAUD) {
/* LD2410: require a *full validated* report frame, not just the
* 4-byte head. A floating UART1 at 256000 baud can emit the head
* pattern 0xF4F3F2F1 from line noise (#1135 bug #2). The shared
* predicate (host-unit-tested in mmwave_detect.h) demands a sane
* intra-frame length AND the matching tail 0xF8F7F6F5. */
if (baud == MMWAVE_LD2410_BAUD && mmwave_ld2410_valid_at(buf, i, len)) {
ld2410_header_seen++;
}
}
@@ -403,9 +416,8 @@ static mmwave_type_t probe_at_baud(uint32_t baud)
if (ld2410_header_seen >= 2) return MMWAVE_TYPE_LD2410;
}
if (mr60_sof_seen > 0) return MMWAVE_TYPE_MR60BHA2;
if (ld2410_header_seen > 0) return MMWAVE_TYPE_LD2410;
/* No weak single-hit fallback: line noise can produce a stray match, so a real
* sensor must clear the 3 (MR60) / 2 (LD2410) validated-frame thresholds. */
return MMWAVE_TYPE_NONE;
}
+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);
+48 -5
View File
@@ -26,9 +26,16 @@ static struct sockaddr_in s_dest_addr;
* rapid-fire CSI callbacks can exhaust the pbuf pool and crash the device.
*/
static int64_t s_backoff_until_us = 0; /* esp_timer timestamp to resume */
#define ENOMEM_COOLDOWN_MS 100 /* suppress sends for 100 ms */
#define ENOMEM_COOLDOWN_MS 100 /* base backoff; doubles per streak */
#define ENOMEM_COOLDOWN_MAX_MS 2000 /* cap on the exponential backoff */
#define ENOMEM_LOG_INTERVAL 50 /* log every Nth suppressed send */
static uint32_t s_enomem_suppressed = 0;
/* Consecutive ENOMEM episodes without an intervening successful send. A fixed
* 100 ms backoff is too short to drain sustained lwIP/WiFi buffer pressure
* (#1135 bug #1: tier-2 + concurrent TX keeps the node stuck), so the backoff
* grows 1002004002000 ms per streak and resets on the first send that
* succeeds. */
static uint32_t s_enomem_streak = 0;
static int sender_init_internal(const char *ip, uint16_t port)
{
@@ -93,16 +100,52 @@ int stream_sender_send(const uint8_t *data, size_t len)
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
if (sent < 0) {
if (errno == ENOMEM) {
/* Start backoff to let lwIP reclaim buffers */
s_backoff_until_us = esp_timer_get_time() +
(int64_t)ENOMEM_COOLDOWN_MS * 1000;
ESP_LOGW(TAG, "sendto ENOMEM — backing off for %d ms", ENOMEM_COOLDOWN_MS);
/* Exponential backoff: double the cooldown each consecutive ENOMEM
* (capped) so sustained buffer pressure actually drains instead of
* the node re-failing every 100 ms forever (#1135 bug #1). */
uint32_t shift = s_enomem_streak < 5 ? s_enomem_streak : 5;
uint32_t cooldown = ENOMEM_COOLDOWN_MS << shift;
if (cooldown > ENOMEM_COOLDOWN_MAX_MS) cooldown = ENOMEM_COOLDOWN_MAX_MS;
s_enomem_streak++;
s_backoff_until_us = esp_timer_get_time() + (int64_t)cooldown * 1000;
ESP_LOGW(TAG, "sendto ENOMEM — backing off for %lu ms (streak %lu)",
(unsigned long)cooldown, (unsigned long)s_enomem_streak);
} else {
ESP_LOGW(TAG, "sendto failed: errno %d", errno);
}
return -1;
}
/* A send got through — buffer pressure cleared; reset the backoff streak. */
s_enomem_streak = 0;
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;
}
@@ -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
+15 -4
View File
@@ -44,9 +44,9 @@ FUZZ_DURATION ?= 30
FUZZ_JOBS ?= 1
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
test_vitals run_vitals host_tests
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect
# --- ADR-110 encoding unit tests ---
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
@@ -69,8 +69,19 @@ test_vitals: test_vitals_count_presence.c $(MAIN_DIR)/edge_processing.h
run_vitals: test_vitals
./test_vitals
host_tests: run_adr110 run_vitals
@echo "Host tests passed (ADR-110 + vitals #998/#996)"
# --- mmWave LD2410 detection predicate (#1135 bug #2) ---
# Host-side, no libFuzzer. Proves a floating-UART head pattern (0xF4F3F2F1)
# without a valid frame length+tail is REJECTED, so a phantom LD2410 is never
# detected on a node with no sensor wired. Tests the real predicate the
# firmware uses (../main/mmwave_detect.h) — test and firmware can't disagree.
test_mmwave_detect: test_mmwave_detect.c $(MAIN_DIR)/mmwave_detect.h
cc -std=c99 -Wall -Wextra -I$(MAIN_DIR) -o $@ $<
run_mmwave_detect: test_mmwave_detect
./test_mmwave_detect
host_tests: run_adr110 run_vitals run_mmwave_detect
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135)"
# --- Serialize fuzzer ---
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
@@ -0,0 +1,80 @@
/**
* @file test_mmwave_detect.c
* @brief Host-side unit tests for the LD2410 frame-validation predicate (#1135).
*
* Proves the phantom-detection fix: a floating UART can emit the 4-byte head
* 0xF4F3F2F1, but the predicate rejects it unless a sane length + matching tail
* 0xF8F7F6F5 are also present. Tests the REAL predicate from mmwave_detect.h
* (the same code the firmware's probe_at_baud calls).
*
* cc -std=c99 -Wall -I../main -o test_mmwave_detect test_mmwave_detect.c && ./test_mmwave_detect
*
* Exits 0 on all-pass; prints the failing case otherwise.
*/
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "mmwave_detect.h"
static int failures = 0;
#define CHECK(cond, msg) do { \
if (!(cond)) { printf("FAIL: %s\n", msg); failures++; } \
else { printf("ok: %s\n", msg); } \
} while (0)
/* Build a valid LD2410 report frame: F4F3F2F1 | len(LE) | data[len] | F8F7F6F5 */
static int make_frame(uint8_t *out, uint16_t dlen)
{
int n = 0;
out[n++] = 0xF4; out[n++] = 0xF3; out[n++] = 0xF2; out[n++] = 0xF1;
out[n++] = (uint8_t)(dlen & 0xFF); out[n++] = (uint8_t)(dlen >> 8);
for (uint16_t k = 0; k < dlen; k++) out[n++] = (uint8_t)(0xAA ^ k);
out[n++] = 0xF8; out[n++] = 0xF7; out[n++] = 0xF6; out[n++] = 0xF5;
return n;
}
int main(void)
{
uint8_t buf[256];
/* 1. A real basic-report frame (data len 13) validates. */
int n = make_frame(buf, 13);
CHECK(mmwave_ld2410_valid_at(buf, 0, n), "valid basic frame (len=13) accepted");
/* 2. A real engineering-report frame (data len 35) validates. */
n = make_frame(buf, 35);
CHECK(mmwave_ld2410_valid_at(buf, 0, n), "valid engineering frame (len=35) accepted");
/* 3. Head magic present but NO valid tail — the #1135 phantom case. */
memset(buf, 0x00, sizeof(buf));
buf[0]=0xF4; buf[1]=0xF3; buf[2]=0xF2; buf[3]=0xF1; buf[4]=13; buf[5]=0;
/* data present but tail is zeros, not F8F7F6F5 */
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "head magic without valid tail REJECTED (#1135)");
/* 4. Head magic with insane length is rejected. */
memset(buf, 0xFF, sizeof(buf));
buf[0]=0xF4; buf[1]=0xF3; buf[2]=0xF2; buf[3]=0xF1; buf[4]=0xFF; buf[5]=0xFF; /* len=65535 */
CHECK(!mmwave_ld2410_valid_at(buf, 0, 200), "head magic with oversized length REJECTED");
/* 5. Pure noise (no head) is rejected. */
for (int k = 0; k < 64; k++) buf[k] = (uint8_t)(0x5A + k);
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "non-header noise REJECTED");
/* 6. Truncated frame (tail would run past the buffer) is rejected. */
n = make_frame(buf, 13);
CHECK(!mmwave_ld2410_valid_at(buf, 0, n - 2), "truncated frame (tail past buffer) REJECTED");
/* 7. Valid frame at a non-zero offset still validates. */
memset(buf, 0x00, sizeof(buf));
n = make_frame(buf + 7, 13);
CHECK(mmwave_ld2410_valid_at(buf, 7, 7 + n), "valid frame at offset 7 accepted");
/* 8. Repeated head bytes without a frame (worst-case noise) rejected. */
for (int k = 0; k + 3 < 64; k += 4) {
buf[k]=0xF4; buf[k+1]=0xF3; buf[k+2]=0xF2; buf[k+3]=0xF1;
}
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "repeated bare head bytes REJECTED");
printf("\n%s (%d failures)\n", failures ? "FAILED" : "ALL PASS", failures);
return failures ? 1 : 0;
}
+1 -1
View File
@@ -1 +1 @@
0.7.0
0.8.4
+18
View File
@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(npx ruview*)",
"mcp__ruview__*"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)"
]
},
"mcpServers": {
"ruview": {
"command": "npx",
"args": ["-y", "@ruvnet/ruview", "mcp", "start"]
}
}
}
@@ -0,0 +1,29 @@
---
name: calibrate-room
description: Run the ADR-151 per-room calibration pipeline — baseline → enroll → extract → train → a bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly).
---
# 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
`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"}`
2. **enroll** — record the occupant(s) doing the target activities.
`ruview_calibrate {step: "enroll"}`
3. **train-room** — train the bank of small specialists from baseline + enrollment.
`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)
## 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.
@@ -0,0 +1,30 @@
---
name: onboard
description: Zero-to-sensing path picker for RuView (WiFi-DensePose) — pick docker-demo, repo-build, or live-esp32 and run the next concrete step.
---
# onboard
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
camera-grade.
## Pick a path
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`.
Use to see what it looks like.
2. **repo-build** — for developers. `cd v2 && cargo test --workspace --no-default-features`
(1,031+ tests pass), then `cargo run -p wifi-densepose-cli -- --help`.
3. **live-esp32** — a real install. Flash a node (`provision-node` skill), point it at
the sensing-server, then `calibrate-room`. This is the only path that senses a real room.
## Then
- Live sensing → go to **provision-node**, then **calibrate-room**.
- Evaluating a model/claim → go to **verify** and run `ruview_claim_check` on any
report before you quote a number.
@@ -0,0 +1,49 @@
---
name: provision-node
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, ESP-IDF Windows-subprocess flow, NVS/WiFi/channel/MAC-filter overrides.
---
# provision-node
Bring an ESP32 sensing node online.
## 1. Pick a firmware variant
- **s3-8mb** (display build) — ESP32-S3 N16R8 / 16MB; AMOLED optional. The display-detect
fix (#1000) means a *bare* board still captures CSI (MGMT+DATA).
- **s3-4mb** (no-display) — ESP32-S3 4MB; dual-OTA, display disabled.
- **c6** — ESP32-C6 + Seeed MR60BHA2 (60 GHz mmWave + WiFi CSI). The mmwave probe
requires a validated MR60 header (#1107) so an empty UART never false-detects.
Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56 rev v0.2).
## 2. Flash
ESP-IDF v5.4 on Windows is **subprocess-only** (Git Bash/MSYS is unsupported — strip
`MSYSTEM*` env vars). Offsets for the S3 image:
```
esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
0x0 bootloader.bin 0x8000 partition-table.bin \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
```
(`ruview_node_flash` returns the exact pinned command rather than running an
unattended flash.)
## 3. Provision
```
python firmware/esp32-csi-node/provision.py --port <PORT> \
--ssid "<SSID>" --password "<secret>" --target-ip <server-ip> --target-port 5005
# optional ADR-060 overrides:
python firmware/esp32-csi-node/provision.py --port <PORT> --channel 6 --filter-mac AA:BB:CC:DD:EE:FF
```
Never echo or commit the WiFi password.
## 4. Confirm CSI is flowing
`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.
@@ -0,0 +1,33 @@
---
name: train-pose
description: Train/evaluate WiFi pose models honestly — camera-supervised (MediaPipe + CSI) and camera-free (WiFlow), always checked against the mean-pose baseline before any PCK is quoted.
---
# train-pose
Build a CSI→pose model without overstating it. The project has a **retracted 92.9%/100%**
history — the discipline below exists so it never recurs.
## The non-negotiable: mean-pose baseline first
A pose model that always predicts the dataset's *mean pose* already scores ~50% PCK.
**Quote PCK only as a delta over that baseline**, on a held-out split with no subject
or temporal leakage. Example honest result (ADR-181):
> Held-out PCK@20 **59.5%** vs a 50% mean-pose baseline = **+9.4 pp real signal** — MEASURED.
## Paths
- **camera-supervised** (ADR-079) — MediaPipe Pose labels the camera frame; paired CSI
trains the net. Train/infer in one camera frame so the skeleton aligns.
- **camera-free** (WiFlow, ADR-152) — no camera at inference; geometry-conditioned.
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
(honest about what's executing).
## Before you publish a number
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.
4. If it's a benchmark vs SOTA, tag MEASURED-EQUIVALENT only with the reproducer.
@@ -0,0 +1,42 @@
---
name: verify
description: Prove a RuView result is real — run the deterministic SHA-256 proof and the witness bundle (ADR-028), and lint any claim for MEASURED-vs-CLAIMED honesty.
---
# verify
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
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.
## Witness bundle (ADR-028)
For a release-grade attestation:
```
bash scripts/generate-witness-bundle.sh
cd dist/witness-bundle-ADR028-*/ && bash VERIFY.sh # must be 7/7 PASS
```
Contains the Rust test log, the proof + expected hash, firmware SHA-256 manifest, and
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
before quoting accuracy. It flags:
- untagged accuracy numbers (must be MEASURED / CLAIMED / SYNTHETIC),
- MEASURED claims with no reproducer cited,
- the retracted "100%/perfect accuracy" framing.
## Firmware-specific
A firmware fix is **not** "hardware-validated" without a captured boot log on real
silicon (e.g. the `v0.8.1-esp32` rev-v0.2 validation: `running headless so CSI
captures (#1000)` + `CSI filter upgraded to MGMT+DATA` + a no-false-detect mmwave
probe). Do not merge or release on a build-passes signal alone.
+39
View File
@@ -0,0 +1,39 @@
{
"schema": 1,
"generator": "metaharness 0.1.15 + ADR-182 hardening",
"template": "vertical:ruview",
"name": "@ruvnet/ruview",
"vars": {
"name": "@ruvnet/ruview",
"description": "RuView WiFi-sensing operator agent harness",
"host": "claude-code"
},
"hosts": [
"claude-code"
],
"files": {
".claude/settings.json": "b0ea971383716f18b89db73010b8f0ea0f1b16bdec4cd1068245772ba1c27bdd",
".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": "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
View File
@@ -0,0 +1 @@
380d4bf928fd7c5fa753d11a30c1e24e2ea471caca57b439f765a9d864cef472 manifest.json
+34
View File
@@ -0,0 +1,34 @@
# RuView harness — agent operating notes
You are operating **RuView** (WiFi-DensePose), a camera-free WiFi-CSI sensing system.
## The one rule: prove everything
This project was accused of AI-slop; the fix is hard discipline. Before you quote ANY
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
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
(`node_flash`) require explicit confirmation and are Windows/ESP-IDF gated.
## Skills
`onboard` · `provision-node` · `calibrate-room` · `train-pose` · `verify`
(`npx @ruvnet/ruview skill <name>`).
## Don'ts
- Don't present WiFi sensing as camera-grade.
- Don't echo or commit WiFi passwords / secrets.
- Don't merge or release firmware without a real boot log.
- Don't report a PCK without its mean-pose baseline.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ruvnet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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