mirror of
https://github.com/ruvnet/RuView
synced 2026-07-24 17:43:20 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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."
|
||||
@@ -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 (10–150 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 O1–O8. 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 O1–O9. 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.
|
||||
|
||||
@@ -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.8–2.0 Hz, zero-crossing BPM | 40–120 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 ($6–10) | ~$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>
|
||||
|
|
||||
@@ -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 P7–P9 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 P7–P9 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
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
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 49–50). 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:49–50` |
|
||||
| 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 16–23) 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 2–4)
|
||||
|
||||
```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 49–50)
|
||||
- **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)
|
||||
@@ -0,0 +1,687 @@
|
||||
# ADR-185: Python P6 SOTA bindings — AETHER, MERIDIAN, and MAT via PyO3 extras
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed — **P1–P4 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):** P1–P4 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 2–3-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:97–98`). 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)
|
||||
|
||||
P1–P4 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.
|
||||
@@ -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:4986–5006
|
||||
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:8068–8071
|
||||
// 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:1125–1127
|
||||
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:1–25`):
|
||||
|
||||
- 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:11–13`)
|
||||
|
||||
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:1778–1836`).
|
||||
- A `routes()` factory wires the whole surface, **including the missing route**:
|
||||
|
||||
```rust
|
||||
// v2/crates/wifi-densepose-sensing-server/src/training_api.rs:1841–1849
|
||||
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-2–5 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:4986–5006` |
|
||||
| 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:8068–8071` |
|
||||
| `/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:1125–1127` |
|
||||
| Real in-server trainer | 1,860-line implemented pipeline, `tch`-free, exports `.rvf` | `training_api.rs:1–25` |
|
||||
| Real WS progress handler | subscribes to broadcast, streams `progress` frames | `training_api.rs:1778–1836` |
|
||||
| Real route factory (has the missing route) | `routes()` incl. `/ws/train/progress` | `training_api.rs:1841–1849` |
|
||||
| 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:11–13`) | 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:1125–1127`) 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:4977–5023`) and their three route mounts (`main.rs:8069–8071`).
|
||||
- Merge the real router **after** `.with_state(state.clone())`, the same pattern the
|
||||
RuField surface already uses (`main.rs:8104–8111`):
|
||||
|
||||
```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:8095–8102`,
|
||||
`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:126–130`) 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:1796–1815`). 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:4977–5023` (handlers),
|
||||
`:8068–8071` (routes), `:1125–1127` (state fields), `:1024`/`:1249` (`AppStateInner`/`SharedState`).
|
||||
- **Orphaned trainer**: `v2/crates/wifi-densepose-sensing-server/src/training_api.rs` —
|
||||
module doc `:1–25`, `TrainingState` `:232`, `AppState` alias `:249`, `start_training` `:1564`
|
||||
(spawn `:1610`), WS handler `:1778–1836`, `routes()` `:1841–1849`.
|
||||
- **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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Generated
+3072
-24
File diff suppressed because it is too large
Load Diff
+69
-2
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(&litudes, &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(())
|
||||
}
|
||||
@@ -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(&litude, &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, >, 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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
ae46351e28c01161c3c20f1a3134d8e32fbbef0cda2fdb9c3a27984da0ce026d
|
||||
@@ -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
|
||||
@@ -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(&, &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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
// 1–4: 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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: ...
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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: ...
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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: ...
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
+143
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user