diff --git a/api-docs/adr/ADR-184-adr117-completion-pypi-trusted-publishing.md b/api-docs/adr/ADR-184-adr117-completion-pypi-trusted-publishing.md new file mode 100644 index 00000000..a43aeab3 --- /dev/null +++ b/api-docs/adr/ADR-184-adr117-completion-pypi-trusted-publishing.md @@ -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) diff --git a/api-docs/adr/ADR-185-python-p6-sota-bindings.md b/api-docs/adr/ADR-185-python-p6-sota-bindings.md new file mode 100644 index 00000000..16be714e --- /dev/null +++ b/api-docs/adr/ADR-185-python-p6-sota-bindings.md @@ -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=;triage_priorities=` 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. diff --git a/api-docs/adr/ADR-186-training-progress-api.md b/api-docs/adr/ADR-186-training-progress-api.md new file mode 100644 index 00000000..6ca7c194 --- /dev/null +++ b/api-docs/adr/ADR-186-training-progress-api.md @@ -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, + Json(body): Json, +) -> Json { + 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, +``` + +### 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 { + 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>; +// 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` | `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, // 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`. +- [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. diff --git a/api-docs/adr/ADR-187-archive-v1-deprecation-honest-labeling.md b/api-docs/adr/ADR-187-archive-v1-deprecation-honest-labeling.md new file mode 100644 index 00000000..5adce978 --- /dev/null +++ b/api-docs/adr/ADR-187-archive-v1-deprecation-honest-labeling.md @@ -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). diff --git a/api-docs/adr/README.md b/api-docs/adr/README.md index 7c9e510e..d43a4d70 100644 --- a/api-docs/adr/README.md +++ b/api-docs/adr/README.md @@ -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? diff --git a/api-docs/user-guide.md b/api-docs/user-guide.md index d9542a6e..c8ca06ab 100644 --- a/api-docs/user-guide.md +++ b/api-docs/user-guide.md @@ -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