Merge remote-tracking branch 'origin/main' into feat/ruview-auth-cognitum-oauth-verifier

# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
This commit is contained in:
Dragan Spiridonov
2026-07-24 15:26:15 +02:00
71 changed files with 10058 additions and 345 deletions
+24 -4
View File
@@ -13,10 +13,26 @@
# 1. cut tag `v1.99.0-pip` → publishes the tombstone wheel first
# 2. cut tag `v2.0.0-pip` → publishes the PyO3 v2 wheel matrix
#
# Publishes via the `PYPI_API_TOKEN` GitHub Actions secret. The
# token-refresh runbook (GCP Secret Manager → gh secret set) lives in
# docs/integrations/pypi-release.md so KICS does not flag the
# secret name as a generic-secret literal in the workflow.
# Publishes via the `PYPI_API_TOKEN` GitHub Actions secret (API-token
# auth). This is the ACTIVE, working publish path — the token is sourced
# fresh from GCP Secret Manager per the runbook in
# docs/integrations/pypi-release.md (GCP Secret Manager → gh secret set),
# which also keeps KICS from flagging the secret name as a generic-secret
# literal here.
#
# TODO(ADR-184 P1b): migrate to PyPI OIDC Trusted Publishing to remove
# this rotatable/expire-able credential. That switch is GATED on a manual
# pypi.org step no CLI/agent can perform: the repo owner must register a
# Trusted Publisher on pypi.org for owner=ruvnet / repo=RuView /
# workflow=pip-release.yml (BOTH the wifi-densepose and ruview projects;
# ruview as a pending publisher) — see docs/adr/ADR-184-*.md. Do NOT grant
# the OIDC id-token write permission before that registration exists, or
# publishing fails with "no trusted publisher configured" — a silent
# regression the `Verify fix markers` guard `RuView#786-pypi-token-auth`
# exists to catch (it forbids that permission string in this file). When
# the owner confirms both entries are live, do the OIDC switch as a
# dedicated follow-up commit (drop `password:`, add the OIDC id-token
# permission + `environment: pypi`) so there is no capability gap between.
#
# Q3 (witness hash v2 — open in ADR-117 §11.3) MUST be resolved
# before the first v2.0.0 publish. When v2 lands, add a parallel
@@ -241,6 +257,8 @@ jobs:
mkdir -p dist
find dist-staging -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp -v {} dist/ \;
ls -lh dist/
# API-token auth (active path). See TODO(ADR-184 P1b) in the header
# before replacing `password:` with the OIDC id-token permission.
- name: Publish to TestPyPI (dry-run target)
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
uses: pypa/gh-action-pypi-publish@release/v1
@@ -274,6 +292,8 @@ jobs:
with:
name: tombstone
path: dist
# API-token auth (active path). See TODO(ADR-184 P1b) in the header
# before replacing `password:` with the OIDC id-token permission.
- name: Publish to TestPyPI (dry-run target)
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
uses: pypa/gh-action-pypi-publish@release/v1
+170
View File
@@ -0,0 +1,170 @@
# Python Package CI — gates the `python/` PyO3+maturin wheel (`wifi-densepose`).
#
# ADR-117 (pip modernization) + ADR-185 (SOTA extras). Unlike the frozen
# `archive/v1/` Python app — which is `continue-on-error: true` in ci.yml
# because it is reference-only — the `python/` package is an actively-shipped
# PyPI wheel (published by pip-release.yml). Before this workflow, `python/`
# had ZERO per-PR coverage: pip-release.yml only fires on release triggers
# (tags / dispatch), so a PR could break the wheel build, break a native-Rust
# parity test, or blow the wheel-size budget and nothing in the normal gating
# CI would notice until release day. This workflow closes that gap.
#
# Path-scoped as a DEDICATED workflow rather than a job inside ci.yml. That is
# this repo's own convention for component-scoped CI (cf. firmware-ci.yml,
# sensing-server-docker.yml, bfld-mqtt-integration.yml — all separate files
# with `paths:` triggers). GitHub only supports workflow-level `paths:`, not
# per-job path filters, and no workflow in this repo uses a change-detection
# action (dorny/paths-filter, tj-actions/changed-files) — so the idiomatic,
# no-new-dependency way to scope to `python/**` is a standalone workflow. It
# simply does not run on unrelated PRs.
name: Python Package CI
on:
push:
branches:
- '**'
# NOTE: kept in sync with the pull_request paths below. GitHub Actions
# does not reliably support YAML anchors in workflow files, so the two
# lists are duplicated deliberately rather than aliased.
paths:
- 'python/**'
- 'v2/crates/wifi-densepose-core/**'
- 'v2/crates/wifi-densepose-vitals/**'
- 'v2/crates/wifi-densepose-bfld/**'
- 'v2/crates/wifi-densepose-aether/**'
- 'v2/crates/wifi-densepose-mat/**'
- 'v2/crates/wifi-densepose-train/**'
- 'v2/crates/wifi-densepose-signal/**'
- '.github/workflows/python-ci.yml'
pull_request:
paths:
- 'python/**'
- 'v2/crates/wifi-densepose-core/**'
- 'v2/crates/wifi-densepose-vitals/**'
- 'v2/crates/wifi-densepose-bfld/**'
- 'v2/crates/wifi-densepose-aether/**'
- 'v2/crates/wifi-densepose-mat/**'
- 'v2/crates/wifi-densepose-train/**'
- 'v2/crates/wifi-densepose-signal/**'
- '.github/workflows/python-ci.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: python-ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# Build the wheel with ALL SOTA features and run the full parity suite.
# `--features sota` = aether + meridian + mat, so the compiled feature
# submodules (wifi_densepose.aether / .meridian / .mat) exist and their
# SHA-256 parity tests against the native-Rust reference actually run
# (test_aether.py / test_meridian.py / test_mat.py import those submodules
# at collection time — without the features they would error, not skip).
parity-tests:
name: Wheel + parity tests (features=sota)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# The python/ crate path-deps v2/crates/* and (transitively via
# train) the vendored ruvector submodule — recursive checkout keeps
# those path deps resolvable, matching the rust-tests job in ci.yml.
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
with:
workspaces: |
v2
python
# Fast-fail with per-crate attribution BEFORE the heavier maturin build,
# so a break in a binding-backing crate is reported as "this crate failed
# to build" rather than buried in a maturin link error. These are the
# crates the [aether]/[mat]/[meridian] compiled extras link.
- name: Build binding-backing crates
working-directory: v2
env:
CARGO_PROFILE_DEV_DEBUG: "0"
run: cargo build -p wifi-densepose-aether -p wifi-densepose-mat -p wifi-densepose-train
# maturin develop needs an active virtualenv; create one and expose it
# to the later steps via GITHUB_PATH so `maturin` / `pytest` resolve to
# it. Test deps: pytest-asyncio (client tests are async, asyncio_mode
# auto), numpy (test_bfld), websockets + paho-mqtt (the [client] extra
# used by test_client_*).
- name: Create venv + install maturin and test deps
run: |
python -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
pip install "maturin>=1.7,<2.0" pytest pytest-asyncio numpy websockets paho-mqtt
echo "VIRTUAL_ENV=$PWD/.venv" >> "$GITHUB_ENV"
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
- name: Build + install wheel (maturin develop --features sota)
working-directory: python
env:
CARGO_PROFILE_DEV_DEBUG: "0"
run: maturin develop --features sota
- name: Run parity + binding tests
run: pytest python/tests/ -q
# Numeric enforcement of the ADR-117 §5.4 default-wheel budget. A fix-marker
# can guard the CONFIG that keeps the wheel small (empty default features,
# optional SOTA deps, strip=true — see RuView#1387-default-wheel-budget-config
# in scripts/fix-markers.json) but it cannot measure bytes. This job builds
# the DEFAULT (no-features) wheel and fails if it exceeds the budget — the
# real guard against a dependency silently ballooning the shipped wheel.
wheel-size-budget:
name: Default wheel <= 5 MiB (ADR-117 §5.4)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
with:
workspaces: python
- name: Install maturin
run: python -m pip install --upgrade pip "maturin>=1.7,<2.0"
- name: Build default wheel and assert size budget
working-directory: python
run: |
set -euo pipefail
maturin build --release --out dist
whl="$(ls dist/*.whl | head -1)"
bytes="$(stat -c%s "$whl")"
limit=$((5 * 1024 * 1024)) # ADR-117 §5.4: 5 MiB
printf 'Default wheel: %s = %s bytes (%s MiB); budget = %s bytes\n' \
"$whl" "$bytes" "$((bytes / 1024 / 1024))" "$limit"
if [ "$bytes" -gt "$limit" ]; then
echo "::error::default wheel is $bytes bytes, over the ADR-117 §5.4 $limit-byte (5 MiB) budget"
exit 1
fi
echo "Default wheel is within the 5 MiB budget."
+7
View File
@@ -8,9 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha``5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. Pip-release now authenticates via Trusted Publishing (see the entry below). **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload (ADR-184 P3) is still gated on the one-time manual Trusted Publisher registration on pypi.org that only the repo owner can perform. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client).
- **CI (ADR-184): `pip-release.yml` publish job migrated to PyPI OIDC Trusted Publishing** (commit `cc153e8b5`; refs #785, completes ADR-117). The release workflow now authenticates to PyPI via short-lived OIDC tokens (`id-token: write`) instead of a long-lived `PYPI_API_TOKEN` secret. **Not yet active**: publishing will fail until the matching Trusted Publisher is registered manually on pypi.org (a one-time, per-project step that cannot be automated from CI) — ADR-184 P1 tracks this as the remaining gate (status recorded in `dfc4c1abd`).
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
### Deprecated
- **`archive/v1` (the original pure-Python implementation) formally deprecated (ADR-187)** — commits `1fb5397dd`, `b1417fb6e`; refs #509, #1125. Added `archive/v1/DEPRECATED.md` (a loud tombstone) and a `> ⚠️ DEPRECATED` notice atop `archive/v1/README.md`, both pointing at the maintained `v2/` workspace and the `wifi-densepose 2.x` / `ruview` pip wheel (ADR-117). Records the honest fact behind #509: `archive/v1`'s `DensePoseHead` is **architecture-only** — random `kaiming_normal_` init with **zero committed checkpoints** under `archive/v1/` (MEASURED by Glob over `**/*.{pth,onnx,safetensors,pt,ckpt,bin}`). The ADR-028 deterministic proof `archive/v1/data/proof/verify.py` stays live and is explicitly out of scope. The same effort added a **"Model weights: what's real, what's not" three-tier table** to `README.md` + `docs/user-guide.md`, separating real-and-validated checkpoints (presence 82.3% held-out temporal-triplet, MM-Fi pose 82.69% torso-PCK@20, `count_v1`) from the real-but-weak on-device `pose_v1` (PCK@20 = 3.0%, runtime `confidence=0` stub, below the ADR-079 ≥35% target) from the architecture-only `archive/v1` head — and caveated every live single-ESP32 17-keypoint advertisement accordingly. Docs/labeling only; no code or model behavior changed.
### Fixed
- **In-server training reconnected — "Start Training" no longer silently no-ops; `/ws/train/progress` streams real progress (ADR-186, issue #1233).** The dashboard's Start Training button POSTed a config, got `success:true`, and nothing happened: `/api/v1/train/start` was a stub that flipped a status string and logged one line, and `/ws/train/progress` 404'd. The full pure-Rust trainer in `training_api.rs` (loads recorded CSI, gradient-descent, exports a `.rvf`) already existed but was **orphaned** — never declared as a module (no `mod training_api;`), so it wasn't compiled at all. Fix (`wifi-densepose-sensing-server`): declared the module, reconciled `AppStateInner` (replaced the `training_status`/`training_config` stub fields with a shared `TrainingState` status handle + cooperative cancel flag + a `training_progress_tx` broadcast), deleted the stub handlers, and merged the real `training_api::routes()` (so `/api/v1/train/{start,stop,status,pretrain,lora}` and `/ws/train/progress` resolve under the existing `/api/v1/*` bearer gate). The training core was decoupled from the ~60-field server state so it is unit-testable. **P5 honesty guarantee:** with `RUVIEW_DISABLE_SERVER_TRAINING` set, start returns a structured `{enabled:false, cli:"wifi-densepose train-room"}` HTTP 409 — never a silent success — and the dashboard disables the Start buttons with a CLI tooltip (enablement is surfaced on `/api/v1/train/status`). Pinned by 8 new tests incl. a **live-socket** test that completes a genuine 101 WebSocket handshake and receives a real progress frame after a POST start, a full POST→poll-status→`.rvf`-exists round-trip, a path-traversal rejection, cancellation, and the disabled-409 path. `cargo test -p wifi-densepose-sensing-server -p wifi-densepose-train --no-default-features` — 0 failed.
- **FastAPI health/metrics endpoints event-loop starvation.** Calling `psutil.cpu_percent(interval=1)` blocked the single-threaded async event loop for 1.0 second on every health check or metrics collection tick, stalling all incoming requests and WebSocket operations. Fixed by changing `cpu_percent` to use non-blocking `interval=None` and offloading all blocking OS metrics gathering to background thread pools via `asyncio.to_thread`. Verified event loop responsiveness via concurrency regression tests.
- **EngineBridge now honors `WDP_GUARD_INTERVAL_US`/`WDP_SOFT_GUARD_US`/`WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US`** (#1309, PR #1312, @erichkusuki). The governed trust path previously built its multistatic fuser from a hardcoded `MultistaticConfig::default()` (60 ms guard), so multi-node deployments with WiFi/ESP-NOW time sync (10150 ms drift) failed every governed cycle regardless of configuration — while the startup log claimed the override took effect. New `StreamingEngine::set_multistatic_config()`; `EngineBridge::new()` takes an `Option<MultistaticConfig>` threaded from the same env-derived config as `AppState.multistatic_fuser`. Hardware-verified on a live 2-node ESP32-S3 setup (90 s window, 0 fusion errors; previously every cycle failed).
- **`/api/v1/stream/pose` WebSocket reachable with `RUVIEW_API_TOKEN` set + dashboard bearer-token field** (#1310, PR #1313, @erichkusuki). Browsers cannot attach an `Authorization` header to a WS upgrade, so the Live Demo pose stream always failed when auth was on; the path is now on a narrow exact-match exemption list (mirrors `/ws/sensing`), with a regression test pinning that the exemption doesn't leak to other `/api/v1/*` paths. The QuickSettings panel gains an "API Access" field storing the bearer token in `localStorage`; the token is applied at `api.service.js` module load so the very first request carries it.
@@ -36,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.
+26 -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,7 +128,7 @@ 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 |
@@ -145,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;
@@ -157,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).
@@ -204,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.
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

@@ -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).
+5 -1
View File
@@ -2,10 +2,14 @@
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 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.)
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?
+14 -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
+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(n²)).
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
+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(())
}
+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
+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;
}
Generated
+9 -1
View File
@@ -11017,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"
@@ -11318,11 +11325,13 @@ dependencies = [
"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",
@@ -11394,7 +11403,6 @@ dependencies = [
"tracing",
"tracing-subscriber",
"walkdir",
"wifi-densepose-nn",
"wifi-densepose-signal",
]
+1
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",
@@ -0,0 +1,26 @@
[package]
name = "wifi-densepose-aether"
description = "AETHER pure-compute stack (ADR-024): contrastive CSI embedding, CSI-to-pose transformer, SONA drift/LoRA, and quantization — std-only, no async/server deps so the Python `[aether]` wheel stays lean (ADR-185 §3.2)"
version = "0.3.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
documentation.workspace = true
keywords.workspace = true
categories.workspace = true
# Intentionally dependency-free: this crate is the leaf hoisted out of
# `wifi-densepose-sensing-server` (ADR-185 §13) precisely so that binding it into
# the `wifi_densepose[aether]` wheel does not pull the Axum/tokio/worldgraph/
# ruvector server tree. Keep it std-only.
[dependencies]
# Test-only: parses the committed golden fixtures shared with the Python parity
# tests. Never linked into the library or the wheel.
[dev-dependencies]
serde_json = "1"
[lib]
name = "wifi_densepose_aether"
path = "src/lib.rs"
@@ -822,8 +822,73 @@ impl EmbeddingExtractor {
self.projection = proj;
Ok(())
}
/// Serialize all weights (transformer + projection) to `path`.
///
/// Format (little-endian, zero-dep so the std-only leaf crate stays
/// dependency-free): 8-byte magic `AETHERW1`, then a `u32` parameter
/// count, then that many `f32` values — exactly `flatten_weights()`.
///
/// This is the counterpart of [`Self::load_weights`]. It enables loading a
/// *real trained* checkpoint once one exists (ADR-185 §13.a); it does not
/// itself make the default (random-init) extractor trained.
pub fn save_weights<P: AsRef<std::path::Path>>(&self, path: P) -> std::io::Result<()> {
let weights = self.flatten_weights();
let mut buf = Vec::with_capacity(WEIGHT_HEADER_LEN + weights.len() * 4);
buf.extend_from_slice(WEIGHT_MAGIC);
buf.extend_from_slice(&(weights.len() as u32).to_le_bytes());
for v in &weights {
buf.extend_from_slice(&v.to_le_bytes());
}
std::fs::write(path, buf)
}
/// Load weights previously written by [`Self::save_weights`] (or any file in
/// that format) into this extractor, replacing the current (random-init or
/// prior) weights.
///
/// Errors (never panics) on: unreadable file, a payload shorter than the
/// header, a wrong magic, a truncated/oversized payload, or a parameter
/// count that does not match this extractor's architecture (delegated to
/// [`Self::unflatten_weights`]).
pub fn load_weights<P: AsRef<std::path::Path>>(&mut self, path: P) -> Result<(), String> {
let bytes = std::fs::read(path).map_err(|e| format!("failed to read weight file: {e}"))?;
if bytes.len() < WEIGHT_HEADER_LEN {
return Err(format!(
"weight file too short: {} bytes < {WEIGHT_HEADER_LEN}-byte header",
bytes.len()
));
}
if &bytes[0..8] != WEIGHT_MAGIC {
return Err("bad magic: not an AETHER weight file (expected 'AETHERW1')".to_string());
}
let count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
let expected_len = WEIGHT_HEADER_LEN + count * 4;
if bytes.len() != expected_len {
return Err(format!(
"weight payload size mismatch: header declares {count} params ({expected_len} bytes), file is {} bytes",
bytes.len()
));
}
let mut weights = Vec::with_capacity(count);
for i in 0..count {
let o = WEIGHT_HEADER_LEN + i * 4;
weights.push(f32::from_le_bytes([
bytes[o],
bytes[o + 1],
bytes[o + 2],
bytes[o + 3],
]));
}
self.unflatten_weights(&weights)
}
}
/// Magic prefix for AETHER weight files (see [`EmbeddingExtractor::save_weights`]).
const WEIGHT_MAGIC: &[u8; 8] = b"AETHERW1";
/// 8-byte magic + 4-byte `u32` param count.
const WEIGHT_HEADER_LEN: usize = 12;
// ── CSI feature statistics ─────────────────────────────────────────────────
/// Compute mean and variance of all values in a CSI feature matrix.
@@ -1219,6 +1284,84 @@ mod tests {
}
}
// ── Weight save/load (ADR-185 §13.a) ────────────────────────────────
/// Deterministic, non-random weight pattern. Values are `k/65536 - 0.5`
/// with `k ∈ [0, 65535]`, i.e. multiples of 2⁻¹⁶ — exactly representable
/// in both f32 and f64 so a cross-language (Rust ↔ Python) fixture using
/// the same formula produces byte-identical weights.
fn deterministic_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()
}
#[test]
fn load_weights_actually_replaces_weights_and_round_trips() {
let mut ext = EmbeddingExtractor::new(small_config(), small_embed_config());
let csi = make_csi(4, 16, 42);
let baseline = ext.extract(&csi); // random Xavier init
// Build a source extractor with deterministic non-default weights and
// serialize it.
let det = deterministic_weights(ext.param_count());
let mut src = EmbeddingExtractor::new(small_config(), small_embed_config());
src.unflatten_weights(&det).unwrap();
let src_emb = src.extract(&csi);
let path = std::env::temp_dir()
.join(format!("aether_wtest_{}_{:p}.bin", std::process::id(), &ext));
src.save_weights(&path).unwrap();
// Load into the random-init extractor.
ext.load_weights(&path).unwrap();
let loaded_emb = ext.extract(&csi);
// (1) The loaded weights are ACTUALLY used — output moved away from the
// random-init baseline (proves load is not a silent no-op).
let differs = baseline
.iter()
.zip(&loaded_emb)
.any(|(a, b)| (a - b).abs() > 1e-6);
assert!(
differs,
"load_weights had no effect: embedding still equals the random-init baseline"
);
// (2) It matches the source extractor whose weights we saved (round-trip).
for (a, b) in src_emb.iter().zip(&loaded_emb) {
assert!((a - b).abs() < 1e-6, "loaded embedding != source: {a} vs {b}");
}
// (3) The weights are bit-identical after the file round-trip.
assert_eq!(ext.flatten_weights(), det);
std::fs::remove_file(&path).ok();
}
#[test]
fn load_weights_rejects_bad_magic_and_wrong_count() {
let mut ext = EmbeddingExtractor::new(small_config(), small_embed_config());
let base = std::env::temp_dir().join(format!("aether_wbad_{}.bin", std::process::id()));
// Bad magic.
std::fs::write(&base, b"NOPEMAGIC\x00\x00\x00").unwrap();
assert!(ext.load_weights(&base).is_err());
// Right magic, wrong param count for this architecture.
let mut bad = Vec::new();
bad.extend_from_slice(WEIGHT_MAGIC);
bad.extend_from_slice(&3u32.to_le_bytes());
bad.extend_from_slice(&[0u8; 12]); // 3 f32s — won't match param_count
std::fs::write(&base, &bad).unwrap();
assert!(ext.load_weights(&base).is_err());
std::fs::remove_file(&base).ok();
}
// ── FingerprintIndex tests ──────────────────────────────────────────
#[test]
@@ -0,0 +1,28 @@
//! AETHER pure-compute stack (ADR-024 / ADR-185 §3.2).
//!
//! This crate is the dependency-free leaf hoisted out of
//! `wifi-densepose-sensing-server` so that the Python `wifi_densepose[aether]`
//! wheel can bind the contrastive-embedding surface without linking the server's
//! Axum / tokio / worldgraph / ruvector tree (which blew the ADR-117 §5.4 ≤5 MB
//! wheel budget).
//!
//! Modules:
//! - [`embedding`] — AETHER contrastive CSI embedding: `EmbeddingConfig`,
//! `EmbeddingExtractor`, `ProjectionHead`, `CsiAugmenter`, `info_nce_loss`,
//! fingerprint indices.
//! - [`graph_transformer`] — CSI-to-pose transformer primitives
//! (`CsiToPoseTransformer`, `TransformerConfig`, `Linear`).
//! - [`sona`] — self-organizing drift detection + LoRA adaptation + EWC.
//! - [`sparse_inference`] — quantization helpers used by the embedding path.
//!
//! `wifi-densepose-sensing-server` re-exports these modules so its own code and
//! public API are unchanged.
// `embedding` carries a couple of not-yet-read fields (e.g. `PoseEncoder.d_proj`);
// this mirrors the `#[allow(dead_code)]` the module had at its previous home in
// `wifi-densepose-sensing-server`.
#[allow(dead_code)]
pub mod embedding;
pub mod graph_transformer;
pub mod sona;
pub mod sparse_inference;
@@ -0,0 +1,115 @@
//! ADR-185 §4.1 — NATIVE half of the AETHER parity gate, and the half that runs
//! in CI.
//!
//! The committed golden vectors live under `python/tests/golden/` and are
//! shared with `python/tests/test_aether.py` (the binding half). That pytest
//! runs in python-ci; the native reference tests in `python/tests/*.rs` link
//! against the PyO3 crate and are NOT run by any workflow. This test closes that
//! gap: it recomputes the embedding through THIS std-only crate — no PyO3, no
//! marshalling — and asserts it matches the same golden within tolerance.
//!
//! Together with the pytest half: native≈golden AND binding≈golden ⇒
//! binding≈native, portably. And because this side has no marshalling, a
//! binding-specific defect baked into the golden would surface here as a native
//! mismatch — which is the failure mode a binding-derived golden + pytest-only
//! CI would otherwise hide.
//!
//! Runs under the repo's `cargo test --workspace` (this crate is a member).
use std::path::PathBuf;
use wifi_densepose_aether::embedding::{EmbeddingConfig, EmbeddingExtractor};
use wifi_densepose_aether::graph_transformer::TransformerConfig;
// Same tolerance as the Python and .rs parity tests. f32 + transcendentals are
// not bit-reproducible across arch, so the golden (generated on one machine) is
// compared within a bound, not by hash.
const PARITY_ATOL: f32 = 1e-4;
const PARITY_RTOL: f32 = 1e-4;
fn golden_dir() -> PathBuf {
// This crate lives at v2/crates/wifi-densepose-aether; the shared golden
// fixtures are the single source of truth under python/tests/golden.
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../python/tests/golden")
}
fn read_vec(name: &str) -> Vec<f32> {
let raw = std::fs::read_to_string(golden_dir().join(name))
.unwrap_or_else(|e| panic!("read {name}: {e}"));
serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {name}: {e}"))
}
fn load_input() -> Vec<Vec<f32>> {
let raw = std::fs::read_to_string(golden_dir().join("aether_input.json"))
.expect("read aether_input.json");
let rows: Vec<Vec<f64>> = serde_json::from_str(&raw).expect("parse aether_input.json");
rows.into_iter()
.map(|r| r.into_iter().map(|x| x as f32).collect())
.collect()
}
fn extractor() -> EmbeddingExtractor {
let e = EmbeddingConfig { d_model: 64, d_proj: 128, temperature: 0.07, normalize: true };
let t = TransformerConfig {
n_subcarriers: 56,
n_keypoints: 17,
d_model: 64,
n_heads: 4,
n_gnn_layers: 2,
};
EmbeddingExtractor::new(t, e)
}
fn formula_weights(n: usize) -> Vec<f32> {
(0..n)
.map(|i| ((i as u64 * 1103515245 + 12345) % 65536) as f32 / 65536.0 - 0.5)
.collect()
}
fn assert_matches_golden(embedding: &[f32], name: &str) {
let golden = read_vec(name);
assert_eq!(embedding.len(), golden.len(), "{name}: length mismatch");
for (i, (&a, &b)) in embedding.iter().zip(&golden).enumerate() {
assert!(a.is_finite(), "{name}: element {i} is not finite ({a})");
let tol = PARITY_ATOL + PARITY_RTOL * b.abs();
assert!(
(a - b).abs() <= tol,
"{name}: element {i} diverged beyond tolerance \
(got {a}, golden {b}, |Δ|={}) real regression, not arch drift",
(a - b).abs()
);
}
}
#[test]
fn native_base_embedding_matches_committed_golden() {
let emb = extractor().extract(&load_input());
assert_matches_golden(&emb, "aether_embedding.json");
}
#[test]
fn native_loaded_embedding_matches_committed_golden() {
let input = load_input();
let mut ext = extractor();
let baseline = ext.extract(&input);
let weights = formula_weights(ext.param_count());
let mut buf = Vec::new();
buf.extend_from_slice(b"AETHERW1");
buf.extend_from_slice(&(weights.len() as u32).to_le_bytes());
for w in &weights {
buf.extend_from_slice(&w.to_le_bytes());
}
let wpath = std::env::temp_dir().join(format!("aether_golden_parity_{}.bin", std::process::id()));
std::fs::write(&wpath, &buf).expect("write weights");
ext.load_weights(&wpath).expect("load_weights");
let _ = std::fs::remove_file(&wpath);
let loaded = ext.extract(&input);
assert!(
baseline.iter().zip(&loaded).any(|(a, b)| (a - b).abs() > 1e-6),
"load_weights had no effect vs the random-init baseline"
);
assert_matches_golden(&loaded, "aether_loaded_embedding.json");
}
+18 -4
View File
@@ -12,15 +12,25 @@ categories = ["science", "algorithms"]
readme = "README.md"
[features]
default = ["std", "api", "ruvector"]
default = ["std", "api", "ruvector", "ml"]
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
std = []
# ONNX-backed ML detection (debris + vital-signs classifiers). Pulls
# `wifi-densepose-nn` (and, via its default `onnx` feature, the `ort`
# ONNX Runtime + its download/reqwest stack) ONLY when enabled. The
# survivor-detection/triage pipeline works without it (ML is an optional
# enhancement, off unless `DetectionConfig::enable_ml`), so consumers that
# don't need ONNX — e.g. the ADR-185 `wifi-densepose-py` `[mat]` wheel —
# build `--no-default-features` and drop the entire ort/reqwest tree,
# keeping the wheel within the ADR-117 §5.4 budget.
ml = ["dep:wifi-densepose-nn"]
# REST/WebSocket surface. Pulls the web stack (axum, futures-util) only when
# enabled, and enables the `serde` FEATURE (not just `dep:serde`) so the
# `cfg_attr(feature = "serde", ...)` derives on domain types are actually
# active when the API is on (review finding 5: `api = ["dep:serde"]` enabled
# the dependency but left every `feature = "serde"` cfg dead).
api = ["serde", "dep:axum", "dep:futures-util"]
# The REST surface exposes ML status (`ml_ready`), so `api` implies `ml`.
api = ["ml", "serde", "dep:axum", "dep:futures-util"]
# Real ESP32 serial CSI ingest. Pulls the native `serialport` crate (libudev on
# Linux) only when enabled, so the default/no-default appliance build stays free
# of native serial deps. With the feature OFF, the ESP32 serial *parser* still
@@ -36,14 +46,18 @@ serde = ["dep:serde", "chrono/serde", "geo/use-serde"]
# Workspace dependencies
wifi-densepose-core = { version = "0.3.0", path = "../wifi-densepose-core" }
wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false }
wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn" }
wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn", optional = true }
ruvector-solver = { workspace = true, optional = true }
ruvector-temporal-tensor = { workspace = true, optional = true }
# Async runtime — required by the core integration layer (UDP CSI receiver,
# hardware adapter, scan loop in `DisasterResponse::start_scanning`), not just
# the REST API, so it is deliberately NOT gated behind `api`.
tokio = { version = "1.35", features = ["rt", "sync", "time"] }
# `macros` is needed by `tokio::select!` in integration/hardware_adapter.rs.
# It was previously satisfied only by feature-unification from the (now
# optional) `wifi-densepose-nn` dep; declare it explicitly so a
# `--no-default-features` build (the ADR-185 [mat] wheel) still compiles.
tokio = { version = "1.35", features = ["rt", "sync", "time", "macros"] }
async-trait = "0.1"
# Web framework (REST API) — only compiled with the `api` feature.
@@ -8,6 +8,7 @@ use super::{
MovementClassifier, MovementClassifierConfig,
};
use crate::domain::{ScanZone, VitalSignsReading};
#[cfg(feature = "ml")]
use crate::ml::{MlDetectionConfig, MlDetectionPipeline, MlDetectionResult};
use crate::{DisasterConfig, MatError};
@@ -26,9 +27,10 @@ pub struct DetectionConfig {
pub enable_heartbeat: bool,
/// Minimum overall confidence to report detection
pub min_confidence: f64,
/// Enable ML-enhanced detection
/// Enable ML-enhanced detection (requires the `ml` feature to have any effect)
pub enable_ml: bool,
/// ML detection configuration (if enabled)
#[cfg(feature = "ml")]
pub ml_config: Option<MlDetectionConfig>,
}
@@ -42,6 +44,7 @@ impl Default for DetectionConfig {
enable_heartbeat: false,
min_confidence: 0.3,
enable_ml: false,
#[cfg(feature = "ml")]
ml_config: None,
}
}
@@ -64,6 +67,7 @@ impl DetectionConfig {
}
/// Enable ML-enhanced detection with the given configuration
#[cfg(feature = "ml")]
pub fn with_ml(mut self, ml_config: MlDetectionConfig) -> Self {
self.enable_ml = true;
self.ml_config = Some(ml_config);
@@ -71,6 +75,7 @@ impl DetectionConfig {
}
/// Enable ML-enhanced detection with default configuration
#[cfg(feature = "ml")]
pub fn with_default_ml(mut self) -> Self {
self.enable_ml = true;
self.ml_config = Some(MlDetectionConfig::default());
@@ -147,12 +152,14 @@ pub struct DetectionPipeline {
movement_classifier: MovementClassifier,
data_buffer: parking_lot::RwLock<CsiDataBuffer>,
/// Optional ML detection pipeline
#[cfg(feature = "ml")]
ml_pipeline: Option<MlDetectionPipeline>,
}
impl DetectionPipeline {
/// Create a new detection pipeline
pub fn new(config: DetectionConfig) -> Self {
#[cfg(feature = "ml")]
let ml_pipeline = if config.enable_ml {
config.ml_config.clone().map(MlDetectionPipeline::new)
} else {
@@ -164,12 +171,14 @@ impl DetectionPipeline {
heartbeat_detector: HeartbeatDetector::new(config.heartbeat.clone()),
movement_classifier: MovementClassifier::new(config.movement.clone()),
data_buffer: parking_lot::RwLock::new(CsiDataBuffer::new(config.sample_rate)),
#[cfg(feature = "ml")]
ml_pipeline,
config,
}
}
/// Initialize ML models asynchronously (if enabled)
#[cfg(feature = "ml")]
pub async fn initialize_ml(&mut self) -> Result<(), MatError> {
if let Some(ref mut ml) = self.ml_pipeline {
ml.initialize().await.map_err(MatError::from)?;
@@ -178,6 +187,7 @@ impl DetectionPipeline {
}
/// Check if ML pipeline is ready
#[cfg(feature = "ml")]
pub fn ml_ready(&self) -> bool {
self.ml_pipeline.as_ref().is_none_or(|ml| ml.is_ready())
}
@@ -210,13 +220,23 @@ impl DetectionPipeline {
// `buffer` guard dropped here
};
// If ML is enabled and ready, enhance with ML predictions
let enhanced_reading = if self.config.enable_ml && self.ml_ready() {
// Snapshot the buffer under the lock, then drop the guard before await.
let buffer_snapshot = { self.data_buffer.read().clone() };
self.enhance_with_ml(reading, &buffer_snapshot).await?
} else {
reading
// If ML is enabled and ready, enhance with ML predictions (only
// compiled under the `ml` feature; the base build is signal-only).
let enhanced_reading = {
#[cfg(feature = "ml")]
{
if self.config.enable_ml && self.ml_ready() {
// Snapshot the buffer under the lock, then drop the guard before await.
let buffer_snapshot = { self.data_buffer.read().clone() };
self.enhance_with_ml(reading, &buffer_snapshot).await?
} else {
reading
}
}
#[cfg(not(feature = "ml"))]
{
reading
}
};
// Check minimum confidence
@@ -230,6 +250,7 @@ impl DetectionPipeline {
}
/// Enhance detection results with ML predictions
#[cfg(feature = "ml")]
async fn enhance_with_ml(
&self,
traditional_reading: Option<VitalSignsReading>,
@@ -262,6 +283,7 @@ impl DetectionPipeline {
}
/// Get the latest ML detection results (if ML is enabled)
#[cfg(feature = "ml")]
pub async fn get_ml_results(&self) -> Option<MlDetectionResult> {
let ml = match &self.ml_pipeline {
Some(ml) => ml,
@@ -346,6 +368,7 @@ impl DetectionPipeline {
self.movement_classifier = MovementClassifier::new(config.movement.clone());
// Update ML pipeline if configuration changed
#[cfg(feature = "ml")]
if config.enable_ml != self.config.enable_ml || config.ml_config != self.config.ml_config {
self.ml_pipeline = if config.enable_ml {
config.ml_config.clone().map(MlDetectionPipeline::new)
@@ -358,6 +381,7 @@ impl DetectionPipeline {
}
/// Get the ML pipeline (if enabled)
#[cfg(feature = "ml")]
pub fn ml_pipeline(&self) -> Option<&MlDetectionPipeline> {
self.ml_pipeline.as_ref()
}
+13 -9
View File
@@ -87,6 +87,10 @@ pub mod detection;
pub mod domain;
pub mod integration;
pub mod localization;
/// ONNX-backed ML detection. Requires the `ml` feature (pulls
/// `wifi-densepose-nn` + `ort`). The core survivor-detection/triage
/// pipeline works without it.
#[cfg(feature = "ml")]
pub mod ml;
pub mod tracking;
@@ -130,6 +134,7 @@ pub use integration::{
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
pub use api::{create_router, AppState};
#[cfg(feature = "ml")]
pub use ml::{
AttenuationPrediction,
BreathingClassification,
@@ -207,6 +212,7 @@ pub enum MatError {
Io(#[from] std::io::Error),
/// Machine learning error
#[cfg(feature = "ml")]
#[error("ML error: {0}")]
Ml(#[from] ml::MlError),
}
@@ -592,8 +598,6 @@ pub mod prelude {
AssociationResult,
BreathingPattern,
Coordinates3D,
DebrisClassification,
DebrisModel,
DetectionEvent,
DetectionObservation,
// Detection
@@ -614,11 +618,6 @@ pub mod prelude {
// Localization
LocalizationService,
MatError,
MaterialType,
// ML types
MlDetectionConfig,
MlDetectionPipeline,
MlDetectionResult,
Priority,
Result,
ScanZone,
@@ -631,12 +630,17 @@ pub mod prelude {
TrackerConfig,
TrackingEvent,
TriageStatus,
UncertaintyEstimate,
VitalSignsClassifier,
VitalSignsDetector,
VitalSignsReading,
ZoneBounds,
};
// ONNX-backed ML types — only when the `ml` feature is enabled.
#[cfg(feature = "ml")]
pub use crate::{
DebrisClassification, DebrisModel, MaterialType, MlDetectionConfig, MlDetectionPipeline,
MlDetectionResult, UncertaintyEstimate, VitalSignsClassifier,
};
}
#[cfg(test)]
@@ -41,6 +41,12 @@ chrono = { version = "0.4", features = ["serde"] }
# CLI
clap = { workspace = true }
# ADR-185 §3.2/§13: AETHER pure-compute stack (embedding / graph_transformer /
# sona / sparse_inference), hoisted into a std-only leaf crate and re-exported
# from `lib.rs` so the Python `[aether]` wheel can bind it without this server's
# Axum/tokio/worldgraph/ruvector tree.
wifi-densepose-aether = { version = "0.3.0", path = "../wifi-densepose-aether" }
# Multi-BSSID WiFi scanning pipeline (ADR-022 Phase 3)
wifi-densepose-wifiscan = { version = "0.3.0", path = "../wifi-densepose-wifiscan" }
@@ -120,6 +126,10 @@ matter = []
tempfile = "3.10"
# `tower::ServiceExt::oneshot` for in-process Router tests (bearer_auth).
tower = { workspace = true }
# ADR-186 P6 — real-socket WebSocket client for the `/ws/train/progress`
# 101-upgrade + live-progress-frame test. Pinned to the version already resolved
# in the workspace lock (via homecore-api) so this adds no new lock entry.
tokio-tungstenite = "0.24"
# ADR-115 P9 — micro-benchmarks for MQTT hot paths + semantic bus.
# Heavy dep tree (~80 transitive crates) so it's dev-only; benches live
# behind --features mqtt because they bench the mqtt module.
@@ -14,10 +14,7 @@ pub mod ws_ticket;
pub mod cli;
pub mod dataset;
pub mod edge_registry;
#[allow(dead_code)]
pub mod embedding;
pub mod error_response;
pub mod graph_transformer;
pub mod host_validation;
pub mod introspection;
pub mod matter;
@@ -31,8 +28,6 @@ pub mod semantic;
pub mod rufield_surface;
pub mod rvf_container;
pub mod rvf_pipeline;
pub mod sona;
pub mod sparse_inference;
#[allow(dead_code)]
pub mod trainer;
pub mod vital_signs;
@@ -44,3 +39,12 @@ pub mod vendor_origin_plume;
pub mod vendor_remaining;
/// ADR-270 provider registry and canonical event helpers.
pub mod vendor_rf;
// ADR-185 §3.2/§13: the AETHER pure-compute stack (contrastive embedding,
// CSI-to-pose transformer, SONA, quantization) was hoisted into the std-only
// `wifi-densepose-aether` leaf crate so the Python `[aether]` wheel can bind it
// without this crate's Axum/tokio/worldgraph/ruvector tree. Re-exported here so
// this crate's own code (`crate::embedding`, `crate::graph_transformer`,
// `crate::sona`) and public API (`wifi_densepose_sensing_server::embedding`, …)
// are unchanged.
pub use wifi_densepose_aether::{embedding, graph_transformer, sona, sparse_inference};
@@ -20,8 +20,14 @@ mod multistatic_bridge;
mod mediatek_csi;
mod qualcomm_csi;
mod realtek_radar;
mod path_safety;
pub mod pose;
mod rvf_container;
// ADR-186 (TRAIN-RECONNECT): the in-server training pipeline was written but
// never declared as a module, so it was orphaned / uncompiled. Declaring it
// here compiles it against the real `AppStateInner` and wires its `routes()`
// (including `/ws/train/progress`) into the live router below.
mod training_api;
mod rvf_pipeline;
mod tracker_bridge;
pub mod types;
@@ -1120,11 +1126,13 @@ struct AppStateInner {
recording_current_id: Option<String>,
/// Shutdown signal for the recording writer task.
recording_stop_tx: Option<tokio::sync::watch::Sender<bool>>,
// ── Training fields ─────────────────────────────────────────────────────
/// Training status: "idle", "running", "completed", "failed".
training_status: String,
/// Training configuration, if any.
training_config: Option<serde_json::Value>,
// ── Training fields (ADR-186 TRAIN-RECONNECT) ────────────────────────────
/// Live training state (shared status snapshot + cooperative cancel flag +
/// background task handle) for the in-server trainer in `training_api`.
training_state: training_api::TrainingState,
/// Fan-out channel the background training job publishes progress JSON to;
/// the `/ws/train/progress` WebSocket handler subscribes to it.
training_progress_tx: broadcast::Sender<String>,
// ── Adaptive classifier (environment-tuned) ──────────────────────────
/// Trained adaptive model (loaded from data/adaptive_model.json or trained at runtime).
adaptive_model: Option<adaptive_classifier::AdaptiveModel>,
@@ -1248,6 +1256,87 @@ const FRAME_HISTORY_CAPACITY: usize = 100;
type SharedState = Arc<RwLock<AppStateInner>>;
#[cfg(test)]
impl AppStateInner {
/// Minimal, dependency-free `AppStateInner` for in-process router tests
/// (ADR-186 P6). Uses the same field constructors as the real state seeding
/// in `main()` but with trivial values and no CLI/config inputs, so tests can
/// build the training router without the full server boot.
pub(crate) fn minimal() -> Self {
AppStateInner {
latest_update: None,
rssi_history: VecDeque::new(),
frame_history: VecDeque::new(),
tick: 0,
source: "test".to_string(),
last_esp32_frame: None,
latest_realtek_radar: None,
last_realtek_frame: None,
latest_mediatek_csi: None,
last_mediatek_frame: None,
latest_qualcomm_csi: None,
last_qualcomm_frame: None,
latest_vendor_rf: BTreeMap::new(),
tx: broadcast::channel::<String>(16).0,
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
intro_tx: broadcast::channel::<String>(16).0,
total_detections: 0,
start_time: std::time::Instant::now(),
vital_detector: VitalSignDetector::new(10.0),
latest_vitals: VitalSigns::default(),
rvf_info: None,
save_rvf_path: None,
progressive_loader: None,
active_sona_profile: None,
model_loaded: false,
smoothed_person_score: 0.0,
prev_person_count: 0,
smoothed_motion: 0.0,
current_motion_level: "absent".to_string(),
debounce_counter: 0,
debounce_candidate: "absent".to_string(),
baseline_motion: 0.0,
baseline_frames: 0,
smoothed_hr: 0.0,
smoothed_br: 0.0,
smoothed_hr_conf: 0.0,
smoothed_br_conf: 0.0,
hr_buffer: VecDeque::with_capacity(8),
br_buffer: VecDeque::with_capacity(8),
edge_vitals: None,
latest_wasm_events: None,
discovered_models: Vec::new(),
active_model_id: None,
recordings: Vec::new(),
recording_active: false,
recording_start_time: None,
recording_current_id: None,
recording_stop_tx: None,
training_state: training_api::TrainingState::default(),
training_progress_tx: broadcast::channel::<String>(256).0,
adaptive_model: None,
node_states: HashMap::new(),
pose_tracker: PoseTracker::new(),
last_tracker_instant: None,
multistatic_fuser: MultistaticFuser::new(),
engine_bridge: engine_bridge::EngineBridge::new(
wifi_densepose_bfld::PrivacyMode::PrivateHome,
1,
"default",
"Default Room",
None,
),
field_model: None,
p95_variance: RollingP95::new(600, 60),
p95_motion_band_power: RollingP95::new(600, 60),
p95_spectral_power: RollingP95::new(600, 60),
dedup_factor: 3.0,
data_dir: std::path::PathBuf::from("data"),
field_surface: Arc::new(RwLock::new(rufield_surface::FieldSurface::from_env())),
}
}
}
// ── ESP32 Edge Vitals Packet (ADR-039, magic 0xC511_0002) ────────────────────
/// Decoded vitals packet from ESP32 edge processing pipeline.
@@ -4973,54 +5062,12 @@ fn scan_recording_files() -> Vec<serde_json::Value> {
}
// ── Training Endpoints ──────────────────────────────────────────────────────
/// GET /api/v1/train/status — get training status.
async fn train_status(State(state): State<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
Json(serde_json::json!({
"status": s.training_status,
"config": s.training_config,
}))
}
/// POST /api/v1/train/start — start a training run.
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" {
return Json(serde_json::json!({
"error": "training already running",
"success": false,
}));
}
s.training_status = "running".to_string();
s.training_config = Some(body.clone());
info!("Training started with config: {}", body);
Json(serde_json::json!({
"success": true,
"status": "running",
"message": "Training pipeline started. Use GET /api/v1/train/status to monitor.",
}))
}
/// POST /api/v1/train/stop — stop the current training run.
async fn train_stop(State(state): State<SharedState>) -> Json<serde_json::Value> {
let mut s = state.write().await;
if s.training_status != "running" {
return Json(serde_json::json!({
"error": "no training in progress",
"success": false,
}));
}
s.training_status = "idle".to_string();
info!("Training stopped");
Json(serde_json::json!({
"success": true,
"status": "idle",
}))
}
//
// ADR-186 (TRAIN-RECONNECT): the former stub handlers here flipped a status
// string and logged one line without ever starting a job (issue #1233). They
// are replaced by the real `training_api` router, merged into the app below,
// which runs the pure-Rust trainer on a background task and streams live
// progress over `/ws/train/progress`.
// ── Adaptive classifier endpoints ────────────────────────────────────────────
@@ -7826,9 +7873,9 @@ async fn main() {
recording_start_time: None,
recording_current_id: None,
recording_stop_tx: None,
// Training
training_status: "idle".to_string(),
training_config: None,
// Training (ADR-186 TRAIN-RECONNECT)
training_state: training_api::TrainingState::default(),
training_progress_tx: broadcast::channel::<String>(256).0,
adaptive_model:
adaptive_classifier::AdaptiveModel::load(&adaptive_classifier::model_path())
.ok()
@@ -8117,10 +8164,12 @@ async fn main() {
.route("/api/v1/recording/start", post(start_recording))
.route("/api/v1/recording/stop", post(stop_recording))
.route("/api/v1/recording/{id}", delete(delete_recording))
// 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))
// Training endpoints (ADR-186 TRAIN-RECONNECT): the real in-server
// trainer + `/ws/train/progress` stream. Merged while the router is
// still `Router<SharedState>` (before `.with_state`) so these routes
// share `AppStateInner` and `/api/v1/train/*` sits under the bearer gate
// applied below (like the rest of `/api/v1/*`).
.merge(training_api::routes())
// Adaptive classifier endpoints
.route("/api/v1/adaptive/train", post(adaptive_train))
.route("/api/v1/adaptive/status", get(adaptive_status))
@@ -9369,3 +9418,256 @@ async fn oauth_status(
"scope": session.as_ref().map(|s| s.scope.clone()),
}))
}
#[cfg(test)]
mod adr186_http_tests {
//! ADR-186 P6: HTTP-level tests that build the real `training_api` router
//! and drive it in-process, guarding against the module being orphaned again
//! (`training_api::routes()` cannot compile unless the module is declared).
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
/// Serializes tests that read/toggle the process-global
/// `RUVIEW_DISABLE_SERVER_TRAINING` env var, so the disabled-path test cannot
/// flip enablement while an enabled-path test is mid-request.
static TRAIN_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn test_state() -> SharedState {
Arc::new(RwLock::new(AppStateInner::minimal()))
}
/// The `/ws/train/progress` route is registered and reaches the WebSocket
/// handler (issue #1233 was a 404). Over `oneshot` there is no real socket to
/// upgrade, so axum returns 426 Upgrade Required — which still distinguishes a
/// wired WS endpoint (426) from an orphaned/absent route (404). The genuine
/// 101 handshake is asserted by `ws_train_progress_live_101_and_frame`.
#[tokio::test]
async fn ws_train_progress_route_is_wired_not_404() {
let app = training_api::routes().with_state(test_state());
let req = Request::builder()
.uri("/ws/train/progress")
.header("connection", "upgrade")
.header("upgrade", "websocket")
.header("sec-websocket-version", "13")
.header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_ne!(resp.status(), StatusCode::NOT_FOUND, "route must not 404");
assert_eq!(
resp.status(),
StatusCode::UPGRADE_REQUIRED,
"a wired WS route returns 426 under oneshot — got {}",
resp.status()
);
}
/// ADR-186 §7 acceptance: over a real socket, `/ws/train/progress` completes a
/// genuine 101 WebSocket handshake and, after a `POST /api/v1/train/start`,
/// delivers at least one real `progress` frame to the connected client.
#[tokio::test]
async fn ws_train_progress_live_101_and_frame() {
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
use tokio_tungstenite::tungstenite::Message as TMsg;
let _env_lock = TRAIN_ENV_LOCK.lock().unwrap(); // enablement must stay ON
let shared = test_state();
{
let mut s = shared.write().await;
for i in 0..40 {
let sub: Vec<f64> = (0..56)
.map(|k| 10.0 + ((i as f64) * 0.3 + (k as f64) * 0.1).sin() * 2.0)
.collect();
s.frame_history.push_back(sub);
}
}
// Serve the training router on an ephemeral port.
let app = training_api::routes().with_state(shared.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
// A successful `connect_async` IS the 101 handshake (it errors otherwise).
let (mut ws, resp) =
tokio_tungstenite::connect_async(format!("ws://{addr}/ws/train/progress"))
.await
.expect("WebSocket handshake should succeed (101)");
assert_eq!(resp.status().as_u16(), 101, "handshake must be 101");
// Drive training via a real HTTP POST over a fresh TCP connection.
let body = r#"{"dataset_ids":[],"config":{"epochs":3,"batch_size":8,"warmup_epochs":1,"early_stopping_patience":10}}"#;
let req = format!(
"POST /api/v1/train/start HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let mut post = tokio::net::TcpStream::connect(addr).await.unwrap();
post.write_all(req.as_bytes()).await.unwrap();
post.flush().await.unwrap();
// Read WS frames until a `progress` frame arrives (or a 10s ceiling).
let mut got_progress = false;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
while tokio::time::Instant::now() < deadline {
match tokio::time::timeout(std::time::Duration::from_secs(2), ws.next()).await {
Ok(Some(Ok(TMsg::Text(txt)))) => {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) {
if v.get("type").and_then(|t| t.as_str()) == Some("progress") {
got_progress = true;
break;
}
}
}
Ok(Some(Ok(_))) => {}
Ok(Some(Err(_))) | Ok(None) => break,
Err(_) => {}
}
}
assert!(
got_progress,
"should receive a real progress frame over the live WS after POST start"
);
// NOTE: deliberately no directory-diff cleanup here. `data/models` is
// gitignored, and deleting by dir-diff would race concurrent model-writing
// tests (it could remove a `.rvf` another test is asserting exists).
}
/// Full HTTP round-trip: POST /api/v1/train/start → poll /api/v1/train/status
/// until completion → a real `.rvf` model artifact exists on disk, and real
/// progress frames were streamed on the broadcast channel.
#[tokio::test]
async fn http_train_start_produces_model_and_streams() {
let _env_lock = TRAIN_ENV_LOCK.lock().unwrap(); // enablement must stay ON
let shared = test_state();
// Seed synthetic frames so training's fallback path has data (no files).
{
let mut s = shared.write().await;
for i in 0..40 {
let sub: Vec<f64> = (0..56)
.map(|k| 10.0 + ((i as f64) * 0.3 + (k as f64) * 0.1).sin() * 2.0)
.collect();
s.frame_history.push_back(sub);
}
}
let mut progress_rx = {
let s = shared.read().await;
s.training_progress_tx.subscribe()
};
let models_dir = std::path::PathBuf::from(training_api::MODELS_DIR);
let before: std::collections::HashSet<std::path::PathBuf> = std::fs::read_dir(&models_dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.collect();
let app = training_api::routes().with_state(shared.clone());
// POST start.
let body = serde_json::json!({
"dataset_ids": [],
"config": {"epochs": 3, "batch_size": 8, "warmup_epochs": 1, "early_stopping_patience": 10}
});
let req = Request::builder()
.method("POST")
.uri("/api/v1/train/start")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "start should be accepted");
// Poll status until the job reports completion.
let mut completed = false;
for _ in 0..250 {
let req = Request::builder()
.uri("/api/v1/train/status")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
let bytes = axum::body::to_bytes(resp.into_body(), 65536).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
// Status also carries the P5 enablement flag.
assert_eq!(v.get("enabled"), Some(&serde_json::Value::Bool(true)));
if v.get("active") == Some(&serde_json::Value::Bool(false))
&& v.get("phase").and_then(|p| p.as_str()) == Some("completed")
{
completed = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(completed, "training should reach the completed phase");
// Real progress frames were streamed.
let mut saw_progress = false;
while progress_rx.try_recv().is_ok() {
saw_progress = true;
}
assert!(saw_progress, "expected streamed progress frames over the WS channel");
// A new .rvf artifact was written by the run.
let after: std::collections::HashSet<std::path::PathBuf> = std::fs::read_dir(&models_dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.collect();
let new_models: Vec<_> = after
.difference(&before)
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("rvf"))
.cloned()
.collect();
assert!(
!new_models.is_empty(),
"training should write a new .rvf model artifact under {}",
models_dir.display()
);
// No deletion here: removing by dir-diff would race concurrent
// model-writing tests. `data/models` is gitignored.
}
/// P5 fallback guarantee: with server training disabled, POST start returns a
/// structured `{enabled:false, cli:...}` 409 — never a silent success.
#[tokio::test]
async fn http_train_start_disabled_returns_structured_409() {
// Serialize against the enabled-path tests so our env toggle can't race
// their in-flight requests.
let _env_lock = TRAIN_ENV_LOCK.lock().unwrap();
std::env::set_var("RUVIEW_DISABLE_SERVER_TRAINING", "1");
let app = training_api::routes().with_state(test_state());
let body = serde_json::json!({"dataset_ids": [], "config": {"epochs": 1}});
let req = Request::builder()
.method("POST")
.uri("/api/v1/train/start")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 65536).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
std::env::remove_var("RUVIEW_DISABLE_SERVER_TRAINING");
assert_eq!(status, StatusCode::CONFLICT, "disabled start must be 4xx/409");
assert_eq!(v.get("enabled"), Some(&serde_json::Value::Bool(false)));
assert_eq!(
v.get("cli").and_then(|c| c.as_str()),
Some("wifi-densepose train-room"),
"must point at the CLI fallback, never a silent success"
);
assert_ne!(
v.get("success"),
Some(&serde_json::Value::Bool(true)),
"must never claim success:true when disabled"
);
}
}
@@ -26,22 +26,23 @@
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::{IntoResponse, Json},
http::StatusCode,
response::{IntoResponse, Json, Response},
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, RwLock};
use tokio::sync::broadcast;
use tracing::{error, info, warn};
use crate::recording::{RecordedFrame, RECORDINGS_DIR};
use crate::rvf_container::RvfBuilder;
// ── Constants ────────────────────────────────────────────────────────────────
@@ -49,6 +50,28 @@ use crate::rvf_container::RvfBuilder;
/// Directory for trained model output.
pub const MODELS_DIR: &str = "data/models";
/// Directory the training loop reads recorded CSI datasets from. Each
/// `dataset_id` maps to `{RECORDINGS_DIR}/{dataset_id}.csi.jsonl`.
pub const RECORDINGS_DIR: &str = "data/recordings";
/// Monotonic per-process counter appended to exported model filenames so two
/// runs that complete in the same wall-clock microsecond still get distinct
/// paths (prevents silent overwrite; keeps concurrent runs from colliding).
static MODEL_ID_SEQ: AtomicU64 = AtomicU64::new(0);
/// Build a process-unique model id `trained-{type}-{ts_micros}-{seq}`. A
/// second-resolution timestamp alone collided for runs finishing in the same
/// second (silent overwrite); microseconds + the monotonic counter guarantee
/// uniqueness even for same-microsecond concurrent completions.
fn next_model_id(training_type: &str) -> String {
format!(
"trained-{}-{}-{}",
training_type,
chrono::Utc::now().format("%Y%m%d_%H%M%S_%6f"),
MODEL_ID_SEQ.fetch_add(1, Ordering::Relaxed)
)
}
/// Number of COCO keypoints.
const N_KEYPOINTS: usize = 17;
/// Dimensions per keypoint in the target vector (x, y, z).
@@ -67,6 +90,25 @@ const N_GLOBAL_FEATURES: usize = 3;
// ── Types ────────────────────────────────────────────────────────────────────
/// A single recorded CSI frame line, as stored in the `.csi.jsonl` datasets the
/// training loop consumes.
///
/// This mirrors the on-disk JSONL schema and is intentionally self-contained so
/// the trainer does not couple to the (separate, orphaned) `recording.rs`
/// module. Only the fields the feature extractor needs are read; `rssi` /
/// `noise_floor` / `features` are carried for schema fidelity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordedFrame {
pub timestamp: f64,
pub subcarriers: Vec<f64>,
#[serde(default)]
pub rssi: f64,
#[serde(default)]
pub noise_floor: f64,
#[serde(default)]
pub features: serde_json::Value,
}
/// Training configuration submitted with a start request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingConfig {
@@ -229,24 +271,45 @@ pub struct TrainingProgress {
}
/// Runtime training state stored in `AppStateInner`.
///
/// `status` and `cancel` are shared handles (not owned snapshots) so the
/// background training job can update progress and observe stop requests
/// **without holding a reference to the full `AppStateInner`**. That decoupling
/// is what makes the training core ([`run_training_job`]) unit-testable in
/// isolation from the ~60-field server state.
pub struct TrainingState {
/// Current status snapshot.
pub status: TrainingStatus,
/// Handle to the background training task (for cancellation).
/// Live status snapshot, shared with the running training job.
pub status: Arc<Mutex<TrainingStatus>>,
/// Cooperative stop flag; `stop_training` sets it and the job loop observes it.
pub cancel: Arc<AtomicBool>,
/// Handle to the background training task.
pub task_handle: Option<tokio::task::JoinHandle<()>>,
}
impl Default for TrainingState {
fn default() -> Self {
Self {
status: TrainingStatus::default(),
status: Arc::new(Mutex::new(TrainingStatus::default())),
cancel: Arc::new(AtomicBool::new(false)),
task_handle: None,
}
}
}
impl TrainingState {
/// Clone of the current status snapshot.
pub fn snapshot(&self) -> TrainingStatus {
self.status.lock().unwrap().clone()
}
/// Whether a training job is currently active.
pub fn is_active(&self) -> bool {
self.status.lock().unwrap().active
}
}
/// Shared application state type.
pub type AppState = Arc<RwLock<super::AppStateInner>>;
pub type AppState = Arc<tokio::sync::RwLock<super::AppStateInner>>;
/// Feature normalization statistics computed from the training set.
/// Stored alongside the model weights inside the .rvf container so that
@@ -317,11 +380,11 @@ async fn load_recording_frames(dataset_ids: &[String]) -> Vec<RecordedFrame> {
all_frames
}
/// Attempt to collect frames from the live frame_history buffer in AppState.
/// Each `Vec<f64>` in frame_history is a subcarrier amplitude vector.
async fn load_frames_from_history(state: &AppState) -> Vec<RecordedFrame> {
let s = state.read().await;
let history: &VecDeque<Vec<f64>> = &s.frame_history;
/// Build fallback training frames from a snapshot of the live `frame_history`
/// buffer. Each `Vec<f64>` is one frame's subcarrier amplitude vector. Passed as
/// an owned snapshot (not a live `AppState` borrow) so the training core stays
/// state-free and independently testable.
fn frames_from_history(history: &[Vec<f64>]) -> Vec<RecordedFrame> {
history
.iter()
.enumerate()
@@ -938,13 +1001,15 @@ fn deterministic_shuffle(n: usize, seed: u64) -> Vec<usize> {
/// linear model via mini-batch gradient descent.
///
/// On completion, exports a `.rvf` container with real calibrated weights.
async fn real_training_loop(
state: AppState,
async fn run_training_job(
status: Arc<Mutex<TrainingStatus>>,
cancel: Arc<AtomicBool>,
progress_tx: broadcast::Sender<String>,
config: TrainingConfig,
dataset_ids: Vec<String>,
history_snapshot: Vec<Vec<f64>>,
training_type: &str,
) {
) -> Option<PathBuf> {
let total_epochs = config.epochs;
let patience = config.early_stopping_patience;
let mut best_pck = 0.0f64;
@@ -978,7 +1043,7 @@ async fn real_training_loop(
let mut frames = load_recording_frames(&dataset_ids).await;
if frames.is_empty() {
info!("No recordings found for dataset_ids; falling back to live frame_history");
frames = load_frames_from_history(&state).await;
frames = frames_from_history(&history_snapshot);
}
if frames.len() < 10 {
@@ -999,11 +1064,12 @@ async fn real_training_loop(
if let Ok(json) = serde_json::to_string(&fail) {
let _ = progress_tx.send(json);
}
let mut s = state.write().await;
s.training_state.status.active = false;
s.training_state.status.phase = "failed".to_string();
s.training_state.task_handle = None;
return;
{
let mut st = status.lock().unwrap();
st.active = false;
st.phase = "failed".to_string();
}
return None;
}
info!("Loaded {} frames for training", frames.len());
@@ -1079,13 +1145,10 @@ async fn real_training_loop(
// ── Phase 5: Training loop ───────────────────────────────────────────────
for epoch in 1..=total_epochs {
// Check cancellation.
{
let s = state.read().await;
if !s.training_state.status.active {
info!("Training cancelled at epoch {epoch}");
break;
}
// Check cancellation (cooperative stop flag set by `stop_training`).
if cancel.load(Ordering::Relaxed) {
info!("Training cancelled at epoch {epoch}");
break;
}
let phase = if epoch <= config.warmup_epochs {
@@ -1245,10 +1308,10 @@ async fn real_training_loop(
let remaining = total_epochs.saturating_sub(epoch);
let eta_secs = (remaining as f64 * secs_per_epoch) as u64;
// Update shared state.
// Update the shared status snapshot (read by GET /api/v1/train/status).
{
let mut s = state.write().await;
s.training_state.status = TrainingStatus {
let mut st = status.lock().unwrap();
*st = TrainingStatus {
active: true,
epoch,
total_epochs,
@@ -1297,15 +1360,12 @@ async fn real_training_loop(
// ── Phase 6: Export .rvf model ───────────────────────────────────────────
let completed_phase;
{
let s = state.read().await;
completed_phase = if s.training_state.status.active {
"completed"
} else {
"cancelled"
};
}
let completed_phase = if cancel.load(Ordering::Relaxed) {
"cancelled"
} else {
"completed"
};
let mut written_rvf: Option<PathBuf> = None;
// Emit completion message.
let completion = TrainingProgress {
@@ -1326,11 +1386,7 @@ async fn real_training_loop(
if let Err(e) = tokio::fs::create_dir_all(MODELS_DIR).await {
error!("Failed to create models directory: {e}");
} else {
let model_id = format!(
"trained-{}-{}",
training_type,
chrono::Utc::now().format("%Y%m%d_%H%M%S")
);
let model_id = next_model_id(training_type);
let rvf_path = PathBuf::from(MODELS_DIR).join(format!("{model_id}.rvf"));
let mut builder = RvfBuilder::new();
@@ -1407,28 +1463,32 @@ async fn real_training_loop(
}),
);
if let Err(e) = builder.write_to_file(&rvf_path) {
error!("Failed to write trained model RVF: {e}");
} else {
info!(
"Trained model saved: {} ({} params, pck_torso_h@0.2={:.4})",
rvf_path.display(),
total_params,
best_pck
);
match builder.write_to_file(&rvf_path) {
Err(e) => {
error!("Failed to write trained model RVF: {e}");
}
Ok(()) => {
info!(
"Trained model saved: {} ({} params, pck_torso_h@0.2={:.4})",
rvf_path.display(),
total_params,
best_pck
);
written_rvf = Some(rvf_path);
}
}
}
}
// Mark training as inactive.
// Mark training as inactive in the shared status snapshot.
{
let mut s = state.write().await;
s.training_state.status.active = false;
s.training_state.status.phase = completed_phase.to_string();
s.training_state.task_handle = None;
let mut st = status.lock().unwrap();
st.active = false;
st.phase = completed_phase.to_string();
}
info!("Real {training_type} training finished: phase={completed_phase}");
written_rvf
}
// ── Public inference function ────────────────────────────────────────────────
@@ -1559,56 +1619,151 @@ fn default_keypoints() -> Vec<[f64; 4]> {
vec![[320.0, 240.0, 0.0, 0.0]; N_KEYPOINTS]
}
// ── Server-training enablement gate (ADR-186 P5) ─────────────────────────────
/// Env var that opts a deployment out of in-server training (e.g. the
/// lightweight appliance image without recordings). When set truthy, the start
/// endpoints return a structured `enabled:false` response pointing at the CLI —
/// never a silent `success:true` no-op.
const DISABLE_ENV: &str = "RUVIEW_DISABLE_SERVER_TRAINING";
/// Whether in-server training is enabled for this deployment.
fn server_training_enabled() -> bool {
training_enabled_from_env(std::env::var(DISABLE_ENV).ok().as_deref())
}
/// Pure decision (unit-testable without touching process env): enabled unless
/// the flag is a truthy disable value.
fn training_enabled_from_env(flag: Option<&str>) -> bool {
match flag {
Some(v) => {
let v = v.trim();
!(v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes"))
}
None => true,
}
}
/// Structured, honest "server training is off for this build — use the CLI"
/// response (HTTP 409). Guarantees no silent no-op in the disabled config.
fn disabled_response() -> Response {
(
StatusCode::CONFLICT,
Json(serde_json::json!({
"status": "error",
"enabled": false,
"reason": "In-server training is disabled for this deployment.",
"cli": "wifi-densepose train-room",
// `detail` is surfaced verbatim by the dashboard's API client.
"detail": "In-server training is disabled on this build. Train from the CLI: wifi-densepose train-room",
})),
)
.into_response()
}
// ── Axum handlers ────────────────────────────────────────────────────────────
async fn start_training(
State(state): State<AppState>,
Json(body): Json<StartTrainingRequest>,
) -> Json<serde_json::Value> {
// Check if training is already active.
{
let s = state.read().await;
if s.training_state.status.active {
return Json(serde_json::json!({
"status": "error",
"message": "Training is already active. Stop it first.",
"current_epoch": s.training_state.status.epoch,
"total_epochs": s.training_state.status.total_epochs,
}));
}
) -> Response {
if !server_training_enabled() {
return disabled_response();
}
let config = body.config.clone();
let dataset_ids = body.dataset_ids.clone();
match spawn_training_job(&state, config, body.dataset_ids.clone(), "supervised").await {
Ok(()) => Json(serde_json::json!({
"status": "started",
"type": "supervised",
"dataset_ids": body.dataset_ids,
"config": body.config,
}))
.into_response(),
Err(active) => Json(active_error(&active)).into_response(),
}
}
// Mark training as active and spawn background task.
let progress_tx;
{
/// Snapshot of the already-running job returned when a start is rejected.
fn active_error(snap: &TrainingStatus) -> serde_json::Value {
serde_json::json!({
"status": "error",
"message": "Training is already active. Stop it first.",
"current_epoch": snap.epoch,
"total_epochs": snap.total_epochs,
})
}
/// Seed the shared status, snapshot `frame_history`, and spawn the background
/// training job. Returns `Err(current_status)` if a job is already active.
///
/// Centralises the single-job guard + spawn used by the supervised, pretrain,
/// and LoRA start handlers so they cannot diverge.
/// Atomically claim the single training slot.
///
/// Checks `active` and sets it `true` **in one `status` lock scope**, so two
/// concurrent callers cannot both observe the slot free — the first claims it,
/// the second gets `Err(current_status)`. Returns the seeded status on success.
///
/// This is the fix for a TOCTOU race: the previous code checked `is_active()`
/// under a `state` READ lock, released it, and only afterward set `active`.
/// A `tokio::RwLock` read lock is shared, so two starts could both hold it, both
/// see the slot inactive, both proceed — spawning two jobs that then share and
/// overwrite one status/cancel and orphan a task handle. The claim's atomicity
/// lives on the `status` mutex, not the coarse `state` lock, which also keeps it
/// unit-testable without a full `AppState`.
fn claim_training_slot(
status: &Mutex<TrainingStatus>,
config: &TrainingConfig,
) -> Result<(), TrainingStatus> {
let mut st = status.lock().unwrap();
if st.active {
return Err(st.clone());
}
*st = TrainingStatus {
active: true,
total_epochs: config.epochs,
lr: config.learning_rate,
patience_remaining: config.early_stopping_patience,
phase: "initializing".to_string(),
..Default::default()
};
Ok(())
}
async fn spawn_training_job(
state: &AppState,
config: TrainingConfig,
dataset_ids: Vec<String>,
training_type: &'static str,
) -> Result<(), TrainingStatus> {
// Grab the shared handles under a read lock; the RwLock is only guarding
// access to the Arcs, not the single-job decision.
let (progress_tx, status, cancel, history_snapshot) = {
let s = state.read().await;
progress_tx = s.training_progress_tx.clone();
}
(
s.training_progress_tx.clone(),
s.training_state.status.clone(),
s.training_state.cancel.clone(),
s.frame_history.iter().cloned().collect::<Vec<_>>(),
)
};
{
let mut s = state.write().await;
s.training_state.status = TrainingStatus {
active: true,
epoch: 0,
total_epochs: config.epochs,
train_loss: 0.0,
val_pck: 0.0,
val_oks: 0.0,
lr: config.learning_rate,
best_pck: 0.0,
best_epoch: 0,
patience_remaining: config.early_stopping_patience,
eta_secs: None,
phase: "initializing".to_string(),
};
}
// Atomic check-and-set on the status mutex. This — not the read lock above —
// is what serialises concurrent starts (see `claim_training_slot`).
claim_training_slot(&status, &config)?;
cancel.store(false, Ordering::Relaxed);
let state_clone = state.clone();
let handle = tokio::spawn(async move {
real_training_loop(state_clone, progress_tx, config, dataset_ids, "supervised").await;
run_training_job(
status,
cancel,
progress_tx,
config,
dataset_ids,
history_snapshot,
training_type,
)
.await;
});
{
@@ -1616,57 +1771,58 @@ async fn start_training(
s.training_state.task_handle = Some(handle);
}
Json(serde_json::json!({
"status": "started",
"type": "supervised",
"dataset_ids": body.dataset_ids,
"config": body.config,
}))
Ok(())
}
async fn stop_training(State(state): State<AppState>) -> Json<serde_json::Value> {
let mut s = state.write().await;
if !s.training_state.status.active {
let s = state.read().await;
if !s.training_state.is_active() {
return Json(serde_json::json!({
"status": "error",
"message": "No training is currently active.",
}));
}
s.training_state.status.active = false;
s.training_state.status.phase = "stopping".to_string();
// The background task checks the active flag and will exit.
// We do not abort the handle -- we let it finish the current batch gracefully.
// Set the cooperative stop flag; the background job observes it between
// epochs and exits gracefully after the current batch. We do not abort the
// task handle.
s.training_state.cancel.store(true, Ordering::Relaxed);
{
let mut st = s.training_state.status.lock().unwrap();
st.phase = "stopping".to_string();
}
let snap = s.training_state.snapshot();
info!("Training stop requested");
Json(serde_json::json!({
"status": "stopping",
"epoch": s.training_state.status.epoch,
"best_pck": s.training_state.status.best_pck,
"epoch": snap.epoch,
"best_pck": snap.best_pck,
}))
}
async fn training_status(State(state): State<AppState>) -> Json<serde_json::Value> {
let s = state.read().await;
Json(serde_json::to_value(&s.training_state.status).unwrap_or_default())
let mut value = serde_json::to_value(s.training_state.snapshot()).unwrap_or_default();
// Surface the enablement flag so the dashboard can honestly disable the
// Start button (with a CLI tooltip) without first firing a POST (ADR-186 P5).
if let Some(obj) = value.as_object_mut() {
obj.insert(
"enabled".to_string(),
serde_json::Value::Bool(server_training_enabled()),
);
}
Json(value)
}
async fn start_pretrain(
State(state): State<AppState>,
Json(body): Json<PretrainRequest>,
) -> Json<serde_json::Value> {
{
let s = state.read().await;
if s.training_state.status.active {
return Json(serde_json::json!({
"status": "error",
"message": "Training is already active. Stop it first.",
}));
}
) -> Response {
if !server_training_enabled() {
return disabled_response();
}
let config = TrainingConfig {
epochs: body.epochs,
learning_rate: body.lr,
@@ -1675,56 +1831,26 @@ async fn start_pretrain(
..Default::default()
};
let progress_tx;
{
let s = state.read().await;
progress_tx = s.training_progress_tx.clone();
match spawn_training_job(&state, config, body.dataset_ids.clone(), "pretrain").await {
Ok(()) => Json(serde_json::json!({
"status": "started",
"type": "pretrain",
"epochs": body.epochs,
"lr": body.lr,
"dataset_ids": body.dataset_ids,
}))
.into_response(),
Err(active) => Json(active_error(&active)).into_response(),
}
{
let mut s = state.write().await;
s.training_state.status = TrainingStatus {
active: true,
total_epochs: body.epochs,
phase: "initializing".to_string(),
..Default::default()
};
}
let state_clone = state.clone();
let dataset_ids = body.dataset_ids.clone();
let handle = tokio::spawn(async move {
real_training_loop(state_clone, progress_tx, config, dataset_ids, "pretrain").await;
});
{
let mut s = state.write().await;
s.training_state.task_handle = Some(handle);
}
Json(serde_json::json!({
"status": "started",
"type": "pretrain",
"epochs": body.epochs,
"lr": body.lr,
"dataset_ids": body.dataset_ids,
}))
}
async fn start_lora_training(
State(state): State<AppState>,
Json(body): Json<LoraTrainRequest>,
) -> Json<serde_json::Value> {
{
let s = state.read().await;
if s.training_state.status.active {
return Json(serde_json::json!({
"status": "error",
"message": "Training is already active. Stop it first.",
}));
}
) -> Response {
if !server_training_enabled() {
return disabled_response();
}
let config = TrainingConfig {
epochs: body.epochs,
learning_rate: 0.0005, // lower LR for LoRA
@@ -1735,42 +1861,19 @@ async fn start_lora_training(
..Default::default()
};
let progress_tx;
{
let s = state.read().await;
progress_tx = s.training_progress_tx.clone();
match spawn_training_job(&state, config, body.dataset_ids.clone(), "lora").await {
Ok(()) => Json(serde_json::json!({
"status": "started",
"type": "lora",
"base_model_id": body.base_model_id,
"profile_name": body.profile_name,
"rank": body.rank,
"epochs": body.epochs,
"dataset_ids": body.dataset_ids,
}))
.into_response(),
Err(active) => Json(active_error(&active)).into_response(),
}
{
let mut s = state.write().await;
s.training_state.status = TrainingStatus {
active: true,
total_epochs: body.epochs,
phase: "initializing".to_string(),
..Default::default()
};
}
let state_clone = state.clone();
let dataset_ids = body.dataset_ids.clone();
let handle = tokio::spawn(async move {
real_training_loop(state_clone, progress_tx, config, dataset_ids, "lora").await;
});
{
let mut s = state.write().await;
s.training_state.task_handle = Some(handle);
}
Json(serde_json::json!({
"status": "started",
"type": "lora",
"base_model_id": body.base_model_id,
"profile_name": body.profile_name,
"rank": body.rank,
"epochs": body.epochs,
"dataset_ids": body.dataset_ids,
}))
}
// ── WebSocket handler for training progress ──────────────────────────────────
@@ -1792,8 +1895,11 @@ async fn handle_train_ws_client(mut socket: WebSocket, state: AppState) {
// Send current status immediately.
{
let s = state.read().await;
if let Ok(json) = serde_json::to_string(&s.training_state.status) {
let snapshot = {
let s = state.read().await;
s.training_state.snapshot()
};
if let Ok(json) = serde_json::to_string(&snapshot) {
let msg = serde_json::json!({
"type": "status",
"data": serde_json::from_str::<serde_json::Value>(&json).unwrap_or_default(),
@@ -1869,6 +1975,60 @@ mod tests {
assert_eq!(status.phase, "idle");
}
#[test]
fn claim_training_slot_admits_exactly_one_concurrent_start() {
// Regression test for the single-job TOCTOU race. Many threads race to
// claim one slot at the same instant (a barrier maximises contention);
// the status mutex must admit EXACTLY ONE. A split check-then-set (the
// old shape) would let several through under load — verified by
// temporarily reverting the atomicity, which drops this from 1.
use std::sync::atomic::{AtomicUsize, Ordering as O};
use std::sync::{Arc, Barrier};
let status = Arc::new(Mutex::new(TrainingStatus::default()));
let config = TrainingConfig::default();
let winners = Arc::new(AtomicUsize::new(0));
const N: usize = 32;
let barrier = Arc::new(Barrier::new(N));
let mut handles = Vec::with_capacity(N);
for _ in 0..N {
let status = status.clone();
let config = config.clone();
let winners = winners.clone();
let barrier = barrier.clone();
handles.push(std::thread::spawn(move || {
barrier.wait();
if claim_training_slot(&status, &config).is_ok() {
winners.fetch_add(1, O::SeqCst);
}
}));
}
for h in handles {
h.join().unwrap();
}
assert_eq!(
winners.load(O::SeqCst),
1,
"exactly one concurrent start may claim the single training slot"
);
assert!(
status.lock().unwrap().active,
"the slot must be marked active after a successful claim"
);
}
#[test]
fn claim_training_slot_rejects_when_already_active() {
let status = Arc::new(Mutex::new(TrainingStatus::default()));
let config = TrainingConfig::default();
assert!(claim_training_slot(&status, &config).is_ok(), "first claim wins");
let err = claim_training_slot(&status, &config)
.expect_err("second claim must be refused while active");
assert!(err.active, "the rejection carries the active status");
}
#[test]
fn training_progress_serializes() {
let progress = TrainingProgress {
@@ -2132,4 +2292,169 @@ mod tests {
assert_eq!(parsed.n_features, 2);
assert_eq!(parsed.mean, vec![1.0, 2.0]);
}
/// Build a small deterministic set of synthetic CSI frames with enough
/// variation that feature extraction is non-degenerate.
fn synthetic_history(n: usize, n_sub: usize) -> Vec<Vec<f64>> {
(0..n)
.map(|i| {
(0..n_sub)
.map(|k| 10.0 + ((i as f64) * 0.3 + (k as f64) * 0.1).sin() * 2.0)
.collect()
})
.collect()
}
/// ADR-186 P3/P6 end-to-end: the real (state-free) training core must
/// (a) stream real progress events over the broadcast channel and
/// (b) actually write a `.rvf` model artifact on completion — not merely
/// flip a status flag. This is the regression guard that keeps the trainer
/// wired (the module was previously orphaned / uncompiled — ADR-186 §1.3).
#[tokio::test]
async fn training_job_streams_real_progress_and_writes_model() {
let history = synthetic_history(40, 56);
let (tx, mut rx) = broadcast::channel::<String>(1024);
let status = Arc::new(Mutex::new(TrainingStatus::default()));
let cancel = Arc::new(AtomicBool::new(false));
let config = TrainingConfig {
epochs: 3,
batch_size: 8,
warmup_epochs: 1,
early_stopping_patience: 10,
..Default::default()
};
// Empty dataset_ids → falls back to the in-memory history snapshot, so
// this test does not depend on the recordings directory.
let rvf = run_training_job(
status.clone(),
cancel,
tx,
config,
Vec::new(),
history,
"supervised",
)
.await;
// (b) A real model artifact was produced and exists on disk.
let rvf_path = rvf.expect("training must produce an .rvf model artifact");
assert!(
rvf_path.exists(),
"rvf artifact should exist at {}",
rvf_path.display()
);
// (a) Real progress frames were streamed, at least one carrying an epoch.
let mut n_frames = 0usize;
let mut saw_epoch = false;
let mut saw_completed = false;
while let Ok(msg) = rx.try_recv() {
n_frames += 1;
let v: serde_json::Value = serde_json::from_str(&msg).unwrap();
if v.get("epoch").and_then(|e| e.as_u64()).unwrap_or(0) >= 1 {
saw_epoch = true;
}
if v.get("phase").and_then(|p| p.as_str()) == Some("completed") {
saw_completed = true;
}
}
assert!(n_frames > 0, "expected streamed progress frames, got none");
assert!(saw_epoch, "expected at least one epoch-tagged progress frame");
assert!(saw_completed, "expected a terminal 'completed' progress frame");
// Final shared status reflects genuine completion, not just a flag flip:
// real epochs ran (the loop wrote per-epoch status) and a finite loss was
// computed from the real gradient-descent pass.
let final_status = status.lock().unwrap().clone();
assert!(!final_status.active, "job should be inactive when finished");
assert_eq!(final_status.phase, "completed");
assert!(
final_status.epoch >= 1,
"at least one real training epoch should have run"
);
assert!(
final_status.train_loss.is_finite(),
"a finite training loss should have been computed"
);
// Keep the test hermetic — remove the artifact it wrote.
let _ = std::fs::remove_file(&rvf_path);
}
/// ADR-186 P4 (path safety): a `dataset_id` containing directory traversal
/// is rejected before any file is opened, so the loader returns no frames
/// rather than reading an arbitrary file.
#[tokio::test]
async fn load_recording_frames_rejects_path_traversal() {
let frames = load_recording_frames(&["../../etc/passwd".to_string()]).await;
assert!(
frames.is_empty(),
"path-traversal dataset_id must yield no frames"
);
}
/// Exported model ids must be unique per call — a second-resolution
/// timestamp alone collided for runs finishing in the same wall-clock second
/// (silently overwriting each other's `.rvf`, which also flaked the
/// concurrent model-writing tests on CI). Guards against regressing the
/// filename scheme back to non-unique.
#[test]
fn model_ids_are_unique_per_call() {
let ids: Vec<String> = (0..1000).map(|_| next_model_id("supervised")).collect();
let unique: std::collections::HashSet<&String> = ids.iter().collect();
assert_eq!(unique.len(), ids.len(), "every model id must be distinct");
assert!(ids[0].starts_with("trained-supervised-"));
}
/// ADR-186 P5: the enablement gate is enabled by default and only disabled
/// by an explicit truthy opt-out, so a `--no-default-features` / default
/// build always has server training ON (no silent regression to disabled).
#[test]
fn training_enablement_gate() {
assert!(training_enabled_from_env(None), "default is enabled");
assert!(training_enabled_from_env(Some("0")), "0 keeps it enabled");
assert!(training_enabled_from_env(Some("")), "empty keeps it enabled");
assert!(!training_enabled_from_env(Some("1")), "1 disables");
assert!(!training_enabled_from_env(Some("true")), "true disables");
assert!(!training_enabled_from_env(Some("YES")), "case-insensitive");
assert!(!training_enabled_from_env(Some(" 1 ")), "trims whitespace");
}
/// A job that is cancelled before it starts still exits cleanly and reports
/// the `cancelled` terminal phase (drives `stop_training`'s cooperative flag).
#[tokio::test]
async fn training_job_honors_cancellation() {
let history = synthetic_history(40, 56);
let (tx, _rx) = broadcast::channel::<String>(1024);
let status = Arc::new(Mutex::new(TrainingStatus::default()));
let cancel = Arc::new(AtomicBool::new(true)); // pre-cancelled
let config = TrainingConfig {
epochs: 50,
batch_size: 8,
warmup_epochs: 1,
early_stopping_patience: 10,
..Default::default()
};
let rvf = run_training_job(
status.clone(),
cancel,
tx,
config,
Vec::new(),
history,
"supervised",
)
.await;
// Cancelled before the first epoch → no model, terminal phase cancelled.
assert!(rvf.is_none(), "cancelled run should not export a model");
let final_status = status.lock().unwrap().clone();
assert!(!final_status.active);
assert_eq!(final_status.phase, "cancelled");
}
}
+7 -1
View File
@@ -35,7 +35,13 @@ cuda = ["tch-backend"]
[dependencies]
# Internal crates
wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false }
wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn" }
# NOTE: `wifi-densepose-nn` was declared here but never imported anywhere in
# this crate's src/ or bin/ (the tch-backend model path uses `tch` directly,
# not this crate). It was a dead dependency that pulled `ort` (ONNX Runtime) +
# reqwest/hyper into every downstream consumer — including the ADR-185
# `[meridian]` wheel. Removed to slim the dependency graph. Inference at
# serving time is done via `wifi-densepose-nn` by the binaries that actually
# load models, which depend on it directly.
# Core
thiserror.workspace = true