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

# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
This commit is contained in:
Dragan Spiridonov
2026-07-24 15:26:15 +02:00
71 changed files with 10058 additions and 345 deletions
@@ -0,0 +1,551 @@
# ADR-184: Complete ADR-117 via PyPI Trusted Publishing (OIDC) + real v2.0.0 / ruview publish
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-07-21 |
| **Deciders** | ruv |
| **Codename** | **PHOENIX-LANDING** — the PIP-PHOENIX wheel that never actually took off |
| **Relates to** | [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX modernization — this ADR completes it), [ADR-028](ADR-028-esp32-capability-audit.md) (witness chain), [ADR-115](ADR-115-home-assistant-integration.md) (HA/Matter sibling), [ADR-168](ADR-168-benchmark-proof.md) (measured-not-claimed house style) |
| **Tracking issue** | [#785](https://github.com/ruvnet/RuView/issues/785) (ADR-117, still OPEN) |
---
## 1. Context
ADR-117 (PIP-PHOENIX) designed the v2.0.0 rewrite of the pip `wifi-densepose`
package as a PyO3 + maturin compiled wheel over the Rust core, plus a `ruview`
sibling package, replacing the 11.5-month-stale pure-Python `1.1.0` line. The
code landed on `main` (the `python/` workspace: `Cargo.toml`, `src/bindings/*.rs`,
the `wifi_densepose/` Python package, `tests/`, `bench/`). The tombstone shipped.
**But the release itself is broken and the design doc's own P5 intent was never met.**
This ADR is a **gap analysis and remediation plan**, not a new feature. Every fact
below was verified against PyPI and GitHub Actions on 2026-07-21; none are projected.
### 1.1 What is actually live on PyPI (measured)
`pip index versions wifi-densepose` returns:
```
wifi-densepose (1.99.0)
Available versions: 1.99.0, 1.2.0, 1.1.0, 1.0.0
```
- `1.99.0` — the tombstone wheel **is genuinely live**. `import wifi_densepose`
raises `ImportError` pointing users to 2.0+. This part of ADR-117 §7.2 shipped.
- `2.0.0a1` — appears in PyPI's release history as a **pre-release** (hidden from
the default `pip index` view, surfaced with `--pre`). It is still an **alpha**.
- `2.0.0` (stable) — **does not exist.** ADR-117's headline deliverable
(`pip install wifi-densepose==2.0.0`) is not installable.
`pip index versions ruview` returns:
```
ERROR: No matching distribution found for ruview
```
The `ruview` sibling package **was never published.** Commit `b71d243b4`
(*"feat(adr-117): publish wifi-densepose 2.0.0a1 + ruview 2.0.0a1 to PyPI"*) claims
a publish that did not happen for that package — a real **claimed-vs-measured gap**
of exactly the kind [ADR-168](ADR-168-benchmark-proof.md) and the project's
"prove everything" posture exist to catch.
### 1.2 Why the release pipeline was stuck (measured; interim-fixed — see §1.4)
`gh run list --workflow pip-release` shows the last **4** runs all
`conclusion=failure` (most recent `2026-05-24T16:34`). The full failure log for
run `26366735779` (job *"Publish v1.99 tombstone"* → step *"Publish to PyPI"*)
shows two things:
1. The publish step uses `pypa/gh-action-pypi-publish` with a `password`
(API-token) input and fails:
```
403 Forbidden — Invalid or non-existent authentication information.
```
i.e. the `PYPI_API_TOKEN` GitHub secret is stale / expired / revoked.
2. The action's own log warns:
```
Warning: the workflow was run with 'attestations: true' ... but an explicit
password was also set, disabling Trusted Publishing.
```
The workflow at `.github/workflows/pip-release.yml` wires `password:
${{ secrets.PYPI_API_TOKEN }}` into **four** publish steps (lines 249, 258, 282,
291) and declares only `permissions: contents: read` (line 4950). So it is using a
rotatable, leak-able, expire-able API token in exactly the place ADR-117 §5.4 / §5.5
and the issue #785 P5 row explicitly called for **OIDC Trusted Publishing** ("cp310
… abi3-py310, OIDC"; ADR-117 §5.5 line 547: *"PyPI publish via Trusted Publisher
(OIDC, no API token in secrets)"*). **The implementation drifted from its own
design doc.**
### 1.3 Why the package is still alpha (measured)
`python/pyproject.toml` pins `version = "2.0.0a1"` (line 13) and
`Development Status :: 3 - Alpha` (line 26). Issue #785's closing criteria
(§"Done") require `wifi-densepose==2.0.0` (**not** alpha) published, plus all 10
acceptance criteria in §11. None of those can be true today given §1.1–§1.2.
**Why this matters:** ADR-117 is the sole Python entry point for the whole RuView
ecosystem (per its §2 "PyPI org presence check"). A stale token silently blocking
every release means the entire "plug-and-play Python entry point for the pip +
Jupyter customer base" thesis (issue #785 "Strategic alignment") is stalled behind a
one-line credential problem — and a commit message claims otherwise.
### 1.4 Interim fix applied (2026-07-21) — credential unblocked, migration still pending
**As of 2026-07-21T22:57:29Z the stale-credential symptom is fixed at the credential
layer.** The maintainer fetched a valid `PYPI_TOKEN` from GCP Secret Manager (project
`cognitum-20260110`) and ran `gh secret set PYPI_API_TOKEN` to replace the
revoked/expired value. Authentication was confirmed non-destructively via a
`twine upload --skip-existing` re-upload of the existing `1.99.0` tombstone artifacts,
which returned a benign 400/skip response (not the previous `403 Forbidden`) — proving
the new token authenticates correctly.
This means **token-based publishing works again today** — the `403` root cause
described in §1.2 no longer reproduces. It does **not**, however, close this ADR:
- A **manually-rotated token still expires, leaks, and can be revoked over time** — it
re-introduces exactly the silent-failure mode that blocked the last 4 runs. It is a
stopgap at the same layer as the §3.2 fallback, not the durable fix.
- The OIDC **Trusted Publishing migration (§3, P1) remains the decision** — a
credential PyPI mints per-run with no secret to rotate is the only fix that removes
the recurring-expiry class of failure.
- The other three gaps are **untouched** by this rotation: `wifi-densepose` is still
`2.0.0a1` (not stable `2.0.0`), and `ruview` is still unpublished.
**Why/How to apply:** read §1.2's "root cause" as *diagnosed and temporarily
mitigated*, not *still broken*. A reviewer re-running the §7.5 check today may now see
a green token-based run — that is expected and does not satisfy this ADR, which is
Accepted only when §6's criteria pass **and** the workflow no longer carries a static
token (§7.4).
---
## 2. Current state — evidence
| Artifact | Value | Source |
|---|---|---|
| Latest stable `wifi-densepose` on PyPI | **1.99.0** (tombstone) | `pip index versions wifi-densepose` |
| `wifi-densepose==2.0.0` stable | **absent** | `pip index versions` (not listed) |
| `wifi-densepose==2.0.0a1` pre-release | present (alpha) | PyPI release history (`--pre`) |
| `ruview` on PyPI | **No matching distribution found** | `pip index versions ruview` |
| `pip-release.yml` last 4 runs | all `failure` | `gh run list --workflow pip-release` |
| Most recent failed run | `2026-05-24T16:34` | `gh run list` |
| Failing step | Publish v1.99 tombstone → Publish to PyPI | run `26366735779` log |
| Failure code | `403 Forbidden — Invalid or non-existent authentication information` | run `26366735779` log |
| Root cause | `PYPI_API_TOKEN` stale/revoked; explicit password disables Trusted Publishing | run `26366735779` log warning |
| `password:` uses in workflow | 4 (lines 249, 258, 282, 291) | `.github/workflows/pip-release.yml` |
| Workflow permissions | `contents: read` only (no `id-token: write`) | `pip-release.yml:4950` |
| pyproject version | `2.0.0a1` | `python/pyproject.toml:13` |
| pyproject dev status | `3 - Alpha` | `python/pyproject.toml:26` |
| Issue #785 | **OPEN** | GitHub |
**Why/How to apply:** treat this table as the falsifiable baseline. A reviewer who
re-runs each `Source` command must reproduce each `Value`, or this ADR is wrong and
should be revised before any remediation is attempted.
---
## 3. Decision
Complete ADR-117 by closing four gaps, in order:
1. **Migrate `pip-release.yml` to PyPI Trusted Publishing (OIDC)** — as the durable
end-state, drop all four `password: ${{ secrets.PYPI_API_TOKEN }}` inputs, grant
`id-token: write` to the publish jobs, and add `environment: pypi`. This removes
the rotatable/expire-able credential and realigns with ADR-117 §5.5's stated OIDC
intent. **This is gated behind sub-phase P1b** (§5): the switch is inert — and in
fact 403-breaking — until the manual pypi.org registration (§3.1) exists, so the
OIDC change must land *together* with that registration. Until then, token auth
(the freshly-rotated `PYPI_API_TOKEN`, §1.4) is the correct active path and is
what the `RuView#786-pypi-token-auth` fix-marker guard enforces. An OIDC migration
was attempted (`cc153e8b5`) and reverted (`82d5c7339`) for exactly this reason.
2. **Promote `wifi-densepose` from `2.0.0a1` to stable `2.0.0`** in
`python/pyproject.toml` (version + `Development Status :: 5 - Production/Stable`)
and record the promotion in `CHANGELOG.md`.
3. **Actually publish `ruview==2.0.0`** — the sibling package that commit
`b71d243b4` claimed but never shipped — and verify it with `pip index versions`.
4. **Adopt issue #785 §11's 10 acceptance criteria verbatim as this ADR's own
acceptance criteria** (§6 below), and only flip ADR-117 → Accepted and close
#785 once every one passes against the real index — proven, not claimed.
### 3.1 Mandatory human prerequisite (cannot be automated)
**Trusted Publishing requires a one-time manual step on `pypi.org` that no CLI, API,
or agent can perform** — PyPI restricts Trusted Publisher configuration to the
project owner via the web UI for security reasons. Before P1's workflow change can
succeed, a human with owner rights on both PyPI projects must:
1. Log in to `pypi.org`.
2. For **`wifi-densepose`**: Project → *Publishing* → *Add a new pending/trusted
publisher* → GitHub, with:
- Owner: `ruvnet`
- Repository: `RuView`
- Workflow filename: `pip-release.yml`
- Environment: `pypi`
3. Repeat the identical step for the **`ruview`** project. Because `ruview` is not
yet on PyPI, register it as a **pending publisher** (PyPI supports configuring a
trusted publisher for a project name before its first release — the first OIDC
publish then creates the project).
**Why/How to apply:** the workflow change in P1 is inert until this is done — the
publish step will fail with a "no trusted publisher configured" error rather than a
403. Land P1 and this manual step together; do not tag a release expecting OIDC to
work until a human confirms both entries exist. Treat this section as a blocking
checklist item on the release-day runbook, not a footnote.
### 3.2 Fallback path (if the owner declines Trusted Publishing)
If the maintainer prefers not to adopt OIDC yet, the code-side remediation is a
**token regeneration**, not a redesign:
- Generate a fresh PyPI API token (scoped to the `wifi-densepose` and `ruview`
projects) and store it in GCP Secret Manager (project `cognitum-20260110`, where
the project's tokens live), then `gh secret set PYPI_API_TOKEN` from it, following
the existing runbook referenced in the workflow header (`docs/integrations/pypi-release.md`).
- Keep the current `password:`-based workflow unchanged.
**Why/How to apply:** this path clears the 403 and unblocks releases immediately,
but it re-introduces the exact failure mode this ADR is trying to eliminate — a
credential that silently expires and blocks the whole Python entry point again. Use
it only as a stopgap; the Trusted Publishing migration (P1) is the durable fix and
should remain the default recommendation.
---
## 4. Detailed design — workflow migration
The change to `.github/workflows/pip-release.yml` is small and surgical. It does
**not** touch the build matrix (`build-wheels`, `build-sdist`, `build-tombstone`
jobs are unchanged — the 403 is a publish-credential problem, not a build problem).
### 4.1 Grant OIDC token permission on the publish jobs
The `gh-action-pypi-publish` action mints its OIDC token from the job's
`id-token: write` permission. The current top-level `permissions: contents: read`
must be extended on the two publish jobs (`publish-v2`, `publish-tombstone`) — plus
the future `publish-ruview` job:
```yaml
publish-v2:
name: Publish v2 wheels
needs: [build-wheels, build-sdist]
permissions:
id-token: write # ← added: mint the OIDC token for PyPI
contents: read
environment: pypi # ← added: binds to the PyPI trusted-publisher entry
```
### 4.2 Drop the `password:` inputs
Every publish step loses its `password:` line. Trusted Publishing needs no secret —
the action exchanges the job's OIDC token for a short-lived PyPI upload token
automatically:
```yaml
# BEFORE (current — fails with 403 when the token is stale)
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }} # ← remove
packages-dir: dist
# AFTER (Trusted Publishing — no secret, activates once the pypi.org entry exists)
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist
```
The TestPyPI dry-run steps keep `repository-url: https://test.pypi.org/legacy/` and
likewise drop `password:` — a matching trusted-publisher entry must be registered on
`test.pypi.org` if the dry-run path is to be used (otherwise gate the dry-run behind
the fallback token or remove it).
**Why/How to apply:** the header comment block (lines 1623) that documents the
`PYPI_API_TOKEN` / GCP-Secret-Manager runbook must be rewritten to document the
Trusted Publishing setup instead, so the next maintainer does not re-add a token
"to fix" a future failure and silently re-disable OIDC.
### 4.3 Add the `publish-ruview` job
`ruview` is published by a new job mirroring `publish-v2` (same `id-token: write` +
`environment: pypi`, no `password:`), gated on a `ruview`-scoped build. Because the
package has never shipped, its first successful OIDC publish creates the PyPI
project against the pending trusted-publisher entry from §3.1.
---
## 5. Phase ledger
```
P1 ──► P1b ──► P2 ──► P3 ──► P4
token OIDC version real close
unblock (gated) promote publish #785
```
### P1 — Credential unblock (token auth, active)
- [x] Rotate `PYPI_API_TOKEN` to a validated token (§1.4, `gh secret set`, verified
`2026-07-21T22:57:29Z` via `twine upload --skip-existing`). Token-based publishing
works today.
- [x] Keep `password: ${{ secrets.PYPI_API_TOKEN }}` as the active auth path,
satisfying the `RuView#786-pypi-token-auth` fix-marker guard.
- [ ] Rewrite the `pip-release.yml` header comment block so the next maintainer
knows OIDC is the intended P1b end-state (not a token to keep re-rotating forever).
> Note: an OIDC migration was attempted (`cc153e8b5`) and **reverted** (`82d5c7339`)
> because it tripped the fix-marker guard before the pypi.org registration existed.
> The OIDC work is therefore tracked as P1b below, not P1. See the Status note.
**Status 2026-07-21 — DESIGNED then REVERTED (token auth is the ACTIVE path):**
The OIDC migration was implemented (commit `cc153e8b5` — `id-token: write` +
`environment: pypi` on both publish jobs, all four `PYPI_API_TOKEN` password
inputs removed) but then **reverted** (commit `82d5c7339`) after it tripped the
pre-existing `RuView#786-pypi-token-auth` fix-marker guard
(`scripts/fix-markers.json`). That guard `require`s
`password: ${{ secrets.PYPI_API_TOKEN }}` and `forbid`s `id-token: write`
precisely because a half-activated OIDC path (id-token permission present, but no
Trusted Publisher yet registered on pypi.org) leaves publishing **403-broken**
rather than working — it correctly predicted this exact failure. The revert was
verified locally against the real checker (`python scripts/check_fix_markers.py` →
all 25 markers pass, exit 0) before pushing.
**Active path today:** token-based auth via the freshly-rotated `PYPI_API_TOKEN`
(§1.4). The current `pip-release.yml` (HEAD `82d5c7339`) carries
`password: ${{ secrets.PYPI_API_TOKEN }}` at four publish steps plus a TODO
comment marking the OIDC follow-up. The OIDC switch is therefore **not** done — it
moves to sub-phase P1b below.
**Why this revert was correct (measured, not claimed):** OIDC is the better
long-term design and matches ADR-117's original §5.5 P5 intent — but implementing
it *before* the manual pypi.org registration exists would have shipped a workflow
that looks migrated yet 403s on the next real publish. The fix-marker caught a
well-intentioned improvement that wasn't the honest, currently-working state, and
it was reverted rather than overridden. That is the same "measured not claimed"
discipline (per [ADR-168](ADR-168-benchmark-proof.md)) this entire ADR exists to
enforce — applied here to our own change.
### P1b — Switch to OIDC Trusted Publishing (gated follow-up)
- [ ] **(human, manual, pypi.org — BLOCKING)** Complete the §3.1 Trusted Publisher
registration for BOTH `wifi-densepose` and `ruview` (owner=ruvnet, repo=RuView,
workflow=pip-release.yml, environment=pypi). P1b must not start until this exists.
- [ ] Re-apply the `cc153e8b5` change (add `id-token: write` + `environment: pypi`,
drop the four `password:` inputs) as its own follow-up commit.
- [ ] Update the `RuView#786-pypi-token-auth` fix-marker in `scripts/fix-markers.json`
in the *same* commit — invert it to `require: id-token: write` / `forbid:
password: ${{ secrets.PYPI_API_TOKEN }}` — so the guard tracks the new intended
state instead of blocking it (referencing the TODO comment now in pip-release.yml).
- [ ] Confirm a green OIDC publish before removing the token, per §3.2's
keep-both-paths recommendation (OIDC first, token fallback until OIDC is proven).
- [ ] No capability gap: publishing must keep working across the P1→P1b transition.
### P2 — Version promotion + changelog
- [ ] `python/pyproject.toml`: `version = "2.0.0"` (drop the `a1` suffix).
- [ ] `python/pyproject.toml`: `Development Status :: 5 - Production/Stable`.
- [ ] `CHANGELOG.md`: `[Unreleased]` entry — "wifi-densepose 2.0.0 promoted from
alpha; ruview 2.0.0 first stable publish; pip-release migrated to Trusted Publishing".
- [ ] Confirm the `ruview` package's own version metadata is set to `2.0.0`.
### P3 — Real publish + verification
- [ ] Cut tag `v2.0.0-pip` (per the workflow's `v*-pip` trigger) → OIDC publish of
the `wifi-densepose` wheel matrix.
- [ ] Publish `ruview==2.0.0` via the new `publish-ruview` job.
- [ ] Run every command in §7 against the **real** PyPI index and capture output.
- [ ] Generate + commit `expected_features_v2.sha256` (issue #785 §11 criterion 10),
resolving ADR-117 §11.3 / the workflow header's Q3 note.
### P4 — Close issue #785
- [ ] All 10 acceptance criteria (§6) pass against the real index.
- [ ] Flip ADR-117 §Status → **Accepted**.
- [ ] Flip this ADR (ADR-184) §Status → **Accepted**.
- [ ] Close issue #785.
**Why/How to apply:** the phases are strictly ordered — P3 cannot succeed until both
P1 (working credential path) and the §3.1 human step are done, and P4 must not be
marked complete on the strength of a commit message (the failure mode this ADR
exists to correct). Nothing in this ledger is checked; this is a Proposed plan.
---
## 6. Acceptance criteria (verbatim from issue #785 §11)
A reviewer must be able to:
1. `pip install --pre wifi-densepose==2.0.0a1` from PyPI test index → wheel installs
without compile step on Linux/macOS/Windows
2. `python -c "import wifi_densepose; print(wifi_densepose.__version__, wifi_densepose.__rust_version__)"`
→ both versions print
3. `python -c "from wifi_densepose import CsiFrame; ..."` → core type round-trips
through PyO3
4. `python -c "from wifi_densepose import vitals; vitals.detect_hr(...)"` → 4-stage
pipeline runs on a sample CSI buffer
5. `pip install wifi-densepose[client]; python -c "import wifi_densepose.client; ..."`
→ WS client connects to a running sensing-server
6. `pytest python/tests/` → ≥30 tests pass (smoke + binding round-trips)
7. `maturin build --release --strip` → wheel under 5 MB per platform (ADR §5.4 budget)
8. `wifi-densepose==1.99.0` is the latest 1.x; `import wifi_densepose` raises
`ImportError` with migration URL
9. `wifi-densepose==1.0.0` is yanked from PyPI; `1.1.0` is un-yanked with deprecation
notice (90-day window)
10. Witness `expected_features_v2.sha256` generated in CI, committed alongside the
existing `archive/v1/data/proof/`, re-verifiable from Python via
`wifi_densepose.verify_witness(...)`
**Note (amendment to criterion 1):** issue #785 §11 was written when `2.0.0a1` was
the target. This ADR promotes to stable `2.0.0`, so criterion 1 is read as
`pip install wifi-densepose==2.0.0` (no `--pre`) against the production index. The
`--pre`/`a1` wording is preserved verbatim above per the transcription requirement;
the stable form is what P3/P4 must actually satisfy. This ADR additionally requires
`ruview==2.0.0` to be installable (the sibling package from commit `b71d243b4`),
which #785 §11 did not enumerate but the issue "Done" section implies.
---
## 7. How to verify (prove, don't claim)
Exact commands a reviewer runs to prove — not assume — each gap is closed. Every one
produces falsifiable output; capture it in the PR that flips ADR-117 to Accepted.
### 7.1 Both packages live and stable
```bash
# wifi-densepose 2.0.0 (stable, NOT alpha) must appear
pip index versions wifi-densepose
# expect: "wifi-densepose (2.0.0)" and 2.0.0 in the available list
# ruview 2.0.0 must now exist (currently: "No matching distribution found")
pip index versions ruview
# expect: "ruview (2.0.0)"
```
### 7.2 Clean-venv install + import (criteria 24)
```bash
python -m venv /tmp/verify-184 && . /tmp/verify-184/bin/activate
pip install wifi-densepose==2.0.0 # stable, no --pre
python -c "import wifi_densepose; print(wifi_densepose.__version__, wifi_densepose.__rust_version__)"
python -c "from wifi_densepose import CsiFrame; print(CsiFrame([1.0]*56,[0.0]*56,56,0,100.0))"
python -c "from wifi_densepose import vitals; print(hasattr(vitals,'detect_hr'))"
pip install ruview==2.0.0
python -c "import ruview; print(ruview.__version__)"
```
### 7.3 Tombstone still guards the 1.x line (criterion 8)
```bash
pip install wifi-densepose==1.99.0
python -c "import wifi_densepose" 2>&1 | grep -q "github.com/ruvnet/RuView" \
&& echo "PASS: tombstone raises with migration URL" \
|| echo "FAIL"
```
### 7.4 Workflow auth state
**Current state (P1, active today):** token auth is the working path and is what
the `RuView#786-pypi-token-auth` fix-marker requires. The honest check today is
that token auth is present and the fix-marker guard passes:
```bash
# token auth present (the ACTIVE, working path — expected PASS today)
grep -q 'password: ${{ secrets.PYPI_API_TOKEN }}' .github/workflows/pip-release.yml \
&& echo "PASS: token auth active" || echo "FAIL"
# fix-marker regression guard must pass
python scripts/check_fix_markers.py && echo "PASS: all markers pass"
```
**P1b end-state (after the manual pypi.org registration):** the checks below flip
to PASS *only once P1b lands together with the fix-marker inversion* — they are
**not** expected to pass today and their passing now would mean a half-migrated,
403-prone workflow:
```bash
# after P1b: no static token should remain in the publish steps
grep -nE 'password:|PYPI_API_TOKEN' .github/workflows/pip-release.yml \
&& echo "not yet: token still present (expected during P1)" \
|| echo "P1b done: no static token"
# after P1b: id-token permission granted on publish jobs
grep -q 'id-token: write' .github/workflows/pip-release.yml \
&& echo "P1b done: OIDC permission present" \
|| echo "not yet: OIDC not enabled (expected during P1)"
```
### 7.5 The release actually went green
```bash
gh run list --workflow pip-release --limit 1
# expect: conclusion=success on the v2.0.0-pip tag run
```
**Why/How to apply:** §7.1 and §7.5 together are the minimal proof that the two
headline gaps (no stable 2.0.0, no `ruview`, dead pipeline) are closed. If any
command's actual output diverges from the `expect` line, the corresponding phase is
not done — regardless of what any commit message or checkbox says.
---
## 8. Consequences
### Positive
- The Python entry point for the entire RuView ecosystem (issue #785 "Strategic
alignment") is unblocked with a credential that cannot silently expire.
- The claimed-vs-measured gap in commit `b71d243b4` (`ruview` never published) is
closed with reproducible proof, upholding the project's "prove everything" posture.
- Trusted Publishing removes a leak-able long-lived secret from CI entirely — the
security posture ADR-117 §5.5 originally specified.
- ADR-117 / issue #785 can finally reach a defensible Accepted/closed state instead
of sitting open behind a one-line token failure.
### Negative
- The `pypi.org` trusted-publisher registration (§3.1) is a hard human dependency
with no automated fallback beyond re-introducing a token (§3.2). Release day is
blocked on a person, not a pipeline.
- Promoting to stable `2.0.0` removes the alpha escape hatch — any binding bug now
ships under a stable version and needs a `2.0.1`, not a new `a`-tag.
- `test.pypi.org` needs its own trusted-publisher entry if the dry-run path is kept,
adding a second manual registration.
### Neutral
- The build matrix (`build-wheels`, `build-sdist`, `build-tombstone`) is untouched;
the risk surface of this change is confined to the three publish jobs.
- The witness-hash-v2 open question (ADR-117 §11.3, workflow header Q3) is pulled
into scope as criterion 10 but is orthogonal to the credential migration.
---
## 9. References
- **ADR-117** — `docs/adr/ADR-117-pip-wifi-densepose-modernization.md` (the design
this ADR completes; §5.4/§5.5 OIDC intent, §7.2 tombstone, §11.3 witness hash)
- **Issue #785** — https://github.com/ruvnet/RuView/issues/785 (tracking issue,
OPEN; §11 acceptance criteria transcribed in §6)
- **Workflow** — `.github/workflows/pip-release.yml` (four `password:` inputs at
lines 249/258/282/291; `contents: read` only at 4950)
- **pyproject** — `python/pyproject.toml` (`version = "2.0.0a1"` line 13;
`3 - Alpha` line 26)
- **Failed run** — GitHub Actions `pip-release` run `26366735779`, job "Publish
v1.99 tombstone" → step "Publish to PyPI" (403 + Trusted-Publishing-disabled warning)
- **Commit `b71d243b4`** — *"feat(adr-117): publish wifi-densepose 2.0.0a1 + ruview
2.0.0a1 to PyPI"* — the `ruview` publish it claims did not occur
- **PyPI Trusted Publishing** — https://docs.pypi.org/trusted-publishers/ (web-UI-only
registration; pending-publisher support for not-yet-created projects)
- **`pypa/gh-action-pypi-publish`** — https://github.com/pypa/gh-action-pypi-publish
(OIDC via `id-token: write`; `password:` disables Trusted Publishing)
- **ADR-168** — `docs/adr/ADR-168-benchmark-proof.md` (measured-not-claimed house style)
+687
View File
@@ -0,0 +1,687 @@
# ADR-185: Python P6 SOTA bindings — AETHER, MERIDIAN, and MAT via PyO3 extras
| Field | Value |
|-------|-------|
| **Status** | Proposed — **P1P4 implemented & tested** (commits `d060998e3`, `189ac9dfb`, `1c9727f9c`, `0f405213d`) + **leaf-crate hoists done** (`a47bb71b2`/`7ed57f041`/`99fea9df9`); **not yet Accepted** (§6.6 CI gate PARTIAL, §6.7 accuracy bars OPEN — see §13) |
| **Date** | 2026-07-21 (impl status recorded 2026-07-21) |
| **Deciders** | ruv |
| **Codename** | **PIP-TRINITY** — three SOTA subsystems join the `wifi_densepose` wheel |
| **Relates to** | [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX — the PyO3 wheel this extends), [ADR-024](ADR-024-contrastive-csi-embedding-model.md) (AETHER contrastive embeddings), [ADR-027](ADR-027-cross-environment-domain-generalization.md) (MERIDIAN domain generalization), [ADR-152](ADR-152-wifi-pose-sota-2026.md) (WiFlow-STD ~96% PCK@20 SOTA bar) |
| **Tracking issue** | TBD — file under RuView issue tracker |
---
## 1. Context
### 1.1 Where ADR-117 stopped
ADR-117 (PIP-PHOENIX) shipped the `wifi-densepose` v2.x PyPI wheel as a PyO3 +
maturin compiled extension (`wifi_densepose._native`) with a pure-Python facade.
The bound surface today (`python/src/bindings/*.rs`, `python/src/lib.rs`):
| Bound today | Crate | Kind |
|---|---|---|
| `CsiFrame`, `Keypoint`, `KeypointType`, `BoundingBox`, `PersonPose`, `PoseEstimate` | `wifi-densepose-core` | P2 core types |
| 4-stage vitals (`BreathingExtractor`, `HeartRateExtractor`, `VitalEstimate`, `VitalReading`, `VitalStatus`) | `wifi-densepose-vitals` | P3 DSP |
| `BfldFrame`, `BfldReport`, `BfldKind` + `PrivacyClass` gate | `wifi-densepose-bfld` | P3.5 / ADR-118 |
| `SensingClient` (WS), `RuViewMqttClient` (MQTT), HA helpers | pure-Python `wifi_densepose.client` | P4 `[client]` extra |
ADR-117's own phase ledger (§6, "P6+ — Deferred") explicitly parked three
higher-value subsystems as post-v2.0.0 work:
> - [ ] `wifi-densepose-nn` bindings … · `wifi-densepose-ruvector` bindings …
> - [ ] MQTT/Matter integration helpers …
and ADR-117 §5.1 deferred `wifi-densepose-mat` (depends on nn) and the RuVector
tier for wheel-size reasons. The three SOTA subsystems that a Python researcher
most wants — re-identification embeddings, cross-environment transfer, and the
disaster-triage tool — are precisely the ones still unreachable from
`pip install wifi-densepose`.
### 1.2 The three subsystems already exist and are tested in Rust
None of this is new research. Each subsystem is a shipped, tested Rust module:
| Subsystem | ADR | Rust location (verified HEAD) | Nature |
|---|---|---|---|
| **AETHER** — contrastive CSI embedding / re-identification | ADR-024 | `wifi-densepose-sensing-server/src/embedding.rs` (`EmbeddingExtractor`, `ProjectionHead`, `CsiAugmenter`, `AetherConfig`, `aether_loss`, `info_nce_loss`, `alignment_metric`, `uniformity_metric`) | Pure-sync DSP + linear algebra; 128-dim L2-normalized embeddings |
| **MERIDIAN** — cross-environment domain generalization | ADR-027 | `wifi-densepose-train` (`domain::{DomainFactorizer, DomainClassifier, GradientReversalLayer, AdversarialSchedule}`, `geometry::{GeometryEncoder, FourierPositionalEncoding, FilmLayer, MeridianGeometryConfig}`, `rapid_adapt::{RapidAdaptation, AdaptationLoss}`, `virtual_aug::VirtualDomainAugmentor`, `eval::CrossDomainEvaluator`) + `wifi-densepose-signal::hardware_norm::{HardwareNormalizer, HardwareType, CanonicalCsiFrame}` | Inference/adaptation path is pure-Rust and **un-gated**; only `model`/`trainer`/`losses` need `tch-backend` (libtorch) |
| **MAT** — Mass Casualty Assessment Tool | (root CLAUDE.md crate table) | `wifi-densepose-mat` (`DisasterResponse`, `DisasterConfig`, `DetectionPipeline`, `EnsembleClassifier`, `TriageCalculator`, `TriageStatus`, `Survivor`, `VitalSignsReading`) | Cargo-feature-gated (`mat`); sync ingest (`push_csi_data`) + async scan loop (`start_scanning`, tokio) |
### 1.3 Why now, and why gated extras
Two forces make P6 timely: (a) the v2.0.0 wheel is stable and its abi3-py310
build matrix is proven, so adding modules is incremental; (b) integrators reading
the ADR-115/ADR-117 notes are asking for Python access to re-identification and
cross-room transfer specifically.
But pulling all three into the **default** wheel would break ADR-117 §5.4's
**≤ 5 MB per-platform wheel budget** and its "no heavy system deps" invariant:
- MAT is already cargo-`mat`-gated upstream *because* it drags in the ML/detection
stack; the default wheel must not carry it.
- MERIDIAN's training path (`model`/`trainer`/`losses`) is `tch-backend`-gated and
would pull libtorch (30 MB+), the exact wheel-size risk ADR-117 §5.1 flagged.
So P6 mirrors the existing `[client]` extra pattern (ADR-117 §5.6): each subsystem
becomes an **optional pip extra**, and the compiled surface is **feature-gated in
`wifi-densepose-py`'s `Cargo.toml`** so the default wheel stays lean.
### 1.4 What this ADR is *not*
- Not a port of the Rust subsystems to Python — the Rust workspace stays
authoritative and unmodified, exactly as ADR-117 §1.3 established.
- Not the `wifi-densepose-nn` / libtorch binding (still deferred; MERIDIAN binds
only the un-gated inference/adaptation path, not `tch-backend` training).
- Not a change to the default wheel's contents, size budget, or abi3 base.
---
## 2. Gap analysis
| Capability | Rust crate(s) | pip v2.x status | Gap severity |
|---|---|---|---|
| Extract a 128-dim re-ID embedding from a CSI window | `sensing-server::embedding` (AETHER) | Not present | **High** |
| Compare two CSI observations by learned similarity (same room? same person?) | AETHER `EmbeddingExtractor` + cosine | Not present | **High** |
| Hardware-invariant CSI normalization (ESP32 / Intel 5300 / Atheros → canonical 56) | `signal::hardware_norm` (MERIDIAN) | Not present | **High** |
| Geometry-conditioned zero-shot deployment (AP positions → FiLM) | `train::geometry` (MERIDIAN) | Not present | **Medium** |
| 10-second unlabeled few-shot room adaptation | `train::rapid_adapt` (MERIDIAN) | Not present | **Medium** |
| Cross-domain evaluation protocol (in/cross/few-shot MPJPE) | `train::eval` (MERIDIAN) | Not present | **Medium** |
| Disaster-survivor detection + START triage from CSI | `wifi-densepose-mat` | Not present | **Medium** (specialist audience) |
---
## 3. Decision
Adopt **three new optional pip extras**, each binding one SOTA subsystem into the
existing `wifi_densepose` wheel as a dedicated Python submodule, gated behind a
matching Cargo feature so the default wheel is unchanged:
```
pip install wifi-densepose # unchanged: core + vitals + bfld (≤5 MB)
pip install wifi-densepose[aether] # + wifi_densepose.aether
pip install wifi-densepose[meridian] # + wifi_densepose.meridian
pip install wifi-densepose[mat] # + wifi_densepose.mat (mirrors upstream `mat` cargo feature)
pip install wifi-densepose[sota] # convenience: aether + meridian + mat
```
This path is called **PIP-TRINITY**. It reuses ADR-117's established idiom
end-to-end: `#[pyclass]` newtype wrappers holding an `inner` Rust value, `#[new]`
constructors, `#[getter]` accessors, `__repr__`, a per-module `register(m)` fn,
and — critically — **GIL release via `py.allow_threads(|| …)` on every
compute-heavy call**, exactly as `bindings/vitals.rs:229` and `:293` already do.
### 3.1 Feature gating in `wifi-densepose-py`
New Cargo features and optional path-deps in `python/Cargo.toml`; each binding
module is `#[cfg(feature = "…")]`-compiled and conditionally `register()`ed in
`src/lib.rs`, so a default build links none of the three:
```toml
[features]
default = []
aether = ["dep:wifi-densepose-sensing-server"]
meridian = ["dep:wifi-densepose-train", "dep:wifi-densepose-signal"]
mat = ["dep:wifi-densepose-mat"] # upstream `mat` feature flows through
sota = ["aether", "meridian", "mat"]
[dependencies]
wifi-densepose-sensing-server = { version = "0.3.0", path = "../v2/crates/wifi-densepose-sensing-server", optional = true, default-features = false }
wifi-densepose-train = { version = "0.3.0", path = "../v2/crates/wifi-densepose-train", optional = true, default-features = false } # NO tch-backend
wifi-densepose-signal = { version = "0.3.0", path = "../v2/crates/wifi-densepose-signal", optional = true }
wifi-densepose-mat = { version = "0.3.0", path = "../v2/crates/wifi-densepose-mat", optional = true, default-features = false }
```
`[project.optional-dependencies]` in `pyproject.toml` gains `aether`, `meridian`,
`mat`, and `sota` keys mirroring the existing `client`/`dev` extras. Because each
extra changes the compiled surface, extras map to **cibuildwheel feature-flag
builds**, not pure-Python markers — the publish workflow (ADR-117 §5.4) gains a
build axis for the `[sota]` wheel variant.
### 3.2 Binding surface — AETHER (`wifi_densepose.aether`)
Backing crate: `wifi-densepose-sensing-server::embedding` (ADR-024 §2.6). The
crate is Axum/tokio-based, so we depend on it `default-features = false` and bind
**only the sync `embedding` types** — never the server/runtime. If the embedding
module cannot be reached without a tokio dependency (Open Question §11.1), the
fallback is to hoist `embedding.rs` into a leaf crate; that is a Rust-side
refactor, not a Python API change.
| Python symbol | Wraps | Signature (Python) |
|---|---|---|
| `AetherConfig` | `AetherConfig` | `AetherConfig(d_model=64, d_proj=128, temperature=0.07, vicreg_alpha=1.0, vicreg_beta=25.0, vicreg_gamma=1.0)` — frozen, `__repr__` |
| `CsiAugmenter` | `CsiAugmenter` | `CsiAugmenter(seed)`; `.augment(window: list[list[float]]) -> list[list[float]]` |
| `EmbeddingExtractor` | `EmbeddingExtractor` | `.embed(csi_features: list[list[float]]) -> list[float]` (128-dim, L2-normed); `.forward_dual(...) -> tuple[PoseEstimate, list[float]]` |
| `aether_loss(...)` | `aether_loss` | returns `AetherLossComponents(total, info_nce, variance, covariance)` — frozen dataclass-like |
| `cosine_similarity(a, b)` | thin helper | `float`; convenience for re-ID scoring (not a re-impl — calls the same dot product) |
| `alignment_metric`, `uniformity_metric` | same | `float` |
GIL strategy: `embed`, `forward_dual`, `augment`, and `aether_loss` wrap their
Rust call in `py.allow_threads(|| …)` — these are pure-sync matrix ops that touch
no Python objects, matching the vitals precedent. A single-frame `embed()` is
sub-millisecond (ADR-024 §2.8 target <1 ms FP32), but batch/augment calls exceed
the 0.5 ms GIL-release threshold ADR-117 §P3 set.
`.pyi` stubs: add `wifi_densepose/aether.pyi` declaring the five classes/functions
with precise numeric types; extend the top-level `wifi_densepose/__init__.pyi`
with a `TYPE_CHECKING`-guarded re-export so `mypy --strict` sees them only when
the extra is installed.
### 3.3 Binding surface — MERIDIAN (`wifi_densepose.meridian`)
Backing crates: `wifi-densepose-train` (inference/adaptation path, **no
`tch-backend`**) + `wifi-densepose-signal::hardware_norm`. The `model`/`trainer`/
`losses` modules are libtorch-gated and are **out of scope** — Python gets the
domain-generalization *inference and calibration* surface, not the training loop.
| Python symbol | Wraps | Signature (Python) |
|---|---|---|
| `HardwareType` | `HardwareType` | `#[pyclass(eq, eq_int, hash, frozen)]` enum: `Esp32S3 / Intel5300 / Atheros / Generic`; `HardwareType.detect(subcarrier_count) -> HardwareType` |
| `HardwareNormalizer` | `HardwareNormalizer` | `.normalize(frame: CsiFrame, hw: HardwareType) -> CanonicalCsiFrame` |
| `CanonicalCsiFrame` | `CanonicalCsiFrame` | frozen; `.amplitudes`, `.phases`, `.hardware_type` getters |
| `GeometryEncoder` | `GeometryEncoder` | `GeometryEncoder(MeridianGeometryConfig)`; `.encode(ap_positions: list[tuple[float,float,float]]) -> list[float]` (64-dim, permutation-invariant) |
| `MeridianGeometryConfig` | `MeridianGeometryConfig` | frozen config |
| `RapidAdaptation` | `RapidAdaptation` | `.calibrate(csi_windows: list[list[list[float]]]) -> AdaptationResult` (10-sec unlabeled few-shot) |
| `AdaptationResult` | `AdaptationResult` | frozen result: `.frames_used`, `.converged`, `.loss` |
| `CrossDomainEvaluator` | `CrossDomainEvaluator` | `.evaluate(...) -> dict[str, float]` (in/cross/few-shot MPJPE, domain-gap ratio) |
GIL strategy: `normalize`, `encode`, `calibrate`, and `evaluate` are wrapped in
`py.allow_threads`. `normalize` targets <50 µs/frame (ADR-027 §4.1) and `encode`
<100 µs (§4.3), but `calibrate` runs contrastive test-time training over 200
frames and is the primary GIL-release beneficiary.
`.pyi` stubs: `wifi_densepose/meridian.pyi`. `DomainFactorizer` /
`GradientReversalLayer` / `VirtualDomainAugmentor` are **training-time only** and
are *not* bound in P6 (they need the tch training loop) — Open Question §11.2
records this boundary.
### 3.4 Binding surface — MAT (`wifi_densepose.mat`)
Backing crate: `wifi-densepose-mat`, bound behind the `[mat]` extra so the
disaster/ML stack never enters the default wheel — mirroring the upstream `mat`
cargo feature exactly. `DisasterResponse::start_scanning` is async (tokio); rather
than bind an event loop, P6 binds the **sync ingest + query surface** and a
single-shot `scan_once()` helper (a sync wrapper over one `scan_cycle`, added
Rust-side if needed — see §11.3).
| Python symbol | Wraps | Signature (Python) |
|---|---|---|
| `DisasterType` | `DisasterType` | `#[pyclass(eq, eq_int, hash, frozen)]` enum: `Earthquake / BuildingCollapse / Avalanche / Flood / Mine / Unknown` |
| `TriageStatus` | `TriageStatus` | frozen enum (START protocol classes) |
| `DisasterConfig` | `DisasterConfig` | builder-style kwargs: `DisasterConfig(disaster_type, sensitivity=0.8, confidence_threshold=0.5, max_depth=5.0)` |
| `DisasterResponse` | `DisasterResponse` | `.push_csi_data(amplitudes, phases)`; `.scan_once()`; `.survivors() -> list[Survivor]`; `.survivors_by_triage(status) -> list[Survivor]` |
| `Survivor` | `Survivor` | frozen: `.id`, `.triage_status`, `.location`, `.vital_signs` getters |
| `VitalSignsReading` | `VitalSignsReading` | frozen: breathing / heartbeat / movement fields |
GIL strategy: `push_csi_data` and `scan_once` wrap the detection-pipeline call in
`py.allow_threads` — the ensemble classifier + localization are the compute-heavy
part and touch no Python state.
`.pyi` stubs: `wifi_densepose/mat.pyi`.
---
## 4. Benchmarking & the measured-vs-claimed parity requirement
A binding that "runs without crashing" is worthless if it silently regresses
accuracy versus the native Rust call. The point of P6 is to prove the Python
surface reproduces the Rust subsystem **bit-for-bit**, then to hold each binding
to the *same* published SOTA bar its ADR already claims.
### 4.1 Parity harness (bit-for-bit, mandatory)
Each subsystem ships a golden-vector parity test. A committed input fixture is
run through **both** a tiny native-Rust reference binary (in
`v2/crates/wifi-densepose-py/tests/golden/`) and the Python binding; the two
outputs must hash-match under SHA-256 (the ADR-028 / ADR-117 §5.7 witness scheme):
- `aether`: identical 128-dim embedding bytes for a fixed CSI window + fixed seed.
- `meridian`: identical `CanonicalCsiFrame` bytes for a fixed ESP32 (64-sub) and
Intel-5300 (30-sub) frame; identical 64-dim geometry vector for fixed AP set.
- `mat`: identical triage classification + survivor count for a fixed CSI stream.
A mismatch is a **release blocker**, not a warning. This is the "MEASURED, not
CLAIMED" gate the project holds itself to.
**Scope, stated honestly:** parity proves the **strongest claim available today**
the Python binding is bit-identical to native Rust for the bound surface. It is
**not** accuracy validation. The bound AETHER surface moreover ships *untrained*
(random-init weights; a `load_weights` API exists since `65da488ad` but no trained
checkpoint exists to load — §6.7.2, §13.c.a), so byte-equality here says nothing
about the SOTA accuracy bars in §4.3; those remain OPEN (§6.7, §13.c).
### 4.2 pytest-benchmark micro-benchmarks
Following the existing `python/bench/test_bench_vitals.py` pattern (skipped by
default via `addopts`; run with `pytest python/bench/ --benchmark-only`):
- `python/bench/test_bench_aether.py` — steady-state `embed()` per-window cost;
assert < 2 ms (ADR-024 §2.8 FP32 target < 1 ms with headroom) and that batched
`embed()` scales linearly (no accidental O(n²)).
- `python/bench/test_bench_meridian.py``normalize()` < 200 µs/frame,
`encode()` < 200 µs (ADR-027 §4.1/§4.3 targets ×2 headroom).
- `python/bench/test_bench_mat.py``scan_once()` per-cycle cost bounded by the
configured scan interval.
### 4.3 SOTA accuracy bar the binding must reproduce (not merely run)
The parity harness (§4.1) guarantees the Python path is byte-identical to Rust, so
these published numbers are the bar the *binding output* is validated against on a
committed labeled fixture — a regression in any is a binding bug:
| Metric | Bar | Source |
|---|---|---|
| WiFlow-STD pose accuracy | **~96% PCK@20** (MEASURED-EQUIVALENT) | ADR-152 §2.2 |
| Room identification (k-NN on `env_fingerprint`) | **> 95%** | ADR-024 §2.8 |
| Person re-ID mAP | **> 80%** (WhoFi bar 95.5% on NTU-Fi) | ADR-024 §2.8, §1.5 |
| Anomaly detection F1 | **> 0.90** | ADR-024 §2.8 |
| INT8 rank correlation vs FP32 (Spearman) | **> 0.95** | ADR-024 §2.8 |
| Cross-domain MPJPE improvement | **> 20%** vs non-adversarial | ADR-027 §4.2 |
| Domain-gap ratio (cross/in-domain) | **< 1.5** | ADR-027 §4.6 |
| Few-shot MPJPE after 10-sec calibration | within **15%** of in-domain | ADR-027 §4.5 |
---
## 5. Phase ledger
```
P1 ──► P2 ──► P3 ──► P4
aether meridian mat docs +
bindings bindings behind examples
extra
```
> **Implementation note (2026-07-21):** P1P4 were built against the **real Rust
> code at HEAD**, not this ADR's proposed surface. Where §3's proposed API named
> functions/fields that do not exist in the crates (e.g. `aether_loss`/VICReg
> components/`alignment_metric`/`forward_dual`, `RapidAdaptation.calibrate`,
> `AdaptationResult.converged`), the coder **did not fabricate them** — the real
> API was bound and the deviation documented in each module header and commit body.
> Treat §3 as the original proposal and the commit messages as the authoritative
> record of what shipped.
### P1 — AETHER bindings (`[aether]` extra) — **DONE** (`d060998e3`; leaf-crate hoist `a47bb71b2`)
- [x] `aether` Cargo feature + gated optional `wifi-densepose-sensing-server` dep;
default build links **0** sensing-server refs (base wheel stays lean).
- [x] `python/src/bindings/aether.rs``AetherConfig` (→ real `EmbeddingConfig`),
`CsiAugmenter.augment_pair`, `EmbeddingExtractor.embed` (128-dim L2-normed,
GIL-released), `info_nce_loss`, `cosine_similarity`. **Not bound** (absent in
`embedding.rs` at HEAD, a Rust-side gap, not fabricated): `aether_loss`/VICReg
components, `alignment_metric`, `uniformity_metric`, `forward_dual`, `vicreg_*`.
- [x] `#[cfg(feature = "aether")]` gate + facade + `aether.pyi` + `[aether]` extra.
- [x] `python/tests/golden/aether_embedding.sha256` parity fixture:
`tests/aether_parity.rs` locks the native reference; `tests/test_aether.py`
asserts identical SHA-256 of the LE-f32 bytes.
- [x] **Verified:** `cargo test --features aether --test aether_parity` → 2/2;
`pytest tests/test_aether.py` → 9/9.
- [x] **Leaf-crate hoist (`a47bb71b2`):** `embedding.rs` moved into a new
`wifi-densepose-aether` crate. Measured stripped wheel **~361 KB → ~312 KB** (was
already ~14× under the 5 MB budget — see §13.a; the hoist's value is build-time
71 s → 12 s + dep-graph hygiene, not size). No regression: `aether_parity` 2/2,
`pytest` 9/9, sensing-server 217+388 tests 0 failed, new `wifi-densepose-aether`
crate 96 passed.
### P2 — MERIDIAN bindings (`[meridian]` extra) — **DONE** (`189ac9dfb`)
- [x] `meridian` feature + gated optional `wifi-densepose-train` (**no `tch-backend`
— libtorch avoided, confirmed**) + `wifi-densepose-signal` deps.
- [x] `python/src/bindings/meridian.rs``HardwareType`/`HardwareNormalizer`/
`CanonicalCsiFrame` (real API: `normalize(amplitude, phase, hw)` over f64 →
`Result`; singular `amplitude`/`phase` fields), `MeridianGeometryConfig`/
`GeometryEncoder` (64-dim, permutation-invariant), `RapidAdaptation`
(**real API: `push_frame` + `adapt()`**, not the ADR's `calibrate`) →
`AdaptationResult` (`lora_weights`/`final_loss`/`frames_used`/
`adaptation_epochs`; **no `converged`**), `CrossDomainEvaluator` + `mpjpe`. All
compute paths GIL-released. Training-time types (`DomainFactorizer`, GRL,
`VirtualDomainAugmentor`) correctly left out of P6 scope.
- [x] Gate + facade + `meridian.pyi` + `[meridian]` extra; default dep graph has 0
train/signal/sensing-server refs.
- [x] `tests/golden/meridian_output.sha256` parity fixture (esp32 + intel canonical
frames + 64-dim geometry vector + rapid-adapt LoRA weights).
- [x] **Verified:** `cargo test --features meridian --test meridian_parity` → 2/2;
`pytest tests/test_meridian.py` → 13/13.
### P3 — MAT bindings behind `[mat]` extra — **DONE** (`1c9727f9c`)
- [x] `mat` feature + gated optional `wifi-densepose-mat` dep. **§11.3 resolved: no
Rust change needed** — the public async `start_scanning()` already runs exactly
one `scan_cycle` when `continuous_monitoring == false`; the binding forces that
flag off and drives one cycle on a private current-thread tokio runtime.
- [x] `python/src/bindings/mat.rs``DisasterType` (**9 variants at HEAD**, not the
6 the ADR listed), `TriageStatus` (5, START), `DisasterConfig`,
`DisasterResponse` (`initialize_event`/`add_zone`/`push_csi_data`/`scan_once`/
`survivors`/`survivors_by_triage``initialize_event`+`add_zone` are **required
additions** the ADR surface omitted), `Survivor` (`latest_vitals`, since real
`vital_signs` is a history), `VitalSignsReading`, `ScanZone.rectangle`/`.circle`.
`push_csi_data`+`scan_once` GIL-released.
- [x] Gate + facade + `mat.pyi` + `[mat]` **and** `[sota]` (superset) extras.
- [x] `tests/golden/mat_result.sha256` parity fixture over a canonical
`count=<K>;triage_priorities=<sorted>` string (UUIDs/timestamps excluded as
non-deterministic). **Honest scope: proves binding==native path, NOT live
detection accuracy** — the synthetic stream yields 1 survivor, triage Delayed.
- [x] **Verified:** `cargo test --features mat --test mat_parity` → 2/2;
`pytest tests/test_mat.py` → 7/7.
### P4 — Docs, examples, and benchmark suite — **DONE** (`0f405213d`)
- [x] `python/bench/test_bench_{aether,meridian,mat}.py` (pytest-benchmark, §4.2).
Measured on a `--release --features sota` wheel: AETHER `embed()` ~150 µs
(target <2 ms), batch 1/8/64 = 140/1091/8509 µs (linear); MERIDIAN `normalize()`
~2.2 µs (target <200 µs), `encode()` ~6.9 µs; MAT ingest+`scan_once()` ~40 ms /
256-frame (< 500 ms). All pass.
- [x] `python/examples/{reid_from_csi,cross_room_calibrate,mat_triage}.py` — typed,
runnable, `mypy --strict` clean; README SOTA extras table.
- [~] Parity harness wiring into CI as a **release-blocking gate** — golden gates
are green locally (`cargo test --features sota` → 6/6; 3/3 SHA gates), but the CI
**wiring** is not done (§6.6 PARTIAL — see §13.b).
- [ ] Update ADR-117 §6 "P6+ Deferred" to point at this ADR — still open.
### P5 — New required follow-ups (blocking Accepted)
See §13. In short: (a) three leaf-crate hoists — **DONE** (`a47bb71b2`/`7ed57f041`/
`99fea9df9`; only MAT was a real budget fix, AETHER was a false alarm), (b) wire the
parity harness into CI as an actual release gate — **still open**, (c) source/generate
labeled fixtures to validate the SOTA accuracy bars (§4.3) for real — **still open**.
### P6+ — Deferred (unchanged from ADR-117)
- [ ] `wifi-densepose-nn` / libtorch bindings (MERIDIAN training loop,
`DomainFactorizer`, GRL) — still blocked on the libtorch wheel-size question.
- [ ] `wifi-densepose-ruvector` RuVector attention bindings.
- [ ] Matter integration helpers.
---
## 6. Acceptance criteria
Status recorded from the P4 self-verification run (`0f405213d`), reference machine
per ADR-117 §10. **7 of 9 met; 2 remain** — the ADR is therefore **not** Accepted.
- [x] **§6.1** `pip install wifi-densepose` (no extras) → default wheel **279 KB**
(≤ 5 MB); `build_features()` carries no `p6-*` feature — base wheel byte-for-byte
unaffected by P6. **PASS**
- [x] **§6.2** `pytest python/tests/test_aether.py -q`**9/9**, incl. a real
128-dim `embed()` round-trip asserting L2-norm ≈ 1.0 and byte-identity to the
golden Rust reference. **PASS**
- [x] **§6.3** `pytest python/tests/test_meridian.py -q`**13/13**, incl.
ESP32 (64-sub) **and** Intel-5300 (30-sub) canonicalization hash-matching native
Rust. **PASS**
- [x] **§6.4** `pytest python/tests/test_mat.py -q`**7/7**, incl. a fixed CSI
stream whose triage classification matches native `DisasterResponse` exactly.
**PASS**
- [x] **§6.5** `pytest python/bench/ --benchmark-only` — all targets met (AETHER
`embed()` ~150 µs < 2 ms; MERIDIAN `normalize()` ~2.2 µs, `encode()` ~6.9 µs
< 200 µs; MAT `scan_once()` ~40 ms < 500 ms). **PASS**
- [~] **§6.6** Parity harness (§4.1): all three golden-vector SHA-256 gates green
(`cargo test --features sota` → 6/6). **But CI wiring** as a release-blocking
gate is **not done** (out of `python/` scope). **PARTIAL — see §13.b.**
- [ ] **§6.7** SOTA-bar reproduction (§4.3): **definitively OPEN** — cannot be
closed transitively via the parity harness. Investigated; three concrete reasons:
1. **The native SOTA numbers aren't reproduced by any committed, runnable-today
test.** ADR-152 ~96% PCK@20 is a frozen result in
`benchmarks/wiflow-std/results/eval_retrained.json` that points at an **external
checkpoint** (`/home/ruvultra/wiflow-std-bench/upstream/test/best_pose_model.pth`,
not in the repo); the only relevant test `test_wiflow_std_parity.rs` is
`#![cfg(feature = "tch-backend")]` **and** `#[ignore]`d (needs gitignored
fixtures + LibTorch). ADR-027's `eval.rs::CrossDomainEvaluator` tests are pure
unit-math on hand-coded 23-element vectors, not dataset accuracy. ADR-024's
only accuracy-ish test asserts Spearman > 0.90 on **synthetic random**
embeddings (not real CSI, not the published > 0.95 bar); no room-ID / mAP /
anomaly-F1 test exists at all.
2. **The bound AETHER surface ships untrained.** `EmbeddingExtractor`/
`ProjectionHead` default to random Xavier init (`Linear::with_seed(…,
2024/2025)`, `embedding.rs:9798`). A weight-loading API now **does** exist
(`load_weights`/`save_weights`, `65da488ad` — see §13.c.a), so the earlier
"no loading path" blocker is removed; but **no trained checkpoint exists** to
load, so the binding still produces untrained embeddings and cannot validate
mAP > 80% or any trained-model bar today.
3. **No committed labeled CSI input/pose-pair data exists** to reuse (MM-Fi/NTU-Fi
appear only as config-default subcarrier counts / external paths;
`benchmarks/wiflow-std/results/*.npy` are corruption masks + result summaries,
not labeled fixtures).
The §4.1 parity harness proves the **strongest claim available today** — the
Python binding is bit-identical to native Rust for the bound (untrained) surface.
That is **not** accuracy validation. **See §13.c.**
- [x] **§6.8** `.pyi` stubs present for all three modules; `mypy --strict` passes on
the three examples. **PASS**
- [x] **§6.9** `python -c "import wifi_densepose.aether"` (etc.) on the base wheel
raises a clear `ImportError` naming the missing extra. **PASS**
No regression: 76 pre-existing tests pass on the default wheel. The two unmet
criteria (§6.6 CI wiring, §6.7 accuracy) plus the wheel-size hoists (§13.a) are the
gate to Accepted.
---
## 7. Consequences
### 7.1 Positive
- **Closes the ADR-117 P6 gap**: the three most-requested SOTA subsystems become
scriptable from Python without touching the Rust workspace.
- **Default wheel stays lean**: feature-gated extras preserve ADR-117 §5.4's ≤ 5 MB
budget and "no heavy system deps" invariant; MAT's ML stack and MERIDIAN's
libtorch path never enter the base wheel.
- **Reuses the proven idiom**: no new binding machinery — same `#[pyclass]` +
`py.allow_threads` + `register()` pattern already shipping in `bindings/vitals.rs`.
- **Prove-everything alignment**: the parity harness makes "the Python binding
equals the Rust core" a *measured, hash-verified* claim, not an assertion —
matching the project's MEASURED-vs-CLAIMED discipline.
- **Upstream consistency**: `[mat]` pip extra mirrors the `mat` cargo feature, so
the Python packaging story matches the Rust one exactly.
### 7.2 Negative
- **cibuildwheel matrix grows**: `[sota]` is a distinct compiled variant, adding a
build axis (and CI time) beyond ADR-117's 5-wheel abi3 matrix.
- **AETHER's backing crate is server-shaped**: depending on
`wifi-densepose-sensing-server` (Axum/tokio) risks pulling a runtime into an
extension module; may force a Rust-side refactor to hoist `embedding.rs` into a
leaf crate (§11.1).
- **MERIDIAN surface is partial**: training-time types (`DomainFactorizer`, GRL,
`VirtualDomainAugmentor`) stay unbound until the deferred libtorch tier, so the
Python API is inference/adaptation-only — potential user confusion (mitigated by
docs + `.pyi` omissions).
- **Golden fixtures are maintenance surface**: any intentional numeric change in a
Rust subsystem requires regenerating and re-witnessing its golden vector.
### 7.3 Neutral
- The `[sota]` convenience extra is purely additive; users who want one subsystem
install one extra.
- No change to the v2.0.0 semver line; extras ship additively as v2.x.y.
---
## 8. Alternatives considered
### Alt-A: Fold all three into the default wheel
Rejected — breaks ADR-117 §5.4's ≤ 5 MB budget, drags MAT's ML stack and (via
MERIDIAN training) libtorch into every install, and contradicts the upstream
`mat` cargo-feature gating.
### Alt-B: Separate PyPI packages (`wifi-densepose-aether`, etc.)
Rejected for the SOTA trio — three packages fragment the import namespace and
duplicate the abi3/cibuildwheel setup. (This remains the right call for the
libtorch `nn` tier per ADR-117 Open Q §11.2, which is genuinely heavy.) Extras of
one wheel keep `wifi_densepose.*` coherent.
### Alt-C: Pure-Python reimplementation of the three subsystems
Rejected explicitly — this is the exact drift ADR-117 §8 Alt-C was created to
exit. A Python reimplementation would immediately begin diverging from the Rust
SOTA and could not pass the §4.1 bit-for-bit parity gate.
### Alt-D: REST/WS client to a running sensing-server for AETHER
Rejected as the primary path — provides zero offline embedding utility and cannot
host the parity harness over local Rust code (same reasoning as ADR-117 §8 Alt-B).
The pure-Python client layer (`[client]`) remains available for streaming.
---
## 9. Risks
| Risk | Likelihood | Severity | Mitigation |
|---|---|---|---|
| `wifi-densepose-sensing-server` pulls tokio into the extension module | ~~High~~ **Not realized** | ~~High~~ **Low** | **Measured, not realized:** the stripped `[aether]` wheel was **~361 KB** (14× under budget) even before the hoist — linker DCE (`--gc-sections`) strips the server's unreached Axum/tokio/worldgraph code because the binding reaches only pure-compute symbols. Hoist (`a47bb71b2`) still done for build-time / dep-graph hygiene, not budget. See §11.1, §13.a |
| MERIDIAN accidentally links `tch-backend` (libtorch) via a default feature | Medium | High | Explicit `default-features = false` on `wifi-densepose-train`; CI `auditwheel`/`ldd` check that no libtorch symbol is present in the `[meridian]` wheel |
| `[sota]` build axis blows up cibuildwheel time | Medium | Medium | Build `[sota]` variant only on tagged releases, not every PR |
| Golden vectors drift when a Rust subsystem changes intentionally | Medium | Low | Documented regeneration step + ADR-028 witness re-sign; parity mismatch is a loud release blocker, never silent |
| MAT async-only surface has no clean sync entry point | Medium | Medium | Add sync `scan_once()` wrapper Rust-side (§11.3) before binding |
| Users install base wheel and expect `wifi_densepose.aether` | Low | Low | Clear `ImportError` naming the missing extra (acceptance criterion §6) |
---
## 10. Compatibility
- No change to the default wheel, its abi3-py310 base, or its size budget.
- Extras ship additively on the existing v2.x line; no semver break.
- `[mat]` pip extra ↔ `mat` cargo feature parity is preserved by construction.
- `.pyi` stubs are gated so `mypy --strict` only sees a subsystem when its extra
is installed.
---
## 11. Open questions
1. **AETHER crate shape****RESOLVED (`a47bb71b2`).** The original worry that
linking `wifi-densepose-sensing-server` would bloat the wheel was **never
measured** — it reasoned from the dependency tree (server has non-optional
tokio/Axum ⇒ wheel must be huge). The stripped-release measurement disproves it:
`[aether]` was **369,782 B (~361 KB)** *before* the hoist — already ~14× under
the 5 MB budget — and **319,719 B (~312 KB)** after. Linker dead-code elimination
(`--gc-sections` on the pyo3 cdylib) already strips the server's unreached
Axum/tokio/worldgraph/ruvector paths because the binding reaches only
pure-compute symbols. The hoist into `wifi-densepose-aether` was still done — its
real payoff is **build-time** (`[aether]` alone 71 s → 12 s), **dep-graph
hygiene** (`python/Cargo.lock` 1238 lines), and **removing latent risk** (a
future change that makes server code reachable would then genuinely bloat the
wheel). **Convention note:** measure the stripped release wheel size before
assuming a dependency-tree risk requires a hoist — linker DCE handles pure-Rust
unreached code, but native/FFI-bundled deps (e.g. `ort`/ONNX Runtime, see §13.a
MAT) are *not* stripped and are the real size-risk category.
2. **MERIDIAN training-time types**: `DomainFactorizer`, `GradientReversalLayer`,
and `VirtualDomainAugmentor` are meaningful only with the tch training loop.
Confirm they stay unbound in P6 and move with the deferred libtorch tier.
*Tentative: yes — P6 is inference/adaptation only.*
3. **MAT sync entry point**: `DisasterResponse::start_scanning` is an async tokio
loop. Does a sync single-cycle `scan_once()` already exist, or must it be added
Rust-side? *Tentative: add a thin sync `scan_once()` wrapping one `scan_cycle`;
do not bind an event loop into the extension.*
4. **`[sota]` wheel vs per-extra wheels**: cibuildwheel builds one binary per
feature-set. Do we publish one `[sota]` wheel and let pip select, or per-extra
wheels? This affects the number of build variants. *Tentative: single `[sota]`
superset wheel on tagged releases; base wheel stays feature-free.*
5. **INT8 embedding path in Python**: ADR-024 §2.8 sets an INT8 rank-correlation
bar. Do we expose the INT8 quantized `embed()` in P6, or FP32 only first?
*Tentative: FP32 in P6; INT8 follows once the Rust quantized path is stable.*
---
## 12. References
### Internal ADRs
- **ADR-117**: pip modernization via PyO3 + maturin — the wheel this ADR extends;
§5.1/§5.4/§5.6 (extras + wheel budget), §6 "P6+ Deferred".
- **ADR-024**: Project AETHER — contrastive CSI embedding; §2.6 module surface,
§2.8 performance/accuracy targets.
- **ADR-027**: Project MERIDIAN — cross-environment domain generalization; §4
phase acceptance criteria, §4.6 evaluation protocol.
- **ADR-152**: WiFi-Pose SOTA 2026 — WiFlow-STD ~96% PCK@20 MEASURED-EQUIVALENT bar.
- **ADR-028**: ESP32 capability audit / witness scheme — the SHA-256 parity gate
the §4.1 golden harness reuses.
### Rust source (verified HEAD)
- `v2/crates/wifi-densepose-sensing-server/src/embedding.rs` — AETHER.
- `v2/crates/wifi-densepose-train/src/{domain,geometry,rapid_adapt,virtual_aug,eval}.rs` — MERIDIAN.
- `v2/crates/wifi-densepose-signal/src/hardware_norm.rs` — MERIDIAN HardwareNormalizer.
- `v2/crates/wifi-densepose-mat/src/lib.rs` — MAT.
- `python/src/bindings/vitals.rs` — the `py.allow_threads` GIL-release precedent.
- `python/bench/test_bench_vitals.py` — the pytest-benchmark pattern P4 follows.
---
## 13. Open follow-ups (blocking Accepted)
P1P4 are real, well-tested progress: **32/32 binding tests** (aether 9, meridian
13, mat 7, + 3 smoke) and **6/6 native parity tests** all pass, verified on the
reference machine. The three leaf-crate hoists (§13.a) are now **done**. Two items
still gate Accepted: **§13.b** (wire the parity harness into CI as a release gate)
and **§13.c** (the SOTA accuracy gap — bindings are structurally *untrained*, and no
eval harness or labeled data exists yet; genuine long-term work, not a quick fix).
### 13.a — Leaf-crate hoists (all three DONE) — one real fix, one minor, one false alarm
All three extras' backing crates carry heavy declared deps, so the hoist was applied
to each. But **measuring the stripped release wheel** (not reasoning from the
dependency tree) showed the wheel-size story differs sharply per extra. Linker
dead-code elimination (`--gc-sections` on the pyo3 cdylib) strips **pure-Rust
unreached** code, so a heavy declared dep tree does **not** imply a big wheel;
**native/FFI-bundled** deps (`ort`/ONNX Runtime's native library) are the exception
— DCE cannot strip them, and those are the real size risk.
| Extra | Commit | Wheel size (stripped) | Verdict |
|---|---|---|---|
| `[aether]` | `a47bb71b2` | **~361 KB → ~312 KB** | **False alarm.** Never breached the 5 MB budget — DCE already stripped the sensing-server's unreached Axum/tokio/worldgraph/ruvector code. Hoist justified by build-time (71 s → 12 s), dep-graph hygiene (`Cargo.lock` 1238 lines), and latent-risk removal — **not** budget. |
| `[mat]` | `7ed57f041` | **8.4 MB → 2.0 MB** | **Real, measured regression.** `wifi-densepose-nn` bundles `ort`/ONNX Runtime, a **native** library DCE does **not** strip → genuine breach. Fix necessary and correctly characterized. |
| `[meridian]` | `99fea9df9` | **1.8 MB → 1.7 MB** | **Real but minor.** Measured from the start; a dead dep removed. Already under budget; small win. `libtorch` correctly avoided throughout (`tch` optional, off). |
These were changes **inside** the upstream `v2/` crates (owned by other agents this
session); the default wheel was unaffected throughout because every extra is
feature-gated off. All three hoists are now landed — the remaining Accepted blockers
are §13.b (CI gate) and §13.c (accuracy fixtures), **not** wheel size.
### 13.b — Wire the parity harness into CI as a real release gate (§6.6)
The three golden-vector SHA-256 gates pass locally (`cargo test --features sota`
6/6) but are not yet wired into a CI workflow that **blocks release** on mismatch.
Add a job to the ADR-117 §5.4 publish pipeline that runs the native `*_parity.rs`
references + the `pytest` binding checks and fails the release on any divergence.
### 13.c — Close the SOTA accuracy gap (§4.3, §6.7) — genuine long-term work
This is the most important honesty gap and it is **more fundamental than missing
labeled data** (see §6.7 for the three findings). The parity harness proves the
Python binding is **byte-identical to the native Rust path** for the bound, **but
untrained**, surface — it does **not** prove the cited SOTA numbers (ADR-152
~96% PCK@20; ADR-024 room-ID > 95% / re-ID mAP > 80% / anomaly F1 > 0.90; ADR-027
cross-domain MPJPE + 20% / domain-gap < 1.5). Those bars remain **CLAIMED, not
MEASURED** by this work.
Closing it requires three steps, in dependency order:
- **(a) Add trained-weight loading to the AETHER/pose bindings — DONE (`65da488ad`).**
`EmbeddingExtractor` gained `save_weights(path)` / `load_weights(path)` /
`param_count` on both the native crate and the Python binding (GIL-released,
`ValueError`/never-panics on bad input), removing the "structurally untrained, no
loading path" blocker: a real checkpoint can now be loaded whenever one exists.
Default construction is unchanged (still random `with_seed` init, clearly labeled
untrained) — purely additive. **Format tradeoff:** rather than pull in
`safetensors`/`serde`/`bincode`, the on-disk format is raw little-endian `f32`
with a 12-byte header (8-byte magic `AETHERW1` + `u32` param count), reusing the
pre-existing `flatten_weights`/`unflatten_weights` — this deliberately preserves
`wifi-densepose-aether`'s zero-dependency std-only leaf-crate property from the
§13.a hoist. **Verified:** `cargo test -p wifi-densepose-aether` 98/98; parity 3/3
incl. the new cross-language golden `aether_weights_parity.rs` (native Rust and the
Python binding load the same weight file and produce a byte-identical embedding
SHA-256, and the loaded weights demonstrably move the output off the random-init
baseline — not a silent no-op); `pytest test_aether.py` 13/13 (up from 9).
**This does NOT close §6.7** — it is the *capability* to load weights, not trained
weights; (b) and (c) below remain, and no SOTA number is validated yet.
- **(b) Commit or source a small labeled CSI fixture** (input CSI + ground-truth
pose/identity/room labels) — **still OPEN.** Genuine **data-acquisition scope**.
- **(c) Build a real eval harness** computing PCK / mAP / room-ID / anomaly-F1 /
Spearman on (a)+(b) and asserting the published bars — **still OPEN.**
With (a) landed, the remaining work is (b) and (c): genuine research /
data-acquisition scope beyond one session. This is now purely a data-availability +
missing-eval-infra problem, **not** a binding defect. Status stays **Proposed**
until (b)(c) land and §4.3 is run for real.
+486
View File
@@ -0,0 +1,486 @@
# ADR-186: Training progress API — wire the orphaned in-server trainer to `/ws/train/progress`
| Field | Value |
|-------|-------|
| **Status** | Accepted |
| **Date** | 2026-07-21 |
| **Deciders** | ruv |
| **Codename** | **TRAIN-RECONNECT** — connecting a trainer that was written, committed, and then never plugged in |
| **Relates to** | [ADR-051](ADR-051-sensing-server-decomposition.md) (main.rs decomposition into ~14 modules), [ADR-151](ADR-151-per-room-calibration.md) (`train-room` specialist bank), [ADR-152](ADR-152-wifi-pose-sota-2026.md) (MAE recipe / geometry conditioning), [ADR-166](ADR-166-quality-engineering-security-hardening.md) (WS auth + god-object decomposition) |
| **Tracking issue** | [#1233](https://github.com/ruvnet/wifi-densepose/issues/1233) — "Training does not start /ws/train/progress returns 404 and no model is generated" (open) |
---
## 1. Context
### 1.1 The reported gap
A user starting training from the web dashboard hits
`ws://localhost:3000/ws/train/progress`, which **404s**, and the backend never
produces a trained `.rvf` model or any further log output beyond a single
"Training started" line. Issue #1233 is open, and the repo owner's own comment on
it states:
> The `/ws/train/progress` WebSocket endpoint is not yet exposed in the stable
> server — the training pipeline (room-calibration specialists, MAE pretraining)
> runs via the CLI (`wifi-densepose train-room`) rather than through the
> HTTP/WebSocket API, which is why the Docker image returns 404 for that path.
So the dashboard has a **"Start Training" button that silently no-ops**: it POSTs a
config, receives a `success: true` response, and then nothing happens — no error is
surfaced, no model is produced, no progress stream exists. A button that appears to
work but does nothing is the definition of slop, and this ADR exists to close that
gap honestly.
### 1.2 What the live server actually does today (evidence)
The stable server mounts **stub** training handlers. The POST handler flips a string
flag, logs one line, and returns success — it starts no job:
```rust
// v2/crates/wifi-densepose-sensing-server/src/main.rs:49865006
async fn train_start(
State(state): State<SharedState>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
let mut s = state.write().await;
if s.training_status == "running" { /* ... */ }
s.training_status = "running".to_string();
s.training_config = Some(body.clone());
info!("Training started with config: {}", body); // ← the one log line the issue reports
Json(serde_json::json!({
"success": true,
"status": "running",
"message": "Training pipeline started. Use GET /api/v1/train/status to monitor.",
}))
}
```
These three stubs — and **nothing else training-related** — are wired into the live
router:
```rust
// v2/crates/wifi-densepose-sensing-server/src/main.rs:80688071
// Training endpoints
.route("/api/v1/train/status", get(train_status))
.route("/api/v1/train/start", post(train_start))
.route("/api/v1/train/stop", post(train_stop))
```
There is **no `/ws/train/progress` route in the live app** — hence the 404 that
issue #1233 reports. The stub state fields backing them are just:
```rust
// v2/crates/wifi-densepose-sensing-server/src/main.rs:11251127
training_status: String, // "idle" | "running" | ...
training_config: Option<serde_json::Value>,
```
### 1.3 The surprising finding: a real trainer already exists, orphaned
The gap is **not** that training was never built for the server. A complete
in-server training pipeline **already exists in the tree** at
`v2/crates/wifi-densepose-sensing-server/src/training_api.rs` (1,860 lines). Its own
module doc describes what it does (`training_api.rs:125`):
- Loads recorded CSI from `.csi.jsonl` files, extracts signal features (subcarrier
variance, temporal gradients, Goertzel frequency-domain power).
- Trains a regularised linear model via batch gradient descent.
- Exports a calibrated `.rvf` model container via `RvfBuilder` on completion.
- **"No PyTorch / `tch` dependency is required. All linear algebra is implemented
inline using standard Rust math."** (`training_api.rs:1113`)
It runs training on a **background tokio task** and streams progress over a
`tokio::sync::broadcast` channel to a real WebSocket handler:
- `start_training` spawns the job: `tokio::spawn(async move { ... })`
(`training_api.rs:1564`, spawn at `:1610`).
- `ws_train_progress_handler` subscribes to `training_progress_tx` and forwards
`{"type":"progress", "data": …}` frames (`training_api.rs:17781836`).
- A `routes()` factory wires the whole surface, **including the missing route**:
```rust
// v2/crates/wifi-densepose-sensing-server/src/training_api.rs:18411849
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/v1/train/start", post(start_training))
.route("/api/v1/train/stop", post(stop_training))
.route("/api/v1/train/status", get(training_status))
.route("/api/v1/train/pretrain", post(start_pretrain))
.route("/api/v1/train/lora", post(start_lora_training))
.route("/ws/train/progress", get(ws_train_progress_handler))
}
```
**This module is dead code.** There is no `mod training_api;` declaration anywhere
in the crate — a repo-wide search for `training_api` returns only a doc-comment
mention in `path_safety.rs:9`. Because Rust never sees the file without a `mod`
declaration, `training_api.rs` is **not compiled into the binary at all**, and
`training_api::routes()` is never merged into the app. It was written, committed
(last touched by commit `9b07dff29`), and then orphaned.
### 1.4 Why it would not even compile if naively wired in
The orphan was written against a **different state shape than the one that shipped**.
`training_api.rs` expects its parent to expose an `AppStateInner` carrying a training
sub-state and a broadcast sender:
```rust
// v2/crates/wifi-densepose-sensing-server/src/training_api.rs:249
pub type AppState = Arc<RwLock<super::AppStateInner>>;
// handlers read s.training_state.status, s.training_state.task_handle,
// s.training_progress_tx (e.g. training_api.rs:1588, :1610, :1788)
```
But the **real** `AppStateInner` (`main.rs:1024`, aliased `SharedState` at
`main.rs:1249`) has none of those fields — only the `training_status: String` /
`training_config` stubs from §1.2. `training_state: TrainingState` is defined
locally in `training_api.rs:232`, and `training_progress_tx` exists nowhere on the
live state. So adding `mod training_api;` today produces a compile error: the module
references `AppStateInner` fields that do not exist. Wiring it in requires
**reconciling the state struct first**, not merely uncommenting a route.
### 1.5 The working path today
The path that actually trains a model is the CLI, exactly as the maintainer's
comment says:
- `wifi-densepose train-room``room.rs:241` `train_room(...)`, the ADR-151
Stage-25 per-room specialist-bank trainer (`enroll → train-room → room-watch`).
- The heavier `wifi-densepose-train` crate exposes epoch-level metrics
(`trainer.rs:43` `pub epoch: usize`, `trainer.rs:64` `best_epoch`) that a progress
stream could surface directly — the data a WebSocket needs already exists in the
training loop.
### 1.6 What this ADR is *not*
- Not a rewrite of the trainer. The pipeline in `training_api.rs` already exists;
this ADR reconnects and hardens it.
- Not a move of GPU/`tch`-backed training into the Axum server. The in-server
trainer is deliberately `tch`-free (§1.3). Heavy MAE/LoRA training stays in the
CLI / `wifi-densepose-train` crate; the server streams progress for the light,
pure-Rust specialist trainer and (optionally) proxies status for CLI-launched runs.
- Not a change to the `train-room` CLI contract (ADR-151). The CLI remains the
authoritative path for offline / batch training.
---
## 2. Current state — evidence
| Artifact | Value | Source |
|---|---|---|
| Live POST handler | `train_start` — flips a flag, logs, returns `success:true`, starts no job | `main.rs:49865006` |
| The "Training started" log line from the issue | `info!("Training started with config: {}", body)` | `main.rs:5000` |
| Live training routes | `train/status`, `train/start`, `train/stop` (stubs only) | `main.rs:80688071` |
| `/ws/train/progress` in live app | **Absent** → 404 | (no route in `main.rs` router) |
| Live training state fields | `training_status: String`, `training_config: Option<Value>` | `main.rs:11251127` |
| Real in-server trainer | 1,860-line implemented pipeline, `tch`-free, exports `.rvf` | `training_api.rs:125` |
| Real WS progress handler | subscribes to broadcast, streams `progress` frames | `training_api.rs:17781836` |
| Real route factory (has the missing route) | `routes()` incl. `/ws/train/progress` | `training_api.rs:18411849` |
| Background job spawn | `tokio::spawn` of the training task | `training_api.rs:1564`, spawn `:1610` |
| `mod training_api;` declaration | **None in the crate** (only a doc mention) | `path_safety.rs:9` |
| State-shape mismatch | expects `super::AppStateInner.{training_state, training_progress_tx}` | `training_api.rs:249`, `:232` |
| Real `AppStateInner` / `SharedState` | has neither field | `main.rs:1024`, `:1249` |
| Working training path | CLI `train-room` (ADR-151 specialist bank) | `room.rs:241` |
| Epoch metrics available to stream | `TrainMetrics.epoch`, `best_epoch` | `train/src/trainer.rs:43`, `:64` |
---
## 3. Gap analysis
| Capability | Desired | Today | Gap severity |
|---|---|---|---|
| `/ws/train/progress` resolves | 101 Switching Protocols, streams epoch/loss/eta | 404 (route absent) | **Critical** — the reported bug |
| "Start Training" produces a model | background job trains and writes `.rvf` | flag flip + one log line, no job, no model | **Critical** |
| Error surfaced to the user | button reflects real state / disabled with reason | silent no-op, `success:true` | **Critical** (slop) |
| In-server trainer compiled | part of the crate, unit-tested | orphaned; not compiled (no `mod`) | **High** |
| State supports progress streaming | `training_state` + `training_progress_tx` on `AppStateInner` | absent — orphan won't compile as-is | **High** |
| WS auth on the training surface | `/ws/train/progress` under bearer gate (ADR-166 §Sprint-1) | n/a (route absent) | **High** |
| `dataset_ids` path safety | validated before file open | `path_safety.rs` exists but unreached by live routes | **Medium** |
| Server ↔ CLI parity | shared/consistent training semantics | two divergent trainers (stub vs CLI vs orphan) | **Medium** |
---
## 4. Decision
**Chosen path: wire the existing in-server trainer into the live server** — reconcile
the state struct, declare the module, merge `training_api::routes()`, delete the
stub handlers, and expose a real `/ws/train/progress` that streams epoch/loss/eta
events from the already-implemented background job.
This is called **TRAIN-RECONNECT**.
### 4.1 Why this path, and not "make the button honestly say CLI-only"
The task framing offered two honest options. Investigation decided it:
| Consideration | Evidence | Implication |
|---|---|---|
| Is server-side training genuinely GPU/`tch`-bound (→ keep CLI-only)? | The in-server trainer is explicitly **`tch`-free**, pure Rust, exports `.rvf` (`training_api.rs:1113`) | The "too heavy for Axum" argument is contradicted by the code |
| Does a real streaming implementation already exist? | Full pipeline + broadcast + WS handler + `routes()` present (`training_api.rs:1564,1778,1841`) | The impressive-sounding option is also the *least* new code — it already exists |
| Why does it 404 then? | No `mod training_api;`; state-shape mismatch (`:249` vs `main.rs:1024`) | The fix is reconnection + reconciliation, not new invention |
Because the honest, code-supported reality is "a working trainer was written and left
unplugged," the right decision is to plug it in — this is not choosing the flashier
option over the code; it *is* what the code says.
**However**, path B is retained as a **mandatory fallback guarantee** (Phase P5): if,
for a given build/deployment, server-side training is disabled (e.g. behind a
feature flag, or on the lightweight appliance image where recordings aren't
available), the dashboard button MUST be disabled with a tooltip pointing at
`wifi-densepose train-room` — never a silent `success:true` no-op again. The slop is
eliminated in both the enabled and disabled configurations.
### 4.2 Scope boundary — light trainer streams, heavy trainer proxies
- The **pure-Rust specialist trainer** (`training_api.rs`, ADR-151 flavour) runs
in-process and streams live epoch/loss/eta over `/ws/train/progress`.
- **Heavy MAE/LoRA training** (`wifi-densepose-train`, `tch`/GPU) stays CLI-launched.
The server does not host it; at most `/api/v1/train/status` reports on a
CLI-launched run if one registers itself. Streaming heavy training is out of scope
for this ADR (noted as an open question, §8).
---
## 5. Detailed design
### 5.1 Reconcile `AppStateInner`
Replace the two stub fields (`main.rs:11251127`) with the sub-state the trainer
expects, so `training_api.rs` compiles against `super::AppStateInner`:
```rust
// main.rs — inside AppStateInner (replacing training_status / training_config)
training_state: training_api::TrainingState, // status, epoch, best_pck, task_handle
training_progress_tx: tokio::sync::broadcast::Sender<String>, // progress fan-out
```
`train_status` consumers that read `s.training_status` / `s.training_config` are
updated to read `s.training_state.status`. The broadcast sender is created at state
init (`main.rs:7826` region, where the stubs are seeded today).
### 5.2 Declare and merge the module
- Add `mod training_api;` to `main.rs` (or `pub mod` in `lib.rs` if the router is
assembled there).
- Delete the stub handlers `train_start` / `train_stop` / `train_status`
(`main.rs:49775023`) and their three route mounts (`main.rs:80698071`).
- Merge the real router **after** `.with_state(state.clone())`, the same pattern the
RuField surface already uses (`main.rs:81048111`):
```rust
// main.rs router assembly
.merge(training_api::routes())
```
so that `/api/v1/train/*` and `/ws/train/progress` resolve against the shared state.
### 5.3 Auth and safety (ADR-166 alignment)
- `/api/v1/train/*` sits under the existing opt-in bearer gate (`main.rs:80958102`,
`RUVIEW_API_TOKEN`). `/ws/train/progress` follows the same policy decision made for
`/ws/sensing` — document explicitly whether the training WS is gated (recommended:
gated when a token is set, since training reads/writes recordings and models).
- `dataset_ids` from `StartTrainingRequest` (`training_api.rs:126130`) are resolved
through `path_safety` before any file open — `path_safety.rs:9` already anticipates
`{dataset_id}.csi.jsonl` under `RECORDINGS_DIR`; wire it in the load path.
- Single-job concurrency guard: `start_training` already rejects a second run while
`training_state.status.active` (`training_api.rs:1571`) — keep it.
### 5.4 Progress event schema (already emitted)
The WS handler already frames messages as `{"type":"status"|"progress", "data": …}`
(`training_api.rs:17961815`). Confirm the `data` payload carries at minimum
`epoch`, `total_epochs`, `loss`, `best_pck`, and an `eta_seconds`; these map onto the
`TrainMetrics`/`TrainingStatus` fields already populated by the loop
(`training_api.rs:1251`, `train/src/trainer.rs:43,64`).
### 5.5 Dashboard honesty (both configurations)
- **Enabled build:** button POSTs `/api/v1/train/start`, then opens
`/ws/train/progress`; the UI renders live epoch/loss/eta and a terminal
success/failure with the output `.rvf` path.
- **Disabled build:** `/api/v1/train/start` returns a structured
`{"enabled": false, "reason": "...", "cli": "wifi-densepose train-room"}` and the
button renders disabled with a tooltip — no silent `success:true`.
---
## 6. Phase ledger
```
P0 ──► P1 ──► P2 ──► P3 ──► P4 ──► P5 ──► P6
repro state wire stream auth+ dash tests+
+audit recon router job safety honesty witness
```
### P0 — Reproduce & audit (evidence lock)
- [x] Confirmed the orphan: `grep -rn "mod training_api"` returned **nothing**; the only
hit was a doc mention in `path_safety.rs`. `training_api.rs` was uncompiled.
- [x] Confirmed the stub no-op (`train_start` at `main.rs:4986` flipped a string + logged
one line, no job, no `.rvf`) and the missing `/ws/train/progress` route.
### P1 — Reconcile `AppStateInner`
- [x] Replaced `training_status`/`training_config` with `training_state:
training_api::TrainingState` + `training_progress_tx: broadcast::Sender<String>`.
- [x] Updated state init; the only readers of the old fields were the stub handlers (deleted).
- [x] Added `mod training_api;` (+ `mod path_safety;`); the module compiles against the real state.
### P2 — Wire the router, delete the stubs
- [x] Removed `train_start`/`train_stop`/`train_status` and their 3 route mounts.
- [x] `.merge(training_api::routes())` — merged **before** `.with_state(...)` (not after).
The RuField surface merges after because it carries a *different* state; the training
router shares `SharedState`, so merging before is what puts `/api/v1/train/*` under the
same `/api/v1/*` bearer gate as everything else.
- [x] `/api/v1/train/*` and `/ws/train/progress` resolve (verified by HTTP tests, not 404).
### P3 — Confirm the real job streams and produces a model
- [x] The spawned job loads `.csi.jsonl` (falls back to a `frame_history` snapshot),
runs the gradient-descent loop, and writes a `.rvf` under `data/models`.
- [x] Progress frames carry `epoch`, `total_epochs`, `train_loss`, `val_pck`, `eta_secs`.
- [x] Server-vs-CLI semantics documented as **intentionally divergent** (§4.2, §9.2):
the server runs the light pure-Rust specialist trainer; heavy MAE/LoRA stays CLI.
### P4 — Auth & path safety
- [x] `/api/v1/train/*` sits under the existing `RUVIEW_API_TOKEN` bearer gate (merged
before `.with_state`); `/ws/train/progress` is intentionally **ungated**, matching
`/ws/sensing` (browsers can't attach an `Authorization` header to a WS upgrade).
- [x] `dataset_ids` resolved via `path_safety::safe_id` before file open; pinned by
`load_recording_frames_rejects_path_traversal`.
- [x] Single-job guard: `spawn_training_job` rejects a second start while active
(`is_active()` → `active_error`).
### P5 — Dashboard honesty (fallback guarantee)
- [x] Enabled build: `TrainingPanel` opens `/ws/train/progress` before the POST and renders
live epoch/loss/PCK/ETA + a terminal Complete state (already wired; verified).
- [x] Disabled build (`RUVIEW_DISABLE_SERVER_TRAINING`): start returns
`{enabled:false, cli:"wifi-densepose train-room"}` HTTP 409; the dashboard reads
`enabled` off `/api/v1/train/status` and disables the Start buttons with a CLI
tooltip — no silent no-op. Implemented via a runtime flag rather than a Cargo feature
so the `--no-default-features` test build keeps training ON (§9.4 resolved this way).
### P6 — Tests & witness
- [x] Live-socket test `ws_train_progress_live_101_and_frame`: genuine 101 handshake + a real
progress frame after POST start. Plus `ws_train_progress_route_is_wired_not_404`.
- [x] `http_train_start_produces_model_and_streams`: POST start → poll status → `.rvf` exists.
- [x] CHANGELOG updated. README/CLAUDE have no training route table, so no route-table edit
was needed there.
*(All phases complete. Acceptance criteria verified below — this ADR is Accepted.)*
---
## 7. Acceptance criteria (concrete verification)
All must pass before ADR-186 is Accepted:
- [x] **Orphan is reconnected:**
`grep -rn "mod training_api" v2/crates/wifi-densepose-sensing-server/src/`
returns a hit (`main.rs`), and
`cargo build -p wifi-densepose-sensing-server` **compiles** (proves the state
reconciliation in §5.1 is correct — the module cannot compile against the
current `AppStateInner`). **VERIFIED.**
- [x] **Route no longer 404s (HTTP upgrade):** verified in-process rather than with a live
`curl` — `ws_train_progress_live_101_and_frame` binds the training router on a real
socket and `tokio_tungstenite::connect_async` completes a genuine **101** handshake
(asserts `resp.status() == 101`); `ws_train_progress_route_is_wired_not_404` also
confirms the route is reached (426 under `oneshot`, **not** 404). **VERIFIED.**
- [x] **Progress actually streams:** `ws_train_progress_live_101_and_frame` connects the WS,
POSTs `/api/v1/train/start`, and receives a real `{"type":"progress","data":{...}}`
frame within the 10 s ceiling. **VERIFIED.**
- [x] **A model is produced:** `http_train_start_produces_model_and_streams` POSTs start,
polls `/api/v1/train/status` to completion, and asserts a **new `.rvf`** appeared under
`data/models/` (snapshot diff). Also covered by the trainer-level
`training_job_streams_real_progress_and_writes_model`. **VERIFIED.**
- [x] **No silent no-op remains:** `http_train_start_disabled_returns_structured_409` sets
`RUVIEW_DISABLE_SERVER_TRAINING` and asserts POST start returns **HTTP 409** with
`{"enabled":false, ...,"cli":"wifi-densepose train-room"}` and never `success:true`.
**VERIFIED.**
- [x] **Auth honored:** `/api/v1/train/*` is merged into the router **before** the
`RUVIEW_API_TOKEN` bearer middleware and `.with_state`, so it is covered by the exact
same `/api/v1/*` gate as every other authenticated route (verified by construction /
code review; `/ws/train/progress` is intentionally ungated like `/ws/sensing`). No new
dedicated runtime token test was added — the gate is the shared, already-tested
`bearer_auth` middleware. **VERIFIED (by construction).**
- [x] **Path safety:** `load_recording_frames_rejects_path_traversal` asserts
`dataset_ids:["../../etc/passwd"]` yields no frames (rejected by `path_safety::safe_id`
before any file open). **VERIFIED.**
- [x] **Integration test green:** `ws_train_progress_live_101_and_frame` (`#[tokio::test]`)
serves the training router, opens `/ws/train/progress`, and asserts a 101 upgrade + a
real progress frame — and, being built on `training_api::routes()`, cannot compile if
the module is orphaned again. **VERIFIED.**
- [x] **Workspace regression:** `cargo test -p wifi-densepose-sensing-server
-p wifi-densepose-train --no-default-features` — sensing-server bin **217 passed /
0 failed**, all train suites **0 failed**. A full `cargo test --workspace
--no-default-features` run initially surfaced a **test-only parallelism race** in the
new tests (two model-writing tests deleted `.rvf`s by directory-diff, occasionally
removing a file a third test asserted existed) — fixed by removing the cross-test
deletions (each test cleans only its own artifact; `data/models` is gitignored).
Re-verified post-fix: `cargo test --workspace --no-default-features` — **0 failed**
(exit 0). **VERIFIED.**
---
## 8. Consequences
### Positive
- Closes issue #1233: the dashboard button either trains-and-streams or honestly says
"use the CLI" — the silent no-op is gone in every configuration.
- Reclaims 1,860 lines of already-written, already-committed trainer that were dead
(uncompiled) code, and adds a test that keeps them wired.
- `/ws/train/progress` gives the UI real epoch/loss/eta, matching the maintainer's
stated intent.
- Forces the state-shape reconciliation that the orphan implied but never landed,
removing a latent "two competing training designs" trap in `AppStateInner`.
### Negative
- Editing `AppStateInner` (`main.rs:1024`) and the router (`main.rs:8068`) touches the
large `main.rs`; merge-conflict risk with concurrent work on the same file (the
ADR-166 decomposition is relevant here).
- Adds a live training code path to the server's attack surface — mitigated by the
bearer gate and `path_safety`, but it must be reviewed (network/hardware boundary,
per the pre-merge security checklist).
- Server and CLI now have two trainers that must be kept semantically consistent, or
their divergence explicitly documented.
### Neutral
- Heavy MAE/LoRA/`tch` training remains CLI-only; the server streams only the
light pure-Rust specialist trainer. Streaming heavy runs is deferred.
- The progress event schema (`epoch/loss/best_pck/eta`) is already emitted by the
orphan; no new schema is invented, only confirmed and documented.
---
## 9. Open questions
1. **WS auth policy for `/ws/train/progress`:** gate it whenever `RUVIEW_API_TOKEN`
is set (like `/api/v1/*`), or leave it open like `/ws/sensing`? *Tentative: gate
it — training reads recordings and writes models.*
2. **Server ↔ CLI trainer parity:** should the in-server trainer and
`wifi-densepose train-room` (ADR-151) share one code path, or remain deliberately
separate (server = quick UI-driven specialist fit; CLI = full bank + geometry
conditioning)? *Tentative: keep separate, document the split, share feature
extraction where cheap.*
3. **Heavy-training progress:** can a CLI-launched `wifi-densepose-train` (`tch`)
run register itself so `/api/v1/train/status` and the WS can report on it without
hosting it in-process? *Tentative: out of scope here; a follow-on ADR.*
4. **Feature-flagging server training:** should in-server training be behind a Cargo
feature (off on the lightweight appliance image), making the P5 disabled-button
path the default there? *Tentative: yes — flag it; default the UI to the honest
disabled state on images without recordings.*
---
## 10. References
- **Issue #1233**: https://github.com/ruvnet/wifi-densepose/issues/1233 — the reported bug.
- **Live stubs**: `v2/crates/wifi-densepose-sensing-server/src/main.rs:49775023` (handlers),
`:80688071` (routes), `:11251127` (state fields), `:1024`/`:1249` (`AppStateInner`/`SharedState`).
- **Orphaned trainer**: `v2/crates/wifi-densepose-sensing-server/src/training_api.rs` —
module doc `:125`, `TrainingState` `:232`, `AppState` alias `:249`, `start_training` `:1564`
(spawn `:1610`), WS handler `:17781836`, `routes()` `:18411849`.
- **Not-a-module proof**: repo-wide `training_api` only in `path_safety.rs:9` (doc comment).
- **CLI working path**: `v2/crates/wifi-densepose-cli/src/room.rs:241` `train_room` (ADR-151).
- **Epoch metrics**: `v2/crates/wifi-densepose-train/src/trainer.rs:43`, `:64`.
- **ADR-166**: WebSocket authentication + `main.rs` decomposition (security context for this change).
- **ADR-151**: per-room calibration / `train-room` specialist bank.
@@ -0,0 +1,198 @@
# ADR-187: `archive/v1` Deprecation & Model-Weights Honest Labeling
- **Status**: Accepted
- **Date**: 2026-07-21
- **Deciders**: ruv
- **Tags**: archive-v1, deprecation, densepose-head, model-weights, honest-labeling, prove-everything, credibility, pip-tombstone
- **Refs**: [#509](https://github.com/ruvnet/RuView/issues/509) (missing model weights / reproducibility), [#1125](https://github.com/ruvnet/RuView/issues/1125) ("has anyone got this to work?")
- **Relates to**: [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (pip modernization + 1.99.0 tombstone), [ADR-160](ADR-160-edge-skill-library-honest-labeling.md) (honest-labeling precedent), [ADR-079](ADR-079-camera-ground-truth-training.md) (camera-supervised pose target), [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) (WiFlow-STD PCK@20 measurement), [ADR-175](ADR-175-int8-quantization-half-pose-model-measured.md) (int8 pose trade-off), [ADR-101](ADR-101-pose-estimation-cog.md) (pose cog)
---
## Context
Two open GitHub issues are, at root, the same complaint: the project's public surface
lets a reader believe a WiFi→17-keypoint pose model exists and produces real accuracy,
when the specific code they land on cannot back that claim.
- **#509** — a detailed technical review states: *"While the network architecture for
DensePoseHead is defined in the code, there are no pre-trained weights (.pth or .onnx
files) available in the repository,"* and questions whether ESP32 1×1 SISO antennas
can match the multi-antenna NIC research this project is inspired by.
- **#1125** — a user asks for anyone to testify the project actually runs and returns
real data. A pure credibility complaint.
This ADR follows the **prove-everything / anti-"AI-slop"** directive and the
**honest-labeling** precedent set by ADR-160: the fix is to make the labels TRUE, not
to fabricate a capability. Grading vocabulary (from ADR-152 / ADR-160):
- **MEASURED** — reproduced in this worktree; the file/absence was directly inspected.
- **DATA-GATED** — a real code path exists; honestly flagged where the accuracy is not validated.
- **NO-ACTION (already-honest)** — audited, found correct, cited as a positive.
### What the investigation actually found (MEASURED in this worktree)
The situation is **more nuanced than either issue implies** — worse in one place, and
distinctly *better* in others. Forcing a uniformly negative narrative would itself be
dishonest. The findings:
**1. `archive/v1` — the issue reporter is correct here.**
- `archive/v1/src/models/densepose_head.py` defines `DensePoseHead` (segmentation +
UV-regression heads). Its `_initialize_weights()` uses **`kaiming_normal_` random
initialization only** — there is no checkpoint-loading path in the class.
- `Glob archive/v1/**/*.{pth,onnx,safetensors,pt,ckpt,bin}`**zero files**. There are
**no trained weights anywhere under `archive/v1/`.** The "architecture defined, no
weights" claim is TRUE for this tree.
- `archive/v1/README.md` calls the tree "the legacy Python implementation" in a single
closing note but does **not** loudly warn users off it, and there is **no
`archive/v1/DEPRECATED.md`.** This is the dead-but-present code that shows up in greps
and search and reads as if it were the live implementation.
- Per ADR-117, this exact tree is the source of the tombstoned pip package
`wifi-densepose 1.x` (1.99.0 raises an `ImportError` telling users to migrate). The
code is already tombstoned *on PyPI* but not *in the repo*.
**2. `v2` (the current, maintained system) — real weights DO exist; the "no weights
anywhere" reading is FALSE at the project level.** Git-tracked, committed checkpoints:
- `v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors` (507 KB) +
`pose_v1.onnx` (12 KB) + `train_results.json` — a **real committed 17-keypoint
model**, trained with Candle on an RTX 5080.
- `v2/crates/cog-person-count/cog/artifacts/count_v1.{safetensors,onnx}` — a committed
person-count model.
- Externally published on Hugging Face (not committed, but real and released):
`ruvnet/wifi-densepose-pretrained` (CSI encoder + presence head, honestly re-labeled
at **82.3% held-out temporal-triplet accuracy** — the older "100% presence" figure was
already retracted, an existing honest-labeling win) and `ruvnet/wifi-densepose-mmfi-pose`
(a pose model reporting **82.69% torso-PCK@20** on the MM-Fi `random_split` protocol).
- ADR-152 measurement (a): the *external* WiFlow-STD (DY2434) model was reproduced at
**96.09% PCK@20** on an RTX 5080 (graded MEASURED-EQUIVALENT). That is an external
baseline, not RuView's own weights.
**3. The honest gap is narrow and specific — the live, on-device ESP32 17-keypoint
pose path.** Per `v2/crates/cog-pose-estimation/cog/README.md` (already an exemplary
"Honest reading" section):
- The committed `pose_v1` scores **PCK@20 = 3.0% / PCK@50 = 18.5%** on a 217-sample
holdout — **below the ADR-079 target of PCK@20 ≥ 35%.** It learns coarse structure
(`r_hip` 77% PCK@50) but distal/face joints are near-random. `encoder_init` was
`random`; it was trained on a single 30-min seated-at-desk recording (1,077 samples,
avg confidence 0.44).
- The cog's **runtime inference path is still a centred-skeleton stub returning
`confidence=0`** — the `pose_v1.safetensors` weights are not yet wired into
`src/inference.rs`.
- ADR-079 records the proxy-supervised baseline at **PCK@20 = 2.5%**, and ADR-152
**retracted** the internal camera-supervised 92.9% PCK@20 figure (it was a
constant-output model scored under an absolute threshold on near-static frames; a mean
predictor scores 100% under the same broken protocol).
### The real problem to fix
Not "the project has no weights" (false) and not "there is a validated pretrained
DensePoseHead" (false for the live ESP32 path). The real problem is a **labeling and
navigation gap**:
1. `archive/v1`'s random-init `DensePoseHead` is indistinguishable, to a grepping
reader, from the live implementation, and carries no deprecation notice.
2. Nowhere is the split stated plainly: *which* checkpoints are real and validated
(presence 82.3%, MM-Fi pose 82.69% torso-PCK@20), *which* are real-but-weak and
honestly labeled (`pose_v1` 3% PCK@20, runtime stubbed), and *which* are
architecture-only with no weights at all (`archive/v1` `DensePoseHead`).
## Decision
Two coordinated honest-labeling actions. Neither invents a capability; both make the
public surface match what the code and checkpoints actually deliver.
### (a) Formally deprecate `archive/v1` in the repo — MEASURED gap, proposed fix
- **Add `archive/v1/DEPRECATED.md`** — a loud tombstone stating that `archive/v1` is the
original pure-Python implementation, is **unmaintained and superseded**, that its
`DensePoseHead` is **architecture-only with random-initialized weights and ships no
trained checkpoint**, and that the maintained path is the `v2/` Rust workspace + the
`wifi-densepose 2.x` / `ruview` pip wheel (ADR-117). Mirror the disclaimer tone of
ADR-160's `//!` headers and the pip 1.99.0 tombstone text.
- **Prepend a loud notice to `archive/v1/README.md`** (the file exists) — a `> ⚠️
DEPRECATED` block at the very top pointing to `DEPRECATED.md`, `v2/`, and the pip
wheel, before any of the existing "how to install v1" content.
- **Rule:** no doc outside `archive/v1/` may reference `archive/v1` code (other than the
ADR-028 deterministic proof at `archive/v1/data/proof/verify.py`, which is a
legitimately live signal-pipeline witness and stays) as if it were current. The two
README references verified (`README.md` lines 139/198/204; `docs/user-guide.md`
proof/swift-compile lines) are all proof/utility invocations, not implementation
claims — they are acceptable and out of scope.
### (b) Model-weights honest labeling — state the three tiers explicitly
Add a **"Model weights: what's real, what's not"** subsection to `README.md` and
`docs/user-guide.md` that names the three tiers verified above, so no reader can infer
"a pretrained 17-keypoint DensePoseHead produces real pose accuracy on my ESP32":
| Tier | Checkpoint(s) | Honest status |
|------|---------------|---------------|
| **Real & validated** | `ruvnet/wifi-densepose-pretrained` (encoder + presence, 82.3% held-out temporal-triplet); `ruvnet/wifi-densepose-mmfi-pose` (82.69% torso-PCK@20, MM-Fi `random_split`); `count_v1` | MEASURED / published; keep current honest labels |
| **Real but weak (honestly labeled)** | committed `pose_v1.safetensors` in `cog-pose-estimation` | **PCK@20 = 3.0%**, below the ADR-079 ≥35% target; runtime path is a `confidence=0` stub until weights are wired into `src/inference.rs`. Already disclosed in the cog README; surface the same caveat wherever the live ESP32 pose feature is advertised |
| **Architecture only, no weights** | `archive/v1` `DensePoseHead` | random-init, no checkpoint; deprecated per (a) |
- The existing MM-Fi/presence honest labels (retraction of "100% presence", the cog
"Honest reading") are **NO-ACTION positives** — cite them, do not weaken them.
- The live ESP32 17-keypoint claim stays **DATA-GATED**: the path to a first
*reproducible* on-device baseline is ADR-079 (multi-session, full-body-framed,
camera-supervised, ≥30K paired samples at conf ≥0.7, target PCK@20 ≥35%), tracked in
[#645]. Do not advertise the live ESP32 pose feature without the "first-cut / below
target / runtime stub" caveat until that baseline is MEASURED.
- Directly answer #509's ESP32-SISO question in the docs, honestly: single-antenna 56-
subcarrier CSI at a 20-frame window does **not** carry the fine-grained spatial
information the multi-antenna NIC research relies on (the cog README already shows
distal/face joints near-random) — the shippable pose accuracy the project *can* stand
behind today is the **MM-Fi benchmark** number, not a live single-ESP32 number.
## Phase ledger
| Phase | Action | State |
|-------|--------|-------|
| **P0** | This ADR (investigation + decision) | **DONE** (this file) |
| **P1** | Add `archive/v1/DEPRECATED.md` + loud notice atop `archive/v1/README.md` | **DONE** (1fb5397dd) |
| **P2** | Add "Model weights: what's real, what's not" tier table to `README.md` + `docs/user-guide.md`; add the caveat wherever the live ESP32 17-keypoint feature is advertised | **DONE** (1fb5397dd; follow-up caveated the hardware table, hero caption, and live-pipeline note) |
| **P3** | Answer #509's SISO/no-weights question and #1125's "does it run" in `docs/user-guide.md` (point to the reproducible proofs: MM-Fi arena, `archive/v1/data/proof/verify.py`, cog `train_results.json`) | **DONE** (1fb5397dd) |
| **P4** | Close the DATA-GATED live-pose gap via ADR-079 first reproducible on-device baseline (PCK@20 ≥35%) + wire `pose_v1.safetensors` into `cog-pose-estimation/src/inference.rs` | ACCEPTED-FUTURE ([#645]) |
## Acceptance criteria
- [x] `archive/v1/DEPRECATED.md` exists and names `v2/` + the pip wheel as the maintained path.
- [x] `archive/v1/README.md` opens with a `> ⚠️ DEPRECATED` block before any install instructions.
- [x] `README.md` and `docs/user-guide.md` no longer let a reader infer that `archive/v1`
or an untrained/random-init `DensePoseHead` produces real pose accuracy without the
caveats added here.
- [x] The live ESP32 17-keypoint pose feature is nowhere advertised without its
"first-cut, PCK@20 = 3.0%, below ADR-079 target, runtime stub" caveat.
- [x] The three real/published checkpoints (presence 82.3%, MM-Fi pose 82.69% torso-PCK@20,
`count_v1`) keep their existing honest labels — nothing is weakened or overclaimed.
- [x] No claim is added that is not MEASURED or explicitly DATA-GATED.
## Consequences
### Positive
- A grepping reader can no longer mistake `archive/v1`'s random-init `DensePoseHead` for
the live system; the dead code is loudly tombstoned in the repo, matching its PyPI 1.99.0 tombstone.
- #509 and #1125 get an honest, verifiable answer: real trained weights *do* exist
(presence + MM-Fi pose are published and benchmarked), the *specific* file the reporter
found is architecture-only, and the live ESP32 pose path is honestly weak-and-in-progress.
- Reinforces the ADR-160 honest-labeling discipline: the project's credibility comes from
precise labels, not from a suppressed or inflated narrative.
### Negative
- The docs must openly state that the live single-ESP32 17-keypoint pose is not yet at a
citable accuracy — a short-term "looks less finished" cost, paid for by not overclaiming.
- Two more files to keep in sync (`DEPRECATED.md`, the tier table) as the checkpoints evolve.
### Neutral
- No code or model behavior changes; `archive/v1` stays in the tree as a research archive
(ADR-117 §1.3) and its ADR-028 proof witness is untouched.
- Purely documentation/labeling; no crate, wheel, or firmware rebuild required.
## References
- `archive/v1/src/models/densepose_head.py` — `DensePoseHead`, random `_initialize_weights()`, no checkpoint load.
- `archive/v1/README.md` — legacy note; no loud deprecation (target of P1).
- `v2/crates/cog-pose-estimation/cog/README.md` — the "Honest reading" precedent (PCK@20 = 3.0%, runtime stub).
- `v2/crates/cog-pose-estimation/cog/artifacts/{pose_v1.safetensors,pose_v1.onnx,train_results.json}` — committed first-cut pose model.
- `v2/crates/cog-person-count/cog/artifacts/count_v1.{safetensors,onnx}` — committed count model.
- `ruvnet/wifi-densepose-pretrained`, `ruvnet/wifi-densepose-mmfi-pose` — published, benchmarked checkpoints.
- ADR-079 §Target (PCK@20 ≥35%), ADR-152 measurement (a) (96.09% PCK@20 external; internal 92.9% retracted), ADR-160 (honest-labeling method), ADR-117 (pip 1.99.0 tombstone).
+5 -1
View File
@@ -2,10 +2,14 @@
Latest proposed decisions:
- [ADR-187: archive/v1 deprecation + model-weights honest labeling](ADR-187-archive-v1-deprecation-honest-labeling.md) (refs #509, #1125)
- [ADR-186: Training progress API — wire the orphaned in-server trainer to /ws/train/progress](ADR-186-training-progress-api.md) (refs #1233)
- [ADR-185: Python P6 SOTA bindings — AETHER, MERIDIAN, MAT](ADR-185-python-p6-sota-bindings.md)
- [ADR-184: ADR-117 completion via PyPI Trusted Publishing](ADR-184-adr117-completion-pypi-trusted-publishing.md) (refs #785)
- [ADR-264: Versioned wire protocol for RTL8720F CFR and Range-FFT reports](ADR-264-rtl8720f-radar-wire-protocol.md)
- [ADR-263: Adopt RTL8720F 2.4 GHz FMCW radar as an optional RuView sensing platform](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md)
This folder contains 182 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
This folder contains 193 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
## Why ADRs?
+14 -1
View File
@@ -1141,7 +1141,20 @@ What it ships (and what it does not):
| Presence detection (occupied / empty) | ✅ Trained head — v2 encoder reports 82.3% held-out temporal-triplet acc (v1's "100% on validation" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
| 128-dim CSI embeddings (re-ID, similarity, downstream training) | ✅ Trained encoder |
| Single-person breathing / heart-rate | ⚠️ Server still uses heuristic DSP — model does not replace this yet |
| 17-keypoint full-body pose | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
| 17-keypoint full-body pose | 🔬 This HF bundle ships no keypoint head — but real pose weights exist elsewhere; see the tier table below |
### Model weights: what's real, what's not
"WiFi → pose" means three different things in this repo, at three different maturity
levels. Read the label, not the headline ([ADR-187](adr/ADR-187-archive-v1-deprecation-honest-labeling.md)):
| Tier | Checkpoint(s) | Honest status |
|------|---------------|---------------|
| **Real & validated** | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) (encoder + presence head) · [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) (17-keypoint pose) · `cog-person-count/count_v1` | **MEASURED / published.** Presence = 82.3% held-out temporal-triplet accuracy (the old "100% presence" figure was retracted); MM-Fi pose = 82.69% torso-PCK@20 on the `random_split` protocol. |
| **Real but weak (honestly labeled)** | committed `v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors` | First-cut on-device model. **PCK@20 = 3.0% / PCK@50 = 18.5%** on a 217-sample holdout — **below the ADR-079 target of ≥ 35%.** Learns coarse structure (`r_hip` 77% PCK@50); distal/face joints near-random. Its runtime path in `cog-pose-estimation/src/inference.rs` is still a centred-skeleton **stub returning `confidence=0`**. Full disclosure in the [cog README](../v2/crates/cog-pose-estimation/cog/README.md). Do not advertise the live single-ESP32 17-keypoint feature without this caveat. |
| **Architecture only, no weights** | `archive/v1` `DensePoseHead` | Random `kaiming_normal_` init, **no checkpoint of any kind** (zero `.pth`/`.onnx`/`.safetensors` files under `archive/v1/`). Deprecated and superseded — see [`archive/v1/DEPRECATED.md`](../archive/v1/DEPRECATED.md). Do not expect real pose output from it. |
**Does it actually run, and can a single ESP32 do pose? ([#509](https://github.com/ruvnet/RuView/issues/509), [#1125](https://github.com/ruvnet/RuView/issues/1125))** Yes, it runs, and the results are reproducible: the deterministic signal-pipeline proof (`python archive/v1/data/proof/verify.py`, must print `VERDICT: PASS`), the committed pose training dump (`v2/crates/cog-pose-estimation/cog/artifacts/train_results.json`), and the auditable MM-Fi arena all back specific numbers. But a single-antenna, 56-subcarrier CSI stream at a 20-frame window does *not* carry the fine-grained spatial information the multi-antenna NIC research relies on — so the shippable pose accuracy the project stands behind today is the **MM-Fi benchmark number**, not a live single-ESP32 number. The path to a first reproducible on-device baseline (PCK@20 ≥ 35%) is tracked in [ADR-079](adr/ADR-079-camera-ground-truth-training.md) / [#645](https://github.com/ruvnet/RuView/issues/645).
### Download