Compare commits

...

97 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
165 changed files with 25972 additions and 550 deletions
+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']
})
})
+41 -5
View File
@@ -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
+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."
+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
+13
View File
@@ -8,10 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` prepared for its first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha``5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload remains gated on the ADR-117 v2 witness hash. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client).
- **CI (ADR-184): `pip-release.yml` keeps token-based PyPI authentication until Trusted Publishing is registered.** An OIDC migration was attempted in `cc153e8b5` and reverted in `82d5c7339` so releases would not enter a half-configured state. Production currently uses `PYPI_API_TOKEN`; TestPyPI uses its independent `TESTPYPI_API_TOKEN`. The workflow now builds and publishes `wifi-densepose` and `ruview` together, verifies their versions and dependency pin match, and fails closed before production upload when `expected_features_v2.sha256` is absent.
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
### Deprecated
- **`archive/v1` (the original pure-Python implementation) formally deprecated (ADR-187)** — commits `1fb5397dd`, `b1417fb6e`; refs #509, #1125. Added `archive/v1/DEPRECATED.md` (a loud tombstone) and a `> ⚠️ DEPRECATED` notice atop `archive/v1/README.md`, both pointing at the maintained `v2/` workspace and the `wifi-densepose 2.x` / `ruview` pip wheel (ADR-117). Records the honest fact behind #509: `archive/v1`'s `DensePoseHead` is **architecture-only** — random `kaiming_normal_` init with **zero committed checkpoints** under `archive/v1/` (MEASURED by Glob over `**/*.{pth,onnx,safetensors,pt,ckpt,bin}`). The ADR-028 deterministic proof `archive/v1/data/proof/verify.py` stays live and is explicitly out of scope. The same effort added a **"Model weights: what's real, what's not" three-tier table** to `README.md` + `docs/user-guide.md`, separating real-and-validated checkpoints (presence 82.3% held-out temporal-triplet, MM-Fi pose 82.69% torso-PCK@20, `count_v1`) from the real-but-weak on-device `pose_v1` (PCK@20 = 3.0%, runtime `confidence=0` stub, below the ADR-079 ≥35% target) from the architecture-only `archive/v1` head — and caveated every live single-ESP32 17-keypoint advertisement accordingly. Docs/labeling only; no code or model behavior changed.
### Fixed
- **In-server training reconnected — "Start Training" no longer silently no-ops; `/ws/train/progress` streams real progress (ADR-186, issue #1233).** The dashboard's Start Training button POSTed a config, got `success:true`, and nothing happened: `/api/v1/train/start` was a stub that flipped a status string and logged one line, and `/ws/train/progress` 404'd. The full pure-Rust trainer in `training_api.rs` (loads recorded CSI, gradient-descent, exports a `.rvf`) already existed but was **orphaned** — never declared as a module (no `mod training_api;`), so it wasn't compiled at all. Fix (`wifi-densepose-sensing-server`): declared the module, reconciled `AppStateInner` (replaced the `training_status`/`training_config` stub fields with a shared `TrainingState` status handle + cooperative cancel flag + a `training_progress_tx` broadcast), deleted the stub handlers, and merged the real `training_api::routes()` (so `/api/v1/train/{start,stop,status,pretrain,lora}` and `/ws/train/progress` resolve under the existing `/api/v1/*` bearer gate). The training core was decoupled from the ~60-field server state so it is unit-testable. **P5 honesty guarantee:** with `RUVIEW_DISABLE_SERVER_TRAINING` set, start returns a structured `{enabled:false, cli:"wifi-densepose train-room"}` HTTP 409 — never a silent success — and the dashboard disables the Start buttons with a CLI tooltip (enablement is surfaced on `/api/v1/train/status`). Pinned by 8 new tests incl. a **live-socket** test that completes a genuine 101 WebSocket handshake and receives a real progress frame after a POST start, a full POST→poll-status→`.rvf`-exists round-trip, a path-traversal rejection, cancellation, and the disabled-409 path. `cargo test -p wifi-densepose-sensing-server -p wifi-densepose-train --no-default-features` — 0 failed.
- **FastAPI health/metrics endpoints event-loop starvation.** Calling `psutil.cpu_percent(interval=1)` blocked the single-threaded async event loop for 1.0 second on every health check or metrics collection tick, stalling all incoming requests and WebSocket operations. Fixed by changing `cpu_percent` to use non-blocking `interval=None` and offloading all blocking OS metrics gathering to background thread pools via `asyncio.to_thread`. Verified event loop responsiveness via concurrency regression tests.
- **EngineBridge now honors `WDP_GUARD_INTERVAL_US`/`WDP_SOFT_GUARD_US`/`WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US`** (#1309, PR #1312, @erichkusuki). The governed trust path previously built its multistatic fuser from a hardcoded `MultistaticConfig::default()` (60 ms guard), so multi-node deployments with WiFi/ESP-NOW time sync (10150 ms drift) failed every governed cycle regardless of configuration — while the startup log claimed the override took effect. New `StreamingEngine::set_multistatic_config()`; `EngineBridge::new()` takes an `Option<MultistaticConfig>` threaded from the same env-derived config as `AppState.multistatic_fuser`. Hardware-verified on a live 2-node ESP32-S3 setup (90 s window, 0 fusion errors; previously every cycle failed).
- **`/api/v1/stream/pose` WebSocket reachable with `RUVIEW_API_TOKEN` set + dashboard bearer-token field** (#1310, PR #1313, @erichkusuki). Browsers cannot attach an `Authorization` header to a WS upgrade, so the Live Demo pose stream always failed when auth was on; the path is now on a narrow exact-match exemption list (mirrors `/ws/sensing`), with a regression test pinning that the exemption doesn't leak to other `/api/v1/*` paths. The QuickSettings panel gains an "API Access" field storing the bearer token in `localStorage`; the token is applied at `api.service.js` module load so the very first request carries it.
- **Display-less DevKitC-1 boards: `sdkconfig.defaults.devkitc` build overlay** (#1308, PR #1311, @erichkusuki). The ADR-045 runtime display probe false-positives on stock ESP32-S3-DevKitC-1 (floating QSPI pins), which silently skipped the RuView#893 MGMT+DATA CSI upgrade and collapsed CSI yield to 0 pps. The overlay compiles display support out (`has_display` constant-false). Also fixes stale `espressif/idf:v5.2` README references to v5.4 (source requires `esp_driver_uart`, IDF ≥5.3). Hardware-verified on 2× DevKitC-1-N16R8 (0 → 4045 pps).
- **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn``ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `<jobsDir>/<id>.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20.
- **Empty-room field-model calibration collected nothing on real HT40 nodes — raw 128-wide frames rejected by the 56-tone model (follow-up to the deadlock fix below).** Once the status-gate deadlock was fixed, `maybe_feed_calibration` reached `feed_calibration`, but a real ESP32 HT40 node streams 128-wide amplitude vectors while the single-link `FieldModel` is the canonical 56-tone grid — so `LinkStats::update` returned `DimensionMismatch`, `feed_calibration` bubbled the error, and `maybe_feed_calibration` swallowed it at `debug` level. Net effect: `frame_count` stayed pinned at 0 on live hardware even though presence/motion/vitals (which read the global history) worked fine. Fixed by resampling each frame onto the model's canonical 56-tone grid via `HardwareNormalizer::resample_to_canonical` before feeding — the same length-only canonicalization the multistatic fusion path uses (#1170). Pinned by `field_bridge::maybe_feed_calibration_resamples_wide_frames_and_accumulates` (128-wide frame → Collecting + count 1; fails on old code). Verified live on the ESP32-S3 deployment.
- **Empty-room field-model calibration could never start — `/api/v1/calibration/*` was a dead endpoint (frame count pinned at 0).** `POST /calibration/start` creates the `FieldModel` in `Uncalibrated`, but the per-frame server feed `field_bridge::maybe_feed_calibration` only fed observations while the model was **already** `Collecting` — and the *only* thing that sets `Collecting` is `feed_calibration` itself (on its first fed frame). The two gates deadlocked: nothing ever fed the first frame, so `calibration_frame_count` stayed 0, `status` never left `Uncalibrated`, and the SVD room eigenstructure (eigenvalue-based person counting / localization) could never calibrate — observed live as `{"status":"Uncalibrated","frame_count":0}` never advancing on a streaming ESP32 node. Fixed the guard to feed while `Uncalibrated | Collecting` so the first frame flips the model to `Collecting` and the count advances. Also made `calibration_stop` return a structured `{success:false, frame_count, frames_needed}` (with a new `FieldModel::min_calibration_frames()` accessor) instead of an opaque 500 when finalized before enough empty-room frames accumulate. Pinned by `field_bridge::maybe_feed_calibration_advances_uncalibrated_to_collecting` (asserts `Uncalibrated → Collecting` + count 0 → 1 → 2; fails on old code). Presence/motion/vitals were unaffected — they use the separate auto rolling baseline, not the field model.
- **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed.
- **`csi_fps_ema` reported the CSI frame rate 40840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`.
- **`stream_sender` ENOMEM backoff starved low-rate control packets under a weak uplink (#1183, follow-up to #1135/#1159).** The global `s_backoff_until_us` gate (triggered by the 50 Hz CSI flood at weak RSSI) also suppressed the ≤48 B, ≤1 Hz `feature_state` / mesh `HEALTH` / sync packets that contribute negligible buffer pressure, so telemetry failed essentially every cycle. Added `stream_sender_send_priority()` — bypasses the backoff gate, reports ENOMEM quietly, and never extends/resets the global streak — and routed `feature_state`, HEALTH/anomaly (`rv_mesh_send`), and sync packets through it. Also fixed the misleading `"HEALTH sent"` log that printed unconditionally even when `rv_mesh_send` returned `ESP_FAIL` (now prints `sent`/`FAILED` from the actual return). Firmware builds clean (ESP-IDF v5.4).
@@ -30,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`homecore-recorder` security review (ADR-132 surfaces) — two real bounding fixes; SQL-injection & NaN-index dimensions confirmed clean with evidence.** Beyond-SOTA review of the HA-compat state recorder (DB persistence + history + ruvector semantic search), the crux being its DB-backed SQL-injection surface. **Findings + fixes:** (1) **Memory-DoS — unbounded `get_state_history`.** The history query carried no `LIMIT`, so a wide `[since, until]` window over a high-frequency entity (a per-second sensor ≈ 86k rows/day) would load an unbounded row set into a single in-memory `Vec`. Added a hard `LIMIT MAX_HISTORY_ROWS` (1,000,000 — generous enough never to truncate a realistic history graph, bounded enough to cap the worst case); the sibling search paths were already `k`-bounded. (2) **Disk-DoS / documented-but-missing `purge`.** The README + HA-compat table advertised `Recorder::purge(older_than)` as a capability, but **no such method existed** — i.e. no retention path at all → unbounded disk growth. Implemented a **transactional** `purge` that deletes `states` + `events` strictly **older than** the cutoff (**exclusive** boundary — idempotent, no off-by-one; a row at the cutoff instant is kept) and **garbage-collects** orphaned `state_attributes` blobs (a dedup-shared blob is dropped only once its last referencing state is gone); all three deletes run in one transaction so a mid-purge failure rolls back cleanly (no states-deleted-but-events-kept corruption). **Confirmed clean with evidence:** SQL injection — **every** query in `db.rs` uses bound `?` parameters (no `format!`/string-concat of user data into SQL); the lone `format!` builds the LIKE *pattern*, which is itself bound as a parameter with `ESCAPE '\\'` and metacharacter escaping. Pinned: a state value `'; DROP TABLE states; --` is stored/queried **literally** (table survives), and a `%`/`_` in a search query matches **literally**, not as a wildcard. NaN-index poisoning (the calibration/vitals/geo class) — **structurally impossible** here: embeddings are SHA-256 → `i32``f32` (an `i32` cast to `f32` is always finite, never NaN/Inf), with an all-zero-digest norm guard; probed empty-index search, empty-string query, and `k=0` — all return `Ok(0)`, **no panic**. Fail-closed write path — a removal event yields `Ok(None)`, semantic-index failure is logged not propagated (best-effort, never blocks the durable SQLite write), and `EntityId` parsing failures fall back rather than panic. **6 new pinning tests** (SQL-injection literal-storage, LIKE-metacharacter literalness, history `LIMIT`, purge exclusive-boundary, purge attribute-GC-keeps-shared, purge old-events): `homecore-recorder` **19 → 25** (`--no-default-features`) / **25 → 31** (`--features ruvector`), 0 failed; the purge-boundary test is a true pin (fails deleting 2 rows under an inclusive cutoff, passes deleting 1 under the exclusive cutoff). Behaviour otherwise unchanged; Python deterministic proof unchanged (recorder is off the signal proof path).
### Added
- **ADR-184 / ADR-185 / ADR-186 / ADR-187 decision records added under `docs/adr/`** (indexed in the ADR README, count corrected to 193 — commit `cca5bd811`). **ADR-184** (PyPI Trusted Publishing, completes ADR-117) — Proposed; its CI migration has landed but is pending pypi.org registration (see Changed). **ADR-185** (P6 Python bindings for AETHER/MERIDIAN/MAT) and **ADR-186** (training progress API, refs #1233) are **Proposed only** — decision records for work not yet implemented on this branch. **ADR-187** (archive/v1 deprecation + model-weights honest labeling) — Accepted and implemented (see Deprecated).
- **ADR-263/264/265: deep review of the RuView npm surface (`@ruvnet/ruview`, `@ruvnet/rvagent`, `@ruv/ruview-cli`) with optimization strategies recorded as ADRs.** ADR-263 reviews the published `@ruvnet/ruview@0.1.0` harness: fail-open `claim-check` on empty input (HIGH), `spawnSync` head-of-line blocking of the MCP stdio server during long `verify`/`calibrate` runs (HIGH), optionalDependencies tripling the cold `npx` install for a code path that never uses them (MEASURED, `npm i` in a clean prefix: 4 packages / 620 kB / 71 files default vs 1 package / 172 kB / 22 files with `--omit=optional`), 1 MiB `maxBuffer` truncation risk, `python -c` port-interpolation surface in `node_monitor`, hardcoded MCP server version, duplicated skill payload — optimizations O1O8. ADR-264 reviews `@ruvnet/rvagent@0.1.0` + the private CLI **against the published registry tarball**: `exports.require` → nonexistent `dist/index.cjs` (HIGH, every CJS consumer breaks), 44 dead source-map files = 62,698 B of the 188 kB unpacked payload pointing at unshipped `../src` (MEASURED), stdio-only server described as "dual-transport" (CLAIMED capability), mixed dot/underscore tool naming, double Zod validation + hand-duplicated advertised schemas, 2-fd leak per training job, unbounded request body in the unwired HTTP scaffold, dead `detectCogBinary` candidate list, `ruview` bin-name collision — optimizations O1O9. ADR-265 adds the cross-cutting distribution layer: an `npm-packages.yml` CI matrix (tests + pack-content/size gate + tarball-install smoke test — none of the three packages currently has any CI, and `ci.yml` pins Node 18 against `engines >= 20`), publish-from-CI-only with `npm publish --provenance`, version single-sourcing from package.json, bin/namespace ownership (the `ruview` bin belongs to `@ruvnet/ruview`), and claim-check enforcement on package READMEs/descriptions. Docs only — no runtime code changed; the findings are the work orders for the follow-up PRs.
- **ADR-131 §11–§12: HOMECORE-UI wired to a real backend — single-origin BFF gateway + production front-end (no mock in prod).** Implements the §11 wiring decision so the dashboard stops rendering fabricated data. **Front-end (DONE + verified under Node):** `api.js` rewritten so every data accessor is async and calls the §11.2 gateway routes; the in-browser mock is demoted to a **dev-only fixture** reachable only via `?demo=1`/`HOMECORE_UI_DEMO` (§2.2); all ten panels now `await` and render a **typed empty/error state** on upstream failure (no mock fallback in production) — 3 panels converted by hand, 7 via a parallel agent swarm. **New `homecore-server` BFF gateway (`src/gateway.rs`, compile-pending — no Rust toolchain in the authoring env):** promotes `homecore-server` to the single origin (§2.1); adds `/api/homecore/*` + `/api/cal/*` merged into `build_app`, with `reqwest` + CLI/env flags (`--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`). Real handlers: calibration **reverse-proxy** (W2), `GET /api/homecore/rooms` with the §11.3 **RoomState adapter** (`breathing``breathing_bpm`, `heartbeat``heart_bpm`, `None``null` preserving not-trained-vs-withheld, injected `anomaly.threshold`/`room_id`), **COG supervisor** over `/var/lib/cognitum/apps/` (W4), and **appliance metrics** from `/proc` + TCP service probes (W6); SEED-device/appliance routes (seeds/federation/witness/privacy/settings/automations/events-history/hailo/tokens — W3/W5) return a typed `503 upstream_unavailable` and the UI shows error states. **Tests:** front-end **5 files green** — import-graph, boot, render-smoke (22), interaction (3), and a **new prod-errors suite (13)** that runs with demo OFF + gateway unreachable and proves every panel renders an error state, never mock, never throws (it caught + fixed a real unhandled-rejection in the events automation builder). **Gateway compiled, tested, and run on Rust 1.89:** `cargo test -p homecore-server --no-default-features` = **12/12 pass** (6 gateway + 6 UI mount); the binary was **run live**`GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes, unauth → `401`, `cogs``[]` (no apps dir), SEED-tier → typed `503`, and against a mock calibration upstream the `/api/cal/*` proxy passes through (`200`) and `GET /api/homecore/rooms` adapts `RoomState` to the UI shape (`breathing``breathing_bpm`, `heartbeat:null``heart_bpm:null`, injected `anomaly.threshold`/`room_id`). **Live testing caught + fixed a real bug** — a double-`v1` segment in the `/api/cal/*` proxy URL. **Remaining (intrinsic, not an env limit):** W3/W5/W6-Hailo/federation depend on services/hardware **not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, Hailo stat source), so they return honest `503`s rather than fabricate data; W1/W2/W4/W6-appliance are functional now. ADR-131 §10/§12.1 updated with per-wave status.
- **ADR-131: HOMECORE-UI — the complete operational dashboard for the two-tier Cognitum stack, served by `homecore-server` at `/homecore`.** A zero-dependency, no-build-step vanilla TS/JS + CSS frontend (the `rufield-viewer` "Axum + vanilla-JS" pattern) that extends the Cognitum Appliance shell as a first-class nav section (Framework | Guide | Cog Store | **HOMECORE** | Status). **Complete, not a scaffold** (per the ADR's revised §2/§7): all **10 panels** ship fully built and rendered — §4.1 System Dashboard (v0 Appliance health strip + SEED fleet grid + ESP32 summary + COG status row + event-bus sparkline), §4.2 SEED Detail (vector store / witness chain / 5 onboard sensors / reflex rules / cognitive-fragility / ingest packet-type), §4.3 SEED Fleet Map (Appliance→SEED→ESP32 hierarchy, ESP-NOW mesh, cross-SEED fusion badges, ADR-105 federation), §4.4 Entity & State Browser (domain-grouped, **live WebSocket `subscribe_events` patching — never polls**, first-class provenance badges, keyword filter, context-causality slide-over), §4.5 RoomState/Sensing (mixture-of-specialists), §4.6 COG Management + App Registry, §4.7 Calibration Wizard (5-step baseline→enroll→train→verify), §4.8 Event Bus + Automation builder, §4.9 Witness/Audit log (two-tier SHA-256 + Ed25519 timeline, privacy-mode banner, pagination, export), §4.10 Settings. **Design system is the exact production Cognitum palette** (`tokens.css` carries `--cyan #4ecdc4``--r 10px` verbatim, §3.1) so there is no visual seam with the Cog Store (§3.3 invariant). **§6 UX invariants enforced in code and pinned by tests:** tier-origin provenance is always-visible (never collapsed); `stale`/`vetoed` flags and the kNN fragility score are prominent (amber/red tint + banners, never grey-on-grey); a `null` specialist renders "Not trained / calibrate to enable" **visually distinct from** veto-`withheld` (rendered as explicitly withheld, never zero) **distinct from** an error; all IDs/hashes/endpoints/payloads use `--mono`; Hailo-sourced COGs (`arch: hailo10`) are visually distinguished from CPU-only (`arch: arm`). **Wiring:** `homecore-server` gains a `--ui-dir`/`HOMECORE_UI_DIR` flag and mounts the assets via `tower-http` `ServeDir` at `/homecore` alongside the unchanged HA-compat `/api` surface (new testable `build_app()`), with **5 Rust integration tests** (`#[cfg(test)] mod ui_tests`, `tower::oneshot`) asserting index / design tokens / all-10-panels are served, the API coexists, and an empty `--ui-dir` disables the mount. **JS test + benchmark suite (`ui/`, runs under plain `node`, no npm install): 24 checks / 0 failed** — an import/export graph verifier (15 modules consistent), a DOM-shim render-smoke that *executes every panel* (21 checks: ui helpers + mock contracts + all 10 panels render without throwing), and an interaction suite (3 checks: live WS state-patch, ws.js handshake/parse, calibration backend contract). **Benchmark:** total bundle **136.8 KB uncompressed across 18 files — ~37× smaller than HA's ~5 MB Lit bundle** (the ADR-126 §1.1 foil), slowest panel **1.5 ms/cold-render**. **Honest scope (§7.1):** the live HOMECORE REST API (`/api/config|states|services`) and the WebSocket `subscribe_events` feed are driven for real; panels whose backing service is **not** in this binary (SEED HTTPS API, calibration ADR-151, ADR-105 federation) render against a **contract-conformant mock layer flagged with a DEMO banner** and swap to live the moment those endpoints land — no mock data is ever presented as real. **Not verified in this environment:** the Rust crate was edited and the integration tests written but **not compiled/run here** (no Rust toolchain present); `cargo test -p homecore-server` + `cargo build` must be run on a Rust host before merge.
+2
View File
@@ -93,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)
+28 -7
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
+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

@@ -83,7 +83,7 @@ This ADR covers Phase 1 (TV box as aggregator) and Phase 2 (custom WiFi firmware
|---------|--------|-------------|--------------|--------|
| Broadcom BCM43455 | brcmfmac | **Proven** (Nexmon CSI) | Yes | Low — patches exist |
| Realtek RTL8822CS | rtw88 | **Moderate** — driver is open-source, CSI hooks need adding | Yes (patched) | Medium |
| MediaTek MT7661 | mt76 | **Unknown** — MediaTek has released CSI tools for some chips | Yes | Medium-High |
| MediaTek MT7661 | mt76 | **Unverified** — no supported public CSI capture API was found in upstream `mt76` or public MediaTek SDK material | Yes | Research only |
2. **CSI extraction architecture** (Linux kernel driver modification):
@@ -0,0 +1,551 @@
# ADR-184: Complete ADR-117 via PyPI Trusted Publishing (OIDC) + real v2.0.0 / ruview publish
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-07-21 |
| **Deciders** | ruv |
| **Codename** | **PHOENIX-LANDING** — the PIP-PHOENIX wheel that never actually took off |
| **Relates to** | [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX modernization — this ADR completes it), [ADR-028](ADR-028-esp32-capability-audit.md) (witness chain), [ADR-115](ADR-115-home-assistant-integration.md) (HA/Matter sibling), [ADR-168](ADR-168-benchmark-proof.md) (measured-not-claimed house style) |
| **Tracking issue** | [#785](https://github.com/ruvnet/RuView/issues/785) (ADR-117, still OPEN) |
---
## 1. Context
ADR-117 (PIP-PHOENIX) designed the v2.0.0 rewrite of the pip `wifi-densepose`
package as a PyO3 + maturin compiled wheel over the Rust core, plus a `ruview`
sibling package, replacing the 11.5-month-stale pure-Python `1.1.0` line. The
code landed on `main` (the `python/` workspace: `Cargo.toml`, `src/bindings/*.rs`,
the `wifi_densepose/` Python package, `tests/`, `bench/`). The tombstone shipped.
**But the release itself is broken and the design doc's own P5 intent was never met.**
This ADR is a **gap analysis and remediation plan**, not a new feature. Every fact
below was verified against PyPI and GitHub Actions on 2026-07-21; none are projected.
### 1.1 What is actually live on PyPI (measured)
`pip index versions wifi-densepose` returns:
```
wifi-densepose (1.99.0)
Available versions: 1.99.0, 1.2.0, 1.1.0, 1.0.0
```
- `1.99.0` — the tombstone wheel **is genuinely live**. `import wifi_densepose`
raises `ImportError` pointing users to 2.0+. This part of ADR-117 §7.2 shipped.
- `2.0.0a1` — appears in PyPI's release history as a **pre-release** (hidden from
the default `pip index` view, surfaced with `--pre`). It is still an **alpha**.
- `2.0.0` (stable) — **does not exist.** ADR-117's headline deliverable
(`pip install wifi-densepose==2.0.0`) is not installable.
`pip index versions ruview` returns:
```
ERROR: No matching distribution found for ruview
```
The `ruview` sibling package **was never published.** Commit `b71d243b4`
(*"feat(adr-117): publish wifi-densepose 2.0.0a1 + ruview 2.0.0a1 to PyPI"*) claims
a publish that did not happen for that package — a real **claimed-vs-measured gap**
of exactly the kind [ADR-168](ADR-168-benchmark-proof.md) and the project's
"prove everything" posture exist to catch.
### 1.2 Why the release pipeline was stuck (measured; interim-fixed — see §1.4)
`gh run list --workflow pip-release` shows the last **4** runs all
`conclusion=failure` (most recent `2026-05-24T16:34`). The full failure log for
run `26366735779` (job *"Publish v1.99 tombstone"* → step *"Publish to PyPI"*)
shows two things:
1. The publish step uses `pypa/gh-action-pypi-publish` with a `password`
(API-token) input and fails:
```
403 Forbidden — Invalid or non-existent authentication information.
```
i.e. the `PYPI_API_TOKEN` GitHub secret is stale / expired / revoked.
2. The action's own log warns:
```
Warning: the workflow was run with 'attestations: true' ... but an explicit
password was also set, disabling Trusted Publishing.
```
The workflow at `.github/workflows/pip-release.yml` wires `password:
${{ secrets.PYPI_API_TOKEN }}` into **four** publish steps (lines 249, 258, 282,
291) and declares only `permissions: contents: read` (line 4950). So it is using a
rotatable, leak-able, expire-able API token in exactly the place ADR-117 §5.4 / §5.5
and the issue #785 P5 row explicitly called for **OIDC Trusted Publishing** ("cp310
… abi3-py310, OIDC"; ADR-117 §5.5 line 547: *"PyPI publish via Trusted Publisher
(OIDC, no API token in secrets)"*). **The implementation drifted from its own
design doc.**
### 1.3 Why the package is still alpha (measured)
`python/pyproject.toml` pins `version = "2.0.0a1"` (line 13) and
`Development Status :: 3 - Alpha` (line 26). Issue #785's closing criteria
(§"Done") require `wifi-densepose==2.0.0` (**not** alpha) published, plus all 10
acceptance criteria in §11. None of those can be true today given §1.1–§1.2.
**Why this matters:** ADR-117 is the sole Python entry point for the whole RuView
ecosystem (per its §2 "PyPI org presence check"). A stale token silently blocking
every release means the entire "plug-and-play Python entry point for the pip +
Jupyter customer base" thesis (issue #785 "Strategic alignment") is stalled behind a
one-line credential problem — and a commit message claims otherwise.
### 1.4 Interim fix applied (2026-07-21) — credential unblocked, migration still pending
**As of 2026-07-21T22:57:29Z the stale-credential symptom is fixed at the credential
layer.** The maintainer fetched a valid `PYPI_TOKEN` from GCP Secret Manager (project
`cognitum-20260110`) and ran `gh secret set PYPI_API_TOKEN` to replace the
revoked/expired value. Authentication was confirmed non-destructively via a
`twine upload --skip-existing` re-upload of the existing `1.99.0` tombstone artifacts,
which returned a benign 400/skip response (not the previous `403 Forbidden`) — proving
the new token authenticates correctly.
This means **token-based publishing works again today** — the `403` root cause
described in §1.2 no longer reproduces. It does **not**, however, close this ADR:
- A **manually-rotated token still expires, leaks, and can be revoked over time** — it
re-introduces exactly the silent-failure mode that blocked the last 4 runs. It is a
stopgap at the same layer as the §3.2 fallback, not the durable fix.
- The OIDC **Trusted Publishing migration (§3, P1) remains the decision** — a
credential PyPI mints per-run with no secret to rotate is the only fix that removes
the recurring-expiry class of failure.
- The other three gaps are **untouched** by this rotation: `wifi-densepose` is still
`2.0.0a1` (not stable `2.0.0`), and `ruview` is still unpublished.
**Why/How to apply:** read §1.2's "root cause" as *diagnosed and temporarily
mitigated*, not *still broken*. A reviewer re-running the §7.5 check today may now see
a green token-based run — that is expected and does not satisfy this ADR, which is
Accepted only when §6's criteria pass **and** the workflow no longer carries a static
token (§7.4).
---
## 2. Current state — evidence
| Artifact | Value | Source |
|---|---|---|
| Latest stable `wifi-densepose` on PyPI | **1.99.0** (tombstone) | `pip index versions wifi-densepose` |
| `wifi-densepose==2.0.0` stable | **absent** | `pip index versions` (not listed) |
| `wifi-densepose==2.0.0a1` pre-release | present (alpha) | PyPI release history (`--pre`) |
| `ruview` on PyPI | **No matching distribution found** | `pip index versions ruview` |
| `pip-release.yml` last 4 runs | all `failure` | `gh run list --workflow pip-release` |
| Most recent failed run | `2026-05-24T16:34` | `gh run list` |
| Failing step | Publish v1.99 tombstone → Publish to PyPI | run `26366735779` log |
| Failure code | `403 Forbidden — Invalid or non-existent authentication information` | run `26366735779` log |
| Root cause | `PYPI_API_TOKEN` stale/revoked; explicit password disables Trusted Publishing | run `26366735779` log warning |
| `password:` uses in workflow | 4 (lines 249, 258, 282, 291) | `.github/workflows/pip-release.yml` |
| Workflow permissions | `contents: read` only (no `id-token: write`) | `pip-release.yml:4950` |
| pyproject version | `2.0.0a1` | `python/pyproject.toml:13` |
| pyproject dev status | `3 - Alpha` | `python/pyproject.toml:26` |
| Issue #785 | **OPEN** | GitHub |
**Why/How to apply:** treat this table as the falsifiable baseline. A reviewer who
re-runs each `Source` command must reproduce each `Value`, or this ADR is wrong and
should be revised before any remediation is attempted.
---
## 3. Decision
Complete ADR-117 by closing four gaps, in order:
1. **Migrate `pip-release.yml` to PyPI Trusted Publishing (OIDC)** — as the durable
end-state, drop all four `password: ${{ secrets.PYPI_API_TOKEN }}` inputs, grant
`id-token: write` to the publish jobs, and add `environment: pypi`. This removes
the rotatable/expire-able credential and realigns with ADR-117 §5.5's stated OIDC
intent. **This is gated behind sub-phase P1b** (§5): the switch is inert — and in
fact 403-breaking — until the manual pypi.org registration (§3.1) exists, so the
OIDC change must land *together* with that registration. Until then, token auth
(the freshly-rotated `PYPI_API_TOKEN`, §1.4) is the correct active path and is
what the `RuView#786-pypi-token-auth` fix-marker guard enforces. An OIDC migration
was attempted (`cc153e8b5`) and reverted (`82d5c7339`) for exactly this reason.
2. **Promote `wifi-densepose` from `2.0.0a1` to stable `2.0.0`** in
`python/pyproject.toml` (version + `Development Status :: 5 - Production/Stable`)
and record the promotion in `CHANGELOG.md`.
3. **Actually publish `ruview==2.0.0`** — the sibling package that commit
`b71d243b4` claimed but never shipped — and verify it with `pip index versions`.
4. **Adopt issue #785 §11's 10 acceptance criteria verbatim as this ADR's own
acceptance criteria** (§6 below), and only flip ADR-117 → Accepted and close
#785 once every one passes against the real index — proven, not claimed.
### 3.1 Mandatory human prerequisite (cannot be automated)
**Trusted Publishing requires a one-time manual step on `pypi.org` that no CLI, API,
or agent can perform** — PyPI restricts Trusted Publisher configuration to the
project owner via the web UI for security reasons. Before P1's workflow change can
succeed, a human with owner rights on both PyPI projects must:
1. Log in to `pypi.org`.
2. For **`wifi-densepose`**: Project → *Publishing* → *Add a new pending/trusted
publisher* → GitHub, with:
- Owner: `ruvnet`
- Repository: `RuView`
- Workflow filename: `pip-release.yml`
- Environment: `pypi`
3. Repeat the identical step for the **`ruview`** project. Because `ruview` is not
yet on PyPI, register it as a **pending publisher** (PyPI supports configuring a
trusted publisher for a project name before its first release — the first OIDC
publish then creates the project).
**Why/How to apply:** the workflow change in P1 is inert until this is done — the
publish step will fail with a "no trusted publisher configured" error rather than a
403. Land P1 and this manual step together; do not tag a release expecting OIDC to
work until a human confirms both entries exist. Treat this section as a blocking
checklist item on the release-day runbook, not a footnote.
### 3.2 Fallback path (if the owner declines Trusted Publishing)
If the maintainer prefers not to adopt OIDC yet, the code-side remediation is a
**token regeneration**, not a redesign:
- Generate a fresh PyPI API token (scoped to the `wifi-densepose` and `ruview`
projects) and store it in GCP Secret Manager (project `cognitum-20260110`, where
the project's tokens live), then `gh secret set PYPI_API_TOKEN` from it, following
the existing runbook referenced in the workflow header (`docs/integrations/pypi-release.md`).
- Keep the current `password:`-based workflow unchanged.
**Why/How to apply:** this path clears the 403 and unblocks releases immediately,
but it re-introduces the exact failure mode this ADR is trying to eliminate — a
credential that silently expires and blocks the whole Python entry point again. Use
it only as a stopgap; the Trusted Publishing migration (P1) is the durable fix and
should remain the default recommendation.
---
## 4. Detailed design — workflow migration
The change to `.github/workflows/pip-release.yml` is small and surgical. It does
**not** touch the build matrix (`build-wheels`, `build-sdist`, `build-tombstone`
jobs are unchanged — the 403 is a publish-credential problem, not a build problem).
### 4.1 Grant OIDC token permission on the publish jobs
The `gh-action-pypi-publish` action mints its OIDC token from the job's
`id-token: write` permission. The current top-level `permissions: contents: read`
must be extended on the two publish jobs (`publish-v2`, `publish-tombstone`) — plus
the future `publish-ruview` job:
```yaml
publish-v2:
name: Publish v2 wheels
needs: [build-wheels, build-sdist]
permissions:
id-token: write # ← added: mint the OIDC token for PyPI
contents: read
environment: pypi # ← added: binds to the PyPI trusted-publisher entry
```
### 4.2 Drop the `password:` inputs
Every publish step loses its `password:` line. Trusted Publishing needs no secret —
the action exchanges the job's OIDC token for a short-lived PyPI upload token
automatically:
```yaml
# BEFORE (current — fails with 403 when the token is stale)
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }} # ← remove
packages-dir: dist
# AFTER (Trusted Publishing — no secret, activates once the pypi.org entry exists)
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist
```
The TestPyPI dry-run steps keep `repository-url: https://test.pypi.org/legacy/` and
likewise drop `password:` — a matching trusted-publisher entry must be registered on
`test.pypi.org` if the dry-run path is to be used (otherwise gate the dry-run behind
the fallback token or remove it).
**Why/How to apply:** the header comment block (lines 1623) that documents the
`PYPI_API_TOKEN` / GCP-Secret-Manager runbook must be rewritten to document the
Trusted Publishing setup instead, so the next maintainer does not re-add a token
"to fix" a future failure and silently re-disable OIDC.
### 4.3 Add the `publish-ruview` job
`ruview` is published by a new job mirroring `publish-v2` (same `id-token: write` +
`environment: pypi`, no `password:`), gated on a `ruview`-scoped build. Because the
package has never shipped, its first successful OIDC publish creates the PyPI
project against the pending trusted-publisher entry from §3.1.
---
## 5. Phase ledger
```
P1 ──► P1b ──► P2 ──► P3 ──► P4
token OIDC version real close
unblock (gated) promote publish #785
```
### P1 — Credential unblock (token auth, active)
- [x] Rotate `PYPI_API_TOKEN` to a validated token (§1.4, `gh secret set`, verified
`2026-07-21T22:57:29Z` via `twine upload --skip-existing`). Token-based publishing
works today.
- [x] Keep `password: ${{ secrets.PYPI_API_TOKEN }}` as the active auth path,
satisfying the `RuView#786-pypi-token-auth` fix-marker guard.
- [ ] Rewrite the `pip-release.yml` header comment block so the next maintainer
knows OIDC is the intended P1b end-state (not a token to keep re-rotating forever).
> Note: an OIDC migration was attempted (`cc153e8b5`) and **reverted** (`82d5c7339`)
> because it tripped the fix-marker guard before the pypi.org registration existed.
> The OIDC work is therefore tracked as P1b below, not P1. See the Status note.
**Status 2026-07-21 — DESIGNED then REVERTED (token auth is the ACTIVE path):**
The OIDC migration was implemented (commit `cc153e8b5` — `id-token: write` +
`environment: pypi` on both publish jobs, all four `PYPI_API_TOKEN` password
inputs removed) but then **reverted** (commit `82d5c7339`) after it tripped the
pre-existing `RuView#786-pypi-token-auth` fix-marker guard
(`scripts/fix-markers.json`). That guard `require`s
`password: ${{ secrets.PYPI_API_TOKEN }}` and `forbid`s `id-token: write`
precisely because a half-activated OIDC path (id-token permission present, but no
Trusted Publisher yet registered on pypi.org) leaves publishing **403-broken**
rather than working — it correctly predicted this exact failure. The revert was
verified locally against the real checker (`python scripts/check_fix_markers.py` →
all 25 markers pass, exit 0) before pushing.
**Active path today:** token-based auth via the freshly-rotated `PYPI_API_TOKEN`
(§1.4). The current `pip-release.yml` (HEAD `82d5c7339`) carries
`password: ${{ secrets.PYPI_API_TOKEN }}` at four publish steps plus a TODO
comment marking the OIDC follow-up. The OIDC switch is therefore **not** done — it
moves to sub-phase P1b below.
**Why this revert was correct (measured, not claimed):** OIDC is the better
long-term design and matches ADR-117's original §5.5 P5 intent — but implementing
it *before* the manual pypi.org registration exists would have shipped a workflow
that looks migrated yet 403s on the next real publish. The fix-marker caught a
well-intentioned improvement that wasn't the honest, currently-working state, and
it was reverted rather than overridden. That is the same "measured not claimed"
discipline (per [ADR-168](ADR-168-benchmark-proof.md)) this entire ADR exists to
enforce — applied here to our own change.
### P1b — Switch to OIDC Trusted Publishing (gated follow-up)
- [ ] **(human, manual, pypi.org — BLOCKING)** Complete the §3.1 Trusted Publisher
registration for BOTH `wifi-densepose` and `ruview` (owner=ruvnet, repo=RuView,
workflow=pip-release.yml, environment=pypi). P1b must not start until this exists.
- [ ] Re-apply the `cc153e8b5` change (add `id-token: write` + `environment: pypi`,
drop the four `password:` inputs) as its own follow-up commit.
- [ ] Update the `RuView#786-pypi-token-auth` fix-marker in `scripts/fix-markers.json`
in the *same* commit — invert it to `require: id-token: write` / `forbid:
password: ${{ secrets.PYPI_API_TOKEN }}` — so the guard tracks the new intended
state instead of blocking it (referencing the TODO comment now in pip-release.yml).
- [ ] Confirm a green OIDC publish before removing the token, per §3.2's
keep-both-paths recommendation (OIDC first, token fallback until OIDC is proven).
- [ ] No capability gap: publishing must keep working across the P1→P1b transition.
### P2 — Version promotion + changelog
- [ ] `python/pyproject.toml`: `version = "2.0.0"` (drop the `a1` suffix).
- [ ] `python/pyproject.toml`: `Development Status :: 5 - Production/Stable`.
- [ ] `CHANGELOG.md`: `[Unreleased]` entry — "wifi-densepose 2.0.0 promoted from
alpha; ruview 2.0.0 first stable publish; pip-release migrated to Trusted Publishing".
- [ ] Confirm the `ruview` package's own version metadata is set to `2.0.0`.
### P3 — Real publish + verification
- [ ] Cut tag `v2.0.0-pip` (per the workflow's `v*-pip` trigger) → OIDC publish of
the `wifi-densepose` wheel matrix.
- [ ] Publish `ruview==2.0.0` via the new `publish-ruview` job.
- [ ] Run every command in §7 against the **real** PyPI index and capture output.
- [ ] Generate + commit `expected_features_v2.sha256` (issue #785 §11 criterion 10),
resolving ADR-117 §11.3 / the workflow header's Q3 note.
### P4 — Close issue #785
- [ ] All 10 acceptance criteria (§6) pass against the real index.
- [ ] Flip ADR-117 §Status → **Accepted**.
- [ ] Flip this ADR (ADR-184) §Status → **Accepted**.
- [ ] Close issue #785.
**Why/How to apply:** the phases are strictly ordered — P3 cannot succeed until both
P1 (working credential path) and the §3.1 human step are done, and P4 must not be
marked complete on the strength of a commit message (the failure mode this ADR
exists to correct). Nothing in this ledger is checked; this is a Proposed plan.
---
## 6. Acceptance criteria (verbatim from issue #785 §11)
A reviewer must be able to:
1. `pip install --pre wifi-densepose==2.0.0a1` from PyPI test index → wheel installs
without compile step on Linux/macOS/Windows
2. `python -c "import wifi_densepose; print(wifi_densepose.__version__, wifi_densepose.__rust_version__)"`
→ both versions print
3. `python -c "from wifi_densepose import CsiFrame; ..."` → core type round-trips
through PyO3
4. `python -c "from wifi_densepose import vitals; vitals.detect_hr(...)"` → 4-stage
pipeline runs on a sample CSI buffer
5. `pip install wifi-densepose[client]; python -c "import wifi_densepose.client; ..."`
→ WS client connects to a running sensing-server
6. `pytest python/tests/` → ≥30 tests pass (smoke + binding round-trips)
7. `maturin build --release --strip` → wheel under 5 MB per platform (ADR §5.4 budget)
8. `wifi-densepose==1.99.0` is the latest 1.x; `import wifi_densepose` raises
`ImportError` with migration URL
9. `wifi-densepose==1.0.0` is yanked from PyPI; `1.1.0` is un-yanked with deprecation
notice (90-day window)
10. Witness `expected_features_v2.sha256` generated in CI, committed alongside the
existing `archive/v1/data/proof/`, re-verifiable from Python via
`wifi_densepose.verify_witness(...)`
**Note (amendment to criterion 1):** issue #785 §11 was written when `2.0.0a1` was
the target. This ADR promotes to stable `2.0.0`, so criterion 1 is read as
`pip install wifi-densepose==2.0.0` (no `--pre`) against the production index. The
`--pre`/`a1` wording is preserved verbatim above per the transcription requirement;
the stable form is what P3/P4 must actually satisfy. This ADR additionally requires
`ruview==2.0.0` to be installable (the sibling package from commit `b71d243b4`),
which #785 §11 did not enumerate but the issue "Done" section implies.
---
## 7. How to verify (prove, don't claim)
Exact commands a reviewer runs to prove — not assume — each gap is closed. Every one
produces falsifiable output; capture it in the PR that flips ADR-117 to Accepted.
### 7.1 Both packages live and stable
```bash
# wifi-densepose 2.0.0 (stable, NOT alpha) must appear
pip index versions wifi-densepose
# expect: "wifi-densepose (2.0.0)" and 2.0.0 in the available list
# ruview 2.0.0 must now exist (currently: "No matching distribution found")
pip index versions ruview
# expect: "ruview (2.0.0)"
```
### 7.2 Clean-venv install + import (criteria 24)
```bash
python -m venv /tmp/verify-184 && . /tmp/verify-184/bin/activate
pip install wifi-densepose==2.0.0 # stable, no --pre
python -c "import wifi_densepose; print(wifi_densepose.__version__, wifi_densepose.__rust_version__)"
python -c "from wifi_densepose import CsiFrame; print(CsiFrame([1.0]*56,[0.0]*56,56,0,100.0))"
python -c "from wifi_densepose import vitals; print(hasattr(vitals,'detect_hr'))"
pip install ruview==2.0.0
python -c "import ruview; print(ruview.__version__)"
```
### 7.3 Tombstone still guards the 1.x line (criterion 8)
```bash
pip install wifi-densepose==1.99.0
python -c "import wifi_densepose" 2>&1 | grep -q "github.com/ruvnet/RuView" \
&& echo "PASS: tombstone raises with migration URL" \
|| echo "FAIL"
```
### 7.4 Workflow auth state
**Current state (P1, active today):** token auth is the working path and is what
the `RuView#786-pypi-token-auth` fix-marker requires. The honest check today is
that token auth is present and the fix-marker guard passes:
```bash
# token auth present (the ACTIVE, working path — expected PASS today)
grep -q 'password: ${{ secrets.PYPI_API_TOKEN }}' .github/workflows/pip-release.yml \
&& echo "PASS: token auth active" || echo "FAIL"
# fix-marker regression guard must pass
python scripts/check_fix_markers.py && echo "PASS: all markers pass"
```
**P1b end-state (after the manual pypi.org registration):** the checks below flip
to PASS *only once P1b lands together with the fix-marker inversion* — they are
**not** expected to pass today and their passing now would mean a half-migrated,
403-prone workflow:
```bash
# after P1b: no static token should remain in the publish steps
grep -nE 'password:|PYPI_API_TOKEN' .github/workflows/pip-release.yml \
&& echo "not yet: token still present (expected during P1)" \
|| echo "P1b done: no static token"
# after P1b: id-token permission granted on publish jobs
grep -q 'id-token: write' .github/workflows/pip-release.yml \
&& echo "P1b done: OIDC permission present" \
|| echo "not yet: OIDC not enabled (expected during P1)"
```
### 7.5 The release actually went green
```bash
gh run list --workflow pip-release --limit 1
# expect: conclusion=success on the v2.0.0-pip tag run
```
**Why/How to apply:** §7.1 and §7.5 together are the minimal proof that the two
headline gaps (no stable 2.0.0, no `ruview`, dead pipeline) are closed. If any
command's actual output diverges from the `expect` line, the corresponding phase is
not done — regardless of what any commit message or checkbox says.
---
## 8. Consequences
### Positive
- The Python entry point for the entire RuView ecosystem (issue #785 "Strategic
alignment") is unblocked with a credential that cannot silently expire.
- The claimed-vs-measured gap in commit `b71d243b4` (`ruview` never published) is
closed with reproducible proof, upholding the project's "prove everything" posture.
- Trusted Publishing removes a leak-able long-lived secret from CI entirely — the
security posture ADR-117 §5.5 originally specified.
- ADR-117 / issue #785 can finally reach a defensible Accepted/closed state instead
of sitting open behind a one-line token failure.
### Negative
- The `pypi.org` trusted-publisher registration (§3.1) is a hard human dependency
with no automated fallback beyond re-introducing a token (§3.2). Release day is
blocked on a person, not a pipeline.
- Promoting to stable `2.0.0` removes the alpha escape hatch — any binding bug now
ships under a stable version and needs a `2.0.1`, not a new `a`-tag.
- `test.pypi.org` needs its own trusted-publisher entry if the dry-run path is kept,
adding a second manual registration.
### Neutral
- The build matrix (`build-wheels`, `build-sdist`, `build-tombstone`) is untouched;
the risk surface of this change is confined to the three publish jobs.
- The witness-hash-v2 open question (ADR-117 §11.3, workflow header Q3) is pulled
into scope as criterion 10 but is orthogonal to the credential migration.
---
## 9. References
- **ADR-117** — `docs/adr/ADR-117-pip-wifi-densepose-modernization.md` (the design
this ADR completes; §5.4/§5.5 OIDC intent, §7.2 tombstone, §11.3 witness hash)
- **Issue #785** — https://github.com/ruvnet/RuView/issues/785 (tracking issue,
OPEN; §11 acceptance criteria transcribed in §6)
- **Workflow** — `.github/workflows/pip-release.yml` (four `password:` inputs at
lines 249/258/282/291; `contents: read` only at 4950)
- **pyproject** — `python/pyproject.toml` (`version = "2.0.0a1"` line 13;
`3 - Alpha` line 26)
- **Failed run** — GitHub Actions `pip-release` run `26366735779`, job "Publish
v1.99 tombstone" → step "Publish to PyPI" (403 + Trusted-Publishing-disabled warning)
- **Commit `b71d243b4`** — *"feat(adr-117): publish wifi-densepose 2.0.0a1 + ruview
2.0.0a1 to PyPI"* — the `ruview` publish it claims did not occur
- **PyPI Trusted Publishing** — https://docs.pypi.org/trusted-publishers/ (web-UI-only
registration; pending-publisher support for not-yet-created projects)
- **`pypa/gh-action-pypi-publish`** — https://github.com/pypa/gh-action-pypi-publish
(OIDC via `id-token: write`; `password:` disables Trusted Publishing)
- **ADR-168** — `docs/adr/ADR-168-benchmark-proof.md` (measured-not-claimed house style)
+687
View File
@@ -0,0 +1,687 @@
# ADR-185: Python P6 SOTA bindings — AETHER, MERIDIAN, and MAT via PyO3 extras
| Field | Value |
|-------|-------|
| **Status** | Proposed — **P1P4 implemented & tested** (commits `d060998e3`, `189ac9dfb`, `1c9727f9c`, `0f405213d`) + **leaf-crate hoists done** (`a47bb71b2`/`7ed57f041`/`99fea9df9`); **not yet Accepted** (§6.6 CI gate PARTIAL, §6.7 accuracy bars OPEN — see §13) |
| **Date** | 2026-07-21 (impl status recorded 2026-07-21) |
| **Deciders** | ruv |
| **Codename** | **PIP-TRINITY** — three SOTA subsystems join the `wifi_densepose` wheel |
| **Relates to** | [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX — the PyO3 wheel this extends), [ADR-024](ADR-024-contrastive-csi-embedding-model.md) (AETHER contrastive embeddings), [ADR-027](ADR-027-cross-environment-domain-generalization.md) (MERIDIAN domain generalization), [ADR-152](ADR-152-wifi-pose-sota-2026.md) (WiFlow-STD ~96% PCK@20 SOTA bar) |
| **Tracking issue** | TBD — file under RuView issue tracker |
---
## 1. Context
### 1.1 Where ADR-117 stopped
ADR-117 (PIP-PHOENIX) shipped the `wifi-densepose` v2.x PyPI wheel as a PyO3 +
maturin compiled extension (`wifi_densepose._native`) with a pure-Python facade.
The bound surface today (`python/src/bindings/*.rs`, `python/src/lib.rs`):
| Bound today | Crate | Kind |
|---|---|---|
| `CsiFrame`, `Keypoint`, `KeypointType`, `BoundingBox`, `PersonPose`, `PoseEstimate` | `wifi-densepose-core` | P2 core types |
| 4-stage vitals (`BreathingExtractor`, `HeartRateExtractor`, `VitalEstimate`, `VitalReading`, `VitalStatus`) | `wifi-densepose-vitals` | P3 DSP |
| `BfldFrame`, `BfldReport`, `BfldKind` + `PrivacyClass` gate | `wifi-densepose-bfld` | P3.5 / ADR-118 |
| `SensingClient` (WS), `RuViewMqttClient` (MQTT), HA helpers | pure-Python `wifi_densepose.client` | P4 `[client]` extra |
ADR-117's own phase ledger (§6, "P6+ — Deferred") explicitly parked three
higher-value subsystems as post-v2.0.0 work:
> - [ ] `wifi-densepose-nn` bindings … · `wifi-densepose-ruvector` bindings …
> - [ ] MQTT/Matter integration helpers …
and ADR-117 §5.1 deferred `wifi-densepose-mat` (depends on nn) and the RuVector
tier for wheel-size reasons. The three SOTA subsystems that a Python researcher
most wants — re-identification embeddings, cross-environment transfer, and the
disaster-triage tool — are precisely the ones still unreachable from
`pip install wifi-densepose`.
### 1.2 The three subsystems already exist and are tested in Rust
None of this is new research. Each subsystem is a shipped, tested Rust module:
| Subsystem | ADR | Rust location (verified HEAD) | Nature |
|---|---|---|---|
| **AETHER** — contrastive CSI embedding / re-identification | ADR-024 | `wifi-densepose-sensing-server/src/embedding.rs` (`EmbeddingExtractor`, `ProjectionHead`, `CsiAugmenter`, `AetherConfig`, `aether_loss`, `info_nce_loss`, `alignment_metric`, `uniformity_metric`) | Pure-sync DSP + linear algebra; 128-dim L2-normalized embeddings |
| **MERIDIAN** — cross-environment domain generalization | ADR-027 | `wifi-densepose-train` (`domain::{DomainFactorizer, DomainClassifier, GradientReversalLayer, AdversarialSchedule}`, `geometry::{GeometryEncoder, FourierPositionalEncoding, FilmLayer, MeridianGeometryConfig}`, `rapid_adapt::{RapidAdaptation, AdaptationLoss}`, `virtual_aug::VirtualDomainAugmentor`, `eval::CrossDomainEvaluator`) + `wifi-densepose-signal::hardware_norm::{HardwareNormalizer, HardwareType, CanonicalCsiFrame}` | Inference/adaptation path is pure-Rust and **un-gated**; only `model`/`trainer`/`losses` need `tch-backend` (libtorch) |
| **MAT** — Mass Casualty Assessment Tool | (root CLAUDE.md crate table) | `wifi-densepose-mat` (`DisasterResponse`, `DisasterConfig`, `DetectionPipeline`, `EnsembleClassifier`, `TriageCalculator`, `TriageStatus`, `Survivor`, `VitalSignsReading`) | Cargo-feature-gated (`mat`); sync ingest (`push_csi_data`) + async scan loop (`start_scanning`, tokio) |
### 1.3 Why now, and why gated extras
Two forces make P6 timely: (a) the v2.0.0 wheel is stable and its abi3-py310
build matrix is proven, so adding modules is incremental; (b) integrators reading
the ADR-115/ADR-117 notes are asking for Python access to re-identification and
cross-room transfer specifically.
But pulling all three into the **default** wheel would break ADR-117 §5.4's
**≤ 5 MB per-platform wheel budget** and its "no heavy system deps" invariant:
- MAT is already cargo-`mat`-gated upstream *because* it drags in the ML/detection
stack; the default wheel must not carry it.
- MERIDIAN's training path (`model`/`trainer`/`losses`) is `tch-backend`-gated and
would pull libtorch (30 MB+), the exact wheel-size risk ADR-117 §5.1 flagged.
So P6 mirrors the existing `[client]` extra pattern (ADR-117 §5.6): each subsystem
becomes an **optional pip extra**, and the compiled surface is **feature-gated in
`wifi-densepose-py`'s `Cargo.toml`** so the default wheel stays lean.
### 1.4 What this ADR is *not*
- Not a port of the Rust subsystems to Python — the Rust workspace stays
authoritative and unmodified, exactly as ADR-117 §1.3 established.
- Not the `wifi-densepose-nn` / libtorch binding (still deferred; MERIDIAN binds
only the un-gated inference/adaptation path, not `tch-backend` training).
- Not a change to the default wheel's contents, size budget, or abi3 base.
---
## 2. Gap analysis
| Capability | Rust crate(s) | pip v2.x status | Gap severity |
|---|---|---|---|
| Extract a 128-dim re-ID embedding from a CSI window | `sensing-server::embedding` (AETHER) | Not present | **High** |
| Compare two CSI observations by learned similarity (same room? same person?) | AETHER `EmbeddingExtractor` + cosine | Not present | **High** |
| Hardware-invariant CSI normalization (ESP32 / Intel 5300 / Atheros → canonical 56) | `signal::hardware_norm` (MERIDIAN) | Not present | **High** |
| Geometry-conditioned zero-shot deployment (AP positions → FiLM) | `train::geometry` (MERIDIAN) | Not present | **Medium** |
| 10-second unlabeled few-shot room adaptation | `train::rapid_adapt` (MERIDIAN) | Not present | **Medium** |
| Cross-domain evaluation protocol (in/cross/few-shot MPJPE) | `train::eval` (MERIDIAN) | Not present | **Medium** |
| Disaster-survivor detection + START triage from CSI | `wifi-densepose-mat` | Not present | **Medium** (specialist audience) |
---
## 3. Decision
Adopt **three new optional pip extras**, each binding one SOTA subsystem into the
existing `wifi_densepose` wheel as a dedicated Python submodule, gated behind a
matching Cargo feature so the default wheel is unchanged:
```
pip install wifi-densepose # unchanged: core + vitals + bfld (≤5 MB)
pip install wifi-densepose[aether] # + wifi_densepose.aether
pip install wifi-densepose[meridian] # + wifi_densepose.meridian
pip install wifi-densepose[mat] # + wifi_densepose.mat (mirrors upstream `mat` cargo feature)
pip install wifi-densepose[sota] # convenience: aether + meridian + mat
```
This path is called **PIP-TRINITY**. It reuses ADR-117's established idiom
end-to-end: `#[pyclass]` newtype wrappers holding an `inner` Rust value, `#[new]`
constructors, `#[getter]` accessors, `__repr__`, a per-module `register(m)` fn,
and — critically — **GIL release via `py.allow_threads(|| …)` on every
compute-heavy call**, exactly as `bindings/vitals.rs:229` and `:293` already do.
### 3.1 Feature gating in `wifi-densepose-py`
New Cargo features and optional path-deps in `python/Cargo.toml`; each binding
module is `#[cfg(feature = "…")]`-compiled and conditionally `register()`ed in
`src/lib.rs`, so a default build links none of the three:
```toml
[features]
default = []
aether = ["dep:wifi-densepose-sensing-server"]
meridian = ["dep:wifi-densepose-train", "dep:wifi-densepose-signal"]
mat = ["dep:wifi-densepose-mat"] # upstream `mat` feature flows through
sota = ["aether", "meridian", "mat"]
[dependencies]
wifi-densepose-sensing-server = { version = "0.3.0", path = "../v2/crates/wifi-densepose-sensing-server", optional = true, default-features = false }
wifi-densepose-train = { version = "0.3.0", path = "../v2/crates/wifi-densepose-train", optional = true, default-features = false } # NO tch-backend
wifi-densepose-signal = { version = "0.3.0", path = "../v2/crates/wifi-densepose-signal", optional = true }
wifi-densepose-mat = { version = "0.3.0", path = "../v2/crates/wifi-densepose-mat", optional = true, default-features = false }
```
`[project.optional-dependencies]` in `pyproject.toml` gains `aether`, `meridian`,
`mat`, and `sota` keys mirroring the existing `client`/`dev` extras. Because each
extra changes the compiled surface, extras map to **cibuildwheel feature-flag
builds**, not pure-Python markers — the publish workflow (ADR-117 §5.4) gains a
build axis for the `[sota]` wheel variant.
### 3.2 Binding surface — AETHER (`wifi_densepose.aether`)
Backing crate: `wifi-densepose-sensing-server::embedding` (ADR-024 §2.6). The
crate is Axum/tokio-based, so we depend on it `default-features = false` and bind
**only the sync `embedding` types** — never the server/runtime. If the embedding
module cannot be reached without a tokio dependency (Open Question §11.1), the
fallback is to hoist `embedding.rs` into a leaf crate; that is a Rust-side
refactor, not a Python API change.
| Python symbol | Wraps | Signature (Python) |
|---|---|---|
| `AetherConfig` | `AetherConfig` | `AetherConfig(d_model=64, d_proj=128, temperature=0.07, vicreg_alpha=1.0, vicreg_beta=25.0, vicreg_gamma=1.0)` — frozen, `__repr__` |
| `CsiAugmenter` | `CsiAugmenter` | `CsiAugmenter(seed)`; `.augment(window: list[list[float]]) -> list[list[float]]` |
| `EmbeddingExtractor` | `EmbeddingExtractor` | `.embed(csi_features: list[list[float]]) -> list[float]` (128-dim, L2-normed); `.forward_dual(...) -> tuple[PoseEstimate, list[float]]` |
| `aether_loss(...)` | `aether_loss` | returns `AetherLossComponents(total, info_nce, variance, covariance)` — frozen dataclass-like |
| `cosine_similarity(a, b)` | thin helper | `float`; convenience for re-ID scoring (not a re-impl — calls the same dot product) |
| `alignment_metric`, `uniformity_metric` | same | `float` |
GIL strategy: `embed`, `forward_dual`, `augment`, and `aether_loss` wrap their
Rust call in `py.allow_threads(|| …)` — these are pure-sync matrix ops that touch
no Python objects, matching the vitals precedent. A single-frame `embed()` is
sub-millisecond (ADR-024 §2.8 target <1 ms FP32), but batch/augment calls exceed
the 0.5 ms GIL-release threshold ADR-117 §P3 set.
`.pyi` stubs: add `wifi_densepose/aether.pyi` declaring the five classes/functions
with precise numeric types; extend the top-level `wifi_densepose/__init__.pyi`
with a `TYPE_CHECKING`-guarded re-export so `mypy --strict` sees them only when
the extra is installed.
### 3.3 Binding surface — MERIDIAN (`wifi_densepose.meridian`)
Backing crates: `wifi-densepose-train` (inference/adaptation path, **no
`tch-backend`**) + `wifi-densepose-signal::hardware_norm`. The `model`/`trainer`/
`losses` modules are libtorch-gated and are **out of scope** — Python gets the
domain-generalization *inference and calibration* surface, not the training loop.
| Python symbol | Wraps | Signature (Python) |
|---|---|---|
| `HardwareType` | `HardwareType` | `#[pyclass(eq, eq_int, hash, frozen)]` enum: `Esp32S3 / Intel5300 / Atheros / Generic`; `HardwareType.detect(subcarrier_count) -> HardwareType` |
| `HardwareNormalizer` | `HardwareNormalizer` | `.normalize(frame: CsiFrame, hw: HardwareType) -> CanonicalCsiFrame` |
| `CanonicalCsiFrame` | `CanonicalCsiFrame` | frozen; `.amplitudes`, `.phases`, `.hardware_type` getters |
| `GeometryEncoder` | `GeometryEncoder` | `GeometryEncoder(MeridianGeometryConfig)`; `.encode(ap_positions: list[tuple[float,float,float]]) -> list[float]` (64-dim, permutation-invariant) |
| `MeridianGeometryConfig` | `MeridianGeometryConfig` | frozen config |
| `RapidAdaptation` | `RapidAdaptation` | `.calibrate(csi_windows: list[list[list[float]]]) -> AdaptationResult` (10-sec unlabeled few-shot) |
| `AdaptationResult` | `AdaptationResult` | frozen result: `.frames_used`, `.converged`, `.loss` |
| `CrossDomainEvaluator` | `CrossDomainEvaluator` | `.evaluate(...) -> dict[str, float]` (in/cross/few-shot MPJPE, domain-gap ratio) |
GIL strategy: `normalize`, `encode`, `calibrate`, and `evaluate` are wrapped in
`py.allow_threads`. `normalize` targets <50 µs/frame (ADR-027 §4.1) and `encode`
<100 µs (§4.3), but `calibrate` runs contrastive test-time training over 200
frames and is the primary GIL-release beneficiary.
`.pyi` stubs: `wifi_densepose/meridian.pyi`. `DomainFactorizer` /
`GradientReversalLayer` / `VirtualDomainAugmentor` are **training-time only** and
are *not* bound in P6 (they need the tch training loop) — Open Question §11.2
records this boundary.
### 3.4 Binding surface — MAT (`wifi_densepose.mat`)
Backing crate: `wifi-densepose-mat`, bound behind the `[mat]` extra so the
disaster/ML stack never enters the default wheel — mirroring the upstream `mat`
cargo feature exactly. `DisasterResponse::start_scanning` is async (tokio); rather
than bind an event loop, P6 binds the **sync ingest + query surface** and a
single-shot `scan_once()` helper (a sync wrapper over one `scan_cycle`, added
Rust-side if needed — see §11.3).
| Python symbol | Wraps | Signature (Python) |
|---|---|---|
| `DisasterType` | `DisasterType` | `#[pyclass(eq, eq_int, hash, frozen)]` enum: `Earthquake / BuildingCollapse / Avalanche / Flood / Mine / Unknown` |
| `TriageStatus` | `TriageStatus` | frozen enum (START protocol classes) |
| `DisasterConfig` | `DisasterConfig` | builder-style kwargs: `DisasterConfig(disaster_type, sensitivity=0.8, confidence_threshold=0.5, max_depth=5.0)` |
| `DisasterResponse` | `DisasterResponse` | `.push_csi_data(amplitudes, phases)`; `.scan_once()`; `.survivors() -> list[Survivor]`; `.survivors_by_triage(status) -> list[Survivor]` |
| `Survivor` | `Survivor` | frozen: `.id`, `.triage_status`, `.location`, `.vital_signs` getters |
| `VitalSignsReading` | `VitalSignsReading` | frozen: breathing / heartbeat / movement fields |
GIL strategy: `push_csi_data` and `scan_once` wrap the detection-pipeline call in
`py.allow_threads` — the ensemble classifier + localization are the compute-heavy
part and touch no Python state.
`.pyi` stubs: `wifi_densepose/mat.pyi`.
---
## 4. Benchmarking & the measured-vs-claimed parity requirement
A binding that "runs without crashing" is worthless if it silently regresses
accuracy versus the native Rust call. The point of P6 is to prove the Python
surface reproduces the Rust subsystem **bit-for-bit**, then to hold each binding
to the *same* published SOTA bar its ADR already claims.
### 4.1 Parity harness (bit-for-bit, mandatory)
Each subsystem ships a golden-vector parity test. A committed input fixture is
run through **both** a tiny native-Rust reference binary (in
`v2/crates/wifi-densepose-py/tests/golden/`) and the Python binding; the two
outputs must hash-match under SHA-256 (the ADR-028 / ADR-117 §5.7 witness scheme):
- `aether`: identical 128-dim embedding bytes for a fixed CSI window + fixed seed.
- `meridian`: identical `CanonicalCsiFrame` bytes for a fixed ESP32 (64-sub) and
Intel-5300 (30-sub) frame; identical 64-dim geometry vector for fixed AP set.
- `mat`: identical triage classification + survivor count for a fixed CSI stream.
A mismatch is a **release blocker**, not a warning. This is the "MEASURED, not
CLAIMED" gate the project holds itself to.
**Scope, stated honestly:** parity proves the **strongest claim available today**
the Python binding is bit-identical to native Rust for the bound surface. It is
**not** accuracy validation. The bound AETHER surface moreover ships *untrained*
(random-init weights; a `load_weights` API exists since `65da488ad` but no trained
checkpoint exists to load — §6.7.2, §13.c.a), so byte-equality here says nothing
about the SOTA accuracy bars in §4.3; those remain OPEN (§6.7, §13.c).
### 4.2 pytest-benchmark micro-benchmarks
Following the existing `python/bench/test_bench_vitals.py` pattern (skipped by
default via `addopts`; run with `pytest python/bench/ --benchmark-only`):
- `python/bench/test_bench_aether.py` — steady-state `embed()` per-window cost;
assert < 2 ms (ADR-024 §2.8 FP32 target < 1 ms with headroom) and that batched
`embed()` scales linearly (no accidental O(n²)).
- `python/bench/test_bench_meridian.py``normalize()` < 200 µs/frame,
`encode()` < 200 µs (ADR-027 §4.1/§4.3 targets ×2 headroom).
- `python/bench/test_bench_mat.py``scan_once()` per-cycle cost bounded by the
configured scan interval.
### 4.3 SOTA accuracy bar the binding must reproduce (not merely run)
The parity harness (§4.1) guarantees the Python path is byte-identical to Rust, so
these published numbers are the bar the *binding output* is validated against on a
committed labeled fixture — a regression in any is a binding bug:
| Metric | Bar | Source |
|---|---|---|
| WiFlow-STD pose accuracy | **~96% PCK@20** (MEASURED-EQUIVALENT) | ADR-152 §2.2 |
| Room identification (k-NN on `env_fingerprint`) | **> 95%** | ADR-024 §2.8 |
| Person re-ID mAP | **> 80%** (WhoFi bar 95.5% on NTU-Fi) | ADR-024 §2.8, §1.5 |
| Anomaly detection F1 | **> 0.90** | ADR-024 §2.8 |
| INT8 rank correlation vs FP32 (Spearman) | **> 0.95** | ADR-024 §2.8 |
| Cross-domain MPJPE improvement | **> 20%** vs non-adversarial | ADR-027 §4.2 |
| Domain-gap ratio (cross/in-domain) | **< 1.5** | ADR-027 §4.6 |
| Few-shot MPJPE after 10-sec calibration | within **15%** of in-domain | ADR-027 §4.5 |
---
## 5. Phase ledger
```
P1 ──► P2 ──► P3 ──► P4
aether meridian mat docs +
bindings bindings behind examples
extra
```
> **Implementation note (2026-07-21):** P1P4 were built against the **real Rust
> code at HEAD**, not this ADR's proposed surface. Where §3's proposed API named
> functions/fields that do not exist in the crates (e.g. `aether_loss`/VICReg
> components/`alignment_metric`/`forward_dual`, `RapidAdaptation.calibrate`,
> `AdaptationResult.converged`), the coder **did not fabricate them** — the real
> API was bound and the deviation documented in each module header and commit body.
> Treat §3 as the original proposal and the commit messages as the authoritative
> record of what shipped.
### P1 — AETHER bindings (`[aether]` extra) — **DONE** (`d060998e3`; leaf-crate hoist `a47bb71b2`)
- [x] `aether` Cargo feature + gated optional `wifi-densepose-sensing-server` dep;
default build links **0** sensing-server refs (base wheel stays lean).
- [x] `python/src/bindings/aether.rs``AetherConfig` (→ real `EmbeddingConfig`),
`CsiAugmenter.augment_pair`, `EmbeddingExtractor.embed` (128-dim L2-normed,
GIL-released), `info_nce_loss`, `cosine_similarity`. **Not bound** (absent in
`embedding.rs` at HEAD, a Rust-side gap, not fabricated): `aether_loss`/VICReg
components, `alignment_metric`, `uniformity_metric`, `forward_dual`, `vicreg_*`.
- [x] `#[cfg(feature = "aether")]` gate + facade + `aether.pyi` + `[aether]` extra.
- [x] `python/tests/golden/aether_embedding.sha256` parity fixture:
`tests/aether_parity.rs` locks the native reference; `tests/test_aether.py`
asserts identical SHA-256 of the LE-f32 bytes.
- [x] **Verified:** `cargo test --features aether --test aether_parity` → 2/2;
`pytest tests/test_aether.py` → 9/9.
- [x] **Leaf-crate hoist (`a47bb71b2`):** `embedding.rs` moved into a new
`wifi-densepose-aether` crate. Measured stripped wheel **~361 KB → ~312 KB** (was
already ~14× under the 5 MB budget — see §13.a; the hoist's value is build-time
71 s → 12 s + dep-graph hygiene, not size). No regression: `aether_parity` 2/2,
`pytest` 9/9, sensing-server 217+388 tests 0 failed, new `wifi-densepose-aether`
crate 96 passed.
### P2 — MERIDIAN bindings (`[meridian]` extra) — **DONE** (`189ac9dfb`)
- [x] `meridian` feature + gated optional `wifi-densepose-train` (**no `tch-backend`
— libtorch avoided, confirmed**) + `wifi-densepose-signal` deps.
- [x] `python/src/bindings/meridian.rs``HardwareType`/`HardwareNormalizer`/
`CanonicalCsiFrame` (real API: `normalize(amplitude, phase, hw)` over f64 →
`Result`; singular `amplitude`/`phase` fields), `MeridianGeometryConfig`/
`GeometryEncoder` (64-dim, permutation-invariant), `RapidAdaptation`
(**real API: `push_frame` + `adapt()`**, not the ADR's `calibrate`) →
`AdaptationResult` (`lora_weights`/`final_loss`/`frames_used`/
`adaptation_epochs`; **no `converged`**), `CrossDomainEvaluator` + `mpjpe`. All
compute paths GIL-released. Training-time types (`DomainFactorizer`, GRL,
`VirtualDomainAugmentor`) correctly left out of P6 scope.
- [x] Gate + facade + `meridian.pyi` + `[meridian]` extra; default dep graph has 0
train/signal/sensing-server refs.
- [x] `tests/golden/meridian_output.sha256` parity fixture (esp32 + intel canonical
frames + 64-dim geometry vector + rapid-adapt LoRA weights).
- [x] **Verified:** `cargo test --features meridian --test meridian_parity` → 2/2;
`pytest tests/test_meridian.py` → 13/13.
### P3 — MAT bindings behind `[mat]` extra — **DONE** (`1c9727f9c`)
- [x] `mat` feature + gated optional `wifi-densepose-mat` dep. **§11.3 resolved: no
Rust change needed** — the public async `start_scanning()` already runs exactly
one `scan_cycle` when `continuous_monitoring == false`; the binding forces that
flag off and drives one cycle on a private current-thread tokio runtime.
- [x] `python/src/bindings/mat.rs``DisasterType` (**9 variants at HEAD**, not the
6 the ADR listed), `TriageStatus` (5, START), `DisasterConfig`,
`DisasterResponse` (`initialize_event`/`add_zone`/`push_csi_data`/`scan_once`/
`survivors`/`survivors_by_triage``initialize_event`+`add_zone` are **required
additions** the ADR surface omitted), `Survivor` (`latest_vitals`, since real
`vital_signs` is a history), `VitalSignsReading`, `ScanZone.rectangle`/`.circle`.
`push_csi_data`+`scan_once` GIL-released.
- [x] Gate + facade + `mat.pyi` + `[mat]` **and** `[sota]` (superset) extras.
- [x] `tests/golden/mat_result.sha256` parity fixture over a canonical
`count=<K>;triage_priorities=<sorted>` string (UUIDs/timestamps excluded as
non-deterministic). **Honest scope: proves binding==native path, NOT live
detection accuracy** — the synthetic stream yields 1 survivor, triage Delayed.
- [x] **Verified:** `cargo test --features mat --test mat_parity` → 2/2;
`pytest tests/test_mat.py` → 7/7.
### P4 — Docs, examples, and benchmark suite — **DONE** (`0f405213d`)
- [x] `python/bench/test_bench_{aether,meridian,mat}.py` (pytest-benchmark, §4.2).
Measured on a `--release --features sota` wheel: AETHER `embed()` ~150 µs
(target <2 ms), batch 1/8/64 = 140/1091/8509 µs (linear); MERIDIAN `normalize()`
~2.2 µs (target <200 µs), `encode()` ~6.9 µs; MAT ingest+`scan_once()` ~40 ms /
256-frame (< 500 ms). All pass.
- [x] `python/examples/{reid_from_csi,cross_room_calibrate,mat_triage}.py` — typed,
runnable, `mypy --strict` clean; README SOTA extras table.
- [~] Parity harness wiring into CI as a **release-blocking gate** — golden gates
are green locally (`cargo test --features sota` → 6/6; 3/3 SHA gates), but the CI
**wiring** is not done (§6.6 PARTIAL — see §13.b).
- [ ] Update ADR-117 §6 "P6+ Deferred" to point at this ADR — still open.
### P5 — New required follow-ups (blocking Accepted)
See §13. In short: (a) three leaf-crate hoists — **DONE** (`a47bb71b2`/`7ed57f041`/
`99fea9df9`; only MAT was a real budget fix, AETHER was a false alarm), (b) wire the
parity harness into CI as an actual release gate — **still open**, (c) source/generate
labeled fixtures to validate the SOTA accuracy bars (§4.3) for real — **still open**.
### P6+ — Deferred (unchanged from ADR-117)
- [ ] `wifi-densepose-nn` / libtorch bindings (MERIDIAN training loop,
`DomainFactorizer`, GRL) — still blocked on the libtorch wheel-size question.
- [ ] `wifi-densepose-ruvector` RuVector attention bindings.
- [ ] Matter integration helpers.
---
## 6. Acceptance criteria
Status recorded from the P4 self-verification run (`0f405213d`), reference machine
per ADR-117 §10. **7 of 9 met; 2 remain** — the ADR is therefore **not** Accepted.
- [x] **§6.1** `pip install wifi-densepose` (no extras) → default wheel **279 KB**
(≤ 5 MB); `build_features()` carries no `p6-*` feature — base wheel byte-for-byte
unaffected by P6. **PASS**
- [x] **§6.2** `pytest python/tests/test_aether.py -q`**9/9**, incl. a real
128-dim `embed()` round-trip asserting L2-norm ≈ 1.0 and byte-identity to the
golden Rust reference. **PASS**
- [x] **§6.3** `pytest python/tests/test_meridian.py -q`**13/13**, incl.
ESP32 (64-sub) **and** Intel-5300 (30-sub) canonicalization hash-matching native
Rust. **PASS**
- [x] **§6.4** `pytest python/tests/test_mat.py -q`**7/7**, incl. a fixed CSI
stream whose triage classification matches native `DisasterResponse` exactly.
**PASS**
- [x] **§6.5** `pytest python/bench/ --benchmark-only` — all targets met (AETHER
`embed()` ~150 µs < 2 ms; MERIDIAN `normalize()` ~2.2 µs, `encode()` ~6.9 µs
< 200 µs; MAT `scan_once()` ~40 ms < 500 ms). **PASS**
- [~] **§6.6** Parity harness (§4.1): all three golden-vector SHA-256 gates green
(`cargo test --features sota` → 6/6). **But CI wiring** as a release-blocking
gate is **not done** (out of `python/` scope). **PARTIAL — see §13.b.**
- [ ] **§6.7** SOTA-bar reproduction (§4.3): **definitively OPEN** — cannot be
closed transitively via the parity harness. Investigated; three concrete reasons:
1. **The native SOTA numbers aren't reproduced by any committed, runnable-today
test.** ADR-152 ~96% PCK@20 is a frozen result in
`benchmarks/wiflow-std/results/eval_retrained.json` that points at an **external
checkpoint** (`/home/ruvultra/wiflow-std-bench/upstream/test/best_pose_model.pth`,
not in the repo); the only relevant test `test_wiflow_std_parity.rs` is
`#![cfg(feature = "tch-backend")]` **and** `#[ignore]`d (needs gitignored
fixtures + LibTorch). ADR-027's `eval.rs::CrossDomainEvaluator` tests are pure
unit-math on hand-coded 23-element vectors, not dataset accuracy. ADR-024's
only accuracy-ish test asserts Spearman > 0.90 on **synthetic random**
embeddings (not real CSI, not the published > 0.95 bar); no room-ID / mAP /
anomaly-F1 test exists at all.
2. **The bound AETHER surface ships untrained.** `EmbeddingExtractor`/
`ProjectionHead` default to random Xavier init (`Linear::with_seed(…,
2024/2025)`, `embedding.rs:9798`). A weight-loading API now **does** exist
(`load_weights`/`save_weights`, `65da488ad` — see §13.c.a), so the earlier
"no loading path" blocker is removed; but **no trained checkpoint exists** to
load, so the binding still produces untrained embeddings and cannot validate
mAP > 80% or any trained-model bar today.
3. **No committed labeled CSI input/pose-pair data exists** to reuse (MM-Fi/NTU-Fi
appear only as config-default subcarrier counts / external paths;
`benchmarks/wiflow-std/results/*.npy` are corruption masks + result summaries,
not labeled fixtures).
The §4.1 parity harness proves the **strongest claim available today** — the
Python binding is bit-identical to native Rust for the bound (untrained) surface.
That is **not** accuracy validation. **See §13.c.**
- [x] **§6.8** `.pyi` stubs present for all three modules; `mypy --strict` passes on
the three examples. **PASS**
- [x] **§6.9** `python -c "import wifi_densepose.aether"` (etc.) on the base wheel
raises a clear `ImportError` naming the missing extra. **PASS**
No regression: 76 pre-existing tests pass on the default wheel. The two unmet
criteria (§6.6 CI wiring, §6.7 accuracy) plus the wheel-size hoists (§13.a) are the
gate to Accepted.
---
## 7. Consequences
### 7.1 Positive
- **Closes the ADR-117 P6 gap**: the three most-requested SOTA subsystems become
scriptable from Python without touching the Rust workspace.
- **Default wheel stays lean**: feature-gated extras preserve ADR-117 §5.4's ≤ 5 MB
budget and "no heavy system deps" invariant; MAT's ML stack and MERIDIAN's
libtorch path never enter the base wheel.
- **Reuses the proven idiom**: no new binding machinery — same `#[pyclass]` +
`py.allow_threads` + `register()` pattern already shipping in `bindings/vitals.rs`.
- **Prove-everything alignment**: the parity harness makes "the Python binding
equals the Rust core" a *measured, hash-verified* claim, not an assertion —
matching the project's MEASURED-vs-CLAIMED discipline.
- **Upstream consistency**: `[mat]` pip extra mirrors the `mat` cargo feature, so
the Python packaging story matches the Rust one exactly.
### 7.2 Negative
- **cibuildwheel matrix grows**: `[sota]` is a distinct compiled variant, adding a
build axis (and CI time) beyond ADR-117's 5-wheel abi3 matrix.
- **AETHER's backing crate is server-shaped**: depending on
`wifi-densepose-sensing-server` (Axum/tokio) risks pulling a runtime into an
extension module; may force a Rust-side refactor to hoist `embedding.rs` into a
leaf crate (§11.1).
- **MERIDIAN surface is partial**: training-time types (`DomainFactorizer`, GRL,
`VirtualDomainAugmentor`) stay unbound until the deferred libtorch tier, so the
Python API is inference/adaptation-only — potential user confusion (mitigated by
docs + `.pyi` omissions).
- **Golden fixtures are maintenance surface**: any intentional numeric change in a
Rust subsystem requires regenerating and re-witnessing its golden vector.
### 7.3 Neutral
- The `[sota]` convenience extra is purely additive; users who want one subsystem
install one extra.
- No change to the v2.0.0 semver line; extras ship additively as v2.x.y.
---
## 8. Alternatives considered
### Alt-A: Fold all three into the default wheel
Rejected — breaks ADR-117 §5.4's ≤ 5 MB budget, drags MAT's ML stack and (via
MERIDIAN training) libtorch into every install, and contradicts the upstream
`mat` cargo-feature gating.
### Alt-B: Separate PyPI packages (`wifi-densepose-aether`, etc.)
Rejected for the SOTA trio — three packages fragment the import namespace and
duplicate the abi3/cibuildwheel setup. (This remains the right call for the
libtorch `nn` tier per ADR-117 Open Q §11.2, which is genuinely heavy.) Extras of
one wheel keep `wifi_densepose.*` coherent.
### Alt-C: Pure-Python reimplementation of the three subsystems
Rejected explicitly — this is the exact drift ADR-117 §8 Alt-C was created to
exit. A Python reimplementation would immediately begin diverging from the Rust
SOTA and could not pass the §4.1 bit-for-bit parity gate.
### Alt-D: REST/WS client to a running sensing-server for AETHER
Rejected as the primary path — provides zero offline embedding utility and cannot
host the parity harness over local Rust code (same reasoning as ADR-117 §8 Alt-B).
The pure-Python client layer (`[client]`) remains available for streaming.
---
## 9. Risks
| Risk | Likelihood | Severity | Mitigation |
|---|---|---|---|
| `wifi-densepose-sensing-server` pulls tokio into the extension module | ~~High~~ **Not realized** | ~~High~~ **Low** | **Measured, not realized:** the stripped `[aether]` wheel was **~361 KB** (14× under budget) even before the hoist — linker DCE (`--gc-sections`) strips the server's unreached Axum/tokio/worldgraph code because the binding reaches only pure-compute symbols. Hoist (`a47bb71b2`) still done for build-time / dep-graph hygiene, not budget. See §11.1, §13.a |
| MERIDIAN accidentally links `tch-backend` (libtorch) via a default feature | Medium | High | Explicit `default-features = false` on `wifi-densepose-train`; CI `auditwheel`/`ldd` check that no libtorch symbol is present in the `[meridian]` wheel |
| `[sota]` build axis blows up cibuildwheel time | Medium | Medium | Build `[sota]` variant only on tagged releases, not every PR |
| Golden vectors drift when a Rust subsystem changes intentionally | Medium | Low | Documented regeneration step + ADR-028 witness re-sign; parity mismatch is a loud release blocker, never silent |
| MAT async-only surface has no clean sync entry point | Medium | Medium | Add sync `scan_once()` wrapper Rust-side (§11.3) before binding |
| Users install base wheel and expect `wifi_densepose.aether` | Low | Low | Clear `ImportError` naming the missing extra (acceptance criterion §6) |
---
## 10. Compatibility
- No change to the default wheel, its abi3-py310 base, or its size budget.
- Extras ship additively on the existing v2.x line; no semver break.
- `[mat]` pip extra ↔ `mat` cargo feature parity is preserved by construction.
- `.pyi` stubs are gated so `mypy --strict` only sees a subsystem when its extra
is installed.
---
## 11. Open questions
1. **AETHER crate shape****RESOLVED (`a47bb71b2`).** The original worry that
linking `wifi-densepose-sensing-server` would bloat the wheel was **never
measured** — it reasoned from the dependency tree (server has non-optional
tokio/Axum ⇒ wheel must be huge). The stripped-release measurement disproves it:
`[aether]` was **369,782 B (~361 KB)** *before* the hoist — already ~14× under
the 5 MB budget — and **319,719 B (~312 KB)** after. Linker dead-code elimination
(`--gc-sections` on the pyo3 cdylib) already strips the server's unreached
Axum/tokio/worldgraph/ruvector paths because the binding reaches only
pure-compute symbols. The hoist into `wifi-densepose-aether` was still done — its
real payoff is **build-time** (`[aether]` alone 71 s → 12 s), **dep-graph
hygiene** (`python/Cargo.lock` 1238 lines), and **removing latent risk** (a
future change that makes server code reachable would then genuinely bloat the
wheel). **Convention note:** measure the stripped release wheel size before
assuming a dependency-tree risk requires a hoist — linker DCE handles pure-Rust
unreached code, but native/FFI-bundled deps (e.g. `ort`/ONNX Runtime, see §13.a
MAT) are *not* stripped and are the real size-risk category.
2. **MERIDIAN training-time types**: `DomainFactorizer`, `GradientReversalLayer`,
and `VirtualDomainAugmentor` are meaningful only with the tch training loop.
Confirm they stay unbound in P6 and move with the deferred libtorch tier.
*Tentative: yes — P6 is inference/adaptation only.*
3. **MAT sync entry point**: `DisasterResponse::start_scanning` is an async tokio
loop. Does a sync single-cycle `scan_once()` already exist, or must it be added
Rust-side? *Tentative: add a thin sync `scan_once()` wrapping one `scan_cycle`;
do not bind an event loop into the extension.*
4. **`[sota]` wheel vs per-extra wheels**: cibuildwheel builds one binary per
feature-set. Do we publish one `[sota]` wheel and let pip select, or per-extra
wheels? This affects the number of build variants. *Tentative: single `[sota]`
superset wheel on tagged releases; base wheel stays feature-free.*
5. **INT8 embedding path in Python**: ADR-024 §2.8 sets an INT8 rank-correlation
bar. Do we expose the INT8 quantized `embed()` in P6, or FP32 only first?
*Tentative: FP32 in P6; INT8 follows once the Rust quantized path is stable.*
---
## 12. References
### Internal ADRs
- **ADR-117**: pip modernization via PyO3 + maturin — the wheel this ADR extends;
§5.1/§5.4/§5.6 (extras + wheel budget), §6 "P6+ Deferred".
- **ADR-024**: Project AETHER — contrastive CSI embedding; §2.6 module surface,
§2.8 performance/accuracy targets.
- **ADR-027**: Project MERIDIAN — cross-environment domain generalization; §4
phase acceptance criteria, §4.6 evaluation protocol.
- **ADR-152**: WiFi-Pose SOTA 2026 — WiFlow-STD ~96% PCK@20 MEASURED-EQUIVALENT bar.
- **ADR-028**: ESP32 capability audit / witness scheme — the SHA-256 parity gate
the §4.1 golden harness reuses.
### Rust source (verified HEAD)
- `v2/crates/wifi-densepose-sensing-server/src/embedding.rs` — AETHER.
- `v2/crates/wifi-densepose-train/src/{domain,geometry,rapid_adapt,virtual_aug,eval}.rs` — MERIDIAN.
- `v2/crates/wifi-densepose-signal/src/hardware_norm.rs` — MERIDIAN HardwareNormalizer.
- `v2/crates/wifi-densepose-mat/src/lib.rs` — MAT.
- `python/src/bindings/vitals.rs` — the `py.allow_threads` GIL-release precedent.
- `python/bench/test_bench_vitals.py` — the pytest-benchmark pattern P4 follows.
---
## 13. Open follow-ups (blocking Accepted)
P1P4 are real, well-tested progress: **32/32 binding tests** (aether 9, meridian
13, mat 7, + 3 smoke) and **6/6 native parity tests** all pass, verified on the
reference machine. The three leaf-crate hoists (§13.a) are now **done**. Two items
still gate Accepted: **§13.b** (wire the parity harness into CI as a release gate)
and **§13.c** (the SOTA accuracy gap — bindings are structurally *untrained*, and no
eval harness or labeled data exists yet; genuine long-term work, not a quick fix).
### 13.a — Leaf-crate hoists (all three DONE) — one real fix, one minor, one false alarm
All three extras' backing crates carry heavy declared deps, so the hoist was applied
to each. But **measuring the stripped release wheel** (not reasoning from the
dependency tree) showed the wheel-size story differs sharply per extra. Linker
dead-code elimination (`--gc-sections` on the pyo3 cdylib) strips **pure-Rust
unreached** code, so a heavy declared dep tree does **not** imply a big wheel;
**native/FFI-bundled** deps (`ort`/ONNX Runtime's native library) are the exception
— DCE cannot strip them, and those are the real size risk.
| Extra | Commit | Wheel size (stripped) | Verdict |
|---|---|---|---|
| `[aether]` | `a47bb71b2` | **~361 KB → ~312 KB** | **False alarm.** Never breached the 5 MB budget — DCE already stripped the sensing-server's unreached Axum/tokio/worldgraph/ruvector code. Hoist justified by build-time (71 s → 12 s), dep-graph hygiene (`Cargo.lock` 1238 lines), and latent-risk removal — **not** budget. |
| `[mat]` | `7ed57f041` | **8.4 MB → 2.0 MB** | **Real, measured regression.** `wifi-densepose-nn` bundles `ort`/ONNX Runtime, a **native** library DCE does **not** strip → genuine breach. Fix necessary and correctly characterized. |
| `[meridian]` | `99fea9df9` | **1.8 MB → 1.7 MB** | **Real but minor.** Measured from the start; a dead dep removed. Already under budget; small win. `libtorch` correctly avoided throughout (`tch` optional, off). |
These were changes **inside** the upstream `v2/` crates (owned by other agents this
session); the default wheel was unaffected throughout because every extra is
feature-gated off. All three hoists are now landed — the remaining Accepted blockers
are §13.b (CI gate) and §13.c (accuracy fixtures), **not** wheel size.
### 13.b — Wire the parity harness into CI as a real release gate (§6.6)
The three golden-vector SHA-256 gates pass locally (`cargo test --features sota`
6/6) but are not yet wired into a CI workflow that **blocks release** on mismatch.
Add a job to the ADR-117 §5.4 publish pipeline that runs the native `*_parity.rs`
references + the `pytest` binding checks and fails the release on any divergence.
### 13.c — Close the SOTA accuracy gap (§4.3, §6.7) — genuine long-term work
This is the most important honesty gap and it is **more fundamental than missing
labeled data** (see §6.7 for the three findings). The parity harness proves the
Python binding is **byte-identical to the native Rust path** for the bound, **but
untrained**, surface — it does **not** prove the cited SOTA numbers (ADR-152
~96% PCK@20; ADR-024 room-ID > 95% / re-ID mAP > 80% / anomaly F1 > 0.90; ADR-027
cross-domain MPJPE + 20% / domain-gap < 1.5). Those bars remain **CLAIMED, not
MEASURED** by this work.
Closing it requires three steps, in dependency order:
- **(a) Add trained-weight loading to the AETHER/pose bindings — DONE (`65da488ad`).**
`EmbeddingExtractor` gained `save_weights(path)` / `load_weights(path)` /
`param_count` on both the native crate and the Python binding (GIL-released,
`ValueError`/never-panics on bad input), removing the "structurally untrained, no
loading path" blocker: a real checkpoint can now be loaded whenever one exists.
Default construction is unchanged (still random `with_seed` init, clearly labeled
untrained) — purely additive. **Format tradeoff:** rather than pull in
`safetensors`/`serde`/`bincode`, the on-disk format is raw little-endian `f32`
with a 12-byte header (8-byte magic `AETHERW1` + `u32` param count), reusing the
pre-existing `flatten_weights`/`unflatten_weights` — this deliberately preserves
`wifi-densepose-aether`'s zero-dependency std-only leaf-crate property from the
§13.a hoist. **Verified:** `cargo test -p wifi-densepose-aether` 98/98; parity 3/3
incl. the new cross-language golden `aether_weights_parity.rs` (native Rust and the
Python binding load the same weight file and produce a byte-identical embedding
SHA-256, and the loaded weights demonstrably move the output off the random-init
baseline — not a silent no-op); `pytest test_aether.py` 13/13 (up from 9).
**This does NOT close §6.7** — it is the *capability* to load weights, not trained
weights; (b) and (c) below remain, and no SOTA number is validated yet.
- **(b) Commit or source a small labeled CSI fixture** (input CSI + ground-truth
pose/identity/room labels) — **still OPEN.** Genuine **data-acquisition scope**.
- **(c) Build a real eval harness** computing PCK / mAP / room-ID / anomaly-F1 /
Spearman on (a)+(b) and asserting the published bars — **still OPEN.**
With (a) landed, the remaining work is (b) and (c): genuine research /
data-acquisition scope beyond one session. This is now purely a data-availability +
missing-eval-infra problem, **not** a binding defect. Status stays **Proposed**
until (b)(c) land and §4.3 is run for real.
+486
View File
@@ -0,0 +1,486 @@
# ADR-186: Training progress API — wire the orphaned in-server trainer to `/ws/train/progress`
| Field | Value |
|-------|-------|
| **Status** | Accepted |
| **Date** | 2026-07-21 |
| **Deciders** | ruv |
| **Codename** | **TRAIN-RECONNECT** — connecting a trainer that was written, committed, and then never plugged in |
| **Relates to** | [ADR-051](ADR-051-sensing-server-decomposition.md) (main.rs decomposition into ~14 modules), [ADR-151](ADR-151-per-room-calibration.md) (`train-room` specialist bank), [ADR-152](ADR-152-wifi-pose-sota-2026.md) (MAE recipe / geometry conditioning), [ADR-166](ADR-166-quality-engineering-security-hardening.md) (WS auth + god-object decomposition) |
| **Tracking issue** | [#1233](https://github.com/ruvnet/wifi-densepose/issues/1233) — "Training does not start /ws/train/progress returns 404 and no model is generated" (open) |
---
## 1. Context
### 1.1 The reported gap
A user starting training from the web dashboard hits
`ws://localhost:3000/ws/train/progress`, which **404s**, and the backend never
produces a trained `.rvf` model or any further log output beyond a single
"Training started" line. Issue #1233 is open, and the repo owner's own comment on
it states:
> The `/ws/train/progress` WebSocket endpoint is not yet exposed in the stable
> server — the training pipeline (room-calibration specialists, MAE pretraining)
> runs via the CLI (`wifi-densepose train-room`) rather than through the
> HTTP/WebSocket API, which is why the Docker image returns 404 for that path.
So the dashboard has a **"Start Training" button that silently no-ops**: it POSTs a
config, receives a `success: true` response, and then nothing happens — no error is
surfaced, no model is produced, no progress stream exists. A button that appears to
work but does nothing is the definition of slop, and this ADR exists to close that
gap honestly.
### 1.2 What the live server actually does today (evidence)
The stable server mounts **stub** training handlers. The POST handler flips a string
flag, logs one line, and returns success — it starts no job:
```rust
// v2/crates/wifi-densepose-sensing-server/src/main.rs:49865006
async fn train_start(
State(state): State<SharedState>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
let mut s = state.write().await;
if s.training_status == "running" { /* ... */ }
s.training_status = "running".to_string();
s.training_config = Some(body.clone());
info!("Training started with config: {}", body); // ← the one log line the issue reports
Json(serde_json::json!({
"success": true,
"status": "running",
"message": "Training pipeline started. Use GET /api/v1/train/status to monitor.",
}))
}
```
These three stubs — and **nothing else training-related** — are wired into the live
router:
```rust
// v2/crates/wifi-densepose-sensing-server/src/main.rs:80688071
// Training endpoints
.route("/api/v1/train/status", get(train_status))
.route("/api/v1/train/start", post(train_start))
.route("/api/v1/train/stop", post(train_stop))
```
There is **no `/ws/train/progress` route in the live app** — hence the 404 that
issue #1233 reports. The stub state fields backing them are just:
```rust
// v2/crates/wifi-densepose-sensing-server/src/main.rs:11251127
training_status: String, // "idle" | "running" | ...
training_config: Option<serde_json::Value>,
```
### 1.3 The surprising finding: a real trainer already exists, orphaned
The gap is **not** that training was never built for the server. A complete
in-server training pipeline **already exists in the tree** at
`v2/crates/wifi-densepose-sensing-server/src/training_api.rs` (1,860 lines). Its own
module doc describes what it does (`training_api.rs:125`):
- Loads recorded CSI from `.csi.jsonl` files, extracts signal features (subcarrier
variance, temporal gradients, Goertzel frequency-domain power).
- Trains a regularised linear model via batch gradient descent.
- Exports a calibrated `.rvf` model container via `RvfBuilder` on completion.
- **"No PyTorch / `tch` dependency is required. All linear algebra is implemented
inline using standard Rust math."** (`training_api.rs:1113`)
It runs training on a **background tokio task** and streams progress over a
`tokio::sync::broadcast` channel to a real WebSocket handler:
- `start_training` spawns the job: `tokio::spawn(async move { ... })`
(`training_api.rs:1564`, spawn at `:1610`).
- `ws_train_progress_handler` subscribes to `training_progress_tx` and forwards
`{"type":"progress", "data": …}` frames (`training_api.rs:17781836`).
- A `routes()` factory wires the whole surface, **including the missing route**:
```rust
// v2/crates/wifi-densepose-sensing-server/src/training_api.rs:18411849
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/v1/train/start", post(start_training))
.route("/api/v1/train/stop", post(stop_training))
.route("/api/v1/train/status", get(training_status))
.route("/api/v1/train/pretrain", post(start_pretrain))
.route("/api/v1/train/lora", post(start_lora_training))
.route("/ws/train/progress", get(ws_train_progress_handler))
}
```
**This module is dead code.** There is no `mod training_api;` declaration anywhere
in the crate — a repo-wide search for `training_api` returns only a doc-comment
mention in `path_safety.rs:9`. Because Rust never sees the file without a `mod`
declaration, `training_api.rs` is **not compiled into the binary at all**, and
`training_api::routes()` is never merged into the app. It was written, committed
(last touched by commit `9b07dff29`), and then orphaned.
### 1.4 Why it would not even compile if naively wired in
The orphan was written against a **different state shape than the one that shipped**.
`training_api.rs` expects its parent to expose an `AppStateInner` carrying a training
sub-state and a broadcast sender:
```rust
// v2/crates/wifi-densepose-sensing-server/src/training_api.rs:249
pub type AppState = Arc<RwLock<super::AppStateInner>>;
// handlers read s.training_state.status, s.training_state.task_handle,
// s.training_progress_tx (e.g. training_api.rs:1588, :1610, :1788)
```
But the **real** `AppStateInner` (`main.rs:1024`, aliased `SharedState` at
`main.rs:1249`) has none of those fields — only the `training_status: String` /
`training_config` stubs from §1.2. `training_state: TrainingState` is defined
locally in `training_api.rs:232`, and `training_progress_tx` exists nowhere on the
live state. So adding `mod training_api;` today produces a compile error: the module
references `AppStateInner` fields that do not exist. Wiring it in requires
**reconciling the state struct first**, not merely uncommenting a route.
### 1.5 The working path today
The path that actually trains a model is the CLI, exactly as the maintainer's
comment says:
- `wifi-densepose train-room``room.rs:241` `train_room(...)`, the ADR-151
Stage-25 per-room specialist-bank trainer (`enroll → train-room → room-watch`).
- The heavier `wifi-densepose-train` crate exposes epoch-level metrics
(`trainer.rs:43` `pub epoch: usize`, `trainer.rs:64` `best_epoch`) that a progress
stream could surface directly — the data a WebSocket needs already exists in the
training loop.
### 1.6 What this ADR is *not*
- Not a rewrite of the trainer. The pipeline in `training_api.rs` already exists;
this ADR reconnects and hardens it.
- Not a move of GPU/`tch`-backed training into the Axum server. The in-server
trainer is deliberately `tch`-free (§1.3). Heavy MAE/LoRA training stays in the
CLI / `wifi-densepose-train` crate; the server streams progress for the light,
pure-Rust specialist trainer and (optionally) proxies status for CLI-launched runs.
- Not a change to the `train-room` CLI contract (ADR-151). The CLI remains the
authoritative path for offline / batch training.
---
## 2. Current state — evidence
| Artifact | Value | Source |
|---|---|---|
| Live POST handler | `train_start` — flips a flag, logs, returns `success:true`, starts no job | `main.rs:49865006` |
| The "Training started" log line from the issue | `info!("Training started with config: {}", body)` | `main.rs:5000` |
| Live training routes | `train/status`, `train/start`, `train/stop` (stubs only) | `main.rs:80688071` |
| `/ws/train/progress` in live app | **Absent** → 404 | (no route in `main.rs` router) |
| Live training state fields | `training_status: String`, `training_config: Option<Value>` | `main.rs:11251127` |
| Real in-server trainer | 1,860-line implemented pipeline, `tch`-free, exports `.rvf` | `training_api.rs:125` |
| Real WS progress handler | subscribes to broadcast, streams `progress` frames | `training_api.rs:17781836` |
| Real route factory (has the missing route) | `routes()` incl. `/ws/train/progress` | `training_api.rs:18411849` |
| Background job spawn | `tokio::spawn` of the training task | `training_api.rs:1564`, spawn `:1610` |
| `mod training_api;` declaration | **None in the crate** (only a doc mention) | `path_safety.rs:9` |
| State-shape mismatch | expects `super::AppStateInner.{training_state, training_progress_tx}` | `training_api.rs:249`, `:232` |
| Real `AppStateInner` / `SharedState` | has neither field | `main.rs:1024`, `:1249` |
| Working training path | CLI `train-room` (ADR-151 specialist bank) | `room.rs:241` |
| Epoch metrics available to stream | `TrainMetrics.epoch`, `best_epoch` | `train/src/trainer.rs:43`, `:64` |
---
## 3. Gap analysis
| Capability | Desired | Today | Gap severity |
|---|---|---|---|
| `/ws/train/progress` resolves | 101 Switching Protocols, streams epoch/loss/eta | 404 (route absent) | **Critical** — the reported bug |
| "Start Training" produces a model | background job trains and writes `.rvf` | flag flip + one log line, no job, no model | **Critical** |
| Error surfaced to the user | button reflects real state / disabled with reason | silent no-op, `success:true` | **Critical** (slop) |
| In-server trainer compiled | part of the crate, unit-tested | orphaned; not compiled (no `mod`) | **High** |
| State supports progress streaming | `training_state` + `training_progress_tx` on `AppStateInner` | absent — orphan won't compile as-is | **High** |
| WS auth on the training surface | `/ws/train/progress` under bearer gate (ADR-166 §Sprint-1) | n/a (route absent) | **High** |
| `dataset_ids` path safety | validated before file open | `path_safety.rs` exists but unreached by live routes | **Medium** |
| Server ↔ CLI parity | shared/consistent training semantics | two divergent trainers (stub vs CLI vs orphan) | **Medium** |
---
## 4. Decision
**Chosen path: wire the existing in-server trainer into the live server** — reconcile
the state struct, declare the module, merge `training_api::routes()`, delete the
stub handlers, and expose a real `/ws/train/progress` that streams epoch/loss/eta
events from the already-implemented background job.
This is called **TRAIN-RECONNECT**.
### 4.1 Why this path, and not "make the button honestly say CLI-only"
The task framing offered two honest options. Investigation decided it:
| Consideration | Evidence | Implication |
|---|---|---|
| Is server-side training genuinely GPU/`tch`-bound (→ keep CLI-only)? | The in-server trainer is explicitly **`tch`-free**, pure Rust, exports `.rvf` (`training_api.rs:1113`) | The "too heavy for Axum" argument is contradicted by the code |
| Does a real streaming implementation already exist? | Full pipeline + broadcast + WS handler + `routes()` present (`training_api.rs:1564,1778,1841`) | The impressive-sounding option is also the *least* new code — it already exists |
| Why does it 404 then? | No `mod training_api;`; state-shape mismatch (`:249` vs `main.rs:1024`) | The fix is reconnection + reconciliation, not new invention |
Because the honest, code-supported reality is "a working trainer was written and left
unplugged," the right decision is to plug it in — this is not choosing the flashier
option over the code; it *is* what the code says.
**However**, path B is retained as a **mandatory fallback guarantee** (Phase P5): if,
for a given build/deployment, server-side training is disabled (e.g. behind a
feature flag, or on the lightweight appliance image where recordings aren't
available), the dashboard button MUST be disabled with a tooltip pointing at
`wifi-densepose train-room` — never a silent `success:true` no-op again. The slop is
eliminated in both the enabled and disabled configurations.
### 4.2 Scope boundary — light trainer streams, heavy trainer proxies
- The **pure-Rust specialist trainer** (`training_api.rs`, ADR-151 flavour) runs
in-process and streams live epoch/loss/eta over `/ws/train/progress`.
- **Heavy MAE/LoRA training** (`wifi-densepose-train`, `tch`/GPU) stays CLI-launched.
The server does not host it; at most `/api/v1/train/status` reports on a
CLI-launched run if one registers itself. Streaming heavy training is out of scope
for this ADR (noted as an open question, §8).
---
## 5. Detailed design
### 5.1 Reconcile `AppStateInner`
Replace the two stub fields (`main.rs:11251127`) with the sub-state the trainer
expects, so `training_api.rs` compiles against `super::AppStateInner`:
```rust
// main.rs — inside AppStateInner (replacing training_status / training_config)
training_state: training_api::TrainingState, // status, epoch, best_pck, task_handle
training_progress_tx: tokio::sync::broadcast::Sender<String>, // progress fan-out
```
`train_status` consumers that read `s.training_status` / `s.training_config` are
updated to read `s.training_state.status`. The broadcast sender is created at state
init (`main.rs:7826` region, where the stubs are seeded today).
### 5.2 Declare and merge the module
- Add `mod training_api;` to `main.rs` (or `pub mod` in `lib.rs` if the router is
assembled there).
- Delete the stub handlers `train_start` / `train_stop` / `train_status`
(`main.rs:49775023`) and their three route mounts (`main.rs:80698071`).
- Merge the real router **after** `.with_state(state.clone())`, the same pattern the
RuField surface already uses (`main.rs:81048111`):
```rust
// main.rs router assembly
.merge(training_api::routes())
```
so that `/api/v1/train/*` and `/ws/train/progress` resolve against the shared state.
### 5.3 Auth and safety (ADR-166 alignment)
- `/api/v1/train/*` sits under the existing opt-in bearer gate (`main.rs:80958102`,
`RUVIEW_API_TOKEN`). `/ws/train/progress` follows the same policy decision made for
`/ws/sensing` — document explicitly whether the training WS is gated (recommended:
gated when a token is set, since training reads/writes recordings and models).
- `dataset_ids` from `StartTrainingRequest` (`training_api.rs:126130`) are resolved
through `path_safety` before any file open — `path_safety.rs:9` already anticipates
`{dataset_id}.csi.jsonl` under `RECORDINGS_DIR`; wire it in the load path.
- Single-job concurrency guard: `start_training` already rejects a second run while
`training_state.status.active` (`training_api.rs:1571`) — keep it.
### 5.4 Progress event schema (already emitted)
The WS handler already frames messages as `{"type":"status"|"progress", "data": …}`
(`training_api.rs:17961815`). Confirm the `data` payload carries at minimum
`epoch`, `total_epochs`, `loss`, `best_pck`, and an `eta_seconds`; these map onto the
`TrainMetrics`/`TrainingStatus` fields already populated by the loop
(`training_api.rs:1251`, `train/src/trainer.rs:43,64`).
### 5.5 Dashboard honesty (both configurations)
- **Enabled build:** button POSTs `/api/v1/train/start`, then opens
`/ws/train/progress`; the UI renders live epoch/loss/eta and a terminal
success/failure with the output `.rvf` path.
- **Disabled build:** `/api/v1/train/start` returns a structured
`{"enabled": false, "reason": "...", "cli": "wifi-densepose train-room"}` and the
button renders disabled with a tooltip — no silent `success:true`.
---
## 6. Phase ledger
```
P0 ──► P1 ──► P2 ──► P3 ──► P4 ──► P5 ──► P6
repro state wire stream auth+ dash tests+
+audit recon router job safety honesty witness
```
### P0 — Reproduce & audit (evidence lock)
- [x] Confirmed the orphan: `grep -rn "mod training_api"` returned **nothing**; the only
hit was a doc mention in `path_safety.rs`. `training_api.rs` was uncompiled.
- [x] Confirmed the stub no-op (`train_start` at `main.rs:4986` flipped a string + logged
one line, no job, no `.rvf`) and the missing `/ws/train/progress` route.
### P1 — Reconcile `AppStateInner`
- [x] Replaced `training_status`/`training_config` with `training_state:
training_api::TrainingState` + `training_progress_tx: broadcast::Sender<String>`.
- [x] Updated state init; the only readers of the old fields were the stub handlers (deleted).
- [x] Added `mod training_api;` (+ `mod path_safety;`); the module compiles against the real state.
### P2 — Wire the router, delete the stubs
- [x] Removed `train_start`/`train_stop`/`train_status` and their 3 route mounts.
- [x] `.merge(training_api::routes())` — merged **before** `.with_state(...)` (not after).
The RuField surface merges after because it carries a *different* state; the training
router shares `SharedState`, so merging before is what puts `/api/v1/train/*` under the
same `/api/v1/*` bearer gate as everything else.
- [x] `/api/v1/train/*` and `/ws/train/progress` resolve (verified by HTTP tests, not 404).
### P3 — Confirm the real job streams and produces a model
- [x] The spawned job loads `.csi.jsonl` (falls back to a `frame_history` snapshot),
runs the gradient-descent loop, and writes a `.rvf` under `data/models`.
- [x] Progress frames carry `epoch`, `total_epochs`, `train_loss`, `val_pck`, `eta_secs`.
- [x] Server-vs-CLI semantics documented as **intentionally divergent** (§4.2, §9.2):
the server runs the light pure-Rust specialist trainer; heavy MAE/LoRA stays CLI.
### P4 — Auth & path safety
- [x] `/api/v1/train/*` sits under the existing `RUVIEW_API_TOKEN` bearer gate (merged
before `.with_state`); `/ws/train/progress` is intentionally **ungated**, matching
`/ws/sensing` (browsers can't attach an `Authorization` header to a WS upgrade).
- [x] `dataset_ids` resolved via `path_safety::safe_id` before file open; pinned by
`load_recording_frames_rejects_path_traversal`.
- [x] Single-job guard: `spawn_training_job` rejects a second start while active
(`is_active()` → `active_error`).
### P5 — Dashboard honesty (fallback guarantee)
- [x] Enabled build: `TrainingPanel` opens `/ws/train/progress` before the POST and renders
live epoch/loss/PCK/ETA + a terminal Complete state (already wired; verified).
- [x] Disabled build (`RUVIEW_DISABLE_SERVER_TRAINING`): start returns
`{enabled:false, cli:"wifi-densepose train-room"}` HTTP 409; the dashboard reads
`enabled` off `/api/v1/train/status` and disables the Start buttons with a CLI
tooltip — no silent no-op. Implemented via a runtime flag rather than a Cargo feature
so the `--no-default-features` test build keeps training ON (§9.4 resolved this way).
### P6 — Tests & witness
- [x] Live-socket test `ws_train_progress_live_101_and_frame`: genuine 101 handshake + a real
progress frame after POST start. Plus `ws_train_progress_route_is_wired_not_404`.
- [x] `http_train_start_produces_model_and_streams`: POST start → poll status → `.rvf` exists.
- [x] CHANGELOG updated. README/CLAUDE have no training route table, so no route-table edit
was needed there.
*(All phases complete. Acceptance criteria verified below — this ADR is Accepted.)*
---
## 7. Acceptance criteria (concrete verification)
All must pass before ADR-186 is Accepted:
- [x] **Orphan is reconnected:**
`grep -rn "mod training_api" v2/crates/wifi-densepose-sensing-server/src/`
returns a hit (`main.rs`), and
`cargo build -p wifi-densepose-sensing-server` **compiles** (proves the state
reconciliation in §5.1 is correct — the module cannot compile against the
current `AppStateInner`). **VERIFIED.**
- [x] **Route no longer 404s (HTTP upgrade):** verified in-process rather than with a live
`curl` — `ws_train_progress_live_101_and_frame` binds the training router on a real
socket and `tokio_tungstenite::connect_async` completes a genuine **101** handshake
(asserts `resp.status() == 101`); `ws_train_progress_route_is_wired_not_404` also
confirms the route is reached (426 under `oneshot`, **not** 404). **VERIFIED.**
- [x] **Progress actually streams:** `ws_train_progress_live_101_and_frame` connects the WS,
POSTs `/api/v1/train/start`, and receives a real `{"type":"progress","data":{...}}`
frame within the 10 s ceiling. **VERIFIED.**
- [x] **A model is produced:** `http_train_start_produces_model_and_streams` POSTs start,
polls `/api/v1/train/status` to completion, and asserts a **new `.rvf`** appeared under
`data/models/` (snapshot diff). Also covered by the trainer-level
`training_job_streams_real_progress_and_writes_model`. **VERIFIED.**
- [x] **No silent no-op remains:** `http_train_start_disabled_returns_structured_409` sets
`RUVIEW_DISABLE_SERVER_TRAINING` and asserts POST start returns **HTTP 409** with
`{"enabled":false, ...,"cli":"wifi-densepose train-room"}` and never `success:true`.
**VERIFIED.**
- [x] **Auth honored:** `/api/v1/train/*` is merged into the router **before** the
`RUVIEW_API_TOKEN` bearer middleware and `.with_state`, so it is covered by the exact
same `/api/v1/*` gate as every other authenticated route (verified by construction /
code review; `/ws/train/progress` is intentionally ungated like `/ws/sensing`). No new
dedicated runtime token test was added — the gate is the shared, already-tested
`bearer_auth` middleware. **VERIFIED (by construction).**
- [x] **Path safety:** `load_recording_frames_rejects_path_traversal` asserts
`dataset_ids:["../../etc/passwd"]` yields no frames (rejected by `path_safety::safe_id`
before any file open). **VERIFIED.**
- [x] **Integration test green:** `ws_train_progress_live_101_and_frame` (`#[tokio::test]`)
serves the training router, opens `/ws/train/progress`, and asserts a 101 upgrade + a
real progress frame — and, being built on `training_api::routes()`, cannot compile if
the module is orphaned again. **VERIFIED.**
- [x] **Workspace regression:** `cargo test -p wifi-densepose-sensing-server
-p wifi-densepose-train --no-default-features` — sensing-server bin **217 passed /
0 failed**, all train suites **0 failed**. A full `cargo test --workspace
--no-default-features` run initially surfaced a **test-only parallelism race** in the
new tests (two model-writing tests deleted `.rvf`s by directory-diff, occasionally
removing a file a third test asserted existed) — fixed by removing the cross-test
deletions (each test cleans only its own artifact; `data/models` is gitignored).
Re-verified post-fix: `cargo test --workspace --no-default-features` — **0 failed**
(exit 0). **VERIFIED.**
---
## 8. Consequences
### Positive
- Closes issue #1233: the dashboard button either trains-and-streams or honestly says
"use the CLI" — the silent no-op is gone in every configuration.
- Reclaims 1,860 lines of already-written, already-committed trainer that were dead
(uncompiled) code, and adds a test that keeps them wired.
- `/ws/train/progress` gives the UI real epoch/loss/eta, matching the maintainer's
stated intent.
- Forces the state-shape reconciliation that the orphan implied but never landed,
removing a latent "two competing training designs" trap in `AppStateInner`.
### Negative
- Editing `AppStateInner` (`main.rs:1024`) and the router (`main.rs:8068`) touches the
large `main.rs`; merge-conflict risk with concurrent work on the same file (the
ADR-166 decomposition is relevant here).
- Adds a live training code path to the server's attack surface — mitigated by the
bearer gate and `path_safety`, but it must be reviewed (network/hardware boundary,
per the pre-merge security checklist).
- Server and CLI now have two trainers that must be kept semantically consistent, or
their divergence explicitly documented.
### Neutral
- Heavy MAE/LoRA/`tch` training remains CLI-only; the server streams only the
light pure-Rust specialist trainer. Streaming heavy runs is deferred.
- The progress event schema (`epoch/loss/best_pck/eta`) is already emitted by the
orphan; no new schema is invented, only confirmed and documented.
---
## 9. Open questions
1. **WS auth policy for `/ws/train/progress`:** gate it whenever `RUVIEW_API_TOKEN`
is set (like `/api/v1/*`), or leave it open like `/ws/sensing`? *Tentative: gate
it — training reads recordings and writes models.*
2. **Server ↔ CLI trainer parity:** should the in-server trainer and
`wifi-densepose train-room` (ADR-151) share one code path, or remain deliberately
separate (server = quick UI-driven specialist fit; CLI = full bank + geometry
conditioning)? *Tentative: keep separate, document the split, share feature
extraction where cheap.*
3. **Heavy-training progress:** can a CLI-launched `wifi-densepose-train` (`tch`)
run register itself so `/api/v1/train/status` and the WS can report on it without
hosting it in-process? *Tentative: out of scope here; a follow-on ADR.*
4. **Feature-flagging server training:** should in-server training be behind a Cargo
feature (off on the lightweight appliance image), making the P5 disabled-button
path the default there? *Tentative: yes — flag it; default the UI to the honest
disabled state on images without recordings.*
---
## 10. References
- **Issue #1233**: https://github.com/ruvnet/wifi-densepose/issues/1233 — the reported bug.
- **Live stubs**: `v2/crates/wifi-densepose-sensing-server/src/main.rs:49775023` (handlers),
`:80688071` (routes), `:11251127` (state fields), `:1024`/`:1249` (`AppStateInner`/`SharedState`).
- **Orphaned trainer**: `v2/crates/wifi-densepose-sensing-server/src/training_api.rs` —
module doc `:125`, `TrainingState` `:232`, `AppState` alias `:249`, `start_training` `:1564`
(spawn `:1610`), WS handler `:17781836`, `routes()` `:18411849`.
- **Not-a-module proof**: repo-wide `training_api` only in `path_safety.rs:9` (doc comment).
- **CLI working path**: `v2/crates/wifi-densepose-cli/src/room.rs:241` `train_room` (ADR-151).
- **Epoch metrics**: `v2/crates/wifi-densepose-train/src/trainer.rs:43`, `:64`.
- **ADR-166**: WebSocket authentication + `main.rs` decomposition (security context for this change).
- **ADR-151**: per-room calibration / `train-room` specialist bank.
@@ -0,0 +1,198 @@
# ADR-187: `archive/v1` Deprecation & Model-Weights Honest Labeling
- **Status**: Accepted
- **Date**: 2026-07-21
- **Deciders**: ruv
- **Tags**: archive-v1, deprecation, densepose-head, model-weights, honest-labeling, prove-everything, credibility, pip-tombstone
- **Refs**: [#509](https://github.com/ruvnet/RuView/issues/509) (missing model weights / reproducibility), [#1125](https://github.com/ruvnet/RuView/issues/1125) ("has anyone got this to work?")
- **Relates to**: [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (pip modernization + 1.99.0 tombstone), [ADR-160](ADR-160-edge-skill-library-honest-labeling.md) (honest-labeling precedent), [ADR-079](ADR-079-camera-ground-truth-training.md) (camera-supervised pose target), [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) (WiFlow-STD PCK@20 measurement), [ADR-175](ADR-175-int8-quantization-half-pose-model-measured.md) (int8 pose trade-off), [ADR-101](ADR-101-pose-estimation-cog.md) (pose cog)
---
## Context
Two open GitHub issues are, at root, the same complaint: the project's public surface
lets a reader believe a WiFi→17-keypoint pose model exists and produces real accuracy,
when the specific code they land on cannot back that claim.
- **#509** — a detailed technical review states: *"While the network architecture for
DensePoseHead is defined in the code, there are no pre-trained weights (.pth or .onnx
files) available in the repository,"* and questions whether ESP32 1×1 SISO antennas
can match the multi-antenna NIC research this project is inspired by.
- **#1125** — a user asks for anyone to testify the project actually runs and returns
real data. A pure credibility complaint.
This ADR follows the **prove-everything / anti-"AI-slop"** directive and the
**honest-labeling** precedent set by ADR-160: the fix is to make the labels TRUE, not
to fabricate a capability. Grading vocabulary (from ADR-152 / ADR-160):
- **MEASURED** — reproduced in this worktree; the file/absence was directly inspected.
- **DATA-GATED** — a real code path exists; honestly flagged where the accuracy is not validated.
- **NO-ACTION (already-honest)** — audited, found correct, cited as a positive.
### What the investigation actually found (MEASURED in this worktree)
The situation is **more nuanced than either issue implies** — worse in one place, and
distinctly *better* in others. Forcing a uniformly negative narrative would itself be
dishonest. The findings:
**1. `archive/v1` — the issue reporter is correct here.**
- `archive/v1/src/models/densepose_head.py` defines `DensePoseHead` (segmentation +
UV-regression heads). Its `_initialize_weights()` uses **`kaiming_normal_` random
initialization only** — there is no checkpoint-loading path in the class.
- `Glob archive/v1/**/*.{pth,onnx,safetensors,pt,ckpt,bin}`**zero files**. There are
**no trained weights anywhere under `archive/v1/`.** The "architecture defined, no
weights" claim is TRUE for this tree.
- `archive/v1/README.md` calls the tree "the legacy Python implementation" in a single
closing note but does **not** loudly warn users off it, and there is **no
`archive/v1/DEPRECATED.md`.** This is the dead-but-present code that shows up in greps
and search and reads as if it were the live implementation.
- Per ADR-117, this exact tree is the source of the tombstoned pip package
`wifi-densepose 1.x` (1.99.0 raises an `ImportError` telling users to migrate). The
code is already tombstoned *on PyPI* but not *in the repo*.
**2. `v2` (the current, maintained system) — real weights DO exist; the "no weights
anywhere" reading is FALSE at the project level.** Git-tracked, committed checkpoints:
- `v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors` (507 KB) +
`pose_v1.onnx` (12 KB) + `train_results.json` — a **real committed 17-keypoint
model**, trained with Candle on an RTX 5080.
- `v2/crates/cog-person-count/cog/artifacts/count_v1.{safetensors,onnx}` — a committed
person-count model.
- Externally published on Hugging Face (not committed, but real and released):
`ruvnet/wifi-densepose-pretrained` (CSI encoder + presence head, honestly re-labeled
at **82.3% held-out temporal-triplet accuracy** — the older "100% presence" figure was
already retracted, an existing honest-labeling win) and `ruvnet/wifi-densepose-mmfi-pose`
(a pose model reporting **82.69% torso-PCK@20** on the MM-Fi `random_split` protocol).
- ADR-152 measurement (a): the *external* WiFlow-STD (DY2434) model was reproduced at
**96.09% PCK@20** on an RTX 5080 (graded MEASURED-EQUIVALENT). That is an external
baseline, not RuView's own weights.
**3. The honest gap is narrow and specific — the live, on-device ESP32 17-keypoint
pose path.** Per `v2/crates/cog-pose-estimation/cog/README.md` (already an exemplary
"Honest reading" section):
- The committed `pose_v1` scores **PCK@20 = 3.0% / PCK@50 = 18.5%** on a 217-sample
holdout — **below the ADR-079 target of PCK@20 ≥ 35%.** It learns coarse structure
(`r_hip` 77% PCK@50) but distal/face joints are near-random. `encoder_init` was
`random`; it was trained on a single 30-min seated-at-desk recording (1,077 samples,
avg confidence 0.44).
- The cog's **runtime inference path is still a centred-skeleton stub returning
`confidence=0`** — the `pose_v1.safetensors` weights are not yet wired into
`src/inference.rs`.
- ADR-079 records the proxy-supervised baseline at **PCK@20 = 2.5%**, and ADR-152
**retracted** the internal camera-supervised 92.9% PCK@20 figure (it was a
constant-output model scored under an absolute threshold on near-static frames; a mean
predictor scores 100% under the same broken protocol).
### The real problem to fix
Not "the project has no weights" (false) and not "there is a validated pretrained
DensePoseHead" (false for the live ESP32 path). The real problem is a **labeling and
navigation gap**:
1. `archive/v1`'s random-init `DensePoseHead` is indistinguishable, to a grepping
reader, from the live implementation, and carries no deprecation notice.
2. Nowhere is the split stated plainly: *which* checkpoints are real and validated
(presence 82.3%, MM-Fi pose 82.69% torso-PCK@20), *which* are real-but-weak and
honestly labeled (`pose_v1` 3% PCK@20, runtime stubbed), and *which* are
architecture-only with no weights at all (`archive/v1` `DensePoseHead`).
## Decision
Two coordinated honest-labeling actions. Neither invents a capability; both make the
public surface match what the code and checkpoints actually deliver.
### (a) Formally deprecate `archive/v1` in the repo — MEASURED gap, proposed fix
- **Add `archive/v1/DEPRECATED.md`** — a loud tombstone stating that `archive/v1` is the
original pure-Python implementation, is **unmaintained and superseded**, that its
`DensePoseHead` is **architecture-only with random-initialized weights and ships no
trained checkpoint**, and that the maintained path is the `v2/` Rust workspace + the
`wifi-densepose 2.x` / `ruview` pip wheel (ADR-117). Mirror the disclaimer tone of
ADR-160's `//!` headers and the pip 1.99.0 tombstone text.
- **Prepend a loud notice to `archive/v1/README.md`** (the file exists) — a `> ⚠️
DEPRECATED` block at the very top pointing to `DEPRECATED.md`, `v2/`, and the pip
wheel, before any of the existing "how to install v1" content.
- **Rule:** no doc outside `archive/v1/` may reference `archive/v1` code (other than the
ADR-028 deterministic proof at `archive/v1/data/proof/verify.py`, which is a
legitimately live signal-pipeline witness and stays) as if it were current. The two
README references verified (`README.md` lines 139/198/204; `docs/user-guide.md`
proof/swift-compile lines) are all proof/utility invocations, not implementation
claims — they are acceptable and out of scope.
### (b) Model-weights honest labeling — state the three tiers explicitly
Add a **"Model weights: what's real, what's not"** subsection to `README.md` and
`docs/user-guide.md` that names the three tiers verified above, so no reader can infer
"a pretrained 17-keypoint DensePoseHead produces real pose accuracy on my ESP32":
| Tier | Checkpoint(s) | Honest status |
|------|---------------|---------------|
| **Real & validated** | `ruvnet/wifi-densepose-pretrained` (encoder + presence, 82.3% held-out temporal-triplet); `ruvnet/wifi-densepose-mmfi-pose` (82.69% torso-PCK@20, MM-Fi `random_split`); `count_v1` | MEASURED / published; keep current honest labels |
| **Real but weak (honestly labeled)** | committed `pose_v1.safetensors` in `cog-pose-estimation` | **PCK@20 = 3.0%**, below the ADR-079 ≥35% target; runtime path is a `confidence=0` stub until weights are wired into `src/inference.rs`. Already disclosed in the cog README; surface the same caveat wherever the live ESP32 pose feature is advertised |
| **Architecture only, no weights** | `archive/v1` `DensePoseHead` | random-init, no checkpoint; deprecated per (a) |
- The existing MM-Fi/presence honest labels (retraction of "100% presence", the cog
"Honest reading") are **NO-ACTION positives** — cite them, do not weaken them.
- The live ESP32 17-keypoint claim stays **DATA-GATED**: the path to a first
*reproducible* on-device baseline is ADR-079 (multi-session, full-body-framed,
camera-supervised, ≥30K paired samples at conf ≥0.7, target PCK@20 ≥35%), tracked in
[#645]. Do not advertise the live ESP32 pose feature without the "first-cut / below
target / runtime stub" caveat until that baseline is MEASURED.
- Directly answer #509's ESP32-SISO question in the docs, honestly: single-antenna 56-
subcarrier CSI at a 20-frame window does **not** carry the fine-grained spatial
information the multi-antenna NIC research relies on (the cog README already shows
distal/face joints near-random) — the shippable pose accuracy the project *can* stand
behind today is the **MM-Fi benchmark** number, not a live single-ESP32 number.
## Phase ledger
| Phase | Action | State |
|-------|--------|-------|
| **P0** | This ADR (investigation + decision) | **DONE** (this file) |
| **P1** | Add `archive/v1/DEPRECATED.md` + loud notice atop `archive/v1/README.md` | **DONE** (1fb5397dd) |
| **P2** | Add "Model weights: what's real, what's not" tier table to `README.md` + `docs/user-guide.md`; add the caveat wherever the live ESP32 17-keypoint feature is advertised | **DONE** (1fb5397dd; follow-up caveated the hardware table, hero caption, and live-pipeline note) |
| **P3** | Answer #509's SISO/no-weights question and #1125's "does it run" in `docs/user-guide.md` (point to the reproducible proofs: MM-Fi arena, `archive/v1/data/proof/verify.py`, cog `train_results.json`) | **DONE** (1fb5397dd) |
| **P4** | Close the DATA-GATED live-pose gap via ADR-079 first reproducible on-device baseline (PCK@20 ≥35%) + wire `pose_v1.safetensors` into `cog-pose-estimation/src/inference.rs` | ACCEPTED-FUTURE ([#645]) |
## Acceptance criteria
- [x] `archive/v1/DEPRECATED.md` exists and names `v2/` + the pip wheel as the maintained path.
- [x] `archive/v1/README.md` opens with a `> ⚠️ DEPRECATED` block before any install instructions.
- [x] `README.md` and `docs/user-guide.md` no longer let a reader infer that `archive/v1`
or an untrained/random-init `DensePoseHead` produces real pose accuracy without the
caveats added here.
- [x] The live ESP32 17-keypoint pose feature is nowhere advertised without its
"first-cut, PCK@20 = 3.0%, below ADR-079 target, runtime stub" caveat.
- [x] The three real/published checkpoints (presence 82.3%, MM-Fi pose 82.69% torso-PCK@20,
`count_v1`) keep their existing honest labels — nothing is weakened or overclaimed.
- [x] No claim is added that is not MEASURED or explicitly DATA-GATED.
## Consequences
### Positive
- A grepping reader can no longer mistake `archive/v1`'s random-init `DensePoseHead` for
the live system; the dead code is loudly tombstoned in the repo, matching its PyPI 1.99.0 tombstone.
- #509 and #1125 get an honest, verifiable answer: real trained weights *do* exist
(presence + MM-Fi pose are published and benchmarked), the *specific* file the reporter
found is architecture-only, and the live ESP32 pose path is honestly weak-and-in-progress.
- Reinforces the ADR-160 honest-labeling discipline: the project's credibility comes from
precise labels, not from a suppressed or inflated narrative.
### Negative
- The docs must openly state that the live single-ESP32 17-keypoint pose is not yet at a
citable accuracy — a short-term "looks less finished" cost, paid for by not overclaiming.
- Two more files to keep in sync (`DEPRECATED.md`, the tier table) as the checkpoints evolve.
### Neutral
- No code or model behavior changes; `archive/v1` stays in the tree as a research archive
(ADR-117 §1.3) and its ADR-028 proof witness is untouched.
- Purely documentation/labeling; no crate, wheel, or firmware rebuild required.
## References
- `archive/v1/src/models/densepose_head.py` — `DensePoseHead`, random `_initialize_weights()`, no checkpoint load.
- `archive/v1/README.md` — legacy note; no loud deprecation (target of P1).
- `v2/crates/cog-pose-estimation/cog/README.md` — the "Honest reading" precedent (PCK@20 = 3.0%, runtime stub).
- `v2/crates/cog-pose-estimation/cog/artifacts/{pose_v1.safetensors,pose_v1.onnx,train_results.json}` — committed first-cut pose model.
- `v2/crates/cog-person-count/cog/artifacts/count_v1.{safetensors,onnx}` — committed count model.
- `ruvnet/wifi-densepose-pretrained`, `ruvnet/wifi-densepose-mmfi-pose` — published, benchmarked checkpoints.
- ADR-079 §Target (PCK@20 ≥35%), ADR-152 measurement (a) (96.09% PCK@20 external; internal 92.9% retracted), ADR-160 (honest-labeling method), ADR-117 (pip 1.99.0 tombstone).
@@ -0,0 +1,171 @@
# ADR-263: Adopt RTL8720F 2.4 GHz FMCW radar as an optional RuView sensing platform
- **Status**: proposed
- **Date**: 2026-07-18
- **Deciders**: ruv
- **Tags**: realtek, rtl8720f, ameba, fmcw, radar, cfr, csi, hardware
- **Relates to**: ADR-018, ADR-063, ADR-064, ADR-095, ADR-097, ADR-260, ADR-262
## Context
Realtek's `RTL8720F-2.4G-Radar-Advantages_EN.pptx` describes an RTL8720F mode that shares the
2.4 GHz radio between Wi-Fi, Bluetooth, and an active FMCW radar. It offers two data products that
are useful to RuView:
1. **CFR (Channel Frequency Report)**, described by Realtek as the same concept as Wi-Fi CSI.
2. **Near and far Range-FFT reports**, preserving near-field content while extending observation to
approximately 56 m.
The proposed radio uses one transmit and one receive antenna, 20/40/70 MHz sweeps, configurable
8/16/32/64 microsecond chirp symbols, a maximum 2.56 ms FMCW packet, and a configurable frame
interval above 15 ms. The deck recommends 40 MHz outside Japan and 20 MHz in Japan. It also
describes EDCCA/CTS channel access, Wi-Fi/BT/radar time division, interference reporting, and
priority arbitration in the driver.
This is not a drop-in replacement for ESP32 CSI:
- it is **active monostatic FMCW**, while the ESP32 path observes Wi-Fi packet CSI;
- one Tx/one Rx has no angle-of-arrival or native multi-target separation;
- the stated 40 MHz range resolution is about 3.15 m, despite a finer 0.59 m Range-FFT report step;
- the presentation is a capability description, not an SDK contract. It contains no header names,
function signatures, callback ABI, binary layouts, toolchain version, licensing terms, or public
RTL8720F board package.
Realtek's public Ameba RTOS repository is the base. Release v1.2.1 includes the CSI API and fixes a
CSI application-buffer semaphore issue, but does not expose the radar application surface. Open
upstream PR #1336 (2026-07-18 snapshot) adds RTL8720F project artifacts, `AT+RAD`, `AT+RADDBG`, and
the public configuration call `wifi_radar_config(struct rtw_radar_action_parm *)`. Its public
parameter struct confirms mode, channel, 70/40/20 MHz bandwidth selector, trigger period, and
enable/config actions. Report reception still crosses non-public/placeholder HAL symbols such as
`wifi_hal_radar_recv_data(frame_num, frame_type, data)`, so the report layout and buffer lifetime
remain vendor-gated. Therefore the integration stays split at that boundary.
## Decision
RuView will support RTL8720F radar as an **optional, capability-negotiated source**, without
replacing the ESP32 firmware or treating radar CFR as byte-compatible with ADR-018 CSI.
The integration has three layers:
1. **Realtek device firmware**: a small application built in the vendor-supported Ameba SDK calls
the radar API, owns coexistence configuration, and emits versioned reports. This code lives under
`firmware/rtl8720f-radar/` only after the redistributable SDK/API is available.
2. **Transport-neutral wire contract**: CFR and Range-FFT reports are framed independently from the
vendor ABI and sent over UDP, USB CDC, or UART. ADR-264 defines this boundary.
3. **Rust host adapter**: `wifi-densepose-hardware` parses reports from bytes and converts CFR into
the existing CSI-domain representation, while Range-FFT remains a radar modality and feeds the
RuField/RuView cross-modality bridge from ADR-260/262.
The two report types remain semantically distinct:
| RTL8720F output | RuView representation | Permitted use |
|---|---|---|
| CFR | `CsiFrame` through a Realtek calibration adapter | CSI feature extraction after validation |
| Range-FFT near/far | `RadarFrame` / RuField `mmwave_radar`-class event with a 2.4 GHz descriptor | range, motion, presence, fusion |
| Vendor AI presence probability | derived observation with model/version provenance | advisory input, never ground truth |
| Interference report | quality/provenance metadata | reject, down-weight, or mark contaminated frames |
The modality registry should eventually distinguish `fmcw_radar_2_4ghz` from `mmwave_radar`; until
that RuField schema revision is accepted, the adapter must attach `carrier_hz = 2.4e9` and must not
claim millimetre-wave provenance.
## Delivery phases and gates
### P0 — Vendor enablement
Obtain the PR #1336-or-newer RTL8720F SDK package, radar API headers/libraries, a supported evaluation
board, flashing/debug instructions, report definitions, and written redistribution terms.
**Gate:** compile and run Realtek's unmodified radar example and capture CFR plus near/far
Range-FFT output. Until this passes, device firmware is `VENDOR_BLOCKED`, not implemented.
### P1 — Host-first contract
Implement ADR-264 types, parsers, fixtures, fuzz tests, and replay support without linking vendor
code. Use the Rust `Rtl8720fSimulator` as the only pre-hardware live source. It emits deterministic
CFR, near/far Range-FFT, interference, and capabilities frames through the same ADR-264 encoder and
parser used by hardware. Every simulated frame sets `RadarFlags::SYNTHETIC`; simulation results are
never reported as device measurements.
**Gate:** malformed inputs never panic; encode/decode round trips; unknown versions and report
types fail closed.
### P2 — RTL8720F firmware adapter
Wrap only the minimum vendor API surface: initialization, profile configuration, start/stop,
callback acquisition, interference status, and report serialization. Keep vendor types out of the
wire protocol.
**Gate:** 30-minute simultaneous Wi-Fi telemetry and radar capture with no watchdog reset, bounded
loss, monotonic sequence numbers, and explicit coexistence/interference statistics.
### P3 — Calibration and signal validation
Calibrate CFR phase/amplitude, Range-FFT bin spacing, static leakage, and clock drift. Compare
reported range against measured targets at multiple distances and bandwidths.
**Gate:** publish measured error distributions. Do not infer accuracy from report-bin spacing and
do not advertise multi-person pose or vital signs from the vendor deck.
### P4 — Fusion and productization
Feed calibrated CFR through the CSI path and Range-FFT through RuField, retaining source, mode,
bandwidth, calibration, firmware, and interference provenance.
**Gate:** ablation shows whether the radar stream improves a named RuView metric over ESP32 CSI
alone. If it does not, ship it only as an independent presence/range sensor.
## Consequences
### Positive
- One low-cost radio can provide active radar and CSI-like CFR while retaining Wi-Fi connectivity.
- Range-FFT adds an independent physical measurement for presence/range fusion.
- The vendor SDK is isolated from the Rust sensing core and from the stable on-wire contract.
- Capability negotiation permits future Realtek parts without another application-level fork.
### Negative
- The first implementation is blocked on access to the actual RTL8720F radar SDK/API and hardware.
- Active 2.4 GHz transmission changes coexistence, privacy, power, and regional compliance concerns.
- 1T1R and limited sweep bandwidth cannot provide the spatial resolution of multi-antenna mmWave.
- A second embedded toolchain and firmware release process must be maintained.
### Neutral
- ESP32 remains the default CSI node.
- Existing consumers receive normalized frames and do not link against Realtek code.
- Vendor AI output is optional metadata; RuView retains responsibility for its own validation.
## Rejected alternatives
1. **Map Range-FFT directly to `CsiFrame`.** Rejected because range bins and channel-frequency
samples have different axes and physical meaning.
2. **Link the Realtek SDK into the Rust server.** Rejected because it couples host builds to a
proprietary embedded ABI and toolchain.
3. **Wait to define any interface until hardware arrives.** Rejected because the host protocol,
parser safety, replay, and provenance can be developed and reviewed independently.
4. **Replace ESP32 nodes.** Rejected because the modes are complementary and availability differs.
## Open vendor questions
- Exact RTL8720F part/board identifier and production availability.
- SDK repository/tag, compiler, RTOS, binary blobs, license, and redistribution permissions.
- Radar initialization/configuration/callback API signatures and threading/ISR constraints.
- CFR and near/far Range-FFT element type, complex ordering, scaling, endianness, and timestamps.
- Whether CFR is calibrated complex data and whether phase remains coherent across frames.
- Maximum report rates, buffer ownership, DMA/cache constraints, and Wi-Fi throughput impact.
- Region/channel enforcement and whether 70 MHz operation is allowed by the supplied firmware.
- Secure boot, signed OTA, unique device identity, and firmware attestation support.
## Sources
- Realtek Semiconductor, `RTL8720F-2.4G-Radar-Advantages_EN.pptx`, slides 3 and 1019,
supplied 2026-07-18. This is product material, not measured RuView validation.
- [Ameba-AIoT/ameba-rtos releases](https://github.com/Ameba-AIoT/ameba-rtos/releases), reviewed
2026-07-18; v1.2.1 is the current QC release and includes a CSI buffer-semaphore fix.
- [Ameba-AIoT/ameba-rtos PR #1336](https://github.com/Ameba-AIoT/ameba-rtos/pull/1336), reviewed
2026-07-18; exposes RTL8720F build assets, `wifi_radar_config`, and radar AT commands while report
internals remain in binary/private layers.
- ADR-063 (mmWave sensor fusion), ADR-095/097 (source normalization), and ADR-260/262 (RuField
multimodal event model and live bridge).
@@ -0,0 +1,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,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.
+10 -1
View File
@@ -1,6 +1,15 @@
# Architecture Decision Records
This folder contains 182 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.)
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?
+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.
+31 -1
View File
@@ -1141,7 +1141,20 @@ What it ships (and what it does not):
| Presence detection (occupied / empty) | ✅ Trained head — v2 encoder reports 82.3% held-out temporal-triplet acc (v1's "100% on validation" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
| 128-dim CSI embeddings (re-ID, similarity, downstream training) | ✅ Trained encoder |
| Single-person breathing / heart-rate | ⚠️ Server still uses heuristic DSP — model does not replace this yet |
| 17-keypoint full-body pose | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
| 17-keypoint full-body pose | 🔬 This HF bundle ships no keypoint head — but real pose weights exist elsewhere; see the tier table below |
### Model weights: what's real, what's not
"WiFi → pose" means three different things in this repo, at three different maturity
levels. Read the label, not the headline ([ADR-187](adr/ADR-187-archive-v1-deprecation-honest-labeling.md)):
| Tier | Checkpoint(s) | Honest status |
|------|---------------|---------------|
| **Real & validated** | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) (encoder + presence head) · [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) (17-keypoint pose) · `cog-person-count/count_v1` | **MEASURED / published.** Presence = 82.3% held-out temporal-triplet accuracy (the old "100% presence" figure was retracted); MM-Fi pose = 82.69% torso-PCK@20 on the `random_split` protocol. |
| **Real but weak (honestly labeled)** | committed `v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors` | First-cut on-device model. **PCK@20 = 3.0% / PCK@50 = 18.5%** on a 217-sample holdout — **below the ADR-079 target of ≥ 35%.** Learns coarse structure (`r_hip` 77% PCK@50); distal/face joints near-random. Its runtime path in `cog-pose-estimation/src/inference.rs` is still a centred-skeleton **stub returning `confidence=0`**. Full disclosure in the [cog README](../v2/crates/cog-pose-estimation/cog/README.md). Do not advertise the live single-ESP32 17-keypoint feature without this caveat. |
| **Architecture only, no weights** | `archive/v1` `DensePoseHead` | Random `kaiming_normal_` init, **no checkpoint of any kind** (zero `.pth`/`.onnx`/`.safetensors` files under `archive/v1/`). Deprecated and superseded — see [`archive/v1/DEPRECATED.md`](../archive/v1/DEPRECATED.md). Do not expect real pose output from it. |
**Does it actually run, and can a single ESP32 do pose? ([#509](https://github.com/ruvnet/RuView/issues/509), [#1125](https://github.com/ruvnet/RuView/issues/1125))** Yes, it runs, and the results are reproducible: the deterministic signal-pipeline proof (`python archive/v1/data/proof/verify.py`, must print `VERDICT: PASS`), the committed pose training dump (`v2/crates/cog-pose-estimation/cog/artifacts/train_results.json`), and the auditable MM-Fi arena all back specific numbers. But a single-antenna, 56-subcarrier CSI stream at a 20-frame window does *not* carry the fine-grained spatial information the multi-antenna NIC research relies on — so the shippable pose accuracy the project stands behind today is the **MM-Fi benchmark number**, not a live single-ESP32 number. The path to a first reproducible on-device baseline (PCK@20 ≥ 35%) is tracked in [ADR-079](adr/ADR-079-camera-ground-truth-training.md) / [#645](https://github.com/ruvnet/RuView/issues/645).
### Download
@@ -1861,6 +1874,23 @@ node scripts/eval-wiflow.js \
--data data/paired/*.jsonl
```
> **Model format boundary:** `train-wiflow-supervised.js` produces the
> JavaScript WiFlow model `wiflow-v1.json`. There is currently no supported
> command that converts that JSON model into the sensing server's binary RVF
> container, and renaming the file to `.rvf` does not convert it. Use the JSON
> model with the JavaScript evaluation/inference tools. To train a model that
> the Rust sensing server can load, use its native training path, which writes
> RVF directly:
>
> ```bash
> cargo run -p wifi-densepose-sensing-server --release -- \
> --train --dataset data/mmfi --dataset-type mmfi \
> --epochs 100 --save-rvf models/room-model.rvf
> ```
>
> The camera+CSI paired JSONL workflow and the native RVF trainer are separate
> pipelines today. A JSON-to-RVF exporter is future work.
**Evaluation protocol matters.** Use `eval-wiflow.js` (torso-normalized
PCK@20, the metric comparable to published WiFi-pose results) on a temporal
hold-out, and sanity-check that predictions actually vary across frames
+60
View File
@@ -0,0 +1,60 @@
# ADR-270 Vendor RF Providers
RuView exposes a capability-safe Rust provider layer for vendor sensing and RF
telemetry. It never converts RSSI, occupancy, location or network inventory into
complex CSI.
## API
- `GET /api/v1/rf/vendors` — all provider descriptors and access states.
- `GET /api/v1/rf/vendors/latest` — latest validated event per vendor.
- `GET /api/v1/rf/vendors/:vendor/latest` — latest event for one stable vendor ID.
- `POST /api/v1/rf/vendors/:vendor/events` — ingest the vendor's documented
sidecar/webhook payload through its strict provider decoder. This `/api/v1/*`
route uses the server's bearer-token policy when configured.
Stable IDs are `origin_ai`, `plume`, `mist`, `netgear`, `electric_imp`,
`rf_solutions`, `linksys`, `luma`, `google_nest`, and `wifigarden`.
## Deterministic simulator
```bash
cd v2
cargo run -p wifi-densepose-hardware --bin vendor-rf-sim -- \
--vendor plume --frames 100 --output plume.jsonl
# Stream canonical synthetic events to the sensing server UDP port.
cargo run -p wifi-densepose-hardware --bin vendor-rf-sim -- \
--vendor mist --frames 100 --udp 127.0.0.1:5005 --realtime
```
Supported simulator names are `origin-ai`, `plume`, `mist`, `netgear`,
`electric-imp`, `rf-solutions`, `luma`, and `google-nest`. Linksys is refused
because its sensing service is discontinued. Wifigarden is refused until a
contracted event schema exists.
Every synthetic event includes `synthetic: true`, a deterministic sequence and
timestamp, and a source ending in `-sim-01`.
Canonical UDP JSON is accepted only when `synthetic: true`. Live vendor payloads
must use the HTTP ingestion route so provider-specific schemas, metric allowlists,
access states and bounds cannot be bypassed.
## Live/provider payloads
Provider decoders are strict, bounded and reject unknown schema fields. Origin
paths and credentials are supplied by the commercial contract. Plume uses a
read-only allow-listed OVSDB request plan. Mist and NETGEAR configurations use
regional HTTPS endpoints with redacted tokens. Electric Imp, RF Solutions and
Luma accept only allow-listed scalar metrics. Google Nest remains network-only.
Credentials are never embedded in fixtures or descriptors. Linksys returns
`Unsupported`; Wifigarden returns `ContractRequired`. These are usable,
test-covered provider outcomes—not simulated integrations.
## Hardware honesty
All descriptors remain `hardware_validated: false` until exact hardware/cloud
versions, lawful access, repeatable captures, calibration where applicable, and
fixture publication rights have been verified. Passing the simulator and API
tests validates RuView software only.
+1 -1
View File
@@ -1,5 +1,5 @@
# ESP32 CSI Node Firmware (ADR-018)
# Requires ESP-IDF v5.2+
# Requires ESP-IDF v5.4+
cmake_minimum_required(VERSION 3.16)
set(EXTRA_COMPONENT_DIRS "")
+14 -4
View File
@@ -4,7 +4,7 @@
This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (production) or ESP32-C6 (research target — Wi-Fi 6 / 802.15.4 / TWT / LP-core hibernation, see [ADR-110](../../docs/adr/ADR-110-esp32-c6-firmware-extension.md)) and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the [WiFi-DensePose](../../README.md) project.
[![ESP-IDF v5.2](https://img.shields.io/badge/ESP--IDF-v5.2-blue.svg)](https://docs.espressif.com/projects/esp-idf/en/v5.2/)
[![ESP-IDF v5.4](https://img.shields.io/badge/ESP--IDF-v5.4-blue.svg)](https://docs.espressif.com/projects/esp-idf/en/v5.4/)
[![Target: ESP32-S3 / ESP32-C6](https://img.shields.io/badge/target-ESP32--S3%20%7C%20ESP32--C6-purple.svg)](https://www.espressif.com/en/products/socs/esp32-s3)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-green.svg)](../../LICENSE)
[![Binary: ~943 KB](https://img.shields.io/badge/binary-~943%20KB-orange.svg)](#memory-budget)
@@ -48,10 +48,18 @@ with `--flash_size 4MB`.
# From the repository root:
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \
espressif/idf:v5.4 bash -c \
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
```
> **Display-less boards (ESP32-S3-DevKitC-1 and similar):** build with the
> `sdkconfig.defaults.devkitc` overlay instead — the default build compiles
> display support in, and the runtime panel probe false-positives on boards
> with no panel, which disables the RuView#893 MGMT+DATA CSI upgrade and
> collapses CSI yield to 0 pps. See the header of
> [`sdkconfig.defaults.devkitc`](sdkconfig.defaults.devkitc) for the exact
> build command.
### 2. Flash
Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB):
@@ -105,6 +113,8 @@ curl http://<ESP32_IP>:8032/wasm/list
> **Tip:** A single node provides presence and vital signs along its line of sight. Multiple nodes (3-6) create a multistatic mesh that resolves 3D pose with <30 mm jitter and zero identity swaps.
> **⚠️ Thermal warning — compact boards (ESP32-S3-Zero, SuperMini, other coin-sized clones):** This firmware runs the WiFi radio with modem sleep disabled (`WIFI_PS_NONE`, required for continuous CSI capture) plus a full edge-processing DSP pipeline on Core 1 (`edge_tier=2`) plus, on ADR-183 builds, a continuous 40 Hz onboard LED driver. That's sustained high current draw with no duty-cycling. Full-size dev boards (DevKitC-1, XIAO) have more copper pour and thermal mass around the regulator and tolerate this fine. Coin-sized clones with minimal PCB area and budget regulators may run hot to the touch during normal operation, and in at least one field report, boards that ran hot during a session failed to power on afterward (regulator damage suspected — see issue tracker). Give these boards airflow, don't stack or enclose them, and check them by touch during the first several minutes of a new deployment. If a board is uncomfortably hot (not just warm), power it down and let it cool before continuing.
---
## Firmware Architecture
@@ -250,7 +260,7 @@ Offset Size Field
# From the repository root:
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \
espressif/idf:v5.4 bash -c \
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
```
@@ -268,7 +278,7 @@ To change Kconfig settings before building:
```bash
MSYS_NO_PATHCONV=1 docker run --rm -it \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \
espressif/idf:v5.4 bash -c \
"idf.py set-target esp32s3 && idf.py menuconfig"
```
+6 -1
View File
@@ -67,6 +67,8 @@ static void event_handler(void *arg, esp_event_base_t event_base,
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data;
ESP_LOGW(TAG, "WiFi disconnected, reason=%d rssi=%d", disc->reason, disc->rssi);
if (s_retry_num < MAX_RETRY) {
esp_wifi_connect();
s_retry_num++;
@@ -102,7 +104,10 @@ static void wifi_init_sta(void)
wifi_config_t wifi_config = {
.sta = {
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
/* WPA_PSK (not WPA2_PSK) so routers running WPA/WPA2-mixed
* compatibility mode aren't rejected with
* WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD (#1050). */
.threshold.authmode = WIFI_AUTH_WPA_PSK,
},
};
@@ -0,0 +1,16 @@
# DevKitC-1 (display-less) production overlay.
#
# The stock ESP32-S3-DevKitC-1 has no AMOLED panel, but the ADR-045 runtime
# probe false-positives on it: with no TCA9554 and floating QSPI pins, the
# SH8601 init sequence reports success, display_is_active() returns true, and
# main.c skips the RuView#893 MGMT+DATA promiscuous upgrade — CSI yield
# collapses to 0 pps (the exact symptom #893 fixed). Compiling display support
# out makes has_display constant-false so the upgrade always applies.
#
# Build (from repo root, per README "Docker — the only reliable method"):
# MSYS_NO_PATHCONV=1 docker run --rm \
# -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
# espressif/idf:v5.4 bash -c \
# "rm -rf build sdkconfig && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' set-target esp32s3 && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' build"
# CONFIG_DISPLAY_ENABLE is not set
+1 -1
View File
@@ -1 +1 @@
0.7.0
0.8.4
@@ -18,6 +18,8 @@ Bring a RuView sensing node online: build firmware → flash → provision WiFi
**Not supported:** original ESP32, ESP32-C3 (single-core).
**⚠️ Ask about board form factor before flashing.** If the user's board is a coin-sized clone (ESP32-S3-Zero, SuperMini, or similar — not a full DevKitC/XIAO-style board with a real USB connector and visible regulator), warn them before they walk away from it: this firmware runs the WiFi radio continuously (`WIFI_PS_NONE`) plus a full DSP pipeline (`edge_tier=2`), which is sustained high current draw that full-size dev boards handle fine but tiny clones with minimal copper/budget regulators may not. At least one field report: boards ran hot during a normal session and failed to power on again afterward (regulator damage suspected). Tell them to give the board airflow (don't stack/enclose it) and check it by touch during the first several minutes of any new deployment.
## 1. Build firmware (Windows — Python subprocess, NOT bash directly)
ESP-IDF v5.4 does not support MSYS2/Git Bash. Use the Espressif Python venv as a subprocess with `MSYSTEM*` env vars stripped. The proven command lives in `CLAUDE.local.md` — reproduce it:
+3072 -24
View File
File diff suppressed because it is too large Load Diff
+69 -2
View File
@@ -23,6 +23,27 @@ name = "wifi_densepose_native"
crate-type = ["cdylib", "rlib"]
path = "src/lib.rs"
# ADR-185 §3.1 — optional pip extras map to Cargo features so the
# default wheel links none of the SOTA subsystems. P1 wires `aether`.
[features]
default = []
# ADR-185 P1 — AETHER contrastive CSI embeddings. Binds the std-only
# `wifi-densepose-aether` leaf crate (the pure-compute stack hoisted out of
# `wifi-densepose-sensing-server` per §13), so this extra links no server tree.
aether = ["dep:wifi-densepose-aether"]
# ADR-185 P2 — MERIDIAN domain generalization. Binds the tch-free
# inference/adaptation path only (see the wheel-size note on the deps
# below). `wifi-densepose-train` is depended on WITHOUT `tch-backend`,
# so no libtorch is linked.
meridian = ["dep:wifi-densepose-train", "dep:wifi-densepose-signal"]
# ADR-185 P3 — MAT disaster-survivor detection. Mirrors the upstream
# disaster/ML gating: bound only under this extra so the default wheel
# never carries the detection stack. `tokio`/`geo` are pulled to drive a
# single-shot scan (see the wheel-size note on the deps below).
mat = ["dep:wifi-densepose-mat", "dep:tokio", "dep:geo"]
# ADR-185 §3 — convenience superset: all three SOTA subsystems.
sota = ["aether", "meridian", "mat"]
[dependencies]
# PyO3 with abi3-py310 — one compiled binary covers Python 3.10, 3.11,
# 3.12, 3.13, and any future 3.x that keeps the stable ABI (ADR-117 §5.4).
@@ -50,6 +71,52 @@ wifi-densepose-bfld = { version = "0.3.0", path = "../v2/crates/wifi-densepose-b
# the future P3 CsiFrame numpy round-trip.
numpy = "0.22"
# ADR-185 P1 — AETHER backing crate (contrastive `embedding` +
# `graph_transformer`/`sona`/`sparse_inference`, ADR-024). Optional +
# gated behind the `aether` feature.
#
# WHEEL-SIZE FIX LANDED (ADR-185 §13): this is now the std-only
# `wifi-densepose-aether` leaf crate — zero external deps, no tokio/axum/
# worldgraph/ruvector — hoisted out of `wifi-densepose-sensing-server`
# (which re-exports it, so the server is unchanged). The `[aether]` wheel
# therefore links only pure compute and stays within the ADR-117 §5.4
# ≤5 MB budget.
wifi-densepose-aether = { version = "0.3.0", path = "../v2/crates/wifi-densepose-aether", optional = true }
# ADR-185 P2 — MERIDIAN backing crates (optional, `meridian`-gated).
#
# HONEST WHEEL-SIZE NOTE (ADR-185 §9 / §1.2): unlike AETHER, the libtorch
# risk is AVOIDED here — `wifi-densepose-train`'s `tch` dep is properly
# optional (feature `tch-backend`, OFF by default), so no libtorch links.
# BUT `wifi-densepose-train` still carries NON-optional deps: `tokio` (rt
# subset), the five `ruvector-*` crates, `wifi-densepose-nn`, petgraph,
# memmap2, indicatif, ndarray-npy, csv, toml, clap. So a `[meridian]`
# wheel is heavier than the ≤5 MB ADR-117 §5.4 budget (though far lighter
# than AETHER's axum/tokio server tree). The clean fix is the same
# leaf-crate hoist: move the pure inference modules (geometry,
# rapid_adapt, eval, hardware_norm) into a tch/tokio-free leaf crate.
# `wifi-densepose-signal` is depended on `default-features = false` to
# drop the optional ndarray-linalg/BLAS chain (Windows-friendly).
wifi-densepose-train = { version = "0.3.0", path = "../v2/crates/wifi-densepose-train", optional = true, default-features = false }
wifi-densepose-signal = { version = "0.3.0", path = "../v2/crates/wifi-densepose-signal", optional = true, default-features = false }
# ADR-185 P3 — MAT backing crate + the tokio/geo needed to drive one scan.
#
# HONEST WHEEL-SIZE NOTE (ADR-185 §9 / §1.3): `default-features = false`
# drops MAT's `api` (axum) and `ruvector` features from the wheel, but MAT
# still carries NON-optional `tokio` (rt/sync/time), `wifi-densepose-nn`
# (which pulls `ort` / ONNX Runtime + reqwest/hyper), `rustfft`, `geo`,
# and `ndarray`. So a `[mat]` wheel exceeds the ADR-117 §5.4 ≤5 MB budget
# — same leaf-crate-hoist story as AETHER/MERIDIAN, gated the same way so
# the DEFAULT wheel is untouched. `tokio` (rt+time) and `geo` are depended
# on directly (version-matched to MAT) to build the single-shot scan
# runtime and construct the event `geo::Point` in the binding.
wifi-densepose-mat = { version = "0.3.0", path = "../v2/crates/wifi-densepose-mat", optional = true, default-features = false, features = ["std"] }
tokio = { version = "1.35", features = ["rt", "time"], optional = true }
geo = { version = "0.27", optional = true }
[dev-dependencies]
# Doc-test infrastructure for the Python-facing examples in the bound
# Rust functions. Lands properly in P2 once #[pyfunction]s exist to test.
# ADR-185 §4.1 parity harness — SHA-256 the native-Rust reference
# embedding and read the committed golden fixture.
sha2 = "0.10"
serde_json = "1"
+23
View File
@@ -43,6 +43,29 @@ pip install "wifi-densepose[client]" # + WebSocket/MQTT clients
Wheels are published for Linux (x86_64, aarch64), macOS (x86_64, arm64), and
Windows (amd64).
### SOTA extras (ADR-185)
Three optional subsystems bind the Rust SOTA modules as compiled-feature
wheels. Each raises a clear `ImportError` if you import it without the extra:
| Extra | Module | What it adds |
|-------|--------|--------------|
| `[aether]` | `wifi_densepose.aether` | Contrastive CSI embeddings / re-identification (ADR-024) — `EmbeddingExtractor`, `cosine_similarity`, `info_nce_loss` |
| `[meridian]` | `wifi_densepose.meridian` | Cross-environment domain generalization (ADR-027) — `HardwareNormalizer`, `GeometryEncoder`, `RapidAdaptation`, `CrossDomainEvaluator` |
| `[mat]` | `wifi_densepose.mat` | Mass-Casualty Assessment disaster-survivor detection + START triage — `DisasterResponse`, `Survivor`, `TriageStatus` |
| `[sota]` | all three | Convenience superset |
```bash
pip install "wifi-densepose[aether]" # re-identification embeddings
pip install "wifi-densepose[meridian]" # cross-room calibration
pip install "wifi-densepose[mat]" # disaster triage
pip install "wifi-densepose[sota]" # all three
```
Runnable examples: [`examples/reid_from_csi.py`](examples/reid_from_csi.py),
[`examples/cross_room_calibrate.py`](examples/cross_room_calibrate.py),
[`examples/mat_triage.py`](examples/mat_triage.py).
## Usage
### Extract breathing rate from a CSI stream
+44
View File
@@ -0,0 +1,44 @@
"""ADR-185 §4.2 — AETHER embed() micro-benchmarks.
Target (release build, ADR-024 §2.8 FP32 <1 ms with headroom): steady-state
`embed()` < 2 ms/window, and batched `embed()` scales roughly linearly (no
accidental O()).
Run with:
pytest python/bench/test_bench_aether.py --benchmark-only
Skipped by default (they live in `bench/`, outside `testpaths`). Timing
targets are validated on a RELEASE wheel (`maturin develop --release
--features sota`); a debug wheel will be several× slower.
"""
from __future__ import annotations
import math
import pytest
from wifi_densepose import aether
def _window(frames: int = 8, subc: int = 56) -> list[list[float]]:
return [[math.sin(0.1 * t + 0.03 * k) for k in range(subc)] for t in range(frames)]
def _extractor() -> aether.EmbeddingExtractor:
return aether.EmbeddingExtractor(n_subcarriers=56, config=aether.AetherConfig())
def test_embed_per_window(benchmark) -> None:
ext = _extractor()
window = _window()
out = benchmark(lambda: ext.embed(window))
assert len(out) == 128
@pytest.mark.parametrize("batch", [1, 8, 64])
def test_embed_batch_scaling(benchmark, batch: int) -> None:
ext = _extractor()
windows = [_window() for _ in range(batch)]
out = benchmark(lambda: [ext.embed(w) for w in windows])
assert len(out) == batch
+44
View File
@@ -0,0 +1,44 @@
"""ADR-185 §4.2 — MAT scan micro-benchmark.
Measures the cost of one full ingest + `scan_once()` cycle over the
committed 256-frame CSI stream. The per-cycle cost should stay comfortably
below the configured scan interval (default 500 ms) so the binding is not
the bottleneck.
Run with:
pytest python/bench/test_bench_mat.py --benchmark-only
Validated on a RELEASE wheel; a debug wheel will be several× slower.
"""
from __future__ import annotations
import json
from pathlib import Path
from wifi_densepose import mat
_FIXTURE = Path(__file__).resolve().parents[1] / "tests" / "golden" / "mat_input.json"
def _stream() -> list[dict]:
return json.loads(_FIXTURE.read_text())["stream"]
def test_scan_cycle_cost(benchmark) -> None:
stream = _stream()
def _run() -> int:
cfg = mat.DisasterConfig(
mat.DisasterType.Earthquake, sensitivity=0.9, confidence_threshold=0.1
)
resp = mat.DisasterResponse(cfg)
resp.initialize_event(0.0, 0.0, "bench")
resp.add_zone(mat.ScanZone.rectangle("Zone A", 0.0, 0.0, 50.0, 30.0))
for frame in stream:
resp.push_csi_data(frame["amplitude"], frame["phase"])
resp.scan_once()
return len(resp.survivors())
survivors = benchmark(_run)
assert survivors == 1
+29
View File
@@ -0,0 +1,29 @@
"""ADR-185 §4.2 — MERIDIAN micro-benchmarks.
Targets (release build, ADR-027 §4.1/§4.3 ×2 headroom): `normalize()`
< 200 µs/frame, `encode()` < 200 µs.
Run with:
pytest python/bench/test_bench_meridian.py --benchmark-only
Validated on a RELEASE wheel; a debug wheel will be several× slower.
"""
from __future__ import annotations
from wifi_densepose import meridian as mer
def test_normalize_per_frame(benchmark) -> None:
norm = mer.HardwareNormalizer()
amp = [10.0 + 0.05 * k for k in range(64)]
phase = [0.01 * k for k in range(64)]
out = benchmark(lambda: norm.normalize(amp, phase, mer.HardwareType.Esp32S3))
assert len(out.amplitude) == 56
def test_geometry_encode(benchmark) -> None:
enc = mer.GeometryEncoder(mer.MeridianGeometryConfig())
aps = [[0.0, 0.0, 2.5], [5.0, 0.0, 2.5], [0.0, 4.0, 2.5]]
out = benchmark(lambda: enc.encode(aps))
assert len(out) == 64
+4 -4
View File
@@ -57,12 +57,12 @@ def test_heart_rate_extract_per_frame_cost(benchmark) -> None:
hr = HeartRateExtractor.esp32_default()
rng = Random(43)
for i in range(1500):
residuals, weights = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
hr.extract(residuals=residuals, weights=weights)
residuals, phases = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
hr.extract(residuals=residuals, phases=phases)
def _one_frame():
residuals, weights = _synth_frame(56, 100.0, 16.0, 1.2, rng)
return hr.extract(residuals=residuals, weights=weights)
residuals, phases = _synth_frame(56, 100.0, 16.0, 1.2, rng)
return hr.extract(residuals=residuals, phases=phases)
benchmark(_one_frame)
+45
View File
@@ -0,0 +1,45 @@
"""MERIDIAN cross-room calibration (ADR-185 P2, `[meridian]` extra).
Hardware-invariant CSI normalization, AP-geometry encoding, and few-shot
rapid adaptation the tch-free domain-generalization path.
pip install wifi-densepose[meridian]
python examples/cross_room_calibrate.py
"""
from __future__ import annotations
import math
from wifi_densepose.meridian import (
GeometryEncoder,
HardwareNormalizer,
HardwareType,
MeridianGeometryConfig,
RapidAdaptation,
)
def main() -> None:
# 1. Normalize a 64-subcarrier ESP32 frame to the canonical 56-tone grid.
norm = HardwareNormalizer()
amp = [10.0 + 0.05 * k for k in range(64)]
phase = [0.01 * k for k in range(64)]
frame = norm.normalize(amp, phase, HardwareType.detect(64))
print(f"canonical subcarriers: {len(frame.amplitude)} (hw={frame.hardware_type})")
# 2. Encode AP positions into a permutation-invariant geometry embedding.
enc = GeometryEncoder(MeridianGeometryConfig())
geometry = enc.encode([[0.0, 0.0, 2.5], [5.0, 0.0, 2.5], [0.0, 4.0, 2.5]])
print(f"geometry embedding dim: {len(geometry)}")
# 3. Few-shot rapid adaptation over a handful of unlabeled frames.
ra = RapidAdaptation(min_calibration_frames=10, lora_rank=4)
for i in range(12):
ra.push_frame([math.sin(0.1 * i + 0.05 * d) for d in range(16)])
result = ra.adapt()
print(f"adapted over {result.frames_used} frames, final_loss={result.final_loss:.4f}")
if __name__ == "__main__":
main()
+49
View File
@@ -0,0 +1,49 @@
"""MAT disaster-survivor triage from CSI (ADR-185 P3, `[mat]` extra).
Ingest a CSI stream, run one detection cycle, and list detected survivors
by START triage class.
pip install wifi-densepose[mat]
python examples/mat_triage.py
Note: the stream here is synthetic (breathing-modulated) it demonstrates
the API and pipeline, not validated detection accuracy on real rubble.
"""
from __future__ import annotations
import math
from collections.abc import Iterator
from wifi_densepose.mat import DisasterConfig, DisasterResponse, DisasterType, ScanZone
def breathing_stream(
frames: int = 256, subc: int = 56, fs: float = 20.0
) -> Iterator[tuple[list[float], list[float]]]:
for t in range(frames):
tt = t / fs
breath = 2.0 * math.sin(2 * math.pi * 0.3 * tt)
amp = [10.0 + 0.05 * k + breath for k in range(subc)]
phase = [0.01 * k + 0.1 * math.sin(2 * math.pi * 0.3 * tt) for k in range(subc)]
yield amp, phase
def main() -> None:
cfg = DisasterConfig(DisasterType.Earthquake, sensitivity=0.9, confidence_threshold=0.1)
resp = DisasterResponse(cfg)
resp.initialize_event(0.0, 0.0, "Collapsed Building A")
resp.add_zone(ScanZone.rectangle("North Wing", 0.0, 0.0, 50.0, 30.0))
for amp, phase in breathing_stream():
resp.push_csi_data(amp, phase)
resp.scan_once()
survivors = resp.survivors()
print(f"detected {len(survivors)} survivor(s)")
for s in survivors:
print(f" {s.id[:8]} triage={s.triage_status} confidence={s.confidence:.3f}")
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
"""AETHER re-identification from CSI (ADR-185 P1, `[aether]` extra).
Compute 128-dim contrastive embeddings for CSI windows and score them by
cosine similarity the primitive behind room fingerprinting and person
re-identification.
pip install wifi-densepose[aether]
python examples/reid_from_csi.py
"""
from __future__ import annotations
import math
from wifi_densepose.aether import AetherConfig, EmbeddingExtractor, cosine_similarity
def make_window(phase_shift: float, frames: int = 8, subc: int = 56) -> list[list[float]]:
"""A synthetic CSI window; `phase_shift` stands in for a different scene."""
return [
[math.sin(0.1 * t + 0.03 * k + phase_shift) for k in range(subc)]
for t in range(frames)
]
def main() -> None:
ext = EmbeddingExtractor(n_subcarriers=56, config=AetherConfig())
same_a = ext.embed(make_window(0.0))
same_b = ext.embed(make_window(0.0)) # same scene
other = ext.embed(make_window(1.5)) # different scene
print(f"embedding dim: {len(same_a)}")
print(f"same-scene similarity: {cosine_similarity(same_a, same_b):.4f}")
print(f"cross-scene similarity: {cosine_similarity(same_a, other):.4f}")
if __name__ == "__main__":
main()
+16 -2
View File
@@ -10,7 +10,7 @@ build-backend = "maturin"
[project]
name = "wifi-densepose"
version = "2.0.0a1"
version = "2.0.0"
description = "WiFi-based human pose estimation, vital sign extraction, and ambient intelligence from Channel State Information (CSI). PyO3 bindings for the Rust core."
readme = "README.md"
requires-python = ">=3.10"
@@ -23,7 +23,7 @@ keywords = [
"biometric", "ambient-intelligence", "home-assistant", "matter",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
@@ -48,6 +48,20 @@ client = [
"websockets>=12.0",
"paho-mqtt>=2.1",
]
# ADR-185 P1 — AETHER contrastive embeddings. Unlike `client`, this
# extra carries no pure-Python deps: it is a marker for a *compiled*
# feature build (`maturin ... --features aether` / a cibuildwheel
# feature axis, ADR-185 §3.1). Installing the base wheel and importing
# `wifi_densepose.aether` raises a clear ImportError naming this extra.
aether = []
# ADR-185 P2 — MERIDIAN domain generalization. Same compiled-feature
# marker pattern as `aether` (built via `maturin ... --features meridian`).
meridian = []
# ADR-185 P3 — MAT disaster-survivor detection. Same compiled-feature
# marker (built via `maturin ... --features mat`).
mat = []
# ADR-185 §3 convenience — all three SOTA subsystems at once.
sota = []
# Developer dependencies for running the test suite + lint.
dev = [
"pytest>=8.0",
+6 -6
View File
@@ -16,7 +16,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ruview"
version = "2.0.0a1"
version = "2.0.0"
description = "RuView — ambient intelligence from WiFi CSI. Meta-package; installs `wifi-densepose` and re-exports it under the `ruview` namespace. See https://github.com/ruvnet/RuView."
readme = "README.md"
requires-python = ">=3.10"
@@ -28,7 +28,7 @@ keywords = [
"ruview",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
@@ -43,13 +43,13 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
# Pin to the matching v2 release so an alpha-pin `pip install ruview`
# always gets a compatible wifi-densepose.
"wifi-densepose==2.0.0a1",
# Pin to the matching v2 release so `pip install ruview` always gets a
# compatible wifi-densepose.
"wifi-densepose==2.0.0",
]
[project.optional-dependencies]
client = ["wifi-densepose[client]==2.0.0a1"]
client = ["wifi-densepose[client]==2.0.0"]
[project.urls]
Homepage = "https://github.com/ruvnet/RuView"
+317
View File
@@ -0,0 +1,317 @@
//! ADR-185 P1 — PyO3 bindings for AETHER contrastive CSI embeddings.
//!
//! Surfaces the **pure-sync** contrastive-embedding compute from
//! `wifi-densepose-aether::embedding` (ADR-024; the std-only leaf hoisted per
//! ADR-185 §13) into `wifi_densepose.aether`:
//!
//! - `AetherConfig` — wraps `EmbeddingConfig` (d_model / d_proj /
//! temperature / normalize)
//! - `CsiAugmenter` — SimCLR-style augmentation pair generator
//! - `EmbeddingExtractor`— backbone + projection → 128-dim L2-normed embedding
//! - `info_nce_loss` — NT-Xent contrastive loss (module function)
//! - `cosine_similarity` — re-ID similarity helper (module function)
//!
//! ## Honest scope vs ADR-185 §3.2
//!
//! ADR-185 §3.2 names an aspirational surface (`aether_loss` returning
//! VICReg components, `alignment_metric`, `uniformity_metric`,
//! `forward_dual`, an `AetherConfig` with `vicreg_*` fields). Those do
//! **not** exist in the backing crate at HEAD — `embedding.rs` exposes
//! `EmbeddingConfig { d_model, d_proj, temperature, normalize }`,
//! `info_nce_loss` (plain `f32`), `CsiAugmenter::augment_pair`, and
//! `EmbeddingExtractor::extract`. This binding surfaces **what actually
//! exists** rather than fabricating the ADR's wished-for API. The
//! VICReg loss / metric surface is a Rust-side gap, not a binding gap.
//!
//! ## GIL release strategy (per ADR-117 §7, matching bindings/vitals.rs)
//!
//! `extract`, `augment_pair`, and `info_nce_loss` are pure-sync matrix
//! ops touching no Python objects, so they run inside
//! `py.allow_threads(|| ...)`.
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use wifi_densepose_aether::embedding::{
info_nce_loss as rust_info_nce_loss, CsiAugmenter, EmbeddingConfig, EmbeddingExtractor,
};
use wifi_densepose_aether::graph_transformer::TransformerConfig;
/// Upper bound on model/CSI dimensions accepted from Python. The transformer
/// allocates weight matrices quadratic in these, so this caps a single
/// construction well under a gigabyte and turns an accidental or malicious
/// `d_model=100_000` into a `ValueError` instead of an allocation that aborts
/// the interpreter. Generous relative to real configs (defaults 64/128); raise
/// deliberately if a workload genuinely needs larger.
const MAX_DIM: usize = 4096;
/// Upper bound on GNN layer count — a sanity cap, not a modelling limit.
const MAX_LAYERS: usize = 64;
// ─── AetherConfig ────────────────────────────────────────────────────
/// Configuration for the contrastive embedding model.
///
/// Python:
/// ```python
/// from wifi_densepose.aether import AetherConfig
/// cfg = AetherConfig(d_model=64, d_proj=128, temperature=0.07, normalize=True)
/// ```
#[pyclass(frozen, name = "AetherConfig")]
#[derive(Clone)]
pub struct PyAetherConfig {
inner: EmbeddingConfig,
}
#[pymethods]
impl PyAetherConfig {
#[new]
#[pyo3(signature = (d_model=64, d_proj=128, temperature=0.07, normalize=true))]
fn new(d_model: usize, d_proj: usize, temperature: f32, normalize: bool) -> PyResult<Self> {
// Validate at the boundary and raise ValueError. The native constructor
// allocates weight matrices quadratic in these dims and (elsewhere)
// divides by them, so zero or absurd values would otherwise reach Rust
// as a panic (surfacing to Python as an opaque PanicException) or a
// multi-gigabyte allocation that aborts the interpreter.
if d_model == 0 || d_proj == 0 {
return Err(PyValueError::new_err(
"d_model and d_proj must be positive",
));
}
if d_model > MAX_DIM || d_proj > MAX_DIM {
return Err(PyValueError::new_err(format!(
"d_model ({d_model}) and d_proj ({d_proj}) must be <= {MAX_DIM}"
)));
}
Ok(Self {
inner: EmbeddingConfig {
d_model,
d_proj,
temperature,
normalize,
},
})
}
#[getter]
fn d_model(&self) -> usize {
self.inner.d_model
}
#[getter]
fn d_proj(&self) -> usize {
self.inner.d_proj
}
#[getter]
fn temperature(&self) -> f32 {
self.inner.temperature
}
#[getter]
fn normalize(&self) -> bool {
self.inner.normalize
}
fn __repr__(&self) -> String {
format!(
"AetherConfig(d_model={}, d_proj={}, temperature={}, normalize={})",
self.inner.d_model, self.inner.d_proj, self.inner.temperature, self.inner.normalize,
)
}
}
// ─── CsiAugmenter ────────────────────────────────────────────────────
/// SimCLR-style CSI augmentation. `augment_pair` returns two distinct
/// augmented views of the same CSI window for contrastive pretraining.
///
/// Python:
/// ```python
/// from wifi_densepose.aether import CsiAugmenter
/// aug = CsiAugmenter()
/// view_a, view_b = aug.augment_pair(window, seed=42)
/// ```
#[pyclass(name = "CsiAugmenter")]
pub struct PyCsiAugmenter {
inner: CsiAugmenter,
}
#[pymethods]
impl PyCsiAugmenter {
#[new]
fn new() -> Self {
Self {
inner: CsiAugmenter::new(),
}
}
/// Produce two augmented views `(view_a, view_b)` of `window`
/// (frames × subcarriers) using the deterministic `seed`. GIL is
/// released during augmentation.
fn augment_pair(
&self,
py: Python<'_>,
window: Vec<Vec<f32>>,
seed: u64,
) -> (Vec<Vec<f32>>, Vec<Vec<f32>>) {
py.allow_threads(|| self.inner.augment_pair(&window, seed))
}
fn __repr__(&self) -> String {
"CsiAugmenter(SimCLR-style CSI augmentation)".to_string()
}
}
// ─── EmbeddingExtractor ──────────────────────────────────────────────
/// Full AETHER embedding extractor: CSI→pose transformer backbone +
/// projection head → a `d_proj`-dim (default 128) L2-normalized
/// embedding. Weights are deterministically seeded, so `embed` is a
/// pure function of its input for a fixed config.
///
/// Python:
/// ```python
/// from wifi_densepose.aether import AetherConfig, EmbeddingExtractor
/// ext = EmbeddingExtractor(n_subcarriers=56, config=AetherConfig())
/// emb = ext.embed(window) # list[float], len == config.d_proj
/// ```
#[pyclass(name = "EmbeddingExtractor")]
pub struct PyEmbeddingExtractor {
inner: EmbeddingExtractor,
embedding_dim: usize,
}
#[pymethods]
impl PyEmbeddingExtractor {
/// Construct an extractor. The transformer backbone is sized from
/// `n_subcarriers` and `config.d_model`; `config.d_proj` sets the
/// embedding dimension.
#[new]
#[pyo3(signature = (n_subcarriers, config, n_keypoints=17, n_heads=4, n_gnn_layers=2))]
fn new(
n_subcarriers: usize,
config: PyAetherConfig,
n_keypoints: usize,
n_heads: usize,
n_gnn_layers: usize,
) -> PyResult<Self> {
let e_config = config.inner.clone();
// n_heads == 0 reaches `d_model % n_heads` in the transformer and panics
// (divide-by-zero); a non-divisor trips the native `assert!`. Both would
// surface to Python as a PanicException. Reject cleanly instead.
if n_heads == 0 {
return Err(PyValueError::new_err("n_heads must be positive"));
}
if e_config.d_model % n_heads != 0 {
return Err(PyValueError::new_err(format!(
"d_model ({}) must be divisible by n_heads ({n_heads})",
e_config.d_model
)));
}
if n_subcarriers == 0 || n_keypoints == 0 {
return Err(PyValueError::new_err(
"n_subcarriers and n_keypoints must be positive",
));
}
if n_subcarriers > MAX_DIM || n_keypoints > MAX_DIM || n_gnn_layers > MAX_LAYERS {
return Err(PyValueError::new_err(format!(
"n_subcarriers/n_keypoints must be <= {MAX_DIM} and n_gnn_layers <= {MAX_LAYERS}"
)));
}
let t_config = TransformerConfig {
n_subcarriers,
n_keypoints,
d_model: e_config.d_model,
n_heads,
n_gnn_layers,
};
let embedding_dim = e_config.d_proj;
Ok(Self {
inner: EmbeddingExtractor::new(t_config, e_config),
embedding_dim,
})
}
/// Extract an embedding from a CSI window (frames × subcarriers).
/// Returns a `d_proj`-length vector (L2-normed when the config's
/// `normalize` is set). GIL released during the forward pass.
fn embed(&mut self, py: Python<'_>, csi_features: Vec<Vec<f32>>) -> Vec<f32> {
py.allow_threads(|| self.inner.extract(&csi_features))
}
#[getter]
fn embedding_dim(&self) -> usize {
self.embedding_dim
}
/// Total trainable parameter count (transformer + projection). Equals the
/// number of `f32`s in a weight file for this architecture.
#[getter]
fn param_count(&self) -> usize {
self.inner.param_count()
}
/// Load weights from `path` (a file written by `save_weights` or the Rust
/// `EmbeddingExtractor::save_weights`), replacing the current weights.
///
/// By default an `EmbeddingExtractor` uses deterministic **random** init
/// (untrained); this is the additive path to load real weights once a
/// trained checkpoint exists (ADR-185 §13.a). Raises `ValueError` on a
/// missing/corrupt file or a param-count mismatch with this architecture.
/// GIL released during file I/O + deserialization.
fn load_weights(&mut self, py: Python<'_>, path: String) -> PyResult<()> {
py.allow_threads(|| self.inner.load_weights(&path))
.map_err(PyValueError::new_err)
}
/// Serialize the current weights to `path` (magic `AETHERW1` + `u32` count
/// + little-endian `f32` payload). GIL released.
fn save_weights(&self, py: Python<'_>, path: String) -> PyResult<()> {
py.allow_threads(|| self.inner.save_weights(&path))
.map_err(|e| PyValueError::new_err(e.to_string()))
}
fn __repr__(&self) -> String {
format!("EmbeddingExtractor(embedding_dim={})", self.embedding_dim)
}
}
// ─── Module functions ────────────────────────────────────────────────
/// InfoNCE (NT-Xent) contrastive loss between two batches of embeddings.
/// Delegates to the identical Rust implementation. GIL released.
#[pyfunction]
#[pyo3(signature = (embeddings_a, embeddings_b, temperature=0.07))]
fn info_nce_loss(
py: Python<'_>,
embeddings_a: Vec<Vec<f32>>,
embeddings_b: Vec<Vec<f32>>,
temperature: f32,
) -> f32 {
py.allow_threads(|| rust_info_nce_loss(&embeddings_a, &embeddings_b, temperature))
}
/// Cosine similarity between two embeddings — the re-ID scoring
/// primitive. Byte-identical to the private `cosine_similarity` in the
/// backing crate (same dot-product / norm formula, `f32`).
#[pyfunction]
fn cosine_similarity(a: Vec<f32>, b: Vec<f32>) -> f32 {
let n = a.len().min(b.len());
let dot: f32 = (0..n).map(|i| a[i] * b[i]).sum();
let na = (0..n).map(|i| a[i] * a[i]).sum::<f32>().sqrt();
let nb = (0..n).map(|i| b[i] * b[i]).sum::<f32>().sqrt();
if na > 1e-10 && nb > 1e-10 {
dot / (na * nb)
} else {
0.0
}
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyAetherConfig>()?;
m.add_class::<PyCsiAugmenter>()?;
m.add_class::<PyEmbeddingExtractor>()?;
m.add_function(wrap_pyfunction!(info_nce_loss, m)?)?;
m.add_function(wrap_pyfunction!(cosine_similarity, m)?)?;
Ok(())
}
+433
View File
@@ -0,0 +1,433 @@
//! ADR-185 P3 — PyO3 bindings for MAT (Mass Casualty Assessment Tool, ADR-024
//! crate table): WiFi-based disaster-survivor detection + START triage.
//!
//! Bound behind the `[mat]` extra so the disaster/ML stack never enters the
//! default wheel.
//!
//! ## Honest scope vs ADR-185 §3.4
//!
//! - **`scan_once()`** — ADR-185 §3.4/§11.3 proposed adding a sync
//! `scan_once()` wrapper Rust-side. That turned out to be unnecessary: the
//! public async `DisasterResponse::start_scanning()` runs **exactly one**
//! `scan_cycle` and returns when `continuous_monitoring == false`. So this
//! binding forces `continuous_monitoring = false` and drives one scan on a
//! private current-thread tokio runtime — no change to `wifi-densepose-mat`.
//! - **event + zone are required** — `scan_cycle` errors without an active
//! event and an Active zone. ADR-185 §3.4's surface omitted this; the real
//! pipeline needs `initialize_event(...)` + `add_zone(...)` first, so both
//! are bound (documented additions, not fabrications).
//! - **`Survivor.vital_signs`** — the ADR implies a single `VitalSignsReading`;
//! the real accessor returns a *history*. Bound here as
//! `Survivor.latest_vitals -> Optional[VitalSignsReading]`.
//! - **`DisasterType`** has 9 variants at HEAD (adds Landslide, MineCollapse,
//! Industrial, TunnelCollapse) vs the ADR's shorter list; all are bound.
//!
//! ## GIL release
//!
//! `push_csi_data` and `scan_once` release the GIL (`py.allow_threads`) — the
//! detection pipeline + ensemble classifier are the compute-heavy part and
//! touch no Python state.
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use wifi_densepose_mat::{
DisasterConfig, DisasterResponse, DisasterType, ScanZone, Survivor, TriageStatus,
VitalSignsReading, ZoneBounds,
};
// ─── DisasterType ────────────────────────────────────────────────────
/// Type of disaster event (shapes the debris/attenuation model).
#[pyclass(eq, eq_int, frozen, hash, name = "DisasterType")]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum PyDisasterType {
BuildingCollapse = 0,
Earthquake = 1,
Landslide = 2,
Avalanche = 3,
Flood = 4,
MineCollapse = 5,
Industrial = 6,
TunnelCollapse = 7,
Unknown = 8,
}
impl PyDisasterType {
fn as_rust(self) -> DisasterType {
match self {
Self::BuildingCollapse => DisasterType::BuildingCollapse,
Self::Earthquake => DisasterType::Earthquake,
Self::Landslide => DisasterType::Landslide,
Self::Avalanche => DisasterType::Avalanche,
Self::Flood => DisasterType::Flood,
Self::MineCollapse => DisasterType::MineCollapse,
Self::Industrial => DisasterType::Industrial,
Self::TunnelCollapse => DisasterType::TunnelCollapse,
Self::Unknown => DisasterType::Unknown,
}
}
}
#[pymethods]
impl PyDisasterType {
fn __repr__(&self) -> String {
format!("DisasterType.{:?}", self.as_rust())
}
}
// ─── TriageStatus ────────────────────────────────────────────────────
/// START-protocol triage class.
#[pyclass(eq, eq_int, frozen, hash, name = "TriageStatus")]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum PyTriageStatus {
Immediate = 0,
Delayed = 1,
Minor = 2,
Deceased = 3,
Unknown = 4,
}
impl PyTriageStatus {
fn as_rust(self) -> TriageStatus {
match self {
Self::Immediate => TriageStatus::Immediate,
Self::Delayed => TriageStatus::Delayed,
Self::Minor => TriageStatus::Minor,
Self::Deceased => TriageStatus::Deceased,
Self::Unknown => TriageStatus::Unknown,
}
}
fn from_rust(s: &TriageStatus) -> Self {
match s {
TriageStatus::Immediate => Self::Immediate,
TriageStatus::Delayed => Self::Delayed,
TriageStatus::Minor => Self::Minor,
TriageStatus::Deceased => Self::Deceased,
TriageStatus::Unknown => Self::Unknown,
}
}
}
#[pymethods]
impl PyTriageStatus {
/// START priority (1 = highest / Immediate ... 5 = Unknown).
#[getter]
fn priority(&self) -> u8 {
self.as_rust().priority()
}
fn __repr__(&self) -> String {
format!("TriageStatus.{:?}", self.as_rust())
}
}
// ─── VitalSignsReading ───────────────────────────────────────────────
/// A single vital-signs reading (optional breathing/heartbeat + movement).
#[pyclass(frozen, name = "VitalSignsReading")]
pub struct PyVitalSignsReading {
breathing_rate_bpm: Option<f32>,
heartbeat_rate_bpm: Option<f32>,
movement_intensity: f32,
confidence: f64,
}
impl PyVitalSignsReading {
fn from_rust(r: &VitalSignsReading) -> Self {
Self {
breathing_rate_bpm: r.breathing.as_ref().map(|b| b.rate_bpm),
heartbeat_rate_bpm: r.heartbeat.as_ref().map(|h| h.rate_bpm),
movement_intensity: r.movement.intensity,
confidence: r.confidence.value(),
}
}
}
#[pymethods]
impl PyVitalSignsReading {
#[getter]
fn breathing_rate_bpm(&self) -> Option<f32> {
self.breathing_rate_bpm
}
#[getter]
fn heartbeat_rate_bpm(&self) -> Option<f32> {
self.heartbeat_rate_bpm
}
#[getter]
fn movement_intensity(&self) -> f32 {
self.movement_intensity
}
#[getter]
fn confidence(&self) -> f64 {
self.confidence
}
fn __repr__(&self) -> String {
format!(
"VitalSignsReading(breathing={:?}, heartbeat={:?}, movement={:.3}, confidence={:.3})",
self.breathing_rate_bpm, self.heartbeat_rate_bpm, self.movement_intensity, self.confidence,
)
}
}
// ─── Survivor ────────────────────────────────────────────────────────
/// A detected survivor: id, triage class, confidence, optional 3-D location,
/// and the latest vital-signs reading.
#[pyclass(frozen, name = "Survivor")]
pub struct PySurvivor {
id: String,
triage_status: PyTriageStatus,
confidence: f64,
location: Option<(f64, f64, f64)>,
latest_vitals: Option<Py<PyVitalSignsReading>>,
}
impl PySurvivor {
fn from_rust(py: Python<'_>, s: &Survivor) -> PyResult<Self> {
let latest_vitals = match s.vital_signs().latest() {
Some(r) => Some(Py::new(py, PyVitalSignsReading::from_rust(r))?),
None => None,
};
Ok(Self {
id: s.id().as_uuid().to_string(),
triage_status: PyTriageStatus::from_rust(s.triage_status()),
confidence: s.confidence(),
location: s.location().map(|c| (c.x, c.y, c.z)),
latest_vitals,
})
}
}
#[pymethods]
impl PySurvivor {
#[getter]
fn id(&self) -> &str {
&self.id
}
#[getter]
fn triage_status(&self) -> PyTriageStatus {
self.triage_status
}
#[getter]
fn confidence(&self) -> f64 {
self.confidence
}
#[getter]
fn location(&self) -> Option<(f64, f64, f64)> {
self.location
}
#[getter]
fn latest_vitals(&self, py: Python<'_>) -> Option<Py<PyVitalSignsReading>> {
self.latest_vitals.as_ref().map(|v| v.clone_ref(py))
}
fn __repr__(&self) -> String {
format!(
"Survivor(id={}, triage={:?}, confidence={:.3})",
&self.id[..8.min(self.id.len())],
self.triage_status.as_rust(),
self.confidence,
)
}
}
// ─── DisasterConfig ──────────────────────────────────────────────────
/// Configuration for the disaster-response pipeline.
///
/// Note: the Python binding always runs **single-shot** scans (`scan_once`),
/// so `continuous_monitoring` is forced off internally.
#[pyclass(frozen, name = "DisasterConfig")]
#[derive(Clone)]
pub struct PyDisasterConfig {
inner: DisasterConfig,
}
#[pymethods]
impl PyDisasterConfig {
#[new]
#[pyo3(signature = (
disaster_type,
sensitivity=0.8,
confidence_threshold=0.5,
max_depth=5.0,
scan_interval_ms=500
))]
fn new(
disaster_type: PyDisasterType,
sensitivity: f64,
confidence_threshold: f64,
max_depth: f64,
scan_interval_ms: u64,
) -> Self {
let inner = DisasterConfig::builder()
.disaster_type(disaster_type.as_rust())
.sensitivity(sensitivity)
.confidence_threshold(confidence_threshold)
.max_depth(max_depth)
.scan_interval_ms(scan_interval_ms)
.continuous_monitoring(false)
.build();
Self { inner }
}
#[getter]
fn sensitivity(&self) -> f64 {
self.inner.sensitivity
}
#[getter]
fn confidence_threshold(&self) -> f64 {
self.inner.confidence_threshold
}
#[getter]
fn max_depth(&self) -> f64 {
self.inner.max_depth
}
fn __repr__(&self) -> String {
format!(
"DisasterConfig(disaster_type={:?}, sensitivity={}, confidence_threshold={}, max_depth={})",
self.inner.disaster_type,
self.inner.sensitivity,
self.inner.confidence_threshold,
self.inner.max_depth,
)
}
}
// ─── ScanZone ────────────────────────────────────────────────────────
/// A rectangular or circular scan zone (new zones start Active).
#[pyclass(name = "ScanZone")]
#[derive(Clone)]
pub struct PyScanZone {
inner: ScanZone,
}
#[pymethods]
impl PyScanZone {
/// Rectangular zone with corner bounds (metres).
#[staticmethod]
fn rectangle(name: &str, min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
Self {
inner: ScanZone::new(name, ZoneBounds::rectangle(min_x, min_y, max_x, max_y)),
}
}
/// Circular zone centred at `(center_x, center_y)` with `radius` (metres).
#[staticmethod]
fn circle(name: &str, center_x: f64, center_y: f64, radius: f64) -> Self {
Self {
inner: ScanZone::new(name, ZoneBounds::circle(center_x, center_y, radius)),
}
}
#[getter]
fn name(&self) -> &str {
self.inner.name()
}
fn __repr__(&self) -> String {
format!("ScanZone(name={:?})", self.inner.name())
}
}
// ─── DisasterResponse ────────────────────────────────────────────────
/// Main disaster-response coordinator: ingest CSI, run one scan cycle, query
/// detected survivors by START triage.
#[pyclass(name = "DisasterResponse")]
pub struct PyDisasterResponse {
inner: DisasterResponse,
rt: tokio::runtime::Runtime,
}
#[pymethods]
impl PyDisasterResponse {
#[new]
fn new(config: PyDisasterConfig) -> PyResult<Self> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.map_err(|e| PyValueError::new_err(format!("failed to build tokio runtime: {e}")))?;
Ok(Self {
inner: DisasterResponse::new(config.inner),
rt,
})
}
/// Initialize the active disaster event at map coordinate `(x, y)`.
/// Required before `add_zone`/`scan_once`.
fn initialize_event(&mut self, x: f64, y: f64, description: &str) -> PyResult<()> {
self.inner
.initialize_event(geo::Point::new(x, y), description)
.map(|_| ())
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Add an (Active) scan zone to the current event. Raises if no event.
fn add_zone(&mut self, zone: PyScanZone) -> PyResult<()> {
self.inner
.add_zone(zone.inner)
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Push a raw CSI frame (equal-length `amplitudes`/`phases`) into the
/// detection pipeline. Raises on empty/mismatched input. GIL released.
fn push_csi_data(
&self,
py: Python<'_>,
amplitudes: Vec<f64>,
phases: Vec<f64>,
) -> PyResult<()> {
py.allow_threads(|| self.inner.push_csi_data(&amplitudes, &phases))
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Run exactly one scan cycle over the buffered CSI (detection → ensemble
/// → localization → triage). Requires an initialized event with an Active
/// zone. GIL released during the scan.
fn scan_once(&mut self, py: Python<'_>) -> PyResult<()> {
let rt = &self.rt;
let inner = &mut self.inner;
py.allow_threads(|| rt.block_on(inner.start_scanning()))
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// All detected survivors.
fn survivors(&self, py: Python<'_>) -> PyResult<Vec<PySurvivor>> {
self.inner
.survivors()
.into_iter()
.map(|s| PySurvivor::from_rust(py, s))
.collect()
}
/// Survivors filtered by START triage class.
fn survivors_by_triage(
&self,
py: Python<'_>,
status: PyTriageStatus,
) -> PyResult<Vec<PySurvivor>> {
self.inner
.survivors_by_triage(status.as_rust())
.into_iter()
.map(|s| PySurvivor::from_rust(py, s))
.collect()
}
fn __repr__(&self) -> String {
"DisasterResponse()".to_string()
}
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyDisasterType>()?;
m.add_class::<PyTriageStatus>()?;
m.add_class::<PyVitalSignsReading>()?;
m.add_class::<PySurvivor>()?;
m.add_class::<PyDisasterConfig>()?;
m.add_class::<PyScanZone>()?;
m.add_class::<PyDisasterResponse>()?;
Ok(())
}
+492
View File
@@ -0,0 +1,492 @@
//! ADR-185 P2 — PyO3 bindings for MERIDIAN cross-environment domain
//! generalization (ADR-027).
//!
//! Surfaces the **pure-sync, tch-free** inference/adaptation path into
//! `wifi_densepose.meridian`:
//!
//! - `HardwareType` / `HardwareNormalizer` / `CanonicalCsiFrame`
//! (from `wifi-densepose-signal::hardware_norm`)
//! - `MeridianGeometryConfig` / `GeometryEncoder`
//! - `RapidAdaptation` / `AdaptationResult`
//! - `CrossDomainEvaluator` + `mpjpe`
//! (from `wifi-densepose-train`, NO `tch-backend`)
//!
//! ## Honest scope vs ADR-185 §3.3
//!
//! ADR-185 §3.3 names a surface that partly diverges from the code at HEAD;
//! this binding tracks the **real** API and documents each deviation:
//!
//! - `HardwareType.detect(subcarrier_count)` — the real detector is the
//! static `HardwareNormalizer::detect_hardware`; exposed here as a
//! `HardwareType.detect` staticmethod delegating to it (no reimpl).
//! - `HardwareNormalizer.normalize(frame: CsiFrame, hw)` — the real method
//! takes raw `(amplitude, phase)` f64 vectors and returns a `Result`, so
//! it is bound as `normalize(amplitude, phase, hw)` (raises on error).
//! - `CanonicalCsiFrame.amplitudes/.phases` — the real fields are singular
//! `amplitude`/`phase`; bound under their real names.
//! - `RapidAdaptation.calibrate(csi_windows) -> AdaptationResult` with a
//! `converged` field — **does not exist**. The real engine is
//! `push_frame` + `adapt()`, and `AdaptationResult` carries
//! `{lora_weights, final_loss, frames_used, adaptation_epochs}` (no
//! `converged`). Bound as-is; the `calibrate`/`converged` surface is a
//! Rust-side gap, not fabricated here.
//!
//! Training-time types (`DomainFactorizer`, `GradientReversalLayer`,
//! `VirtualDomainAugmentor`) are out of P6 scope (ADR-185 §3.3 / Open Q
//! §11.2) — inference/adaptation only.
//!
//! ## GIL release (per ADR-117 §7, matching bindings/vitals.rs)
//!
//! `normalize`, `encode`, `adapt`, and `evaluate` are pure-sync numeric
//! ops touching no Python objects, so they run inside `py.allow_threads`.
use std::collections::HashMap;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use wifi_densepose_signal::hardware_norm::{
CanonicalCsiFrame, HardwareNormalizer, HardwareType,
};
use wifi_densepose_train::eval::{mpjpe as rust_mpjpe, CrossDomainEvaluator};
use wifi_densepose_train::geometry::{GeometryEncoder, MeridianGeometryConfig};
use wifi_densepose_train::rapid_adapt::{AdaptationLoss, AdaptationResult, RapidAdaptation};
// ─── HardwareType ────────────────────────────────────────────────────
/// WiFi chipset family, keyed by subcarrier count.
#[pyclass(eq, eq_int, frozen, hash, name = "HardwareType")]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum PyHardwareType {
Esp32S3 = 0,
Intel5300 = 1,
Atheros = 2,
Generic = 3,
}
impl PyHardwareType {
fn as_rust(self) -> HardwareType {
match self {
Self::Esp32S3 => HardwareType::Esp32S3,
Self::Intel5300 => HardwareType::Intel5300,
Self::Atheros => HardwareType::Atheros,
Self::Generic => HardwareType::Generic,
}
}
fn from_rust(hw: HardwareType) -> Self {
match hw {
HardwareType::Esp32S3 => Self::Esp32S3,
HardwareType::Intel5300 => Self::Intel5300,
HardwareType::Atheros => Self::Atheros,
HardwareType::Generic => Self::Generic,
}
}
}
#[pymethods]
impl PyHardwareType {
/// Detect hardware from subcarrier count (64→Esp32S3, 30→Intel5300,
/// 56→Atheros, else Generic). Delegates to the real
/// `HardwareNormalizer::detect_hardware`.
#[staticmethod]
fn detect(subcarrier_count: usize) -> Self {
Self::from_rust(HardwareNormalizer::detect_hardware(subcarrier_count))
}
#[getter]
fn subcarrier_count(&self) -> usize {
self.as_rust().subcarrier_count()
}
#[getter]
fn mimo_streams(&self) -> usize {
self.as_rust().mimo_streams()
}
fn __repr__(&self) -> String {
format!("HardwareType.{:?}", self.as_rust())
}
}
// ─── CanonicalCsiFrame ───────────────────────────────────────────────
/// A CSI frame canonicalized to the normalizer's subcarrier grid
/// (default 56): z-scored amplitude + sanitized (unwrapped, detrended)
/// phase.
#[pyclass(frozen, name = "CanonicalCsiFrame")]
pub struct PyCanonicalCsiFrame {
inner: CanonicalCsiFrame,
}
#[pymethods]
impl PyCanonicalCsiFrame {
#[getter]
fn amplitude(&self) -> Vec<f32> {
self.inner.amplitude.clone()
}
#[getter]
fn phase(&self) -> Vec<f32> {
self.inner.phase.clone()
}
#[getter]
fn hardware_type(&self) -> PyHardwareType {
PyHardwareType::from_rust(self.inner.hardware_type)
}
fn __repr__(&self) -> String {
format!(
"CanonicalCsiFrame(subcarriers={}, hardware_type={:?})",
self.inner.amplitude.len(),
self.inner.hardware_type,
)
}
}
// ─── HardwareNormalizer ──────────────────────────────────────────────
/// Normalizes CSI frames from heterogeneous chipsets into a canonical
/// representation (cubic resample → z-score amplitude → sanitize phase).
#[pyclass(name = "HardwareNormalizer")]
pub struct PyHardwareNormalizer {
inner: HardwareNormalizer,
}
#[pymethods]
impl PyHardwareNormalizer {
/// Create a normalizer. `canonical_subcarriers` defaults to 56.
#[new]
#[pyo3(signature = (canonical_subcarriers=56))]
fn new(canonical_subcarriers: usize) -> PyResult<Self> {
HardwareNormalizer::with_canonical_subcarriers(canonical_subcarriers)
.map(|inner| Self { inner })
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Detect hardware from subcarrier count (static).
#[staticmethod]
fn detect_hardware(subcarrier_count: usize) -> PyHardwareType {
PyHardwareType::from_rust(HardwareNormalizer::detect_hardware(subcarrier_count))
}
#[getter]
fn canonical_subcarriers(&self) -> usize {
self.inner.canonical_subcarriers()
}
/// Normalize a raw CSI frame given per-subcarrier `amplitude` and
/// `phase` (equal length) and its `hardware` type. Raises
/// `ValueError` on empty/mismatched input. GIL released.
fn normalize(
&self,
py: Python<'_>,
amplitude: Vec<f64>,
phase: Vec<f64>,
hardware: PyHardwareType,
) -> PyResult<PyCanonicalCsiFrame> {
let hw = hardware.as_rust();
py.allow_threads(|| self.inner.normalize(&amplitude, &phase, hw))
.map(|inner| PyCanonicalCsiFrame { inner })
.map_err(|e| PyValueError::new_err(e.to_string()))
}
fn __repr__(&self) -> String {
format!(
"HardwareNormalizer(canonical_subcarriers={})",
self.inner.canonical_subcarriers()
)
}
}
// ─── MeridianGeometryConfig ──────────────────────────────────────────
/// Config for the geometry encoder (Fourier bands + DeepSets output dim).
#[pyclass(frozen, name = "MeridianGeometryConfig")]
#[derive(Clone)]
pub struct PyMeridianGeometryConfig {
inner: MeridianGeometryConfig,
}
#[pymethods]
impl PyMeridianGeometryConfig {
#[new]
#[pyo3(signature = (n_frequencies=10, scale=1.0, geometry_dim=64, seed=42))]
fn new(n_frequencies: usize, scale: f32, geometry_dim: usize, seed: u64) -> Self {
Self {
inner: MeridianGeometryConfig {
n_frequencies,
scale,
geometry_dim,
seed,
},
}
}
#[getter]
fn n_frequencies(&self) -> usize {
self.inner.n_frequencies
}
#[getter]
fn scale(&self) -> f32 {
self.inner.scale
}
#[getter]
fn geometry_dim(&self) -> usize {
self.inner.geometry_dim
}
#[getter]
fn seed(&self) -> u64 {
self.inner.seed
}
fn __repr__(&self) -> String {
format!(
"MeridianGeometryConfig(n_frequencies={}, scale={}, geometry_dim={}, seed={})",
self.inner.n_frequencies, self.inner.scale, self.inner.geometry_dim, self.inner.seed,
)
}
}
// ─── GeometryEncoder ─────────────────────────────────────────────────
/// Permutation-invariant encoder: variable-count AP positions `[x,y,z]`
/// → a fixed `geometry_dim` (default 64) vector.
#[pyclass(name = "GeometryEncoder")]
pub struct PyGeometryEncoder {
inner: GeometryEncoder,
geometry_dim: usize,
}
#[pymethods]
impl PyGeometryEncoder {
#[new]
#[pyo3(signature = (config=None))]
fn new(config: Option<PyMeridianGeometryConfig>) -> Self {
let cfg = config.map(|c| c.inner).unwrap_or_default();
let geometry_dim = cfg.geometry_dim;
Self {
inner: GeometryEncoder::new(&cfg),
geometry_dim,
}
}
/// Encode AP positions (a non-empty list of `[x, y, z]`) into a
/// `geometry_dim`-length vector. Raises `ValueError` if the list is
/// empty or any position is not exactly 3 coordinates. GIL released.
fn encode(&self, py: Python<'_>, ap_positions: Vec<Vec<f32>>) -> PyResult<Vec<f32>> {
if ap_positions.is_empty() {
return Err(PyValueError::new_err(
"ap_positions must contain at least one [x, y, z] position",
));
}
let mut coords: Vec<[f32; 3]> = Vec::with_capacity(ap_positions.len());
for (i, p) in ap_positions.iter().enumerate() {
if p.len() != 3 {
return Err(PyValueError::new_err(format!(
"ap_positions[{i}] must have exactly 3 coordinates, got {}",
p.len()
)));
}
coords.push([p[0], p[1], p[2]]);
}
Ok(py.allow_threads(|| self.inner.encode(&coords)))
}
#[getter]
fn geometry_dim(&self) -> usize {
self.geometry_dim
}
fn __repr__(&self) -> String {
format!("GeometryEncoder(geometry_dim={})", self.geometry_dim)
}
}
// ─── RapidAdaptation / AdaptationResult ──────────────────────────────
/// Result of `RapidAdaptation.adapt()`.
#[pyclass(frozen, name = "AdaptationResult")]
pub struct PyAdaptationResult {
inner: AdaptationResult,
}
#[pymethods]
impl PyAdaptationResult {
#[getter]
fn lora_weights(&self) -> Vec<f32> {
self.inner.lora_weights.clone()
}
#[getter]
fn final_loss(&self) -> f32 {
self.inner.final_loss
}
#[getter]
fn frames_used(&self) -> usize {
self.inner.frames_used
}
#[getter]
fn adaptation_epochs(&self) -> usize {
self.inner.adaptation_epochs
}
fn __repr__(&self) -> String {
format!(
"AdaptationResult(final_loss={:.6}, frames_used={}, adaptation_epochs={})",
self.inner.final_loss, self.inner.frames_used, self.inner.adaptation_epochs,
)
}
}
/// Few-shot test-time adaptation: accumulate unlabeled CSI frames, then
/// `adapt()` to produce LoRA weight deltas that minimize a self-supervised
/// proxy loss.
///
/// Scope caveat (from the Rust module, kept honest): this minimizes a
/// self-supervised proxy over a tiny LoRA bottleneck; it is NOT wired to
/// the pose model and there is no measured end-to-end PCK gain from this
/// path — do not cite a PCK improvement from `adapt()`.
#[pyclass(name = "RapidAdaptation")]
pub struct PyRapidAdaptation {
inner: RapidAdaptation,
}
#[pymethods]
impl PyRapidAdaptation {
/// Build an adaptation engine. `loss_kind` is one of
/// `"contrastive"`, `"entropy"`, `"combined"` (default). `lambda_ent`
/// is used only by `"combined"`.
#[new]
#[pyo3(signature = (
min_calibration_frames,
lora_rank,
loss_kind="combined",
epochs=5,
lr=0.001,
lambda_ent=0.5
))]
fn new(
min_calibration_frames: usize,
lora_rank: usize,
loss_kind: &str,
epochs: usize,
lr: f32,
lambda_ent: f32,
) -> PyResult<Self> {
let loss = match loss_kind {
"contrastive" => AdaptationLoss::ContrastiveTTT { epochs, lr },
"entropy" => AdaptationLoss::EntropyMin { epochs, lr },
"combined" => AdaptationLoss::Combined {
epochs,
lr,
lambda_ent,
},
other => {
return Err(PyValueError::new_err(format!(
"unknown loss_kind '{other}'; expected 'contrastive', 'entropy', or 'combined'"
)))
}
};
Ok(Self {
inner: RapidAdaptation::new(min_calibration_frames, lora_rank, loss),
})
}
/// Push a single unlabeled CSI frame into the calibration buffer.
fn push_frame(&mut self, frame: Vec<f32>) {
self.inner.push_frame(&frame);
}
/// True once at least `min_calibration_frames` have been buffered.
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[getter]
fn buffer_len(&self) -> usize {
self.inner.buffer_len()
}
/// Run test-time adaptation over the buffered frames. Raises
/// `ValueError` if the buffer is empty or `lora_rank == 0`. GIL
/// released during the finite-difference optimization.
fn adapt(&self, py: Python<'_>) -> PyResult<PyAdaptationResult> {
py.allow_threads(|| self.inner.adapt())
.map(|inner| PyAdaptationResult { inner })
.map_err(|e| PyValueError::new_err(e.to_string()))
}
fn __repr__(&self) -> String {
format!("RapidAdaptation(buffered={})", self.inner.buffer_len())
}
}
// ─── CrossDomainEvaluator ────────────────────────────────────────────
/// Cross-domain pose-accuracy evaluator (MPJPE + domain-gap ratio).
#[pyclass(name = "CrossDomainEvaluator")]
pub struct PyCrossDomainEvaluator {
inner: CrossDomainEvaluator,
}
#[pymethods]
impl PyCrossDomainEvaluator {
/// Create an evaluator for `n_joints` (e.g. 17 for COCO).
#[new]
fn new(n_joints: usize) -> Self {
Self {
inner: CrossDomainEvaluator::new(n_joints),
}
}
/// Evaluate `predictions` (a list of `(pred, gt)` flat `n_joints*3`
/// vectors) grouped by `domain_labels` (0 = in-domain). Returns a
/// dict of the six cross-domain metrics. Raises `ValueError` on a
/// length mismatch. GIL released.
fn evaluate(
&self,
py: Python<'_>,
predictions: Vec<(Vec<f32>, Vec<f32>)>,
domain_labels: Vec<u32>,
) -> PyResult<HashMap<String, f32>> {
if predictions.len() != domain_labels.len() {
return Err(PyValueError::new_err(format!(
"predictions ({}) and domain_labels ({}) must have equal length",
predictions.len(),
domain_labels.len()
)));
}
let m = py.allow_threads(|| self.inner.evaluate(&predictions, &domain_labels));
let mut out = HashMap::with_capacity(6);
out.insert("in_domain_mpjpe".to_string(), m.in_domain_mpjpe);
out.insert("cross_domain_mpjpe".to_string(), m.cross_domain_mpjpe);
out.insert("few_shot_mpjpe".to_string(), m.few_shot_mpjpe);
out.insert("cross_hardware_mpjpe".to_string(), m.cross_hardware_mpjpe);
out.insert("domain_gap_ratio".to_string(), m.domain_gap_ratio);
out.insert("adaptation_speedup".to_string(), m.adaptation_speedup);
Ok(out)
}
fn __repr__(&self) -> String {
"CrossDomainEvaluator()".to_string()
}
}
/// Mean Per Joint Position Error between flat `[n_joints*3]` pose vectors.
#[pyfunction]
fn mpjpe(pred: Vec<f32>, gt: Vec<f32>, n_joints: usize) -> f32 {
rust_mpjpe(&pred, &gt, n_joints)
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyHardwareType>()?;
m.add_class::<PyCanonicalCsiFrame>()?;
m.add_class::<PyHardwareNormalizer>()?;
m.add_class::<PyMeridianGeometryConfig>()?;
m.add_class::<PyGeometryEncoder>()?;
m.add_class::<PyAdaptationResult>()?;
m.add_class::<PyRapidAdaptation>()?;
m.add_class::<PyCrossDomainEvaluator>()?;
m.add_function(wrap_pyfunction!(mpjpe, m)?)?;
Ok(())
}
+27 -5
View File
@@ -242,7 +242,22 @@ impl PyBreathingExtractor {
// ─── HeartRateExtractor ──────────────────────────────────────────────
/// Extracts heart rate (40120 BPM) from per-subcarrier amplitude
/// residuals via 0.82.0 Hz bandpass + autocorrelation peak detection.
/// residuals and per-subcarrier unwrapped phases (radians) via
/// 0.82.0 Hz bandpass + autocorrelation peak detection.
///
/// Python:
/// ```python
/// from wifi_densepose import HeartRateExtractor
///
/// hr = HeartRateExtractor.esp32_default() # 56 subcarriers, 100 Hz, 15s window
///
/// # Feed residuals and matching unwrapped phases from your preprocessor.
/// # Unlike BreathingExtractor weights, phases=[] is invalid for heart-rate
/// # extraction because the Rust core requires phase data for each subcarrier.
/// est = hr.extract(residuals=[0.01, -0.02, …], phases=[0.0, 0.01, …])
/// if est is not None:
/// print(est.value_bpm, est.confidence)
/// ```
#[pyclass(name = "HeartRateExtractor")]
pub struct PyHeartRateExtractor {
inner: HeartRateExtractor,
@@ -265,10 +280,17 @@ impl PyHeartRateExtractor {
Self { inner: HeartRateExtractor::esp32_default() }
}
/// Extract heart rate from per-subcarrier residuals. GIL released
/// during DSP.
fn extract(&mut self, py: Python<'_>, residuals: Vec<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights));
/// Extract heart rate from per-subcarrier residuals and matching
/// per-subcarrier unwrapped phases (radians). Empty phases are invalid
/// and return `None` because the Rust extractor requires phase data.
/// GIL released during DSP.
fn extract(
&mut self,
py: Python<'_>,
residuals: Vec<f64>,
phases: Vec<f64>,
) -> Option<PyVitalEstimate> {
let est = py.allow_threads(|| self.inner.extract(&residuals, &phases));
est.map(PyVitalEstimate::from_rust)
}
+30
View File
@@ -17,7 +17,13 @@
use pyo3::prelude::*;
mod bindings {
#[cfg(feature = "aether")]
pub mod aether;
pub mod bfld;
#[cfg(feature = "mat")]
pub mod mat;
#[cfg(feature = "meridian")]
pub mod meridian;
pub mod keypoint;
pub mod pose;
pub mod privacy_gate;
@@ -43,6 +49,12 @@ fn build_features() -> Vec<&'static str> {
feats.push("p2-pose-bindings"); // BoundingBox + PersonPose + PoseEstimate
feats.push("p3-vitals-bindings"); // BreathingExtractor + HeartRateExtractor + VitalEstimate
feats.push("p3.5-bfld-bindings"); // BfldFrame + BfldReport + BfldKind (stub Rust)
#[cfg(feature = "aether")]
feats.push("p6-aether-bindings"); // ADR-185 P1 — AETHER contrastive embeddings
#[cfg(feature = "meridian")]
feats.push("p6-meridian-bindings"); // ADR-185 P2 — MERIDIAN domain generalization
#[cfg(feature = "mat")]
feats.push("p6-mat-bindings"); // ADR-185 P3 — MAT disaster survivor detection
feats
}
@@ -85,5 +97,23 @@ fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
// the published `wifi-densepose-bfld 0.3.0` crate, not the Python port).
// Closes ADR-125 §2.1.d at the binding boundary.
bindings::privacy_gate::register(m)?;
// ADR-185 P1 — AETHER contrastive CSI embedding bindings, compiled
// and registered only under the `aether` feature so the default
// wheel links none of the sensing-server dependency tree.
#[cfg(feature = "aether")]
bindings::aether::register(m)?;
// ADR-185 P2 — MERIDIAN cross-environment domain-generalization
// bindings (hardware normalization, geometry encoding, rapid
// adaptation, cross-domain eval). Gated behind `meridian`; tch-free.
#[cfg(feature = "meridian")]
bindings::meridian::register(m)?;
// ADR-185 P3 — MAT disaster-survivor detection + START triage. Gated
// behind `mat`, mirroring the upstream disaster/ML stack gating.
#[cfg(feature = "mat")]
bindings::mat::register(m)?;
Ok(())
}
+111
View File
@@ -0,0 +1,111 @@
//! ADR-185 §4.1 — AETHER parity: native-Rust reference half.
//!
//! Produces the golden 128-dim embedding by calling the canonical
//! `wifi-densepose-aether::embedding` code DIRECTLY (no PyO3), for the
//! committed `tests/golden/aether_input.json` fixture, and compares it to the
//! committed golden VECTOR `tests/golden/aether_embedding.json` within a
//! numerical tolerance.
//!
//! Why a vector + tolerance and not a SHA-256 of the f32 bytes: the embedding
//! is pure f32 and uses transcendental ops (ln/sqrt/cos), which are not
//! bit-reproducible across CPU architectures or libm implementations. A byte
//! hash only ever matched the one arch that generated it and failed on every
//! other wheel this project builds (aarch64, macOS-arm). The pytest half
//! (`tests/test_aether.py`) compares the Python binding to the SAME golden
//! within the same tolerance — native≈golden and binding≈golden together prove
//! binding≈native, portably.
//!
//! Regeneration (only when the Rust subsystem intentionally changes): delete
//! `tests/golden/aether_embedding.json` and re-run `cargo test --features aether`.
#![cfg(feature = "aether")]
use std::fs;
use std::path::PathBuf;
use wifi_densepose_aether::embedding::{EmbeddingConfig, EmbeddingExtractor};
use wifi_densepose_aether::graph_transformer::TransformerConfig;
/// Cross-architecture f32 parity tolerance; see the module docs and the
/// matching `PARITY_ATOL`/`PARITY_RTOL` in `tests/test_aether.py`.
const PARITY_ATOL: f32 = 1e-4;
const PARITY_RTOL: f32 = 1e-4;
/// Assert `embedding` matches the committed golden vector `<name>` within
/// tolerance, or (if the golden is absent) write it and fail asking for a re-run.
fn assert_matches_golden_vector(embedding: &[f32], name: &str) {
let path = golden_dir().join(name);
match fs::read_to_string(&path) {
Ok(raw) => {
let golden: Vec<f32> = serde_json::from_str(&raw)
.expect("parse golden vector json");
assert_eq!(embedding.len(), golden.len(), "{name}: length mismatch");
for (i, (&got, &want)) in embedding.iter().zip(&golden).enumerate() {
let tol = PARITY_ATOL + PARITY_RTOL * want.abs();
assert!(
(got - want).abs() <= tol,
"{name}: element {i} diverged beyond tolerance \
(got {got}, golden {want}, |Δ|={}) a real regression, \
not cross-arch f32 drift",
(got - want).abs()
);
}
}
Err(_) => {
let json = serde_json::to_string(&embedding).expect("serialize golden");
fs::write(&path, &json).expect("write golden vector");
panic!("no committed golden {name}; wrote it. Re-run to verify parity.");
}
}
}
fn golden_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden")
}
fn load_input() -> Vec<Vec<f32>> {
let raw = fs::read_to_string(golden_dir().join("aether_input.json"))
.expect("read aether_input.json fixture");
let rows: Vec<Vec<f64>> = serde_json::from_str(&raw).expect("parse aether_input.json");
rows.into_iter()
.map(|row| row.into_iter().map(|x| x as f32).collect())
.collect()
}
/// Build the extractor identically to the Python binding's default
/// construction: `AetherConfig()` + `EmbeddingExtractor(n_subcarriers=56, cfg)`.
fn embed_native(input: &[Vec<f32>]) -> Vec<f32> {
let e_config = EmbeddingConfig {
d_model: 64,
d_proj: 128,
temperature: 0.07,
normalize: true,
};
let t_config = TransformerConfig {
n_subcarriers: 56,
n_keypoints: 17,
d_model: 64,
n_heads: 4,
n_gnn_layers: 2,
};
let mut ext = EmbeddingExtractor::new(t_config, e_config);
ext.extract(input)
}
#[test]
fn native_embedding_is_128_dim_unit_norm() {
let emb = embed_native(&load_input());
assert_eq!(emb.len(), 128, "AETHER embedding must be 128-dim");
let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!(
(norm - 1.0).abs() < 1e-4,
"embedding must be L2-normalized, got norm={norm}"
);
}
#[test]
fn native_embedding_matches_committed_golden() {
let emb = embed_native(&load_input());
assert_matches_golden_vector(&emb, "aether_embedding.json");
}
+137
View File
@@ -0,0 +1,137 @@
//! ADR-185 §13.a — weight-loading parity: native-Rust reference half.
//!
//! Proves the AETHER `load_weights` path produces a deterministic, non-random
//! embedding, and compares it to the committed golden VECTOR
//! `tests/golden/aether_loaded_embedding.json` within tolerance. The pytest
//! half (`tests/test_aether.py`) writes a byte-identical weight file (same
//! formula + format) through the binding's `load_weights` and compares to the
//! SAME golden within the same tolerance — native≈golden and binding≈golden
//! prove the binding's weight-loading matches native, portably across arch.
//! (See `aether_parity.rs` for why this is a tolerance compare, not a hash.)
//!
//! Weight formula (shared with the pytest half): `w[i] = k/65536 - 0.5` where
//! `k = (i*1103515245 + 12345) mod 65536`. `k/65536` is a multiple of 2⁻¹⁶,
//! exactly representable in both f32 and f64, so both languages produce
//! byte-identical weights.
//!
//! File format: 8-byte magic `AETHERW1`, `u32` little-endian param count, then
//! that many little-endian `f32`.
//!
//! Regenerate (only on an intentional change): delete the .json golden and
//! re-run `cargo test --features aether --test aether_weights_parity`.
#![cfg(feature = "aether")]
use std::fs;
use std::path::PathBuf;
use wifi_densepose_aether::embedding::{EmbeddingConfig, EmbeddingExtractor};
use wifi_densepose_aether::graph_transformer::TransformerConfig;
fn golden_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden")
}
fn load_input() -> Vec<Vec<f32>> {
let raw = fs::read_to_string(golden_dir().join("aether_input.json"))
.expect("read aether_input.json fixture");
let rows: Vec<Vec<f64>> = serde_json::from_str(&raw).expect("parse aether_input.json");
rows.into_iter()
.map(|row| row.into_iter().map(|x| x as f32).collect())
.collect()
}
/// Same default construction as `aether_parity.rs` / the Python binding default.
fn new_extractor() -> EmbeddingExtractor {
let e_config = EmbeddingConfig {
d_model: 64,
d_proj: 128,
temperature: 0.07,
normalize: true,
};
let t_config = TransformerConfig {
n_subcarriers: 56,
n_keypoints: 17,
d_model: 64,
n_heads: 4,
n_gnn_layers: 2,
};
EmbeddingExtractor::new(t_config, e_config)
}
fn formula_weights(n: usize) -> Vec<f32> {
(0..n)
.map(|i| {
let k = (i as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345) % 65_536;
k as f32 / 65_536.0 - 0.5
})
.collect()
}
fn write_weight_file(path: &PathBuf, weights: &[f32]) {
let mut buf = Vec::with_capacity(12 + weights.len() * 4);
buf.extend_from_slice(b"AETHERW1");
buf.extend_from_slice(&(weights.len() as u32).to_le_bytes());
for v in weights {
buf.extend_from_slice(&v.to_le_bytes());
}
fs::write(path, buf).unwrap();
}
/// Cross-architecture f32 parity tolerance; see `aether_parity.rs` and the
/// matching constants in `tests/test_aether.py` for why this is a tolerance
/// compare and not a byte hash.
const PARITY_ATOL: f32 = 1e-4;
const PARITY_RTOL: f32 = 1e-4;
fn assert_matches_golden_vector(embedding: &[f32], name: &str) {
let path = golden_dir().join(name);
match fs::read_to_string(&path) {
Ok(raw) => {
let golden: Vec<f32> = serde_json::from_str(&raw).expect("parse golden vector json");
assert_eq!(embedding.len(), golden.len(), "{name}: length mismatch");
for (i, (&got, &want)) in embedding.iter().zip(&golden).enumerate() {
let tol = PARITY_ATOL + PARITY_RTOL * want.abs();
assert!(
(got - want).abs() <= tol,
"{name}: element {i} diverged beyond tolerance \
(got {got}, golden {want}, |Δ|={}) real regression, not arch drift",
(got - want).abs()
);
}
}
Err(_) => {
let json = serde_json::to_string(&embedding).expect("serialize golden");
fs::write(&path, &json).expect("write golden vector");
panic!("no committed golden {name}; wrote it. Re-run to verify parity.");
}
}
}
#[test]
fn native_loaded_embedding_matches_committed_golden() {
let input = load_input();
let mut ext = new_extractor();
let baseline = ext.extract(&input); // random Xavier init
let weights = formula_weights(ext.param_count());
let path = std::env::temp_dir().join(format!(
"aether_weights_parity_{}.bin",
std::process::id()
));
write_weight_file(&path, &weights);
ext.load_weights(&path).expect("load_weights");
fs::remove_file(&path).ok();
let loaded = ext.extract(&input);
// Loaded weights must actually take effect.
assert!(
baseline.iter().zip(&loaded).any(|(a, b)| (a - b).abs() > 1e-6),
"load_weights had no effect vs the random-init baseline"
);
assert_eq!(loaded.len(), 128);
assert_matches_golden_vector(&loaded, "aether_loaded_embedding.json");
}
@@ -0,0 +1 @@
[-0.09882868826389313, 0.08015579730272293, 0.019888414070010185, -0.003239249112084508, -0.07265102863311768, -0.08036628365516663, -0.10324385017156601, -0.21274414658546448, 0.1503465175628662, -0.005489329807460308, 0.10834519565105438, 0.05838076397776604, -0.05911992862820625, 0.13135841488838196, -0.006811900530010462, -0.13742603361606598, -0.015311408787965775, -0.21133442223072052, 0.05134191736578941, -0.027355113998055458, -0.044147003442049026, -0.006108833476901054, -0.033326443284749985, 0.15741342306137085, 0.029073286801576614, 0.0375739224255085, 0.023920813575387, 0.07043211907148361, -0.009550352580845356, 0.028179991990327835, 0.05900105461478233, 0.056598298251628876, -0.0047074914909899235, 0.05960564315319061, 0.049969129264354706, 0.017297586426138878, -0.10153798758983612, -0.002574362326413393, 0.06392877548933029, 0.14119082689285278, -0.04484020546078682, -0.038461778312921524, -0.06095990538597107, -0.04703143611550331, 0.07692500203847885, -0.10256559401750565, -0.07250502705574036, 0.12476003915071487, -0.08511383831501007, -0.006181457545608282, 0.09957090020179749, 0.10756388306617737, -0.08597669750452042, -0.09914427250623703, -0.01648416928946972, -0.25724929571151733, -0.024403633549809456, 0.05453304573893547, -0.03141803294420242, -0.07852566242218018, 0.020048094913363457, -0.06215068697929382, 0.11997628211975098, 0.0977955237030983, -0.0652041882276535, -0.007863834500312805, -0.059089504182338715, 0.12663882970809937, 0.16099494695663452, -0.046197254210710526, 0.04186059534549713, -0.07077737897634506, -0.28093546628952026, 0.017284320667386055, 0.1626843810081482, 0.006352100986987352, -0.07779540121555328, -0.004958702251315117, 0.04892482981085777, 0.013853654265403748, 0.08351490646600723, 0.06772761791944504, -0.028758108615875244, 0.04778963327407837, -0.1131502315402031, 0.005114563275128603, -0.012307023629546165, 0.03301718831062317, 0.09168995916843414, -0.04085332900285721, 0.04984535649418831, -0.05280173197388649, -0.01752082072198391, 0.04126245900988579, -0.09206362813711166, 0.08685162663459778, 0.03651193156838417, -0.144779771566391, -0.03290265053510666, 0.15378770232200623, -0.08842422068119049, 0.12492084503173828, 0.04907738417387009, -0.03483045473694801, -0.09838201850652695, 0.04590783640742302, -0.01383654773235321, 0.03766492009162903, -0.03254647180438042, 0.12435581535100937, 0.06573979556560516, 0.055382076650857925, -0.19347889721393585, 0.0018683894304558635, 0.11095203459262848, 0.04290277138352394, -0.07855042070150375, 0.03853689134120941, -0.06062287464737892, 0.004584986716508865, -0.1223185583949089, 0.0031570768915116787, -0.10847429186105728, 0.0023399614728987217, -0.054206088185310364, -0.1367102563381195, -0.14624592661857605, 0.16953621804714203]
+1
View File
@@ -0,0 +1 @@
[[0.0, 0.00390625, 0.0078125, 0.01171875, 0.015625, 0.01953125, 0.0234375, 0.02734375, 0.03125, 0.03515625, 0.0390625, 0.04296875, 0.046875, 0.05078125, 0.0546875, 0.05859375, 0.0625, 0.06640625, 0.0703125, 0.07421875, 0.078125, 0.08203125, 0.0859375, 0.08984375, 0.09375, 0.09765625, 0.1015625, 0.10546875, 0.109375, 0.11328125, 0.1171875, 0.12109375, 0.125, 0.12890625, 0.1328125, 0.13671875, 0.140625, 0.14453125, 0.1484375, 0.15234375, 0.15625, 0.16015625, 0.1640625, 0.16796875, 0.171875, 0.17578125, 0.1796875, 0.18359375, 0.1875, 0.19140625, 0.1953125, 0.19921875, 0.203125, 0.20703125, 0.2109375, 0.21484375], [0.21875, 0.22265625, 0.2265625, 0.23046875, 0.234375, 0.23828125, 0.2421875, 0.24609375, 0.25, 0.25390625, 0.2578125, 0.26171875, 0.265625, 0.26953125, 0.2734375, 0.27734375, 0.28125, 0.28515625, 0.2890625, 0.29296875, 0.296875, 0.30078125, 0.3046875, 0.30859375, 0.3125, 0.31640625, 0.3203125, 0.32421875, 0.328125, 0.33203125, 0.3359375, 0.33984375, 0.34375, 0.34765625, 0.3515625, 0.35546875, 0.359375, 0.36328125, 0.3671875, 0.37109375, 0.375, 0.37890625, 0.3828125, 0.38671875, 0.390625, 0.39453125, 0.3984375, 0.40234375, 0.40625, 0.41015625, 0.4140625, 0.41796875, 0.421875, 0.42578125, 0.4296875, 0.43359375], [0.4375, 0.44140625, 0.4453125, 0.44921875, 0.453125, 0.45703125, 0.4609375, 0.46484375, 0.46875, 0.47265625, 0.4765625, 0.48046875, 0.484375, 0.48828125, 0.4921875, 0.49609375, 0.5, 0.50390625, 0.5078125, 0.51171875, 0.515625, 0.51953125, 0.5234375, 0.52734375, 0.53125, 0.53515625, 0.5390625, 0.54296875, 0.546875, 0.55078125, 0.5546875, 0.55859375, 0.5625, 0.56640625, 0.5703125, 0.57421875, 0.578125, 0.58203125, 0.5859375, 0.58984375, 0.59375, 0.59765625, 0.6015625, 0.60546875, 0.609375, 0.61328125, 0.6171875, 0.62109375, 0.625, 0.62890625, 0.6328125, 0.63671875, 0.640625, 0.64453125, 0.6484375, 0.65234375], [0.65625, 0.66015625, 0.6640625, 0.66796875, 0.671875, 0.67578125, 0.6796875, 0.68359375, 0.6875, 0.69140625, 0.6953125, 0.69921875, 0.703125, 0.70703125, 0.7109375, 0.71484375, 0.71875, 0.72265625, 0.7265625, 0.73046875, 0.734375, 0.73828125, 0.7421875, 0.74609375, 0.75, 0.75390625, 0.7578125, 0.76171875, 0.765625, 0.76953125, 0.7734375, 0.77734375, 0.78125, 0.78515625, 0.7890625, 0.79296875, 0.796875, 0.80078125, 0.8046875, 0.80859375, 0.8125, 0.81640625, 0.8203125, 0.82421875, 0.828125, 0.83203125, 0.8359375, 0.83984375, 0.84375, 0.84765625, 0.8515625, 0.85546875, 0.859375, 0.86328125, 0.8671875, 0.87109375], [0.875, 0.87890625, 0.8828125, 0.88671875, 0.890625, 0.89453125, 0.8984375, 0.90234375, 0.90625, 0.91015625, 0.9140625, 0.91796875, 0.921875, 0.92578125, 0.9296875, 0.93359375, 0.9375, 0.94140625, 0.9453125, 0.94921875, 0.953125, 0.95703125, 0.9609375, 0.96484375, 0.96875, 0.97265625, 0.9765625, 0.98046875, 0.984375, 0.98828125, 0.9921875, 0.99609375, 0.0, 0.00390625, 0.0078125, 0.01171875, 0.015625, 0.01953125, 0.0234375, 0.02734375, 0.03125, 0.03515625, 0.0390625, 0.04296875, 0.046875, 0.05078125, 0.0546875, 0.05859375, 0.0625, 0.06640625, 0.0703125, 0.07421875, 0.078125, 0.08203125, 0.0859375, 0.08984375], [0.09375, 0.09765625, 0.1015625, 0.10546875, 0.109375, 0.11328125, 0.1171875, 0.12109375, 0.125, 0.12890625, 0.1328125, 0.13671875, 0.140625, 0.14453125, 0.1484375, 0.15234375, 0.15625, 0.16015625, 0.1640625, 0.16796875, 0.171875, 0.17578125, 0.1796875, 0.18359375, 0.1875, 0.19140625, 0.1953125, 0.19921875, 0.203125, 0.20703125, 0.2109375, 0.21484375, 0.21875, 0.22265625, 0.2265625, 0.23046875, 0.234375, 0.23828125, 0.2421875, 0.24609375, 0.25, 0.25390625, 0.2578125, 0.26171875, 0.265625, 0.26953125, 0.2734375, 0.27734375, 0.28125, 0.28515625, 0.2890625, 0.29296875, 0.296875, 0.30078125, 0.3046875, 0.30859375], [0.3125, 0.31640625, 0.3203125, 0.32421875, 0.328125, 0.33203125, 0.3359375, 0.33984375, 0.34375, 0.34765625, 0.3515625, 0.35546875, 0.359375, 0.36328125, 0.3671875, 0.37109375, 0.375, 0.37890625, 0.3828125, 0.38671875, 0.390625, 0.39453125, 0.3984375, 0.40234375, 0.40625, 0.41015625, 0.4140625, 0.41796875, 0.421875, 0.42578125, 0.4296875, 0.43359375, 0.4375, 0.44140625, 0.4453125, 0.44921875, 0.453125, 0.45703125, 0.4609375, 0.46484375, 0.46875, 0.47265625, 0.4765625, 0.48046875, 0.484375, 0.48828125, 0.4921875, 0.49609375, 0.5, 0.50390625, 0.5078125, 0.51171875, 0.515625, 0.51953125, 0.5234375, 0.52734375], [0.53125, 0.53515625, 0.5390625, 0.54296875, 0.546875, 0.55078125, 0.5546875, 0.55859375, 0.5625, 0.56640625, 0.5703125, 0.57421875, 0.578125, 0.58203125, 0.5859375, 0.58984375, 0.59375, 0.59765625, 0.6015625, 0.60546875, 0.609375, 0.61328125, 0.6171875, 0.62109375, 0.625, 0.62890625, 0.6328125, 0.63671875, 0.640625, 0.64453125, 0.6484375, 0.65234375, 0.65625, 0.66015625, 0.6640625, 0.66796875, 0.671875, 0.67578125, 0.6796875, 0.68359375, 0.6875, 0.69140625, 0.6953125, 0.69921875, 0.703125, 0.70703125, 0.7109375, 0.71484375, 0.71875, 0.72265625, 0.7265625, 0.73046875, 0.734375, 0.73828125, 0.7421875, 0.74609375]]
@@ -0,0 +1 @@
[0.10761479288339615, 0.05284854769706726, -0.22418244183063507, -0.015137949027121067, -0.03409634903073311, 0.17326673865318298, 0.11726372689008713, -0.07647006958723068, 0.034259773790836334, -0.11302468180656433, 0.05575002729892731, -0.000968325708527118, 0.12398994714021683, 0.010035112500190735, -0.024740785360336304, 0.03396096080541611, -0.13298068940639496, -0.026409678161144257, -0.00981970690190792, 0.1098528727889061, -0.015091875568032265, -0.06820717453956604, -0.08815668523311615, -0.13947811722755432, 0.12845416367053986, 0.007558434270322323, 0.09278517216444016, 0.023929834365844727, -0.15709640085697174, 0.20790696144104004, -0.0814460963010788, 0.035465411841869354, 0.0621788427233696, -0.022502727806568146, 0.1301409900188446, -0.09830718487501144, -0.04854566603899002, -0.060892220586538315, -0.0039014117792248726, 0.08287017792463303, 0.0968087762594223, -0.05596396327018738, -0.18289180099964142, 0.07555137574672699, -0.0711054727435112, 0.017438538372516632, -0.07017677277326584, 0.08828858286142349, 0.09126422554254532, -0.18576686084270477, -0.08681167662143707, 0.006826931145042181, 0.13380175828933716, 0.15818704664707184, -0.03554683178663254, -0.02037874236702919, -0.0721014142036438, 0.09667330235242844, 0.03744731843471527, -0.019951190799474716, 0.05095838010311127, 0.0136749017983675, -0.04109140485525131, -0.09205742925405502, -0.06173047423362732, 0.028595957905054092, 0.07932677119970322, 0.025831375271081924, -0.029791485518217087, -0.047233421355485916, -0.0985548198223114, 0.056721169501543045, 0.04597408324480057, 0.05116378515958786, 0.06485309451818466, -0.11868073791265488, 0.1164744570851326, -0.040522847324609756, -0.12034964561462402, 0.10310209542512894, 0.01842050999403, 0.16855661571025848, -0.05738396197557449, -0.17958901822566986, -0.022476589307188988, 0.03451419249176979, 0.034107644110918045, 0.13773204386234283, -0.07001013308763504, -0.14196854829788208, 0.11647462099790573, -0.03268979489803314, 0.058361802250146866, -0.029253516346216202, 0.1251915991306305, 0.13218750059604645, -0.14484354853630066, -0.1424856185913086, 0.045242637395858765, 0.039408858865499496, 0.199110209941864, 0.0028688418678939342, -0.14990390837192535, -0.031178129836916924, -0.000643149483948946, 0.0783705934882164, 0.02097206376492977, -0.058810342103242874, 0.05459814518690109, -0.00016812187095638365, -0.1195453405380249, -0.06815463304519653, 0.03833199664950371, 0.12025003135204315, 0.06424705684185028, 0.011131756007671356, -0.006310137454420328, -0.06013919785618782, 0.061071064323186874, 0.018219128251075745, 0.08957944065332413, -0.04298160970211029, -0.07775749266147614, -0.01905573531985283, -0.002107167150825262, -0.0794263556599617, -0.005148472264409065, 0.05683618783950806]
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
ae46351e28c01161c3c20f1a3134d8e32fbbef0cda2fdb9c3a27984da0ce026d
+1
View File
@@ -0,0 +1 @@
{"esp32_amplitude": [0.51171875, 0.5390625, 0.56640625, 0.59375, 0.62109375, 0.6484375, 0.67578125, 0.703125, 0.73046875, 0.7578125, 0.78515625, 0.8125, 0.83984375, 0.8671875, 0.89453125, 0.921875, 0.94921875, 0.9765625, 1.00390625, 1.03125, 1.05859375, 1.0859375, 1.11328125, 1.140625, 1.16796875, 1.1953125, 1.22265625, 1.25, 1.27734375, 1.3046875, 1.33203125, 1.359375, 1.38671875, 1.4140625, 1.44140625, 1.46875, 1.49609375, 0.5234375, 0.55078125, 0.578125, 0.60546875, 0.6328125, 0.66015625, 0.6875, 0.71484375, 0.7421875, 0.76953125, 0.796875, 0.82421875, 0.8515625, 0.87890625, 0.90625, 0.93359375, 0.9609375, 0.98828125, 1.015625, 1.04296875, 1.0703125, 1.09765625, 1.125, 1.15234375, 1.1796875, 1.20703125, 1.234375], "esp32_phase": [-0.45703125, -0.4375, -0.41796875, -0.3984375, -0.37890625, -0.359375, -0.33984375, -0.3203125, -0.30078125, -0.28125, -0.26171875, -0.2421875, -0.22265625, -0.203125, -0.18359375, -0.1640625, -0.14453125, -0.125, -0.10546875, -0.0859375, -0.06640625, -0.046875, -0.02734375, -0.0078125, 0.01171875, 0.03125, 0.05078125, 0.0703125, 0.08984375, 0.109375, 0.12890625, 0.1484375, 0.16796875, 0.1875, 0.20703125, 0.2265625, 0.24609375, 0.265625, 0.28515625, 0.3046875, 0.32421875, 0.34375, 0.36328125, 0.3828125, 0.40234375, 0.421875, 0.44140625, 0.4609375, 0.48046875, -0.5, -0.48046875, -0.4609375, -0.44140625, -0.421875, -0.40234375, -0.3828125, -0.36328125, -0.34375, -0.32421875, -0.3046875, -0.28515625, -0.265625, -0.24609375, -0.2265625], "intel_amplitude": [0.50390625, 0.546875, 0.58984375, 0.6328125, 0.67578125, 0.71875, 0.76171875, 0.8046875, 0.84765625, 0.890625, 0.93359375, 0.9765625, 1.01953125, 1.0625, 1.10546875, 1.1484375, 1.19140625, 1.234375, 1.27734375, 1.3203125, 1.36328125, 1.40625, 1.44921875, 1.4921875, 0.53515625, 0.578125, 0.62109375, 0.6640625, 0.70703125, 0.75], "intel_phase": [-0.47265625, -0.4609375, -0.44921875, -0.4375, -0.42578125, -0.4140625, -0.40234375, -0.390625, -0.37890625, -0.3671875, -0.35546875, -0.34375, -0.33203125, -0.3203125, -0.30859375, -0.296875, -0.28515625, -0.2734375, -0.26171875, -0.25, -0.23828125, -0.2265625, -0.21484375, -0.203125, -0.19140625, -0.1796875, -0.16796875, -0.15625, -0.14453125, -0.1328125], "ap_positions": [[0.25, 0.5, 0.75], [1.0, 1.25, 1.5], [2.0, 0.0, -0.5]], "rapid_frames": [[0.0, 0.01953125, 0.0390625, 0.05859375, 0.078125, 0.09765625, 0.1171875, 0.13671875, 0.15625, 0.17578125, 0.1953125, 0.21484375, 0.234375, 0.25390625, 0.2734375, 0.29296875], [0.05078125, 0.0703125, 0.08984375, 0.109375, 0.12890625, 0.1484375, 0.16796875, 0.1875, 0.20703125, 0.2265625, 0.24609375, 0.265625, 0.28515625, 0.3046875, 0.32421875, 0.34375], [0.1015625, 0.12109375, 0.140625, 0.16015625, 0.1796875, 0.19921875, 0.21875, 0.23828125, 0.2578125, 0.27734375, 0.296875, 0.31640625, 0.3359375, 0.35546875, 0.375, 0.39453125], [0.15234375, 0.171875, 0.19140625, 0.2109375, 0.23046875, 0.25, 0.26953125, 0.2890625, 0.30859375, 0.328125, 0.34765625, 0.3671875, 0.38671875, 0.40625, 0.42578125, 0.4453125], [0.203125, 0.22265625, 0.2421875, 0.26171875, 0.28125, 0.30078125, 0.3203125, 0.33984375, 0.359375, 0.37890625, 0.3984375, 0.41796875, 0.4375, 0.45703125, 0.4765625, 0.49609375], [0.25390625, 0.2734375, 0.29296875, 0.3125, 0.33203125, 0.3515625, 0.37109375, 0.390625, 0.41015625, 0.4296875, 0.44921875, 0.46875, 0.48828125, 0.5078125, 0.52734375, 0.546875], [0.3046875, 0.32421875, 0.34375, 0.36328125, 0.3828125, 0.40234375, 0.421875, 0.44140625, 0.4609375, 0.48046875, 0.5, 0.51953125, 0.5390625, 0.55859375, 0.578125, 0.59765625], [0.35546875, 0.375, 0.39453125, 0.4140625, 0.43359375, 0.453125, 0.47265625, 0.4921875, 0.51171875, 0.53125, 0.55078125, 0.5703125, 0.58984375, 0.609375, 0.62890625, 0.6484375], [0.40625, 0.42578125, 0.4453125, 0.46484375, 0.484375, 0.50390625, 0.5234375, 0.54296875, 0.5625, 0.58203125, 0.6015625, 0.62109375, 0.640625, 0.66015625, 0.6796875, 0.69921875], [0.45703125, 0.4765625, 0.49609375, 0.515625, 0.53515625, 0.5546875, 0.57421875, 0.59375, 0.61328125, 0.6328125, 0.65234375, 0.671875, 0.69140625, 0.7109375, 0.73046875, 0.75], [0.5078125, 0.52734375, 0.546875, 0.56640625, 0.5859375, 0.60546875, 0.625, 0.64453125, 0.6640625, 0.68359375, 0.703125, 0.72265625, 0.7421875, 0.76171875, 0.78125, 0.80078125], [0.55859375, 0.578125, 0.59765625, 0.6171875, 0.63671875, 0.65625, 0.67578125, 0.6953125, 0.71484375, 0.734375, 0.75390625, 0.7734375, 0.79296875, 0.8125, 0.83203125, 0.8515625]]}
@@ -0,0 +1 @@
0486402d5a860f459a319cd779ca44a112d8543442ae9ce9eb7b1a01780aee4b
+126
View File
@@ -0,0 +1,126 @@
//! ADR-185 §4.1 — MAT bit-for-bit parity: native-Rust reference half.
//!
//! Drives the canonical `wifi-densepose-mat` `DisasterResponse` pipeline
//! DIRECTLY (no PyO3) over the committed `tests/golden/mat_input.json` CSI
//! stream and locks the SHA-256 of a canonical result string
//! (`count=<K>;triage_priorities=<sorted>`) into
//! `tests/golden/mat_result.sha256`.
//!
//! Only the survivor **count** and **triage classes** are hashed — survivor
//! UUIDs and event timestamps are non-deterministic and deliberately
//! excluded, so the hash captures exactly the "identical triage +
//! survivor count for a fixed CSI stream" invariant of ADR-185 §4.1.
//!
//! Honesty note: the fixture is a synthetic breathing-modulated stream, so
//! this proves the Python binding drives the real pipeline byte-identically
//! to native Rust — it is NOT a detection-accuracy claim on real rubble.
//!
//! Regenerate (only on an intentional Rust change): delete the .sha256 and
//! re-run `cargo test --features mat --test mat_parity`.
#![cfg(feature = "mat")]
use std::fs;
use std::path::PathBuf;
use serde_json::Value;
use sha2::{Digest, Sha256};
use wifi_densepose_mat::{DisasterConfig, DisasterResponse, DisasterType, ScanZone, ZoneBounds};
fn golden_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden")
}
fn fixture() -> Value {
let raw = fs::read_to_string(golden_dir().join("mat_input.json"))
.expect("read mat_input.json fixture");
serde_json::from_str(&raw).expect("parse mat_input.json")
}
/// Build the response identically to the Python binding's default
/// construction and run one scan over the fixture CSI stream. Returns the
/// canonical `count=<K>;triage_priorities=<sorted>` string.
fn mat_canonical_result(fx: &Value) -> String {
let config = DisasterConfig::builder()
.disaster_type(DisasterType::Earthquake)
.sensitivity(0.9)
.confidence_threshold(0.1)
.max_depth(5.0)
.continuous_monitoring(false)
.build();
let mut resp = DisasterResponse::new(config);
resp.initialize_event(geo::Point::new(0.0, 0.0), "parity-fixture")
.expect("initialize_event");
resp.add_zone(ScanZone::new(
"Zone A",
ZoneBounds::rectangle(0.0, 0.0, 50.0, 30.0),
))
.expect("add_zone");
for frame in fx["stream"].as_array().unwrap() {
let amp: Vec<f64> = frame["amplitude"]
.as_array()
.unwrap()
.iter()
.map(|x| x.as_f64().unwrap())
.collect();
let ph: Vec<f64> = frame["phase"]
.as_array()
.unwrap()
.iter()
.map(|x| x.as_f64().unwrap())
.collect();
resp.push_csi_data(&amp, &ph).expect("push_csi_data");
}
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
rt.block_on(resp.start_scanning()).expect("scan");
let survivors = resp.survivors();
let mut priorities: Vec<u8> = survivors
.iter()
.map(|s| s.triage_status().priority())
.collect();
priorities.sort_unstable();
format!("count={};triage_priorities={:?}", survivors.len(), priorities)
}
fn sha256_hex(s: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(s.as_bytes());
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[test]
fn native_mat_result_is_deterministic() {
let fx = fixture();
// Two independent runs must agree (survivor count + triage classes are
// deterministic; UUIDs/timestamps are excluded from the canonical form).
assert_eq!(mat_canonical_result(&fx), mat_canonical_result(&fx));
}
#[test]
fn native_mat_matches_committed_golden() {
let canon = mat_canonical_result(&fixture());
let got = sha256_hex(&canon);
let path = golden_dir().join("mat_result.sha256");
match fs::read_to_string(&path) {
Ok(expected) => assert_eq!(
got,
expected.trim(),
"native MAT result drifted from committed golden (canonical form: {canon})"
),
Err(_) => {
fs::write(&path, &got).expect("write golden sha256");
panic!("no committed golden found; wrote {got} for [{canon}]. Re-run to verify.");
}
}
}
+178
View File
@@ -0,0 +1,178 @@
//! ADR-185 §4.1 — MERIDIAN bit-for-bit parity: native-Rust reference half.
//!
//! Calls the canonical `wifi-densepose-signal::hardware_norm` +
//! `wifi-densepose-train::{geometry,rapid_adapt}` code DIRECTLY (no PyO3)
//! on the committed `tests/golden/meridian_input.json` fixture and locks
//! the SHA-256 of the concatenated f32 outputs into
//! `tests/golden/meridian_output.sha256`.
//!
//! Concatenation order (identical in the pytest half, tests/test_meridian.py):
//! 1. esp32 canonical amplitude (56) 2. esp32 canonical phase (56)
//! 3. intel5300 canonical amplitude 4. intel5300 canonical phase
//! 5. geometry.encode(ap_positions) 6. rapid_adapt lora_weights
//!
//! Regenerate (only on an intentional Rust change): delete the .sha256 and
//! re-run `cargo test --features meridian --test meridian_parity`.
#![cfg(feature = "meridian")]
use std::fs;
use std::path::PathBuf;
use serde_json::Value;
use sha2::{Digest, Sha256};
use wifi_densepose_signal::hardware_norm::{HardwareNormalizer, HardwareType};
use wifi_densepose_train::geometry::{GeometryEncoder, MeridianGeometryConfig};
use wifi_densepose_train::rapid_adapt::{AdaptationLoss, RapidAdaptation};
fn golden_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden")
}
fn fixture() -> Value {
let raw = fs::read_to_string(golden_dir().join("meridian_input.json"))
.expect("read meridian_input.json fixture");
serde_json::from_str(&raw).expect("parse meridian_input.json")
}
fn f64_vec(v: &Value, key: &str) -> Vec<f64> {
v[key]
.as_array()
.unwrap()
.iter()
.map(|x| x.as_f64().unwrap())
.collect()
}
fn f32_frames(v: &Value, key: &str) -> Vec<Vec<f32>> {
v[key]
.as_array()
.unwrap()
.iter()
.map(|row| {
row.as_array()
.unwrap()
.iter()
.map(|x| x.as_f64().unwrap() as f32)
.collect()
})
.collect()
}
/// Compute the full concatenated MERIDIAN output vector, mirroring the
/// Python binding's default construction exactly.
fn meridian_output(fx: &Value) -> Vec<f32> {
let mut out: Vec<f32> = Vec::new();
// 14: hardware normalization (default normalizer, canonical 56).
let norm = HardwareNormalizer::new();
let esp = norm
.normalize(
&f64_vec(fx, "esp32_amplitude"),
&f64_vec(fx, "esp32_phase"),
HardwareType::Esp32S3,
)
.unwrap();
out.extend_from_slice(&esp.amplitude);
out.extend_from_slice(&esp.phase);
let intel = norm
.normalize(
&f64_vec(fx, "intel_amplitude"),
&f64_vec(fx, "intel_phase"),
HardwareType::Intel5300,
)
.unwrap();
out.extend_from_slice(&intel.amplitude);
out.extend_from_slice(&intel.phase);
// 5: geometry encoding (default config → 64-dim).
let enc = GeometryEncoder::new(&MeridianGeometryConfig::default());
let aps: Vec<[f32; 3]> = fx["ap_positions"]
.as_array()
.unwrap()
.iter()
.map(|p| {
let a = p.as_array().unwrap();
[
a[0].as_f64().unwrap() as f32,
a[1].as_f64().unwrap() as f32,
a[2].as_f64().unwrap() as f32,
]
})
.collect();
out.extend_from_slice(&enc.encode(&aps));
// 6: rapid adaptation lora weights (Combined, epochs 5, lr 1e-3, λ 0.5).
let mut ra = RapidAdaptation::new(
10,
4,
AdaptationLoss::Combined {
epochs: 5,
lr: 0.001,
lambda_ent: 0.5,
},
);
for frame in f32_frames(fx, "rapid_frames") {
ra.push_frame(&frame);
}
out.extend_from_slice(&ra.adapt().unwrap().lora_weights);
out
}
fn sha256_le(vals: &[f32]) -> String {
let mut hasher = Sha256::new();
for &x in vals {
hasher.update(x.to_le_bytes());
}
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[test]
fn native_canonical_frames_are_56_wide() {
let fx = fixture();
let norm = HardwareNormalizer::new();
let esp = norm
.normalize(
&f64_vec(&fx, "esp32_amplitude"),
&f64_vec(&fx, "esp32_phase"),
HardwareType::Esp32S3,
)
.unwrap();
assert_eq!(esp.amplitude.len(), 56);
assert_eq!(esp.phase.len(), 56);
let intel = norm
.normalize(
&f64_vec(&fx, "intel_amplitude"),
&f64_vec(&fx, "intel_phase"),
HardwareType::Intel5300,
)
.unwrap();
assert_eq!(intel.amplitude.len(), 56);
// 64-dim geometry vector.
let enc = GeometryEncoder::new(&MeridianGeometryConfig::default());
assert_eq!(enc.encode(&[[0.25, 0.5, 0.75]]).len(), 64);
}
#[test]
fn native_meridian_matches_committed_golden() {
let got = sha256_le(&meridian_output(&fixture()));
let path = golden_dir().join("meridian_output.sha256");
match fs::read_to_string(&path) {
Ok(expected) => assert_eq!(
got,
expected.trim(),
"native MERIDIAN hash drifted from committed golden \
(intentional? delete the .sha256 and regenerate)"
),
Err(_) => {
fs::write(&path, &got).expect("write golden sha256");
panic!("no committed golden found; wrote {got}. Re-run to verify parity.");
}
}
}
+278
View File
@@ -0,0 +1,278 @@
"""ADR-185 P1 — AETHER binding tests, incl. the §4.1 bit-for-bit parity gate.
The parity test compares the binding's embedding to a committed golden VECTOR
produced by the native-Rust reference (`tests/aether_parity.rs`), within a
numerical tolerance. It is NOT a byte-hash: the embedding is f32 with
transcendental ops, so exact bytes are not reproducible across the CPU
architectures this project ships wheels for. A mismatch beyond tolerance is a
release blocker, not a warning.
"""
from __future__ import annotations
import json
import math
import struct
import tempfile
from pathlib import Path
import pytest
from wifi_densepose import aether
GOLDEN = Path(__file__).parent / "golden"
# Cross-architecture f32 parity tolerance. The AETHER embedding is pure f32 with
# transcendental ops that differ in the last bits across CPUs/libm, so exact
# byte equality is not portable across the wheels this project builds. 1e-4 is
# ~100x the observed cross-arch drift on unit-normed values and ~100x smaller
# than any real algorithm change. Combined atol+rtol so both small and larger
# components are bounded. (ADR-185 §4.1.)
PARITY_ATOL = 1e-4
PARITY_RTOL = 1e-4
def load_input() -> list[list[float]]:
return json.loads((GOLDEN / "aether_input.json").read_text())
def assert_embedding_matches_golden(embedding: list[float], golden_name: str) -> None:
"""Assert `embedding` matches the committed golden vector.
Two independent checks, because a per-element tolerance alone is not enough:
- **Per element**, within atol+rtol catches a single component drifting.
- **Whole-vector cosine** 1 - 1e-6 catches a *coherent* shift that stays
inside the per-element bound on every component yet moves the vector as a
whole (the failure mode a loose element tolerance would hide).
NaN/inf are rejected explicitly: `abs(nan - b) > tol` is False, so a bare
tolerance check would silently PASS an all-NaN embedding. Every value must be
finite first.
"""
golden = json.loads((GOLDEN / golden_name).read_text())
assert len(embedding) == len(golden), (
f"{golden_name}: length {len(embedding)} != golden {len(golden)}"
)
for i, x in enumerate(embedding):
assert math.isfinite(x), f"{golden_name}: element {i} is not finite ({x})"
for i, (a, b) in enumerate(zip(embedding, golden)):
tol = PARITY_ATOL + PARITY_RTOL * abs(b)
assert abs(a - b) <= tol, (
f"{golden_name}: element {i} diverged from native golden beyond "
f"tolerance (got {a}, golden {b}, |Δ|={abs(a - b):.3e}) — "
"a real regression, not cross-arch f32 drift."
)
dot = sum(a * b for a, b in zip(embedding, golden))
na = math.sqrt(sum(a * a for a in embedding))
nb = math.sqrt(sum(b * b for b in golden))
cosine = dot / (na * nb) if na > 0 and nb > 0 else 0.0
assert cosine >= 1.0 - 1e-6, (
f"{golden_name}: whole-vector cosine similarity to the golden is "
f"{cosine:.9f} (< 1 - 1e-6) — a coherent shift the per-element "
"tolerance did not catch."
)
def build_extractor() -> aether.EmbeddingExtractor:
# Must match the native-Rust reference construction exactly.
cfg = aether.AetherConfig(d_model=64, d_proj=128, temperature=0.07, normalize=True)
return aether.EmbeddingExtractor(n_subcarriers=56, config=cfg)
def _formula_weights(n: int) -> list[float]:
# Byte-identical to aether_weights_parity.rs (k/65536 is exact in f32+f64).
return [((i * 1103515245 + 12345) % 65536) / 65536.0 - 0.5 for i in range(n)]
def _write_weight_file(path: Path, weights: list[float]) -> None:
# AETHER weight format: b"AETHERW1" + u32 count + LE f32 payload.
with open(path, "wb") as f:
f.write(b"AETHERW1")
f.write(struct.pack("<I", len(weights)))
f.write(b"".join(struct.pack("<f", w) for w in weights))
def test_config_roundtrips_fields() -> None:
cfg = aether.AetherConfig(d_model=64, d_proj=128, temperature=0.07, normalize=True)
assert cfg.d_model == 64
assert cfg.d_proj == 128
assert abs(cfg.temperature - 0.07) < 1e-6
assert cfg.normalize is True
# ─── Constructor input validation (raise ValueError, never panic) ─────────
# Bad dimensions used to reach Rust and either panic (surfacing as an opaque
# PanicException) or allocate multi-gigabyte matrices that abort the interpreter.
@pytest.mark.parametrize("kwargs", [
{"d_model": 0, "d_proj": 128},
{"d_model": 64, "d_proj": 0},
{"d_model": 100_000, "d_proj": 128}, # unbounded allocation guard
{"d_model": 64, "d_proj": 100_000},
])
def test_aether_config_rejects_bad_dims(kwargs: dict) -> None:
with pytest.raises(ValueError):
aether.AetherConfig(**kwargs)
def test_extractor_rejects_zero_heads_instead_of_panicking() -> None:
# THE crash codex flagged: n_heads=0 -> `d_model % n_heads` -> panic.
cfg = aether.AetherConfig(d_model=64, d_proj=128)
with pytest.raises(ValueError):
aether.EmbeddingExtractor(n_subcarriers=56, config=cfg, n_heads=0)
def test_extractor_rejects_indivisible_head_count() -> None:
# 64 % 5 != 0 trips the native assert; must be a clean ValueError.
cfg = aether.AetherConfig(d_model=64, d_proj=128)
with pytest.raises(ValueError):
aether.EmbeddingExtractor(n_subcarriers=56, config=cfg, n_heads=5)
@pytest.mark.parametrize("kwargs", [
{"n_subcarriers": 0},
{"n_subcarriers": 100_000},
{"n_keypoints": 0},
])
def test_extractor_rejects_bad_shape(kwargs: dict) -> None:
cfg = aether.AetherConfig(d_model=64, d_proj=128)
base = {"n_subcarriers": 56, "config": cfg}
base.update(kwargs)
with pytest.raises(ValueError):
aether.EmbeddingExtractor(**base)
def test_valid_extractor_still_constructs() -> None:
# The negatives above must not pass by making the constructor reject
# everything: a valid config still builds and embeds.
cfg = aether.AetherConfig(d_model=64, d_proj=128)
ext = aether.EmbeddingExtractor(n_subcarriers=56, config=cfg, n_heads=4)
assert len(ext.embed(load_input())) == 128
def test_embedding_shape_and_unit_norm() -> None:
emb = build_extractor().embed(load_input())
assert len(emb) == 128
norm = math.sqrt(sum(x * x for x in emb))
assert abs(norm - 1.0) < 1e-4, f"expected unit-norm embedding, got {norm}"
def test_binding_matches_native_golden_within_tolerance() -> None:
"""The release-blocking §4.1 gate: binding output == native Rust reference.
Compares to a committed golden VECTOR within a numerical tolerance, not a
SHA-256 of the raw f32 bytes. The embedding is pure f32 and uses
transcendental ops (ln/sqrt/cos in the Gaussian init), which are NOT
bit-reproducible across CPU architectures or libm implementations. A
byte-hash therefore only ever matched the one arch that generated it, and
failed on every other wheel this project builds (aarch64, macOS-arm). The
tolerance below (1e-4) is orders of magnitude larger than cross-arch f32
drift yet far tighter than any real algorithm change, which moves
unit-normed elements by ~1e-2 or more. See ADR-185 §4.1.
"""
emb = build_extractor().embed(load_input())
assert_embedding_matches_golden(emb, "aether_embedding.json")
def test_embedding_is_deterministic() -> None:
ext = build_extractor()
inp = load_input()
assert ext.embed(inp) == ext.embed(inp)
def test_cosine_similarity_self_is_one() -> None:
v = [0.1 * i - 0.5 for i in range(32)]
assert abs(aether.cosine_similarity(v, v) - 1.0) < 1e-5
def test_cosine_similarity_orthogonal_is_zero() -> None:
a = [1.0, 0.0, 0.0, 0.0]
b = [0.0, 1.0, 0.0, 0.0]
assert abs(aether.cosine_similarity(a, b)) < 1e-6
def test_info_nce_loss_identical_batch_is_log_n() -> None:
# Identical embeddings → all similarities equal → loss == ln(N).
emb = [[1.0, 0.0, 0.0]] * 4
loss = aether.info_nce_loss(emb, emb, 0.07)
assert abs(loss - math.log(4)) < 0.1
def test_augment_pair_preserves_shape_and_differs() -> None:
window = load_input()
view_a, view_b = aether.CsiAugmenter().augment_pair(window, seed=42)
assert len(view_a) == len(window)
assert len(view_b) == len(window)
assert len(view_a[0]) == len(window[0])
differs = any(
abs(x - y) > 1e-6
for ra, rb in zip(view_a, view_b)
for x, y in zip(ra, rb)
)
assert differs, "augment_pair should return two distinct views"
def test_missing_feature_message_names_the_real_fix() -> None:
# The guard fires only on a from-source build without the feature. Its
# message must name the real fix — rebuild with the feature — and must NOT
# tell users to `pip install [aether]`, which is an empty extra that cannot
# add compiled code to a built wheel.
src = (Path(aether.__file__)).read_text()
assert "--features aether" in src
assert "pip install wifi-densepose[aether]" not in src
# ─── Weight loading (ADR-185 §13.a) ──────────────────────────────────
def test_load_weights_is_used_and_matches_native_golden() -> None:
ext = build_extractor()
baseline = ext.embed(load_input()) # random Xavier init
weights = _formula_weights(ext.param_count)
with tempfile.TemporaryDirectory() as d:
wpath = Path(d) / "weights.bin"
_write_weight_file(wpath, weights)
ext.load_weights(str(wpath))
loaded = ext.embed(load_input())
# (1) The loaded weights actually take effect (not a silent no-op).
assert any(abs(a - b) > 1e-6 for a, b in zip(baseline, loaded)), (
"load_weights had no effect — embedding still equals the random-init baseline"
)
# (2) Matches the native-Rust reference that loaded the same weights, within
# tolerance. See test_binding_matches_native_golden_within_tolerance for
# why this is a tolerance compare and not a byte-hash.
assert_embedding_matches_golden(loaded, "aether_loaded_embedding.json")
def test_save_then_load_weights_round_trips() -> None:
ext = build_extractor()
inp = load_input()
with tempfile.TemporaryDirectory() as d:
wpath = Path(d) / "roundtrip.bin"
ext.save_weights(str(wpath)) # serialize current (random) weights
emb_before = ext.embed(inp)
ext2 = build_extractor()
ext2.load_weights(str(wpath)) # load them into a fresh extractor
assert ext2.embed(inp) == emb_before
def test_load_weights_rejects_bad_magic() -> None:
ext = build_extractor()
with tempfile.TemporaryDirectory() as d:
wpath = Path(d) / "bad.bin"
wpath.write_bytes(b"NOTAETHER" + b"\x00" * 8)
with pytest.raises(ValueError):
ext.load_weights(str(wpath))
def test_load_weights_rejects_wrong_param_count() -> None:
ext = build_extractor()
with tempfile.TemporaryDirectory() as d:
wpath = Path(d) / "short.bin"
_write_weight_file(wpath, [0.1, 0.2, 0.3]) # far too few params
with pytest.raises(ValueError):
ext.load_weights(str(wpath))
+176
View File
@@ -65,7 +65,29 @@ _FIXTURE_MESSAGES = [
]
#: Upgrade-request headers captured by the in-process server, so tests
#: can assert what the client actually put on the handshake.
_CAPTURED_UPGRADE_HEADERS: dict[str, str] = {}
def _upgrade_headers(websocket: Any) -> Any:
"""Read the handshake request headers across websockets versions
(`websocket.request.headers` >= 14, `websocket.request_headers` <= 13)."""
req = getattr(websocket, "request", None)
if req is not None and getattr(req, "headers", None) is not None:
return req.headers
return getattr(websocket, "request_headers", {})
async def _handler(websocket: Any) -> None:
_CAPTURED_UPGRADE_HEADERS.clear()
try:
headers = _upgrade_headers(websocket)
auth = headers.get("Authorization") if hasattr(headers, "get") else None
if auth is not None:
_CAPTURED_UPGRADE_HEADERS["Authorization"] = auth
except Exception:
pass
for msg in _FIXTURE_MESSAGES:
await websocket.send(json.dumps(msg))
# Send one malformed frame to assert the client logs+drops it
@@ -174,6 +196,160 @@ def test_sensing_client_decoder_directly() -> None:
assert msg.rssi is None
# ─── Auth: bearer token on the WS upgrade (issue #1395) ──────────────
def _auth_header_from_kwargs(kwargs: dict) -> Any:
"""Pull the Authorization value out of whichever header kwarg the
installed `websockets` uses (`additional_headers` >= 14,
`extra_headers` <= 13). Returns None if no header kwarg was passed."""
for key in ("additional_headers", "extra_headers"):
if key in kwargs:
return dict(kwargs[key]).get("Authorization")
return None
class _DummyWS:
async def close(self) -> None:
pass
class _CapturingConnect:
"""Stand-in for `websockets.connect` that records the kwargs it was
called with and returns an awaitable yielding a dummy connection."""
def __init__(self) -> None:
self.calls: list[tuple[str, dict]] = []
def __call__(self, url: str, **kwargs: Any) -> Any:
self.calls.append((url, kwargs))
async def _coro() -> _DummyWS:
return _DummyWS()
return _coro()
@property
def last_kwargs(self) -> dict:
return self.calls[-1][1]
async def test_token_from_constructor_sets_auth_header(monkeypatch: Any) -> None:
from wifi_densepose.client import ws as ws_mod
fake = _CapturingConnect()
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
async with SensingClient("ws://x/ws/sensing", token="tok-abc"):
pass
assert _auth_header_from_kwargs(fake.last_kwargs) == "Bearer tok-abc"
async def test_token_from_env_sets_auth_header(monkeypatch: Any) -> None:
from wifi_densepose.client import ws as ws_mod
fake = _CapturingConnect()
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
monkeypatch.setenv("RUVIEW_API_TOKEN", "env-tok-123")
async with SensingClient("ws://x/ws/sensing"):
pass
assert _auth_header_from_kwargs(fake.last_kwargs) == "Bearer env-tok-123"
async def test_constructor_token_overrides_env(monkeypatch: Any) -> None:
from wifi_densepose.client import ws as ws_mod
fake = _CapturingConnect()
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
monkeypatch.setenv("RUVIEW_API_TOKEN", "env-tok")
async with SensingClient("ws://x/ws/sensing", token="ctor-tok"):
pass
assert _auth_header_from_kwargs(fake.last_kwargs) == "Bearer ctor-tok"
async def test_no_token_sends_no_auth_header(monkeypatch: Any) -> None:
from wifi_densepose.client import ws as ws_mod
fake = _CapturingConnect()
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
monkeypatch.delenv("RUVIEW_API_TOKEN", raising=False)
async with SensingClient("ws://x/ws/sensing"):
pass
assert _auth_header_from_kwargs(fake.last_kwargs) is None
# Auth-disabled path must not smuggle either header kwarg in.
assert "additional_headers" not in fake.last_kwargs
assert "extra_headers" not in fake.last_kwargs
async def test_empty_token_sends_no_auth_header(monkeypatch: Any) -> None:
"""An explicitly empty token (or empty env var) means 'no auth'."""
from wifi_densepose.client import ws as ws_mod
fake = _CapturingConnect()
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
monkeypatch.setenv("RUVIEW_API_TOKEN", "")
async with SensingClient("ws://x/ws/sensing"):
pass
assert _auth_header_from_kwargs(fake.last_kwargs) is None
@pytest.mark.parametrize(
"header_param,expected",
[
("additional_headers", "additional_headers"), # websockets >= 14
("extra_headers", "extra_headers"), # websockets <= 13
],
)
def test_select_header_kwarg_across_websockets_versions(
header_param: str, expected: str
) -> None:
"""Version-compat: the kwarg is chosen by inspecting the installed
`connect` signature, so both the pre-14 (`extra_headers`) and
post-14 (`additional_headers`) conventions resolve correctly without
two websockets installs."""
from wifi_densepose.client.ws import _select_header_kwarg
# Build a fake `connect` whose signature carries only the one kwarg
# the emulated websockets version would expose.
ns: dict = {}
exec(
f"def fake_connect(uri, *, {header_param}=None, ping_interval=None): ...",
ns,
)
assert _select_header_kwarg(ns["fake_connect"]) == expected
def test_select_header_kwarg_matches_installed_websockets() -> None:
"""On whatever `websockets` is actually installed, the chosen kwarg
must be a real parameter of `websockets.connect`."""
import inspect
import websockets
from wifi_densepose.client.ws import _select_header_kwarg
chosen = _select_header_kwarg(websockets.connect)
assert chosen in inspect.signature(websockets.connect).parameters
async def test_auth_header_reaches_server_end_to_end(ws_server: str) -> None:
"""Real in-process server: assert the bearer actually arrives on the
upgrade request (proves the header is wired to the live handshake,
not just the connect kwargs)."""
async with SensingClient(ws_server, token="e2e-token") as client:
await client.recv_one(timeout=2.0)
assert _CAPTURED_UPGRADE_HEADERS.get("Authorization") == "Bearer e2e-token"
def test_sensing_client_decoder_handles_None_subfields() -> None:
"""When the sensing-server explicitly emits null for HR/BR (no
measurement yet), the client should propagate None, not crash."""
+109
View File
@@ -0,0 +1,109 @@
"""ADR-185 P3 — MAT binding tests, incl. the §4.1 bit-for-bit parity gate.
The parity test drives the same committed CSI stream through the binding's
DisasterResponse pipeline and asserts the survivor count + triage classes
(as a SHA-256 of a canonical string) match the native-Rust golden. A
mismatch is a release blocker.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
from wifi_densepose import mat
GOLDEN = Path(__file__).parent / "golden"
def fixture() -> dict:
return json.loads((GOLDEN / "mat_input.json").read_text())
def build_response() -> mat.DisasterResponse:
cfg = mat.DisasterConfig(
mat.DisasterType.Earthquake,
sensitivity=0.9,
confidence_threshold=0.1,
max_depth=5.0,
)
resp = mat.DisasterResponse(cfg)
resp.initialize_event(0.0, 0.0, "parity-fixture")
resp.add_zone(mat.ScanZone.rectangle("Zone A", 0.0, 0.0, 50.0, 30.0))
return resp
def run_scan(resp: mat.DisasterResponse) -> None:
for frame in fixture()["stream"]:
resp.push_csi_data(frame["amplitude"], frame["phase"])
resp.scan_once()
# ─── enums / config ──────────────────────────────────────────────────
def test_triage_priority_order() -> None:
assert mat.TriageStatus.Immediate.priority == 1
assert mat.TriageStatus.Delayed.priority == 2
assert mat.TriageStatus.Unknown.priority == 5
def test_disaster_config_fields() -> None:
cfg = mat.DisasterConfig(mat.DisasterType.Flood, sensitivity=1.5, confidence_threshold=0.3)
assert cfg.sensitivity == 1.0 # clamped to [0, 1]
assert abs(cfg.confidence_threshold - 0.3) < 1e-9
# ─── pipeline behaviour ──────────────────────────────────────────────
def test_scan_requires_event() -> None:
resp = mat.DisasterResponse(mat.DisasterConfig(mat.DisasterType.Unknown))
# No initialize_event / add_zone -> scan_cycle errors "No active event".
with pytest.raises(ValueError):
resp.scan_once()
def test_push_csi_rejects_mismatched_lengths() -> None:
resp = build_response()
with pytest.raises(ValueError):
resp.push_csi_data([1.0, 2.0], [1.0])
def test_scan_detects_survivor_from_breathing_stream() -> None:
resp = build_response()
run_scan(resp)
survivors = resp.survivors()
# The synthetic breathing-modulated stream trips one detection (matches
# the native-Rust reference).
assert len(survivors) == 1
s = survivors[0]
assert isinstance(s.id, str) and len(s.id) > 0
assert s.triage_status == mat.TriageStatus.Delayed
assert 0.0 <= s.confidence <= 1.0
# survivors_by_triage is consistent with the survivor's own class.
assert len(resp.survivors_by_triage(mat.TriageStatus.Delayed)) == 1
assert len(resp.survivors_by_triage(mat.TriageStatus.Immediate)) == 0
# ─── §4.1 bit-for-bit parity gate (release-blocking) ─────────────────
def test_bit_for_bit_parity_with_native_rust() -> None:
resp = build_response()
run_scan(resp)
survivors = resp.survivors()
priorities = sorted(s.triage_status.priority for s in survivors)
canon = f"count={len(survivors)};triage_priorities={priorities}"
got = hashlib.sha256(canon.encode()).hexdigest()
expected = (GOLDEN / "mat_result.sha256").read_text().strip()
assert got == expected, (
f"Python MAT result diverged from native-Rust golden "
f"(canonical form: {canon}; {got} != {expected})"
)
def test_base_wheel_import_error_message() -> None:
src = Path(mat.__file__).read_text()
assert "--features mat" in src
assert "pip install wifi-densepose[mat]" not in src
+161
View File
@@ -0,0 +1,161 @@
"""ADR-185 P2 — MERIDIAN binding tests, incl. the §4.1 bit-for-bit parity gate.
The parity test packs the binding's concatenated outputs (2× canonical
frame, geometry vector, rapid-adapt LoRA weights) to little-endian f32
bytes and asserts SHA-256 equality with the golden produced by the
native-Rust reference (`tests/meridian_parity.rs`). A mismatch is a
release blocker.
"""
from __future__ import annotations
import hashlib
import json
import struct
from pathlib import Path
import pytest
from wifi_densepose import meridian as mer
GOLDEN = Path(__file__).parent / "golden"
def fixture() -> dict:
return json.loads((GOLDEN / "meridian_input.json").read_text())
# ─── HardwareType / HardwareNormalizer / CanonicalCsiFrame ───────────
def test_hardware_type_detect() -> None:
assert mer.HardwareType.detect(64) == mer.HardwareType.Esp32S3
assert mer.HardwareType.detect(30) == mer.HardwareType.Intel5300
assert mer.HardwareType.detect(56) == mer.HardwareType.Atheros
assert mer.HardwareType.detect(128) == mer.HardwareType.Generic
def test_hardware_type_properties() -> None:
assert mer.HardwareType.Esp32S3.subcarrier_count == 64
assert mer.HardwareType.Esp32S3.mimo_streams == 1
assert mer.HardwareType.Intel5300.mimo_streams == 3
def test_normalize_shapes_and_hardware() -> None:
fx = fixture()
norm = mer.HardwareNormalizer()
assert norm.canonical_subcarriers == 56
frame = norm.normalize(fx["esp32_amplitude"], fx["esp32_phase"], mer.HardwareType.Esp32S3)
assert len(frame.amplitude) == 56
assert len(frame.phase) == 56
assert frame.hardware_type == mer.HardwareType.Esp32S3
def test_normalize_rejects_mismatched_lengths() -> None:
norm = mer.HardwareNormalizer()
with pytest.raises(ValueError):
norm.normalize([1.0, 2.0], [1.0], mer.HardwareType.Generic)
# ─── GeometryEncoder ─────────────────────────────────────────────────
def test_geometry_encode_dim_and_permutation_invariance() -> None:
enc = mer.GeometryEncoder(mer.MeridianGeometryConfig())
aps = [[0.25, 0.5, 0.75], [1.0, 1.25, 1.5], [2.0, 0.0, -0.5]]
v = enc.encode(aps)
assert len(v) == 64
# DeepSets mean-pool is permutation-invariant.
v_perm = enc.encode([aps[2], aps[0], aps[1]])
assert max(abs(a - b) for a, b in zip(v, v_perm)) < 1e-5
def test_geometry_encode_rejects_empty_and_bad_shape() -> None:
enc = mer.GeometryEncoder()
with pytest.raises(ValueError):
enc.encode([])
with pytest.raises(ValueError):
enc.encode([[0.0, 1.0]]) # not 3 coords
# ─── RapidAdaptation ─────────────────────────────────────────────────
def test_rapid_adaptation_adapt() -> None:
fx = fixture()
ra = mer.RapidAdaptation(
min_calibration_frames=10, lora_rank=4, loss_kind="combined",
epochs=5, lr=0.001, lambda_ent=0.5,
)
for frame in fx["rapid_frames"]:
ra.push_frame(frame)
assert ra.is_ready()
assert ra.buffer_len == 12
res = ra.adapt()
assert res.frames_used == 12
assert res.adaptation_epochs == 5
assert len(res.lora_weights) == 2 * 16 * 4 # 2 * fdim * rank
def test_rapid_adaptation_rejects_bad_loss_kind() -> None:
with pytest.raises(ValueError):
mer.RapidAdaptation(10, 4, loss_kind="nonsense")
def test_rapid_adaptation_empty_buffer_raises() -> None:
ra = mer.RapidAdaptation(1, 4)
with pytest.raises(ValueError):
ra.adapt()
# ─── CrossDomainEvaluator ────────────────────────────────────────────
def test_cross_domain_evaluator_gap_ratio() -> None:
ev = mer.CrossDomainEvaluator(1)
preds = [
([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), # domain 0, err 1
([0.0, 0.0, 0.0], [2.0, 0.0, 0.0]), # domain 1, err 2
]
m = ev.evaluate(preds, [0, 1])
assert abs(m["in_domain_mpjpe"] - 1.0) < 1e-6
assert abs(m["cross_domain_mpjpe"] - 2.0) < 1e-6
assert abs(m["domain_gap_ratio"] - 2.0) < 1e-6
def test_mpjpe_module_fn() -> None:
assert abs(mer.mpjpe([0.0, 0.0, 0.0], [3.0, 4.0, 0.0], 1) - 5.0) < 1e-6
# ─── §4.1 bit-for-bit parity gate (release-blocking) ─────────────────
def test_bit_for_bit_parity_with_native_rust() -> None:
fx = fixture()
out: list[float] = []
norm = mer.HardwareNormalizer()
esp = norm.normalize(fx["esp32_amplitude"], fx["esp32_phase"], mer.HardwareType.Esp32S3)
out += list(esp.amplitude) + list(esp.phase)
intel = norm.normalize(fx["intel_amplitude"], fx["intel_phase"], mer.HardwareType.Intel5300)
out += list(intel.amplitude) + list(intel.phase)
enc = mer.GeometryEncoder(mer.MeridianGeometryConfig())
out += list(enc.encode(fx["ap_positions"]))
ra = mer.RapidAdaptation(
min_calibration_frames=10, lora_rank=4, loss_kind="combined",
epochs=5, lr=0.001, lambda_ent=0.5,
)
for frame in fx["rapid_frames"]:
ra.push_frame(frame)
out += list(ra.adapt().lora_weights)
packed = b"".join(struct.pack("<f", x) for x in out)
got = hashlib.sha256(packed).hexdigest()
expected = (GOLDEN / "meridian_output.sha256").read_text().strip()
assert got == expected, (
f"Python binding MERIDIAN output diverged from native-Rust golden "
f"({got} != {expected})"
)
def test_base_wheel_import_error_message() -> None:
src = Path(mer.__file__).read_text()
assert "--features meridian" in src
assert "pip install wifi-densepose[meridian]" not in src
+35 -1
View File
@@ -185,10 +185,44 @@ def test_heart_rate_explicit_ctor() -> None:
def test_heart_rate_extract_returns_none_with_too_few_samples() -> None:
hr = HeartRateExtractor.esp32_default()
out = hr.extract(residuals=[0.0] * 56, weights=[])
out = hr.extract(residuals=[0.0] * 56, phases=[0.0] * 56)
assert out is None
def test_heart_rate_extract_rejects_old_weights_keyword() -> None:
hr = HeartRateExtractor.esp32_default()
with pytest.raises(TypeError):
hr.extract(residuals=[0.0] * 56, weights=[0.0] * 56)
def test_heart_rate_extract_with_synthetic_signal_and_phases() -> None:
"""Drive the extractor with a synthetic 1.2 Hz sine (72 BPM) plus
same-length phase data. This proves the binding feeds Rust's required
`phases` slice instead of an empty vector that would keep returning None."""
hr = HeartRateExtractor.esp32_default()
sample_rate = 100.0
target_freq = 1.2 # 72 BPM
n_samples = int(60 * sample_rate)
phases = [i * 0.01 for i in range(56)]
produced_estimate = False
rng = Random(43)
for i in range(n_samples):
t = i / sample_rate
base = math.sin(2.0 * math.pi * target_freq * t)
residuals = [base + rng.gauss(0.0, 0.01) for _ in range(56)]
est = hr.extract(residuals=residuals, phases=phases)
if est is not None:
produced_estimate = True
assert math.isfinite(est.value_bpm)
assert 0.0 <= est.confidence <= 1.0
break
assert produced_estimate, (
"HeartRateExtractor never produced an estimate after 60s of synthetic data"
)
# ─── Build feature flag ──────────────────────────────────────────────
+1 -1
View File
@@ -28,7 +28,7 @@ from __future__ import annotations
# Public Python version follows the wheel version, NOT the Rust core
# version. The Rust core version is surfaced separately as
# `__rust_version__` for diagnostics.
__version__ = "2.0.0a1"
__version__ = "2.0.0"
# Re-export the compiled module's surface. The leading underscore on
# `_native` is intentional — it marks the binding module as internal.
+49
View File
@@ -0,0 +1,49 @@
"""AETHER — contrastive CSI embeddings & re-identification (ADR-024, ADR-185 P1).
Self-supervised 128-dim L2-normalized embeddings for WiFi CSI: room
fingerprinting, person re-identification, and anomaly scoring, computed
entirely offline by the Rust core (no server, no network).
Not in the binary wheels yet (see ruvnet/RuView#1412 — the P6 SOTA
bindings are shipped source-build-only for now to keep the base wheel
small). Build from source with ``maturin ... --features aether`` (or
``--features sota`` for all three P6 subsystems).
Quick start::
from wifi_densepose.aether import AetherConfig, EmbeddingExtractor, cosine_similarity
ext = EmbeddingExtractor(n_subcarriers=56, config=AetherConfig())
a = ext.embed(window_a) # list[float], length == config.d_proj (128)
b = ext.embed(window_b)
score = cosine_similarity(a, b) # re-ID similarity in [-1, 1]
"""
from __future__ import annotations
from wifi_densepose import _native
# The AETHER symbols are compiled into `_native` only under the Rust `aether`
# feature. The binary wheels do NOT enable it yet (ruvnet/RuView#1412);
# it is available from a source build with the feature. Name that fix, not
# a pip extra, which cannot add compiled code to a built wheel.
if not hasattr(_native, "AetherConfig"):
raise ImportError(
"wifi_densepose.aether is not in the binary wheels yet "
"(see ruvnet/RuView#1412). Build from source with "
"`maturin ... --features aether` (or `--features sota`)."
)
AetherConfig = _native.AetherConfig
CsiAugmenter = _native.CsiAugmenter
EmbeddingExtractor = _native.EmbeddingExtractor
info_nce_loss = _native.info_nce_loss
cosine_similarity = _native.cosine_similarity
__all__ = [
"AetherConfig",
"CsiAugmenter",
"EmbeddingExtractor",
"info_nce_loss",
"cosine_similarity",
]
+58
View File
@@ -0,0 +1,58 @@
"""Type stubs for the AETHER bindings (ADR-185 P1).
Present only when the wheel is built with the ``[aether]`` extra. The
top-level ``wifi_densepose`` package does not re-export these names, so
``mypy --strict`` sees them only via ``from wifi_densepose.aether import ...``.
"""
from __future__ import annotations
class AetherConfig:
def __init__(
self,
d_model: int = ...,
d_proj: int = ...,
temperature: float = ...,
normalize: bool = ...,
) -> None: ...
@property
def d_model(self) -> int: ...
@property
def d_proj(self) -> int: ...
@property
def temperature(self) -> float: ...
@property
def normalize(self) -> bool: ...
def __repr__(self) -> str: ...
class CsiAugmenter:
def __init__(self) -> None: ...
def augment_pair(
self, window: list[list[float]], seed: int
) -> tuple[list[list[float]], list[list[float]]]: ...
def __repr__(self) -> str: ...
class EmbeddingExtractor:
def __init__(
self,
n_subcarriers: int,
config: AetherConfig,
n_keypoints: int = ...,
n_heads: int = ...,
n_gnn_layers: int = ...,
) -> None: ...
def embed(self, csi_features: list[list[float]]) -> list[float]: ...
@property
def embedding_dim(self) -> int: ...
@property
def param_count(self) -> int: ...
def load_weights(self, path: str) -> None: ...
def save_weights(self, path: str) -> None: ...
def __repr__(self) -> str: ...
def info_nce_loss(
embeddings_a: list[list[float]],
embeddings_b: list[list[float]],
temperature: float = ...,
) -> float: ...
def cosine_similarity(a: list[float], b: list[float]) -> float: ...
+47 -2
View File
@@ -31,8 +31,10 @@ asyncio.run(main())
from __future__ import annotations
import asyncio
import inspect
import json
import logging
import os
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Optional
@@ -48,6 +50,33 @@ except ImportError: # pragma: no cover
log = logging.getLogger(__name__)
#: Environment variable the sensing-server bearer token is read from by
#: default. Mirrors the TypeScript MCP client (tools/ruview-mcp).
TOKEN_ENV_VAR = "RUVIEW_API_TOKEN"
def _select_header_kwarg(connect_fn: Any) -> str:
"""Return the ``websockets.connect`` keyword for extra request headers.
The keyword was renamed inside the ``websockets>=12`` range this
package supports: ``<= 13`` accepts ``extra_headers``, ``>= 14``
accepts ``additional_headers``. We inspect the actual signature of
the installed ``connect`` rather than guessing from ``__version__``,
so a version bump that renames the kwarg again is handled by
detection instead of raising ``TypeError`` at connect time.
"""
try:
params = inspect.signature(connect_fn).parameters
except (TypeError, ValueError): # pragma: no cover — no introspectable sig
return "additional_headers"
if "additional_headers" in params:
return "additional_headers"
if "extra_headers" in params:
return "extra_headers"
# Neither present (unexpected) — prefer the newer convention.
return "additional_headers"
# ─── Typed messages ──────────────────────────────────────────────────
@@ -172,12 +201,18 @@ class SensingClient:
the ``async with`` in your own retry loop. Auto-reconnect logic is
application-specific (e.g., "retry forever" for a long-running
automation vs "fail fast" for a CLI tool that should exit).
Auth: pass ``token=`` to send ``Authorization: Bearer <token>`` on
the WS upgrade, for sensing-servers started with ``RUVIEW_API_TOKEN``
set. If ``token`` is omitted it defaults to the ``RUVIEW_API_TOKEN``
environment variable; when neither is set, no header is sent.
"""
def __init__(
self,
url: str,
*,
token: Optional[str] = None,
ping_interval: float = 20.0,
ping_timeout: float = 20.0,
max_size: int = 16 * 1024 * 1024,
@@ -188,18 +223,28 @@ class SensingClient:
"`pip install \"wifi-densepose[client]\"` to enable the client extras."
)
self.url = url
# Bearer token for auth-enabled sensing-servers. Explicit
# constructor argument wins; otherwise fall back to the
# RUVIEW_API_TOKEN environment variable. An empty value (unset
# env, or "") means "no auth" — no Authorization header is sent.
self._token = token if token is not None else os.environ.get(TOKEN_ENV_VAR)
self._ping_interval = ping_interval
self._ping_timeout = ping_timeout
self._max_size = max_size
self._ws: Any = None # websockets.WebSocketClientProtocol — typed Any to avoid import cost
async def __aenter__(self) -> "SensingClient":
self._ws = await websockets.connect(
self.url,
connect_kwargs: dict[str, Any] = dict(
ping_interval=self._ping_interval,
ping_timeout=self._ping_timeout,
max_size=self._max_size,
)
if self._token:
# Python (unlike the browser UI) can set Authorization
# directly on the WS upgrade — no ticket workaround needed.
header_kwarg = _select_header_kwarg(websockets.connect)
connect_kwargs[header_kwarg] = {"Authorization": f"Bearer {self._token}"}
self._ws = await websockets.connect(self.url, **connect_kwargs)
return self
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
+61
View File
@@ -0,0 +1,61 @@
"""MAT — Mass Casualty Assessment Tool (ADR-024 crate, ADR-185 P3).
WiFi-based disaster-survivor detection and START-protocol triage from CSI:
ingest CSI frames, run a scan cycle, and query detected survivors by triage.
Not in the binary wheels yet (see ruvnet/RuView#1412 — the P6 SOTA
bindings are shipped source-build-only for now to keep the base wheel
small). Build from source with ``maturin ... --features mat`` (or
``--features sota`` for all three P6 subsystems).
Quick start::
from wifi_densepose.mat import DisasterConfig, DisasterResponse, DisasterType, ScanZone
cfg = DisasterConfig(DisasterType.Earthquake, sensitivity=0.9, confidence_threshold=0.1)
resp = DisasterResponse(cfg)
resp.initialize_event(0.0, 0.0, "Building A") # required before scanning
resp.add_zone(ScanZone.rectangle("North Wing", 0.0, 0.0, 50.0, 30.0))
for amp, phase in csi_stream:
resp.push_csi_data(amp, phase)
resp.scan_once() # one detection cycle
for s in resp.survivors():
print(s.id, s.triage_status, s.confidence, s.location)
Honest scope (ADR-185 §3.4): the ADR's Rust-side `scan_once()` wrapper was
unnecessary this binding drives one cycle of the public async
`start_scanning()` (with `continuous_monitoring` forced off) on an internal
runtime. `initialize_event` + `add_zone` are required before `scan_once`.
`Survivor.latest_vitals` returns the latest reading (the Rust accessor is a
history). The detection pipeline is real but unvalidated on live rubble.
"""
from __future__ import annotations
from wifi_densepose import _native
# MAT symbols are compiled into `_native` only under the Rust `mat` feature.
if not hasattr(_native, "DisasterResponse"):
raise ImportError(
"wifi_densepose.mat is not in the binary wheels yet "
"(see ruvnet/RuView#1412). Build from source with "
"`maturin ... --features mat` (or `--features sota`)."
)
DisasterType = _native.DisasterType
TriageStatus = _native.TriageStatus
DisasterConfig = _native.DisasterConfig
DisasterResponse = _native.DisasterResponse
ScanZone = _native.ScanZone
Survivor = _native.Survivor
VitalSignsReading = _native.VitalSignsReading
__all__ = [
"DisasterType",
"TriageStatus",
"DisasterConfig",
"DisasterResponse",
"ScanZone",
"Survivor",
"VitalSignsReading",
]
+92
View File
@@ -0,0 +1,92 @@
"""Type stubs for the MAT bindings (ADR-185 P3).
Present only when the wheel is built with the ``[mat]`` extra.
"""
from __future__ import annotations
import enum
class DisasterType(enum.Enum):
BuildingCollapse = 0
Earthquake = 1
Landslide = 2
Avalanche = 3
Flood = 4
MineCollapse = 5
Industrial = 6
TunnelCollapse = 7
Unknown = 8
def __repr__(self) -> str: ...
class TriageStatus(enum.Enum):
Immediate = 0
Delayed = 1
Minor = 2
Deceased = 3
Unknown = 4
@property
def priority(self) -> int: ...
def __repr__(self) -> str: ...
class VitalSignsReading:
@property
def breathing_rate_bpm(self) -> float | None: ...
@property
def heartbeat_rate_bpm(self) -> float | None: ...
@property
def movement_intensity(self) -> float: ...
@property
def confidence(self) -> float: ...
def __repr__(self) -> str: ...
class Survivor:
@property
def id(self) -> str: ...
@property
def triage_status(self) -> TriageStatus: ...
@property
def confidence(self) -> float: ...
@property
def location(self) -> tuple[float, float, float] | None: ...
@property
def latest_vitals(self) -> VitalSignsReading | None: ...
def __repr__(self) -> str: ...
class DisasterConfig:
def __init__(
self,
disaster_type: DisasterType,
sensitivity: float = ...,
confidence_threshold: float = ...,
max_depth: float = ...,
scan_interval_ms: int = ...,
) -> None: ...
@property
def sensitivity(self) -> float: ...
@property
def confidence_threshold(self) -> float: ...
@property
def max_depth(self) -> float: ...
def __repr__(self) -> str: ...
class ScanZone:
@staticmethod
def rectangle(
name: str, min_x: float, min_y: float, max_x: float, max_y: float
) -> ScanZone: ...
@staticmethod
def circle(name: str, center_x: float, center_y: float, radius: float) -> ScanZone: ...
@property
def name(self) -> str: ...
def __repr__(self) -> str: ...
class DisasterResponse:
def __init__(self, config: DisasterConfig) -> None: ...
def initialize_event(self, x: float, y: float, description: str) -> None: ...
def add_zone(self, zone: ScanZone) -> None: ...
def push_csi_data(self, amplitudes: list[float], phases: list[float]) -> None: ...
def scan_once(self) -> None: ...
def survivors(self) -> list[Survivor]: ...
def survivors_by_triage(self, status: TriageStatus) -> list[Survivor]: ...
def __repr__(self) -> str: ...
+62
View File
@@ -0,0 +1,62 @@
"""MERIDIAN — cross-environment domain generalization (ADR-027, ADR-185 P2).
Hardware-invariant CSI normalization, geometry-conditioned deployment,
few-shot room adaptation, and cross-domain evaluation the tch-free
inference/adaptation path of Project MERIDIAN, computed by the Rust core.
Not in the binary wheels yet (see ruvnet/RuView#1412 — the P6 SOTA
bindings are shipped source-build-only for now to keep the base wheel
small). Build from source with ``maturin ... --features meridian`` (or
``--features sota`` for all three P6 subsystems).
Quick start::
from wifi_densepose.meridian import HardwareNormalizer, HardwareType
norm = HardwareNormalizer() # canonical 56 subcarriers
hw = HardwareType.detect(64) # -> HardwareType.Esp32S3
frame = norm.normalize(amplitude, phase, hw) # -> CanonicalCsiFrame
print(len(frame.amplitude), frame.hardware_type)
Note (honest scope, ADR-185 §3.3): the ADR's ``RapidAdaptation.calibrate``
/ ``AdaptationResult.converged`` do not exist in the Rust core use
``push_frame(...)`` then ``adapt()``; the result exposes ``final_loss``,
``frames_used``, ``adaptation_epochs``. Training-time types
(DomainFactorizer, GradientReversalLayer, VirtualDomainAugmentor) are
out of scope for P6 (they need the deferred libtorch training tier).
"""
from __future__ import annotations
from wifi_densepose import _native
# MERIDIAN symbols are compiled into `_native` only under the Rust
# `meridian` feature; absent in a base wheel (ADR-185 §6 acceptance).
if not hasattr(_native, "HardwareNormalizer"):
raise ImportError(
"wifi_densepose.meridian is not in the binary wheels yet "
"(see ruvnet/RuView#1412). Build from source with "
"`maturin ... --features meridian` (or `--features sota`)."
)
HardwareType = _native.HardwareType
CanonicalCsiFrame = _native.CanonicalCsiFrame
HardwareNormalizer = _native.HardwareNormalizer
MeridianGeometryConfig = _native.MeridianGeometryConfig
GeometryEncoder = _native.GeometryEncoder
RapidAdaptation = _native.RapidAdaptation
AdaptationResult = _native.AdaptationResult
CrossDomainEvaluator = _native.CrossDomainEvaluator
mpjpe = _native.mpjpe
__all__ = [
"HardwareType",
"CanonicalCsiFrame",
"HardwareNormalizer",
"MeridianGeometryConfig",
"GeometryEncoder",
"RapidAdaptation",
"AdaptationResult",
"CrossDomainEvaluator",
"mpjpe",
]
+105
View File
@@ -0,0 +1,105 @@
"""Type stubs for the MERIDIAN bindings (ADR-185 P2).
Present only when the wheel is built with the ``[meridian]`` extra.
"""
from __future__ import annotations
import enum
class HardwareType(enum.Enum):
Esp32S3 = 0
Intel5300 = 1
Atheros = 2
Generic = 3
@staticmethod
def detect(subcarrier_count: int) -> HardwareType: ...
@property
def subcarrier_count(self) -> int: ...
@property
def mimo_streams(self) -> int: ...
def __repr__(self) -> str: ...
class CanonicalCsiFrame:
@property
def amplitude(self) -> list[float]: ...
@property
def phase(self) -> list[float]: ...
@property
def hardware_type(self) -> HardwareType: ...
def __repr__(self) -> str: ...
class HardwareNormalizer:
def __init__(self, canonical_subcarriers: int = ...) -> None: ...
@staticmethod
def detect_hardware(subcarrier_count: int) -> HardwareType: ...
@property
def canonical_subcarriers(self) -> int: ...
def normalize(
self, amplitude: list[float], phase: list[float], hardware: HardwareType
) -> CanonicalCsiFrame: ...
def __repr__(self) -> str: ...
class MeridianGeometryConfig:
def __init__(
self,
n_frequencies: int = ...,
scale: float = ...,
geometry_dim: int = ...,
seed: int = ...,
) -> None: ...
@property
def n_frequencies(self) -> int: ...
@property
def scale(self) -> float: ...
@property
def geometry_dim(self) -> int: ...
@property
def seed(self) -> int: ...
def __repr__(self) -> str: ...
class GeometryEncoder:
def __init__(self, config: MeridianGeometryConfig | None = ...) -> None: ...
def encode(self, ap_positions: list[list[float]]) -> list[float]: ...
@property
def geometry_dim(self) -> int: ...
def __repr__(self) -> str: ...
class AdaptationResult:
@property
def lora_weights(self) -> list[float]: ...
@property
def final_loss(self) -> float: ...
@property
def frames_used(self) -> int: ...
@property
def adaptation_epochs(self) -> int: ...
def __repr__(self) -> str: ...
class RapidAdaptation:
def __init__(
self,
min_calibration_frames: int,
lora_rank: int,
loss_kind: str = ...,
epochs: int = ...,
lr: float = ...,
lambda_ent: float = ...,
) -> None: ...
def push_frame(self, frame: list[float]) -> None: ...
def is_ready(self) -> bool: ...
@property
def buffer_len(self) -> int: ...
def adapt(self) -> AdaptationResult: ...
def __repr__(self) -> str: ...
class CrossDomainEvaluator:
def __init__(self, n_joints: int) -> None: ...
def evaluate(
self,
predictions: list[tuple[list[float], list[float]]],
domain_labels: list[int],
) -> dict[str, float]: ...
def __repr__(self) -> str: ...
def mpjpe(pred: list[float], gt: list[float], n_joints: int) -> float: ...
+31
View File
@@ -273,6 +273,37 @@
],
"rationale": "ADR-117 §P5 — the project is registered with PyPI via API token, not OIDC Trusted Publisher. The token is sourced from GCP Secret Manager (see docs/integrations/pypi-release.md). Re-introducing the `id-token: write` permission would suggest a partial OIDC migration that won't actually work without registering the Trusted Publisher on pypi.org first — a silent regression that would 403 on the next publish.",
"ref": "https://github.com/ruvnet/RuView/pull/786"
},
{
"id": "RuView#1387-rvf-filename-collision",
"title": "training_api next_model_id(): microsecond timestamp + AtomicU64 counter keeps exported .rvf filenames collision-resistant",
"files": ["v2/crates/wifi-densepose-sensing-server/src/training_api.rs"],
"require": [
"static MODEL_ID_SEQ: AtomicU64",
"%Y%m%d_%H%M%S_%6f",
"MODEL_ID_SEQ.fetch_add(1, Ordering::Relaxed)",
"model_ids_are_unique_per_call"
],
"forbid": [
"/%Y%m%d_%H%M%S(?!_%6f)/"
],
"rationale": "next_model_id() builds the exported model path as trained-{type}-{ts}-{seq}. A second-resolution timestamp (%Y%m%d_%H%M%S) alone collided for two runs finishing in the same wall-clock second, silently overwriting each other's .rvf artifact and flaking the concurrent model-writing tests on CI (fixed on this branch in 8409f434c). Uniqueness now relies on BOTH microsecond resolution (%6f) AND a process-monotonic AtomicU64 counter appended to the id. Reverting to a bare %Y%m%d_%H%M%S with no sub-second/counter disambiguator reopens the silent-overwrite regression; the forbid uses a negative lookahead so it fires only on a second-resolution format that is NOT followed by _%6f. The model_ids_are_unique_per_call test (1000 back-to-back ids) locks it in.",
"ref": "https://github.com/ruvnet/RuView/pull/1387"
},
{
"id": "RuView#1387-default-wheel-budget-config",
"title": "python wheel: empty default Cargo features + optional SOTA crates + maturin strip keep the no-extras wheel under the ADR-117 §5.4 5 MB budget",
"files": ["python/Cargo.toml", "python/pyproject.toml"],
"require": [
"default = []",
"optional = true",
"strip = true"
],
"forbid": [
"/default\\s*=\\s*\\[[^\\]]*\"(sota|aether|meridian|mat)\"/"
],
"rationale": "The [aether]/[meridian]/[mat]/[sota] extras map to Cargo features that link heavy crates (mat -> ort/ONNX Runtime, train -> tokio+ruvector, etc.). The DEFAULT wheel must link none of them to stay under the ADR-117 §5.4 5 MB budget, which requires: (1) `default = []` in python/Cargo.toml [features], (2) every SOTA dep declared `optional = true` so it is pulled only by its own feature, and (3) `strip = true` in pyproject [tool.maturin] to drop debug symbols. Flipping default to include a SOTA feature, or making a SOTA dep non-optional, silently balloons the default wheel. NOTE: a fix-marker is a string guard and cannot measure bytes — it protects the CONFIG that keeps the wheel small. The actual numeric 5 MB ceiling is enforced by the `wheel-size-budget` job in .github/workflows/python-ci.yml, which builds the default (no-features) wheel and fails if it exceeds the budget.",
"ref": "https://github.com/ruvnet/RuView/pull/1387"
}
]
}
+25 -2
View File
@@ -154,7 +154,14 @@ export default class TrainingPanel {
};
await trainingService[method](payload);
await this.refresh();
} catch (e) { this._set({ loading: false, error: `Training failed: ${e.message}` }); }
} catch (e) {
// Start was rejected (e.g. server training disabled → HTTP 409). Tear down
// the progress socket we opened optimistically and refresh so the button
// reflects the real (possibly disabled) state instead of a silent no-op.
trainingService.disconnectProgressStream();
this._set({ loading: false, error: `Training failed: ${e.message}` });
this.refresh();
}
}
async _stopTraining() {
@@ -272,13 +279,29 @@ export default class TrainingPanel {
form.appendChild(ir('LoRA Profile (opt.)', 'text', this.config.lora_profile_name, v => { this.config.lora_profile_name = v; }));
s.appendChild(form);
// ADR-186 P5: if the server reports in-server training disabled
// (enabled:false), the Start buttons must be disabled with a CLI tooltip —
// never a silent no-op. Enablement is surfaced on the status payload.
const ts = this.state.trainingStatus;
const disabled = ts && ts.enabled === false;
const cli = (ts && ts.cli) || 'wifi-densepose train-room';
if (disabled) {
const note = this._el('div', 'tp-empty',
`In-server training is disabled on this build. Train from the CLI: ${cli}`);
s.appendChild(note);
}
const acts = this._el('div', 'tp-train-actions');
const btns = [
this._btn('Start Training', 'tp-btn tp-btn-success', () => this._launchTraining('startTraining', { patience: this.config.patience, base_model: this.config.base_model || undefined })),
this._btn('Pretrain', 'tp-btn tp-btn-secondary', () => this._launchTraining('startPretraining')),
this._btn('LoRA', 'tp-btn tp-btn-secondary', () => this._launchTraining('startLoraTraining', { base_model: this.config.base_model || undefined, profile_name: this.config.lora_profile_name || 'default' }))
];
btns.forEach(b => { b.disabled = this.state.loading; acts.appendChild(b); });
btns.forEach(b => {
b.disabled = this.state.loading || disabled;
if (disabled) b.title = `In-server training disabled — use: ${cli}`;
acts.appendChild(b);
});
s.appendChild(acts);
return s;
}
+7 -3
View File
@@ -8,6 +8,7 @@
* - Dot-matrix mist body mass, particle trails, WiFi waves, signal field
* - Reflective floor, settings dialog, and practical data HUD
*/
import { withWsTicket } from '../../services/ws-ticket.js';
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
@@ -462,7 +463,7 @@ class Observatory {
console.log('[Observatory] Sensing server detected at', base, '→', wsUrl);
this.settings.dataSource = 'ws';
this.settings.wsUrl = wsUrl;
this._connectWS(wsUrl);
void this._connectWS(wsUrl);
} else {
tryNext(i + 1);
}
@@ -472,10 +473,13 @@ class Observatory {
tryNext(0);
}
_connectWS(url) {
// async: `/ws/sensing` is gated (ADR-272); mint a single-use ticket first.
async _connectWS(url) {
this._disconnectWS();
let wsUrl = url;
try { wsUrl = await withWsTicket(url); } catch { /* auth off or pre-ADR-272 server */ }
try {
this._ws = new WebSocket(url);
this._ws = new WebSocket(wsUrl);
this._ws.onopen = () => {
console.log('[Observatory] WebSocket connected');
this._hud.updateSourceBadge('ws', this._ws);
+29 -1
View File
@@ -86,6 +86,21 @@ export class ApiService {
// Process response through interceptors
const processedResponse = await this.processResponse(response, url);
// NOTE: there is deliberately no step-up re-authentication branch here.
//
// An earlier revision caught the server's RFC 6750 "reauthentication
// required" challenge and redirected to /oauth/start. That challenge can
// never be issued to a browser: browser sign-in requests `sensing:read`
// only and always will (see BROWSER_SIGNIN_SCOPE), so no browser session
// holds `sensing:admin`, so the freshness gate the challenge announces is
// never reached. Admin work goes through the CLI or a pasted bearer.
//
// Removed rather than left inert, because it was not merely dead — it
// ended in a promise that never settles. If any other 401 ever grew that
// header, every caller awaiting this would hang forever with no error.
// The server-side guard stays as a fail-closed backstop; the client has
// nothing to do about a flow that does not exist.
// Handle errors
if (!processedResponse.ok) {
const error = await processedResponse.json().catch(() => ({
@@ -147,4 +162,17 @@ export class ApiService {
}
// Create singleton instance
export const apiService = new ApiService();
export const apiService = new ApiService();
// Storage key shared with the QuickSettings "API Access" panel.
export const API_TOKEN_STORAGE_KEY = 'ruview-api-token';
// Apply a previously-saved bearer token at module load — before app init
// dispatches its first request — so a configured RUVIEW_API_TOKEN works from
// the very first /api/v1/* call. The server only ever checks the
// `Authorization: Bearer` header (see bearer_auth.rs) — this intentionally
// never puts the token in a URL query string.
try {
const storedToken = localStorage.getItem(API_TOKEN_STORAGE_KEY);
if (storedToken) apiService.setAuthToken(storedToken);
} catch { /* storage unavailable (private browsing etc.) */ }
+18 -4
View File
@@ -1,3 +1,4 @@
import { withWsTicket } from './ws-ticket.js';
/**
* Sensing WebSocket Service
*
@@ -65,7 +66,7 @@ class SensingService {
/** Start the service (connect or simulate). */
start() {
this._connect();
void this._connect();
}
/** Stop the service entirely. */
@@ -120,13 +121,26 @@ class SensingService {
// ---- Connection --------------------------------------------------------
_connect() {
// async because the server gates `/ws/sensing` (ADR-272) and a browser
// cannot set an Authorization header on an upgrade — so we mint a
// single-use ticket first. Minted per connect attempt, never cached: a
// ticket is valid once and expires in seconds, so reusing one across
// reconnects would fail on the second attempt.
async _connect() {
if (this._ws && this._ws.readyState <= WebSocket.OPEN) return;
this._setState('connecting');
let url = SENSING_WS_URL;
try {
this._ws = new WebSocket(SENSING_WS_URL);
url = await withWsTicket(SENSING_WS_URL);
} catch {
// Ticket minting is best-effort: against a server with auth off, or one
// predating ADR-272, connecting without a ticket is correct.
}
try {
this._ws = new WebSocket(url);
} catch (err) {
console.warn('[Sensing] WebSocket constructor failed:', err.message);
this._fallbackToSimulation();
@@ -184,7 +198,7 @@ class SensingService {
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
this._connect();
void this._connect();
}, delay);
// Only start simulation after several failed attempts so a brief hiccup
+10 -3
View File
@@ -1,3 +1,4 @@
import { withWsTicket } from './ws-ticket.js';
// WebSocket Client for Three.js Visualization - WiFi DensePose
// Default endpoint is `/ws/sensing` on the same host the page was served from.
// Callers (e.g. viz.html) usually pass an explicit `url` derived from
@@ -47,7 +48,9 @@ export class WebSocketClient {
}
// Attempt to connect
connect() {
// async: `/ws/*` is gated (ADR-272) and a browser cannot set an
// Authorization header on an upgrade, so mint a single-use ticket first.
async connect() {
if (this.state === 'connecting' || this.state === 'connected') {
console.warn('[WS-VIZ] Already connected or connecting');
return;
@@ -56,8 +59,12 @@ export class WebSocketClient {
this._setState('connecting');
console.log(`[WS-VIZ] Connecting to ${this.url}`);
// Per attempt, never cached — a ticket is single-use and short-lived.
let url = this.url;
try { url = await withWsTicket(this.url); } catch { /* auth off or pre-ADR-272 server */ }
try {
this.ws = new WebSocket(this.url);
this.ws = new WebSocket(url);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => this._handleOpen();
@@ -235,7 +242,7 @@ export class WebSocketClient {
console.log(`[WS-VIZ] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
this.reconnectTimer = setTimeout(() => {
this.connect();
void this.connect();
}, delay);
}
+77
View File
@@ -0,0 +1,77 @@
// Single-use WebSocket tickets (ADR-272).
//
// A browser's WebSocket constructor cannot set an `Authorization` header on the
// upgrade request. That used to mean the sensing WebSocket stayed reachable
// with no credential even when the server had auth switched on — the REST
// control plane was locked while the live sensing stream was open.
//
// The server now gates `/ws/*` and `/api/v1/stream/pose`. Browsers exchange
// their stored bearer token at `POST /api/v1/ws-ticket` — an ordinary request,
// where headers DO work — for a short-lived, single-use ticket, and pass that
// as `?ticket=` on the socket URL.
//
// Why a token in a URL is acceptable here when it normally is not: the ticket
// is consumed on first use, lives ~30 seconds, and authorizes one WebSocket
// and nothing else. It cannot be replayed against /api/v1/*. The long-lived
// bearer token is still never put in a URL.
import { API_TOKEN_STORAGE_KEY } from './api.service.js';
function storedToken() {
try {
return localStorage.getItem(API_TOKEN_STORAGE_KEY) || null;
} catch {
// Private browsing / storage disabled — treat as "no token configured".
return null;
}
}
/**
* Mint a ticket. Returns null when no token is configured (auth is off, so no
* ticket is needed) or when the server does not offer the endpoint.
*/
async function mintTicket() {
const token = storedToken();
if (!token) return null;
try {
const resp = await fetch('/api/v1/ws-ticket', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
// 404 means a server predating ADR-272: it still exempts WebSockets, so
// connecting without a ticket is correct there. Treated as "no ticket
// needed" rather than as an error, so the UI works against both.
if (resp.status === 404) return null;
if (!resp.ok) {
console.warn('[ws-ticket] mint failed:', resp.status);
return null;
}
const body = await resp.json();
return body.ticket || null;
} catch (err) {
// Offline, or the server is down. The socket attempt will fail on its own
// and the caller's reconnect logic handles it; failing loudly here would
// just duplicate that.
console.warn('[ws-ticket] mint error:', err.message);
return null;
}
}
/**
* Return `url` with a freshly minted ticket appended, or unchanged when no
* ticket is available or needed.
*
* Always call this immediately before opening the socket a ticket expires in
* seconds and is valid exactly once, so one must never be cached or reused
* across reconnects.
*
* @param {string} url ws:// or wss:// URL
* @returns {Promise<string>}
*/
export async function withWsTicket(url) {
const ticket = await mintTicket();
if (!ticket) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}ticket=${encodeURIComponent(ticket)}`;
}
+113
View File
@@ -0,0 +1,113 @@
// Executed tests for the WebSocket ticket helper (ADR-272).
//
// Run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
// (a directory argument does not work — Node resolves it as a module)
//
// WHAT THIS IS AND IS NOT.
// This EXECUTES the module in Node with stubbed `fetch` and `localStorage`. It
// is strictly more than the `node --check` syntax pass this file replaces, and
// strictly less than a browser: it does not exercise a real WebSocket upgrade,
// real cookie handling, or the page wiring. The claim "the UI JavaScript has
// never been run" is no longer true of this module; "browser-tested" still is
// not. Both statements matter and neither should be rounded up.
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
const STORAGE_KEY = 'ruview-api-token';
// --- stubs installed before the module under test is imported ---------------
let stored = {};
let fetchCalls = [];
let fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
globalThis.localStorage = {
getItem: (k) => (k in stored ? stored[k] : null),
setItem: (k, v) => { stored[k] = String(v); },
removeItem: (k) => { delete stored[k]; },
};
globalThis.fetch = async (...args) => { fetchCalls.push(args); return fetchImpl(...args); };
const { withWsTicket } = await import('./ws-ticket.js');
beforeEach(() => {
stored = {};
fetchCalls = [];
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
});
test('with no stored token the URL is returned unchanged and nothing is fetched', async () => {
// Auth is off, so no ticket is needed. Minting one would be a pointless
// round-trip on every reconnect.
const url = await withWsTicket('ws://host/ws/sensing');
assert.equal(url, 'ws://host/ws/sensing');
assert.equal(fetchCalls.length, 0);
});
test('a minted ticket is appended and the bearer is sent only in the header', async () => {
stored[STORAGE_KEY] = 'secret-bearer';
const url = await withWsTicket('ws://host/ws/sensing');
assert.equal(url, 'ws://host/ws/sensing?ticket=T');
// The long-lived bearer must never reach a URL — only the bounded ticket does.
assert.ok(!url.includes('secret-bearer'), `bearer leaked into URL: ${url}`);
const [path, init] = fetchCalls[0];
assert.equal(path, '/api/v1/ws-ticket');
assert.equal(init.method, 'POST');
assert.equal(init.headers.Authorization, 'Bearer secret-bearer');
});
test('an existing query string gets & rather than a second ?', async () => {
stored[STORAGE_KEY] = 'b';
const url = await withWsTicket('ws://host/ws/sensing?foo=1');
assert.equal(url, 'ws://host/ws/sensing?foo=1&ticket=T');
});
test('the ticket value is URL-encoded', async () => {
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'a+b/c=d' }) });
const url = await withWsTicket('ws://host/ws/sensing');
assert.ok(url.endsWith('ticket=a%2Bb%2Fc%3Dd'), url);
});
test('404 means a server predating ADR-272, so connect without a ticket', async () => {
// That server still exempts WebSockets, so an unticketed connect is correct.
// This is what lets one UI work against both old and new servers, which is
// what makes the legacy escape hatch removable rather than permanent.
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => ({ ok: false, status: 404, json: async () => ({}) });
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
});
test('a 503 does not append a ticket and does not throw', async () => {
// Ticket store exhausted. The socket attempt will fail on its own and the
// caller's reconnect logic handles it; throwing here would duplicate that.
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => ({ ok: false, status: 503, json: async () => ({}) });
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
});
test('a network failure is swallowed rather than breaking the connect path', async () => {
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => { throw new Error('offline'); };
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
});
test('a 200 with no ticket field yields no ticket', async () => {
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({}) });
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
});
test('a fresh ticket is minted per call and never reused', async () => {
// Tickets are single-use and expire in ~30s, so caching one across reconnects
// fails on the second attempt.
stored[STORAGE_KEY] = 'b';
let n = 0;
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: `T${++n}` }) });
assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T1');
assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T2');
assert.equal(fetchCalls.length, 2, 'each connect attempt must mint its own');
});
+14
View File
@@ -4088,6 +4088,20 @@ a:focus-visible,
color: #fff;
}
.qs-text-input {
padding: var(--space-6) var(--space-8);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-secondary);
color: var(--color-text);
font-size: var(--font-size-sm);
}
.qs-text-input:focus {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
/* --- Screenshot Flash --- */
.screenshot-flash {
+91 -13
View File
@@ -1,7 +1,27 @@
// RuView Service Worker - Offline caching for the dashboard shell
// Strategy: Network-first for API calls, Cache-first for static assets
const CACHE_NAME = 'ruview-v1';
// Bumped from v1: an older SW cached `/oauth/status` cache-first, so browsers
// that ran it hold a permanently signed-out answer. `activate` deletes every
// cache whose name is not CACHE_NAME, so bumping is what evicts it from clients
// already in the field. Bump again if a future change poisons the cache.
const CACHE_NAME = 'ruview-v2';
// Requests whose response depends on the caller's credentials. These must never
// be served from the Cache API.
//
// The Cache API is NOT the HTTP cache: it ignores `Cache-Control` completely, so
// the `no-store, no-cache, must-revalidate` the server already sends on
// `/oauth/status` has no effect here. A cached signed-out response was returned
// to the page forever, and only a hard reload — which bypasses the service
// worker entirely — showed the true state. (ADR-271.)
const NEVER_CACHE_PREFIXES = ['/oauth/'];
// What may be served cache-first. Previously cache-first was the *catch-all* for
// everything outside `/api/` and `/health/`, which meant any endpoint added at a
// new path was frozen on first response. An allowlist fails safe instead: an
// unrecognised path goes to the network untouched.
const STATIC_ASSET = /\.(?:js|mjs|css|html|json|png|jpe?g|gif|svg|ico|webp|woff2?|ttf|map)$/i;
const SHELL_ASSETS = [
'/',
'/index.html',
@@ -74,25 +94,49 @@ self.addEventListener('fetch', (event) => {
// Skip cross-origin requests
if (url.origin !== self.location.origin) return;
// Credentialed endpoints: hands off entirely. Not networkFirst — that still
// writes a copy into the cache, which would be replayed the moment the server
// is briefly unreachable, silently reinstating a stale sign-in state.
if (NEVER_CACHE_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) {
// Signing out is the one moment we know cached data belongs to a session
// that is ending. Observed, not intercepted — the request itself still goes
// straight to the network. `waitUntil` keeps the worker alive for the purge
// even though the navigation is what the browser is really waiting on.
if (url.pathname === '/oauth/logout') {
event.waitUntil(purgeNonShell());
}
return;
}
// API calls: network-first with cache fallback
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/health/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets: cache-first with network fallback
event.respondWith(cacheFirst(request));
// Static assets and the app shell: cache-first with network fallback.
if (request.mode === 'navigate' || STATIC_ASSET.test(url.pathname)) {
event.respondWith(cacheFirst(request));
}
// Anything else is left alone and goes to the network as normal.
});
async function cacheFirst(request) {
const cached = await caches.match(request);
// `ignoreSearch` so the shell is a single entry. Sign-in redirects back to
// `/ui/?signed_in=<ms>`, which would otherwise mint a fresh cache entry per
// sign-in and never hit any of them again.
const cached = await caches.match(request, { ignoreSearch: true });
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
// Store under the search-less URL to match how it is looked up above.
const key = new URL(request.url);
key.search = '';
cache.put(key.toString(), response.clone());
}
return response;
} catch {
@@ -105,20 +149,54 @@ async function cacheFirst(request) {
}
}
/**
* Network-only, with an explicit offline signal.
*
* This used to cache every successful `/api/` response and replay it whenever
* the network failed. Two things are wrong with that now:
*
* 1. **Authorization.** API responses are per-user once auth is on, but the
* cache is keyed by URL alone and nothing purges it at sign-out. Sign in as
* A, load sensing data, sign out, sign in as B, lose the network B is
* served A's data with no authorization check at all. That is the same
* defect class as the cached `/oauth/status`: the Cache API happily outlives
* the session that produced its contents.
* 2. **Correctness.** This is a live sensing dashboard. Replaying a stale pose
* or presence reading as if it were current is its own defect it can show
* a room as occupied after the person has left.
*
* The offline shell (HTML/CSS/JS) is still cached; only the data is not. If
* offline data replay is wanted back, it needs a per-session cache key and a
* purge on sign-out, not a URL-keyed shared cache.
*/
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
return await fetch(request);
} catch {
const cached = await caches.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: 'offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
/**
* Drop everything except the static shell.
*
* Called when the user signs out. Belt-and-braces: nothing user-specific should
* be in the cache after the `networkFirst` change above, but a cache populated
* by an OLDER worker on this browser can still hold API responses, and that
* worker's entries survive into this one under the same name.
*/
async function purgeNonShell() {
const cache = await caches.open(CACHE_NAME);
const keys = await cache.keys();
await Promise.all(
keys
.filter((req) => {
const p = new URL(req.url).pathname;
return p.startsWith('/api/') || p.startsWith('/health/');
})
.map((req) => cache.delete(req))
);
}
+228
View File
@@ -0,0 +1,228 @@
// Executed tests for the service worker's request routing (ADR-271/272).
//
// WHY THIS EXISTS.
// Browser sign-in appeared to fail: after a successful OAuth round-trip the
// settings panel still offered "Sign in with Cognitum", and only a hard reload
// showed the truth. The server was correct throughout — `/oauth/status` fell
// through to the service worker's cache-first catch-all, so the first
// (signed-out) response was stored in the Cache API and replayed forever.
//
// The Cache API is not the HTTP cache. It ignores `Cache-Control` entirely, so
// the `no-store` the server already sent could not prevent this. Nothing in the
// Rust suite, the UI unit tests, or a curl probe could observe it: curl has no
// service worker, and a hard reload bypasses one. It was only visible by
// driving a real browser.
//
// These tests load the REAL `sw.js` with stubbed worker globals and assert on
// which strategy each path is routed to.
//
// Run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
// (a directory argument does not work — Node resolves it as a module)
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
const SW_SOURCE = readFileSync(fileURLToPath(new URL('./sw.js', import.meta.url)), 'utf8');
const ORIGIN = 'http://127.0.0.1:8099';
/** Load sw.js in a fresh context and return its registered `fetch` listener. */
function loadServiceWorker() {
const listeners = {};
const cachePuts = [];
// A real in-memory cache, so purge and put behaviour can be observed rather
// than assumed.
const entries = new Map();
const cacheStub = {
addAll: async () => {},
put: async (req, res) => {
const url = String(req.url ?? req);
cachePuts.push(url);
entries.set(url, res);
},
keys: async () => Array.from(entries.keys()).map((url) => ({ url })),
delete: async (req) => entries.delete(String(req.url ?? req)),
match: async () => undefined,
};
const sandbox = {
self: {
addEventListener: (name, fn) => { listeners[name] = fn; },
location: { origin: ORIGIN },
skipWaiting: () => {},
clients: { claim: () => {} },
},
caches: {
open: async () => cacheStub,
keys: async () => [],
delete: async () => true,
match: async () => undefined,
},
// Never actually reached: every assertion below inspects the routing
// decision, not the network.
fetch: async () => ({ ok: true, clone: () => ({}) }),
URL,
Response: class { constructor(body, init) { this.body = body; Object.assign(this, init); } },
console,
};
vm.createContext(sandbox);
vm.runInContext(SW_SOURCE, sandbox);
return { listeners, cachePuts, sandbox, entries };
}
/**
* Route one request and report the decision.
* `handled: false` means the SW called neither respondWith nor cache the
* request goes to the network untouched, which is the only safe outcome for a
* credentialed endpoint.
*/
function route(path, opts = {}) {
return dispatch(path, opts).handled;
}
/** Route one request and expose everything the worker did with it. */
function dispatch(path, { method = 'GET', mode = 'cors', headers = {}, sw = null } = {}) {
const worker = sw ?? loadServiceWorker();
let handled = false;
let responded = null;
const waited = [];
const request = {
url: `${ORIGIN}${path}`,
method,
mode,
headers: { get: (k) => headers[k] ?? headers[k.toLowerCase()] ?? null },
};
worker.listeners.fetch({
request,
respondWith: (p) => { handled = true; responded = p; },
waitUntil: (p) => { waited.push(p); },
});
return { handled, responded, waited, worker };
}
let sw;
beforeEach(() => { sw = loadServiceWorker(); });
// --- the defect this file exists for ----------------------------------------
test('/oauth/status is never handled by the service worker', () => {
// The exact request whose cached signed-out copy made sign-in look broken.
assert.equal(route('/oauth/status'), false);
});
test('every /oauth/ path bypasses the worker, not just status', () => {
// start/callback/logout all carry or clear credentials. A cached redirect or
// Set-Cookie replayed later is worse than a cached status.
for (const p of ['/oauth/start', '/oauth/callback?code=x&state=y', '/oauth/logout']) {
assert.equal(route(p), false, `${p} must go straight to the network`);
}
});
// --- the underlying defect: cache-first was the catch-all -------------------
test('an unrecognised path is left to the network rather than cached', () => {
// This is what made the OAuth bug possible in the first place: any endpoint
// added outside /api/ was silently frozen on its first response. An
// allowlist means the next such endpoint is safe by default.
assert.equal(route('/some/future/endpoint'), false);
});
test('static assets are still served cache-first', () => {
// The offline shell is the point of the worker; the fix must not disable it.
for (const p of ['/app.js', '/style.css', '/components/TabManager.js', '/icons/logo.svg']) {
assert.equal(route(p), true, `${p} should be cache-first`);
}
});
test('a navigation request is still served cache-first', () => {
assert.equal(route('/ui/', { mode: 'navigate' }), true);
});
test('API paths are still routed through the worker', () => {
// Handled, but network-only — see the "not written to the cache" test below.
assert.equal(route('/api/v1/models'), true);
assert.equal(route('/health/live'), true);
});
// --- pre-existing guards, pinned so the rewrite did not drop them -----------
test('non-GET requests are ignored', () => {
assert.equal(route('/api/v1/models', { method: 'POST' }), false);
});
test('websocket upgrades are ignored', () => {
assert.equal(route('/ws/sensing', { headers: { Upgrade: 'websocket' } }), false);
});
test('cross-origin requests are ignored', () => {
const { listeners } = loadServiceWorker();
let handled = false;
listeners.fetch({
request: {
url: 'https://auth.cognitum.one/oauth/authorize',
method: 'GET',
mode: 'cors',
headers: { get: () => null },
},
respondWith: () => { handled = true; },
});
assert.equal(handled, false);
});
// --- authenticated API responses must not be retained ------------------------
// Filed by the cross-vendor prosecutor in the qe-court round after the
// /oauth/status fix: closing the /oauth/ leg left the /api/ leg open.
test('a successful API response is NOT written to the cache', async () => {
// The leak: cache keys are URLs, nothing partitions them by session, and
// nothing purged them at sign-out. Sign in as A, fetch sensing data, sign
// out, sign in as B, lose the network -> B is served A's data.
const { responded, worker } = dispatch('/api/v1/sensing/latest');
await responded;
assert.deepEqual(worker.cachePuts, [], 'API responses must not be cached');
});
test('an API request with no network returns 503 rather than stale data', async () => {
// Also a correctness property, not only an authorization one: replaying a
// stale pose reading as current can show a room occupied after the person
// has left.
const worker = loadServiceWorker();
worker.sandbox.fetch = async () => { throw new Error('offline'); };
worker.entries.set(`${ORIGIN}/api/v1/sensing/latest`, { stale: true });
const { responded } = dispatch('/api/v1/sensing/latest', { sw: worker });
const res = await responded;
assert.equal(res.status, 503);
assert.match(String(res.body), /offline/);
});
test('signing out purges cached API data but keeps the offline shell', async () => {
const worker = loadServiceWorker();
worker.entries.set(`${ORIGIN}/api/v1/sensing/latest`, {});
worker.entries.set(`${ORIGIN}/health/live`, {});
worker.entries.set(`${ORIGIN}/app.js`, {});
const { handled, waited } = dispatch('/oauth/logout', { sw: worker });
// Observed, not intercepted — the logout request itself must still reach the
// server, or signing out would not actually sign anyone out.
assert.equal(handled, false, '/oauth/logout must still go to the network');
assert.equal(waited.length, 1, 'the purge must be kept alive via waitUntil');
await Promise.all(waited);
const left = Array.from(worker.entries.keys());
assert.deepEqual(left, [`${ORIGIN}/app.js`], 'only the static shell should survive');
});
// --- cache hygiene -----------------------------------------------------------
test('the cache name is bumped so clients holding the poisoned v1 evict it', () => {
// `activate` deletes every cache whose name !== CACHE_NAME. Browsers that
// already ran the old worker hold a signed-out /oauth/status in `ruview-v1`;
// only a name change removes it for them.
assert.ok(!/['"]ruview-v1['"]/.test(SW_SOURCE), 'CACHE_NAME must not still be ruview-v1');
assert.ok(/CACHE_NAME\s*=\s*['"]ruview-v[2-9]/.test(SW_SOURCE));
});
+139
View File
@@ -1,6 +1,8 @@
// Quick Settings Panel - Centralized configuration for all UI features
// Accessible via gear icon in header
import { apiService, API_TOKEN_STORAGE_KEY } from '../services/api.service.js';
export class QuickSettings {
constructor(app) {
this.app = app;
@@ -9,6 +11,8 @@ export class QuickSettings {
this.isOpen = false;
}
// A stored token is applied at api.service.js module load (before any
// request fires) — this panel only saves/clears it.
init() {
this.createButton();
this.createPanel();
@@ -70,6 +74,28 @@ export class QuickSettings {
<span class="qs-switch"></span>
</label>
</div>
<div class="qs-section">
<div class="qs-section-title">Cognitum Account</div>
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
<span id="qs-signin-status" style="font-size: 0.9em; opacity: 0.85;">Checking...</span>
<div style="display: flex; gap: 8px;">
<button class="qs-btn" id="qs-signin" hidden>Sign in with Cognitum</button>
<button class="qs-btn-danger" id="qs-signout" hidden>Sign out</button>
</div>
</div>
</div>
<div class="qs-section">
<div class="qs-section-title">API Access</div>
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
<span>Bearer token (set only if the server enforces RUVIEW_API_TOKEN)</span>
<input type="password" id="qs-api-token" class="qs-text-input" placeholder="Paste token..." autocomplete="off" style="width: 100%; box-sizing: border-box;">
<div style="display: flex; gap: 8px;">
<button class="qs-btn" id="qs-api-token-save">Save & Apply</button>
<button class="qs-btn-danger" id="qs-api-token-clear">Clear</button>
</div>
<span id="qs-api-token-status" style="font-size: 0.85em; opacity: 0.75;"></span>
</div>
</div>
<div class="qs-section">
<div class="qs-section-title">Data</div>
<div class="qs-row">
@@ -87,6 +113,23 @@ export class QuickSettings {
// Bind events
this.panel.querySelector('.qs-close').addEventListener('click', () => this.close());
// Re-check sign-in state whenever the page could be showing a stale view:
// `pageshow` fires on a back/forward-cache restore (where no script re-runs
// and no fetch would otherwise happen), and `visibilitychange` covers
// signing in or out in another tab. Opening the panel alone is not enough —
// the panel may already be open, or the page may be restored wholesale.
window.addEventListener('pageshow', () => { void refreshSignInPanel(this.panel); });
document.addEventListener('visibilitychange', () => {
if (!document.hidden) void refreshSignInPanel(this.panel);
});
// ADR-271 sign-in. Bound here as well as in refreshSignInPanel so a click
// works even if the status fetch has not resolved yet.
this.panel.querySelector('#qs-signin')
.addEventListener('click', () => { window.location.href = '/oauth/start'; });
this.panel.querySelector('#qs-signout')
.addEventListener('click', () => { window.location.href = '/oauth/logout'; });
this.panel.querySelector('#qs-reduced-motion').addEventListener('change', (e) => {
document.body.classList.toggle('reduced-motion', e.target.checked);
this.saveSetting('reduced-motion', e.target.checked);
@@ -112,6 +155,30 @@ export class QuickSettings {
}
});
this.panel.querySelector('#qs-api-token-save').addEventListener('click', () => {
const input = this.panel.querySelector('#qs-api-token');
const status = this.panel.querySelector('#qs-api-token-status');
const token = input.value.trim();
if (!token) {
status.textContent = 'Enter a token first, or use Clear to remove one.';
return;
}
try { localStorage.setItem(API_TOKEN_STORAGE_KEY, token); } catch { /* noop */ }
apiService.setAuthToken(token);
status.textContent = 'Token saved and applied. Reloading...';
setTimeout(() => window.location.reload(), 600);
});
this.panel.querySelector('#qs-api-token-clear').addEventListener('click', () => {
const input = this.panel.querySelector('#qs-api-token');
const status = this.panel.querySelector('#qs-api-token-status');
try { localStorage.removeItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
apiService.setAuthToken(null);
input.value = '';
status.textContent = 'Token cleared. Reloading...';
setTimeout(() => window.location.reload(), 600);
});
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
try {
localStorage.clear();
@@ -154,6 +221,10 @@ export class QuickSettings {
if (this.getSetting('compact')) {
document.body.classList.add('compact-mode');
}
const status = this.panel.querySelector('#qs-api-token-status');
let hasToken = false;
try { hasToken = !!localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
if (status) status.textContent = hasToken ? 'A token is currently set.' : 'No token set (auth is off or unnecessary).';
}
prefersReducedMotion() {
@@ -167,6 +238,11 @@ export class QuickSettings {
open() {
this.isOpen = true;
this.panel.classList.add('open');
// Refresh on every open, not once at construction: the session may have
// been established in another tab, or expired since the page loaded.
// Fire-and-forget — a failure renders as a message in the panel, and must
// not stop the panel opening.
void refreshSignInPanel(this.panel);
}
close() {
@@ -189,3 +265,66 @@ export class QuickSettings {
this.panel?.remove();
}
}
// ---- Cognitum browser sign-in (ADR-271) -------------------------------------
//
// `/oauth/status` is intentionally UNGATED: a signed-out browser cannot ask a
// gated endpoint whether sign-in is available. It returns capability flags and,
// when a session exists, who it belongs to — never a credential.
//
// Sign-in is a full-page navigation, not fetch(): the server replies 302 to
// auth.cognitum.one, and the browser must follow it and carry the transaction
// cookie. An XHR would follow the redirect invisibly and land nowhere useful.
export async function refreshSignInPanel(root = document) {
const status = root.querySelector('#qs-signin-status');
const signIn = root.querySelector('#qs-signin');
const signOut = root.querySelector('#qs-signout');
if (!status || !signIn || !signOut) return null;
let info;
try {
const resp = await fetch('/oauth/status', { credentials: 'same-origin' });
// 404 = a server predating ADR-271. Say so plainly rather than offering a
// button that will 404.
if (resp.status === 404) {
status.textContent = 'This server does not support Cognitum sign-in.';
signIn.hidden = true;
signOut.hidden = true;
return null;
}
if (!resp.ok) throw new Error(`status ${resp.status}`);
info = await resp.json();
} catch (err) {
status.textContent = `Could not reach the server (${err.message}).`;
signIn.hidden = true;
signOut.hidden = true;
return null;
}
if (info.signed_in) {
status.textContent = `Signed in${info.account ? ` as ${info.account}` : ''}${
info.scope ? ` - ${info.scope}` : ''
}`;
signIn.hidden = true;
signOut.hidden = false;
} else if (info.browser_signin) {
status.textContent = info.auth_required
? 'This server requires sign-in.'
: 'Optional: sign in to use your Cognitum account.';
signIn.hidden = false;
signOut.hidden = true;
} else if (info.auth_required) {
// Auth is on but OAuth is not — the static-token panel below is the path.
status.textContent = 'This server uses a shared API token (see API Access below).';
signIn.hidden = true;
signOut.hidden = true;
} else {
status.textContent = 'This server does not require sign-in.';
signIn.hidden = true;
signOut.hidden = true;
}
signIn.onclick = () => { window.location.href = '/oauth/start'; };
signOut.onclick = () => { window.location.href = '/oauth/logout'; };
return info;
}
Generated
+188 -17
View File
@@ -392,6 +392,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.21.7"
@@ -1533,6 +1539,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array 0.14.7",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -1969,6 +1987,20 @@ dependencies = [
"num-traits",
]
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
@@ -2002,6 +2034,26 @@ dependencies = [
"serde",
]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array 0.14.7",
"group",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]]
name = "embed-resource"
version = "3.0.6"
@@ -2160,6 +2212,16 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
@@ -2941,6 +3003,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -3145,6 +3208,17 @@ dependencies = [
"system-deps",
]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "gtk"
version = "0.18.2"
@@ -4278,6 +4352,21 @@ dependencies = [
"serde_json",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "katexit"
version = "0.1.5"
@@ -5594,6 +5683,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -6155,6 +6256,15 @@ dependencies = [
"num-integer",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
@@ -6931,6 +7041,16 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]]
name = "rfd"
version = "0.16.0"
@@ -7511,6 +7631,26 @@ version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "753a07254fa68db183949ec6c7575d890da4d42404afabc11d610a720fcf570c"
[[package]]
name = "ruview-auth"
version = "0.1.0"
dependencies = [
"base64 0.21.7",
"jsonwebtoken",
"libc",
"p256",
"rand 0.8.5",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tokio",
"tracing",
"ureq 2.12.1",
"url",
]
[[package]]
name = "ruview-swarm"
version = "0.1.0"
@@ -7533,7 +7673,6 @@ dependencies = [
"tokio-test",
"toml 0.8.23",
"tracing",
"wifi-densepose-core",
]
[[package]]
@@ -7667,6 +7806,20 @@ dependencies = [
"untrusted",
]
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array 0.14.7",
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -8132,6 +8285,18 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "simsimd"
version = "5.9.11"
@@ -10852,6 +11017,13 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "wifi-densepose-aether"
version = "0.3.0"
dependencies = [
"serde_json",
]
[[package]]
name = "wifi-densepose-bfld"
version = "0.3.1"
@@ -10896,6 +11068,8 @@ dependencies = [
"ndarray 0.17.2",
"num-complex",
"predicates",
"reqwest 0.12.28",
"ruview-auth",
"serde",
"serde_json",
"tabled",
@@ -10961,7 +11135,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-engine"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"blake3",
"criterion",
@@ -11126,29 +11300,38 @@ dependencies = [
[[package]]
name = "wifi-densepose-sensing-server"
version = "0.3.3"
version = "0.3.4"
dependencies = [
"axum",
"base64 0.21.7",
"chrono",
"clap",
"criterion",
"futures-util",
"hmac",
"jsonwebtoken",
"midstreamer-attractor",
"midstreamer-temporal-compare",
"p256",
"proptest",
"rand 0.8.5",
"rumqttc",
"ruvector-mincut",
"ruview-auth",
"serde",
"serde_json",
"sha2",
"subtle",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tokio-tungstenite",
"tower 0.4.13",
"tower-http",
"tracing",
"tracing-subscriber",
"ureq 2.12.1",
"wifi-densepose-aether",
"wifi-densepose-bfld",
"wifi-densepose-engine",
"wifi-densepose-geo",
@@ -11161,7 +11344,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-signal"
version = "0.3.4"
version = "0.3.5"
dependencies = [
"chrono",
"criterion",
@@ -11220,7 +11403,6 @@ dependencies = [
"tracing",
"tracing-subscriber",
"walkdir",
"wifi-densepose-nn",
"wifi-densepose-signal",
]
@@ -11268,7 +11450,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-worldgraph"
version = "0.3.1"
version = "0.3.2"
dependencies = [
"petgraph",
"serde",
@@ -11277,17 +11459,6 @@ dependencies = [
"wifi-densepose-geo",
]
[[package]]
name = "wifi-densepose-worldmodel"
version = "0.3.1"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"wifi-densepose-worldgraph",
]
[[package]]
name = "winapi"
version = "0.3.9"
+7
View File
@@ -20,6 +20,7 @@ members = [
"crates/wifi-densepose-mat",
"crates/wifi-densepose-train",
"crates/wifi-densepose-sensing-server",
"crates/wifi-densepose-aether", # ADR-185 §13 — AETHER pure-compute leaf (std-only)
"crates/wifi-densepose-wifiscan",
"crates/wifi-densepose-vitals",
"crates/wifi-densepose-ruvector",
@@ -28,6 +29,12 @@ members = [
# geo + worldgraph extracted to ruvnet/worldgraph submodule (see crates/worldgraph)
"crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer
"crates/wifi-densepose-calibration", # ADR-151 — per-room calibration & specialist training
# ADR-271 — Cognitum OAuth access-token verification. RuView as an OAuth
# RESOURCE SERVER: offline ES256/JWKS verification of tokens issued by
# auth.cognitum.one, so a user signs in to their own sensing server with
# their Cognitum identity instead of a shared static bearer. No login flow
# and no outbound Cognitum API calls live here — verification only.
"crates/ruview-auth",
"crates/nvsim",
"crates/nvsim-server",
"crates/homecore", # ADR-127 — HOMECORE state machine

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