mirror of
https://github.com/ruvnet/RuView
synced 2026-07-24 17:43:20 +00:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9dc0c726d | |||
| 99700c7851 | |||
| 89babb00a9 | |||
| 544b746895 | |||
| fac8cbc129 | |||
| 788d685b9f | |||
| bc0e8fd031 | |||
| 2febbb813c | |||
| 3ed43e9a2f | |||
| 56327d0931 | |||
| 1ed0bc57ef | |||
| f7cc68bd5c | |||
| 89cceaf835 | |||
| 6ce50d5158 | |||
| c72bbc15dd | |||
| 9b9754778f | |||
| 43737941cb | |||
| 4a704acc02 | |||
| 9299a3b137 | |||
| 347698b67c | |||
| 705a167ffe | |||
| b09625ece7 | |||
| 0547fd7344 | |||
| f67a880a1a | |||
| 7a05417493 | |||
| 3347e258e6 | |||
| eb68e07a2c | |||
| 6300b1cbd2 | |||
| 7d6d66941a | |||
| 6d3fb88677 | |||
| 12635a85b2 | |||
| 714dae9a2c | |||
| 31fb3d53f6 | |||
| 408caf35fa | |||
| c2bd33e649 | |||
| 92cbeb0c34 | |||
| 499cec7914 | |||
| 6fd185f8ce | |||
| 36a27b3211 | |||
| 82d5c73396 | |||
| 34d929ae39 | |||
| 65da488add | |||
| e0cb7ed3b3 | |||
| 8409f434c9 | |||
| d99ea153ff | |||
| a47bb71b22 | |||
| f74e73ccf7 | |||
| 99fea9df9b | |||
| daa82092a0 | |||
| 7ed57f0415 | |||
| 65648fe4c2 | |||
| a4a3f456e1 | |||
| 0f405213d3 | |||
| 1c9727f9cf | |||
| 189ac9dfb0 | |||
| 569cd237fb | |||
| d060998e3b | |||
| 5426fe830c | |||
| b1417fb6e5 | |||
| 1fb5397ddf | |||
| dfc4c1abd6 | |||
| cc153e8b56 | |||
| cca5bd8113 | |||
| c6b4d36ba3 | |||
| abf6d2aee0 | |||
| 601370d61a | |||
| 5dbb7f0d02 | |||
| f8ab1fef94 |
+34
-22
@@ -1,14 +1,10 @@
|
||||
name: Continuous Deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags: [ 'v*' ]
|
||||
workflow_run:
|
||||
workflows: ["Continuous Integration"]
|
||||
workflows: ["wifi-densepose sensing-server → Docker Hub + ghcr.io"]
|
||||
types:
|
||||
- completed
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
@@ -19,6 +15,11 @@ on:
|
||||
options:
|
||||
- staging
|
||||
- production
|
||||
image_tag:
|
||||
description: 'Existing ghcr.io/ruvnet/wifi-densepose tag to deploy'
|
||||
required: true
|
||||
default: 'latest'
|
||||
type: string
|
||||
force_deploy:
|
||||
description: 'Force deployment (skip checks)'
|
||||
required: false
|
||||
@@ -27,7 +28,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
IMAGE_NAME: ruvnet/wifi-densepose
|
||||
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }}
|
||||
|
||||
jobs:
|
||||
@@ -35,7 +36,9 @@ jobs:
|
||||
pre-deployment:
|
||||
name: Pre-deployment Checks
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch'
|
||||
if: |
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
outputs:
|
||||
deploy_env: ${{ steps.determine-env.outputs.environment }}
|
||||
image_tag: ${{ steps.determine-tag.outputs.tag }}
|
||||
@@ -43,6 +46,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
submodules: recursive
|
||||
|
||||
- name: Determine deployment environment
|
||||
@@ -50,14 +54,12 @@ jobs:
|
||||
env:
|
||||
# Use environment variable to prevent shell injection
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
PUBLISHED_REF: ${{ github.event.workflow_run.head_branch }}
|
||||
GITHUB_INPUT_ENVIRONMENT: ${{ github.event.inputs.environment }}
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||
echo "environment=$GITHUB_INPUT_ENVIRONMENT" >> $GITHUB_OUTPUT
|
||||
elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
||||
echo "environment=staging" >> $GITHUB_OUTPUT
|
||||
elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
elif [[ "$PUBLISHED_REF" == v* ]]; then
|
||||
echo "environment=production" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "environment=staging" >> $GITHUB_OUTPUT
|
||||
@@ -65,16 +67,23 @@ jobs:
|
||||
|
||||
- name: Determine image tag
|
||||
id: determine-tag
|
||||
env:
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
PUBLISHED_REF: ${{ github.event.workflow_run.head_branch }}
|
||||
PUBLISHED_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
INPUT_IMAGE_TAG: ${{ github.event.inputs.image_tag }}
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||
echo "tag=$INPUT_IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
elif [[ "$PUBLISHED_REF" == v* ]]; then
|
||||
echo "tag=$PUBLISHED_REF" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
echo "tag=sha-${PUBLISHED_SHA:0:7}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Verify image exists
|
||||
run: |
|
||||
docker manifest inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.determine-tag.outputs.tag }}
|
||||
docker manifest inspect "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.determine-tag.outputs.tag }}"
|
||||
|
||||
# Deploy to staging
|
||||
deploy-staging:
|
||||
@@ -129,7 +138,10 @@ jobs:
|
||||
name: Deploy to Production
|
||||
runs-on: ubuntu-latest
|
||||
needs: [pre-deployment, deploy-staging]
|
||||
if: needs.pre-deployment.outputs.deploy_env == 'production' || (github.ref == 'refs/tags/v*' && needs.deploy-staging.result == 'success')
|
||||
if: |
|
||||
always() &&
|
||||
needs.pre-deployment.result == 'success' &&
|
||||
needs.pre-deployment.outputs.deploy_env == 'production'
|
||||
environment:
|
||||
name: production
|
||||
url: https://wifi-densepose.com
|
||||
@@ -210,7 +222,7 @@ jobs:
|
||||
# kubectl scale rs -n wifi-densepose -l app=wifi-densepose,version!=green --replicas=0
|
||||
|
||||
- name: Upload deployment artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: production-deployment-${{ github.run_number }}
|
||||
path: |
|
||||
@@ -260,7 +272,7 @@ jobs:
|
||||
post-deployment:
|
||||
name: Post-deployment Monitoring
|
||||
runs-on: ubuntu-latest
|
||||
needs: [deploy-staging, deploy-production]
|
||||
needs: [pre-deployment, deploy-staging, deploy-production]
|
||||
if: always() && (needs.deploy-staging.result == 'success' || needs.deploy-production.result == 'success')
|
||||
steps:
|
||||
- name: Monitor deployment health
|
||||
@@ -281,7 +293,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Update deployment status
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const deployEnv = '${{ needs.pre-deployment.outputs.deploy_env }}';
|
||||
@@ -300,7 +312,7 @@ jobs:
|
||||
notify:
|
||||
name: Notify Deployment Status
|
||||
runs-on: ubuntu-latest
|
||||
needs: [deploy-staging, deploy-production, post-deployment]
|
||||
needs: [pre-deployment, deploy-staging, deploy-production, post-deployment]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Notify Slack on success
|
||||
@@ -332,7 +344,7 @@ jobs:
|
||||
|
||||
- name: Create deployment issue on failure
|
||||
if: needs.deploy-production.result == 'failure'
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.create({
|
||||
@@ -355,4 +367,4 @@ jobs:
|
||||
**Logs:** Check the workflow run for detailed error messages.
|
||||
`,
|
||||
labels: ['deployment', 'production', 'urgent']
|
||||
})
|
||||
})
|
||||
|
||||
@@ -171,6 +171,41 @@ jobs:
|
||||
- name: ADR-135 calibration witness proof (determinism guard)
|
||||
run: bash scripts/verify-calibration-proof.sh
|
||||
|
||||
# The workspace runs with --no-default-features, which switches OFF
|
||||
# ruview-auth's `login` and `pkce` features. That silently excluded 40 of
|
||||
# its 87 tests — the whole interactive sign-in path: credential storage,
|
||||
# single-flight refresh, the advisory file lock, the loopback callback, and
|
||||
# PKCE generation. They were green locally and never executed here.
|
||||
# Measured: 47 tests with --no-default-features, 87 with --all-features.
|
||||
- name: Run ruview-auth tests with all features (ADR-271 login path)
|
||||
working-directory: v2
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||
run: cargo test -p ruview-auth --all-features
|
||||
|
||||
# Browser-facing JavaScript.
|
||||
#
|
||||
# These run the dashboard's own modules in Node with stubbed browser globals.
|
||||
# They exist because the Rust suite cannot see them at all: two ADR-271/272
|
||||
# defects (a service worker caching /oauth/status, and the WebSocket ticket
|
||||
# helper) lived entirely in `ui/` and were invisible to a fully green
|
||||
# workspace. Blocking, and fast — no browser, no install step.
|
||||
ui-tests:
|
||||
name: UI JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Run UI unit tests
|
||||
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
|
||||
|
||||
# Unit and Integration Tests
|
||||
# Python pytest matrix — runs against the archived v1 Python tree.
|
||||
# `continue-on-error: true` for the same reason as code-quality above:
|
||||
|
||||
@@ -13,14 +13,29 @@
|
||||
# 1. cut tag `v1.99.0-pip` → publishes the tombstone wheel first
|
||||
# 2. cut tag `v2.0.0-pip` → publishes the PyO3 v2 wheel matrix
|
||||
#
|
||||
# Publishes via the `PYPI_API_TOKEN` GitHub Actions secret. The
|
||||
# token-refresh runbook (GCP Secret Manager → gh secret set) lives in
|
||||
# docs/integrations/pypi-release.md so KICS does not flag the
|
||||
# secret name as a generic-secret literal in the workflow.
|
||||
# Publishes via the `PYPI_API_TOKEN` GitHub Actions secret (API-token
|
||||
# auth). This is the ACTIVE, working publish path — the token is sourced
|
||||
# fresh from GCP Secret Manager per the runbook in
|
||||
# docs/integrations/pypi-release.md (GCP Secret Manager → gh secret set),
|
||||
# which also keeps KICS from flagging the secret name as a generic-secret
|
||||
# literal here.
|
||||
#
|
||||
# Q3 (witness hash v2 — open in ADR-117 §11.3) MUST be resolved
|
||||
# before the first v2.0.0 publish. When v2 lands, add a parallel
|
||||
# step that verifies the v2 hash against the Rust pipeline.
|
||||
# TODO(ADR-184 P1b): migrate to PyPI OIDC Trusted Publishing to remove
|
||||
# this rotatable/expire-able credential. That switch is GATED on a manual
|
||||
# pypi.org step no CLI/agent can perform: the repo owner must register a
|
||||
# Trusted Publisher on pypi.org for owner=ruvnet / repo=RuView /
|
||||
# workflow=pip-release.yml (BOTH the wifi-densepose and ruview projects;
|
||||
# ruview as a pending publisher) — see docs/adr/ADR-184-*.md. Do NOT grant
|
||||
# the OIDC id-token write permission before that registration exists, or
|
||||
# publishing fails with "no trusted publisher configured" — a silent
|
||||
# regression the `Verify fix markers` guard `RuView#786-pypi-token-auth`
|
||||
# exists to catch (it forbids that permission string in this file). When
|
||||
# the owner confirms both entries are live, do the OIDC switch as a
|
||||
# dedicated follow-up commit (drop `password:`, add the OIDC id-token
|
||||
# permission + `environment: pypi`) so there is no capability gap between.
|
||||
#
|
||||
# Production publishing fails closed until the ADR-117 §11.3 v2 witness
|
||||
# hash exists. TestPyPI remains usable to validate release artifacts.
|
||||
|
||||
name: pip-release
|
||||
|
||||
@@ -67,7 +82,7 @@ jobs:
|
||||
arch: x86_64
|
||||
- os: ubuntu-latest
|
||||
arch: aarch64
|
||||
- os: macos-13 # x86_64 runner
|
||||
- os: macos-15-intel # x86_64 runner
|
||||
arch: x86_64
|
||||
- os: macos-14 # arm64 runner
|
||||
arch: arm64
|
||||
@@ -136,6 +151,46 @@ jobs:
|
||||
path: sdist/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
build-ruview:
|
||||
name: Build ruview meta-package
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
|
||||
startsWith(github.ref, 'refs/tags/v2.')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Verify lock-step package versions
|
||||
shell: python
|
||||
run: |
|
||||
import pathlib
|
||||
import tomllib
|
||||
|
||||
root = pathlib.Path("python")
|
||||
core = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
meta = tomllib.loads((root / "ruview-meta" / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
core_version = core["project"]["version"]
|
||||
meta_version = meta["project"]["version"]
|
||||
expected_dependency = f"wifi-densepose=={core_version}"
|
||||
if meta_version != core_version:
|
||||
raise SystemExit(
|
||||
f"package versions differ: wifi-densepose={core_version}, ruview={meta_version}"
|
||||
)
|
||||
if expected_dependency not in meta["project"]["dependencies"]:
|
||||
raise SystemExit(f"ruview must depend on {expected_dependency}")
|
||||
print(f"lock-step version: {core_version}")
|
||||
- name: Build ruview wheel and sdist
|
||||
run: |
|
||||
python -m pip install --upgrade pip build
|
||||
python -m build python/ruview-meta --outdir ruview-dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ruview
|
||||
path: ruview-dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# v1.99.0 — tombstone wheel (pure Python, single sdist + wheel)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
@@ -220,18 +275,29 @@ jobs:
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
publish-v2:
|
||||
name: Publish v2 wheels
|
||||
needs: [build-wheels, build-sdist]
|
||||
name: Publish wifi-densepose + ruview
|
||||
needs: [build-wheels, build-sdist, build-ruview]
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-wheels.result == 'success' &&
|
||||
needs.build-sdist.result == 'success' &&
|
||||
needs.build-ruview.result == 'success' &&
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
|
||||
startsWith(github.ref, 'refs/tags/v2.')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Enforce production witness gate
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v2.') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi')
|
||||
run: |
|
||||
test -s archive/v1/data/proof/expected_features_v2.sha256 || {
|
||||
echo "::error::ADR-117 §11.3 release gate is incomplete: archive/v1/data/proof/expected_features_v2.sha256 is missing or empty"
|
||||
exit 1
|
||||
}
|
||||
- name: Gather all artifacts into dist/
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -241,12 +307,14 @@ jobs:
|
||||
mkdir -p dist
|
||||
find dist-staging -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp -v {} dist/ \;
|
||||
ls -lh dist/
|
||||
# API-token auth (active path). See TODO(ADR-184 P1b) in the header
|
||||
# before replacing `password:` with the OIDC id-token permission.
|
||||
- name: Publish to TestPyPI (dry-run target)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
password: ${{ secrets.TESTPYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
skip-existing: true
|
||||
- name: Publish to PyPI
|
||||
@@ -257,6 +325,7 @@ jobs:
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
verbose: true
|
||||
|
||||
publish-tombstone:
|
||||
name: Publish v1.99 tombstone
|
||||
@@ -274,12 +343,14 @@ jobs:
|
||||
with:
|
||||
name: tombstone
|
||||
path: dist
|
||||
# API-token auth (active path). See TODO(ADR-184 P1b) in the header
|
||||
# before replacing `password:` with the OIDC id-token permission.
|
||||
- name: Publish to TestPyPI (dry-run target)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
password: ${{ secrets.TESTPYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
skip-existing: true
|
||||
- name: Publish to PyPI
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Python Package CI — gates the `python/` PyO3+maturin wheel (`wifi-densepose`).
|
||||
#
|
||||
# ADR-117 (pip modernization) + ADR-185 (SOTA extras). Unlike the frozen
|
||||
# `archive/v1/` Python app — which is `continue-on-error: true` in ci.yml
|
||||
# because it is reference-only — the `python/` package is an actively-shipped
|
||||
# PyPI wheel (published by pip-release.yml). Before this workflow, `python/`
|
||||
# had ZERO per-PR coverage: pip-release.yml only fires on release triggers
|
||||
# (tags / dispatch), so a PR could break the wheel build, break a native-Rust
|
||||
# parity test, or blow the wheel-size budget and nothing in the normal gating
|
||||
# CI would notice until release day. This workflow closes that gap.
|
||||
#
|
||||
# Path-scoped as a DEDICATED workflow rather than a job inside ci.yml. That is
|
||||
# this repo's own convention for component-scoped CI (cf. firmware-ci.yml,
|
||||
# sensing-server-docker.yml, bfld-mqtt-integration.yml — all separate files
|
||||
# with `paths:` triggers). GitHub only supports workflow-level `paths:`, not
|
||||
# per-job path filters, and no workflow in this repo uses a change-detection
|
||||
# action (dorny/paths-filter, tj-actions/changed-files) — so the idiomatic,
|
||||
# no-new-dependency way to scope to `python/**` is a standalone workflow. It
|
||||
# simply does not run on unrelated PRs.
|
||||
|
||||
name: Python Package CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
# NOTE: kept in sync with the pull_request paths below. GitHub Actions
|
||||
# does not reliably support YAML anchors in workflow files, so the two
|
||||
# lists are duplicated deliberately rather than aliased.
|
||||
paths:
|
||||
- 'python/**'
|
||||
- 'v2/crates/wifi-densepose-core/**'
|
||||
- 'v2/crates/wifi-densepose-vitals/**'
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- 'v2/crates/wifi-densepose-aether/**'
|
||||
- 'v2/crates/wifi-densepose-mat/**'
|
||||
- 'v2/crates/wifi-densepose-train/**'
|
||||
- 'v2/crates/wifi-densepose-signal/**'
|
||||
- '.github/workflows/python-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'python/**'
|
||||
- 'v2/crates/wifi-densepose-core/**'
|
||||
- 'v2/crates/wifi-densepose-vitals/**'
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- 'v2/crates/wifi-densepose-aether/**'
|
||||
- 'v2/crates/wifi-densepose-mat/**'
|
||||
- 'v2/crates/wifi-densepose-train/**'
|
||||
- 'v2/crates/wifi-densepose-signal/**'
|
||||
- '.github/workflows/python-ci.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: python-ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Build the wheel with ALL SOTA features and run the full parity suite.
|
||||
# `--features sota` = aether + meridian + mat, so the compiled feature
|
||||
# submodules (wifi_densepose.aether / .meridian / .mat) exist and their
|
||||
# SHA-256 parity tests against the native-Rust reference actually run
|
||||
# (test_aether.py / test_meridian.py / test_mat.py import those submodules
|
||||
# at collection time — without the features they would error, not skip).
|
||||
parity-tests:
|
||||
name: Wheel + parity tests (features=sota)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The python/ crate path-deps v2/crates/* and (transitively via
|
||||
# train) the vendored ruvector submodule — recursive checkout keeps
|
||||
# those path deps resolvable, matching the rust-tests job in ci.yml.
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
v2
|
||||
python
|
||||
|
||||
# Fast-fail with per-crate attribution BEFORE the heavier maturin build,
|
||||
# so a break in a binding-backing crate is reported as "this crate failed
|
||||
# to build" rather than buried in a maturin link error. These are the
|
||||
# crates the [aether]/[mat]/[meridian] compiled extras link.
|
||||
- name: Build binding-backing crates
|
||||
working-directory: v2
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
run: cargo build -p wifi-densepose-aether -p wifi-densepose-mat -p wifi-densepose-train
|
||||
|
||||
# maturin develop needs an active virtualenv; create one and expose it
|
||||
# to the later steps via GITHUB_PATH so `maturin` / `pytest` resolve to
|
||||
# it. Test deps: pytest-asyncio (client tests are async, asyncio_mode
|
||||
# auto), numpy (test_bfld), websockets + paho-mqtt (the [client] extra
|
||||
# used by test_client_*).
|
||||
- name: Create venv + install maturin and test deps
|
||||
run: |
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
pip install "maturin>=1.7,<2.0" pytest pytest-asyncio numpy websockets paho-mqtt
|
||||
echo "VIRTUAL_ENV=$PWD/.venv" >> "$GITHUB_ENV"
|
||||
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build + install wheel (maturin develop --features sota)
|
||||
working-directory: python
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
run: maturin develop --features sota
|
||||
|
||||
- name: Run parity + binding tests
|
||||
run: pytest python/tests/ -q
|
||||
|
||||
# Numeric enforcement of the ADR-117 §5.4 default-wheel budget. A fix-marker
|
||||
# can guard the CONFIG that keeps the wheel small (empty default features,
|
||||
# optional SOTA deps, strip=true — see RuView#1387-default-wheel-budget-config
|
||||
# in scripts/fix-markers.json) but it cannot measure bytes. This job builds
|
||||
# the DEFAULT (no-features) wheel and fails if it exceeds the budget — the
|
||||
# real guard against a dependency silently ballooning the shipped wheel.
|
||||
wheel-size-budget:
|
||||
name: Default wheel <= 5 MiB (ADR-117 §5.4)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: python
|
||||
|
||||
- name: Install maturin
|
||||
run: python -m pip install --upgrade pip "maturin>=1.7,<2.0"
|
||||
|
||||
- name: Build default wheel and assert size budget
|
||||
working-directory: python
|
||||
run: |
|
||||
set -euo pipefail
|
||||
maturin build --release --out dist
|
||||
whl="$(ls dist/*.whl | head -1)"
|
||||
bytes="$(stat -c%s "$whl")"
|
||||
limit=$((5 * 1024 * 1024)) # ADR-117 §5.4: 5 MiB
|
||||
printf 'Default wheel: %s = %s bytes (%s MiB); budget = %s bytes\n' \
|
||||
"$whl" "$bytes" "$((bytes / 1024 / 1024))" "$limit"
|
||||
if [ "$bytes" -gt "$limit" ]; then
|
||||
echo "::error::default wheel is $bytes bytes, over the ADR-117 §5.4 $limit-byte (5 MiB) budget"
|
||||
exit 1
|
||||
fi
|
||||
echo "Default wheel is within the 5 MiB budget."
|
||||
@@ -8,9 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` prepared for its first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha` → `5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload remains gated on the ADR-117 v2 witness hash. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client).
|
||||
- **CI (ADR-184): `pip-release.yml` keeps token-based PyPI authentication until Trusted Publishing is registered.** An OIDC migration was attempted in `cc153e8b5` and reverted in `82d5c7339` so releases would not enter a half-configured state. Production currently uses `PYPI_API_TOKEN`; TestPyPI uses its independent `TESTPYPI_API_TOKEN`. The workflow now builds and publishes `wifi-densepose` and `ruview` together, verifies their versions and dependency pin match, and fails closed before production upload when `expected_features_v2.sha256` is absent.
|
||||
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (−22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
|
||||
|
||||
### Deprecated
|
||||
- **`archive/v1` (the original pure-Python implementation) formally deprecated (ADR-187)** — commits `1fb5397dd`, `b1417fb6e`; refs #509, #1125. Added `archive/v1/DEPRECATED.md` (a loud tombstone) and a `> ⚠️ DEPRECATED` notice atop `archive/v1/README.md`, both pointing at the maintained `v2/` workspace and the `wifi-densepose 2.x` / `ruview` pip wheel (ADR-117). Records the honest fact behind #509: `archive/v1`'s `DensePoseHead` is **architecture-only** — random `kaiming_normal_` init with **zero committed checkpoints** under `archive/v1/` (MEASURED by Glob over `**/*.{pth,onnx,safetensors,pt,ckpt,bin}`). The ADR-028 deterministic proof `archive/v1/data/proof/verify.py` stays live and is explicitly out of scope. The same effort added a **"Model weights: what's real, what's not" three-tier table** to `README.md` + `docs/user-guide.md`, separating real-and-validated checkpoints (presence 82.3% held-out temporal-triplet, MM-Fi pose 82.69% torso-PCK@20, `count_v1`) from the real-but-weak on-device `pose_v1` (PCK@20 = 3.0%, runtime `confidence=0` stub, below the ADR-079 ≥35% target) from the architecture-only `archive/v1` head — and caveated every live single-ESP32 17-keypoint advertisement accordingly. Docs/labeling only; no code or model behavior changed.
|
||||
|
||||
### Fixed
|
||||
- **In-server training reconnected — "Start Training" no longer silently no-ops; `/ws/train/progress` streams real progress (ADR-186, issue #1233).** The dashboard's Start Training button POSTed a config, got `success:true`, and nothing happened: `/api/v1/train/start` was a stub that flipped a status string and logged one line, and `/ws/train/progress` 404'd. The full pure-Rust trainer in `training_api.rs` (loads recorded CSI, gradient-descent, exports a `.rvf`) already existed but was **orphaned** — never declared as a module (no `mod training_api;`), so it wasn't compiled at all. Fix (`wifi-densepose-sensing-server`): declared the module, reconciled `AppStateInner` (replaced the `training_status`/`training_config` stub fields with a shared `TrainingState` status handle + cooperative cancel flag + a `training_progress_tx` broadcast), deleted the stub handlers, and merged the real `training_api::routes()` (so `/api/v1/train/{start,stop,status,pretrain,lora}` and `/ws/train/progress` resolve under the existing `/api/v1/*` bearer gate). The training core was decoupled from the ~60-field server state so it is unit-testable. **P5 honesty guarantee:** with `RUVIEW_DISABLE_SERVER_TRAINING` set, start returns a structured `{enabled:false, cli:"wifi-densepose train-room"}` HTTP 409 — never a silent success — and the dashboard disables the Start buttons with a CLI tooltip (enablement is surfaced on `/api/v1/train/status`). Pinned by 8 new tests incl. a **live-socket** test that completes a genuine 101 WebSocket handshake and receives a real progress frame after a POST start, a full POST→poll-status→`.rvf`-exists round-trip, a path-traversal rejection, cancellation, and the disabled-409 path. `cargo test -p wifi-densepose-sensing-server -p wifi-densepose-train --no-default-features` — 0 failed.
|
||||
- **FastAPI health/metrics endpoints event-loop starvation.** Calling `psutil.cpu_percent(interval=1)` blocked the single-threaded async event loop for 1.0 second on every health check or metrics collection tick, stalling all incoming requests and WebSocket operations. Fixed by changing `cpu_percent` to use non-blocking `interval=None` and offloading all blocking OS metrics gathering to background thread pools via `asyncio.to_thread`. Verified event loop responsiveness via concurrency regression tests.
|
||||
- **EngineBridge now honors `WDP_GUARD_INTERVAL_US`/`WDP_SOFT_GUARD_US`/`WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US`** (#1309, PR #1312, @erichkusuki). The governed trust path previously built its multistatic fuser from a hardcoded `MultistaticConfig::default()` (60 ms guard), so multi-node deployments with WiFi/ESP-NOW time sync (10–150 ms drift) failed every governed cycle regardless of configuration — while the startup log claimed the override took effect. New `StreamingEngine::set_multistatic_config()`; `EngineBridge::new()` takes an `Option<MultistaticConfig>` threaded from the same env-derived config as `AppState.multistatic_fuser`. Hardware-verified on a live 2-node ESP32-S3 setup (90 s window, 0 fusion errors; previously every cycle failed).
|
||||
- **`/api/v1/stream/pose` WebSocket reachable with `RUVIEW_API_TOKEN` set + dashboard bearer-token field** (#1310, PR #1313, @erichkusuki). Browsers cannot attach an `Authorization` header to a WS upgrade, so the Live Demo pose stream always failed when auth was on; the path is now on a narrow exact-match exemption list (mirrors `/ws/sensing`), with a regression test pinning that the exemption doesn't leak to other `/api/v1/*` paths. The QuickSettings panel gains an "API Access" field storing the bearer token in `localStorage`; the token is applied at `api.service.js` module load so the very first request carries it.
|
||||
@@ -36,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **`homecore-recorder` security review (ADR-132 surfaces) — two real bounding fixes; SQL-injection & NaN-index dimensions confirmed clean with evidence.** Beyond-SOTA review of the HA-compat state recorder (DB persistence + history + ruvector semantic search), the crux being its DB-backed SQL-injection surface. **Findings + fixes:** (1) **Memory-DoS — unbounded `get_state_history`.** The history query carried no `LIMIT`, so a wide `[since, until]` window over a high-frequency entity (a per-second sensor ≈ 86k rows/day) would load an unbounded row set into a single in-memory `Vec`. Added a hard `LIMIT MAX_HISTORY_ROWS` (1,000,000 — generous enough never to truncate a realistic history graph, bounded enough to cap the worst case); the sibling search paths were already `k`-bounded. (2) **Disk-DoS / documented-but-missing `purge`.** The README + HA-compat table advertised `Recorder::purge(older_than)` as a capability, but **no such method existed** — i.e. no retention path at all → unbounded disk growth. Implemented a **transactional** `purge` that deletes `states` + `events` strictly **older than** the cutoff (**exclusive** boundary — idempotent, no off-by-one; a row at the cutoff instant is kept) and **garbage-collects** orphaned `state_attributes` blobs (a dedup-shared blob is dropped only once its last referencing state is gone); all three deletes run in one transaction so a mid-purge failure rolls back cleanly (no states-deleted-but-events-kept corruption). **Confirmed clean with evidence:** SQL injection — **every** query in `db.rs` uses bound `?` parameters (no `format!`/string-concat of user data into SQL); the lone `format!` builds the LIKE *pattern*, which is itself bound as a parameter with `ESCAPE '\\'` and metacharacter escaping. Pinned: a state value `'; DROP TABLE states; --` is stored/queried **literally** (table survives), and a `%`/`_` in a search query matches **literally**, not as a wildcard. NaN-index poisoning (the calibration/vitals/geo class) — **structurally impossible** here: embeddings are SHA-256 → `i32` → `f32` (an `i32` cast to `f32` is always finite, never NaN/Inf), with an all-zero-digest norm guard; probed empty-index search, empty-string query, and `k=0` — all return `Ok(0)`, **no panic**. Fail-closed write path — a removal event yields `Ok(None)`, semantic-index failure is logged not propagated (best-effort, never blocks the durable SQLite write), and `EntityId` parsing failures fall back rather than panic. **6 new pinning tests** (SQL-injection literal-storage, LIKE-metacharacter literalness, history `LIMIT`, purge exclusive-boundary, purge attribute-GC-keeps-shared, purge old-events): `homecore-recorder` **19 → 25** (`--no-default-features`) / **25 → 31** (`--features ruvector`), 0 failed; the purge-boundary test is a true pin (fails deleting 2 rows under an inclusive cutoff, passes deleting 1 under the exclusive cutoff). Behaviour otherwise unchanged; Python deterministic proof unchanged (recorder is off the signal proof path).
|
||||
|
||||
### Added
|
||||
- **ADR-184 / ADR-185 / ADR-186 / ADR-187 decision records added under `docs/adr/`** (indexed in the ADR README, count corrected to 193 — commit `cca5bd811`). **ADR-184** (PyPI Trusted Publishing, completes ADR-117) — Proposed; its CI migration has landed but is pending pypi.org registration (see Changed). **ADR-185** (P6 Python bindings for AETHER/MERIDIAN/MAT) and **ADR-186** (training progress API, refs #1233) are **Proposed only** — decision records for work not yet implemented on this branch. **ADR-187** (archive/v1 deprecation + model-weights honest labeling) — Accepted and implemented (see Deprecated).
|
||||
- **ADR-263/264/265: deep review of the RuView npm surface (`@ruvnet/ruview`, `@ruvnet/rvagent`, `@ruv/ruview-cli`) with optimization strategies recorded as ADRs.** ADR-263 reviews the published `@ruvnet/ruview@0.1.0` harness: fail-open `claim-check` on empty input (HIGH), `spawnSync` head-of-line blocking of the MCP stdio server during long `verify`/`calibrate` runs (HIGH), optionalDependencies tripling the cold `npx` install for a code path that never uses them (MEASURED, `npm i` in a clean prefix: 4 packages / 620 kB / 71 files default vs 1 package / 172 kB / 22 files with `--omit=optional`), 1 MiB `maxBuffer` truncation risk, `python -c` port-interpolation surface in `node_monitor`, hardcoded MCP server version, duplicated skill payload — optimizations O1–O8. ADR-264 reviews `@ruvnet/rvagent@0.1.0` + the private CLI **against the published registry tarball**: `exports.require` → nonexistent `dist/index.cjs` (HIGH, every CJS consumer breaks), 44 dead source-map files = 62,698 B of the 188 kB unpacked payload pointing at unshipped `../src` (MEASURED), stdio-only server described as "dual-transport" (CLAIMED capability), mixed dot/underscore tool naming, double Zod validation + hand-duplicated advertised schemas, 2-fd leak per training job, unbounded request body in the unwired HTTP scaffold, dead `detectCogBinary` candidate list, `ruview` bin-name collision — optimizations O1–O9. ADR-265 adds the cross-cutting distribution layer: an `npm-packages.yml` CI matrix (tests + pack-content/size gate + tarball-install smoke test — none of the three packages currently has any CI, and `ci.yml` pins Node 18 against `engines >= 20`), publish-from-CI-only with `npm publish --provenance`, version single-sourcing from package.json, bin/namespace ownership (the `ruview` bin belongs to `@ruvnet/ruview`), and claim-check enforcement on package READMEs/descriptions. Docs only — no runtime code changed; the findings are the work orders for the follow-up PRs.
|
||||
- **ADR-131 §11–§12: HOMECORE-UI wired to a real backend — single-origin BFF gateway + production front-end (no mock in prod).** Implements the §11 wiring decision so the dashboard stops rendering fabricated data. **Front-end (DONE + verified under Node):** `api.js` rewritten so every data accessor is async and calls the §11.2 gateway routes; the in-browser mock is demoted to a **dev-only fixture** reachable only via `?demo=1`/`HOMECORE_UI_DEMO` (§2.2); all ten panels now `await` and render a **typed empty/error state** on upstream failure (no mock fallback in production) — 3 panels converted by hand, 7 via a parallel agent swarm. **New `homecore-server` BFF gateway (`src/gateway.rs`, compile-pending — no Rust toolchain in the authoring env):** promotes `homecore-server` to the single origin (§2.1); adds `/api/homecore/*` + `/api/cal/*` merged into `build_app`, with `reqwest` + CLI/env flags (`--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`). Real handlers: calibration **reverse-proxy** (W2), `GET /api/homecore/rooms` with the §11.3 **RoomState adapter** (`breathing`→`breathing_bpm`, `heartbeat`→`heart_bpm`, `None`→`null` preserving not-trained-vs-withheld, injected `anomaly.threshold`/`room_id`), **COG supervisor** over `/var/lib/cognitum/apps/` (W4), and **appliance metrics** from `/proc` + TCP service probes (W6); SEED-device/appliance routes (seeds/federation/witness/privacy/settings/automations/events-history/hailo/tokens — W3/W5) return a typed `503 upstream_unavailable` and the UI shows error states. **Tests:** front-end **5 files green** — import-graph, boot, render-smoke (22), interaction (3), and a **new prod-errors suite (13)** that runs with demo OFF + gateway unreachable and proves every panel renders an error state, never mock, never throws (it caught + fixed a real unhandled-rejection in the events automation builder). **Gateway compiled, tested, and run on Rust 1.89:** `cargo test -p homecore-server --no-default-features` = **12/12 pass** (6 gateway + 6 UI mount); the binary was **run live** — `GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes, unauth → `401`, `cogs` → `[]` (no apps dir), SEED-tier → typed `503`, and against a mock calibration upstream the `/api/cal/*` proxy passes through (`200`) and `GET /api/homecore/rooms` adapts `RoomState` to the UI shape (`breathing`→`breathing_bpm`, `heartbeat:null`→`heart_bpm:null`, injected `anomaly.threshold`/`room_id`). **Live testing caught + fixed a real bug** — a double-`v1` segment in the `/api/cal/*` proxy URL. **Remaining (intrinsic, not an env limit):** W3/W5/W6-Hailo/federation depend on services/hardware **not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, Hailo stat source), so they return honest `503`s rather than fabricate data; W1/W2/W4/W6-appliance are functional now. ADR-131 §10/§12.1 updated with per-wave status.
|
||||
- **ADR-131: HOMECORE-UI — the complete operational dashboard for the two-tier Cognitum stack, served by `homecore-server` at `/homecore`.** A zero-dependency, no-build-step vanilla TS/JS + CSS frontend (the `rufield-viewer` "Axum + vanilla-JS" pattern) that extends the Cognitum Appliance shell as a first-class nav section (Framework | Guide | Cog Store | **HOMECORE** | Status). **Complete, not a scaffold** (per the ADR's revised §2/§7): all **10 panels** ship fully built and rendered — §4.1 System Dashboard (v0 Appliance health strip + SEED fleet grid + ESP32 summary + COG status row + event-bus sparkline), §4.2 SEED Detail (vector store / witness chain / 5 onboard sensors / reflex rules / cognitive-fragility / ingest packet-type), §4.3 SEED Fleet Map (Appliance→SEED→ESP32 hierarchy, ESP-NOW mesh, cross-SEED fusion badges, ADR-105 federation), §4.4 Entity & State Browser (domain-grouped, **live WebSocket `subscribe_events` patching — never polls**, first-class provenance badges, keyword filter, context-causality slide-over), §4.5 RoomState/Sensing (mixture-of-specialists), §4.6 COG Management + App Registry, §4.7 Calibration Wizard (5-step baseline→enroll→train→verify), §4.8 Event Bus + Automation builder, §4.9 Witness/Audit log (two-tier SHA-256 + Ed25519 timeline, privacy-mode banner, pagination, export), §4.10 Settings. **Design system is the exact production Cognitum palette** (`tokens.css` carries `--cyan #4ecdc4` … `--r 10px` verbatim, §3.1) so there is no visual seam with the Cog Store (§3.3 invariant). **§6 UX invariants enforced in code and pinned by tests:** tier-origin provenance is always-visible (never collapsed); `stale`/`vetoed` flags and the kNN fragility score are prominent (amber/red tint + banners, never grey-on-grey); a `null` specialist renders "Not trained / calibrate to enable" **visually distinct from** veto-`withheld` (rendered as explicitly withheld, never zero) **distinct from** an error; all IDs/hashes/endpoints/payloads use `--mono`; Hailo-sourced COGs (`arch: hailo10`) are visually distinguished from CPU-only (`arch: arm`). **Wiring:** `homecore-server` gains a `--ui-dir`/`HOMECORE_UI_DIR` flag and mounts the assets via `tower-http` `ServeDir` at `/homecore` alongside the unchanged HA-compat `/api` surface (new testable `build_app()`), with **5 Rust integration tests** (`#[cfg(test)] mod ui_tests`, `tower::oneshot`) asserting index / design tokens / all-10-panels are served, the API coexists, and an empty `--ui-dir` disables the mount. **JS test + benchmark suite (`ui/`, runs under plain `node`, no npm install): 24 checks / 0 failed** — an import/export graph verifier (15 modules consistent), a DOM-shim render-smoke that *executes every panel* (21 checks: ui helpers + mock contracts + all 10 panels render without throwing), and an interaction suite (3 checks: live WS state-patch, ws.js handshake/parse, calibration backend contract). **Benchmark:** total bundle **136.8 KB uncompressed across 18 files — ~37× smaller than HA's ~5 MB Lit bundle** (the ADR-126 §1.1 foil), slowest panel **1.5 ms/cold-render**. **Honest scope (§7.1):** the live HOMECORE REST API (`/api/config|states|services`) and the WebSocket `subscribe_events` feed are driven for real; panels whose backing service is **not** in this binary (SEED HTTPS API, calibration ADR-151, ADR-105 federation) render against a **contract-conformant mock layer flagged with a DEMO banner** and swap to live the moment those endpoints land — no mock data is ever presented as real. **Not verified in this environment:** the Rust crate was edited and the integration tests written but **not compiled/run here** (no Rust toolchain present); `cargo test -p homecore-server` + `cargo build` must be run on a Rust host before merge.
|
||||
|
||||
@@ -58,7 +58,7 @@ RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the
|
||||
> | 💓 **Heart rate** | Bandpass 0.8–2.0 Hz, zero-crossing BPM | 40–120 BPM, real-time |
|
||||
> | 👤 **Presence detection** | Trained head on Hugging Face ([`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained); v2 encoder = 82.3% held-out temporal-triplet acc, honestly re-benchmarked) + a phase-variance fallback that needs no model | < 1 ms, ~30 s ambient calibration |
|
||||
> | 🧬 **CSI embeddings** | 128-dim contrastive encoder shipped on Hugging Face, 4-bit quantised variant fits in 8 KB | **164,183 emb/s** on M4 Pro |
|
||||
> | 🦴 **17-keypoint pose estimation** | `cog-pose-estimation` Cog v0.0.1 — signed aarch64 + x86_64 binaries on GCS, loads `pose_v1.safetensors` via Candle. Train your own from paired data in 2.1 s on an RTX 5080 ([ADR-101](docs/adr/ADR-101-pose-estimation-cog.md), [benchmarks](docs/benchmarks/pose-estimation-cog.md)). **SOTA on MM-Fi:** [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) hits **82.69% torso-PCK@20** (ensemble 83.59%), beating MultiFormer (72.25%) and CSI2Pose (68.41%) on the matched MM-Fi `random_split` protocol — self-corrected and auditable on [AetherArena](https://huggingface.co/spaces/ruvnet/aether-arena) | 8.4 ms cold-start on a Pi 5 |
|
||||
> | 🦴 **17-keypoint pose estimation** | `cog-pose-estimation` Cog v0.0.1 — signed aarch64 + x86_64 binaries on GCS, loads `pose_v1.safetensors` via Candle (the committed `pose_v1` is a **first-cut** on-device model: PCK@20 = 3.0%, below the ADR-079 ≥35% target, and its runtime path is still a `confidence=0` stub — see [Model weights: what's real, what's not](#model-weights-whats-real-whats-not); the **82.69%** figure below is the separate published MM-Fi benchmark, not this live cog). Train your own from paired data in 2.1 s on an RTX 5080 ([ADR-101](docs/adr/ADR-101-pose-estimation-cog.md), [benchmarks](docs/benchmarks/pose-estimation-cog.md)). **SOTA on MM-Fi:** [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) hits **82.69% torso-PCK@20** (ensemble 83.59%), beating MultiFormer (72.25%) and CSI2Pose (68.41%) on the matched MM-Fi `random_split` protocol — self-corrected and auditable on [AetherArena](https://huggingface.co/spaces/ruvnet/aether-arena) | 8.4 ms cold-start on a Pi 5 |
|
||||
> | 🚶 **Motion / activity** | Motion-band power + phase acceleration | Real-time |
|
||||
> | 🤸 **Fall detection** | Phase-acceleration threshold + 3-frame debounce + 5 s cooldown ([#263](https://github.com/ruvnet/RuView/issues/263)) | < 200 ms |
|
||||
> | 🧮 **Multi-person count** | Adaptive P95 normalisation + runtime-tunable dedup factor (`/api/v1/config/dedup-factor`, [#491](https://github.com/ruvnet/RuView/pull/491)). Six specialised learned counters available as Cogs: `occupancy-zones`, `elevator-count`, `queue-length`, `customer-flow`, `clean-room`, `person-matching` | Real-time, self-calibrating |
|
||||
@@ -128,7 +128,7 @@ pip install "ruview[client]" # or: pip install "wifi-densepose[clie
|
||||
>
|
||||
> | Option | Hardware | Cost | Full CSI | Capabilities |
|
||||
> |--------|----------|------|----------|-------------|
|
||||
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Presence, motion, breathing, heart rate, fall detection, multi-person counting, 17-keypoint pose (signed Cog binary), 105-cog catalog, persistent vector store, kNN search, witness chain, MCP proxy |
|
||||
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Presence, motion, breathing, heart rate, fall detection, multi-person counting, 17-keypoint pose (signed Cog binary — first-cut on-device model, see [Model weights: what's real, what's not](#model-weights-whats-real-whats-not)), 105-cog catalog, persistent vector store, kNN search, witness chain, MCP proxy |
|
||||
> | **ESP32 Mesh** | 3-6× ESP32-S3 + WiFi router | ~$54 | Yes | Same capabilities as above without the persistent-memory features |
|
||||
> | **ESP32-C6 research node** ([ADR-110](docs/adr/ADR-110-esp32-c6-firmware-extension.md), [witness](docs/WITNESS-LOG-110.md), [reviewer guide](docs/ADR-110-REVIEW-GUIDE.md), [firmware v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32)) | ESP32-C6-DevKit ($6–10) | ~$10 | Yes (Wi-Fi 6 capable) | Same CSI pipeline as S3 with the dual-target firmware. **Firmware-side ADR-110 substrate now closed** (v0.7.0): ESP-NOW cross-board mesh quantified at **99.56 % match / 104 µs smoothed offset stdev / 3.95× EMA suppression** over a 5-min two-board soak (witness §A0.10), 32-byte UDP sync packet with operator-tunable cadence (§A0.12), ADR-018 byte 19 bit 4 wire-fix sourced from the working ESP-NOW path (§A0.13). Wire format ready for HE-LTF PPDU tagging in ADR-018 bytes 18-19 (firmware encoder + Rust + Python decoders verified end-to-end across 23 unit tests). LP-core motion-gate RISC-V program and Wi-Fi 6 soft-AP with TWT Responder both ship as opt-in code paths (default off). **Hardware-gated for measurement**: HE-LTF live subcarrier capture needs an 11ax AP (IDF v5.4 doesn't expose AP-side HE config — §A0.6); ~5 µA LP-core hibernation needs an INA meter to capture; 802.15.4 raw RX is broken in IDF v5.4 (workaround: ESP-NOW transport, shipped + measured). See witness log for the empirical / claimed split. |
|
||||
> | **Research NIC** | Intel 5300 / Atheros AR9580 | ~$50-100 | Yes | Full CSI with 3x3 MIMO |
|
||||
@@ -145,7 +145,7 @@ pip install "ruview[client]" # or: pip install "wifi-densepose[clie
|
||||
<img src="assets/v2-screen.png" alt="WiFi DensePose — Live pose detection with setup guide" width="800">
|
||||
</a>
|
||||
<br>
|
||||
<em>Real-time pose skeleton from WiFi CSI signals — no cameras, no wearables</em>
|
||||
<em>Real-time pose skeleton from WiFi CSI signals — no cameras, no wearables (demo visualization; the live CSI-only single-ESP32 17-keypoint model is still first-cut — see <a href="#model-weights-whats-real-whats-not">Model weights: what's real, what's not</a>)</em>
|
||||
<br><br>
|
||||
<a href="https://ruvnet.github.io/RuView/"><strong>▶ Live Observatory Demo</strong></a>
|
||||
|
|
||||
@@ -157,7 +157,7 @@ pip install "ruview[client]" # or: pip install "wifi-densepose[clie
|
||||
|
||||
> The [server](#-quick-start) is optional for visualization and aggregation — the ESP32 [runs independently](#esp32-s3-hardware-pipeline) for presence detection, vital signs, and fall alerts.
|
||||
>
|
||||
> **Live ESP32 pipeline**: Connect an ESP32-S3 node → run the [sensing server](#sensing-server) → open the [pose fusion demo](https://ruvnet.github.io/RuView/pose-fusion.html) for real-time dual-modal pose estimation (webcam + WiFi CSI). See [ADR-059](docs/adr/ADR-059-live-esp32-csi-pipeline.md).
|
||||
> **Live ESP32 pipeline**: Connect an ESP32-S3 node → run the [sensing server](#sensing-server) → open the [pose fusion demo](https://ruvnet.github.io/RuView/pose-fusion.html) for real-time dual-modal pose estimation (webcam + WiFi CSI). See [ADR-059](docs/adr/ADR-059-live-esp32-csi-pipeline.md). (The webcam supplies ground-truth pose in this dual-modal demo; the CSI-only on-device 17-keypoint model is still first-cut — see [Model weights: what's real, what's not](#model-weights-whats-real-whats-not).)
|
||||
>
|
||||
> **three.js scene gallery** at [`/three.js/`](https://ruvnet.github.io/RuView/three.js/) — five progressively richer ADR-097 demos: helpers, cinematic, GLTF skinned, FBX skinned, and a live MediaPipe→Mixamo retargeting feed driven by ESP32 CSI. Demos 04 and 05 require a local Mixamo `X Bot.fbx` (license boundary — not redistributed).
|
||||
|
||||
@@ -204,7 +204,26 @@ The separate **17-keypoint pose-estimation model** is now published at [`ruvnet/
|
||||
python archive/v1/data/proof/verify.py
|
||||
```
|
||||
|
||||
Tracked in [#509](https://github.com/ruvnet/RuView/issues/509); see [ADR-079](docs/adr/ADR-079-camera-supervised-pose-finetune.md) phases P7–P9 for the camera-supervised fine-tune path.
|
||||
Tracked in [#509](https://github.com/ruvnet/RuView/issues/509); see [ADR-079](docs/adr/ADR-079-camera-ground-truth-training.md) phases P7–P9 for the camera-supervised fine-tune path.
|
||||
|
||||
### Model weights: what's real, what's not
|
||||
|
||||
"WiFi → pose" means three different things in this repo, at three different maturity
|
||||
levels. Read the label, not the headline ([ADR-187](docs/adr/ADR-187-archive-v1-deprecation-honest-labeling.md)):
|
||||
|
||||
| Tier | Checkpoint(s) | Honest status |
|
||||
|------|---------------|---------------|
|
||||
| **Real & validated** | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) (CSI encoder + presence head) · [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) (17-keypoint pose) · `cog-person-count/count_v1` | **MEASURED / published.** Presence = 82.3% held-out temporal-triplet accuracy (the old "100% presence" figure was retracted); MM-Fi pose = 82.69% torso-PCK@20 on the `random_split` protocol. These are the pose/presence numbers the project stands behind today. |
|
||||
| **Real but weak (honestly labeled)** | committed `v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors` | First-cut on-device model. **PCK@20 = 3.0% / PCK@50 = 18.5%** on a 217-sample holdout — **below the ADR-079 target of ≥ 35%.** Learns coarse structure (`r_hip` 77% PCK@50); distal/face joints near-random. Its runtime path in `cog-pose-estimation/src/inference.rs` is still a centred-skeleton **stub returning `confidence=0`** — the weights are not yet wired in. Full disclosure in the [cog README](v2/crates/cog-pose-estimation/cog/README.md). |
|
||||
| **Architecture only, no weights** | `archive/v1` `DensePoseHead` | Random `kaiming_normal_` init, **no checkpoint of any kind** (zero `.pth`/`.onnx`/`.safetensors` files under `archive/v1/`). Deprecated and superseded — see [`archive/v1/DEPRECATED.md`](archive/v1/DEPRECATED.md). Do not expect real pose output from it. |
|
||||
|
||||
**On the ESP32-SISO question ([#509](https://github.com/ruvnet/RuView/issues/509)):** a
|
||||
single-antenna, 56-subcarrier CSI stream at a 20-frame window does *not* carry the
|
||||
fine-grained spatial information the multi-antenna NIC research relies on — the cog
|
||||
measurements above show distal/face joints near-random. The shippable pose accuracy the
|
||||
project can stand behind today is the **MM-Fi benchmark number**, not a live single-ESP32
|
||||
number. The path to a first *reproducible* on-device baseline (PCK@20 ≥ 35%) is tracked in
|
||||
[ADR-079](docs/adr/ADR-079-camera-ground-truth-training.md) / [#645](https://github.com/ruvnet/RuView/issues/645) — do not advertise the live single-ESP32 17-keypoint feature without the "first-cut, below-target, runtime-stub" caveat until that baseline is measured.
|
||||
|
||||
|
||||
## 🧩 Edge Module Catalog
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# ⚠️ DEPRECATED — `archive/v1` is unmaintained and superseded
|
||||
|
||||
**Do not build new work on this tree.** `archive/v1` is the original pure-Python
|
||||
implementation of WiFi-DensePose. It is kept only as a research archive
|
||||
(per [ADR-117 §1.3](../../docs/adr/ADR-117-pip-wifi-densepose-modernization.md)) and
|
||||
as the host of one still-live deterministic proof (see "What still lives here" below).
|
||||
Everything else in this directory is frozen and receives no fixes, reviews, or support.
|
||||
|
||||
Governed by [ADR-187](../../docs/adr/ADR-187-archive-v1-deprecation-honest-labeling.md).
|
||||
|
||||
## The one honest fact that trips people up
|
||||
|
||||
`archive/v1/src/models/densepose_head.py` defines a `DensePoseHead` neural-network
|
||||
architecture (segmentation + UV-regression heads). **It ships no trained weights.** Its
|
||||
`_initialize_weights()` uses `kaiming_normal_` **random initialization only** — there is
|
||||
no checkpoint-loading path in the class, and there are **zero** `.pth` / `.onnx` /
|
||||
`.safetensors` / `.pt` / `.ckpt` / `.bin` files anywhere under `archive/v1/`.
|
||||
|
||||
So: the architecture is *defined*, but it is **architecture-only**. Running it produces
|
||||
random output, not real pose accuracy. This matches the technical review in
|
||||
[#509](https://github.com/ruvnet/RuView/issues/509) — for *this tree*, the "network
|
||||
defined, no pre-trained weights" observation is TRUE.
|
||||
|
||||
Real, trained, benchmarked weights **do** exist — just not here. They live in the
|
||||
maintained `v2/` workspace and on Hugging Face (see next section).
|
||||
|
||||
## Use the maintained path instead
|
||||
|
||||
| You want… | Go here |
|
||||
|-----------|---------|
|
||||
| The maintained implementation | The **`v2/` Rust workspace** (repo root `../../v2/`) |
|
||||
| A pip install | `pip install ruview` **or** `pip install wifi-densepose` (2.x) — the compiled PyO3 wheel ([ADR-117](../../docs/adr/ADR-117-pip-wifi-densepose-modernization.md)). The `wifi-densepose` **1.x** line is tombstoned on PyPI: `1.99.0` raises an `ImportError` telling you to migrate. |
|
||||
| Real trained presence/encoder weights | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) — 82.3% held-out temporal-triplet accuracy |
|
||||
| A real 17-keypoint pose model | [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) — 82.69% torso-PCK@20 on MM-Fi `random_split` |
|
||||
| The honest three-tier weights picture | The "Model weights: what's real, what's not" table in the root [`README.md`](../../README.md) and [`docs/user-guide.md`](../../docs/user-guide.md) |
|
||||
|
||||
## What still lives here (intentionally)
|
||||
|
||||
Only one thing under `archive/v1/` is still a live, cited signal: the deterministic
|
||||
reference-pipeline proof —
|
||||
|
||||
```bash
|
||||
python archive/v1/data/proof/verify.py # must print VERDICT: PASS
|
||||
```
|
||||
|
||||
This is the ADR-028 "Trust Kill Switch": it feeds a fixed reference signal through the
|
||||
signal-processing pipeline and checks the SHA-256 of the output against a published hash.
|
||||
It is a legitimate reproducibility witness and is **not** deprecated. Everything else in
|
||||
this tree is.
|
||||
@@ -1,3 +1,19 @@
|
||||
> ## ⚠️ DEPRECATED — unmaintained and superseded
|
||||
>
|
||||
> This tree is the **original pure-Python implementation** and is kept only as a research
|
||||
> archive. It receives no fixes, reviews, or support. **Read [`DEPRECATED.md`](DEPRECATED.md) before using anything below.**
|
||||
>
|
||||
> - Its `DensePoseHead` is **architecture-only with random-initialized weights and ships no
|
||||
> trained checkpoint** — running it produces random output, not real pose accuracy.
|
||||
> - The maintained path is the **`v2/` Rust workspace** and the `wifi-densepose 2.x` / `ruview`
|
||||
> pip wheel ([ADR-117](../../docs/adr/ADR-117-pip-wifi-densepose-modernization.md)). The
|
||||
> `wifi-densepose` 1.x line is tombstoned on PyPI (1.99.0 raises `ImportError`).
|
||||
> - Real trained weights live elsewhere: [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained)
|
||||
> (presence, 82.3%) and [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose)
|
||||
> (17-keypoint pose, 82.69% torso-PCK@20).
|
||||
> - The only still-live artifact here is the deterministic proof `data/proof/verify.py`
|
||||
> (ADR-028), which stays. See [ADR-187](../../docs/adr/ADR-187-archive-v1-deprecation-honest-labeling.md).
|
||||
|
||||
# WiFi-DensePose v1 (Python Implementation)
|
||||
|
||||
This directory contains the original Python implementation of WiFi-DensePose.
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
# ADR-184: Complete ADR-117 via PyPI Trusted Publishing (OIDC) + real v2.0.0 / ruview publish
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-07-21 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **PHOENIX-LANDING** — the PIP-PHOENIX wheel that never actually took off |
|
||||
| **Relates to** | [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX modernization — this ADR completes it), [ADR-028](ADR-028-esp32-capability-audit.md) (witness chain), [ADR-115](ADR-115-home-assistant-integration.md) (HA/Matter sibling), [ADR-168](ADR-168-benchmark-proof.md) (measured-not-claimed house style) |
|
||||
| **Tracking issue** | [#785](https://github.com/ruvnet/RuView/issues/785) (ADR-117, still OPEN) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-117 (PIP-PHOENIX) designed the v2.0.0 rewrite of the pip `wifi-densepose`
|
||||
package as a PyO3 + maturin compiled wheel over the Rust core, plus a `ruview`
|
||||
sibling package, replacing the 11.5-month-stale pure-Python `1.1.0` line. The
|
||||
code landed on `main` (the `python/` workspace: `Cargo.toml`, `src/bindings/*.rs`,
|
||||
the `wifi_densepose/` Python package, `tests/`, `bench/`). The tombstone shipped.
|
||||
**But the release itself is broken and the design doc's own P5 intent was never met.**
|
||||
|
||||
This ADR is a **gap analysis and remediation plan**, not a new feature. Every fact
|
||||
below was verified against PyPI and GitHub Actions on 2026-07-21; none are projected.
|
||||
|
||||
### 1.1 What is actually live on PyPI (measured)
|
||||
|
||||
`pip index versions wifi-densepose` returns:
|
||||
|
||||
```
|
||||
wifi-densepose (1.99.0)
|
||||
Available versions: 1.99.0, 1.2.0, 1.1.0, 1.0.0
|
||||
```
|
||||
|
||||
- `1.99.0` — the tombstone wheel **is genuinely live**. `import wifi_densepose`
|
||||
raises `ImportError` pointing users to 2.0+. This part of ADR-117 §7.2 shipped.
|
||||
- `2.0.0a1` — appears in PyPI's release history as a **pre-release** (hidden from
|
||||
the default `pip index` view, surfaced with `--pre`). It is still an **alpha**.
|
||||
- `2.0.0` (stable) — **does not exist.** ADR-117's headline deliverable
|
||||
(`pip install wifi-densepose==2.0.0`) is not installable.
|
||||
|
||||
`pip index versions ruview` returns:
|
||||
|
||||
```
|
||||
ERROR: No matching distribution found for ruview
|
||||
```
|
||||
|
||||
The `ruview` sibling package **was never published.** Commit `b71d243b4`
|
||||
(*"feat(adr-117): publish wifi-densepose 2.0.0a1 + ruview 2.0.0a1 to PyPI"*) claims
|
||||
a publish that did not happen for that package — a real **claimed-vs-measured gap**
|
||||
of exactly the kind [ADR-168](ADR-168-benchmark-proof.md) and the project's
|
||||
"prove everything" posture exist to catch.
|
||||
|
||||
### 1.2 Why the release pipeline was stuck (measured; interim-fixed — see §1.4)
|
||||
|
||||
`gh run list --workflow pip-release` shows the last **4** runs all
|
||||
`conclusion=failure` (most recent `2026-05-24T16:34`). The full failure log for
|
||||
run `26366735779` (job *"Publish v1.99 tombstone"* → step *"Publish to PyPI"*)
|
||||
shows two things:
|
||||
|
||||
1. The publish step uses `pypa/gh-action-pypi-publish` with a `password`
|
||||
(API-token) input and fails:
|
||||
|
||||
```
|
||||
403 Forbidden — Invalid or non-existent authentication information.
|
||||
```
|
||||
|
||||
i.e. the `PYPI_API_TOKEN` GitHub secret is stale / expired / revoked.
|
||||
|
||||
2. The action's own log warns:
|
||||
|
||||
```
|
||||
Warning: the workflow was run with 'attestations: true' ... but an explicit
|
||||
password was also set, disabling Trusted Publishing.
|
||||
```
|
||||
|
||||
The workflow at `.github/workflows/pip-release.yml` wires `password:
|
||||
${{ secrets.PYPI_API_TOKEN }}` into **four** publish steps (lines 249, 258, 282,
|
||||
291) and declares only `permissions: contents: read` (line 49–50). So it is using a
|
||||
rotatable, leak-able, expire-able API token in exactly the place ADR-117 §5.4 / §5.5
|
||||
and the issue #785 P5 row explicitly called for **OIDC Trusted Publishing** ("cp310
|
||||
… abi3-py310, OIDC"; ADR-117 §5.5 line 547: *"PyPI publish via Trusted Publisher
|
||||
(OIDC, no API token in secrets)"*). **The implementation drifted from its own
|
||||
design doc.**
|
||||
|
||||
### 1.3 Why the package is still alpha (measured)
|
||||
|
||||
`python/pyproject.toml` pins `version = "2.0.0a1"` (line 13) and
|
||||
`Development Status :: 3 - Alpha` (line 26). Issue #785's closing criteria
|
||||
(§"Done") require `wifi-densepose==2.0.0` (**not** alpha) published, plus all 10
|
||||
acceptance criteria in §11. None of those can be true today given §1.1–§1.2.
|
||||
|
||||
**Why this matters:** ADR-117 is the sole Python entry point for the whole RuView
|
||||
ecosystem (per its §2 "PyPI org presence check"). A stale token silently blocking
|
||||
every release means the entire "plug-and-play Python entry point for the pip +
|
||||
Jupyter customer base" thesis (issue #785 "Strategic alignment") is stalled behind a
|
||||
one-line credential problem — and a commit message claims otherwise.
|
||||
|
||||
### 1.4 Interim fix applied (2026-07-21) — credential unblocked, migration still pending
|
||||
|
||||
**As of 2026-07-21T22:57:29Z the stale-credential symptom is fixed at the credential
|
||||
layer.** The maintainer fetched a valid `PYPI_TOKEN` from GCP Secret Manager (project
|
||||
`cognitum-20260110`) and ran `gh secret set PYPI_API_TOKEN` to replace the
|
||||
revoked/expired value. Authentication was confirmed non-destructively via a
|
||||
`twine upload --skip-existing` re-upload of the existing `1.99.0` tombstone artifacts,
|
||||
which returned a benign 400/skip response (not the previous `403 Forbidden`) — proving
|
||||
the new token authenticates correctly.
|
||||
|
||||
This means **token-based publishing works again today** — the `403` root cause
|
||||
described in §1.2 no longer reproduces. It does **not**, however, close this ADR:
|
||||
|
||||
- A **manually-rotated token still expires, leaks, and can be revoked over time** — it
|
||||
re-introduces exactly the silent-failure mode that blocked the last 4 runs. It is a
|
||||
stopgap at the same layer as the §3.2 fallback, not the durable fix.
|
||||
- The OIDC **Trusted Publishing migration (§3, P1) remains the decision** — a
|
||||
credential PyPI mints per-run with no secret to rotate is the only fix that removes
|
||||
the recurring-expiry class of failure.
|
||||
- The other three gaps are **untouched** by this rotation: `wifi-densepose` is still
|
||||
`2.0.0a1` (not stable `2.0.0`), and `ruview` is still unpublished.
|
||||
|
||||
**Why/How to apply:** read §1.2's "root cause" as *diagnosed and temporarily
|
||||
mitigated*, not *still broken*. A reviewer re-running the §7.5 check today may now see
|
||||
a green token-based run — that is expected and does not satisfy this ADR, which is
|
||||
Accepted only when §6's criteria pass **and** the workflow no longer carries a static
|
||||
token (§7.4).
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state — evidence
|
||||
|
||||
| Artifact | Value | Source |
|
||||
|---|---|---|
|
||||
| Latest stable `wifi-densepose` on PyPI | **1.99.0** (tombstone) | `pip index versions wifi-densepose` |
|
||||
| `wifi-densepose==2.0.0` stable | **absent** | `pip index versions` (not listed) |
|
||||
| `wifi-densepose==2.0.0a1` pre-release | present (alpha) | PyPI release history (`--pre`) |
|
||||
| `ruview` on PyPI | **No matching distribution found** | `pip index versions ruview` |
|
||||
| `pip-release.yml` last 4 runs | all `failure` | `gh run list --workflow pip-release` |
|
||||
| Most recent failed run | `2026-05-24T16:34` | `gh run list` |
|
||||
| Failing step | Publish v1.99 tombstone → Publish to PyPI | run `26366735779` log |
|
||||
| Failure code | `403 Forbidden — Invalid or non-existent authentication information` | run `26366735779` log |
|
||||
| Root cause | `PYPI_API_TOKEN` stale/revoked; explicit password disables Trusted Publishing | run `26366735779` log warning |
|
||||
| `password:` uses in workflow | 4 (lines 249, 258, 282, 291) | `.github/workflows/pip-release.yml` |
|
||||
| Workflow permissions | `contents: read` only (no `id-token: write`) | `pip-release.yml:49–50` |
|
||||
| pyproject version | `2.0.0a1` | `python/pyproject.toml:13` |
|
||||
| pyproject dev status | `3 - Alpha` | `python/pyproject.toml:26` |
|
||||
| Issue #785 | **OPEN** | GitHub |
|
||||
|
||||
**Why/How to apply:** treat this table as the falsifiable baseline. A reviewer who
|
||||
re-runs each `Source` command must reproduce each `Value`, or this ADR is wrong and
|
||||
should be revised before any remediation is attempted.
|
||||
|
||||
---
|
||||
|
||||
## 3. Decision
|
||||
|
||||
Complete ADR-117 by closing four gaps, in order:
|
||||
|
||||
1. **Migrate `pip-release.yml` to PyPI Trusted Publishing (OIDC)** — as the durable
|
||||
end-state, drop all four `password: ${{ secrets.PYPI_API_TOKEN }}` inputs, grant
|
||||
`id-token: write` to the publish jobs, and add `environment: pypi`. This removes
|
||||
the rotatable/expire-able credential and realigns with ADR-117 §5.5's stated OIDC
|
||||
intent. **This is gated behind sub-phase P1b** (§5): the switch is inert — and in
|
||||
fact 403-breaking — until the manual pypi.org registration (§3.1) exists, so the
|
||||
OIDC change must land *together* with that registration. Until then, token auth
|
||||
(the freshly-rotated `PYPI_API_TOKEN`, §1.4) is the correct active path and is
|
||||
what the `RuView#786-pypi-token-auth` fix-marker guard enforces. An OIDC migration
|
||||
was attempted (`cc153e8b5`) and reverted (`82d5c7339`) for exactly this reason.
|
||||
|
||||
2. **Promote `wifi-densepose` from `2.0.0a1` to stable `2.0.0`** in
|
||||
`python/pyproject.toml` (version + `Development Status :: 5 - Production/Stable`)
|
||||
and record the promotion in `CHANGELOG.md`.
|
||||
|
||||
3. **Actually publish `ruview==2.0.0`** — the sibling package that commit
|
||||
`b71d243b4` claimed but never shipped — and verify it with `pip index versions`.
|
||||
|
||||
4. **Adopt issue #785 §11's 10 acceptance criteria verbatim as this ADR's own
|
||||
acceptance criteria** (§6 below), and only flip ADR-117 → Accepted and close
|
||||
#785 once every one passes against the real index — proven, not claimed.
|
||||
|
||||
### 3.1 Mandatory human prerequisite (cannot be automated)
|
||||
|
||||
**Trusted Publishing requires a one-time manual step on `pypi.org` that no CLI, API,
|
||||
or agent can perform** — PyPI restricts Trusted Publisher configuration to the
|
||||
project owner via the web UI for security reasons. Before P1's workflow change can
|
||||
succeed, a human with owner rights on both PyPI projects must:
|
||||
|
||||
1. Log in to `pypi.org`.
|
||||
2. For **`wifi-densepose`**: Project → *Publishing* → *Add a new pending/trusted
|
||||
publisher* → GitHub, with:
|
||||
- Owner: `ruvnet`
|
||||
- Repository: `RuView`
|
||||
- Workflow filename: `pip-release.yml`
|
||||
- Environment: `pypi`
|
||||
3. Repeat the identical step for the **`ruview`** project. Because `ruview` is not
|
||||
yet on PyPI, register it as a **pending publisher** (PyPI supports configuring a
|
||||
trusted publisher for a project name before its first release — the first OIDC
|
||||
publish then creates the project).
|
||||
|
||||
**Why/How to apply:** the workflow change in P1 is inert until this is done — the
|
||||
publish step will fail with a "no trusted publisher configured" error rather than a
|
||||
403. Land P1 and this manual step together; do not tag a release expecting OIDC to
|
||||
work until a human confirms both entries exist. Treat this section as a blocking
|
||||
checklist item on the release-day runbook, not a footnote.
|
||||
|
||||
### 3.2 Fallback path (if the owner declines Trusted Publishing)
|
||||
|
||||
If the maintainer prefers not to adopt OIDC yet, the code-side remediation is a
|
||||
**token regeneration**, not a redesign:
|
||||
|
||||
- Generate a fresh PyPI API token (scoped to the `wifi-densepose` and `ruview`
|
||||
projects) and store it in GCP Secret Manager (project `cognitum-20260110`, where
|
||||
the project's tokens live), then `gh secret set PYPI_API_TOKEN` from it, following
|
||||
the existing runbook referenced in the workflow header (`docs/integrations/pypi-release.md`).
|
||||
- Keep the current `password:`-based workflow unchanged.
|
||||
|
||||
**Why/How to apply:** this path clears the 403 and unblocks releases immediately,
|
||||
but it re-introduces the exact failure mode this ADR is trying to eliminate — a
|
||||
credential that silently expires and blocks the whole Python entry point again. Use
|
||||
it only as a stopgap; the Trusted Publishing migration (P1) is the durable fix and
|
||||
should remain the default recommendation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Detailed design — workflow migration
|
||||
|
||||
The change to `.github/workflows/pip-release.yml` is small and surgical. It does
|
||||
**not** touch the build matrix (`build-wheels`, `build-sdist`, `build-tombstone`
|
||||
jobs are unchanged — the 403 is a publish-credential problem, not a build problem).
|
||||
|
||||
### 4.1 Grant OIDC token permission on the publish jobs
|
||||
|
||||
The `gh-action-pypi-publish` action mints its OIDC token from the job's
|
||||
`id-token: write` permission. The current top-level `permissions: contents: read`
|
||||
must be extended on the two publish jobs (`publish-v2`, `publish-tombstone`) — plus
|
||||
the future `publish-ruview` job:
|
||||
|
||||
```yaml
|
||||
publish-v2:
|
||||
name: Publish v2 wheels
|
||||
needs: [build-wheels, build-sdist]
|
||||
permissions:
|
||||
id-token: write # ← added: mint the OIDC token for PyPI
|
||||
contents: read
|
||||
environment: pypi # ← added: binds to the PyPI trusted-publisher entry
|
||||
```
|
||||
|
||||
### 4.2 Drop the `password:` inputs
|
||||
|
||||
Every publish step loses its `password:` line. Trusted Publishing needs no secret —
|
||||
the action exchanges the job's OIDC token for a short-lived PyPI upload token
|
||||
automatically:
|
||||
|
||||
```yaml
|
||||
# BEFORE (current — fails with 403 when the token is stale)
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }} # ← remove
|
||||
packages-dir: dist
|
||||
|
||||
# AFTER (Trusted Publishing — no secret, activates once the pypi.org entry exists)
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: dist
|
||||
```
|
||||
|
||||
The TestPyPI dry-run steps keep `repository-url: https://test.pypi.org/legacy/` and
|
||||
likewise drop `password:` — a matching trusted-publisher entry must be registered on
|
||||
`test.pypi.org` if the dry-run path is to be used (otherwise gate the dry-run behind
|
||||
the fallback token or remove it).
|
||||
|
||||
**Why/How to apply:** the header comment block (lines 16–23) that documents the
|
||||
`PYPI_API_TOKEN` / GCP-Secret-Manager runbook must be rewritten to document the
|
||||
Trusted Publishing setup instead, so the next maintainer does not re-add a token
|
||||
"to fix" a future failure and silently re-disable OIDC.
|
||||
|
||||
### 4.3 Add the `publish-ruview` job
|
||||
|
||||
`ruview` is published by a new job mirroring `publish-v2` (same `id-token: write` +
|
||||
`environment: pypi`, no `password:`), gated on a `ruview`-scoped build. Because the
|
||||
package has never shipped, its first successful OIDC publish creates the PyPI
|
||||
project against the pending trusted-publisher entry from §3.1.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase ledger
|
||||
|
||||
```
|
||||
P1 ──► P1b ──► P2 ──► P3 ──► P4
|
||||
token OIDC version real close
|
||||
unblock (gated) promote publish #785
|
||||
```
|
||||
|
||||
### P1 — Credential unblock (token auth, active)
|
||||
|
||||
- [x] Rotate `PYPI_API_TOKEN` to a validated token (§1.4, `gh secret set`, verified
|
||||
`2026-07-21T22:57:29Z` via `twine upload --skip-existing`). Token-based publishing
|
||||
works today.
|
||||
- [x] Keep `password: ${{ secrets.PYPI_API_TOKEN }}` as the active auth path,
|
||||
satisfying the `RuView#786-pypi-token-auth` fix-marker guard.
|
||||
- [ ] Rewrite the `pip-release.yml` header comment block so the next maintainer
|
||||
knows OIDC is the intended P1b end-state (not a token to keep re-rotating forever).
|
||||
|
||||
> Note: an OIDC migration was attempted (`cc153e8b5`) and **reverted** (`82d5c7339`)
|
||||
> because it tripped the fix-marker guard before the pypi.org registration existed.
|
||||
> The OIDC work is therefore tracked as P1b below, not P1. See the Status note.
|
||||
|
||||
**Status 2026-07-21 — DESIGNED then REVERTED (token auth is the ACTIVE path):**
|
||||
The OIDC migration was implemented (commit `cc153e8b5` — `id-token: write` +
|
||||
`environment: pypi` on both publish jobs, all four `PYPI_API_TOKEN` password
|
||||
inputs removed) but then **reverted** (commit `82d5c7339`) after it tripped the
|
||||
pre-existing `RuView#786-pypi-token-auth` fix-marker guard
|
||||
(`scripts/fix-markers.json`). That guard `require`s
|
||||
`password: ${{ secrets.PYPI_API_TOKEN }}` and `forbid`s `id-token: write`
|
||||
precisely because a half-activated OIDC path (id-token permission present, but no
|
||||
Trusted Publisher yet registered on pypi.org) leaves publishing **403-broken**
|
||||
rather than working — it correctly predicted this exact failure. The revert was
|
||||
verified locally against the real checker (`python scripts/check_fix_markers.py` →
|
||||
all 25 markers pass, exit 0) before pushing.
|
||||
|
||||
**Active path today:** token-based auth via the freshly-rotated `PYPI_API_TOKEN`
|
||||
(§1.4). The current `pip-release.yml` (HEAD `82d5c7339`) carries
|
||||
`password: ${{ secrets.PYPI_API_TOKEN }}` at four publish steps plus a TODO
|
||||
comment marking the OIDC follow-up. The OIDC switch is therefore **not** done — it
|
||||
moves to sub-phase P1b below.
|
||||
|
||||
**Why this revert was correct (measured, not claimed):** OIDC is the better
|
||||
long-term design and matches ADR-117's original §5.5 P5 intent — but implementing
|
||||
it *before* the manual pypi.org registration exists would have shipped a workflow
|
||||
that looks migrated yet 403s on the next real publish. The fix-marker caught a
|
||||
well-intentioned improvement that wasn't the honest, currently-working state, and
|
||||
it was reverted rather than overridden. That is the same "measured not claimed"
|
||||
discipline (per [ADR-168](ADR-168-benchmark-proof.md)) this entire ADR exists to
|
||||
enforce — applied here to our own change.
|
||||
|
||||
### P1b — Switch to OIDC Trusted Publishing (gated follow-up)
|
||||
|
||||
- [ ] **(human, manual, pypi.org — BLOCKING)** Complete the §3.1 Trusted Publisher
|
||||
registration for BOTH `wifi-densepose` and `ruview` (owner=ruvnet, repo=RuView,
|
||||
workflow=pip-release.yml, environment=pypi). P1b must not start until this exists.
|
||||
- [ ] Re-apply the `cc153e8b5` change (add `id-token: write` + `environment: pypi`,
|
||||
drop the four `password:` inputs) as its own follow-up commit.
|
||||
- [ ] Update the `RuView#786-pypi-token-auth` fix-marker in `scripts/fix-markers.json`
|
||||
in the *same* commit — invert it to `require: id-token: write` / `forbid:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}` — so the guard tracks the new intended
|
||||
state instead of blocking it (referencing the TODO comment now in pip-release.yml).
|
||||
- [ ] Confirm a green OIDC publish before removing the token, per §3.2's
|
||||
keep-both-paths recommendation (OIDC first, token fallback until OIDC is proven).
|
||||
- [ ] No capability gap: publishing must keep working across the P1→P1b transition.
|
||||
|
||||
### P2 — Version promotion + changelog
|
||||
|
||||
- [ ] `python/pyproject.toml`: `version = "2.0.0"` (drop the `a1` suffix).
|
||||
- [ ] `python/pyproject.toml`: `Development Status :: 5 - Production/Stable`.
|
||||
- [ ] `CHANGELOG.md`: `[Unreleased]` entry — "wifi-densepose 2.0.0 promoted from
|
||||
alpha; ruview 2.0.0 first stable publish; pip-release migrated to Trusted Publishing".
|
||||
- [ ] Confirm the `ruview` package's own version metadata is set to `2.0.0`.
|
||||
|
||||
### P3 — Real publish + verification
|
||||
|
||||
- [ ] Cut tag `v2.0.0-pip` (per the workflow's `v*-pip` trigger) → OIDC publish of
|
||||
the `wifi-densepose` wheel matrix.
|
||||
- [ ] Publish `ruview==2.0.0` via the new `publish-ruview` job.
|
||||
- [ ] Run every command in §7 against the **real** PyPI index and capture output.
|
||||
- [ ] Generate + commit `expected_features_v2.sha256` (issue #785 §11 criterion 10),
|
||||
resolving ADR-117 §11.3 / the workflow header's Q3 note.
|
||||
|
||||
### P4 — Close issue #785
|
||||
|
||||
- [ ] All 10 acceptance criteria (§6) pass against the real index.
|
||||
- [ ] Flip ADR-117 §Status → **Accepted**.
|
||||
- [ ] Flip this ADR (ADR-184) §Status → **Accepted**.
|
||||
- [ ] Close issue #785.
|
||||
|
||||
**Why/How to apply:** the phases are strictly ordered — P3 cannot succeed until both
|
||||
P1 (working credential path) and the §3.1 human step are done, and P4 must not be
|
||||
marked complete on the strength of a commit message (the failure mode this ADR
|
||||
exists to correct). Nothing in this ledger is checked; this is a Proposed plan.
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptance criteria (verbatim from issue #785 §11)
|
||||
|
||||
A reviewer must be able to:
|
||||
|
||||
1. `pip install --pre wifi-densepose==2.0.0a1` from PyPI test index → wheel installs
|
||||
without compile step on Linux/macOS/Windows
|
||||
2. `python -c "import wifi_densepose; print(wifi_densepose.__version__, wifi_densepose.__rust_version__)"`
|
||||
→ both versions print
|
||||
3. `python -c "from wifi_densepose import CsiFrame; ..."` → core type round-trips
|
||||
through PyO3
|
||||
4. `python -c "from wifi_densepose import vitals; vitals.detect_hr(...)"` → 4-stage
|
||||
pipeline runs on a sample CSI buffer
|
||||
5. `pip install wifi-densepose[client]; python -c "import wifi_densepose.client; ..."`
|
||||
→ WS client connects to a running sensing-server
|
||||
6. `pytest python/tests/` → ≥30 tests pass (smoke + binding round-trips)
|
||||
7. `maturin build --release --strip` → wheel under 5 MB per platform (ADR §5.4 budget)
|
||||
8. `wifi-densepose==1.99.0` is the latest 1.x; `import wifi_densepose` raises
|
||||
`ImportError` with migration URL
|
||||
9. `wifi-densepose==1.0.0` is yanked from PyPI; `1.1.0` is un-yanked with deprecation
|
||||
notice (90-day window)
|
||||
10. Witness `expected_features_v2.sha256` generated in CI, committed alongside the
|
||||
existing `archive/v1/data/proof/`, re-verifiable from Python via
|
||||
`wifi_densepose.verify_witness(...)`
|
||||
|
||||
**Note (amendment to criterion 1):** issue #785 §11 was written when `2.0.0a1` was
|
||||
the target. This ADR promotes to stable `2.0.0`, so criterion 1 is read as
|
||||
`pip install wifi-densepose==2.0.0` (no `--pre`) against the production index. The
|
||||
`--pre`/`a1` wording is preserved verbatim above per the transcription requirement;
|
||||
the stable form is what P3/P4 must actually satisfy. This ADR additionally requires
|
||||
`ruview==2.0.0` to be installable (the sibling package from commit `b71d243b4`),
|
||||
which #785 §11 did not enumerate but the issue "Done" section implies.
|
||||
|
||||
---
|
||||
|
||||
## 7. How to verify (prove, don't claim)
|
||||
|
||||
Exact commands a reviewer runs to prove — not assume — each gap is closed. Every one
|
||||
produces falsifiable output; capture it in the PR that flips ADR-117 to Accepted.
|
||||
|
||||
### 7.1 Both packages live and stable
|
||||
|
||||
```bash
|
||||
# wifi-densepose 2.0.0 (stable, NOT alpha) must appear
|
||||
pip index versions wifi-densepose
|
||||
# expect: "wifi-densepose (2.0.0)" and 2.0.0 in the available list
|
||||
|
||||
# ruview 2.0.0 must now exist (currently: "No matching distribution found")
|
||||
pip index versions ruview
|
||||
# expect: "ruview (2.0.0)"
|
||||
```
|
||||
|
||||
### 7.2 Clean-venv install + import (criteria 2–4)
|
||||
|
||||
```bash
|
||||
python -m venv /tmp/verify-184 && . /tmp/verify-184/bin/activate
|
||||
pip install wifi-densepose==2.0.0 # stable, no --pre
|
||||
python -c "import wifi_densepose; print(wifi_densepose.__version__, wifi_densepose.__rust_version__)"
|
||||
python -c "from wifi_densepose import CsiFrame; print(CsiFrame([1.0]*56,[0.0]*56,56,0,100.0))"
|
||||
python -c "from wifi_densepose import vitals; print(hasattr(vitals,'detect_hr'))"
|
||||
pip install ruview==2.0.0
|
||||
python -c "import ruview; print(ruview.__version__)"
|
||||
```
|
||||
|
||||
### 7.3 Tombstone still guards the 1.x line (criterion 8)
|
||||
|
||||
```bash
|
||||
pip install wifi-densepose==1.99.0
|
||||
python -c "import wifi_densepose" 2>&1 | grep -q "github.com/ruvnet/RuView" \
|
||||
&& echo "PASS: tombstone raises with migration URL" \
|
||||
|| echo "FAIL"
|
||||
```
|
||||
|
||||
### 7.4 Workflow auth state
|
||||
|
||||
**Current state (P1, active today):** token auth is the working path and is what
|
||||
the `RuView#786-pypi-token-auth` fix-marker requires. The honest check today is
|
||||
that token auth is present and the fix-marker guard passes:
|
||||
|
||||
```bash
|
||||
# token auth present (the ACTIVE, working path — expected PASS today)
|
||||
grep -q 'password: ${{ secrets.PYPI_API_TOKEN }}' .github/workflows/pip-release.yml \
|
||||
&& echo "PASS: token auth active" || echo "FAIL"
|
||||
|
||||
# fix-marker regression guard must pass
|
||||
python scripts/check_fix_markers.py && echo "PASS: all markers pass"
|
||||
```
|
||||
|
||||
**P1b end-state (after the manual pypi.org registration):** the checks below flip
|
||||
to PASS *only once P1b lands together with the fix-marker inversion* — they are
|
||||
**not** expected to pass today and their passing now would mean a half-migrated,
|
||||
403-prone workflow:
|
||||
|
||||
```bash
|
||||
# after P1b: no static token should remain in the publish steps
|
||||
grep -nE 'password:|PYPI_API_TOKEN' .github/workflows/pip-release.yml \
|
||||
&& echo "not yet: token still present (expected during P1)" \
|
||||
|| echo "P1b done: no static token"
|
||||
|
||||
# after P1b: id-token permission granted on publish jobs
|
||||
grep -q 'id-token: write' .github/workflows/pip-release.yml \
|
||||
&& echo "P1b done: OIDC permission present" \
|
||||
|| echo "not yet: OIDC not enabled (expected during P1)"
|
||||
```
|
||||
|
||||
### 7.5 The release actually went green
|
||||
|
||||
```bash
|
||||
gh run list --workflow pip-release --limit 1
|
||||
# expect: conclusion=success on the v2.0.0-pip tag run
|
||||
```
|
||||
|
||||
**Why/How to apply:** §7.1 and §7.5 together are the minimal proof that the two
|
||||
headline gaps (no stable 2.0.0, no `ruview`, dead pipeline) are closed. If any
|
||||
command's actual output diverges from the `expect` line, the corresponding phase is
|
||||
not done — regardless of what any commit message or checkbox says.
|
||||
|
||||
---
|
||||
|
||||
## 8. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- The Python entry point for the entire RuView ecosystem (issue #785 "Strategic
|
||||
alignment") is unblocked with a credential that cannot silently expire.
|
||||
- The claimed-vs-measured gap in commit `b71d243b4` (`ruview` never published) is
|
||||
closed with reproducible proof, upholding the project's "prove everything" posture.
|
||||
- Trusted Publishing removes a leak-able long-lived secret from CI entirely — the
|
||||
security posture ADR-117 §5.5 originally specified.
|
||||
- ADR-117 / issue #785 can finally reach a defensible Accepted/closed state instead
|
||||
of sitting open behind a one-line token failure.
|
||||
|
||||
### Negative
|
||||
|
||||
- The `pypi.org` trusted-publisher registration (§3.1) is a hard human dependency
|
||||
with no automated fallback beyond re-introducing a token (§3.2). Release day is
|
||||
blocked on a person, not a pipeline.
|
||||
- Promoting to stable `2.0.0` removes the alpha escape hatch — any binding bug now
|
||||
ships under a stable version and needs a `2.0.1`, not a new `a`-tag.
|
||||
- `test.pypi.org` needs its own trusted-publisher entry if the dry-run path is kept,
|
||||
adding a second manual registration.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The build matrix (`build-wheels`, `build-sdist`, `build-tombstone`) is untouched;
|
||||
the risk surface of this change is confined to the three publish jobs.
|
||||
- The witness-hash-v2 open question (ADR-117 §11.3, workflow header Q3) is pulled
|
||||
into scope as criterion 10 but is orthogonal to the credential migration.
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
- **ADR-117** — `docs/adr/ADR-117-pip-wifi-densepose-modernization.md` (the design
|
||||
this ADR completes; §5.4/§5.5 OIDC intent, §7.2 tombstone, §11.3 witness hash)
|
||||
- **Issue #785** — https://github.com/ruvnet/RuView/issues/785 (tracking issue,
|
||||
OPEN; §11 acceptance criteria transcribed in §6)
|
||||
- **Workflow** — `.github/workflows/pip-release.yml` (four `password:` inputs at
|
||||
lines 249/258/282/291; `contents: read` only at 49–50)
|
||||
- **pyproject** — `python/pyproject.toml` (`version = "2.0.0a1"` line 13;
|
||||
`3 - Alpha` line 26)
|
||||
- **Failed run** — GitHub Actions `pip-release` run `26366735779`, job "Publish
|
||||
v1.99 tombstone" → step "Publish to PyPI" (403 + Trusted-Publishing-disabled warning)
|
||||
- **Commit `b71d243b4`** — *"feat(adr-117): publish wifi-densepose 2.0.0a1 + ruview
|
||||
2.0.0a1 to PyPI"* — the `ruview` publish it claims did not occur
|
||||
- **PyPI Trusted Publishing** — https://docs.pypi.org/trusted-publishers/ (web-UI-only
|
||||
registration; pending-publisher support for not-yet-created projects)
|
||||
- **`pypa/gh-action-pypi-publish`** — https://github.com/pypa/gh-action-pypi-publish
|
||||
(OIDC via `id-token: write`; `password:` disables Trusted Publishing)
|
||||
- **ADR-168** — `docs/adr/ADR-168-benchmark-proof.md` (measured-not-claimed house style)
|
||||
@@ -0,0 +1,687 @@
|
||||
# ADR-185: Python P6 SOTA bindings — AETHER, MERIDIAN, and MAT via PyO3 extras
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed — **P1–P4 implemented & tested** (commits `d060998e3`, `189ac9dfb`, `1c9727f9c`, `0f405213d`) + **leaf-crate hoists done** (`a47bb71b2`/`7ed57f041`/`99fea9df9`); **not yet Accepted** (§6.6 CI gate PARTIAL, §6.7 accuracy bars OPEN — see §13) |
|
||||
| **Date** | 2026-07-21 (impl status recorded 2026-07-21) |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **PIP-TRINITY** — three SOTA subsystems join the `wifi_densepose` wheel |
|
||||
| **Relates to** | [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX — the PyO3 wheel this extends), [ADR-024](ADR-024-contrastive-csi-embedding-model.md) (AETHER contrastive embeddings), [ADR-027](ADR-027-cross-environment-domain-generalization.md) (MERIDIAN domain generalization), [ADR-152](ADR-152-wifi-pose-sota-2026.md) (WiFlow-STD ~96% PCK@20 SOTA bar) |
|
||||
| **Tracking issue** | TBD — file under RuView issue tracker |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 Where ADR-117 stopped
|
||||
|
||||
ADR-117 (PIP-PHOENIX) shipped the `wifi-densepose` v2.x PyPI wheel as a PyO3 +
|
||||
maturin compiled extension (`wifi_densepose._native`) with a pure-Python facade.
|
||||
The bound surface today (`python/src/bindings/*.rs`, `python/src/lib.rs`):
|
||||
|
||||
| Bound today | Crate | Kind |
|
||||
|---|---|---|
|
||||
| `CsiFrame`, `Keypoint`, `KeypointType`, `BoundingBox`, `PersonPose`, `PoseEstimate` | `wifi-densepose-core` | P2 core types |
|
||||
| 4-stage vitals (`BreathingExtractor`, `HeartRateExtractor`, `VitalEstimate`, `VitalReading`, `VitalStatus`) | `wifi-densepose-vitals` | P3 DSP |
|
||||
| `BfldFrame`, `BfldReport`, `BfldKind` + `PrivacyClass` gate | `wifi-densepose-bfld` | P3.5 / ADR-118 |
|
||||
| `SensingClient` (WS), `RuViewMqttClient` (MQTT), HA helpers | pure-Python `wifi_densepose.client` | P4 `[client]` extra |
|
||||
|
||||
ADR-117's own phase ledger (§6, "P6+ — Deferred") explicitly parked three
|
||||
higher-value subsystems as post-v2.0.0 work:
|
||||
|
||||
> - [ ] `wifi-densepose-nn` bindings … · `wifi-densepose-ruvector` bindings …
|
||||
> - [ ] MQTT/Matter integration helpers …
|
||||
|
||||
and ADR-117 §5.1 deferred `wifi-densepose-mat` (depends on nn) and the RuVector
|
||||
tier for wheel-size reasons. The three SOTA subsystems that a Python researcher
|
||||
most wants — re-identification embeddings, cross-environment transfer, and the
|
||||
disaster-triage tool — are precisely the ones still unreachable from
|
||||
`pip install wifi-densepose`.
|
||||
|
||||
### 1.2 The three subsystems already exist and are tested in Rust
|
||||
|
||||
None of this is new research. Each subsystem is a shipped, tested Rust module:
|
||||
|
||||
| Subsystem | ADR | Rust location (verified HEAD) | Nature |
|
||||
|---|---|---|---|
|
||||
| **AETHER** — contrastive CSI embedding / re-identification | ADR-024 | `wifi-densepose-sensing-server/src/embedding.rs` (`EmbeddingExtractor`, `ProjectionHead`, `CsiAugmenter`, `AetherConfig`, `aether_loss`, `info_nce_loss`, `alignment_metric`, `uniformity_metric`) | Pure-sync DSP + linear algebra; 128-dim L2-normalized embeddings |
|
||||
| **MERIDIAN** — cross-environment domain generalization | ADR-027 | `wifi-densepose-train` (`domain::{DomainFactorizer, DomainClassifier, GradientReversalLayer, AdversarialSchedule}`, `geometry::{GeometryEncoder, FourierPositionalEncoding, FilmLayer, MeridianGeometryConfig}`, `rapid_adapt::{RapidAdaptation, AdaptationLoss}`, `virtual_aug::VirtualDomainAugmentor`, `eval::CrossDomainEvaluator`) + `wifi-densepose-signal::hardware_norm::{HardwareNormalizer, HardwareType, CanonicalCsiFrame}` | Inference/adaptation path is pure-Rust and **un-gated**; only `model`/`trainer`/`losses` need `tch-backend` (libtorch) |
|
||||
| **MAT** — Mass Casualty Assessment Tool | (root CLAUDE.md crate table) | `wifi-densepose-mat` (`DisasterResponse`, `DisasterConfig`, `DetectionPipeline`, `EnsembleClassifier`, `TriageCalculator`, `TriageStatus`, `Survivor`, `VitalSignsReading`) | Cargo-feature-gated (`mat`); sync ingest (`push_csi_data`) + async scan loop (`start_scanning`, tokio) |
|
||||
|
||||
### 1.3 Why now, and why gated extras
|
||||
|
||||
Two forces make P6 timely: (a) the v2.0.0 wheel is stable and its abi3-py310
|
||||
build matrix is proven, so adding modules is incremental; (b) integrators reading
|
||||
the ADR-115/ADR-117 notes are asking for Python access to re-identification and
|
||||
cross-room transfer specifically.
|
||||
|
||||
But pulling all three into the **default** wheel would break ADR-117 §5.4's
|
||||
**≤ 5 MB per-platform wheel budget** and its "no heavy system deps" invariant:
|
||||
|
||||
- MAT is already cargo-`mat`-gated upstream *because* it drags in the ML/detection
|
||||
stack; the default wheel must not carry it.
|
||||
- MERIDIAN's training path (`model`/`trainer`/`losses`) is `tch-backend`-gated and
|
||||
would pull libtorch (30 MB+), the exact wheel-size risk ADR-117 §5.1 flagged.
|
||||
|
||||
So P6 mirrors the existing `[client]` extra pattern (ADR-117 §5.6): each subsystem
|
||||
becomes an **optional pip extra**, and the compiled surface is **feature-gated in
|
||||
`wifi-densepose-py`'s `Cargo.toml`** so the default wheel stays lean.
|
||||
|
||||
### 1.4 What this ADR is *not*
|
||||
|
||||
- Not a port of the Rust subsystems to Python — the Rust workspace stays
|
||||
authoritative and unmodified, exactly as ADR-117 §1.3 established.
|
||||
- Not the `wifi-densepose-nn` / libtorch binding (still deferred; MERIDIAN binds
|
||||
only the un-gated inference/adaptation path, not `tch-backend` training).
|
||||
- Not a change to the default wheel's contents, size budget, or abi3 base.
|
||||
|
||||
---
|
||||
|
||||
## 2. Gap analysis
|
||||
|
||||
| Capability | Rust crate(s) | pip v2.x status | Gap severity |
|
||||
|---|---|---|---|
|
||||
| Extract a 128-dim re-ID embedding from a CSI window | `sensing-server::embedding` (AETHER) | Not present | **High** |
|
||||
| Compare two CSI observations by learned similarity (same room? same person?) | AETHER `EmbeddingExtractor` + cosine | Not present | **High** |
|
||||
| Hardware-invariant CSI normalization (ESP32 / Intel 5300 / Atheros → canonical 56) | `signal::hardware_norm` (MERIDIAN) | Not present | **High** |
|
||||
| Geometry-conditioned zero-shot deployment (AP positions → FiLM) | `train::geometry` (MERIDIAN) | Not present | **Medium** |
|
||||
| 10-second unlabeled few-shot room adaptation | `train::rapid_adapt` (MERIDIAN) | Not present | **Medium** |
|
||||
| Cross-domain evaluation protocol (in/cross/few-shot MPJPE) | `train::eval` (MERIDIAN) | Not present | **Medium** |
|
||||
| Disaster-survivor detection + START triage from CSI | `wifi-densepose-mat` | Not present | **Medium** (specialist audience) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Decision
|
||||
|
||||
Adopt **three new optional pip extras**, each binding one SOTA subsystem into the
|
||||
existing `wifi_densepose` wheel as a dedicated Python submodule, gated behind a
|
||||
matching Cargo feature so the default wheel is unchanged:
|
||||
|
||||
```
|
||||
pip install wifi-densepose # unchanged: core + vitals + bfld (≤5 MB)
|
||||
pip install wifi-densepose[aether] # + wifi_densepose.aether
|
||||
pip install wifi-densepose[meridian] # + wifi_densepose.meridian
|
||||
pip install wifi-densepose[mat] # + wifi_densepose.mat (mirrors upstream `mat` cargo feature)
|
||||
pip install wifi-densepose[sota] # convenience: aether + meridian + mat
|
||||
```
|
||||
|
||||
This path is called **PIP-TRINITY**. It reuses ADR-117's established idiom
|
||||
end-to-end: `#[pyclass]` newtype wrappers holding an `inner` Rust value, `#[new]`
|
||||
constructors, `#[getter]` accessors, `__repr__`, a per-module `register(m)` fn,
|
||||
and — critically — **GIL release via `py.allow_threads(|| …)` on every
|
||||
compute-heavy call**, exactly as `bindings/vitals.rs:229` and `:293` already do.
|
||||
|
||||
### 3.1 Feature gating in `wifi-densepose-py`
|
||||
|
||||
New Cargo features and optional path-deps in `python/Cargo.toml`; each binding
|
||||
module is `#[cfg(feature = "…")]`-compiled and conditionally `register()`ed in
|
||||
`src/lib.rs`, so a default build links none of the three:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = []
|
||||
aether = ["dep:wifi-densepose-sensing-server"]
|
||||
meridian = ["dep:wifi-densepose-train", "dep:wifi-densepose-signal"]
|
||||
mat = ["dep:wifi-densepose-mat"] # upstream `mat` feature flows through
|
||||
sota = ["aether", "meridian", "mat"]
|
||||
|
||||
[dependencies]
|
||||
wifi-densepose-sensing-server = { version = "0.3.0", path = "../v2/crates/wifi-densepose-sensing-server", optional = true, default-features = false }
|
||||
wifi-densepose-train = { version = "0.3.0", path = "../v2/crates/wifi-densepose-train", optional = true, default-features = false } # NO tch-backend
|
||||
wifi-densepose-signal = { version = "0.3.0", path = "../v2/crates/wifi-densepose-signal", optional = true }
|
||||
wifi-densepose-mat = { version = "0.3.0", path = "../v2/crates/wifi-densepose-mat", optional = true, default-features = false }
|
||||
```
|
||||
|
||||
`[project.optional-dependencies]` in `pyproject.toml` gains `aether`, `meridian`,
|
||||
`mat`, and `sota` keys mirroring the existing `client`/`dev` extras. Because each
|
||||
extra changes the compiled surface, extras map to **cibuildwheel feature-flag
|
||||
builds**, not pure-Python markers — the publish workflow (ADR-117 §5.4) gains a
|
||||
build axis for the `[sota]` wheel variant.
|
||||
|
||||
### 3.2 Binding surface — AETHER (`wifi_densepose.aether`)
|
||||
|
||||
Backing crate: `wifi-densepose-sensing-server::embedding` (ADR-024 §2.6). The
|
||||
crate is Axum/tokio-based, so we depend on it `default-features = false` and bind
|
||||
**only the sync `embedding` types** — never the server/runtime. If the embedding
|
||||
module cannot be reached without a tokio dependency (Open Question §11.1), the
|
||||
fallback is to hoist `embedding.rs` into a leaf crate; that is a Rust-side
|
||||
refactor, not a Python API change.
|
||||
|
||||
| Python symbol | Wraps | Signature (Python) |
|
||||
|---|---|---|
|
||||
| `AetherConfig` | `AetherConfig` | `AetherConfig(d_model=64, d_proj=128, temperature=0.07, vicreg_alpha=1.0, vicreg_beta=25.0, vicreg_gamma=1.0)` — frozen, `__repr__` |
|
||||
| `CsiAugmenter` | `CsiAugmenter` | `CsiAugmenter(seed)`; `.augment(window: list[list[float]]) -> list[list[float]]` |
|
||||
| `EmbeddingExtractor` | `EmbeddingExtractor` | `.embed(csi_features: list[list[float]]) -> list[float]` (128-dim, L2-normed); `.forward_dual(...) -> tuple[PoseEstimate, list[float]]` |
|
||||
| `aether_loss(...)` | `aether_loss` | returns `AetherLossComponents(total, info_nce, variance, covariance)` — frozen dataclass-like |
|
||||
| `cosine_similarity(a, b)` | thin helper | `float`; convenience for re-ID scoring (not a re-impl — calls the same dot product) |
|
||||
| `alignment_metric`, `uniformity_metric` | same | `float` |
|
||||
|
||||
GIL strategy: `embed`, `forward_dual`, `augment`, and `aether_loss` wrap their
|
||||
Rust call in `py.allow_threads(|| …)` — these are pure-sync matrix ops that touch
|
||||
no Python objects, matching the vitals precedent. A single-frame `embed()` is
|
||||
sub-millisecond (ADR-024 §2.8 target <1 ms FP32), but batch/augment calls exceed
|
||||
the 0.5 ms GIL-release threshold ADR-117 §P3 set.
|
||||
|
||||
`.pyi` stubs: add `wifi_densepose/aether.pyi` declaring the five classes/functions
|
||||
with precise numeric types; extend the top-level `wifi_densepose/__init__.pyi`
|
||||
with a `TYPE_CHECKING`-guarded re-export so `mypy --strict` sees them only when
|
||||
the extra is installed.
|
||||
|
||||
### 3.3 Binding surface — MERIDIAN (`wifi_densepose.meridian`)
|
||||
|
||||
Backing crates: `wifi-densepose-train` (inference/adaptation path, **no
|
||||
`tch-backend`**) + `wifi-densepose-signal::hardware_norm`. The `model`/`trainer`/
|
||||
`losses` modules are libtorch-gated and are **out of scope** — Python gets the
|
||||
domain-generalization *inference and calibration* surface, not the training loop.
|
||||
|
||||
| Python symbol | Wraps | Signature (Python) |
|
||||
|---|---|---|
|
||||
| `HardwareType` | `HardwareType` | `#[pyclass(eq, eq_int, hash, frozen)]` enum: `Esp32S3 / Intel5300 / Atheros / Generic`; `HardwareType.detect(subcarrier_count) -> HardwareType` |
|
||||
| `HardwareNormalizer` | `HardwareNormalizer` | `.normalize(frame: CsiFrame, hw: HardwareType) -> CanonicalCsiFrame` |
|
||||
| `CanonicalCsiFrame` | `CanonicalCsiFrame` | frozen; `.amplitudes`, `.phases`, `.hardware_type` getters |
|
||||
| `GeometryEncoder` | `GeometryEncoder` | `GeometryEncoder(MeridianGeometryConfig)`; `.encode(ap_positions: list[tuple[float,float,float]]) -> list[float]` (64-dim, permutation-invariant) |
|
||||
| `MeridianGeometryConfig` | `MeridianGeometryConfig` | frozen config |
|
||||
| `RapidAdaptation` | `RapidAdaptation` | `.calibrate(csi_windows: list[list[list[float]]]) -> AdaptationResult` (10-sec unlabeled few-shot) |
|
||||
| `AdaptationResult` | `AdaptationResult` | frozen result: `.frames_used`, `.converged`, `.loss` |
|
||||
| `CrossDomainEvaluator` | `CrossDomainEvaluator` | `.evaluate(...) -> dict[str, float]` (in/cross/few-shot MPJPE, domain-gap ratio) |
|
||||
|
||||
GIL strategy: `normalize`, `encode`, `calibrate`, and `evaluate` are wrapped in
|
||||
`py.allow_threads`. `normalize` targets <50 µs/frame (ADR-027 §4.1) and `encode`
|
||||
<100 µs (§4.3), but `calibrate` runs contrastive test-time training over 200
|
||||
frames and is the primary GIL-release beneficiary.
|
||||
|
||||
`.pyi` stubs: `wifi_densepose/meridian.pyi`. `DomainFactorizer` /
|
||||
`GradientReversalLayer` / `VirtualDomainAugmentor` are **training-time only** and
|
||||
are *not* bound in P6 (they need the tch training loop) — Open Question §11.2
|
||||
records this boundary.
|
||||
|
||||
### 3.4 Binding surface — MAT (`wifi_densepose.mat`)
|
||||
|
||||
Backing crate: `wifi-densepose-mat`, bound behind the `[mat]` extra so the
|
||||
disaster/ML stack never enters the default wheel — mirroring the upstream `mat`
|
||||
cargo feature exactly. `DisasterResponse::start_scanning` is async (tokio); rather
|
||||
than bind an event loop, P6 binds the **sync ingest + query surface** and a
|
||||
single-shot `scan_once()` helper (a sync wrapper over one `scan_cycle`, added
|
||||
Rust-side if needed — see §11.3).
|
||||
|
||||
| Python symbol | Wraps | Signature (Python) |
|
||||
|---|---|---|
|
||||
| `DisasterType` | `DisasterType` | `#[pyclass(eq, eq_int, hash, frozen)]` enum: `Earthquake / BuildingCollapse / Avalanche / Flood / Mine / Unknown` |
|
||||
| `TriageStatus` | `TriageStatus` | frozen enum (START protocol classes) |
|
||||
| `DisasterConfig` | `DisasterConfig` | builder-style kwargs: `DisasterConfig(disaster_type, sensitivity=0.8, confidence_threshold=0.5, max_depth=5.0)` |
|
||||
| `DisasterResponse` | `DisasterResponse` | `.push_csi_data(amplitudes, phases)`; `.scan_once()`; `.survivors() -> list[Survivor]`; `.survivors_by_triage(status) -> list[Survivor]` |
|
||||
| `Survivor` | `Survivor` | frozen: `.id`, `.triage_status`, `.location`, `.vital_signs` getters |
|
||||
| `VitalSignsReading` | `VitalSignsReading` | frozen: breathing / heartbeat / movement fields |
|
||||
|
||||
GIL strategy: `push_csi_data` and `scan_once` wrap the detection-pipeline call in
|
||||
`py.allow_threads` — the ensemble classifier + localization are the compute-heavy
|
||||
part and touch no Python state.
|
||||
|
||||
`.pyi` stubs: `wifi_densepose/mat.pyi`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Benchmarking & the measured-vs-claimed parity requirement
|
||||
|
||||
A binding that "runs without crashing" is worthless if it silently regresses
|
||||
accuracy versus the native Rust call. The point of P6 is to prove the Python
|
||||
surface reproduces the Rust subsystem **bit-for-bit**, then to hold each binding
|
||||
to the *same* published SOTA bar its ADR already claims.
|
||||
|
||||
### 4.1 Parity harness (bit-for-bit, mandatory)
|
||||
|
||||
Each subsystem ships a golden-vector parity test. A committed input fixture is
|
||||
run through **both** a tiny native-Rust reference binary (in
|
||||
`v2/crates/wifi-densepose-py/tests/golden/`) and the Python binding; the two
|
||||
outputs must hash-match under SHA-256 (the ADR-028 / ADR-117 §5.7 witness scheme):
|
||||
|
||||
- `aether`: identical 128-dim embedding bytes for a fixed CSI window + fixed seed.
|
||||
- `meridian`: identical `CanonicalCsiFrame` bytes for a fixed ESP32 (64-sub) and
|
||||
Intel-5300 (30-sub) frame; identical 64-dim geometry vector for fixed AP set.
|
||||
- `mat`: identical triage classification + survivor count for a fixed CSI stream.
|
||||
|
||||
A mismatch is a **release blocker**, not a warning. This is the "MEASURED, not
|
||||
CLAIMED" gate the project holds itself to.
|
||||
|
||||
**Scope, stated honestly:** parity proves the **strongest claim available today** —
|
||||
the Python binding is bit-identical to native Rust for the bound surface. It is
|
||||
**not** accuracy validation. The bound AETHER surface moreover ships *untrained*
|
||||
(random-init weights; a `load_weights` API exists since `65da488ad` but no trained
|
||||
checkpoint exists to load — §6.7.2, §13.c.a), so byte-equality here says nothing
|
||||
about the SOTA accuracy bars in §4.3; those remain OPEN (§6.7, §13.c).
|
||||
|
||||
### 4.2 pytest-benchmark micro-benchmarks
|
||||
|
||||
Following the existing `python/bench/test_bench_vitals.py` pattern (skipped by
|
||||
default via `addopts`; run with `pytest python/bench/ --benchmark-only`):
|
||||
|
||||
- `python/bench/test_bench_aether.py` — steady-state `embed()` per-window cost;
|
||||
assert < 2 ms (ADR-024 §2.8 FP32 target < 1 ms with headroom) and that batched
|
||||
`embed()` scales linearly (no accidental O(n²)).
|
||||
- `python/bench/test_bench_meridian.py` — `normalize()` < 200 µs/frame,
|
||||
`encode()` < 200 µs (ADR-027 §4.1/§4.3 targets ×2 headroom).
|
||||
- `python/bench/test_bench_mat.py` — `scan_once()` per-cycle cost bounded by the
|
||||
configured scan interval.
|
||||
|
||||
### 4.3 SOTA accuracy bar the binding must reproduce (not merely run)
|
||||
|
||||
The parity harness (§4.1) guarantees the Python path is byte-identical to Rust, so
|
||||
these published numbers are the bar the *binding output* is validated against on a
|
||||
committed labeled fixture — a regression in any is a binding bug:
|
||||
|
||||
| Metric | Bar | Source |
|
||||
|---|---|---|
|
||||
| WiFlow-STD pose accuracy | **~96% PCK@20** (MEASURED-EQUIVALENT) | ADR-152 §2.2 |
|
||||
| Room identification (k-NN on `env_fingerprint`) | **> 95%** | ADR-024 §2.8 |
|
||||
| Person re-ID mAP | **> 80%** (WhoFi bar 95.5% on NTU-Fi) | ADR-024 §2.8, §1.5 |
|
||||
| Anomaly detection F1 | **> 0.90** | ADR-024 §2.8 |
|
||||
| INT8 rank correlation vs FP32 (Spearman) | **> 0.95** | ADR-024 §2.8 |
|
||||
| Cross-domain MPJPE improvement | **> 20%** vs non-adversarial | ADR-027 §4.2 |
|
||||
| Domain-gap ratio (cross/in-domain) | **< 1.5** | ADR-027 §4.6 |
|
||||
| Few-shot MPJPE after 10-sec calibration | within **15%** of in-domain | ADR-027 §4.5 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase ledger
|
||||
|
||||
```
|
||||
P1 ──► P2 ──► P3 ──► P4
|
||||
aether meridian mat docs +
|
||||
bindings bindings behind examples
|
||||
extra
|
||||
```
|
||||
|
||||
> **Implementation note (2026-07-21):** P1–P4 were built against the **real Rust
|
||||
> code at HEAD**, not this ADR's proposed surface. Where §3's proposed API named
|
||||
> functions/fields that do not exist in the crates (e.g. `aether_loss`/VICReg
|
||||
> components/`alignment_metric`/`forward_dual`, `RapidAdaptation.calibrate`,
|
||||
> `AdaptationResult.converged`), the coder **did not fabricate them** — the real
|
||||
> API was bound and the deviation documented in each module header and commit body.
|
||||
> Treat §3 as the original proposal and the commit messages as the authoritative
|
||||
> record of what shipped.
|
||||
|
||||
### P1 — AETHER bindings (`[aether]` extra) — **DONE** (`d060998e3`; leaf-crate hoist `a47bb71b2`)
|
||||
|
||||
- [x] `aether` Cargo feature + gated optional `wifi-densepose-sensing-server` dep;
|
||||
default build links **0** sensing-server refs (base wheel stays lean).
|
||||
- [x] `python/src/bindings/aether.rs` — `AetherConfig` (→ real `EmbeddingConfig`),
|
||||
`CsiAugmenter.augment_pair`, `EmbeddingExtractor.embed` (128-dim L2-normed,
|
||||
GIL-released), `info_nce_loss`, `cosine_similarity`. **Not bound** (absent in
|
||||
`embedding.rs` at HEAD, a Rust-side gap, not fabricated): `aether_loss`/VICReg
|
||||
components, `alignment_metric`, `uniformity_metric`, `forward_dual`, `vicreg_*`.
|
||||
- [x] `#[cfg(feature = "aether")]` gate + facade + `aether.pyi` + `[aether]` extra.
|
||||
- [x] `python/tests/golden/aether_embedding.sha256` parity fixture:
|
||||
`tests/aether_parity.rs` locks the native reference; `tests/test_aether.py`
|
||||
asserts identical SHA-256 of the LE-f32 bytes.
|
||||
- [x] **Verified:** `cargo test --features aether --test aether_parity` → 2/2;
|
||||
`pytest tests/test_aether.py` → 9/9.
|
||||
- [x] **Leaf-crate hoist (`a47bb71b2`):** `embedding.rs` moved into a new
|
||||
`wifi-densepose-aether` crate. Measured stripped wheel **~361 KB → ~312 KB** (was
|
||||
already ~14× under the 5 MB budget — see §13.a; the hoist's value is build-time
|
||||
71 s → 12 s + dep-graph hygiene, not size). No regression: `aether_parity` 2/2,
|
||||
`pytest` 9/9, sensing-server 217+388 tests 0 failed, new `wifi-densepose-aether`
|
||||
crate 96 passed.
|
||||
|
||||
### P2 — MERIDIAN bindings (`[meridian]` extra) — **DONE** (`189ac9dfb`)
|
||||
|
||||
- [x] `meridian` feature + gated optional `wifi-densepose-train` (**no `tch-backend`
|
||||
— libtorch avoided, confirmed**) + `wifi-densepose-signal` deps.
|
||||
- [x] `python/src/bindings/meridian.rs` — `HardwareType`/`HardwareNormalizer`/
|
||||
`CanonicalCsiFrame` (real API: `normalize(amplitude, phase, hw)` over f64 →
|
||||
`Result`; singular `amplitude`/`phase` fields), `MeridianGeometryConfig`/
|
||||
`GeometryEncoder` (64-dim, permutation-invariant), `RapidAdaptation`
|
||||
(**real API: `push_frame` + `adapt()`**, not the ADR's `calibrate`) →
|
||||
`AdaptationResult` (`lora_weights`/`final_loss`/`frames_used`/
|
||||
`adaptation_epochs`; **no `converged`**), `CrossDomainEvaluator` + `mpjpe`. All
|
||||
compute paths GIL-released. Training-time types (`DomainFactorizer`, GRL,
|
||||
`VirtualDomainAugmentor`) correctly left out of P6 scope.
|
||||
- [x] Gate + facade + `meridian.pyi` + `[meridian]` extra; default dep graph has 0
|
||||
train/signal/sensing-server refs.
|
||||
- [x] `tests/golden/meridian_output.sha256` parity fixture (esp32 + intel canonical
|
||||
frames + 64-dim geometry vector + rapid-adapt LoRA weights).
|
||||
- [x] **Verified:** `cargo test --features meridian --test meridian_parity` → 2/2;
|
||||
`pytest tests/test_meridian.py` → 13/13.
|
||||
|
||||
### P3 — MAT bindings behind `[mat]` extra — **DONE** (`1c9727f9c`)
|
||||
|
||||
- [x] `mat` feature + gated optional `wifi-densepose-mat` dep. **§11.3 resolved: no
|
||||
Rust change needed** — the public async `start_scanning()` already runs exactly
|
||||
one `scan_cycle` when `continuous_monitoring == false`; the binding forces that
|
||||
flag off and drives one cycle on a private current-thread tokio runtime.
|
||||
- [x] `python/src/bindings/mat.rs` — `DisasterType` (**9 variants at HEAD**, not the
|
||||
6 the ADR listed), `TriageStatus` (5, START), `DisasterConfig`,
|
||||
`DisasterResponse` (`initialize_event`/`add_zone`/`push_csi_data`/`scan_once`/
|
||||
`survivors`/`survivors_by_triage` — `initialize_event`+`add_zone` are **required
|
||||
additions** the ADR surface omitted), `Survivor` (`latest_vitals`, since real
|
||||
`vital_signs` is a history), `VitalSignsReading`, `ScanZone.rectangle`/`.circle`.
|
||||
`push_csi_data`+`scan_once` GIL-released.
|
||||
- [x] Gate + facade + `mat.pyi` + `[mat]` **and** `[sota]` (superset) extras.
|
||||
- [x] `tests/golden/mat_result.sha256` parity fixture over a canonical
|
||||
`count=<K>;triage_priorities=<sorted>` string (UUIDs/timestamps excluded as
|
||||
non-deterministic). **Honest scope: proves binding==native path, NOT live
|
||||
detection accuracy** — the synthetic stream yields 1 survivor, triage Delayed.
|
||||
- [x] **Verified:** `cargo test --features mat --test mat_parity` → 2/2;
|
||||
`pytest tests/test_mat.py` → 7/7.
|
||||
|
||||
### P4 — Docs, examples, and benchmark suite — **DONE** (`0f405213d`)
|
||||
|
||||
- [x] `python/bench/test_bench_{aether,meridian,mat}.py` (pytest-benchmark, §4.2).
|
||||
Measured on a `--release --features sota` wheel: AETHER `embed()` ~150 µs
|
||||
(target <2 ms), batch 1/8/64 = 140/1091/8509 µs (linear); MERIDIAN `normalize()`
|
||||
~2.2 µs (target <200 µs), `encode()` ~6.9 µs; MAT ingest+`scan_once()` ~40 ms /
|
||||
256-frame (< 500 ms). All pass.
|
||||
- [x] `python/examples/{reid_from_csi,cross_room_calibrate,mat_triage}.py` — typed,
|
||||
runnable, `mypy --strict` clean; README SOTA extras table.
|
||||
- [~] Parity harness wiring into CI as a **release-blocking gate** — golden gates
|
||||
are green locally (`cargo test --features sota` → 6/6; 3/3 SHA gates), but the CI
|
||||
**wiring** is not done (§6.6 PARTIAL — see §13.b).
|
||||
- [ ] Update ADR-117 §6 "P6+ Deferred" to point at this ADR — still open.
|
||||
|
||||
### P5 — New required follow-ups (blocking Accepted)
|
||||
|
||||
See §13. In short: (a) three leaf-crate hoists — **DONE** (`a47bb71b2`/`7ed57f041`/
|
||||
`99fea9df9`; only MAT was a real budget fix, AETHER was a false alarm), (b) wire the
|
||||
parity harness into CI as an actual release gate — **still open**, (c) source/generate
|
||||
labeled fixtures to validate the SOTA accuracy bars (§4.3) for real — **still open**.
|
||||
|
||||
### P6+ — Deferred (unchanged from ADR-117)
|
||||
|
||||
- [ ] `wifi-densepose-nn` / libtorch bindings (MERIDIAN training loop,
|
||||
`DomainFactorizer`, GRL) — still blocked on the libtorch wheel-size question.
|
||||
- [ ] `wifi-densepose-ruvector` RuVector attention bindings.
|
||||
- [ ] Matter integration helpers.
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptance criteria
|
||||
|
||||
Status recorded from the P4 self-verification run (`0f405213d`), reference machine
|
||||
per ADR-117 §10. **7 of 9 met; 2 remain** — the ADR is therefore **not** Accepted.
|
||||
|
||||
- [x] **§6.1** `pip install wifi-densepose` (no extras) → default wheel **279 KB**
|
||||
(≤ 5 MB); `build_features()` carries no `p6-*` feature — base wheel byte-for-byte
|
||||
unaffected by P6. **PASS**
|
||||
- [x] **§6.2** `pytest python/tests/test_aether.py -q` — **9/9**, incl. a real
|
||||
128-dim `embed()` round-trip asserting L2-norm ≈ 1.0 and byte-identity to the
|
||||
golden Rust reference. **PASS**
|
||||
- [x] **§6.3** `pytest python/tests/test_meridian.py -q` — **13/13**, incl.
|
||||
ESP32 (64-sub) **and** Intel-5300 (30-sub) canonicalization hash-matching native
|
||||
Rust. **PASS**
|
||||
- [x] **§6.4** `pytest python/tests/test_mat.py -q` — **7/7**, incl. a fixed CSI
|
||||
stream whose triage classification matches native `DisasterResponse` exactly.
|
||||
**PASS**
|
||||
- [x] **§6.5** `pytest python/bench/ --benchmark-only` — all targets met (AETHER
|
||||
`embed()` ~150 µs < 2 ms; MERIDIAN `normalize()` ~2.2 µs, `encode()` ~6.9 µs
|
||||
< 200 µs; MAT `scan_once()` ~40 ms < 500 ms). **PASS**
|
||||
- [~] **§6.6** Parity harness (§4.1): all three golden-vector SHA-256 gates green
|
||||
(`cargo test --features sota` → 6/6). **But CI wiring** as a release-blocking
|
||||
gate is **not done** (out of `python/` scope). **PARTIAL — see §13.b.**
|
||||
- [ ] **§6.7** SOTA-bar reproduction (§4.3): **definitively OPEN** — cannot be
|
||||
closed transitively via the parity harness. Investigated; three concrete reasons:
|
||||
1. **The native SOTA numbers aren't reproduced by any committed, runnable-today
|
||||
test.** ADR-152 ~96% PCK@20 is a frozen result in
|
||||
`benchmarks/wiflow-std/results/eval_retrained.json` that points at an **external
|
||||
checkpoint** (`/home/ruvultra/wiflow-std-bench/upstream/test/best_pose_model.pth`,
|
||||
not in the repo); the only relevant test `test_wiflow_std_parity.rs` is
|
||||
`#![cfg(feature = "tch-backend")]` **and** `#[ignore]`d (needs gitignored
|
||||
fixtures + LibTorch). ADR-027's `eval.rs::CrossDomainEvaluator` tests are pure
|
||||
unit-math on hand-coded 2–3-element vectors, not dataset accuracy. ADR-024's
|
||||
only accuracy-ish test asserts Spearman > 0.90 on **synthetic random**
|
||||
embeddings (not real CSI, not the published > 0.95 bar); no room-ID / mAP /
|
||||
anomaly-F1 test exists at all.
|
||||
2. **The bound AETHER surface ships untrained.** `EmbeddingExtractor`/
|
||||
`ProjectionHead` default to random Xavier init (`Linear::with_seed(…,
|
||||
2024/2025)`, `embedding.rs:97–98`). A weight-loading API now **does** exist
|
||||
(`load_weights`/`save_weights`, `65da488ad` — see §13.c.a), so the earlier
|
||||
"no loading path" blocker is removed; but **no trained checkpoint exists** to
|
||||
load, so the binding still produces untrained embeddings and cannot validate
|
||||
mAP > 80% or any trained-model bar today.
|
||||
3. **No committed labeled CSI input/pose-pair data exists** to reuse (MM-Fi/NTU-Fi
|
||||
appear only as config-default subcarrier counts / external paths;
|
||||
`benchmarks/wiflow-std/results/*.npy` are corruption masks + result summaries,
|
||||
not labeled fixtures).
|
||||
The §4.1 parity harness proves the **strongest claim available today** — the
|
||||
Python binding is bit-identical to native Rust for the bound (untrained) surface.
|
||||
That is **not** accuracy validation. **See §13.c.**
|
||||
- [x] **§6.8** `.pyi` stubs present for all three modules; `mypy --strict` passes on
|
||||
the three examples. **PASS**
|
||||
- [x] **§6.9** `python -c "import wifi_densepose.aether"` (etc.) on the base wheel
|
||||
raises a clear `ImportError` naming the missing extra. **PASS**
|
||||
|
||||
No regression: 76 pre-existing tests pass on the default wheel. The two unmet
|
||||
criteria (§6.6 CI wiring, §6.7 accuracy) plus the wheel-size hoists (§13.a) are the
|
||||
gate to Accepted.
|
||||
|
||||
---
|
||||
|
||||
## 7. Consequences
|
||||
|
||||
### 7.1 Positive
|
||||
|
||||
- **Closes the ADR-117 P6 gap**: the three most-requested SOTA subsystems become
|
||||
scriptable from Python without touching the Rust workspace.
|
||||
- **Default wheel stays lean**: feature-gated extras preserve ADR-117 §5.4's ≤ 5 MB
|
||||
budget and "no heavy system deps" invariant; MAT's ML stack and MERIDIAN's
|
||||
libtorch path never enter the base wheel.
|
||||
- **Reuses the proven idiom**: no new binding machinery — same `#[pyclass]` +
|
||||
`py.allow_threads` + `register()` pattern already shipping in `bindings/vitals.rs`.
|
||||
- **Prove-everything alignment**: the parity harness makes "the Python binding
|
||||
equals the Rust core" a *measured, hash-verified* claim, not an assertion —
|
||||
matching the project's MEASURED-vs-CLAIMED discipline.
|
||||
- **Upstream consistency**: `[mat]` pip extra mirrors the `mat` cargo feature, so
|
||||
the Python packaging story matches the Rust one exactly.
|
||||
|
||||
### 7.2 Negative
|
||||
|
||||
- **cibuildwheel matrix grows**: `[sota]` is a distinct compiled variant, adding a
|
||||
build axis (and CI time) beyond ADR-117's 5-wheel abi3 matrix.
|
||||
- **AETHER's backing crate is server-shaped**: depending on
|
||||
`wifi-densepose-sensing-server` (Axum/tokio) risks pulling a runtime into an
|
||||
extension module; may force a Rust-side refactor to hoist `embedding.rs` into a
|
||||
leaf crate (§11.1).
|
||||
- **MERIDIAN surface is partial**: training-time types (`DomainFactorizer`, GRL,
|
||||
`VirtualDomainAugmentor`) stay unbound until the deferred libtorch tier, so the
|
||||
Python API is inference/adaptation-only — potential user confusion (mitigated by
|
||||
docs + `.pyi` omissions).
|
||||
- **Golden fixtures are maintenance surface**: any intentional numeric change in a
|
||||
Rust subsystem requires regenerating and re-witnessing its golden vector.
|
||||
|
||||
### 7.3 Neutral
|
||||
|
||||
- The `[sota]` convenience extra is purely additive; users who want one subsystem
|
||||
install one extra.
|
||||
- No change to the v2.0.0 semver line; extras ship additively as v2.x.y.
|
||||
|
||||
---
|
||||
|
||||
## 8. Alternatives considered
|
||||
|
||||
### Alt-A: Fold all three into the default wheel
|
||||
|
||||
Rejected — breaks ADR-117 §5.4's ≤ 5 MB budget, drags MAT's ML stack and (via
|
||||
MERIDIAN training) libtorch into every install, and contradicts the upstream
|
||||
`mat` cargo-feature gating.
|
||||
|
||||
### Alt-B: Separate PyPI packages (`wifi-densepose-aether`, etc.)
|
||||
|
||||
Rejected for the SOTA trio — three packages fragment the import namespace and
|
||||
duplicate the abi3/cibuildwheel setup. (This remains the right call for the
|
||||
libtorch `nn` tier per ADR-117 Open Q §11.2, which is genuinely heavy.) Extras of
|
||||
one wheel keep `wifi_densepose.*` coherent.
|
||||
|
||||
### Alt-C: Pure-Python reimplementation of the three subsystems
|
||||
|
||||
Rejected explicitly — this is the exact drift ADR-117 §8 Alt-C was created to
|
||||
exit. A Python reimplementation would immediately begin diverging from the Rust
|
||||
SOTA and could not pass the §4.1 bit-for-bit parity gate.
|
||||
|
||||
### Alt-D: REST/WS client to a running sensing-server for AETHER
|
||||
|
||||
Rejected as the primary path — provides zero offline embedding utility and cannot
|
||||
host the parity harness over local Rust code (same reasoning as ADR-117 §8 Alt-B).
|
||||
The pure-Python client layer (`[client]`) remains available for streaming.
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation |
|
||||
|---|---|---|---|
|
||||
| `wifi-densepose-sensing-server` pulls tokio into the extension module | ~~High~~ **Not realized** | ~~High~~ **Low** | **Measured, not realized:** the stripped `[aether]` wheel was **~361 KB** (14× under budget) even before the hoist — linker DCE (`--gc-sections`) strips the server's unreached Axum/tokio/worldgraph code because the binding reaches only pure-compute symbols. Hoist (`a47bb71b2`) still done for build-time / dep-graph hygiene, not budget. See §11.1, §13.a |
|
||||
| MERIDIAN accidentally links `tch-backend` (libtorch) via a default feature | Medium | High | Explicit `default-features = false` on `wifi-densepose-train`; CI `auditwheel`/`ldd` check that no libtorch symbol is present in the `[meridian]` wheel |
|
||||
| `[sota]` build axis blows up cibuildwheel time | Medium | Medium | Build `[sota]` variant only on tagged releases, not every PR |
|
||||
| Golden vectors drift when a Rust subsystem changes intentionally | Medium | Low | Documented regeneration step + ADR-028 witness re-sign; parity mismatch is a loud release blocker, never silent |
|
||||
| MAT async-only surface has no clean sync entry point | Medium | Medium | Add sync `scan_once()` wrapper Rust-side (§11.3) before binding |
|
||||
| Users install base wheel and expect `wifi_densepose.aether` | Low | Low | Clear `ImportError` naming the missing extra (acceptance criterion §6) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Compatibility
|
||||
|
||||
- No change to the default wheel, its abi3-py310 base, or its size budget.
|
||||
- Extras ship additively on the existing v2.x line; no semver break.
|
||||
- `[mat]` pip extra ↔ `mat` cargo feature parity is preserved by construction.
|
||||
- `.pyi` stubs are gated so `mypy --strict` only sees a subsystem when its extra
|
||||
is installed.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions
|
||||
|
||||
1. **AETHER crate shape** — **RESOLVED (`a47bb71b2`).** The original worry that
|
||||
linking `wifi-densepose-sensing-server` would bloat the wheel was **never
|
||||
measured** — it reasoned from the dependency tree (server has non-optional
|
||||
tokio/Axum ⇒ wheel must be huge). The stripped-release measurement disproves it:
|
||||
`[aether]` was **369,782 B (~361 KB)** *before* the hoist — already ~14× under
|
||||
the 5 MB budget — and **319,719 B (~312 KB)** after. Linker dead-code elimination
|
||||
(`--gc-sections` on the pyo3 cdylib) already strips the server's unreached
|
||||
Axum/tokio/worldgraph/ruvector paths because the binding reaches only
|
||||
pure-compute symbols. The hoist into `wifi-densepose-aether` was still done — its
|
||||
real payoff is **build-time** (`[aether]` alone 71 s → 12 s), **dep-graph
|
||||
hygiene** (`python/Cargo.lock` −1238 lines), and **removing latent risk** (a
|
||||
future change that makes server code reachable would then genuinely bloat the
|
||||
wheel). **Convention note:** measure the stripped release wheel size before
|
||||
assuming a dependency-tree risk requires a hoist — linker DCE handles pure-Rust
|
||||
unreached code, but native/FFI-bundled deps (e.g. `ort`/ONNX Runtime, see §13.a
|
||||
MAT) are *not* stripped and are the real size-risk category.
|
||||
|
||||
2. **MERIDIAN training-time types**: `DomainFactorizer`, `GradientReversalLayer`,
|
||||
and `VirtualDomainAugmentor` are meaningful only with the tch training loop.
|
||||
Confirm they stay unbound in P6 and move with the deferred libtorch tier.
|
||||
*Tentative: yes — P6 is inference/adaptation only.*
|
||||
|
||||
3. **MAT sync entry point**: `DisasterResponse::start_scanning` is an async tokio
|
||||
loop. Does a sync single-cycle `scan_once()` already exist, or must it be added
|
||||
Rust-side? *Tentative: add a thin sync `scan_once()` wrapping one `scan_cycle`;
|
||||
do not bind an event loop into the extension.*
|
||||
|
||||
4. **`[sota]` wheel vs per-extra wheels**: cibuildwheel builds one binary per
|
||||
feature-set. Do we publish one `[sota]` wheel and let pip select, or per-extra
|
||||
wheels? This affects the number of build variants. *Tentative: single `[sota]`
|
||||
superset wheel on tagged releases; base wheel stays feature-free.*
|
||||
|
||||
5. **INT8 embedding path in Python**: ADR-024 §2.8 sets an INT8 rank-correlation
|
||||
bar. Do we expose the INT8 quantized `embed()` in P6, or FP32 only first?
|
||||
*Tentative: FP32 in P6; INT8 follows once the Rust quantized path is stable.*
|
||||
|
||||
---
|
||||
|
||||
## 12. References
|
||||
|
||||
### Internal ADRs
|
||||
- **ADR-117**: pip modernization via PyO3 + maturin — the wheel this ADR extends;
|
||||
§5.1/§5.4/§5.6 (extras + wheel budget), §6 "P6+ Deferred".
|
||||
- **ADR-024**: Project AETHER — contrastive CSI embedding; §2.6 module surface,
|
||||
§2.8 performance/accuracy targets.
|
||||
- **ADR-027**: Project MERIDIAN — cross-environment domain generalization; §4
|
||||
phase acceptance criteria, §4.6 evaluation protocol.
|
||||
- **ADR-152**: WiFi-Pose SOTA 2026 — WiFlow-STD ~96% PCK@20 MEASURED-EQUIVALENT bar.
|
||||
- **ADR-028**: ESP32 capability audit / witness scheme — the SHA-256 parity gate
|
||||
the §4.1 golden harness reuses.
|
||||
|
||||
### Rust source (verified HEAD)
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/embedding.rs` — AETHER.
|
||||
- `v2/crates/wifi-densepose-train/src/{domain,geometry,rapid_adapt,virtual_aug,eval}.rs` — MERIDIAN.
|
||||
- `v2/crates/wifi-densepose-signal/src/hardware_norm.rs` — MERIDIAN HardwareNormalizer.
|
||||
- `v2/crates/wifi-densepose-mat/src/lib.rs` — MAT.
|
||||
- `python/src/bindings/vitals.rs` — the `py.allow_threads` GIL-release precedent.
|
||||
- `python/bench/test_bench_vitals.py` — the pytest-benchmark pattern P4 follows.
|
||||
|
||||
---
|
||||
|
||||
## 13. Open follow-ups (blocking Accepted)
|
||||
|
||||
P1–P4 are real, well-tested progress: **32/32 binding tests** (aether 9, meridian
|
||||
13, mat 7, + 3 smoke) and **6/6 native parity tests** all pass, verified on the
|
||||
reference machine. The three leaf-crate hoists (§13.a) are now **done**. Two items
|
||||
still gate Accepted: **§13.b** (wire the parity harness into CI as a release gate)
|
||||
and **§13.c** (the SOTA accuracy gap — bindings are structurally *untrained*, and no
|
||||
eval harness or labeled data exists yet; genuine long-term work, not a quick fix).
|
||||
|
||||
### 13.a — Leaf-crate hoists (all three DONE) — one real fix, one minor, one false alarm
|
||||
|
||||
All three extras' backing crates carry heavy declared deps, so the hoist was applied
|
||||
to each. But **measuring the stripped release wheel** (not reasoning from the
|
||||
dependency tree) showed the wheel-size story differs sharply per extra. Linker
|
||||
dead-code elimination (`--gc-sections` on the pyo3 cdylib) strips **pure-Rust
|
||||
unreached** code, so a heavy declared dep tree does **not** imply a big wheel;
|
||||
**native/FFI-bundled** deps (`ort`/ONNX Runtime's native library) are the exception
|
||||
— DCE cannot strip them, and those are the real size risk.
|
||||
|
||||
| Extra | Commit | Wheel size (stripped) | Verdict |
|
||||
|---|---|---|---|
|
||||
| `[aether]` | `a47bb71b2` | **~361 KB → ~312 KB** | **False alarm.** Never breached the 5 MB budget — DCE already stripped the sensing-server's unreached Axum/tokio/worldgraph/ruvector code. Hoist justified by build-time (71 s → 12 s), dep-graph hygiene (`Cargo.lock` −1238 lines), and latent-risk removal — **not** budget. |
|
||||
| `[mat]` | `7ed57f041` | **8.4 MB → 2.0 MB** | **Real, measured regression.** `wifi-densepose-nn` bundles `ort`/ONNX Runtime, a **native** library DCE does **not** strip → genuine breach. Fix necessary and correctly characterized. |
|
||||
| `[meridian]` | `99fea9df9` | **1.8 MB → 1.7 MB** | **Real but minor.** Measured from the start; a dead dep removed. Already under budget; small win. `libtorch` correctly avoided throughout (`tch` optional, off). |
|
||||
|
||||
These were changes **inside** the upstream `v2/` crates (owned by other agents this
|
||||
session); the default wheel was unaffected throughout because every extra is
|
||||
feature-gated off. All three hoists are now landed — the remaining Accepted blockers
|
||||
are §13.b (CI gate) and §13.c (accuracy fixtures), **not** wheel size.
|
||||
|
||||
### 13.b — Wire the parity harness into CI as a real release gate (§6.6)
|
||||
|
||||
The three golden-vector SHA-256 gates pass locally (`cargo test --features sota` →
|
||||
6/6) but are not yet wired into a CI workflow that **blocks release** on mismatch.
|
||||
Add a job to the ADR-117 §5.4 publish pipeline that runs the native `*_parity.rs`
|
||||
references + the `pytest` binding checks and fails the release on any divergence.
|
||||
|
||||
### 13.c — Close the SOTA accuracy gap (§4.3, §6.7) — genuine long-term work
|
||||
|
||||
This is the most important honesty gap and it is **more fundamental than missing
|
||||
labeled data** (see §6.7 for the three findings). The parity harness proves the
|
||||
Python binding is **byte-identical to the native Rust path** for the bound, **but
|
||||
untrained**, surface — it does **not** prove the cited SOTA numbers (ADR-152
|
||||
~96% PCK@20; ADR-024 room-ID > 95% / re-ID mAP > 80% / anomaly F1 > 0.90; ADR-027
|
||||
cross-domain MPJPE + 20% / domain-gap < 1.5). Those bars remain **CLAIMED, not
|
||||
MEASURED** by this work.
|
||||
|
||||
Closing it requires three steps, in dependency order:
|
||||
|
||||
- **(a) Add trained-weight loading to the AETHER/pose bindings — DONE (`65da488ad`).**
|
||||
`EmbeddingExtractor` gained `save_weights(path)` / `load_weights(path)` /
|
||||
`param_count` on both the native crate and the Python binding (GIL-released,
|
||||
`ValueError`/never-panics on bad input), removing the "structurally untrained, no
|
||||
loading path" blocker: a real checkpoint can now be loaded whenever one exists.
|
||||
Default construction is unchanged (still random `with_seed` init, clearly labeled
|
||||
untrained) — purely additive. **Format tradeoff:** rather than pull in
|
||||
`safetensors`/`serde`/`bincode`, the on-disk format is raw little-endian `f32`
|
||||
with a 12-byte header (8-byte magic `AETHERW1` + `u32` param count), reusing the
|
||||
pre-existing `flatten_weights`/`unflatten_weights` — this deliberately preserves
|
||||
`wifi-densepose-aether`'s zero-dependency std-only leaf-crate property from the
|
||||
§13.a hoist. **Verified:** `cargo test -p wifi-densepose-aether` 98/98; parity 3/3
|
||||
incl. the new cross-language golden `aether_weights_parity.rs` (native Rust and the
|
||||
Python binding load the same weight file and produce a byte-identical embedding
|
||||
SHA-256, and the loaded weights demonstrably move the output off the random-init
|
||||
baseline — not a silent no-op); `pytest test_aether.py` 13/13 (up from 9).
|
||||
**This does NOT close §6.7** — it is the *capability* to load weights, not trained
|
||||
weights; (b) and (c) below remain, and no SOTA number is validated yet.
|
||||
- **(b) Commit or source a small labeled CSI fixture** (input CSI + ground-truth
|
||||
pose/identity/room labels) — **still OPEN.** Genuine **data-acquisition scope**.
|
||||
- **(c) Build a real eval harness** computing PCK / mAP / room-ID / anomaly-F1 /
|
||||
Spearman on (a)+(b) and asserting the published bars — **still OPEN.**
|
||||
|
||||
With (a) landed, the remaining work is (b) and (c): genuine research /
|
||||
data-acquisition scope beyond one session. This is now purely a data-availability +
|
||||
missing-eval-infra problem, **not** a binding defect. Status stays **Proposed**
|
||||
until (b)–(c) land and §4.3 is run for real.
|
||||
@@ -0,0 +1,486 @@
|
||||
# ADR-186: Training progress API — wire the orphaned in-server trainer to `/ws/train/progress`
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted |
|
||||
| **Date** | 2026-07-21 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **TRAIN-RECONNECT** — connecting a trainer that was written, committed, and then never plugged in |
|
||||
| **Relates to** | [ADR-051](ADR-051-sensing-server-decomposition.md) (main.rs decomposition into ~14 modules), [ADR-151](ADR-151-per-room-calibration.md) (`train-room` specialist bank), [ADR-152](ADR-152-wifi-pose-sota-2026.md) (MAE recipe / geometry conditioning), [ADR-166](ADR-166-quality-engineering-security-hardening.md) (WS auth + god-object decomposition) |
|
||||
| **Tracking issue** | [#1233](https://github.com/ruvnet/wifi-densepose/issues/1233) — "Training does not start – /ws/train/progress returns 404 and no model is generated" (open) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 The reported gap
|
||||
|
||||
A user starting training from the web dashboard hits
|
||||
`ws://localhost:3000/ws/train/progress`, which **404s**, and the backend never
|
||||
produces a trained `.rvf` model or any further log output beyond a single
|
||||
"Training started" line. Issue #1233 is open, and the repo owner's own comment on
|
||||
it states:
|
||||
|
||||
> The `/ws/train/progress` WebSocket endpoint is not yet exposed in the stable
|
||||
> server — the training pipeline (room-calibration specialists, MAE pretraining)
|
||||
> runs via the CLI (`wifi-densepose train-room`) rather than through the
|
||||
> HTTP/WebSocket API, which is why the Docker image returns 404 for that path.
|
||||
|
||||
So the dashboard has a **"Start Training" button that silently no-ops**: it POSTs a
|
||||
config, receives a `success: true` response, and then nothing happens — no error is
|
||||
surfaced, no model is produced, no progress stream exists. A button that appears to
|
||||
work but does nothing is the definition of slop, and this ADR exists to close that
|
||||
gap honestly.
|
||||
|
||||
### 1.2 What the live server actually does today (evidence)
|
||||
|
||||
The stable server mounts **stub** training handlers. The POST handler flips a string
|
||||
flag, logs one line, and returns success — it starts no job:
|
||||
|
||||
```rust
|
||||
// v2/crates/wifi-densepose-sensing-server/src/main.rs:4986–5006
|
||||
async fn train_start(
|
||||
State(state): State<SharedState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let mut s = state.write().await;
|
||||
if s.training_status == "running" { /* ... */ }
|
||||
s.training_status = "running".to_string();
|
||||
s.training_config = Some(body.clone());
|
||||
info!("Training started with config: {}", body); // ← the one log line the issue reports
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"status": "running",
|
||||
"message": "Training pipeline started. Use GET /api/v1/train/status to monitor.",
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
These three stubs — and **nothing else training-related** — are wired into the live
|
||||
router:
|
||||
|
||||
```rust
|
||||
// v2/crates/wifi-densepose-sensing-server/src/main.rs:8068–8071
|
||||
// Training endpoints
|
||||
.route("/api/v1/train/status", get(train_status))
|
||||
.route("/api/v1/train/start", post(train_start))
|
||||
.route("/api/v1/train/stop", post(train_stop))
|
||||
```
|
||||
|
||||
There is **no `/ws/train/progress` route in the live app** — hence the 404 that
|
||||
issue #1233 reports. The stub state fields backing them are just:
|
||||
|
||||
```rust
|
||||
// v2/crates/wifi-densepose-sensing-server/src/main.rs:1125–1127
|
||||
training_status: String, // "idle" | "running" | ...
|
||||
training_config: Option<serde_json::Value>,
|
||||
```
|
||||
|
||||
### 1.3 The surprising finding: a real trainer already exists, orphaned
|
||||
|
||||
The gap is **not** that training was never built for the server. A complete
|
||||
in-server training pipeline **already exists in the tree** at
|
||||
`v2/crates/wifi-densepose-sensing-server/src/training_api.rs` (1,860 lines). Its own
|
||||
module doc describes what it does (`training_api.rs:1–25`):
|
||||
|
||||
- Loads recorded CSI from `.csi.jsonl` files, extracts signal features (subcarrier
|
||||
variance, temporal gradients, Goertzel frequency-domain power).
|
||||
- Trains a regularised linear model via batch gradient descent.
|
||||
- Exports a calibrated `.rvf` model container via `RvfBuilder` on completion.
|
||||
- **"No PyTorch / `tch` dependency is required. All linear algebra is implemented
|
||||
inline using standard Rust math."** (`training_api.rs:11–13`)
|
||||
|
||||
It runs training on a **background tokio task** and streams progress over a
|
||||
`tokio::sync::broadcast` channel to a real WebSocket handler:
|
||||
|
||||
- `start_training` spawns the job: `tokio::spawn(async move { ... })`
|
||||
(`training_api.rs:1564`, spawn at `:1610`).
|
||||
- `ws_train_progress_handler` subscribes to `training_progress_tx` and forwards
|
||||
`{"type":"progress", "data": …}` frames (`training_api.rs:1778–1836`).
|
||||
- A `routes()` factory wires the whole surface, **including the missing route**:
|
||||
|
||||
```rust
|
||||
// v2/crates/wifi-densepose-sensing-server/src/training_api.rs:1841–1849
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/train/start", post(start_training))
|
||||
.route("/api/v1/train/stop", post(stop_training))
|
||||
.route("/api/v1/train/status", get(training_status))
|
||||
.route("/api/v1/train/pretrain", post(start_pretrain))
|
||||
.route("/api/v1/train/lora", post(start_lora_training))
|
||||
.route("/ws/train/progress", get(ws_train_progress_handler))
|
||||
}
|
||||
```
|
||||
|
||||
**This module is dead code.** There is no `mod training_api;` declaration anywhere
|
||||
in the crate — a repo-wide search for `training_api` returns only a doc-comment
|
||||
mention in `path_safety.rs:9`. Because Rust never sees the file without a `mod`
|
||||
declaration, `training_api.rs` is **not compiled into the binary at all**, and
|
||||
`training_api::routes()` is never merged into the app. It was written, committed
|
||||
(last touched by commit `9b07dff29`), and then orphaned.
|
||||
|
||||
### 1.4 Why it would not even compile if naively wired in
|
||||
|
||||
The orphan was written against a **different state shape than the one that shipped**.
|
||||
`training_api.rs` expects its parent to expose an `AppStateInner` carrying a training
|
||||
sub-state and a broadcast sender:
|
||||
|
||||
```rust
|
||||
// v2/crates/wifi-densepose-sensing-server/src/training_api.rs:249
|
||||
pub type AppState = Arc<RwLock<super::AppStateInner>>;
|
||||
// handlers read s.training_state.status, s.training_state.task_handle,
|
||||
// s.training_progress_tx (e.g. training_api.rs:1588, :1610, :1788)
|
||||
```
|
||||
|
||||
But the **real** `AppStateInner` (`main.rs:1024`, aliased `SharedState` at
|
||||
`main.rs:1249`) has none of those fields — only the `training_status: String` /
|
||||
`training_config` stubs from §1.2. `training_state: TrainingState` is defined
|
||||
locally in `training_api.rs:232`, and `training_progress_tx` exists nowhere on the
|
||||
live state. So adding `mod training_api;` today produces a compile error: the module
|
||||
references `AppStateInner` fields that do not exist. Wiring it in requires
|
||||
**reconciling the state struct first**, not merely uncommenting a route.
|
||||
|
||||
### 1.5 The working path today
|
||||
|
||||
The path that actually trains a model is the CLI, exactly as the maintainer's
|
||||
comment says:
|
||||
|
||||
- `wifi-densepose train-room` → `room.rs:241` `train_room(...)`, the ADR-151
|
||||
Stage-2–5 per-room specialist-bank trainer (`enroll → train-room → room-watch`).
|
||||
- The heavier `wifi-densepose-train` crate exposes epoch-level metrics
|
||||
(`trainer.rs:43` `pub epoch: usize`, `trainer.rs:64` `best_epoch`) that a progress
|
||||
stream could surface directly — the data a WebSocket needs already exists in the
|
||||
training loop.
|
||||
|
||||
### 1.6 What this ADR is *not*
|
||||
|
||||
- Not a rewrite of the trainer. The pipeline in `training_api.rs` already exists;
|
||||
this ADR reconnects and hardens it.
|
||||
- Not a move of GPU/`tch`-backed training into the Axum server. The in-server
|
||||
trainer is deliberately `tch`-free (§1.3). Heavy MAE/LoRA training stays in the
|
||||
CLI / `wifi-densepose-train` crate; the server streams progress for the light,
|
||||
pure-Rust specialist trainer and (optionally) proxies status for CLI-launched runs.
|
||||
- Not a change to the `train-room` CLI contract (ADR-151). The CLI remains the
|
||||
authoritative path for offline / batch training.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state — evidence
|
||||
|
||||
| Artifact | Value | Source |
|
||||
|---|---|---|
|
||||
| Live POST handler | `train_start` — flips a flag, logs, returns `success:true`, starts no job | `main.rs:4986–5006` |
|
||||
| The "Training started" log line from the issue | `info!("Training started with config: {}", body)` | `main.rs:5000` |
|
||||
| Live training routes | `train/status`, `train/start`, `train/stop` (stubs only) | `main.rs:8068–8071` |
|
||||
| `/ws/train/progress` in live app | **Absent** → 404 | (no route in `main.rs` router) |
|
||||
| Live training state fields | `training_status: String`, `training_config: Option<Value>` | `main.rs:1125–1127` |
|
||||
| Real in-server trainer | 1,860-line implemented pipeline, `tch`-free, exports `.rvf` | `training_api.rs:1–25` |
|
||||
| Real WS progress handler | subscribes to broadcast, streams `progress` frames | `training_api.rs:1778–1836` |
|
||||
| Real route factory (has the missing route) | `routes()` incl. `/ws/train/progress` | `training_api.rs:1841–1849` |
|
||||
| Background job spawn | `tokio::spawn` of the training task | `training_api.rs:1564`, spawn `:1610` |
|
||||
| `mod training_api;` declaration | **None in the crate** (only a doc mention) | `path_safety.rs:9` |
|
||||
| State-shape mismatch | expects `super::AppStateInner.{training_state, training_progress_tx}` | `training_api.rs:249`, `:232` |
|
||||
| Real `AppStateInner` / `SharedState` | has neither field | `main.rs:1024`, `:1249` |
|
||||
| Working training path | CLI `train-room` (ADR-151 specialist bank) | `room.rs:241` |
|
||||
| Epoch metrics available to stream | `TrainMetrics.epoch`, `best_epoch` | `train/src/trainer.rs:43`, `:64` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Gap analysis
|
||||
|
||||
| Capability | Desired | Today | Gap severity |
|
||||
|---|---|---|---|
|
||||
| `/ws/train/progress` resolves | 101 Switching Protocols, streams epoch/loss/eta | 404 (route absent) | **Critical** — the reported bug |
|
||||
| "Start Training" produces a model | background job trains and writes `.rvf` | flag flip + one log line, no job, no model | **Critical** |
|
||||
| Error surfaced to the user | button reflects real state / disabled with reason | silent no-op, `success:true` | **Critical** (slop) |
|
||||
| In-server trainer compiled | part of the crate, unit-tested | orphaned; not compiled (no `mod`) | **High** |
|
||||
| State supports progress streaming | `training_state` + `training_progress_tx` on `AppStateInner` | absent — orphan won't compile as-is | **High** |
|
||||
| WS auth on the training surface | `/ws/train/progress` under bearer gate (ADR-166 §Sprint-1) | n/a (route absent) | **High** |
|
||||
| `dataset_ids` path safety | validated before file open | `path_safety.rs` exists but unreached by live routes | **Medium** |
|
||||
| Server ↔ CLI parity | shared/consistent training semantics | two divergent trainers (stub vs CLI vs orphan) | **Medium** |
|
||||
|
||||
---
|
||||
|
||||
## 4. Decision
|
||||
|
||||
**Chosen path: wire the existing in-server trainer into the live server** — reconcile
|
||||
the state struct, declare the module, merge `training_api::routes()`, delete the
|
||||
stub handlers, and expose a real `/ws/train/progress` that streams epoch/loss/eta
|
||||
events from the already-implemented background job.
|
||||
|
||||
This is called **TRAIN-RECONNECT**.
|
||||
|
||||
### 4.1 Why this path, and not "make the button honestly say CLI-only"
|
||||
|
||||
The task framing offered two honest options. Investigation decided it:
|
||||
|
||||
| Consideration | Evidence | Implication |
|
||||
|---|---|---|
|
||||
| Is server-side training genuinely GPU/`tch`-bound (→ keep CLI-only)? | The in-server trainer is explicitly **`tch`-free**, pure Rust, exports `.rvf` (`training_api.rs:11–13`) | The "too heavy for Axum" argument is contradicted by the code |
|
||||
| Does a real streaming implementation already exist? | Full pipeline + broadcast + WS handler + `routes()` present (`training_api.rs:1564,1778,1841`) | The impressive-sounding option is also the *least* new code — it already exists |
|
||||
| Why does it 404 then? | No `mod training_api;`; state-shape mismatch (`:249` vs `main.rs:1024`) | The fix is reconnection + reconciliation, not new invention |
|
||||
|
||||
Because the honest, code-supported reality is "a working trainer was written and left
|
||||
unplugged," the right decision is to plug it in — this is not choosing the flashier
|
||||
option over the code; it *is* what the code says.
|
||||
|
||||
**However**, path B is retained as a **mandatory fallback guarantee** (Phase P5): if,
|
||||
for a given build/deployment, server-side training is disabled (e.g. behind a
|
||||
feature flag, or on the lightweight appliance image where recordings aren't
|
||||
available), the dashboard button MUST be disabled with a tooltip pointing at
|
||||
`wifi-densepose train-room` — never a silent `success:true` no-op again. The slop is
|
||||
eliminated in both the enabled and disabled configurations.
|
||||
|
||||
### 4.2 Scope boundary — light trainer streams, heavy trainer proxies
|
||||
|
||||
- The **pure-Rust specialist trainer** (`training_api.rs`, ADR-151 flavour) runs
|
||||
in-process and streams live epoch/loss/eta over `/ws/train/progress`.
|
||||
- **Heavy MAE/LoRA training** (`wifi-densepose-train`, `tch`/GPU) stays CLI-launched.
|
||||
The server does not host it; at most `/api/v1/train/status` reports on a
|
||||
CLI-launched run if one registers itself. Streaming heavy training is out of scope
|
||||
for this ADR (noted as an open question, §8).
|
||||
|
||||
---
|
||||
|
||||
## 5. Detailed design
|
||||
|
||||
### 5.1 Reconcile `AppStateInner`
|
||||
|
||||
Replace the two stub fields (`main.rs:1125–1127`) with the sub-state the trainer
|
||||
expects, so `training_api.rs` compiles against `super::AppStateInner`:
|
||||
|
||||
```rust
|
||||
// main.rs — inside AppStateInner (replacing training_status / training_config)
|
||||
training_state: training_api::TrainingState, // status, epoch, best_pck, task_handle
|
||||
training_progress_tx: tokio::sync::broadcast::Sender<String>, // progress fan-out
|
||||
```
|
||||
|
||||
`train_status` consumers that read `s.training_status` / `s.training_config` are
|
||||
updated to read `s.training_state.status`. The broadcast sender is created at state
|
||||
init (`main.rs:7826` region, where the stubs are seeded today).
|
||||
|
||||
### 5.2 Declare and merge the module
|
||||
|
||||
- Add `mod training_api;` to `main.rs` (or `pub mod` in `lib.rs` if the router is
|
||||
assembled there).
|
||||
- Delete the stub handlers `train_start` / `train_stop` / `train_status`
|
||||
(`main.rs:4977–5023`) and their three route mounts (`main.rs:8069–8071`).
|
||||
- Merge the real router **after** `.with_state(state.clone())`, the same pattern the
|
||||
RuField surface already uses (`main.rs:8104–8111`):
|
||||
|
||||
```rust
|
||||
// main.rs router assembly
|
||||
.merge(training_api::routes())
|
||||
```
|
||||
|
||||
so that `/api/v1/train/*` and `/ws/train/progress` resolve against the shared state.
|
||||
|
||||
### 5.3 Auth and safety (ADR-166 alignment)
|
||||
|
||||
- `/api/v1/train/*` sits under the existing opt-in bearer gate (`main.rs:8095–8102`,
|
||||
`RUVIEW_API_TOKEN`). `/ws/train/progress` follows the same policy decision made for
|
||||
`/ws/sensing` — document explicitly whether the training WS is gated (recommended:
|
||||
gated when a token is set, since training reads/writes recordings and models).
|
||||
- `dataset_ids` from `StartTrainingRequest` (`training_api.rs:126–130`) are resolved
|
||||
through `path_safety` before any file open — `path_safety.rs:9` already anticipates
|
||||
`{dataset_id}.csi.jsonl` under `RECORDINGS_DIR`; wire it in the load path.
|
||||
- Single-job concurrency guard: `start_training` already rejects a second run while
|
||||
`training_state.status.active` (`training_api.rs:1571`) — keep it.
|
||||
|
||||
### 5.4 Progress event schema (already emitted)
|
||||
|
||||
The WS handler already frames messages as `{"type":"status"|"progress", "data": …}`
|
||||
(`training_api.rs:1796–1815`). Confirm the `data` payload carries at minimum
|
||||
`epoch`, `total_epochs`, `loss`, `best_pck`, and an `eta_seconds`; these map onto the
|
||||
`TrainMetrics`/`TrainingStatus` fields already populated by the loop
|
||||
(`training_api.rs:1251`, `train/src/trainer.rs:43,64`).
|
||||
|
||||
### 5.5 Dashboard honesty (both configurations)
|
||||
|
||||
- **Enabled build:** button POSTs `/api/v1/train/start`, then opens
|
||||
`/ws/train/progress`; the UI renders live epoch/loss/eta and a terminal
|
||||
success/failure with the output `.rvf` path.
|
||||
- **Disabled build:** `/api/v1/train/start` returns a structured
|
||||
`{"enabled": false, "reason": "...", "cli": "wifi-densepose train-room"}` and the
|
||||
button renders disabled with a tooltip — no silent `success:true`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase ledger
|
||||
|
||||
```
|
||||
P0 ──► P1 ──► P2 ──► P3 ──► P4 ──► P5 ──► P6
|
||||
repro state wire stream auth+ dash tests+
|
||||
+audit recon router job safety honesty witness
|
||||
```
|
||||
|
||||
### P0 — Reproduce & audit (evidence lock)
|
||||
- [x] Confirmed the orphan: `grep -rn "mod training_api"` returned **nothing**; the only
|
||||
hit was a doc mention in `path_safety.rs`. `training_api.rs` was uncompiled.
|
||||
- [x] Confirmed the stub no-op (`train_start` at `main.rs:4986` flipped a string + logged
|
||||
one line, no job, no `.rvf`) and the missing `/ws/train/progress` route.
|
||||
|
||||
### P1 — Reconcile `AppStateInner`
|
||||
- [x] Replaced `training_status`/`training_config` with `training_state:
|
||||
training_api::TrainingState` + `training_progress_tx: broadcast::Sender<String>`.
|
||||
- [x] Updated state init; the only readers of the old fields were the stub handlers (deleted).
|
||||
- [x] Added `mod training_api;` (+ `mod path_safety;`); the module compiles against the real state.
|
||||
|
||||
### P2 — Wire the router, delete the stubs
|
||||
- [x] Removed `train_start`/`train_stop`/`train_status` and their 3 route mounts.
|
||||
- [x] `.merge(training_api::routes())` — merged **before** `.with_state(...)` (not after).
|
||||
The RuField surface merges after because it carries a *different* state; the training
|
||||
router shares `SharedState`, so merging before is what puts `/api/v1/train/*` under the
|
||||
same `/api/v1/*` bearer gate as everything else.
|
||||
- [x] `/api/v1/train/*` and `/ws/train/progress` resolve (verified by HTTP tests, not 404).
|
||||
|
||||
### P3 — Confirm the real job streams and produces a model
|
||||
- [x] The spawned job loads `.csi.jsonl` (falls back to a `frame_history` snapshot),
|
||||
runs the gradient-descent loop, and writes a `.rvf` under `data/models`.
|
||||
- [x] Progress frames carry `epoch`, `total_epochs`, `train_loss`, `val_pck`, `eta_secs`.
|
||||
- [x] Server-vs-CLI semantics documented as **intentionally divergent** (§4.2, §9.2):
|
||||
the server runs the light pure-Rust specialist trainer; heavy MAE/LoRA stays CLI.
|
||||
|
||||
### P4 — Auth & path safety
|
||||
- [x] `/api/v1/train/*` sits under the existing `RUVIEW_API_TOKEN` bearer gate (merged
|
||||
before `.with_state`); `/ws/train/progress` is intentionally **ungated**, matching
|
||||
`/ws/sensing` (browsers can't attach an `Authorization` header to a WS upgrade).
|
||||
- [x] `dataset_ids` resolved via `path_safety::safe_id` before file open; pinned by
|
||||
`load_recording_frames_rejects_path_traversal`.
|
||||
- [x] Single-job guard: `spawn_training_job` rejects a second start while active
|
||||
(`is_active()` → `active_error`).
|
||||
|
||||
### P5 — Dashboard honesty (fallback guarantee)
|
||||
- [x] Enabled build: `TrainingPanel` opens `/ws/train/progress` before the POST and renders
|
||||
live epoch/loss/PCK/ETA + a terminal Complete state (already wired; verified).
|
||||
- [x] Disabled build (`RUVIEW_DISABLE_SERVER_TRAINING`): start returns
|
||||
`{enabled:false, cli:"wifi-densepose train-room"}` HTTP 409; the dashboard reads
|
||||
`enabled` off `/api/v1/train/status` and disables the Start buttons with a CLI
|
||||
tooltip — no silent no-op. Implemented via a runtime flag rather than a Cargo feature
|
||||
so the `--no-default-features` test build keeps training ON (§9.4 resolved this way).
|
||||
|
||||
### P6 — Tests & witness
|
||||
- [x] Live-socket test `ws_train_progress_live_101_and_frame`: genuine 101 handshake + a real
|
||||
progress frame after POST start. Plus `ws_train_progress_route_is_wired_not_404`.
|
||||
- [x] `http_train_start_produces_model_and_streams`: POST start → poll status → `.rvf` exists.
|
||||
- [x] CHANGELOG updated. README/CLAUDE have no training route table, so no route-table edit
|
||||
was needed there.
|
||||
|
||||
*(All phases complete. Acceptance criteria verified below — this ADR is Accepted.)*
|
||||
|
||||
---
|
||||
|
||||
## 7. Acceptance criteria (concrete verification)
|
||||
|
||||
All must pass before ADR-186 is Accepted:
|
||||
|
||||
- [x] **Orphan is reconnected:**
|
||||
`grep -rn "mod training_api" v2/crates/wifi-densepose-sensing-server/src/`
|
||||
returns a hit (`main.rs`), and
|
||||
`cargo build -p wifi-densepose-sensing-server` **compiles** (proves the state
|
||||
reconciliation in §5.1 is correct — the module cannot compile against the
|
||||
current `AppStateInner`). **VERIFIED.**
|
||||
- [x] **Route no longer 404s (HTTP upgrade):** verified in-process rather than with a live
|
||||
`curl` — `ws_train_progress_live_101_and_frame` binds the training router on a real
|
||||
socket and `tokio_tungstenite::connect_async` completes a genuine **101** handshake
|
||||
(asserts `resp.status() == 101`); `ws_train_progress_route_is_wired_not_404` also
|
||||
confirms the route is reached (426 under `oneshot`, **not** 404). **VERIFIED.**
|
||||
- [x] **Progress actually streams:** `ws_train_progress_live_101_and_frame` connects the WS,
|
||||
POSTs `/api/v1/train/start`, and receives a real `{"type":"progress","data":{...}}`
|
||||
frame within the 10 s ceiling. **VERIFIED.**
|
||||
- [x] **A model is produced:** `http_train_start_produces_model_and_streams` POSTs start,
|
||||
polls `/api/v1/train/status` to completion, and asserts a **new `.rvf`** appeared under
|
||||
`data/models/` (snapshot diff). Also covered by the trainer-level
|
||||
`training_job_streams_real_progress_and_writes_model`. **VERIFIED.**
|
||||
- [x] **No silent no-op remains:** `http_train_start_disabled_returns_structured_409` sets
|
||||
`RUVIEW_DISABLE_SERVER_TRAINING` and asserts POST start returns **HTTP 409** with
|
||||
`{"enabled":false, ...,"cli":"wifi-densepose train-room"}` and never `success:true`.
|
||||
**VERIFIED.**
|
||||
- [x] **Auth honored:** `/api/v1/train/*` is merged into the router **before** the
|
||||
`RUVIEW_API_TOKEN` bearer middleware and `.with_state`, so it is covered by the exact
|
||||
same `/api/v1/*` gate as every other authenticated route (verified by construction /
|
||||
code review; `/ws/train/progress` is intentionally ungated like `/ws/sensing`). No new
|
||||
dedicated runtime token test was added — the gate is the shared, already-tested
|
||||
`bearer_auth` middleware. **VERIFIED (by construction).**
|
||||
- [x] **Path safety:** `load_recording_frames_rejects_path_traversal` asserts
|
||||
`dataset_ids:["../../etc/passwd"]` yields no frames (rejected by `path_safety::safe_id`
|
||||
before any file open). **VERIFIED.**
|
||||
- [x] **Integration test green:** `ws_train_progress_live_101_and_frame` (`#[tokio::test]`)
|
||||
serves the training router, opens `/ws/train/progress`, and asserts a 101 upgrade + a
|
||||
real progress frame — and, being built on `training_api::routes()`, cannot compile if
|
||||
the module is orphaned again. **VERIFIED.**
|
||||
- [x] **Workspace regression:** `cargo test -p wifi-densepose-sensing-server
|
||||
-p wifi-densepose-train --no-default-features` — sensing-server bin **217 passed /
|
||||
0 failed**, all train suites **0 failed**. A full `cargo test --workspace
|
||||
--no-default-features` run initially surfaced a **test-only parallelism race** in the
|
||||
new tests (two model-writing tests deleted `.rvf`s by directory-diff, occasionally
|
||||
removing a file a third test asserted existed) — fixed by removing the cross-test
|
||||
deletions (each test cleans only its own artifact; `data/models` is gitignored).
|
||||
Re-verified post-fix: `cargo test --workspace --no-default-features` — **0 failed**
|
||||
(exit 0). **VERIFIED.**
|
||||
|
||||
---
|
||||
|
||||
## 8. Consequences
|
||||
|
||||
### Positive
|
||||
- Closes issue #1233: the dashboard button either trains-and-streams or honestly says
|
||||
"use the CLI" — the silent no-op is gone in every configuration.
|
||||
- Reclaims 1,860 lines of already-written, already-committed trainer that were dead
|
||||
(uncompiled) code, and adds a test that keeps them wired.
|
||||
- `/ws/train/progress` gives the UI real epoch/loss/eta, matching the maintainer's
|
||||
stated intent.
|
||||
- Forces the state-shape reconciliation that the orphan implied but never landed,
|
||||
removing a latent "two competing training designs" trap in `AppStateInner`.
|
||||
|
||||
### Negative
|
||||
- Editing `AppStateInner` (`main.rs:1024`) and the router (`main.rs:8068`) touches the
|
||||
large `main.rs`; merge-conflict risk with concurrent work on the same file (the
|
||||
ADR-166 decomposition is relevant here).
|
||||
- Adds a live training code path to the server's attack surface — mitigated by the
|
||||
bearer gate and `path_safety`, but it must be reviewed (network/hardware boundary,
|
||||
per the pre-merge security checklist).
|
||||
- Server and CLI now have two trainers that must be kept semantically consistent, or
|
||||
their divergence explicitly documented.
|
||||
|
||||
### Neutral
|
||||
- Heavy MAE/LoRA/`tch` training remains CLI-only; the server streams only the
|
||||
light pure-Rust specialist trainer. Streaming heavy runs is deferred.
|
||||
- The progress event schema (`epoch/loss/best_pck/eta`) is already emitted by the
|
||||
orphan; no new schema is invented, only confirmed and documented.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
1. **WS auth policy for `/ws/train/progress`:** gate it whenever `RUVIEW_API_TOKEN`
|
||||
is set (like `/api/v1/*`), or leave it open like `/ws/sensing`? *Tentative: gate
|
||||
it — training reads recordings and writes models.*
|
||||
2. **Server ↔ CLI trainer parity:** should the in-server trainer and
|
||||
`wifi-densepose train-room` (ADR-151) share one code path, or remain deliberately
|
||||
separate (server = quick UI-driven specialist fit; CLI = full bank + geometry
|
||||
conditioning)? *Tentative: keep separate, document the split, share feature
|
||||
extraction where cheap.*
|
||||
3. **Heavy-training progress:** can a CLI-launched `wifi-densepose-train` (`tch`)
|
||||
run register itself so `/api/v1/train/status` and the WS can report on it without
|
||||
hosting it in-process? *Tentative: out of scope here; a follow-on ADR.*
|
||||
4. **Feature-flagging server training:** should in-server training be behind a Cargo
|
||||
feature (off on the lightweight appliance image), making the P5 disabled-button
|
||||
path the default there? *Tentative: yes — flag it; default the UI to the honest
|
||||
disabled state on images without recordings.*
|
||||
|
||||
---
|
||||
|
||||
## 10. References
|
||||
|
||||
- **Issue #1233**: https://github.com/ruvnet/wifi-densepose/issues/1233 — the reported bug.
|
||||
- **Live stubs**: `v2/crates/wifi-densepose-sensing-server/src/main.rs:4977–5023` (handlers),
|
||||
`:8068–8071` (routes), `:1125–1127` (state fields), `:1024`/`:1249` (`AppStateInner`/`SharedState`).
|
||||
- **Orphaned trainer**: `v2/crates/wifi-densepose-sensing-server/src/training_api.rs` —
|
||||
module doc `:1–25`, `TrainingState` `:232`, `AppState` alias `:249`, `start_training` `:1564`
|
||||
(spawn `:1610`), WS handler `:1778–1836`, `routes()` `:1841–1849`.
|
||||
- **Not-a-module proof**: repo-wide `training_api` only in `path_safety.rs:9` (doc comment).
|
||||
- **CLI working path**: `v2/crates/wifi-densepose-cli/src/room.rs:241` `train_room` (ADR-151).
|
||||
- **Epoch metrics**: `v2/crates/wifi-densepose-train/src/trainer.rs:43`, `:64`.
|
||||
- **ADR-166**: WebSocket authentication + `main.rs` decomposition (security context for this change).
|
||||
- **ADR-151**: per-room calibration / `train-room` specialist bank.
|
||||
@@ -0,0 +1,198 @@
|
||||
# ADR-187: `archive/v1` Deprecation & Model-Weights Honest Labeling
|
||||
|
||||
- **Status**: Accepted
|
||||
- **Date**: 2026-07-21
|
||||
- **Deciders**: ruv
|
||||
- **Tags**: archive-v1, deprecation, densepose-head, model-weights, honest-labeling, prove-everything, credibility, pip-tombstone
|
||||
- **Refs**: [#509](https://github.com/ruvnet/RuView/issues/509) (missing model weights / reproducibility), [#1125](https://github.com/ruvnet/RuView/issues/1125) ("has anyone got this to work?")
|
||||
- **Relates to**: [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (pip modernization + 1.99.0 tombstone), [ADR-160](ADR-160-edge-skill-library-honest-labeling.md) (honest-labeling precedent), [ADR-079](ADR-079-camera-ground-truth-training.md) (camera-supervised pose target), [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) (WiFlow-STD PCK@20 measurement), [ADR-175](ADR-175-int8-quantization-half-pose-model-measured.md) (int8 pose trade-off), [ADR-101](ADR-101-pose-estimation-cog.md) (pose cog)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Two open GitHub issues are, at root, the same complaint: the project's public surface
|
||||
lets a reader believe a WiFi→17-keypoint pose model exists and produces real accuracy,
|
||||
when the specific code they land on cannot back that claim.
|
||||
|
||||
- **#509** — a detailed technical review states: *"While the network architecture for
|
||||
DensePoseHead is defined in the code, there are no pre-trained weights (.pth or .onnx
|
||||
files) available in the repository,"* and questions whether ESP32 1×1 SISO antennas
|
||||
can match the multi-antenna NIC research this project is inspired by.
|
||||
- **#1125** — a user asks for anyone to testify the project actually runs and returns
|
||||
real data. A pure credibility complaint.
|
||||
|
||||
This ADR follows the **prove-everything / anti-"AI-slop"** directive and the
|
||||
**honest-labeling** precedent set by ADR-160: the fix is to make the labels TRUE, not
|
||||
to fabricate a capability. Grading vocabulary (from ADR-152 / ADR-160):
|
||||
|
||||
- **MEASURED** — reproduced in this worktree; the file/absence was directly inspected.
|
||||
- **DATA-GATED** — a real code path exists; honestly flagged where the accuracy is not validated.
|
||||
- **NO-ACTION (already-honest)** — audited, found correct, cited as a positive.
|
||||
|
||||
### What the investigation actually found (MEASURED in this worktree)
|
||||
|
||||
The situation is **more nuanced than either issue implies** — worse in one place, and
|
||||
distinctly *better* in others. Forcing a uniformly negative narrative would itself be
|
||||
dishonest. The findings:
|
||||
|
||||
**1. `archive/v1` — the issue reporter is correct here.**
|
||||
- `archive/v1/src/models/densepose_head.py` defines `DensePoseHead` (segmentation +
|
||||
UV-regression heads). Its `_initialize_weights()` uses **`kaiming_normal_` random
|
||||
initialization only** — there is no checkpoint-loading path in the class.
|
||||
- `Glob archive/v1/**/*.{pth,onnx,safetensors,pt,ckpt,bin}` → **zero files**. There are
|
||||
**no trained weights anywhere under `archive/v1/`.** The "architecture defined, no
|
||||
weights" claim is TRUE for this tree.
|
||||
- `archive/v1/README.md` calls the tree "the legacy Python implementation" in a single
|
||||
closing note but does **not** loudly warn users off it, and there is **no
|
||||
`archive/v1/DEPRECATED.md`.** This is the dead-but-present code that shows up in greps
|
||||
and search and reads as if it were the live implementation.
|
||||
- Per ADR-117, this exact tree is the source of the tombstoned pip package
|
||||
`wifi-densepose 1.x` (1.99.0 raises an `ImportError` telling users to migrate). The
|
||||
code is already tombstoned *on PyPI* but not *in the repo*.
|
||||
|
||||
**2. `v2` (the current, maintained system) — real weights DO exist; the "no weights
|
||||
anywhere" reading is FALSE at the project level.** Git-tracked, committed checkpoints:
|
||||
- `v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors` (507 KB) +
|
||||
`pose_v1.onnx` (12 KB) + `train_results.json` — a **real committed 17-keypoint
|
||||
model**, trained with Candle on an RTX 5080.
|
||||
- `v2/crates/cog-person-count/cog/artifacts/count_v1.{safetensors,onnx}` — a committed
|
||||
person-count model.
|
||||
- Externally published on Hugging Face (not committed, but real and released):
|
||||
`ruvnet/wifi-densepose-pretrained` (CSI encoder + presence head, honestly re-labeled
|
||||
at **82.3% held-out temporal-triplet accuracy** — the older "100% presence" figure was
|
||||
already retracted, an existing honest-labeling win) and `ruvnet/wifi-densepose-mmfi-pose`
|
||||
(a pose model reporting **82.69% torso-PCK@20** on the MM-Fi `random_split` protocol).
|
||||
- ADR-152 measurement (a): the *external* WiFlow-STD (DY2434) model was reproduced at
|
||||
**96.09% PCK@20** on an RTX 5080 (graded MEASURED-EQUIVALENT). That is an external
|
||||
baseline, not RuView's own weights.
|
||||
|
||||
**3. The honest gap is narrow and specific — the live, on-device ESP32 17-keypoint
|
||||
pose path.** Per `v2/crates/cog-pose-estimation/cog/README.md` (already an exemplary
|
||||
"Honest reading" section):
|
||||
- The committed `pose_v1` scores **PCK@20 = 3.0% / PCK@50 = 18.5%** on a 217-sample
|
||||
holdout — **below the ADR-079 target of PCK@20 ≥ 35%.** It learns coarse structure
|
||||
(`r_hip` 77% PCK@50) but distal/face joints are near-random. `encoder_init` was
|
||||
`random`; it was trained on a single 30-min seated-at-desk recording (1,077 samples,
|
||||
avg confidence 0.44).
|
||||
- The cog's **runtime inference path is still a centred-skeleton stub returning
|
||||
`confidence=0`** — the `pose_v1.safetensors` weights are not yet wired into
|
||||
`src/inference.rs`.
|
||||
- ADR-079 records the proxy-supervised baseline at **PCK@20 = 2.5%**, and ADR-152
|
||||
**retracted** the internal camera-supervised 92.9% PCK@20 figure (it was a
|
||||
constant-output model scored under an absolute threshold on near-static frames; a mean
|
||||
predictor scores 100% under the same broken protocol).
|
||||
|
||||
### The real problem to fix
|
||||
|
||||
Not "the project has no weights" (false) and not "there is a validated pretrained
|
||||
DensePoseHead" (false for the live ESP32 path). The real problem is a **labeling and
|
||||
navigation gap**:
|
||||
1. `archive/v1`'s random-init `DensePoseHead` is indistinguishable, to a grepping
|
||||
reader, from the live implementation, and carries no deprecation notice.
|
||||
2. Nowhere is the split stated plainly: *which* checkpoints are real and validated
|
||||
(presence 82.3%, MM-Fi pose 82.69% torso-PCK@20), *which* are real-but-weak and
|
||||
honestly labeled (`pose_v1` 3% PCK@20, runtime stubbed), and *which* are
|
||||
architecture-only with no weights at all (`archive/v1` `DensePoseHead`).
|
||||
|
||||
## Decision
|
||||
|
||||
Two coordinated honest-labeling actions. Neither invents a capability; both make the
|
||||
public surface match what the code and checkpoints actually deliver.
|
||||
|
||||
### (a) Formally deprecate `archive/v1` in the repo — MEASURED gap, proposed fix
|
||||
|
||||
- **Add `archive/v1/DEPRECATED.md`** — a loud tombstone stating that `archive/v1` is the
|
||||
original pure-Python implementation, is **unmaintained and superseded**, that its
|
||||
`DensePoseHead` is **architecture-only with random-initialized weights and ships no
|
||||
trained checkpoint**, and that the maintained path is the `v2/` Rust workspace + the
|
||||
`wifi-densepose 2.x` / `ruview` pip wheel (ADR-117). Mirror the disclaimer tone of
|
||||
ADR-160's `//!` headers and the pip 1.99.0 tombstone text.
|
||||
- **Prepend a loud notice to `archive/v1/README.md`** (the file exists) — a `> ⚠️
|
||||
DEPRECATED` block at the very top pointing to `DEPRECATED.md`, `v2/`, and the pip
|
||||
wheel, before any of the existing "how to install v1" content.
|
||||
- **Rule:** no doc outside `archive/v1/` may reference `archive/v1` code (other than the
|
||||
ADR-028 deterministic proof at `archive/v1/data/proof/verify.py`, which is a
|
||||
legitimately live signal-pipeline witness and stays) as if it were current. The two
|
||||
README references verified (`README.md` lines 139/198/204; `docs/user-guide.md`
|
||||
proof/swift-compile lines) are all proof/utility invocations, not implementation
|
||||
claims — they are acceptable and out of scope.
|
||||
|
||||
### (b) Model-weights honest labeling — state the three tiers explicitly
|
||||
|
||||
Add a **"Model weights: what's real, what's not"** subsection to `README.md` and
|
||||
`docs/user-guide.md` that names the three tiers verified above, so no reader can infer
|
||||
"a pretrained 17-keypoint DensePoseHead produces real pose accuracy on my ESP32":
|
||||
|
||||
| Tier | Checkpoint(s) | Honest status |
|
||||
|------|---------------|---------------|
|
||||
| **Real & validated** | `ruvnet/wifi-densepose-pretrained` (encoder + presence, 82.3% held-out temporal-triplet); `ruvnet/wifi-densepose-mmfi-pose` (82.69% torso-PCK@20, MM-Fi `random_split`); `count_v1` | MEASURED / published; keep current honest labels |
|
||||
| **Real but weak (honestly labeled)** | committed `pose_v1.safetensors` in `cog-pose-estimation` | **PCK@20 = 3.0%**, below the ADR-079 ≥35% target; runtime path is a `confidence=0` stub until weights are wired into `src/inference.rs`. Already disclosed in the cog README; surface the same caveat wherever the live ESP32 pose feature is advertised |
|
||||
| **Architecture only, no weights** | `archive/v1` `DensePoseHead` | random-init, no checkpoint; deprecated per (a) |
|
||||
|
||||
- The existing MM-Fi/presence honest labels (retraction of "100% presence", the cog
|
||||
"Honest reading") are **NO-ACTION positives** — cite them, do not weaken them.
|
||||
- The live ESP32 17-keypoint claim stays **DATA-GATED**: the path to a first
|
||||
*reproducible* on-device baseline is ADR-079 (multi-session, full-body-framed,
|
||||
camera-supervised, ≥30K paired samples at conf ≥0.7, target PCK@20 ≥35%), tracked in
|
||||
[#645]. Do not advertise the live ESP32 pose feature without the "first-cut / below
|
||||
target / runtime stub" caveat until that baseline is MEASURED.
|
||||
- Directly answer #509's ESP32-SISO question in the docs, honestly: single-antenna 56-
|
||||
subcarrier CSI at a 20-frame window does **not** carry the fine-grained spatial
|
||||
information the multi-antenna NIC research relies on (the cog README already shows
|
||||
distal/face joints near-random) — the shippable pose accuracy the project *can* stand
|
||||
behind today is the **MM-Fi benchmark** number, not a live single-ESP32 number.
|
||||
|
||||
## Phase ledger
|
||||
|
||||
| Phase | Action | State |
|
||||
|-------|--------|-------|
|
||||
| **P0** | This ADR (investigation + decision) | **DONE** (this file) |
|
||||
| **P1** | Add `archive/v1/DEPRECATED.md` + loud notice atop `archive/v1/README.md` | **DONE** (1fb5397dd) |
|
||||
| **P2** | Add "Model weights: what's real, what's not" tier table to `README.md` + `docs/user-guide.md`; add the caveat wherever the live ESP32 17-keypoint feature is advertised | **DONE** (1fb5397dd; follow-up caveated the hardware table, hero caption, and live-pipeline note) |
|
||||
| **P3** | Answer #509's SISO/no-weights question and #1125's "does it run" in `docs/user-guide.md` (point to the reproducible proofs: MM-Fi arena, `archive/v1/data/proof/verify.py`, cog `train_results.json`) | **DONE** (1fb5397dd) |
|
||||
| **P4** | Close the DATA-GATED live-pose gap via ADR-079 first reproducible on-device baseline (PCK@20 ≥35%) + wire `pose_v1.safetensors` into `cog-pose-estimation/src/inference.rs` | ACCEPTED-FUTURE ([#645]) |
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `archive/v1/DEPRECATED.md` exists and names `v2/` + the pip wheel as the maintained path.
|
||||
- [x] `archive/v1/README.md` opens with a `> ⚠️ DEPRECATED` block before any install instructions.
|
||||
- [x] `README.md` and `docs/user-guide.md` no longer let a reader infer that `archive/v1`
|
||||
or an untrained/random-init `DensePoseHead` produces real pose accuracy without the
|
||||
caveats added here.
|
||||
- [x] The live ESP32 17-keypoint pose feature is nowhere advertised without its
|
||||
"first-cut, PCK@20 = 3.0%, below ADR-079 target, runtime stub" caveat.
|
||||
- [x] The three real/published checkpoints (presence 82.3%, MM-Fi pose 82.69% torso-PCK@20,
|
||||
`count_v1`) keep their existing honest labels — nothing is weakened or overclaimed.
|
||||
- [x] No claim is added that is not MEASURED or explicitly DATA-GATED.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- A grepping reader can no longer mistake `archive/v1`'s random-init `DensePoseHead` for
|
||||
the live system; the dead code is loudly tombstoned in the repo, matching its PyPI 1.99.0 tombstone.
|
||||
- #509 and #1125 get an honest, verifiable answer: real trained weights *do* exist
|
||||
(presence + MM-Fi pose are published and benchmarked), the *specific* file the reporter
|
||||
found is architecture-only, and the live ESP32 pose path is honestly weak-and-in-progress.
|
||||
- Reinforces the ADR-160 honest-labeling discipline: the project's credibility comes from
|
||||
precise labels, not from a suppressed or inflated narrative.
|
||||
|
||||
### Negative
|
||||
- The docs must openly state that the live single-ESP32 17-keypoint pose is not yet at a
|
||||
citable accuracy — a short-term "looks less finished" cost, paid for by not overclaiming.
|
||||
- Two more files to keep in sync (`DEPRECATED.md`, the tier table) as the checkpoints evolve.
|
||||
|
||||
### Neutral
|
||||
- No code or model behavior changes; `archive/v1` stays in the tree as a research archive
|
||||
(ADR-117 §1.3) and its ADR-028 proof witness is untouched.
|
||||
- Purely documentation/labeling; no crate, wheel, or firmware rebuild required.
|
||||
|
||||
## References
|
||||
|
||||
- `archive/v1/src/models/densepose_head.py` — `DensePoseHead`, random `_initialize_weights()`, no checkpoint load.
|
||||
- `archive/v1/README.md` — legacy note; no loud deprecation (target of P1).
|
||||
- `v2/crates/cog-pose-estimation/cog/README.md` — the "Honest reading" precedent (PCK@20 = 3.0%, runtime stub).
|
||||
- `v2/crates/cog-pose-estimation/cog/artifacts/{pose_v1.safetensors,pose_v1.onnx,train_results.json}` — committed first-cut pose model.
|
||||
- `v2/crates/cog-person-count/cog/artifacts/count_v1.{safetensors,onnx}` — committed count model.
|
||||
- `ruvnet/wifi-densepose-pretrained`, `ruvnet/wifi-densepose-mmfi-pose` — published, benchmarked checkpoints.
|
||||
- ADR-079 §Target (PCK@20 ≥35%), ADR-152 measurement (a) (96.09% PCK@20 external; internal 92.9% retracted), ADR-160 (honest-labeling method), ADR-117 (pip 1.99.0 tombstone).
|
||||
@@ -0,0 +1,487 @@
|
||||
# ADR-271: RuView as a Cognitum OAuth resource server
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-07-22
|
||||
- **Deciders**: RuView maintainers
|
||||
- **Tags**: auth, oauth, cognitum, security, sensing-server
|
||||
- **Related**: ADR-055 (integrated sensing server), ADR-102 (edge module registry), ADR-066 (ESP32 seed pairing), cognitum-one/dashboard ADR-060 (OAuth scopes beyond `inference`), cognitum-one/meta-llm ADR-045 (Bearer at completions)
|
||||
|
||||
## Context
|
||||
|
||||
`/api/v1/*` on `wifi-densepose-sensing-server` is gated by `RUVIEW_API_TOKEN`
|
||||
(`bearer_auth.rs`): a single shared secret, compared in constant time, with no
|
||||
expiry, no rotation and no per-user attribution. `homecore-api` has a second,
|
||||
unrelated scheme (`LongLivedTokenStore` over `HOMECORE_TOKENS`) whose own doc
|
||||
comment describes it as "no expiry, no rotation, no per-user attribution yet".
|
||||
|
||||
That is proportionate for the ADR-055 topology — server bundled in the desktop
|
||||
app, spawned as a child, localhost only. It is not proportionate for the other
|
||||
deployment RuView actually has: a sensing server on a Pi or hub, reachable on a
|
||||
LAN, potentially serving more than one person, exposing live presence, pose,
|
||||
breathing and heart-rate data plus destructive operations (model training,
|
||||
model delete, recording delete).
|
||||
|
||||
Cognitum operates a live OAuth 2.1 authorization server at `auth.cognitum.one`.
|
||||
Users of RuView are already Cognitum account holders. The obvious question is
|
||||
whether RuView can accept that identity instead of a shared string.
|
||||
|
||||
### The direction of the integration is the thing most likely to be misread
|
||||
|
||||
Every existing Cognitum OAuth integration in the org — meta-proxy, musica,
|
||||
metaharness, the dashboard CLI — is an OAuth **client**: it obtains a token so
|
||||
the application can *call* a Cognitum service (the completions plane).
|
||||
|
||||
RuView is the opposite. It makes **no authenticated calls to any Cognitum API**.
|
||||
Its only outbound Cognitum dependency is the ADR-102 registry fetch, which is an
|
||||
anonymous GET against a public GCS bucket. What RuView wants is to be a
|
||||
**resource server**: a user signs in to their *own* RuView instance with their
|
||||
Cognitum identity, and RuView verifies the token they present.
|
||||
|
||||
So the client-side prior art in the org, while useful for a future `ruview
|
||||
login` command, addresses a plane RuView does not have. The only relevant
|
||||
precedent is `meta-llm/src/auth/oauthBearer.ts` (ADR-045) — the org's sole
|
||||
resource-server-side verifier of these tokens. It is TypeScript; **RuView is the
|
||||
first Rust one.**
|
||||
|
||||
### Facts about the tokens, verified against a live production token
|
||||
|
||||
- **ES256 JWT**, signed by a single P-256 key published at
|
||||
`https://auth.cognitum.one/.well-known/jwks.json`.
|
||||
- **15-minute lifetime**, with an opaque refresh token that **rotates with reuse
|
||||
detection** (presenting a spent one ends the session).
|
||||
- Claims: `typ`, `sub`, `account_id`, `org_id`, `workspace_id`, `client_id`,
|
||||
`scope`, `family_id`, `jti`, `iat`, `exp`, `setup`, `workload`.
|
||||
- **No `aud` claim.** No `/oauth/introspect`. No `/userinfo`. It is an OAuth 2.1
|
||||
authorization server, not an OpenID Provider, deliberately.
|
||||
|
||||
## Decision
|
||||
|
||||
Verify Cognitum access tokens **offline**, in a new `ruview-auth` crate, and
|
||||
gate RuView's own API surface on the **scope** they carry.
|
||||
|
||||
### 1. Offline verification is a requirement, not an optimisation
|
||||
|
||||
RuView runs on Pi-class hardware that loses WAN, and there is no introspection
|
||||
endpoint to call even when the network is up. Verification is therefore an
|
||||
ES256 signature check against a `kid`-indexed JWKS cache. Two consequences we
|
||||
accept explicitly:
|
||||
|
||||
- **Revocation window = token lifetime.** A compromised access token stays
|
||||
usable until `exp`. This is the same position meta-llm takes, for the same
|
||||
reason, and it is why §3 refuses long-lived credentials.
|
||||
- **A JWKS refetch failure is survivable while a key set is cached.** A key that
|
||||
verified a minute ago has not stopped being valid because the network blipped;
|
||||
failing closed there would log every user out of their own sensing server
|
||||
whenever their internet wobbled. We fail closed in exactly one case: no key
|
||||
set has *ever* been fetched.
|
||||
|
||||
### 2. The accept-rule is ported from meta-llm, not designed
|
||||
|
||||
```
|
||||
typ == "access" AND NOT setup AND NOT workload
|
||||
AND account_id is a non-empty string
|
||||
AND exp is in the future
|
||||
AND the scope required by the route is held
|
||||
```
|
||||
|
||||
**Note there is no `iss` check.** An earlier revision of this section listed
|
||||
"`iss` matches the configured issuer verbatim" — that rule was implemented,
|
||||
shipped, and rejected EVERY real token, because Cognitum access tokens carry no
|
||||
`iss` claim (see §"Facts about the tokens" above, which contradicted this
|
||||
paragraph for a day). Removed in the code; removed here. The JWKS is the issuer
|
||||
binding.
|
||||
|
||||
Divergence from `oauthBearer.ts` would be a bug rather than a preference: a
|
||||
token meta-llm rejects must not be one RuView accepts. The algorithm is **fixed
|
||||
to ES256 by our code** — the header's `alg` is only ever compared against that
|
||||
allowlist, never used to select an algorithm.
|
||||
|
||||
### 3. Long-lived setup and workload credentials are refused outright
|
||||
|
||||
Identity also issues 365-day *setup* and machine *workload* credentials. Their
|
||||
revocation state lives in identity's `oauth_setup_tokens` table. RuView — like
|
||||
meta-llm — has no database and no way to check it, so accepting one would mean
|
||||
honouring a credential that may already have been revoked. A 15-minute token
|
||||
needs no revocation round-trip because it expires faster than revocation
|
||||
propagates; a 365-day one does.
|
||||
|
||||
### 4. Scope is the capability boundary, because nothing else can be
|
||||
|
||||
Tokens carry no `aud`, so RuView cannot verify a token was minted *for* RuView.
|
||||
`client_id` cannot substitute: clients borrow each other's registrations when
|
||||
their own has not been deployed (musica ships `DEFAULT_CLIENT_ID = "meta-proxy"`).
|
||||
|
||||
This is not a defect to route around. Cross-product **identity** is intended —
|
||||
one Cognitum account, every Cognitum product. Cross-product **capability** is
|
||||
not, and scope is what carries the difference.
|
||||
|
||||
RuView registers two scopes (dashboard ADR-060, identity migration `0016`):
|
||||
|
||||
| Scope | Grants |
|
||||
|---|---|
|
||||
| `sensing:read` | sensing/pose streams, one-shot inference, reading model and recording metadata |
|
||||
| `sensing:admin` | every mutating route not explicitly allowlisted as read-safe — training (`/api/v1/train/*` AND `/api/v1/adaptive/train`), model and recording deletion, config writes |
|
||||
|
||||
**The gate is fail-closed for writes, and that polarity is load-bearing.** An
|
||||
earlier revision enumerated admin routes by prefix and let everything else fall
|
||||
through to `sensing:read`. `POST /api/v1/adaptive/train` — which trains a
|
||||
classifier, overwrites the on-disk model and swaps the live one — does not match
|
||||
`/api/v1/train/`, so it was reachable with `sensing:read`, the scope
|
||||
`wifi-densepose login` requests by default. Found by adversarial review. Now:
|
||||
reads are open, writes require admin unless the exact path is on a short
|
||||
allowlist of non-destructive mutations. A route added tomorrow is admin-gated
|
||||
until someone classifies it.
|
||||
|
||||
**No hierarchy**: `sensing:admin` does not imply `sensing:read`. Consent means
|
||||
exactly what it said, and a token needing both must have consented to both.
|
||||
`client_id` is retained on the principal for logging and attribution only —
|
||||
never as an authorization input.
|
||||
|
||||
### 5. Additive and fail-closed, never a silent downgrade
|
||||
|
||||
`RUVIEW_API_TOKEN` and `HOMECORE_TOKENS` deployments keep working unchanged.
|
||||
OAuth is opt-in; with it unconfigured, behaviour is byte-identical to today.
|
||||
When OAuth *is* configured but unusable (JWKS unreachable at boot, required
|
||||
scope not registered), the server must refuse to serve `/api/v1/*` rather than
|
||||
fall through to an open or single-secret state.
|
||||
|
||||
### 6. `ureq`, and a transport seam
|
||||
|
||||
`wifi-densepose-sensing-server` deliberately chose `ureq` as "the smallest" HTTP
|
||||
client. Introducing `reqwest` for a JWKS fetch would silently reverse that for
|
||||
the whole dependency graph. The fetch sits behind a `JwksFetcher` trait — the
|
||||
`ureq` implementation is a default-on feature, and a host may supply its own and
|
||||
take no HTTP dependency at all.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Requests become attributable: `sub`, `account_id`, `org_id`, `workspace_id`,
|
||||
`jti`. This closes the gap `homecore-api`'s `tokens.rs` has been deferring as
|
||||
"P3", using claims rather than new RuView machinery.
|
||||
- Destructive operations can be separated from observation for the first time.
|
||||
- **The 15-minute lifetime is the main operational cost.** A long-running client
|
||||
must refresh, and because refresh tokens rotate with reuse detection, a
|
||||
concurrent or naively retried refresh **ends the session** — single-flight is a
|
||||
correctness requirement, not an optimisation. This lands with the login flow,
|
||||
not this crate.
|
||||
- Hosts without a battery-backed clock will fail `exp`/`iat` until NTP lands.
|
||||
The verifier reports that distinguishably so it is diagnosable rather than
|
||||
presenting as a generic 401.
|
||||
- A new dependency, `jsonwebtoken` — the same crate, same major version, that
|
||||
identity itself uses to sign these tokens.
|
||||
|
||||
## ~~Known incomplete: the browser cannot obtain an OAuth token~~ — CLOSED 2026-07-23
|
||||
|
||||
> **Superseded within this same PR.** The text below described the state when
|
||||
> this ADR was first written. It is retained because the reasoning still
|
||||
> explains *why* the browser half was built, but every factual claim in it is
|
||||
> now false — in particular `grep -ril "oauth|cognitum|pkce" ui/` now returns
|
||||
> `ui/sw.js`, `ui/sw.test.mjs` and `ui/utils/quick-settings.js`. An adversarial
|
||||
> review caught the ADR still asserting the old state; see "Browser sign-in"
|
||||
> below for what actually ships.
|
||||
|
||||
<details>
|
||||
<summary>Original text (no longer accurate)</summary>
|
||||
|
||||
`wifi-densepose login` writes to `~/.ruview/credentials.json` — a file a browser
|
||||
cannot read. The UI's `ws-ticket.js` reads a bearer from
|
||||
`localStorage['ruview-api-token']`, which is populated **only** by the
|
||||
QuickSettings manual-paste panel. There is no "Sign in with Cognitum" control,
|
||||
no redirect flow, and `grep -ril "oauth|cognitum|pkce" ui/` returns nothing.
|
||||
|
||||
So a user who signs in via the CLI gets **no benefit in the browser UI**, and
|
||||
the WebSocket ticket mechanism this ADR's sibling (ADR-272) introduces "for
|
||||
browsers" is today only exercisable with the legacy static shared secret that
|
||||
OAuth was meant to replace. The server-side gating is correct and complete; the
|
||||
browser half of the story these ADRs tell is not built.
|
||||
|
||||
</details>
|
||||
|
||||
## Browser sign-in
|
||||
|
||||
`/oauth/start`, `/oauth/callback`, `/oauth/logout` and `/oauth/status`, plus a
|
||||
"Cognitum Account" panel in QuickSettings. The server runs the authorization
|
||||
code + PKCE flow itself and hands the browser a **signed session cookie** —
|
||||
never the access token. The browser gets an assertion that this server already
|
||||
verified a token, which is nothing replayable anywhere else.
|
||||
|
||||
Three things about it are load-bearing and were each found the hard way:
|
||||
|
||||
- **The cookie carries the granted scope**, and the gate re-checks it per
|
||||
request. A `sensing:read` session cannot delete a model.
|
||||
- **`__Host-` is deliberately NOT used.** That prefix requires `Secure`, and
|
||||
RuView is routinely reached over plain HTTP on a LAN; a cookie the browser
|
||||
refuses to set is worse than one without the prefix. The cost is real and is
|
||||
recorded as P3 under "Open problems" below.
|
||||
- **The service worker must never cache `/oauth/*` or authenticated `/api/*`.**
|
||||
The Cache API is not the HTTP cache and ignores `Cache-Control` entirely, so
|
||||
a cached `/oauth/status` froze sign-in until a hard reload, and cached API
|
||||
responses could be replayed to a different user after sign-out. `ui/sw.js` is
|
||||
now deny-by-default with an allowlist.
|
||||
|
||||
### Still incomplete
|
||||
|
||||
`redirect_uri` defaults to `http://127.0.0.1:8080/oauth/callback` and is
|
||||
overridden only by `RUVIEW_PUBLIC_BASE_URL`. Browser sign-in therefore works
|
||||
only on a host reached at exactly that origin: an operator browsing
|
||||
`http://localhost:8080` or `http://192.168.1.50:8080` cannot complete the flow
|
||||
(PKCE keeps the code unexchangeable, so this is a broken flow, not a token
|
||||
leak). Deriving it from the request is the fix; deferred deliberately, since
|
||||
deriving a redirect URI from attacker-controllable headers is its own class of
|
||||
bug and deserves its own decision.
|
||||
|
||||
The credential `wifi-densepose login` stores is also **not yet consumed by any
|
||||
shipped client** — no CLI subcommand, MCP server or Python client reads
|
||||
`~/.ruview/credentials.json`. The token is obtainable and verifiable; wiring the
|
||||
clients to send it is separate work.
|
||||
|
||||
## Open problems — RESOLVED 2026-07-23
|
||||
|
||||
Three findings from the 2026-07-23 adversarial review. All three are now
|
||||
**fixed**; the analysis is retained because it explains why each fix has the
|
||||
shape it does, and each is guarded by a test that was confirmed to fail against
|
||||
the old behaviour.
|
||||
|
||||
### P1 — the JWKS fetch blocks a tokio worker, and the stale path is unbounded — **FIXED**
|
||||
|
||||
`verify.rs:182` calls `JwksCache::decoding_key_for`, which performs a blocking
|
||||
`ureq` request (`jwks.rs:181`, 3s connect + 3s read) directly on the async
|
||||
worker running `require_bearer`. The same codebase already knows this is wrong:
|
||||
`main.rs:9265` wraps the token exchange in `spawn_blocking`, commenting "the
|
||||
same mistake this codebase had to fix in `jwks.rs`". The hot verification path
|
||||
did not get the same treatment.
|
||||
|
||||
Worse, the rate limiter does not cover the case that matters.
|
||||
`state.fetched_at` is updated **only on success** (`jwks.rs:188`); the error arm
|
||||
leaves it untouched. So once the TTL elapses after the last *successful* fetch,
|
||||
`fresh` is permanently `false`, the `may_force` guard at `:170` is never
|
||||
consulted, and **every** request performs its own blocking fetch attempt.
|
||||
|
||||
This fires with no attacker present. On a Pi that loses WAN — the documented
|
||||
deployment reality — 300 seconds later every API call and every UI poll starts a
|
||||
blocking outbound attempt, and with few tokio workers the whole server stalls,
|
||||
including `/health`. An attacker can reach the same state deliberately by
|
||||
flooding tokens carrying an unknown `kid`.
|
||||
|
||||
**Proposed fix, in dependency order:**
|
||||
|
||||
1. **Rate-limit attempts, not successes.** Add `last_attempt_at`, recorded
|
||||
before the fetch regardless of outcome, and consult it on the stale path too.
|
||||
This alone converts "every request fetches" into "one request per interval".
|
||||
2. **Get the blocking call off the runtime.** Either wrap the call in
|
||||
`spawn_blocking` at the `verify` boundary, or give `JwksCache` an async
|
||||
transport behind the existing transport seam. The seam already exists —
|
||||
`JwksCache::new` takes a boxed transport — so this is an added
|
||||
implementation, not a redesign.
|
||||
3. **Single-flight the refresh.** Concurrent misses for the same `kid` should
|
||||
await one shared fetch rather than each issuing their own.
|
||||
4. **Refresh ahead of expiry** from a background task, so the request path
|
||||
normally never fetches at all.
|
||||
|
||||
Steps 1 and 2 are the ones that remove the stall; 3 and 4 are optimisations.
|
||||
The test that must accompany this: a transport whose fetch blocks on a barrier,
|
||||
asserting that a second concurrent verification is not serialised behind it —
|
||||
the current suite is entirely single-threaded and could not observe a
|
||||
reintroduction (`jwks::tests` contains no concurrency primitive at all).
|
||||
|
||||
### P2 — a 15-minute access token becomes a 12-hour session — **FIXED**
|
||||
|
||||
`issue()` sets `exp: now() + SESSION_TTL_SECS` with `SESSION_TTL_SECS = 12 *
|
||||
3600`, deliberately not inheriting the access token's ~15-minute lifetime. The
|
||||
session cookie is an assertion that this server verified a token, so it is not
|
||||
*wrong* for it to outlive the token — but 12 hours is a long time to hold an
|
||||
authority that cannot be revoked. Cognitum publishes no introspection endpoint
|
||||
(see "Facts about the tokens"), so RuView has no way to ask whether the grant
|
||||
behind a session still stands. A disabled account keeps sensing access, and
|
||||
`sensing:admin` if it had it, until the cookie expires on its own.
|
||||
|
||||
**Correction.** An earlier revision of this section said capping the session at
|
||||
`sensing:read` was "considered and rejected, because the dashboard genuinely
|
||||
performs admin operations". That was wrong, and a cross-vendor pre-merge sweep
|
||||
caught it: `/oauth/start` (`main.rs:9206`) already requests `SENSING_READ` and
|
||||
nothing else, deliberately — "admin work goes through the CLI, which requires an
|
||||
explicit `--admin`". So a browser session is **already** read-only, and the
|
||||
consequence I claimed capping would cause is simply the current behaviour.
|
||||
|
||||
Two things follow, and both are stated here rather than left for the next reader
|
||||
to trip over:
|
||||
|
||||
1. **The UI's admin controls do not work from a browser OAuth session.**
|
||||
`model.service.js:136` issues `DELETE /api/v1/models/{id}`; from a
|
||||
Cognitum-signed-in browser that returns 401. Admin work requires either the
|
||||
CLI (`wifi-densepose login --admin`) or a manually pasted admin bearer in the
|
||||
QuickSettings token field. This is a gap in the browser feature, not a
|
||||
regression — browser sign-in is new here, and the token-paste path still
|
||||
carries whatever authority the pasted token has.
|
||||
|
||||
2. **The step-up control below is therefore a guard ahead of need, not an active
|
||||
one.** No browser session currently holds `sensing:admin`, so
|
||||
`session.has_scope(SENSING_ADMIN)` is false and the freshness branch never
|
||||
fires in production. Its tests pass because the crate-internal test seam
|
||||
mints an admin cookie the real flow does not produce. That is worth naming
|
||||
plainly: it is correct code guarding a case that cannot yet arise, and it
|
||||
becomes load-bearing the moment anyone widens the requested scope — which is
|
||||
the right time for the guard to already exist, but it is not evidence that
|
||||
the control is exercised today.
|
||||
|
||||
### Decision, 2026-07-23: the browser is read-only, permanently
|
||||
|
||||
**Browser-side admin is not wanted.** `BROWSER_SIGNIN_SCOPE` stays
|
||||
`sensing:read`, and the escalate-on-demand design sketched while this was still
|
||||
open is **not** being built.
|
||||
|
||||
The reasoning holds up on its own terms rather than being a concession to
|
||||
scope: the destructive operations — training, model delete, recording delete —
|
||||
already have a home in the CLI, where `--admin` is explicit, typed by a person,
|
||||
and scoped to the session that needed it. Routing them through a browser would
|
||||
mean either asking every user to consent to delete capability in order to watch
|
||||
a stream, or building a second consent flow to avoid that. Neither is worth it
|
||||
for operations that are administrative by nature and rare by frequency.
|
||||
|
||||
What this settles:
|
||||
|
||||
- **The UI's admin controls are unreachable from a Cognitum browser session**
|
||||
and that is now intended, not a gap. `model.service.js` issuing
|
||||
`DELETE /api/v1/models/{id}` returns 401. The manual token-paste field still
|
||||
works and carries whatever authority the pasted token has, so nothing that
|
||||
worked before this change stops working.
|
||||
- **The client-side step-up redirect has been removed** from
|
||||
`ui/services/api.service.js`. It caught a challenge that can never be issued,
|
||||
and it ended in a promise that never settles — so had any other 401 ever grown
|
||||
that header, every caller would have hung forever. Dead code with a trap in it
|
||||
is worse than no code.
|
||||
- **`ADMIN_REVERIFY_SECS` stays as a server-side backstop.** It is fail-closed
|
||||
and costs nothing, so if the requested scope is ever widened the freshness
|
||||
requirement is already there rather than something to remember. It is
|
||||
documented at its definition as a backstop, so nobody mistakes its passing
|
||||
tests for evidence that it is exercised.
|
||||
|
||||
**Three options, with the tradeoff each carries:**
|
||||
|
||||
| Option | Effect | Cost |
|
||||
|---|---|---|
|
||||
| **A. Shorten the TTL** (e.g. 12h → 4h) | Bounds exposure by a factor of 3, one constant | Re-auth is a full-page navigation, which interrupts a live streaming dashboard. Mostly silent while the Cognitum session is alive, but not free. |
|
||||
| **B. Server-side session store** with the refresh token, revalidated periodically | Real revocation: a disabled grant fails at the next refresh | The server now stores refresh tokens — a new and higher-value secret at rest — and refresh rotates with reuse detection, so a bug logs users out. |
|
||||
| **C. Re-verify on privileged operations only** | `sensing:admin` requires a fresh token; reads keep the long session | Best blast-radius-per-unit-cost, but needs a UI affordance for step-up auth that does not exist. |
|
||||
|
||||
**Chosen: A, at one hour** — `SESSION_TTL_SECS` is 3600, down from 12 hours.
|
||||
|
||||
C was implemented too, and then the browser-read-only decision above made it a
|
||||
backstop rather than an active control: with no browser session holding
|
||||
`sensing:admin`, there is no privileged operation to re-verify. It is kept
|
||||
because it is fail-closed and free, not because it is doing work today.
|
||||
|
||||
B is not built. It is only worth its cost — storing refresh tokens at rest,
|
||||
against an authorization server that rotates them with reuse detection — if
|
||||
RuView later needs true cross-device sign-out. Shortening the window addresses
|
||||
the same risk for a fraction of the exposure.
|
||||
|
||||
That leaves a residual this ADR should not pretend away: **within one hour, a
|
||||
revoked Cognitum grant still reads sensing data through an existing browser
|
||||
session.** Cognitum publishes no introspection endpoint, so nothing short of B
|
||||
closes that, and one hour is the size of the hole we accepted.
|
||||
|
||||
### P3 — dropping `__Host-` costs cookie origin-integrity, not just `Secure` — **FIXED**
|
||||
|
||||
The decision above frames omitting `__Host-` as trading away a `Secure`
|
||||
requirement that RuView cannot meet on a plain-HTTP LAN. That framing is
|
||||
incomplete: `__Host-` also guarantees the cookie was set by *this* origin with
|
||||
`Path=/` and no `Domain`. Without it, cookies are not port-scoped and are not
|
||||
integrity-protected against a same-host writer.
|
||||
|
||||
`read_cookie` returns the **first** match in the header, and RFC 6265 §5.4 sends
|
||||
longer-`Path` cookies first. So an attacker who can set a cookie on the same
|
||||
host — any other service on any port on that appliance, or a plain-HTTP MITM
|
||||
injecting `Set-Cookie` — can plant `ruview_session=<their own validly signed
|
||||
session>; Path=/ui`. The victim's browser then sends both, the attacker's first,
|
||||
and it verifies correctly because it *is* genuinely signed. The victim ends up
|
||||
operating inside the attacker's session; `/oauth/status` reports the attacker's
|
||||
account, and anything the victim records is attributed to them.
|
||||
|
||||
Note the shape: the signature is doing its job. Forgery was never the threat
|
||||
`__Host-` addresses, so "the signature is what protects the value" does not
|
||||
answer this.
|
||||
|
||||
**Proposed fix (cheap, no prefix needed):** have `read_cookie` collect *all*
|
||||
values for the name and accept only if exactly one verifies — or, more strictly,
|
||||
reject outright when more than one `ruview_session` is present, since a browser
|
||||
should never legitimately send two. Add `Secure` and the `__Host-` prefix
|
||||
conditionally when the server knows it is behind TLS, keeping the plain-HTTP LAN
|
||||
case working.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `RUVIEW_API_TOKEN` only.** Zero work, and adequate for a single-user
|
||||
localhost install. Rejected because it cannot express who did what, cannot be
|
||||
revoked without a restart, and cannot separate "watch the stream" from "delete
|
||||
the model" — all of which matter the moment the server is on a LAN.
|
||||
|
||||
**Exchange the OAuth token for a `cog_` key.** The pattern ADR-316 (meta-proxy)
|
||||
and ADR-119 (metaharness) originally described. Rejected: it cannot work.
|
||||
`/v1/me/keys` requires a *Firebase* ID token, not an OAuth token — meta-proxy
|
||||
hit the resulting 401 in production, replaced the approach with Bearer-direct
|
||||
under ADR-045, and deleted `mint.rs` as dead code.
|
||||
|
||||
**Call identity to introspect each token.** Rejected: no introspection endpoint
|
||||
exists, and a network round-trip per request would be wrong for an edge sensing
|
||||
server regardless.
|
||||
|
||||
**Wait for an `aud` claim before shipping.** Rejected as sequencing. `aud` would
|
||||
touch every issued token and every verifier in the org; scope is additive and
|
||||
independently correct. Tracked separately; adding `aud` later strengthens this
|
||||
design rather than invalidating it.
|
||||
|
||||
**Use OAuth for the ESP32 device plane too.** Rejected as a category error.
|
||||
Devices have no browser, no user and no human present; they already pair with a
|
||||
`seed_token` bearer (ADR-066) plus a device-bound PSK. Cognitum OAuth is for the
|
||||
API plane only.
|
||||
|
||||
## Implementation
|
||||
|
||||
`v2/crates/ruview-auth` — `jwks` (fetch, TTL cache, `kid` index, one
|
||||
rate-limited forced refetch on an unknown `kid` so rotation is picked up without
|
||||
waiting out the TTL), `verify` (the §2 accept-rule), `principal` (the verified
|
||||
caller and its scopes).
|
||||
|
||||
41 tests pass under both `cargo test --no-default-features` (the repo's
|
||||
canonical gate) and default features. The matrix signs real ES256 tokens with a
|
||||
runtime-generated key — no key material is committed — and covers `alg:none`,
|
||||
forged signatures, spliced payloads, unknown `kid`, expiry on both sides of the
|
||||
leeway, `typ` confusion, `setup`/`workload` smuggled onto a `typ=access` token, missing and
|
||||
empty `account_id`, and scope escalation.
|
||||
|
||||
The load-bearing case is
|
||||
`g2_a_genuinely_valid_token_from_another_cognitum_product_cannot_reach_the_sensing_surface`:
|
||||
a correctly signed, unexpired, right-issuer, right-`typ` token bearing
|
||||
`client_id=meta-proxy` and `scope=inference` is rejected. Nothing about its
|
||||
signature or identity claims distinguishes it — only scope does. A naive
|
||||
verifier accepts it, and an `inference` token becomes a key to someone's home
|
||||
sensor.
|
||||
|
||||
**Not in this crate**: WebSocket authentication (ADR-272) and any outbound
|
||||
Cognitum call.
|
||||
|
||||
### Amendment, 2026-07-22 — the login flow lives here after all, behind a feature
|
||||
|
||||
The paragraph above originally also excluded the login flow. That was written
|
||||
to keep the sensing server lean, which is the right goal but not a reason to put
|
||||
the code somewhere else: the Tauri desktop app needs the same flow, and a second
|
||||
copy of a PKCE + rotating-refresh implementation is exactly the kind of
|
||||
duplication that drifts apart and then disagrees about something subtle.
|
||||
|
||||
So `login` is a **non-default feature** of this crate. A server built with
|
||||
default features gets the verifier and nothing more — no `reqwest`, no tokio
|
||||
networking, no browser launcher. The CLI opts in with
|
||||
`features = ["login"]`, and the desktop app can do the same.
|
||||
|
||||
Shipped as `wifi-densepose login` / `logout` / `whoami`. Two properties worth
|
||||
restating because they are easy to get wrong:
|
||||
|
||||
* **Refresh is serialised and never retried.** Identity rotates refresh tokens
|
||||
with reuse detection, so a concurrent refresh looks like replay and a retry
|
||||
*is* replay — either revokes the session family. `Session::ensure_fresh`
|
||||
holds an async mutex across the network call, re-checks expiry after
|
||||
acquiring it, and persists the rotated token before returning it.
|
||||
* **Least scope by default.** `login` requests `sensing:read`; `--admin` is an
|
||||
explicit escalation and requests both scopes, since there is no hierarchy
|
||||
server-side.
|
||||
@@ -0,0 +1,185 @@
|
||||
# ADR-272: WebSocket authentication tickets
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-07-22
|
||||
- **Deciders**: RuView maintainers
|
||||
- **Tags**: auth, websocket, security, sensing-server
|
||||
- **Related**: ADR-271 (Cognitum OAuth resource server), ADR-055 (integrated sensing server), PR #1313 (the exemption this supersedes), cognitum-one/dashboard ADR-060
|
||||
|
||||
## Context
|
||||
|
||||
`bearer_auth` gates `/api/v1/*`. WebSocket upgrade endpoints were exempt, for a
|
||||
real reason: a browser's `WebSocket` constructor cannot attach an
|
||||
`Authorization` header to the handshake, so a gated socket is simply
|
||||
unreachable from page JavaScript. `/ws/sensing` and `/ws/introspection` sat
|
||||
outside `PROTECTED_PREFIX` entirely; `/api/v1/stream/pose` was added to an
|
||||
explicit `EXEMPT_PATHS` list by PR #1313.
|
||||
|
||||
The reasoning was sound. The consequence was not, and it was measured rather
|
||||
than argued. On a server with `RUVIEW_API_TOKEN` set — an operator who believes
|
||||
authentication is ON — a real WebSocket handshake carrying **no credential at
|
||||
all**:
|
||||
|
||||
```
|
||||
/ws/sensing -> 101 Switching Protocols
|
||||
/ws/introspection -> 101 Switching Protocols
|
||||
/api/v1/stream/pose -> 101 Switching Protocols
|
||||
/api/v1/models -> 401 Unauthorized (control)
|
||||
```
|
||||
|
||||
**The control plane was locked and the data plane was open.** `/ws/sensing`
|
||||
carries the live sensing output — presence, pose, breathing and heart rate.
|
||||
`/ws/introspection` exposes internal pipeline state. For the ADR-055 desktop
|
||||
topology (server bundled in the app, loopback only) that is bounded. For the
|
||||
LAN/hub deployment RuView also supports, anyone who can reach the port can
|
||||
watch the sensor.
|
||||
|
||||
ADR-271 sharpened the contrast rather than causing it: the REST surface is now
|
||||
genuinely strong — offline-verified Cognitum tokens, scope-separated
|
||||
destructive routes — which makes an ungated data plane the obvious way in.
|
||||
|
||||
*Precision about the evidence:* the handshake completing was verified. A
|
||||
payload frame was not captured in that window, so the finding is "the
|
||||
connection is established without a credential", not "data was read".
|
||||
|
||||
## Decision
|
||||
|
||||
Gate every WebSocket upgrade. Accept **either** of two credentials, chosen to
|
||||
match what each kind of client can actually do.
|
||||
|
||||
### 1. Native clients send a bearer on the upgrade
|
||||
|
||||
The Python client, the Rust CLI and the TypeScript MCP client are not browsers
|
||||
and have never been subject to the header limitation. They **can** send a normal
|
||||
`Authorization: Bearer` on the handshake, so the server accepts one there;
|
||||
routing them through a ticket would add a round-trip and a second credential
|
||||
path for no benefit.
|
||||
|
||||
> **Correction, 2026-07-23.** This section previously stated that those clients
|
||||
> **do** send a bearer. The published Python client does not:
|
||||
> `python/wifi_densepose/client/ws.py` calls `websockets.connect(url,
|
||||
> ping_interval, ping_timeout, max_size)` and passes no headers at all — the
|
||||
> file contains zero occurrences of `extra_headers` or `Authorization`. So every
|
||||
> `wifi-densepose[client]` consumer **401s the moment an operator enables
|
||||
> auth**, and this ADR told them they would be fine.
|
||||
>
|
||||
> The server side of the decision stands — a bearer on the upgrade is accepted,
|
||||
> and that is the right contract for a non-browser client. What is missing is
|
||||
> the client implementing it, tracked as ruvnet/RuView#1395. Until then the only
|
||||
> remedy available to those users is
|
||||
> `RUVIEW_WS_LEGACY_UNAUTHENTICATED=1`, which reopens the exposure this ADR
|
||||
> exists to close — so it is a migration aid with a deadline, not an answer.
|
||||
|
||||
### 2. Browsers exchange their credential for a single-use ticket
|
||||
|
||||
`POST /api/v1/ws-ticket` is an ordinary authenticated request — where headers
|
||||
*do* work — and returns an opaque ticket the page appends as
|
||||
`?ticket=<value>` on the socket URL.
|
||||
|
||||
**A credential in a URL is normally a mistake.** URLs reach access logs,
|
||||
`Referer` headers and browser history. Three properties bound this one, and all
|
||||
three are load-bearing:
|
||||
|
||||
| Property | Why it matters |
|
||||
|---|---|
|
||||
| **Single use** — consumed on the first upgrade attempt, valid or not | A ticket found in a log is already spent |
|
||||
| **~30 second TTL** | Long enough to open a socket; not long enough to harvest |
|
||||
| **Not the credential** — authorizes one WebSocket | Cannot be replayed against `/api/v1/*`, cannot be refreshed, carries no reusable identity |
|
||||
|
||||
The long-lived bearer token is still never placed in a URL.
|
||||
|
||||
A ticket **inherits the issuing principal's scopes**, so a `sensing:read`
|
||||
session cannot mint one that outranks itself, and a ticket from a token without
|
||||
`sensing:read` is refused at the upgrade.
|
||||
|
||||
### 3. WebSocket paths are matched by **prefix**, not by an allowlist
|
||||
|
||||
Anything under `/ws/` is treated as an upgrade path, plus the one endpoint that
|
||||
lives outside it (`/api/v1/stream/pose`).
|
||||
|
||||
This is the most important detail in the ADR. An allowlist means every
|
||||
WebSocket route added later is ungated until someone remembers to extend it —
|
||||
the same bug, reintroduced on a delay. It is not hypothetical:
|
||||
`/ws/train/progress` (ADR-186, arriving with PR #1387) is already referenced by
|
||||
`ui/services/training.service.js` and would have shipped unauthenticated under
|
||||
an allowlist. Prefix matching gates it on arrival.
|
||||
|
||||
New WebSocket routes should live under `/ws/` and inherit gating for free.
|
||||
|
||||
### 4. A migration escape hatch, deliberately uncomfortable
|
||||
|
||||
`RUVIEW_WS_LEGACY_UNAUTHENTICATED=1` restores the previous behaviour. Gating
|
||||
these paths **breaks a browser UI that has not yet been updated to fetch a
|
||||
ticket**, and not every deployment can update server and UI in lockstep.
|
||||
|
||||
It is a migration aid, not a supported configuration:
|
||||
|
||||
- It logs a warning on every boot naming the actual exposure — "the live
|
||||
sensing stream — presence, pose and vital signs — is readable by anyone who
|
||||
can reach this port" — rather than something an operator can skim past.
|
||||
- Its blast radius is exactly the WebSocket paths. A test pins that it does not
|
||||
weaken `/api/v1/*`.
|
||||
- It is read **once at construction**, so changing the environment cannot
|
||||
silently open the paths on a running server.
|
||||
|
||||
The alternative — a clean break with no hatch — was considered and rejected as
|
||||
sequencing, not principle: a hard break tempts an operator into turning auth off
|
||||
entirely, which is strictly worse than a narrow, loudly-announced exception.
|
||||
The hatch should be removed once the shipped UI fetches tickets.
|
||||
|
||||
### 5. Deployments with auth off are unchanged
|
||||
|
||||
No credential configured ⇒ the middleware is the same no-op it has always been.
|
||||
Pinned by a test.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The measured hole is closed: all three paths now return `401` to a
|
||||
credential-less handshake, while a bearer or a valid ticket returns `101`.
|
||||
- Browser UIs need updating. Shipped in the same change for
|
||||
`sensing.service.js`, `websocket-client.js` and `observatory/js/main.js` via
|
||||
a shared `withWsTicket()` helper; a ticket is minted per connection attempt
|
||||
and never cached, because it is single-use and short-lived.
|
||||
- A UI running against a server that predates this ADR still works: the helper
|
||||
treats `404` from `/api/v1/ws-ticket` as "no ticket needed".
|
||||
- One more round-trip before a browser opens a socket. Negligible against a
|
||||
stream that then runs for minutes.
|
||||
- Tickets live in memory, capped at 512 outstanding and self-healing as they
|
||||
expire, so an authenticated but misbehaving caller cannot grow the store
|
||||
without bound. In-memory is correct rather than convenient: a ticket
|
||||
surviving a restart would outlive the server that vouched for it.
|
||||
|
||||
## Supersedes
|
||||
|
||||
PR #1313's `enabled_exempts_pose_stream_websocket`, which asserted the
|
||||
exemption. Its premise about browsers was correct and is preserved here; its
|
||||
conclusion is replaced. The test was renamed and inverted rather than deleted,
|
||||
with the history in its doc comment, and the half that still matters — the
|
||||
WebSocket rule must not leak to other `/api/v1/*` paths — is kept.
|
||||
|
||||
## Deliberately not done
|
||||
|
||||
- **`/health*` stays ungated.** Orchestrator probes hit it anonymously, and
|
||||
that is the point of a liveness endpoint. `/health/metrics` is included in
|
||||
that exemption; if metrics ever carry occupancy-derived values this should be
|
||||
revisited, because that would make them sensing data wearing an ops label.
|
||||
- **`/ui/*` stays ungated.** It is static assets; the data behind them is
|
||||
gated.
|
||||
- **No revocation of an issued ticket.** It expires in seconds and is
|
||||
single-use; a revocation path would be more machinery than the exposure
|
||||
justifies.
|
||||
- **No ticket for native clients.** They can send a header, so they should.
|
||||
|
||||
## Implementation
|
||||
|
||||
`v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs` (store),
|
||||
`src/bearer_auth.rs` (gating), `src/main.rs` (`POST /api/v1/ws-ticket`),
|
||||
`ui/services/ws-ticket.js` plus the three call sites.
|
||||
|
||||
Tests: 12 store, 9 gating, 4 path-matching. Store coverage includes single-use
|
||||
enforcement, replay refusal, expiry refusal *and* pruning, 256-bit
|
||||
unpredictability, cap enforcement and self-healing, and `?myticket=x` not being
|
||||
read as `?ticket=x`. Gating coverage includes every known WS path refusing an
|
||||
unauthenticated upgrade, bearer acceptance, ticket single-use, a ticket being
|
||||
useless against REST, the escape hatch working *and* not weakening REST, and
|
||||
auth-off behaviour unchanged.
|
||||
+5
-1
@@ -2,10 +2,14 @@
|
||||
|
||||
Latest proposed decisions:
|
||||
|
||||
- [ADR-187: archive/v1 deprecation + model-weights honest labeling](ADR-187-archive-v1-deprecation-honest-labeling.md) (refs #509, #1125)
|
||||
- [ADR-186: Training progress API — wire the orphaned in-server trainer to /ws/train/progress](ADR-186-training-progress-api.md) (refs #1233)
|
||||
- [ADR-185: Python P6 SOTA bindings — AETHER, MERIDIAN, MAT](ADR-185-python-p6-sota-bindings.md)
|
||||
- [ADR-184: ADR-117 completion via PyPI Trusted Publishing](ADR-184-adr117-completion-pypi-trusted-publishing.md) (refs #785)
|
||||
- [ADR-264: Versioned wire protocol for RTL8720F CFR and Range-FFT reports](ADR-264-rtl8720f-radar-wire-protocol.md)
|
||||
- [ADR-263: Adopt RTL8720F 2.4 GHz FMCW radar as an optional RuView sensing platform](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md)
|
||||
|
||||
This folder contains 182 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
|
||||
This folder contains 193 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
|
||||
|
||||
## Why ADRs?
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ Operations doc for the `.github/workflows/pip-release.yml` CI workflow.
|
||||
|
||||
## Auth
|
||||
|
||||
The workflow uses one GitHub Actions secret named `PYPI_API_TOKEN`.
|
||||
It's a project-token issued by the rUv PyPI account with upload
|
||||
scope for both `wifi-densepose` and `ruview`.
|
||||
Production uses the GitHub Actions secret `PYPI_API_TOKEN`. It is a
|
||||
project token issued by the rUv PyPI account with upload scope for both
|
||||
`wifi-densepose` and `ruview`.
|
||||
|
||||
TestPyPI uses a separate `TESTPYPI_API_TOKEN` secret issued by
|
||||
test.pypi.org. PyPI and TestPyPI accounts and tokens are independent.
|
||||
|
||||
## Refreshing the token
|
||||
|
||||
@@ -47,16 +50,19 @@ Per ADR-117 §7.3, the tombstone publishes first so it claims the
|
||||
tombstone live at `https://pypi.org/project/wifi-densepose/1.99.0/`
|
||||
2. Verify: `pip install wifi-densepose==1.99.0; python -c "import
|
||||
wifi_densepose"` → ImportError with migration URL.
|
||||
3. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → v2 wheel
|
||||
matrix live at `https://pypi.org/project/wifi-densepose/2.0.0/`.
|
||||
4. (Optional, in lock-step) build + publish a matching `ruview`
|
||||
release from `python/ruview-meta/` so the meta-package version
|
||||
stays pinned to the same wifi-densepose version.
|
||||
3. Confirm `archive/v1/data/proof/expected_features_v2.sha256` is
|
||||
committed and non-empty. Production publishing fails closed without it.
|
||||
4. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → the v2
|
||||
`wifi-densepose` wheel matrix and matching `ruview` wheel/sdist are
|
||||
published together. Their versions and dependency pin are checked in CI.
|
||||
5. Verify both `https://pypi.org/project/wifi-densepose/2.0.0/` and
|
||||
`https://pypi.org/project/ruview/2.0.0/`.
|
||||
|
||||
## Off-loop manual gates
|
||||
|
||||
- **Q3** (ADR-117 §11.3) — generate `expected_features_v2.sha256`
|
||||
from the v2 Rust pipeline before any v2 publish.
|
||||
- **Q3** (ADR-117 §11.3) — generate
|
||||
`archive/v1/data/proof/expected_features_v2.sha256` from the v2 Rust
|
||||
pipeline before a production v2 publish. The workflow enforces this gate.
|
||||
- **OIDC Trusted Publisher** — not used. The workflow is token-based;
|
||||
this is a deliberate choice to keep the secret refresh entirely in
|
||||
GCP. If the project migrates to OIDC later, remove `password:`
|
||||
|
||||
+14
-1
@@ -1141,7 +1141,20 @@ What it ships (and what it does not):
|
||||
| Presence detection (occupied / empty) | ✅ Trained head — v2 encoder reports 82.3% held-out temporal-triplet acc (v1's "100% on validation" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
|
||||
| 128-dim CSI embeddings (re-ID, similarity, downstream training) | ✅ Trained encoder |
|
||||
| Single-person breathing / heart-rate | ⚠️ Server still uses heuristic DSP — model does not replace this yet |
|
||||
| 17-keypoint full-body pose | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
|
||||
| 17-keypoint full-body pose | 🔬 This HF bundle ships no keypoint head — but real pose weights exist elsewhere; see the tier table below |
|
||||
|
||||
### Model weights: what's real, what's not
|
||||
|
||||
"WiFi → pose" means three different things in this repo, at three different maturity
|
||||
levels. Read the label, not the headline ([ADR-187](adr/ADR-187-archive-v1-deprecation-honest-labeling.md)):
|
||||
|
||||
| Tier | Checkpoint(s) | Honest status |
|
||||
|------|---------------|---------------|
|
||||
| **Real & validated** | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) (encoder + presence head) · [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) (17-keypoint pose) · `cog-person-count/count_v1` | **MEASURED / published.** Presence = 82.3% held-out temporal-triplet accuracy (the old "100% presence" figure was retracted); MM-Fi pose = 82.69% torso-PCK@20 on the `random_split` protocol. |
|
||||
| **Real but weak (honestly labeled)** | committed `v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors` | First-cut on-device model. **PCK@20 = 3.0% / PCK@50 = 18.5%** on a 217-sample holdout — **below the ADR-079 target of ≥ 35%.** Learns coarse structure (`r_hip` 77% PCK@50); distal/face joints near-random. Its runtime path in `cog-pose-estimation/src/inference.rs` is still a centred-skeleton **stub returning `confidence=0`**. Full disclosure in the [cog README](../v2/crates/cog-pose-estimation/cog/README.md). Do not advertise the live single-ESP32 17-keypoint feature without this caveat. |
|
||||
| **Architecture only, no weights** | `archive/v1` `DensePoseHead` | Random `kaiming_normal_` init, **no checkpoint of any kind** (zero `.pth`/`.onnx`/`.safetensors` files under `archive/v1/`). Deprecated and superseded — see [`archive/v1/DEPRECATED.md`](../archive/v1/DEPRECATED.md). Do not expect real pose output from it. |
|
||||
|
||||
**Does it actually run, and can a single ESP32 do pose? ([#509](https://github.com/ruvnet/RuView/issues/509), [#1125](https://github.com/ruvnet/RuView/issues/1125))** Yes, it runs, and the results are reproducible: the deterministic signal-pipeline proof (`python archive/v1/data/proof/verify.py`, must print `VERDICT: PASS`), the committed pose training dump (`v2/crates/cog-pose-estimation/cog/artifacts/train_results.json`), and the auditable MM-Fi arena all back specific numbers. But a single-antenna, 56-subcarrier CSI stream at a 20-frame window does *not* carry the fine-grained spatial information the multi-antenna NIC research relies on — so the shippable pose accuracy the project stands behind today is the **MM-Fi benchmark number**, not a live single-ESP32 number. The path to a first reproducible on-device baseline (PCK@20 ≥ 35%) is tracked in [ADR-079](adr/ADR-079-camera-ground-truth-training.md) / [#645](https://github.com/ruvnet/RuView/issues/645).
|
||||
|
||||
### Download
|
||||
|
||||
|
||||
Generated
+3072
-24
File diff suppressed because it is too large
Load Diff
+69
-2
@@ -23,6 +23,27 @@ name = "wifi_densepose_native"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
path = "src/lib.rs"
|
||||
|
||||
# ADR-185 §3.1 — optional pip extras map to Cargo features so the
|
||||
# default wheel links none of the SOTA subsystems. P1 wires `aether`.
|
||||
[features]
|
||||
default = []
|
||||
# ADR-185 P1 — AETHER contrastive CSI embeddings. Binds the std-only
|
||||
# `wifi-densepose-aether` leaf crate (the pure-compute stack hoisted out of
|
||||
# `wifi-densepose-sensing-server` per §13), so this extra links no server tree.
|
||||
aether = ["dep:wifi-densepose-aether"]
|
||||
# ADR-185 P2 — MERIDIAN domain generalization. Binds the tch-free
|
||||
# inference/adaptation path only (see the wheel-size note on the deps
|
||||
# below). `wifi-densepose-train` is depended on WITHOUT `tch-backend`,
|
||||
# so no libtorch is linked.
|
||||
meridian = ["dep:wifi-densepose-train", "dep:wifi-densepose-signal"]
|
||||
# ADR-185 P3 — MAT disaster-survivor detection. Mirrors the upstream
|
||||
# disaster/ML gating: bound only under this extra so the default wheel
|
||||
# never carries the detection stack. `tokio`/`geo` are pulled to drive a
|
||||
# single-shot scan (see the wheel-size note on the deps below).
|
||||
mat = ["dep:wifi-densepose-mat", "dep:tokio", "dep:geo"]
|
||||
# ADR-185 §3 — convenience superset: all three SOTA subsystems.
|
||||
sota = ["aether", "meridian", "mat"]
|
||||
|
||||
[dependencies]
|
||||
# PyO3 with abi3-py310 — one compiled binary covers Python 3.10, 3.11,
|
||||
# 3.12, 3.13, and any future 3.x that keeps the stable ABI (ADR-117 §5.4).
|
||||
@@ -50,6 +71,52 @@ wifi-densepose-bfld = { version = "0.3.0", path = "../v2/crates/wifi-densepose-b
|
||||
# the future P3 CsiFrame numpy round-trip.
|
||||
numpy = "0.22"
|
||||
|
||||
# ADR-185 P1 — AETHER backing crate (contrastive `embedding` +
|
||||
# `graph_transformer`/`sona`/`sparse_inference`, ADR-024). Optional +
|
||||
# gated behind the `aether` feature.
|
||||
#
|
||||
# WHEEL-SIZE FIX LANDED (ADR-185 §13): this is now the std-only
|
||||
# `wifi-densepose-aether` leaf crate — zero external deps, no tokio/axum/
|
||||
# worldgraph/ruvector — hoisted out of `wifi-densepose-sensing-server`
|
||||
# (which re-exports it, so the server is unchanged). The `[aether]` wheel
|
||||
# therefore links only pure compute and stays within the ADR-117 §5.4
|
||||
# ≤5 MB budget.
|
||||
wifi-densepose-aether = { version = "0.3.0", path = "../v2/crates/wifi-densepose-aether", optional = true }
|
||||
|
||||
# ADR-185 P2 — MERIDIAN backing crates (optional, `meridian`-gated).
|
||||
#
|
||||
# HONEST WHEEL-SIZE NOTE (ADR-185 §9 / §1.2): unlike AETHER, the libtorch
|
||||
# risk is AVOIDED here — `wifi-densepose-train`'s `tch` dep is properly
|
||||
# optional (feature `tch-backend`, OFF by default), so no libtorch links.
|
||||
# BUT `wifi-densepose-train` still carries NON-optional deps: `tokio` (rt
|
||||
# subset), the five `ruvector-*` crates, `wifi-densepose-nn`, petgraph,
|
||||
# memmap2, indicatif, ndarray-npy, csv, toml, clap. So a `[meridian]`
|
||||
# wheel is heavier than the ≤5 MB ADR-117 §5.4 budget (though far lighter
|
||||
# than AETHER's axum/tokio server tree). The clean fix is the same
|
||||
# leaf-crate hoist: move the pure inference modules (geometry,
|
||||
# rapid_adapt, eval, hardware_norm) into a tch/tokio-free leaf crate.
|
||||
# `wifi-densepose-signal` is depended on `default-features = false` to
|
||||
# drop the optional ndarray-linalg/BLAS chain (Windows-friendly).
|
||||
wifi-densepose-train = { version = "0.3.0", path = "../v2/crates/wifi-densepose-train", optional = true, default-features = false }
|
||||
wifi-densepose-signal = { version = "0.3.0", path = "../v2/crates/wifi-densepose-signal", optional = true, default-features = false }
|
||||
|
||||
# ADR-185 P3 — MAT backing crate + the tokio/geo needed to drive one scan.
|
||||
#
|
||||
# HONEST WHEEL-SIZE NOTE (ADR-185 §9 / §1.3): `default-features = false`
|
||||
# drops MAT's `api` (axum) and `ruvector` features from the wheel, but MAT
|
||||
# still carries NON-optional `tokio` (rt/sync/time), `wifi-densepose-nn`
|
||||
# (which pulls `ort` / ONNX Runtime + reqwest/hyper), `rustfft`, `geo`,
|
||||
# and `ndarray`. So a `[mat]` wheel exceeds the ADR-117 §5.4 ≤5 MB budget
|
||||
# — same leaf-crate-hoist story as AETHER/MERIDIAN, gated the same way so
|
||||
# the DEFAULT wheel is untouched. `tokio` (rt+time) and `geo` are depended
|
||||
# on directly (version-matched to MAT) to build the single-shot scan
|
||||
# runtime and construct the event `geo::Point` in the binding.
|
||||
wifi-densepose-mat = { version = "0.3.0", path = "../v2/crates/wifi-densepose-mat", optional = true, default-features = false, features = ["std"] }
|
||||
tokio = { version = "1.35", features = ["rt", "time"], optional = true }
|
||||
geo = { version = "0.27", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Doc-test infrastructure for the Python-facing examples in the bound
|
||||
# Rust functions. Lands properly in P2 once #[pyfunction]s exist to test.
|
||||
# ADR-185 §4.1 parity harness — SHA-256 the native-Rust reference
|
||||
# embedding and read the committed golden fixture.
|
||||
sha2 = "0.10"
|
||||
serde_json = "1"
|
||||
|
||||
@@ -43,6 +43,29 @@ pip install "wifi-densepose[client]" # + WebSocket/MQTT clients
|
||||
Wheels are published for Linux (x86_64, aarch64), macOS (x86_64, arm64), and
|
||||
Windows (amd64).
|
||||
|
||||
### SOTA extras (ADR-185)
|
||||
|
||||
Three optional subsystems bind the Rust SOTA modules as compiled-feature
|
||||
wheels. Each raises a clear `ImportError` if you import it without the extra:
|
||||
|
||||
| Extra | Module | What it adds |
|
||||
|-------|--------|--------------|
|
||||
| `[aether]` | `wifi_densepose.aether` | Contrastive CSI embeddings / re-identification (ADR-024) — `EmbeddingExtractor`, `cosine_similarity`, `info_nce_loss` |
|
||||
| `[meridian]` | `wifi_densepose.meridian` | Cross-environment domain generalization (ADR-027) — `HardwareNormalizer`, `GeometryEncoder`, `RapidAdaptation`, `CrossDomainEvaluator` |
|
||||
| `[mat]` | `wifi_densepose.mat` | Mass-Casualty Assessment disaster-survivor detection + START triage — `DisasterResponse`, `Survivor`, `TriageStatus` |
|
||||
| `[sota]` | all three | Convenience superset |
|
||||
|
||||
```bash
|
||||
pip install "wifi-densepose[aether]" # re-identification embeddings
|
||||
pip install "wifi-densepose[meridian]" # cross-room calibration
|
||||
pip install "wifi-densepose[mat]" # disaster triage
|
||||
pip install "wifi-densepose[sota]" # all three
|
||||
```
|
||||
|
||||
Runnable examples: [`examples/reid_from_csi.py`](examples/reid_from_csi.py),
|
||||
[`examples/cross_room_calibrate.py`](examples/cross_room_calibrate.py),
|
||||
[`examples/mat_triage.py`](examples/mat_triage.py).
|
||||
|
||||
## Usage
|
||||
|
||||
### Extract breathing rate from a CSI stream
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""ADR-185 §4.2 — AETHER embed() micro-benchmarks.
|
||||
|
||||
Target (release build, ADR-024 §2.8 FP32 <1 ms with headroom): steady-state
|
||||
`embed()` < 2 ms/window, and batched `embed()` scales roughly linearly (no
|
||||
accidental O(n²)).
|
||||
|
||||
Run with:
|
||||
pytest python/bench/test_bench_aether.py --benchmark-only
|
||||
|
||||
Skipped by default (they live in `bench/`, outside `testpaths`). Timing
|
||||
targets are validated on a RELEASE wheel (`maturin develop --release
|
||||
--features sota`); a debug wheel will be several× slower.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose import aether
|
||||
|
||||
|
||||
def _window(frames: int = 8, subc: int = 56) -> list[list[float]]:
|
||||
return [[math.sin(0.1 * t + 0.03 * k) for k in range(subc)] for t in range(frames)]
|
||||
|
||||
|
||||
def _extractor() -> aether.EmbeddingExtractor:
|
||||
return aether.EmbeddingExtractor(n_subcarriers=56, config=aether.AetherConfig())
|
||||
|
||||
|
||||
def test_embed_per_window(benchmark) -> None:
|
||||
ext = _extractor()
|
||||
window = _window()
|
||||
out = benchmark(lambda: ext.embed(window))
|
||||
assert len(out) == 128
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch", [1, 8, 64])
|
||||
def test_embed_batch_scaling(benchmark, batch: int) -> None:
|
||||
ext = _extractor()
|
||||
windows = [_window() for _ in range(batch)]
|
||||
out = benchmark(lambda: [ext.embed(w) for w in windows])
|
||||
assert len(out) == batch
|
||||
@@ -0,0 +1,44 @@
|
||||
"""ADR-185 §4.2 — MAT scan micro-benchmark.
|
||||
|
||||
Measures the cost of one full ingest + `scan_once()` cycle over the
|
||||
committed 256-frame CSI stream. The per-cycle cost should stay comfortably
|
||||
below the configured scan interval (default 500 ms) so the binding is not
|
||||
the bottleneck.
|
||||
|
||||
Run with:
|
||||
pytest python/bench/test_bench_mat.py --benchmark-only
|
||||
|
||||
Validated on a RELEASE wheel; a debug wheel will be several× slower.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from wifi_densepose import mat
|
||||
|
||||
_FIXTURE = Path(__file__).resolve().parents[1] / "tests" / "golden" / "mat_input.json"
|
||||
|
||||
|
||||
def _stream() -> list[dict]:
|
||||
return json.loads(_FIXTURE.read_text())["stream"]
|
||||
|
||||
|
||||
def test_scan_cycle_cost(benchmark) -> None:
|
||||
stream = _stream()
|
||||
|
||||
def _run() -> int:
|
||||
cfg = mat.DisasterConfig(
|
||||
mat.DisasterType.Earthquake, sensitivity=0.9, confidence_threshold=0.1
|
||||
)
|
||||
resp = mat.DisasterResponse(cfg)
|
||||
resp.initialize_event(0.0, 0.0, "bench")
|
||||
resp.add_zone(mat.ScanZone.rectangle("Zone A", 0.0, 0.0, 50.0, 30.0))
|
||||
for frame in stream:
|
||||
resp.push_csi_data(frame["amplitude"], frame["phase"])
|
||||
resp.scan_once()
|
||||
return len(resp.survivors())
|
||||
|
||||
survivors = benchmark(_run)
|
||||
assert survivors == 1
|
||||
@@ -0,0 +1,29 @@
|
||||
"""ADR-185 §4.2 — MERIDIAN micro-benchmarks.
|
||||
|
||||
Targets (release build, ADR-027 §4.1/§4.3 ×2 headroom): `normalize()`
|
||||
< 200 µs/frame, `encode()` < 200 µs.
|
||||
|
||||
Run with:
|
||||
pytest python/bench/test_bench_meridian.py --benchmark-only
|
||||
|
||||
Validated on a RELEASE wheel; a debug wheel will be several× slower.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wifi_densepose import meridian as mer
|
||||
|
||||
|
||||
def test_normalize_per_frame(benchmark) -> None:
|
||||
norm = mer.HardwareNormalizer()
|
||||
amp = [10.0 + 0.05 * k for k in range(64)]
|
||||
phase = [0.01 * k for k in range(64)]
|
||||
out = benchmark(lambda: norm.normalize(amp, phase, mer.HardwareType.Esp32S3))
|
||||
assert len(out.amplitude) == 56
|
||||
|
||||
|
||||
def test_geometry_encode(benchmark) -> None:
|
||||
enc = mer.GeometryEncoder(mer.MeridianGeometryConfig())
|
||||
aps = [[0.0, 0.0, 2.5], [5.0, 0.0, 2.5], [0.0, 4.0, 2.5]]
|
||||
out = benchmark(lambda: enc.encode(aps))
|
||||
assert len(out) == 64
|
||||
@@ -0,0 +1,45 @@
|
||||
"""MERIDIAN cross-room calibration (ADR-185 P2, `[meridian]` extra).
|
||||
|
||||
Hardware-invariant CSI normalization, AP-geometry encoding, and few-shot
|
||||
rapid adaptation — the tch-free domain-generalization path.
|
||||
|
||||
pip install wifi-densepose[meridian]
|
||||
python examples/cross_room_calibrate.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from wifi_densepose.meridian import (
|
||||
GeometryEncoder,
|
||||
HardwareNormalizer,
|
||||
HardwareType,
|
||||
MeridianGeometryConfig,
|
||||
RapidAdaptation,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# 1. Normalize a 64-subcarrier ESP32 frame to the canonical 56-tone grid.
|
||||
norm = HardwareNormalizer()
|
||||
amp = [10.0 + 0.05 * k for k in range(64)]
|
||||
phase = [0.01 * k for k in range(64)]
|
||||
frame = norm.normalize(amp, phase, HardwareType.detect(64))
|
||||
print(f"canonical subcarriers: {len(frame.amplitude)} (hw={frame.hardware_type})")
|
||||
|
||||
# 2. Encode AP positions into a permutation-invariant geometry embedding.
|
||||
enc = GeometryEncoder(MeridianGeometryConfig())
|
||||
geometry = enc.encode([[0.0, 0.0, 2.5], [5.0, 0.0, 2.5], [0.0, 4.0, 2.5]])
|
||||
print(f"geometry embedding dim: {len(geometry)}")
|
||||
|
||||
# 3. Few-shot rapid adaptation over a handful of unlabeled frames.
|
||||
ra = RapidAdaptation(min_calibration_frames=10, lora_rank=4)
|
||||
for i in range(12):
|
||||
ra.push_frame([math.sin(0.1 * i + 0.05 * d) for d in range(16)])
|
||||
result = ra.adapt()
|
||||
print(f"adapted over {result.frames_used} frames, final_loss={result.final_loss:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""MAT disaster-survivor triage from CSI (ADR-185 P3, `[mat]` extra).
|
||||
|
||||
Ingest a CSI stream, run one detection cycle, and list detected survivors
|
||||
by START triage class.
|
||||
|
||||
pip install wifi-densepose[mat]
|
||||
python examples/mat_triage.py
|
||||
|
||||
Note: the stream here is synthetic (breathing-modulated) — it demonstrates
|
||||
the API and pipeline, not validated detection accuracy on real rubble.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Iterator
|
||||
|
||||
from wifi_densepose.mat import DisasterConfig, DisasterResponse, DisasterType, ScanZone
|
||||
|
||||
|
||||
def breathing_stream(
|
||||
frames: int = 256, subc: int = 56, fs: float = 20.0
|
||||
) -> Iterator[tuple[list[float], list[float]]]:
|
||||
for t in range(frames):
|
||||
tt = t / fs
|
||||
breath = 2.0 * math.sin(2 * math.pi * 0.3 * tt)
|
||||
amp = [10.0 + 0.05 * k + breath for k in range(subc)]
|
||||
phase = [0.01 * k + 0.1 * math.sin(2 * math.pi * 0.3 * tt) for k in range(subc)]
|
||||
yield amp, phase
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cfg = DisasterConfig(DisasterType.Earthquake, sensitivity=0.9, confidence_threshold=0.1)
|
||||
resp = DisasterResponse(cfg)
|
||||
resp.initialize_event(0.0, 0.0, "Collapsed Building A")
|
||||
resp.add_zone(ScanZone.rectangle("North Wing", 0.0, 0.0, 50.0, 30.0))
|
||||
|
||||
for amp, phase in breathing_stream():
|
||||
resp.push_csi_data(amp, phase)
|
||||
resp.scan_once()
|
||||
|
||||
survivors = resp.survivors()
|
||||
print(f"detected {len(survivors)} survivor(s)")
|
||||
for s in survivors:
|
||||
print(f" {s.id[:8]} triage={s.triage_status} confidence={s.confidence:.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
"""AETHER re-identification from CSI (ADR-185 P1, `[aether]` extra).
|
||||
|
||||
Compute 128-dim contrastive embeddings for CSI windows and score them by
|
||||
cosine similarity — the primitive behind room fingerprinting and person
|
||||
re-identification.
|
||||
|
||||
pip install wifi-densepose[aether]
|
||||
python examples/reid_from_csi.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from wifi_densepose.aether import AetherConfig, EmbeddingExtractor, cosine_similarity
|
||||
|
||||
|
||||
def make_window(phase_shift: float, frames: int = 8, subc: int = 56) -> list[list[float]]:
|
||||
"""A synthetic CSI window; `phase_shift` stands in for a different scene."""
|
||||
return [
|
||||
[math.sin(0.1 * t + 0.03 * k + phase_shift) for k in range(subc)]
|
||||
for t in range(frames)
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ext = EmbeddingExtractor(n_subcarriers=56, config=AetherConfig())
|
||||
|
||||
same_a = ext.embed(make_window(0.0))
|
||||
same_b = ext.embed(make_window(0.0)) # same scene
|
||||
other = ext.embed(make_window(1.5)) # different scene
|
||||
|
||||
print(f"embedding dim: {len(same_a)}")
|
||||
print(f"same-scene similarity: {cosine_similarity(same_a, same_b):.4f}")
|
||||
print(f"cross-scene similarity: {cosine_similarity(same_a, other):.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+16
-2
@@ -10,7 +10,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wifi-densepose"
|
||||
version = "2.0.0a1"
|
||||
version = "2.0.0"
|
||||
description = "WiFi-based human pose estimation, vital sign extraction, and ambient intelligence from Channel State Information (CSI). PyO3 bindings for the Rust core."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -23,7 +23,7 @@ keywords = [
|
||||
"biometric", "ambient-intelligence", "home-assistant", "matter",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
@@ -48,6 +48,20 @@ client = [
|
||||
"websockets>=12.0",
|
||||
"paho-mqtt>=2.1",
|
||||
]
|
||||
# ADR-185 P1 — AETHER contrastive embeddings. Unlike `client`, this
|
||||
# extra carries no pure-Python deps: it is a marker for a *compiled*
|
||||
# feature build (`maturin ... --features aether` / a cibuildwheel
|
||||
# feature axis, ADR-185 §3.1). Installing the base wheel and importing
|
||||
# `wifi_densepose.aether` raises a clear ImportError naming this extra.
|
||||
aether = []
|
||||
# ADR-185 P2 — MERIDIAN domain generalization. Same compiled-feature
|
||||
# marker pattern as `aether` (built via `maturin ... --features meridian`).
|
||||
meridian = []
|
||||
# ADR-185 P3 — MAT disaster-survivor detection. Same compiled-feature
|
||||
# marker (built via `maturin ... --features mat`).
|
||||
mat = []
|
||||
# ADR-185 §3 convenience — all three SOTA subsystems at once.
|
||||
sota = []
|
||||
# Developer dependencies for running the test suite + lint.
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
|
||||
@@ -16,7 +16,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ruview"
|
||||
version = "2.0.0a1"
|
||||
version = "2.0.0"
|
||||
description = "RuView — ambient intelligence from WiFi CSI. Meta-package; installs `wifi-densepose` and re-exports it under the `ruview` namespace. See https://github.com/ruvnet/RuView."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -28,7 +28,7 @@ keywords = [
|
||||
"ruview",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
@@ -43,13 +43,13 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
# Pin to the matching v2 release so an alpha-pin `pip install ruview`
|
||||
# always gets a compatible wifi-densepose.
|
||||
"wifi-densepose==2.0.0a1",
|
||||
# Pin to the matching v2 release so `pip install ruview` always gets a
|
||||
# compatible wifi-densepose.
|
||||
"wifi-densepose==2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
client = ["wifi-densepose[client]==2.0.0a1"]
|
||||
client = ["wifi-densepose[client]==2.0.0"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/ruvnet/RuView"
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
//! ADR-185 P1 — PyO3 bindings for AETHER contrastive CSI embeddings.
|
||||
//!
|
||||
//! Surfaces the **pure-sync** contrastive-embedding compute from
|
||||
//! `wifi-densepose-aether::embedding` (ADR-024; the std-only leaf hoisted per
|
||||
//! ADR-185 §13) into `wifi_densepose.aether`:
|
||||
//!
|
||||
//! - `AetherConfig` — wraps `EmbeddingConfig` (d_model / d_proj /
|
||||
//! temperature / normalize)
|
||||
//! - `CsiAugmenter` — SimCLR-style augmentation pair generator
|
||||
//! - `EmbeddingExtractor`— backbone + projection → 128-dim L2-normed embedding
|
||||
//! - `info_nce_loss` — NT-Xent contrastive loss (module function)
|
||||
//! - `cosine_similarity` — re-ID similarity helper (module function)
|
||||
//!
|
||||
//! ## Honest scope vs ADR-185 §3.2
|
||||
//!
|
||||
//! ADR-185 §3.2 names an aspirational surface (`aether_loss` returning
|
||||
//! VICReg components, `alignment_metric`, `uniformity_metric`,
|
||||
//! `forward_dual`, an `AetherConfig` with `vicreg_*` fields). Those do
|
||||
//! **not** exist in the backing crate at HEAD — `embedding.rs` exposes
|
||||
//! `EmbeddingConfig { d_model, d_proj, temperature, normalize }`,
|
||||
//! `info_nce_loss` (plain `f32`), `CsiAugmenter::augment_pair`, and
|
||||
//! `EmbeddingExtractor::extract`. This binding surfaces **what actually
|
||||
//! exists** rather than fabricating the ADR's wished-for API. The
|
||||
//! VICReg loss / metric surface is a Rust-side gap, not a binding gap.
|
||||
//!
|
||||
//! ## GIL release strategy (per ADR-117 §7, matching bindings/vitals.rs)
|
||||
//!
|
||||
//! `extract`, `augment_pair`, and `info_nce_loss` are pure-sync matrix
|
||||
//! ops touching no Python objects, so they run inside
|
||||
//! `py.allow_threads(|| ...)`.
|
||||
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use wifi_densepose_aether::embedding::{
|
||||
info_nce_loss as rust_info_nce_loss, CsiAugmenter, EmbeddingConfig, EmbeddingExtractor,
|
||||
};
|
||||
use wifi_densepose_aether::graph_transformer::TransformerConfig;
|
||||
|
||||
/// Upper bound on model/CSI dimensions accepted from Python. The transformer
|
||||
/// allocates weight matrices quadratic in these, so this caps a single
|
||||
/// construction well under a gigabyte and turns an accidental or malicious
|
||||
/// `d_model=100_000` into a `ValueError` instead of an allocation that aborts
|
||||
/// the interpreter. Generous relative to real configs (defaults 64/128); raise
|
||||
/// deliberately if a workload genuinely needs larger.
|
||||
const MAX_DIM: usize = 4096;
|
||||
/// Upper bound on GNN layer count — a sanity cap, not a modelling limit.
|
||||
const MAX_LAYERS: usize = 64;
|
||||
|
||||
// ─── AetherConfig ────────────────────────────────────────────────────
|
||||
|
||||
/// Configuration for the contrastive embedding model.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose.aether import AetherConfig
|
||||
/// cfg = AetherConfig(d_model=64, d_proj=128, temperature=0.07, normalize=True)
|
||||
/// ```
|
||||
#[pyclass(frozen, name = "AetherConfig")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyAetherConfig {
|
||||
inner: EmbeddingConfig,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyAetherConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (d_model=64, d_proj=128, temperature=0.07, normalize=true))]
|
||||
fn new(d_model: usize, d_proj: usize, temperature: f32, normalize: bool) -> PyResult<Self> {
|
||||
// Validate at the boundary and raise ValueError. The native constructor
|
||||
// allocates weight matrices quadratic in these dims and (elsewhere)
|
||||
// divides by them, so zero or absurd values would otherwise reach Rust
|
||||
// as a panic (surfacing to Python as an opaque PanicException) or a
|
||||
// multi-gigabyte allocation that aborts the interpreter.
|
||||
if d_model == 0 || d_proj == 0 {
|
||||
return Err(PyValueError::new_err(
|
||||
"d_model and d_proj must be positive",
|
||||
));
|
||||
}
|
||||
if d_model > MAX_DIM || d_proj > MAX_DIM {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"d_model ({d_model}) and d_proj ({d_proj}) must be <= {MAX_DIM}"
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
inner: EmbeddingConfig {
|
||||
d_model,
|
||||
d_proj,
|
||||
temperature,
|
||||
normalize,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn d_model(&self) -> usize {
|
||||
self.inner.d_model
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn d_proj(&self) -> usize {
|
||||
self.inner.d_proj
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn temperature(&self) -> f32 {
|
||||
self.inner.temperature
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn normalize(&self) -> bool {
|
||||
self.inner.normalize
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"AetherConfig(d_model={}, d_proj={}, temperature={}, normalize={})",
|
||||
self.inner.d_model, self.inner.d_proj, self.inner.temperature, self.inner.normalize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CsiAugmenter ────────────────────────────────────────────────────
|
||||
|
||||
/// SimCLR-style CSI augmentation. `augment_pair` returns two distinct
|
||||
/// augmented views of the same CSI window for contrastive pretraining.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose.aether import CsiAugmenter
|
||||
/// aug = CsiAugmenter()
|
||||
/// view_a, view_b = aug.augment_pair(window, seed=42)
|
||||
/// ```
|
||||
#[pyclass(name = "CsiAugmenter")]
|
||||
pub struct PyCsiAugmenter {
|
||||
inner: CsiAugmenter,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCsiAugmenter {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: CsiAugmenter::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce two augmented views `(view_a, view_b)` of `window`
|
||||
/// (frames × subcarriers) using the deterministic `seed`. GIL is
|
||||
/// released during augmentation.
|
||||
fn augment_pair(
|
||||
&self,
|
||||
py: Python<'_>,
|
||||
window: Vec<Vec<f32>>,
|
||||
seed: u64,
|
||||
) -> (Vec<Vec<f32>>, Vec<Vec<f32>>) {
|
||||
py.allow_threads(|| self.inner.augment_pair(&window, seed))
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
"CsiAugmenter(SimCLR-style CSI augmentation)".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EmbeddingExtractor ──────────────────────────────────────────────
|
||||
|
||||
/// Full AETHER embedding extractor: CSI→pose transformer backbone +
|
||||
/// projection head → a `d_proj`-dim (default 128) L2-normalized
|
||||
/// embedding. Weights are deterministically seeded, so `embed` is a
|
||||
/// pure function of its input for a fixed config.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose.aether import AetherConfig, EmbeddingExtractor
|
||||
/// ext = EmbeddingExtractor(n_subcarriers=56, config=AetherConfig())
|
||||
/// emb = ext.embed(window) # list[float], len == config.d_proj
|
||||
/// ```
|
||||
#[pyclass(name = "EmbeddingExtractor")]
|
||||
pub struct PyEmbeddingExtractor {
|
||||
inner: EmbeddingExtractor,
|
||||
embedding_dim: usize,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyEmbeddingExtractor {
|
||||
/// Construct an extractor. The transformer backbone is sized from
|
||||
/// `n_subcarriers` and `config.d_model`; `config.d_proj` sets the
|
||||
/// embedding dimension.
|
||||
#[new]
|
||||
#[pyo3(signature = (n_subcarriers, config, n_keypoints=17, n_heads=4, n_gnn_layers=2))]
|
||||
fn new(
|
||||
n_subcarriers: usize,
|
||||
config: PyAetherConfig,
|
||||
n_keypoints: usize,
|
||||
n_heads: usize,
|
||||
n_gnn_layers: usize,
|
||||
) -> PyResult<Self> {
|
||||
let e_config = config.inner.clone();
|
||||
// n_heads == 0 reaches `d_model % n_heads` in the transformer and panics
|
||||
// (divide-by-zero); a non-divisor trips the native `assert!`. Both would
|
||||
// surface to Python as a PanicException. Reject cleanly instead.
|
||||
if n_heads == 0 {
|
||||
return Err(PyValueError::new_err("n_heads must be positive"));
|
||||
}
|
||||
if e_config.d_model % n_heads != 0 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"d_model ({}) must be divisible by n_heads ({n_heads})",
|
||||
e_config.d_model
|
||||
)));
|
||||
}
|
||||
if n_subcarriers == 0 || n_keypoints == 0 {
|
||||
return Err(PyValueError::new_err(
|
||||
"n_subcarriers and n_keypoints must be positive",
|
||||
));
|
||||
}
|
||||
if n_subcarriers > MAX_DIM || n_keypoints > MAX_DIM || n_gnn_layers > MAX_LAYERS {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"n_subcarriers/n_keypoints must be <= {MAX_DIM} and n_gnn_layers <= {MAX_LAYERS}"
|
||||
)));
|
||||
}
|
||||
let t_config = TransformerConfig {
|
||||
n_subcarriers,
|
||||
n_keypoints,
|
||||
d_model: e_config.d_model,
|
||||
n_heads,
|
||||
n_gnn_layers,
|
||||
};
|
||||
let embedding_dim = e_config.d_proj;
|
||||
Ok(Self {
|
||||
inner: EmbeddingExtractor::new(t_config, e_config),
|
||||
embedding_dim,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract an embedding from a CSI window (frames × subcarriers).
|
||||
/// Returns a `d_proj`-length vector (L2-normed when the config's
|
||||
/// `normalize` is set). GIL released during the forward pass.
|
||||
fn embed(&mut self, py: Python<'_>, csi_features: Vec<Vec<f32>>) -> Vec<f32> {
|
||||
py.allow_threads(|| self.inner.extract(&csi_features))
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn embedding_dim(&self) -> usize {
|
||||
self.embedding_dim
|
||||
}
|
||||
|
||||
/// Total trainable parameter count (transformer + projection). Equals the
|
||||
/// number of `f32`s in a weight file for this architecture.
|
||||
#[getter]
|
||||
fn param_count(&self) -> usize {
|
||||
self.inner.param_count()
|
||||
}
|
||||
|
||||
/// Load weights from `path` (a file written by `save_weights` or the Rust
|
||||
/// `EmbeddingExtractor::save_weights`), replacing the current weights.
|
||||
///
|
||||
/// By default an `EmbeddingExtractor` uses deterministic **random** init
|
||||
/// (untrained); this is the additive path to load real weights once a
|
||||
/// trained checkpoint exists (ADR-185 §13.a). Raises `ValueError` on a
|
||||
/// missing/corrupt file or a param-count mismatch with this architecture.
|
||||
/// GIL released during file I/O + deserialization.
|
||||
fn load_weights(&mut self, py: Python<'_>, path: String) -> PyResult<()> {
|
||||
py.allow_threads(|| self.inner.load_weights(&path))
|
||||
.map_err(PyValueError::new_err)
|
||||
}
|
||||
|
||||
/// Serialize the current weights to `path` (magic `AETHERW1` + `u32` count
|
||||
/// + little-endian `f32` payload). GIL released.
|
||||
fn save_weights(&self, py: Python<'_>, path: String) -> PyResult<()> {
|
||||
py.allow_threads(|| self.inner.save_weights(&path))
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("EmbeddingExtractor(embedding_dim={})", self.embedding_dim)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Module functions ────────────────────────────────────────────────
|
||||
|
||||
/// InfoNCE (NT-Xent) contrastive loss between two batches of embeddings.
|
||||
/// Delegates to the identical Rust implementation. GIL released.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (embeddings_a, embeddings_b, temperature=0.07))]
|
||||
fn info_nce_loss(
|
||||
py: Python<'_>,
|
||||
embeddings_a: Vec<Vec<f32>>,
|
||||
embeddings_b: Vec<Vec<f32>>,
|
||||
temperature: f32,
|
||||
) -> f32 {
|
||||
py.allow_threads(|| rust_info_nce_loss(&embeddings_a, &embeddings_b, temperature))
|
||||
}
|
||||
|
||||
/// Cosine similarity between two embeddings — the re-ID scoring
|
||||
/// primitive. Byte-identical to the private `cosine_similarity` in the
|
||||
/// backing crate (same dot-product / norm formula, `f32`).
|
||||
#[pyfunction]
|
||||
fn cosine_similarity(a: Vec<f32>, b: Vec<f32>) -> f32 {
|
||||
let n = a.len().min(b.len());
|
||||
let dot: f32 = (0..n).map(|i| a[i] * b[i]).sum();
|
||||
let na = (0..n).map(|i| a[i] * a[i]).sum::<f32>().sqrt();
|
||||
let nb = (0..n).map(|i| b[i] * b[i]).sum::<f32>().sqrt();
|
||||
if na > 1e-10 && nb > 1e-10 {
|
||||
dot / (na * nb)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyAetherConfig>()?;
|
||||
m.add_class::<PyCsiAugmenter>()?;
|
||||
m.add_class::<PyEmbeddingExtractor>()?;
|
||||
m.add_function(wrap_pyfunction!(info_nce_loss, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(cosine_similarity, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! ADR-185 P3 — PyO3 bindings for MAT (Mass Casualty Assessment Tool, ADR-024
|
||||
//! crate table): WiFi-based disaster-survivor detection + START triage.
|
||||
//!
|
||||
//! Bound behind the `[mat]` extra so the disaster/ML stack never enters the
|
||||
//! default wheel.
|
||||
//!
|
||||
//! ## Honest scope vs ADR-185 §3.4
|
||||
//!
|
||||
//! - **`scan_once()`** — ADR-185 §3.4/§11.3 proposed adding a sync
|
||||
//! `scan_once()` wrapper Rust-side. That turned out to be unnecessary: the
|
||||
//! public async `DisasterResponse::start_scanning()` runs **exactly one**
|
||||
//! `scan_cycle` and returns when `continuous_monitoring == false`. So this
|
||||
//! binding forces `continuous_monitoring = false` and drives one scan on a
|
||||
//! private current-thread tokio runtime — no change to `wifi-densepose-mat`.
|
||||
//! - **event + zone are required** — `scan_cycle` errors without an active
|
||||
//! event and an Active zone. ADR-185 §3.4's surface omitted this; the real
|
||||
//! pipeline needs `initialize_event(...)` + `add_zone(...)` first, so both
|
||||
//! are bound (documented additions, not fabrications).
|
||||
//! - **`Survivor.vital_signs`** — the ADR implies a single `VitalSignsReading`;
|
||||
//! the real accessor returns a *history*. Bound here as
|
||||
//! `Survivor.latest_vitals -> Optional[VitalSignsReading]`.
|
||||
//! - **`DisasterType`** has 9 variants at HEAD (adds Landslide, MineCollapse,
|
||||
//! Industrial, TunnelCollapse) vs the ADR's shorter list; all are bound.
|
||||
//!
|
||||
//! ## GIL release
|
||||
//!
|
||||
//! `push_csi_data` and `scan_once` release the GIL (`py.allow_threads`) — the
|
||||
//! detection pipeline + ensemble classifier are the compute-heavy part and
|
||||
//! touch no Python state.
|
||||
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use wifi_densepose_mat::{
|
||||
DisasterConfig, DisasterResponse, DisasterType, ScanZone, Survivor, TriageStatus,
|
||||
VitalSignsReading, ZoneBounds,
|
||||
};
|
||||
|
||||
// ─── DisasterType ────────────────────────────────────────────────────
|
||||
|
||||
/// Type of disaster event (shapes the debris/attenuation model).
|
||||
#[pyclass(eq, eq_int, frozen, hash, name = "DisasterType")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PyDisasterType {
|
||||
BuildingCollapse = 0,
|
||||
Earthquake = 1,
|
||||
Landslide = 2,
|
||||
Avalanche = 3,
|
||||
Flood = 4,
|
||||
MineCollapse = 5,
|
||||
Industrial = 6,
|
||||
TunnelCollapse = 7,
|
||||
Unknown = 8,
|
||||
}
|
||||
|
||||
impl PyDisasterType {
|
||||
fn as_rust(self) -> DisasterType {
|
||||
match self {
|
||||
Self::BuildingCollapse => DisasterType::BuildingCollapse,
|
||||
Self::Earthquake => DisasterType::Earthquake,
|
||||
Self::Landslide => DisasterType::Landslide,
|
||||
Self::Avalanche => DisasterType::Avalanche,
|
||||
Self::Flood => DisasterType::Flood,
|
||||
Self::MineCollapse => DisasterType::MineCollapse,
|
||||
Self::Industrial => DisasterType::Industrial,
|
||||
Self::TunnelCollapse => DisasterType::TunnelCollapse,
|
||||
Self::Unknown => DisasterType::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDisasterType {
|
||||
fn __repr__(&self) -> String {
|
||||
format!("DisasterType.{:?}", self.as_rust())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TriageStatus ────────────────────────────────────────────────────
|
||||
|
||||
/// START-protocol triage class.
|
||||
#[pyclass(eq, eq_int, frozen, hash, name = "TriageStatus")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PyTriageStatus {
|
||||
Immediate = 0,
|
||||
Delayed = 1,
|
||||
Minor = 2,
|
||||
Deceased = 3,
|
||||
Unknown = 4,
|
||||
}
|
||||
|
||||
impl PyTriageStatus {
|
||||
fn as_rust(self) -> TriageStatus {
|
||||
match self {
|
||||
Self::Immediate => TriageStatus::Immediate,
|
||||
Self::Delayed => TriageStatus::Delayed,
|
||||
Self::Minor => TriageStatus::Minor,
|
||||
Self::Deceased => TriageStatus::Deceased,
|
||||
Self::Unknown => TriageStatus::Unknown,
|
||||
}
|
||||
}
|
||||
fn from_rust(s: &TriageStatus) -> Self {
|
||||
match s {
|
||||
TriageStatus::Immediate => Self::Immediate,
|
||||
TriageStatus::Delayed => Self::Delayed,
|
||||
TriageStatus::Minor => Self::Minor,
|
||||
TriageStatus::Deceased => Self::Deceased,
|
||||
TriageStatus::Unknown => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyTriageStatus {
|
||||
/// START priority (1 = highest / Immediate ... 5 = Unknown).
|
||||
#[getter]
|
||||
fn priority(&self) -> u8 {
|
||||
self.as_rust().priority()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("TriageStatus.{:?}", self.as_rust())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── VitalSignsReading ───────────────────────────────────────────────
|
||||
|
||||
/// A single vital-signs reading (optional breathing/heartbeat + movement).
|
||||
#[pyclass(frozen, name = "VitalSignsReading")]
|
||||
pub struct PyVitalSignsReading {
|
||||
breathing_rate_bpm: Option<f32>,
|
||||
heartbeat_rate_bpm: Option<f32>,
|
||||
movement_intensity: f32,
|
||||
confidence: f64,
|
||||
}
|
||||
|
||||
impl PyVitalSignsReading {
|
||||
fn from_rust(r: &VitalSignsReading) -> Self {
|
||||
Self {
|
||||
breathing_rate_bpm: r.breathing.as_ref().map(|b| b.rate_bpm),
|
||||
heartbeat_rate_bpm: r.heartbeat.as_ref().map(|h| h.rate_bpm),
|
||||
movement_intensity: r.movement.intensity,
|
||||
confidence: r.confidence.value(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyVitalSignsReading {
|
||||
#[getter]
|
||||
fn breathing_rate_bpm(&self) -> Option<f32> {
|
||||
self.breathing_rate_bpm
|
||||
}
|
||||
#[getter]
|
||||
fn heartbeat_rate_bpm(&self) -> Option<f32> {
|
||||
self.heartbeat_rate_bpm
|
||||
}
|
||||
#[getter]
|
||||
fn movement_intensity(&self) -> f32 {
|
||||
self.movement_intensity
|
||||
}
|
||||
#[getter]
|
||||
fn confidence(&self) -> f64 {
|
||||
self.confidence
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"VitalSignsReading(breathing={:?}, heartbeat={:?}, movement={:.3}, confidence={:.3})",
|
||||
self.breathing_rate_bpm, self.heartbeat_rate_bpm, self.movement_intensity, self.confidence,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Survivor ────────────────────────────────────────────────────────
|
||||
|
||||
/// A detected survivor: id, triage class, confidence, optional 3-D location,
|
||||
/// and the latest vital-signs reading.
|
||||
#[pyclass(frozen, name = "Survivor")]
|
||||
pub struct PySurvivor {
|
||||
id: String,
|
||||
triage_status: PyTriageStatus,
|
||||
confidence: f64,
|
||||
location: Option<(f64, f64, f64)>,
|
||||
latest_vitals: Option<Py<PyVitalSignsReading>>,
|
||||
}
|
||||
|
||||
impl PySurvivor {
|
||||
fn from_rust(py: Python<'_>, s: &Survivor) -> PyResult<Self> {
|
||||
let latest_vitals = match s.vital_signs().latest() {
|
||||
Some(r) => Some(Py::new(py, PyVitalSignsReading::from_rust(r))?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Self {
|
||||
id: s.id().as_uuid().to_string(),
|
||||
triage_status: PyTriageStatus::from_rust(s.triage_status()),
|
||||
confidence: s.confidence(),
|
||||
location: s.location().map(|c| (c.x, c.y, c.z)),
|
||||
latest_vitals,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PySurvivor {
|
||||
#[getter]
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
#[getter]
|
||||
fn triage_status(&self) -> PyTriageStatus {
|
||||
self.triage_status
|
||||
}
|
||||
#[getter]
|
||||
fn confidence(&self) -> f64 {
|
||||
self.confidence
|
||||
}
|
||||
#[getter]
|
||||
fn location(&self) -> Option<(f64, f64, f64)> {
|
||||
self.location
|
||||
}
|
||||
#[getter]
|
||||
fn latest_vitals(&self, py: Python<'_>) -> Option<Py<PyVitalSignsReading>> {
|
||||
self.latest_vitals.as_ref().map(|v| v.clone_ref(py))
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Survivor(id={}, triage={:?}, confidence={:.3})",
|
||||
&self.id[..8.min(self.id.len())],
|
||||
self.triage_status.as_rust(),
|
||||
self.confidence,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DisasterConfig ──────────────────────────────────────────────────
|
||||
|
||||
/// Configuration for the disaster-response pipeline.
|
||||
///
|
||||
/// Note: the Python binding always runs **single-shot** scans (`scan_once`),
|
||||
/// so `continuous_monitoring` is forced off internally.
|
||||
#[pyclass(frozen, name = "DisasterConfig")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyDisasterConfig {
|
||||
inner: DisasterConfig,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDisasterConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (
|
||||
disaster_type,
|
||||
sensitivity=0.8,
|
||||
confidence_threshold=0.5,
|
||||
max_depth=5.0,
|
||||
scan_interval_ms=500
|
||||
))]
|
||||
fn new(
|
||||
disaster_type: PyDisasterType,
|
||||
sensitivity: f64,
|
||||
confidence_threshold: f64,
|
||||
max_depth: f64,
|
||||
scan_interval_ms: u64,
|
||||
) -> Self {
|
||||
let inner = DisasterConfig::builder()
|
||||
.disaster_type(disaster_type.as_rust())
|
||||
.sensitivity(sensitivity)
|
||||
.confidence_threshold(confidence_threshold)
|
||||
.max_depth(max_depth)
|
||||
.scan_interval_ms(scan_interval_ms)
|
||||
.continuous_monitoring(false)
|
||||
.build();
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn sensitivity(&self) -> f64 {
|
||||
self.inner.sensitivity
|
||||
}
|
||||
#[getter]
|
||||
fn confidence_threshold(&self) -> f64 {
|
||||
self.inner.confidence_threshold
|
||||
}
|
||||
#[getter]
|
||||
fn max_depth(&self) -> f64 {
|
||||
self.inner.max_depth
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"DisasterConfig(disaster_type={:?}, sensitivity={}, confidence_threshold={}, max_depth={})",
|
||||
self.inner.disaster_type,
|
||||
self.inner.sensitivity,
|
||||
self.inner.confidence_threshold,
|
||||
self.inner.max_depth,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ScanZone ────────────────────────────────────────────────────────
|
||||
|
||||
/// A rectangular or circular scan zone (new zones start Active).
|
||||
#[pyclass(name = "ScanZone")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyScanZone {
|
||||
inner: ScanZone,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyScanZone {
|
||||
/// Rectangular zone with corner bounds (metres).
|
||||
#[staticmethod]
|
||||
fn rectangle(name: &str, min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
|
||||
Self {
|
||||
inner: ScanZone::new(name, ZoneBounds::rectangle(min_x, min_y, max_x, max_y)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Circular zone centred at `(center_x, center_y)` with `radius` (metres).
|
||||
#[staticmethod]
|
||||
fn circle(name: &str, center_x: f64, center_y: f64, radius: f64) -> Self {
|
||||
Self {
|
||||
inner: ScanZone::new(name, ZoneBounds::circle(center_x, center_y, radius)),
|
||||
}
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn name(&self) -> &str {
|
||||
self.inner.name()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("ScanZone(name={:?})", self.inner.name())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DisasterResponse ────────────────────────────────────────────────
|
||||
|
||||
/// Main disaster-response coordinator: ingest CSI, run one scan cycle, query
|
||||
/// detected survivors by START triage.
|
||||
#[pyclass(name = "DisasterResponse")]
|
||||
pub struct PyDisasterResponse {
|
||||
inner: DisasterResponse,
|
||||
rt: tokio::runtime::Runtime,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDisasterResponse {
|
||||
#[new]
|
||||
fn new(config: PyDisasterConfig) -> PyResult<Self> {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.map_err(|e| PyValueError::new_err(format!("failed to build tokio runtime: {e}")))?;
|
||||
Ok(Self {
|
||||
inner: DisasterResponse::new(config.inner),
|
||||
rt,
|
||||
})
|
||||
}
|
||||
|
||||
/// Initialize the active disaster event at map coordinate `(x, y)`.
|
||||
/// Required before `add_zone`/`scan_once`.
|
||||
fn initialize_event(&mut self, x: f64, y: f64, description: &str) -> PyResult<()> {
|
||||
self.inner
|
||||
.initialize_event(geo::Point::new(x, y), description)
|
||||
.map(|_| ())
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
/// Add an (Active) scan zone to the current event. Raises if no event.
|
||||
fn add_zone(&mut self, zone: PyScanZone) -> PyResult<()> {
|
||||
self.inner
|
||||
.add_zone(zone.inner)
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
/// Push a raw CSI frame (equal-length `amplitudes`/`phases`) into the
|
||||
/// detection pipeline. Raises on empty/mismatched input. GIL released.
|
||||
fn push_csi_data(
|
||||
&self,
|
||||
py: Python<'_>,
|
||||
amplitudes: Vec<f64>,
|
||||
phases: Vec<f64>,
|
||||
) -> PyResult<()> {
|
||||
py.allow_threads(|| self.inner.push_csi_data(&litudes, &phases))
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
/// Run exactly one scan cycle over the buffered CSI (detection → ensemble
|
||||
/// → localization → triage). Requires an initialized event with an Active
|
||||
/// zone. GIL released during the scan.
|
||||
fn scan_once(&mut self, py: Python<'_>) -> PyResult<()> {
|
||||
let rt = &self.rt;
|
||||
let inner = &mut self.inner;
|
||||
py.allow_threads(|| rt.block_on(inner.start_scanning()))
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
/// All detected survivors.
|
||||
fn survivors(&self, py: Python<'_>) -> PyResult<Vec<PySurvivor>> {
|
||||
self.inner
|
||||
.survivors()
|
||||
.into_iter()
|
||||
.map(|s| PySurvivor::from_rust(py, s))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Survivors filtered by START triage class.
|
||||
fn survivors_by_triage(
|
||||
&self,
|
||||
py: Python<'_>,
|
||||
status: PyTriageStatus,
|
||||
) -> PyResult<Vec<PySurvivor>> {
|
||||
self.inner
|
||||
.survivors_by_triage(status.as_rust())
|
||||
.into_iter()
|
||||
.map(|s| PySurvivor::from_rust(py, s))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
"DisasterResponse()".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyDisasterType>()?;
|
||||
m.add_class::<PyTriageStatus>()?;
|
||||
m.add_class::<PyVitalSignsReading>()?;
|
||||
m.add_class::<PySurvivor>()?;
|
||||
m.add_class::<PyDisasterConfig>()?;
|
||||
m.add_class::<PyScanZone>()?;
|
||||
m.add_class::<PyDisasterResponse>()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
//! ADR-185 P2 — PyO3 bindings for MERIDIAN cross-environment domain
|
||||
//! generalization (ADR-027).
|
||||
//!
|
||||
//! Surfaces the **pure-sync, tch-free** inference/adaptation path into
|
||||
//! `wifi_densepose.meridian`:
|
||||
//!
|
||||
//! - `HardwareType` / `HardwareNormalizer` / `CanonicalCsiFrame`
|
||||
//! (from `wifi-densepose-signal::hardware_norm`)
|
||||
//! - `MeridianGeometryConfig` / `GeometryEncoder`
|
||||
//! - `RapidAdaptation` / `AdaptationResult`
|
||||
//! - `CrossDomainEvaluator` + `mpjpe`
|
||||
//! (from `wifi-densepose-train`, NO `tch-backend`)
|
||||
//!
|
||||
//! ## Honest scope vs ADR-185 §3.3
|
||||
//!
|
||||
//! ADR-185 §3.3 names a surface that partly diverges from the code at HEAD;
|
||||
//! this binding tracks the **real** API and documents each deviation:
|
||||
//!
|
||||
//! - `HardwareType.detect(subcarrier_count)` — the real detector is the
|
||||
//! static `HardwareNormalizer::detect_hardware`; exposed here as a
|
||||
//! `HardwareType.detect` staticmethod delegating to it (no reimpl).
|
||||
//! - `HardwareNormalizer.normalize(frame: CsiFrame, hw)` — the real method
|
||||
//! takes raw `(amplitude, phase)` f64 vectors and returns a `Result`, so
|
||||
//! it is bound as `normalize(amplitude, phase, hw)` (raises on error).
|
||||
//! - `CanonicalCsiFrame.amplitudes/.phases` — the real fields are singular
|
||||
//! `amplitude`/`phase`; bound under their real names.
|
||||
//! - `RapidAdaptation.calibrate(csi_windows) -> AdaptationResult` with a
|
||||
//! `converged` field — **does not exist**. The real engine is
|
||||
//! `push_frame` + `adapt()`, and `AdaptationResult` carries
|
||||
//! `{lora_weights, final_loss, frames_used, adaptation_epochs}` (no
|
||||
//! `converged`). Bound as-is; the `calibrate`/`converged` surface is a
|
||||
//! Rust-side gap, not fabricated here.
|
||||
//!
|
||||
//! Training-time types (`DomainFactorizer`, `GradientReversalLayer`,
|
||||
//! `VirtualDomainAugmentor`) are out of P6 scope (ADR-185 §3.3 / Open Q
|
||||
//! §11.2) — inference/adaptation only.
|
||||
//!
|
||||
//! ## GIL release (per ADR-117 §7, matching bindings/vitals.rs)
|
||||
//!
|
||||
//! `normalize`, `encode`, `adapt`, and `evaluate` are pure-sync numeric
|
||||
//! ops touching no Python objects, so they run inside `py.allow_threads`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use wifi_densepose_signal::hardware_norm::{
|
||||
CanonicalCsiFrame, HardwareNormalizer, HardwareType,
|
||||
};
|
||||
use wifi_densepose_train::eval::{mpjpe as rust_mpjpe, CrossDomainEvaluator};
|
||||
use wifi_densepose_train::geometry::{GeometryEncoder, MeridianGeometryConfig};
|
||||
use wifi_densepose_train::rapid_adapt::{AdaptationLoss, AdaptationResult, RapidAdaptation};
|
||||
|
||||
// ─── HardwareType ────────────────────────────────────────────────────
|
||||
|
||||
/// WiFi chipset family, keyed by subcarrier count.
|
||||
#[pyclass(eq, eq_int, frozen, hash, name = "HardwareType")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PyHardwareType {
|
||||
Esp32S3 = 0,
|
||||
Intel5300 = 1,
|
||||
Atheros = 2,
|
||||
Generic = 3,
|
||||
}
|
||||
|
||||
impl PyHardwareType {
|
||||
fn as_rust(self) -> HardwareType {
|
||||
match self {
|
||||
Self::Esp32S3 => HardwareType::Esp32S3,
|
||||
Self::Intel5300 => HardwareType::Intel5300,
|
||||
Self::Atheros => HardwareType::Atheros,
|
||||
Self::Generic => HardwareType::Generic,
|
||||
}
|
||||
}
|
||||
fn from_rust(hw: HardwareType) -> Self {
|
||||
match hw {
|
||||
HardwareType::Esp32S3 => Self::Esp32S3,
|
||||
HardwareType::Intel5300 => Self::Intel5300,
|
||||
HardwareType::Atheros => Self::Atheros,
|
||||
HardwareType::Generic => Self::Generic,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyHardwareType {
|
||||
/// Detect hardware from subcarrier count (64→Esp32S3, 30→Intel5300,
|
||||
/// 56→Atheros, else Generic). Delegates to the real
|
||||
/// `HardwareNormalizer::detect_hardware`.
|
||||
#[staticmethod]
|
||||
fn detect(subcarrier_count: usize) -> Self {
|
||||
Self::from_rust(HardwareNormalizer::detect_hardware(subcarrier_count))
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn subcarrier_count(&self) -> usize {
|
||||
self.as_rust().subcarrier_count()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn mimo_streams(&self) -> usize {
|
||||
self.as_rust().mimo_streams()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("HardwareType.{:?}", self.as_rust())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CanonicalCsiFrame ───────────────────────────────────────────────
|
||||
|
||||
/// A CSI frame canonicalized to the normalizer's subcarrier grid
|
||||
/// (default 56): z-scored amplitude + sanitized (unwrapped, detrended)
|
||||
/// phase.
|
||||
#[pyclass(frozen, name = "CanonicalCsiFrame")]
|
||||
pub struct PyCanonicalCsiFrame {
|
||||
inner: CanonicalCsiFrame,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCanonicalCsiFrame {
|
||||
#[getter]
|
||||
fn amplitude(&self) -> Vec<f32> {
|
||||
self.inner.amplitude.clone()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn phase(&self) -> Vec<f32> {
|
||||
self.inner.phase.clone()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn hardware_type(&self) -> PyHardwareType {
|
||||
PyHardwareType::from_rust(self.inner.hardware_type)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"CanonicalCsiFrame(subcarriers={}, hardware_type={:?})",
|
||||
self.inner.amplitude.len(),
|
||||
self.inner.hardware_type,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HardwareNormalizer ──────────────────────────────────────────────
|
||||
|
||||
/// Normalizes CSI frames from heterogeneous chipsets into a canonical
|
||||
/// representation (cubic resample → z-score amplitude → sanitize phase).
|
||||
#[pyclass(name = "HardwareNormalizer")]
|
||||
pub struct PyHardwareNormalizer {
|
||||
inner: HardwareNormalizer,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyHardwareNormalizer {
|
||||
/// Create a normalizer. `canonical_subcarriers` defaults to 56.
|
||||
#[new]
|
||||
#[pyo3(signature = (canonical_subcarriers=56))]
|
||||
fn new(canonical_subcarriers: usize) -> PyResult<Self> {
|
||||
HardwareNormalizer::with_canonical_subcarriers(canonical_subcarriers)
|
||||
.map(|inner| Self { inner })
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
/// Detect hardware from subcarrier count (static).
|
||||
#[staticmethod]
|
||||
fn detect_hardware(subcarrier_count: usize) -> PyHardwareType {
|
||||
PyHardwareType::from_rust(HardwareNormalizer::detect_hardware(subcarrier_count))
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn canonical_subcarriers(&self) -> usize {
|
||||
self.inner.canonical_subcarriers()
|
||||
}
|
||||
|
||||
/// Normalize a raw CSI frame given per-subcarrier `amplitude` and
|
||||
/// `phase` (equal length) and its `hardware` type. Raises
|
||||
/// `ValueError` on empty/mismatched input. GIL released.
|
||||
fn normalize(
|
||||
&self,
|
||||
py: Python<'_>,
|
||||
amplitude: Vec<f64>,
|
||||
phase: Vec<f64>,
|
||||
hardware: PyHardwareType,
|
||||
) -> PyResult<PyCanonicalCsiFrame> {
|
||||
let hw = hardware.as_rust();
|
||||
py.allow_threads(|| self.inner.normalize(&litude, &phase, hw))
|
||||
.map(|inner| PyCanonicalCsiFrame { inner })
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"HardwareNormalizer(canonical_subcarriers={})",
|
||||
self.inner.canonical_subcarriers()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MeridianGeometryConfig ──────────────────────────────────────────
|
||||
|
||||
/// Config for the geometry encoder (Fourier bands + DeepSets output dim).
|
||||
#[pyclass(frozen, name = "MeridianGeometryConfig")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyMeridianGeometryConfig {
|
||||
inner: MeridianGeometryConfig,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyMeridianGeometryConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (n_frequencies=10, scale=1.0, geometry_dim=64, seed=42))]
|
||||
fn new(n_frequencies: usize, scale: f32, geometry_dim: usize, seed: u64) -> Self {
|
||||
Self {
|
||||
inner: MeridianGeometryConfig {
|
||||
n_frequencies,
|
||||
scale,
|
||||
geometry_dim,
|
||||
seed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn n_frequencies(&self) -> usize {
|
||||
self.inner.n_frequencies
|
||||
}
|
||||
#[getter]
|
||||
fn scale(&self) -> f32 {
|
||||
self.inner.scale
|
||||
}
|
||||
#[getter]
|
||||
fn geometry_dim(&self) -> usize {
|
||||
self.inner.geometry_dim
|
||||
}
|
||||
#[getter]
|
||||
fn seed(&self) -> u64 {
|
||||
self.inner.seed
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"MeridianGeometryConfig(n_frequencies={}, scale={}, geometry_dim={}, seed={})",
|
||||
self.inner.n_frequencies, self.inner.scale, self.inner.geometry_dim, self.inner.seed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GeometryEncoder ─────────────────────────────────────────────────
|
||||
|
||||
/// Permutation-invariant encoder: variable-count AP positions `[x,y,z]`
|
||||
/// → a fixed `geometry_dim` (default 64) vector.
|
||||
#[pyclass(name = "GeometryEncoder")]
|
||||
pub struct PyGeometryEncoder {
|
||||
inner: GeometryEncoder,
|
||||
geometry_dim: usize,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyGeometryEncoder {
|
||||
#[new]
|
||||
#[pyo3(signature = (config=None))]
|
||||
fn new(config: Option<PyMeridianGeometryConfig>) -> Self {
|
||||
let cfg = config.map(|c| c.inner).unwrap_or_default();
|
||||
let geometry_dim = cfg.geometry_dim;
|
||||
Self {
|
||||
inner: GeometryEncoder::new(&cfg),
|
||||
geometry_dim,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode AP positions (a non-empty list of `[x, y, z]`) into a
|
||||
/// `geometry_dim`-length vector. Raises `ValueError` if the list is
|
||||
/// empty or any position is not exactly 3 coordinates. GIL released.
|
||||
fn encode(&self, py: Python<'_>, ap_positions: Vec<Vec<f32>>) -> PyResult<Vec<f32>> {
|
||||
if ap_positions.is_empty() {
|
||||
return Err(PyValueError::new_err(
|
||||
"ap_positions must contain at least one [x, y, z] position",
|
||||
));
|
||||
}
|
||||
let mut coords: Vec<[f32; 3]> = Vec::with_capacity(ap_positions.len());
|
||||
for (i, p) in ap_positions.iter().enumerate() {
|
||||
if p.len() != 3 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"ap_positions[{i}] must have exactly 3 coordinates, got {}",
|
||||
p.len()
|
||||
)));
|
||||
}
|
||||
coords.push([p[0], p[1], p[2]]);
|
||||
}
|
||||
Ok(py.allow_threads(|| self.inner.encode(&coords)))
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn geometry_dim(&self) -> usize {
|
||||
self.geometry_dim
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("GeometryEncoder(geometry_dim={})", self.geometry_dim)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── RapidAdaptation / AdaptationResult ──────────────────────────────
|
||||
|
||||
/// Result of `RapidAdaptation.adapt()`.
|
||||
#[pyclass(frozen, name = "AdaptationResult")]
|
||||
pub struct PyAdaptationResult {
|
||||
inner: AdaptationResult,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyAdaptationResult {
|
||||
#[getter]
|
||||
fn lora_weights(&self) -> Vec<f32> {
|
||||
self.inner.lora_weights.clone()
|
||||
}
|
||||
#[getter]
|
||||
fn final_loss(&self) -> f32 {
|
||||
self.inner.final_loss
|
||||
}
|
||||
#[getter]
|
||||
fn frames_used(&self) -> usize {
|
||||
self.inner.frames_used
|
||||
}
|
||||
#[getter]
|
||||
fn adaptation_epochs(&self) -> usize {
|
||||
self.inner.adaptation_epochs
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"AdaptationResult(final_loss={:.6}, frames_used={}, adaptation_epochs={})",
|
||||
self.inner.final_loss, self.inner.frames_used, self.inner.adaptation_epochs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Few-shot test-time adaptation: accumulate unlabeled CSI frames, then
|
||||
/// `adapt()` to produce LoRA weight deltas that minimize a self-supervised
|
||||
/// proxy loss.
|
||||
///
|
||||
/// Scope caveat (from the Rust module, kept honest): this minimizes a
|
||||
/// self-supervised proxy over a tiny LoRA bottleneck; it is NOT wired to
|
||||
/// the pose model and there is no measured end-to-end PCK gain from this
|
||||
/// path — do not cite a PCK improvement from `adapt()`.
|
||||
#[pyclass(name = "RapidAdaptation")]
|
||||
pub struct PyRapidAdaptation {
|
||||
inner: RapidAdaptation,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyRapidAdaptation {
|
||||
/// Build an adaptation engine. `loss_kind` is one of
|
||||
/// `"contrastive"`, `"entropy"`, `"combined"` (default). `lambda_ent`
|
||||
/// is used only by `"combined"`.
|
||||
#[new]
|
||||
#[pyo3(signature = (
|
||||
min_calibration_frames,
|
||||
lora_rank,
|
||||
loss_kind="combined",
|
||||
epochs=5,
|
||||
lr=0.001,
|
||||
lambda_ent=0.5
|
||||
))]
|
||||
fn new(
|
||||
min_calibration_frames: usize,
|
||||
lora_rank: usize,
|
||||
loss_kind: &str,
|
||||
epochs: usize,
|
||||
lr: f32,
|
||||
lambda_ent: f32,
|
||||
) -> PyResult<Self> {
|
||||
let loss = match loss_kind {
|
||||
"contrastive" => AdaptationLoss::ContrastiveTTT { epochs, lr },
|
||||
"entropy" => AdaptationLoss::EntropyMin { epochs, lr },
|
||||
"combined" => AdaptationLoss::Combined {
|
||||
epochs,
|
||||
lr,
|
||||
lambda_ent,
|
||||
},
|
||||
other => {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"unknown loss_kind '{other}'; expected 'contrastive', 'entropy', or 'combined'"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
inner: RapidAdaptation::new(min_calibration_frames, lora_rank, loss),
|
||||
})
|
||||
}
|
||||
|
||||
/// Push a single unlabeled CSI frame into the calibration buffer.
|
||||
fn push_frame(&mut self, frame: Vec<f32>) {
|
||||
self.inner.push_frame(&frame);
|
||||
}
|
||||
|
||||
/// True once at least `min_calibration_frames` have been buffered.
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn buffer_len(&self) -> usize {
|
||||
self.inner.buffer_len()
|
||||
}
|
||||
|
||||
/// Run test-time adaptation over the buffered frames. Raises
|
||||
/// `ValueError` if the buffer is empty or `lora_rank == 0`. GIL
|
||||
/// released during the finite-difference optimization.
|
||||
fn adapt(&self, py: Python<'_>) -> PyResult<PyAdaptationResult> {
|
||||
py.allow_threads(|| self.inner.adapt())
|
||||
.map(|inner| PyAdaptationResult { inner })
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("RapidAdaptation(buffered={})", self.inner.buffer_len())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CrossDomainEvaluator ────────────────────────────────────────────
|
||||
|
||||
/// Cross-domain pose-accuracy evaluator (MPJPE + domain-gap ratio).
|
||||
#[pyclass(name = "CrossDomainEvaluator")]
|
||||
pub struct PyCrossDomainEvaluator {
|
||||
inner: CrossDomainEvaluator,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCrossDomainEvaluator {
|
||||
/// Create an evaluator for `n_joints` (e.g. 17 for COCO).
|
||||
#[new]
|
||||
fn new(n_joints: usize) -> Self {
|
||||
Self {
|
||||
inner: CrossDomainEvaluator::new(n_joints),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate `predictions` (a list of `(pred, gt)` flat `n_joints*3`
|
||||
/// vectors) grouped by `domain_labels` (0 = in-domain). Returns a
|
||||
/// dict of the six cross-domain metrics. Raises `ValueError` on a
|
||||
/// length mismatch. GIL released.
|
||||
fn evaluate(
|
||||
&self,
|
||||
py: Python<'_>,
|
||||
predictions: Vec<(Vec<f32>, Vec<f32>)>,
|
||||
domain_labels: Vec<u32>,
|
||||
) -> PyResult<HashMap<String, f32>> {
|
||||
if predictions.len() != domain_labels.len() {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"predictions ({}) and domain_labels ({}) must have equal length",
|
||||
predictions.len(),
|
||||
domain_labels.len()
|
||||
)));
|
||||
}
|
||||
let m = py.allow_threads(|| self.inner.evaluate(&predictions, &domain_labels));
|
||||
let mut out = HashMap::with_capacity(6);
|
||||
out.insert("in_domain_mpjpe".to_string(), m.in_domain_mpjpe);
|
||||
out.insert("cross_domain_mpjpe".to_string(), m.cross_domain_mpjpe);
|
||||
out.insert("few_shot_mpjpe".to_string(), m.few_shot_mpjpe);
|
||||
out.insert("cross_hardware_mpjpe".to_string(), m.cross_hardware_mpjpe);
|
||||
out.insert("domain_gap_ratio".to_string(), m.domain_gap_ratio);
|
||||
out.insert("adaptation_speedup".to_string(), m.adaptation_speedup);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
"CrossDomainEvaluator()".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mean Per Joint Position Error between flat `[n_joints*3]` pose vectors.
|
||||
#[pyfunction]
|
||||
fn mpjpe(pred: Vec<f32>, gt: Vec<f32>, n_joints: usize) -> f32 {
|
||||
rust_mpjpe(&pred, >, n_joints)
|
||||
}
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyHardwareType>()?;
|
||||
m.add_class::<PyCanonicalCsiFrame>()?;
|
||||
m.add_class::<PyHardwareNormalizer>()?;
|
||||
m.add_class::<PyMeridianGeometryConfig>()?;
|
||||
m.add_class::<PyGeometryEncoder>()?;
|
||||
m.add_class::<PyAdaptationResult>()?;
|
||||
m.add_class::<PyRapidAdaptation>()?;
|
||||
m.add_class::<PyCrossDomainEvaluator>()?;
|
||||
m.add_function(wrap_pyfunction!(mpjpe, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -17,7 +17,13 @@
|
||||
use pyo3::prelude::*;
|
||||
|
||||
mod bindings {
|
||||
#[cfg(feature = "aether")]
|
||||
pub mod aether;
|
||||
pub mod bfld;
|
||||
#[cfg(feature = "mat")]
|
||||
pub mod mat;
|
||||
#[cfg(feature = "meridian")]
|
||||
pub mod meridian;
|
||||
pub mod keypoint;
|
||||
pub mod pose;
|
||||
pub mod privacy_gate;
|
||||
@@ -43,6 +49,12 @@ fn build_features() -> Vec<&'static str> {
|
||||
feats.push("p2-pose-bindings"); // BoundingBox + PersonPose + PoseEstimate
|
||||
feats.push("p3-vitals-bindings"); // BreathingExtractor + HeartRateExtractor + VitalEstimate
|
||||
feats.push("p3.5-bfld-bindings"); // BfldFrame + BfldReport + BfldKind (stub Rust)
|
||||
#[cfg(feature = "aether")]
|
||||
feats.push("p6-aether-bindings"); // ADR-185 P1 — AETHER contrastive embeddings
|
||||
#[cfg(feature = "meridian")]
|
||||
feats.push("p6-meridian-bindings"); // ADR-185 P2 — MERIDIAN domain generalization
|
||||
#[cfg(feature = "mat")]
|
||||
feats.push("p6-mat-bindings"); // ADR-185 P3 — MAT disaster survivor detection
|
||||
feats
|
||||
}
|
||||
|
||||
@@ -85,5 +97,23 @@ fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
// the published `wifi-densepose-bfld 0.3.0` crate, not the Python port).
|
||||
// Closes ADR-125 §2.1.d at the binding boundary.
|
||||
bindings::privacy_gate::register(m)?;
|
||||
|
||||
// ADR-185 P1 — AETHER contrastive CSI embedding bindings, compiled
|
||||
// and registered only under the `aether` feature so the default
|
||||
// wheel links none of the sensing-server dependency tree.
|
||||
#[cfg(feature = "aether")]
|
||||
bindings::aether::register(m)?;
|
||||
|
||||
// ADR-185 P2 — MERIDIAN cross-environment domain-generalization
|
||||
// bindings (hardware normalization, geometry encoding, rapid
|
||||
// adaptation, cross-domain eval). Gated behind `meridian`; tch-free.
|
||||
#[cfg(feature = "meridian")]
|
||||
bindings::meridian::register(m)?;
|
||||
|
||||
// ADR-185 P3 — MAT disaster-survivor detection + START triage. Gated
|
||||
// behind `mat`, mirroring the upstream disaster/ML stack gating.
|
||||
#[cfg(feature = "mat")]
|
||||
bindings::mat::register(m)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! ADR-185 §4.1 — AETHER parity: native-Rust reference half.
|
||||
//!
|
||||
//! Produces the golden 128-dim embedding by calling the canonical
|
||||
//! `wifi-densepose-aether::embedding` code DIRECTLY (no PyO3), for the
|
||||
//! committed `tests/golden/aether_input.json` fixture, and compares it to the
|
||||
//! committed golden VECTOR `tests/golden/aether_embedding.json` within a
|
||||
//! numerical tolerance.
|
||||
//!
|
||||
//! Why a vector + tolerance and not a SHA-256 of the f32 bytes: the embedding
|
||||
//! is pure f32 and uses transcendental ops (ln/sqrt/cos), which are not
|
||||
//! bit-reproducible across CPU architectures or libm implementations. A byte
|
||||
//! hash only ever matched the one arch that generated it and failed on every
|
||||
//! other wheel this project builds (aarch64, macOS-arm). The pytest half
|
||||
//! (`tests/test_aether.py`) compares the Python binding to the SAME golden
|
||||
//! within the same tolerance — native≈golden and binding≈golden together prove
|
||||
//! binding≈native, portably.
|
||||
//!
|
||||
//! Regeneration (only when the Rust subsystem intentionally changes): delete
|
||||
//! `tests/golden/aether_embedding.json` and re-run `cargo test --features aether`.
|
||||
#![cfg(feature = "aether")]
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use wifi_densepose_aether::embedding::{EmbeddingConfig, EmbeddingExtractor};
|
||||
use wifi_densepose_aether::graph_transformer::TransformerConfig;
|
||||
|
||||
/// Cross-architecture f32 parity tolerance; see the module docs and the
|
||||
/// matching `PARITY_ATOL`/`PARITY_RTOL` in `tests/test_aether.py`.
|
||||
const PARITY_ATOL: f32 = 1e-4;
|
||||
const PARITY_RTOL: f32 = 1e-4;
|
||||
|
||||
/// Assert `embedding` matches the committed golden vector `<name>` within
|
||||
/// tolerance, or (if the golden is absent) write it and fail asking for a re-run.
|
||||
fn assert_matches_golden_vector(embedding: &[f32], name: &str) {
|
||||
let path = golden_dir().join(name);
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(raw) => {
|
||||
let golden: Vec<f32> = serde_json::from_str(&raw)
|
||||
.expect("parse golden vector json");
|
||||
assert_eq!(embedding.len(), golden.len(), "{name}: length mismatch");
|
||||
for (i, (&got, &want)) in embedding.iter().zip(&golden).enumerate() {
|
||||
let tol = PARITY_ATOL + PARITY_RTOL * want.abs();
|
||||
assert!(
|
||||
(got - want).abs() <= tol,
|
||||
"{name}: element {i} diverged beyond tolerance \
|
||||
(got {got}, golden {want}, |Δ|={}) — a real regression, \
|
||||
not cross-arch f32 drift",
|
||||
(got - want).abs()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let json = serde_json::to_string(&embedding).expect("serialize golden");
|
||||
fs::write(&path, &json).expect("write golden vector");
|
||||
panic!("no committed golden {name}; wrote it. Re-run to verify parity.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn golden_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("golden")
|
||||
}
|
||||
|
||||
fn load_input() -> Vec<Vec<f32>> {
|
||||
let raw = fs::read_to_string(golden_dir().join("aether_input.json"))
|
||||
.expect("read aether_input.json fixture");
|
||||
let rows: Vec<Vec<f64>> = serde_json::from_str(&raw).expect("parse aether_input.json");
|
||||
rows.into_iter()
|
||||
.map(|row| row.into_iter().map(|x| x as f32).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the extractor identically to the Python binding's default
|
||||
/// construction: `AetherConfig()` + `EmbeddingExtractor(n_subcarriers=56, cfg)`.
|
||||
fn embed_native(input: &[Vec<f32>]) -> Vec<f32> {
|
||||
let e_config = EmbeddingConfig {
|
||||
d_model: 64,
|
||||
d_proj: 128,
|
||||
temperature: 0.07,
|
||||
normalize: true,
|
||||
};
|
||||
let t_config = TransformerConfig {
|
||||
n_subcarriers: 56,
|
||||
n_keypoints: 17,
|
||||
d_model: 64,
|
||||
n_heads: 4,
|
||||
n_gnn_layers: 2,
|
||||
};
|
||||
let mut ext = EmbeddingExtractor::new(t_config, e_config);
|
||||
ext.extract(input)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_embedding_is_128_dim_unit_norm() {
|
||||
let emb = embed_native(&load_input());
|
||||
assert_eq!(emb.len(), 128, "AETHER embedding must be 128-dim");
|
||||
let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!(
|
||||
(norm - 1.0).abs() < 1e-4,
|
||||
"embedding must be L2-normalized, got norm={norm}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_embedding_matches_committed_golden() {
|
||||
let emb = embed_native(&load_input());
|
||||
assert_matches_golden_vector(&emb, "aether_embedding.json");
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! ADR-185 §13.a — weight-loading parity: native-Rust reference half.
|
||||
//!
|
||||
//! Proves the AETHER `load_weights` path produces a deterministic, non-random
|
||||
//! embedding, and compares it to the committed golden VECTOR
|
||||
//! `tests/golden/aether_loaded_embedding.json` within tolerance. The pytest
|
||||
//! half (`tests/test_aether.py`) writes a byte-identical weight file (same
|
||||
//! formula + format) through the binding's `load_weights` and compares to the
|
||||
//! SAME golden within the same tolerance — native≈golden and binding≈golden
|
||||
//! prove the binding's weight-loading matches native, portably across arch.
|
||||
//! (See `aether_parity.rs` for why this is a tolerance compare, not a hash.)
|
||||
//!
|
||||
//! Weight formula (shared with the pytest half): `w[i] = k/65536 - 0.5` where
|
||||
//! `k = (i*1103515245 + 12345) mod 65536`. `k/65536` is a multiple of 2⁻¹⁶,
|
||||
//! exactly representable in both f32 and f64, so both languages produce
|
||||
//! byte-identical weights.
|
||||
//!
|
||||
//! File format: 8-byte magic `AETHERW1`, `u32` little-endian param count, then
|
||||
//! that many little-endian `f32`.
|
||||
//!
|
||||
//! Regenerate (only on an intentional change): delete the .json golden and
|
||||
//! re-run `cargo test --features aether --test aether_weights_parity`.
|
||||
#![cfg(feature = "aether")]
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use wifi_densepose_aether::embedding::{EmbeddingConfig, EmbeddingExtractor};
|
||||
use wifi_densepose_aether::graph_transformer::TransformerConfig;
|
||||
|
||||
fn golden_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("golden")
|
||||
}
|
||||
|
||||
fn load_input() -> Vec<Vec<f32>> {
|
||||
let raw = fs::read_to_string(golden_dir().join("aether_input.json"))
|
||||
.expect("read aether_input.json fixture");
|
||||
let rows: Vec<Vec<f64>> = serde_json::from_str(&raw).expect("parse aether_input.json");
|
||||
rows.into_iter()
|
||||
.map(|row| row.into_iter().map(|x| x as f32).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Same default construction as `aether_parity.rs` / the Python binding default.
|
||||
fn new_extractor() -> EmbeddingExtractor {
|
||||
let e_config = EmbeddingConfig {
|
||||
d_model: 64,
|
||||
d_proj: 128,
|
||||
temperature: 0.07,
|
||||
normalize: true,
|
||||
};
|
||||
let t_config = TransformerConfig {
|
||||
n_subcarriers: 56,
|
||||
n_keypoints: 17,
|
||||
d_model: 64,
|
||||
n_heads: 4,
|
||||
n_gnn_layers: 2,
|
||||
};
|
||||
EmbeddingExtractor::new(t_config, e_config)
|
||||
}
|
||||
|
||||
fn formula_weights(n: usize) -> Vec<f32> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let k = (i as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345) % 65_536;
|
||||
k as f32 / 65_536.0 - 0.5
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_weight_file(path: &PathBuf, weights: &[f32]) {
|
||||
let mut buf = Vec::with_capacity(12 + weights.len() * 4);
|
||||
buf.extend_from_slice(b"AETHERW1");
|
||||
buf.extend_from_slice(&(weights.len() as u32).to_le_bytes());
|
||||
for v in weights {
|
||||
buf.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
fs::write(path, buf).unwrap();
|
||||
}
|
||||
|
||||
/// Cross-architecture f32 parity tolerance; see `aether_parity.rs` and the
|
||||
/// matching constants in `tests/test_aether.py` for why this is a tolerance
|
||||
/// compare and not a byte hash.
|
||||
const PARITY_ATOL: f32 = 1e-4;
|
||||
const PARITY_RTOL: f32 = 1e-4;
|
||||
|
||||
fn assert_matches_golden_vector(embedding: &[f32], name: &str) {
|
||||
let path = golden_dir().join(name);
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(raw) => {
|
||||
let golden: Vec<f32> = serde_json::from_str(&raw).expect("parse golden vector json");
|
||||
assert_eq!(embedding.len(), golden.len(), "{name}: length mismatch");
|
||||
for (i, (&got, &want)) in embedding.iter().zip(&golden).enumerate() {
|
||||
let tol = PARITY_ATOL + PARITY_RTOL * want.abs();
|
||||
assert!(
|
||||
(got - want).abs() <= tol,
|
||||
"{name}: element {i} diverged beyond tolerance \
|
||||
(got {got}, golden {want}, |Δ|={}) — real regression, not arch drift",
|
||||
(got - want).abs()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let json = serde_json::to_string(&embedding).expect("serialize golden");
|
||||
fs::write(&path, &json).expect("write golden vector");
|
||||
panic!("no committed golden {name}; wrote it. Re-run to verify parity.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_loaded_embedding_matches_committed_golden() {
|
||||
let input = load_input();
|
||||
|
||||
let mut ext = new_extractor();
|
||||
let baseline = ext.extract(&input); // random Xavier init
|
||||
|
||||
let weights = formula_weights(ext.param_count());
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"aether_weights_parity_{}.bin",
|
||||
std::process::id()
|
||||
));
|
||||
write_weight_file(&path, &weights);
|
||||
ext.load_weights(&path).expect("load_weights");
|
||||
fs::remove_file(&path).ok();
|
||||
|
||||
let loaded = ext.extract(&input);
|
||||
// Loaded weights must actually take effect.
|
||||
assert!(
|
||||
baseline.iter().zip(&loaded).any(|(a, b)| (a - b).abs() > 1e-6),
|
||||
"load_weights had no effect vs the random-init baseline"
|
||||
);
|
||||
assert_eq!(loaded.len(), 128);
|
||||
|
||||
assert_matches_golden_vector(&loaded, "aether_loaded_embedding.json");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[-0.09882868826389313, 0.08015579730272293, 0.019888414070010185, -0.003239249112084508, -0.07265102863311768, -0.08036628365516663, -0.10324385017156601, -0.21274414658546448, 0.1503465175628662, -0.005489329807460308, 0.10834519565105438, 0.05838076397776604, -0.05911992862820625, 0.13135841488838196, -0.006811900530010462, -0.13742603361606598, -0.015311408787965775, -0.21133442223072052, 0.05134191736578941, -0.027355113998055458, -0.044147003442049026, -0.006108833476901054, -0.033326443284749985, 0.15741342306137085, 0.029073286801576614, 0.0375739224255085, 0.023920813575387, 0.07043211907148361, -0.009550352580845356, 0.028179991990327835, 0.05900105461478233, 0.056598298251628876, -0.0047074914909899235, 0.05960564315319061, 0.049969129264354706, 0.017297586426138878, -0.10153798758983612, -0.002574362326413393, 0.06392877548933029, 0.14119082689285278, -0.04484020546078682, -0.038461778312921524, -0.06095990538597107, -0.04703143611550331, 0.07692500203847885, -0.10256559401750565, -0.07250502705574036, 0.12476003915071487, -0.08511383831501007, -0.006181457545608282, 0.09957090020179749, 0.10756388306617737, -0.08597669750452042, -0.09914427250623703, -0.01648416928946972, -0.25724929571151733, -0.024403633549809456, 0.05453304573893547, -0.03141803294420242, -0.07852566242218018, 0.020048094913363457, -0.06215068697929382, 0.11997628211975098, 0.0977955237030983, -0.0652041882276535, -0.007863834500312805, -0.059089504182338715, 0.12663882970809937, 0.16099494695663452, -0.046197254210710526, 0.04186059534549713, -0.07077737897634506, -0.28093546628952026, 0.017284320667386055, 0.1626843810081482, 0.006352100986987352, -0.07779540121555328, -0.004958702251315117, 0.04892482981085777, 0.013853654265403748, 0.08351490646600723, 0.06772761791944504, -0.028758108615875244, 0.04778963327407837, -0.1131502315402031, 0.005114563275128603, -0.012307023629546165, 0.03301718831062317, 0.09168995916843414, -0.04085332900285721, 0.04984535649418831, -0.05280173197388649, -0.01752082072198391, 0.04126245900988579, -0.09206362813711166, 0.08685162663459778, 0.03651193156838417, -0.144779771566391, -0.03290265053510666, 0.15378770232200623, -0.08842422068119049, 0.12492084503173828, 0.04907738417387009, -0.03483045473694801, -0.09838201850652695, 0.04590783640742302, -0.01383654773235321, 0.03766492009162903, -0.03254647180438042, 0.12435581535100937, 0.06573979556560516, 0.055382076650857925, -0.19347889721393585, 0.0018683894304558635, 0.11095203459262848, 0.04290277138352394, -0.07855042070150375, 0.03853689134120941, -0.06062287464737892, 0.004584986716508865, -0.1223185583949089, 0.0031570768915116787, -0.10847429186105728, 0.0023399614728987217, -0.054206088185310364, -0.1367102563381195, -0.14624592661857605, 0.16953621804714203]
|
||||
@@ -0,0 +1 @@
|
||||
[[0.0, 0.00390625, 0.0078125, 0.01171875, 0.015625, 0.01953125, 0.0234375, 0.02734375, 0.03125, 0.03515625, 0.0390625, 0.04296875, 0.046875, 0.05078125, 0.0546875, 0.05859375, 0.0625, 0.06640625, 0.0703125, 0.07421875, 0.078125, 0.08203125, 0.0859375, 0.08984375, 0.09375, 0.09765625, 0.1015625, 0.10546875, 0.109375, 0.11328125, 0.1171875, 0.12109375, 0.125, 0.12890625, 0.1328125, 0.13671875, 0.140625, 0.14453125, 0.1484375, 0.15234375, 0.15625, 0.16015625, 0.1640625, 0.16796875, 0.171875, 0.17578125, 0.1796875, 0.18359375, 0.1875, 0.19140625, 0.1953125, 0.19921875, 0.203125, 0.20703125, 0.2109375, 0.21484375], [0.21875, 0.22265625, 0.2265625, 0.23046875, 0.234375, 0.23828125, 0.2421875, 0.24609375, 0.25, 0.25390625, 0.2578125, 0.26171875, 0.265625, 0.26953125, 0.2734375, 0.27734375, 0.28125, 0.28515625, 0.2890625, 0.29296875, 0.296875, 0.30078125, 0.3046875, 0.30859375, 0.3125, 0.31640625, 0.3203125, 0.32421875, 0.328125, 0.33203125, 0.3359375, 0.33984375, 0.34375, 0.34765625, 0.3515625, 0.35546875, 0.359375, 0.36328125, 0.3671875, 0.37109375, 0.375, 0.37890625, 0.3828125, 0.38671875, 0.390625, 0.39453125, 0.3984375, 0.40234375, 0.40625, 0.41015625, 0.4140625, 0.41796875, 0.421875, 0.42578125, 0.4296875, 0.43359375], [0.4375, 0.44140625, 0.4453125, 0.44921875, 0.453125, 0.45703125, 0.4609375, 0.46484375, 0.46875, 0.47265625, 0.4765625, 0.48046875, 0.484375, 0.48828125, 0.4921875, 0.49609375, 0.5, 0.50390625, 0.5078125, 0.51171875, 0.515625, 0.51953125, 0.5234375, 0.52734375, 0.53125, 0.53515625, 0.5390625, 0.54296875, 0.546875, 0.55078125, 0.5546875, 0.55859375, 0.5625, 0.56640625, 0.5703125, 0.57421875, 0.578125, 0.58203125, 0.5859375, 0.58984375, 0.59375, 0.59765625, 0.6015625, 0.60546875, 0.609375, 0.61328125, 0.6171875, 0.62109375, 0.625, 0.62890625, 0.6328125, 0.63671875, 0.640625, 0.64453125, 0.6484375, 0.65234375], [0.65625, 0.66015625, 0.6640625, 0.66796875, 0.671875, 0.67578125, 0.6796875, 0.68359375, 0.6875, 0.69140625, 0.6953125, 0.69921875, 0.703125, 0.70703125, 0.7109375, 0.71484375, 0.71875, 0.72265625, 0.7265625, 0.73046875, 0.734375, 0.73828125, 0.7421875, 0.74609375, 0.75, 0.75390625, 0.7578125, 0.76171875, 0.765625, 0.76953125, 0.7734375, 0.77734375, 0.78125, 0.78515625, 0.7890625, 0.79296875, 0.796875, 0.80078125, 0.8046875, 0.80859375, 0.8125, 0.81640625, 0.8203125, 0.82421875, 0.828125, 0.83203125, 0.8359375, 0.83984375, 0.84375, 0.84765625, 0.8515625, 0.85546875, 0.859375, 0.86328125, 0.8671875, 0.87109375], [0.875, 0.87890625, 0.8828125, 0.88671875, 0.890625, 0.89453125, 0.8984375, 0.90234375, 0.90625, 0.91015625, 0.9140625, 0.91796875, 0.921875, 0.92578125, 0.9296875, 0.93359375, 0.9375, 0.94140625, 0.9453125, 0.94921875, 0.953125, 0.95703125, 0.9609375, 0.96484375, 0.96875, 0.97265625, 0.9765625, 0.98046875, 0.984375, 0.98828125, 0.9921875, 0.99609375, 0.0, 0.00390625, 0.0078125, 0.01171875, 0.015625, 0.01953125, 0.0234375, 0.02734375, 0.03125, 0.03515625, 0.0390625, 0.04296875, 0.046875, 0.05078125, 0.0546875, 0.05859375, 0.0625, 0.06640625, 0.0703125, 0.07421875, 0.078125, 0.08203125, 0.0859375, 0.08984375], [0.09375, 0.09765625, 0.1015625, 0.10546875, 0.109375, 0.11328125, 0.1171875, 0.12109375, 0.125, 0.12890625, 0.1328125, 0.13671875, 0.140625, 0.14453125, 0.1484375, 0.15234375, 0.15625, 0.16015625, 0.1640625, 0.16796875, 0.171875, 0.17578125, 0.1796875, 0.18359375, 0.1875, 0.19140625, 0.1953125, 0.19921875, 0.203125, 0.20703125, 0.2109375, 0.21484375, 0.21875, 0.22265625, 0.2265625, 0.23046875, 0.234375, 0.23828125, 0.2421875, 0.24609375, 0.25, 0.25390625, 0.2578125, 0.26171875, 0.265625, 0.26953125, 0.2734375, 0.27734375, 0.28125, 0.28515625, 0.2890625, 0.29296875, 0.296875, 0.30078125, 0.3046875, 0.30859375], [0.3125, 0.31640625, 0.3203125, 0.32421875, 0.328125, 0.33203125, 0.3359375, 0.33984375, 0.34375, 0.34765625, 0.3515625, 0.35546875, 0.359375, 0.36328125, 0.3671875, 0.37109375, 0.375, 0.37890625, 0.3828125, 0.38671875, 0.390625, 0.39453125, 0.3984375, 0.40234375, 0.40625, 0.41015625, 0.4140625, 0.41796875, 0.421875, 0.42578125, 0.4296875, 0.43359375, 0.4375, 0.44140625, 0.4453125, 0.44921875, 0.453125, 0.45703125, 0.4609375, 0.46484375, 0.46875, 0.47265625, 0.4765625, 0.48046875, 0.484375, 0.48828125, 0.4921875, 0.49609375, 0.5, 0.50390625, 0.5078125, 0.51171875, 0.515625, 0.51953125, 0.5234375, 0.52734375], [0.53125, 0.53515625, 0.5390625, 0.54296875, 0.546875, 0.55078125, 0.5546875, 0.55859375, 0.5625, 0.56640625, 0.5703125, 0.57421875, 0.578125, 0.58203125, 0.5859375, 0.58984375, 0.59375, 0.59765625, 0.6015625, 0.60546875, 0.609375, 0.61328125, 0.6171875, 0.62109375, 0.625, 0.62890625, 0.6328125, 0.63671875, 0.640625, 0.64453125, 0.6484375, 0.65234375, 0.65625, 0.66015625, 0.6640625, 0.66796875, 0.671875, 0.67578125, 0.6796875, 0.68359375, 0.6875, 0.69140625, 0.6953125, 0.69921875, 0.703125, 0.70703125, 0.7109375, 0.71484375, 0.71875, 0.72265625, 0.7265625, 0.73046875, 0.734375, 0.73828125, 0.7421875, 0.74609375]]
|
||||
@@ -0,0 +1 @@
|
||||
[0.10761479288339615, 0.05284854769706726, -0.22418244183063507, -0.015137949027121067, -0.03409634903073311, 0.17326673865318298, 0.11726372689008713, -0.07647006958723068, 0.034259773790836334, -0.11302468180656433, 0.05575002729892731, -0.000968325708527118, 0.12398994714021683, 0.010035112500190735, -0.024740785360336304, 0.03396096080541611, -0.13298068940639496, -0.026409678161144257, -0.00981970690190792, 0.1098528727889061, -0.015091875568032265, -0.06820717453956604, -0.08815668523311615, -0.13947811722755432, 0.12845416367053986, 0.007558434270322323, 0.09278517216444016, 0.023929834365844727, -0.15709640085697174, 0.20790696144104004, -0.0814460963010788, 0.035465411841869354, 0.0621788427233696, -0.022502727806568146, 0.1301409900188446, -0.09830718487501144, -0.04854566603899002, -0.060892220586538315, -0.0039014117792248726, 0.08287017792463303, 0.0968087762594223, -0.05596396327018738, -0.18289180099964142, 0.07555137574672699, -0.0711054727435112, 0.017438538372516632, -0.07017677277326584, 0.08828858286142349, 0.09126422554254532, -0.18576686084270477, -0.08681167662143707, 0.006826931145042181, 0.13380175828933716, 0.15818704664707184, -0.03554683178663254, -0.02037874236702919, -0.0721014142036438, 0.09667330235242844, 0.03744731843471527, -0.019951190799474716, 0.05095838010311127, 0.0136749017983675, -0.04109140485525131, -0.09205742925405502, -0.06173047423362732, 0.028595957905054092, 0.07932677119970322, 0.025831375271081924, -0.029791485518217087, -0.047233421355485916, -0.0985548198223114, 0.056721169501543045, 0.04597408324480057, 0.05116378515958786, 0.06485309451818466, -0.11868073791265488, 0.1164744570851326, -0.040522847324609756, -0.12034964561462402, 0.10310209542512894, 0.01842050999403, 0.16855661571025848, -0.05738396197557449, -0.17958901822566986, -0.022476589307188988, 0.03451419249176979, 0.034107644110918045, 0.13773204386234283, -0.07001013308763504, -0.14196854829788208, 0.11647462099790573, -0.03268979489803314, 0.058361802250146866, -0.029253516346216202, 0.1251915991306305, 0.13218750059604645, -0.14484354853630066, -0.1424856185913086, 0.045242637395858765, 0.039408858865499496, 0.199110209941864, 0.0028688418678939342, -0.14990390837192535, -0.031178129836916924, -0.000643149483948946, 0.0783705934882164, 0.02097206376492977, -0.058810342103242874, 0.05459814518690109, -0.00016812187095638365, -0.1195453405380249, -0.06815463304519653, 0.03833199664950371, 0.12025003135204315, 0.06424705684185028, 0.011131756007671356, -0.006310137454420328, -0.06013919785618782, 0.061071064323186874, 0.018219128251075745, 0.08957944065332413, -0.04298160970211029, -0.07775749266147614, -0.01905573531985283, -0.002107167150825262, -0.0794263556599617, -0.005148472264409065, 0.05683618783950806]
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
ae46351e28c01161c3c20f1a3134d8e32fbbef0cda2fdb9c3a27984da0ce026d
|
||||
@@ -0,0 +1 @@
|
||||
{"esp32_amplitude": [0.51171875, 0.5390625, 0.56640625, 0.59375, 0.62109375, 0.6484375, 0.67578125, 0.703125, 0.73046875, 0.7578125, 0.78515625, 0.8125, 0.83984375, 0.8671875, 0.89453125, 0.921875, 0.94921875, 0.9765625, 1.00390625, 1.03125, 1.05859375, 1.0859375, 1.11328125, 1.140625, 1.16796875, 1.1953125, 1.22265625, 1.25, 1.27734375, 1.3046875, 1.33203125, 1.359375, 1.38671875, 1.4140625, 1.44140625, 1.46875, 1.49609375, 0.5234375, 0.55078125, 0.578125, 0.60546875, 0.6328125, 0.66015625, 0.6875, 0.71484375, 0.7421875, 0.76953125, 0.796875, 0.82421875, 0.8515625, 0.87890625, 0.90625, 0.93359375, 0.9609375, 0.98828125, 1.015625, 1.04296875, 1.0703125, 1.09765625, 1.125, 1.15234375, 1.1796875, 1.20703125, 1.234375], "esp32_phase": [-0.45703125, -0.4375, -0.41796875, -0.3984375, -0.37890625, -0.359375, -0.33984375, -0.3203125, -0.30078125, -0.28125, -0.26171875, -0.2421875, -0.22265625, -0.203125, -0.18359375, -0.1640625, -0.14453125, -0.125, -0.10546875, -0.0859375, -0.06640625, -0.046875, -0.02734375, -0.0078125, 0.01171875, 0.03125, 0.05078125, 0.0703125, 0.08984375, 0.109375, 0.12890625, 0.1484375, 0.16796875, 0.1875, 0.20703125, 0.2265625, 0.24609375, 0.265625, 0.28515625, 0.3046875, 0.32421875, 0.34375, 0.36328125, 0.3828125, 0.40234375, 0.421875, 0.44140625, 0.4609375, 0.48046875, -0.5, -0.48046875, -0.4609375, -0.44140625, -0.421875, -0.40234375, -0.3828125, -0.36328125, -0.34375, -0.32421875, -0.3046875, -0.28515625, -0.265625, -0.24609375, -0.2265625], "intel_amplitude": [0.50390625, 0.546875, 0.58984375, 0.6328125, 0.67578125, 0.71875, 0.76171875, 0.8046875, 0.84765625, 0.890625, 0.93359375, 0.9765625, 1.01953125, 1.0625, 1.10546875, 1.1484375, 1.19140625, 1.234375, 1.27734375, 1.3203125, 1.36328125, 1.40625, 1.44921875, 1.4921875, 0.53515625, 0.578125, 0.62109375, 0.6640625, 0.70703125, 0.75], "intel_phase": [-0.47265625, -0.4609375, -0.44921875, -0.4375, -0.42578125, -0.4140625, -0.40234375, -0.390625, -0.37890625, -0.3671875, -0.35546875, -0.34375, -0.33203125, -0.3203125, -0.30859375, -0.296875, -0.28515625, -0.2734375, -0.26171875, -0.25, -0.23828125, -0.2265625, -0.21484375, -0.203125, -0.19140625, -0.1796875, -0.16796875, -0.15625, -0.14453125, -0.1328125], "ap_positions": [[0.25, 0.5, 0.75], [1.0, 1.25, 1.5], [2.0, 0.0, -0.5]], "rapid_frames": [[0.0, 0.01953125, 0.0390625, 0.05859375, 0.078125, 0.09765625, 0.1171875, 0.13671875, 0.15625, 0.17578125, 0.1953125, 0.21484375, 0.234375, 0.25390625, 0.2734375, 0.29296875], [0.05078125, 0.0703125, 0.08984375, 0.109375, 0.12890625, 0.1484375, 0.16796875, 0.1875, 0.20703125, 0.2265625, 0.24609375, 0.265625, 0.28515625, 0.3046875, 0.32421875, 0.34375], [0.1015625, 0.12109375, 0.140625, 0.16015625, 0.1796875, 0.19921875, 0.21875, 0.23828125, 0.2578125, 0.27734375, 0.296875, 0.31640625, 0.3359375, 0.35546875, 0.375, 0.39453125], [0.15234375, 0.171875, 0.19140625, 0.2109375, 0.23046875, 0.25, 0.26953125, 0.2890625, 0.30859375, 0.328125, 0.34765625, 0.3671875, 0.38671875, 0.40625, 0.42578125, 0.4453125], [0.203125, 0.22265625, 0.2421875, 0.26171875, 0.28125, 0.30078125, 0.3203125, 0.33984375, 0.359375, 0.37890625, 0.3984375, 0.41796875, 0.4375, 0.45703125, 0.4765625, 0.49609375], [0.25390625, 0.2734375, 0.29296875, 0.3125, 0.33203125, 0.3515625, 0.37109375, 0.390625, 0.41015625, 0.4296875, 0.44921875, 0.46875, 0.48828125, 0.5078125, 0.52734375, 0.546875], [0.3046875, 0.32421875, 0.34375, 0.36328125, 0.3828125, 0.40234375, 0.421875, 0.44140625, 0.4609375, 0.48046875, 0.5, 0.51953125, 0.5390625, 0.55859375, 0.578125, 0.59765625], [0.35546875, 0.375, 0.39453125, 0.4140625, 0.43359375, 0.453125, 0.47265625, 0.4921875, 0.51171875, 0.53125, 0.55078125, 0.5703125, 0.58984375, 0.609375, 0.62890625, 0.6484375], [0.40625, 0.42578125, 0.4453125, 0.46484375, 0.484375, 0.50390625, 0.5234375, 0.54296875, 0.5625, 0.58203125, 0.6015625, 0.62109375, 0.640625, 0.66015625, 0.6796875, 0.69921875], [0.45703125, 0.4765625, 0.49609375, 0.515625, 0.53515625, 0.5546875, 0.57421875, 0.59375, 0.61328125, 0.6328125, 0.65234375, 0.671875, 0.69140625, 0.7109375, 0.73046875, 0.75], [0.5078125, 0.52734375, 0.546875, 0.56640625, 0.5859375, 0.60546875, 0.625, 0.64453125, 0.6640625, 0.68359375, 0.703125, 0.72265625, 0.7421875, 0.76171875, 0.78125, 0.80078125], [0.55859375, 0.578125, 0.59765625, 0.6171875, 0.63671875, 0.65625, 0.67578125, 0.6953125, 0.71484375, 0.734375, 0.75390625, 0.7734375, 0.79296875, 0.8125, 0.83203125, 0.8515625]]}
|
||||
@@ -0,0 +1 @@
|
||||
0486402d5a860f459a319cd779ca44a112d8543442ae9ce9eb7b1a01780aee4b
|
||||
@@ -0,0 +1,126 @@
|
||||
//! ADR-185 §4.1 — MAT bit-for-bit parity: native-Rust reference half.
|
||||
//!
|
||||
//! Drives the canonical `wifi-densepose-mat` `DisasterResponse` pipeline
|
||||
//! DIRECTLY (no PyO3) over the committed `tests/golden/mat_input.json` CSI
|
||||
//! stream and locks the SHA-256 of a canonical result string
|
||||
//! (`count=<K>;triage_priorities=<sorted>`) into
|
||||
//! `tests/golden/mat_result.sha256`.
|
||||
//!
|
||||
//! Only the survivor **count** and **triage classes** are hashed — survivor
|
||||
//! UUIDs and event timestamps are non-deterministic and deliberately
|
||||
//! excluded, so the hash captures exactly the "identical triage +
|
||||
//! survivor count for a fixed CSI stream" invariant of ADR-185 §4.1.
|
||||
//!
|
||||
//! Honesty note: the fixture is a synthetic breathing-modulated stream, so
|
||||
//! this proves the Python binding drives the real pipeline byte-identically
|
||||
//! to native Rust — it is NOT a detection-accuracy claim on real rubble.
|
||||
//!
|
||||
//! Regenerate (only on an intentional Rust change): delete the .sha256 and
|
||||
//! re-run `cargo test --features mat --test mat_parity`.
|
||||
#![cfg(feature = "mat")]
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use wifi_densepose_mat::{DisasterConfig, DisasterResponse, DisasterType, ScanZone, ZoneBounds};
|
||||
|
||||
fn golden_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("golden")
|
||||
}
|
||||
|
||||
fn fixture() -> Value {
|
||||
let raw = fs::read_to_string(golden_dir().join("mat_input.json"))
|
||||
.expect("read mat_input.json fixture");
|
||||
serde_json::from_str(&raw).expect("parse mat_input.json")
|
||||
}
|
||||
|
||||
/// Build the response identically to the Python binding's default
|
||||
/// construction and run one scan over the fixture CSI stream. Returns the
|
||||
/// canonical `count=<K>;triage_priorities=<sorted>` string.
|
||||
fn mat_canonical_result(fx: &Value) -> String {
|
||||
let config = DisasterConfig::builder()
|
||||
.disaster_type(DisasterType::Earthquake)
|
||||
.sensitivity(0.9)
|
||||
.confidence_threshold(0.1)
|
||||
.max_depth(5.0)
|
||||
.continuous_monitoring(false)
|
||||
.build();
|
||||
let mut resp = DisasterResponse::new(config);
|
||||
resp.initialize_event(geo::Point::new(0.0, 0.0), "parity-fixture")
|
||||
.expect("initialize_event");
|
||||
resp.add_zone(ScanZone::new(
|
||||
"Zone A",
|
||||
ZoneBounds::rectangle(0.0, 0.0, 50.0, 30.0),
|
||||
))
|
||||
.expect("add_zone");
|
||||
|
||||
for frame in fx["stream"].as_array().unwrap() {
|
||||
let amp: Vec<f64> = frame["amplitude"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| x.as_f64().unwrap())
|
||||
.collect();
|
||||
let ph: Vec<f64> = frame["phase"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| x.as_f64().unwrap())
|
||||
.collect();
|
||||
resp.push_csi_data(&, &ph).expect("push_csi_data");
|
||||
}
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(resp.start_scanning()).expect("scan");
|
||||
|
||||
let survivors = resp.survivors();
|
||||
let mut priorities: Vec<u8> = survivors
|
||||
.iter()
|
||||
.map(|s| s.triage_status().priority())
|
||||
.collect();
|
||||
priorities.sort_unstable();
|
||||
format!("count={};triage_priorities={:?}", survivors.len(), priorities)
|
||||
}
|
||||
|
||||
fn sha256_hex(s: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(s.as_bytes());
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_mat_result_is_deterministic() {
|
||||
let fx = fixture();
|
||||
// Two independent runs must agree (survivor count + triage classes are
|
||||
// deterministic; UUIDs/timestamps are excluded from the canonical form).
|
||||
assert_eq!(mat_canonical_result(&fx), mat_canonical_result(&fx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_mat_matches_committed_golden() {
|
||||
let canon = mat_canonical_result(&fixture());
|
||||
let got = sha256_hex(&canon);
|
||||
let path = golden_dir().join("mat_result.sha256");
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(expected) => assert_eq!(
|
||||
got,
|
||||
expected.trim(),
|
||||
"native MAT result drifted from committed golden (canonical form: {canon})"
|
||||
),
|
||||
Err(_) => {
|
||||
fs::write(&path, &got).expect("write golden sha256");
|
||||
panic!("no committed golden found; wrote {got} for [{canon}]. Re-run to verify.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! ADR-185 §4.1 — MERIDIAN bit-for-bit parity: native-Rust reference half.
|
||||
//!
|
||||
//! Calls the canonical `wifi-densepose-signal::hardware_norm` +
|
||||
//! `wifi-densepose-train::{geometry,rapid_adapt}` code DIRECTLY (no PyO3)
|
||||
//! on the committed `tests/golden/meridian_input.json` fixture and locks
|
||||
//! the SHA-256 of the concatenated f32 outputs into
|
||||
//! `tests/golden/meridian_output.sha256`.
|
||||
//!
|
||||
//! Concatenation order (identical in the pytest half, tests/test_meridian.py):
|
||||
//! 1. esp32 canonical amplitude (56) 2. esp32 canonical phase (56)
|
||||
//! 3. intel5300 canonical amplitude 4. intel5300 canonical phase
|
||||
//! 5. geometry.encode(ap_positions) 6. rapid_adapt lora_weights
|
||||
//!
|
||||
//! Regenerate (only on an intentional Rust change): delete the .sha256 and
|
||||
//! re-run `cargo test --features meridian --test meridian_parity`.
|
||||
#![cfg(feature = "meridian")]
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use wifi_densepose_signal::hardware_norm::{HardwareNormalizer, HardwareType};
|
||||
use wifi_densepose_train::geometry::{GeometryEncoder, MeridianGeometryConfig};
|
||||
use wifi_densepose_train::rapid_adapt::{AdaptationLoss, RapidAdaptation};
|
||||
|
||||
fn golden_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("golden")
|
||||
}
|
||||
|
||||
fn fixture() -> Value {
|
||||
let raw = fs::read_to_string(golden_dir().join("meridian_input.json"))
|
||||
.expect("read meridian_input.json fixture");
|
||||
serde_json::from_str(&raw).expect("parse meridian_input.json")
|
||||
}
|
||||
|
||||
fn f64_vec(v: &Value, key: &str) -> Vec<f64> {
|
||||
v[key]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| x.as_f64().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn f32_frames(v: &Value, key: &str) -> Vec<Vec<f32>> {
|
||||
v[key]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| {
|
||||
row.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| x.as_f64().unwrap() as f32)
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute the full concatenated MERIDIAN output vector, mirroring the
|
||||
/// Python binding's default construction exactly.
|
||||
fn meridian_output(fx: &Value) -> Vec<f32> {
|
||||
let mut out: Vec<f32> = Vec::new();
|
||||
|
||||
// 1–4: hardware normalization (default normalizer, canonical 56).
|
||||
let norm = HardwareNormalizer::new();
|
||||
let esp = norm
|
||||
.normalize(
|
||||
&f64_vec(fx, "esp32_amplitude"),
|
||||
&f64_vec(fx, "esp32_phase"),
|
||||
HardwareType::Esp32S3,
|
||||
)
|
||||
.unwrap();
|
||||
out.extend_from_slice(&esp.amplitude);
|
||||
out.extend_from_slice(&esp.phase);
|
||||
let intel = norm
|
||||
.normalize(
|
||||
&f64_vec(fx, "intel_amplitude"),
|
||||
&f64_vec(fx, "intel_phase"),
|
||||
HardwareType::Intel5300,
|
||||
)
|
||||
.unwrap();
|
||||
out.extend_from_slice(&intel.amplitude);
|
||||
out.extend_from_slice(&intel.phase);
|
||||
|
||||
// 5: geometry encoding (default config → 64-dim).
|
||||
let enc = GeometryEncoder::new(&MeridianGeometryConfig::default());
|
||||
let aps: Vec<[f32; 3]> = fx["ap_positions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let a = p.as_array().unwrap();
|
||||
[
|
||||
a[0].as_f64().unwrap() as f32,
|
||||
a[1].as_f64().unwrap() as f32,
|
||||
a[2].as_f64().unwrap() as f32,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
out.extend_from_slice(&enc.encode(&aps));
|
||||
|
||||
// 6: rapid adaptation lora weights (Combined, epochs 5, lr 1e-3, λ 0.5).
|
||||
let mut ra = RapidAdaptation::new(
|
||||
10,
|
||||
4,
|
||||
AdaptationLoss::Combined {
|
||||
epochs: 5,
|
||||
lr: 0.001,
|
||||
lambda_ent: 0.5,
|
||||
},
|
||||
);
|
||||
for frame in f32_frames(fx, "rapid_frames") {
|
||||
ra.push_frame(&frame);
|
||||
}
|
||||
out.extend_from_slice(&ra.adapt().unwrap().lora_weights);
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn sha256_le(vals: &[f32]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
for &x in vals {
|
||||
hasher.update(x.to_le_bytes());
|
||||
}
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_canonical_frames_are_56_wide() {
|
||||
let fx = fixture();
|
||||
let norm = HardwareNormalizer::new();
|
||||
let esp = norm
|
||||
.normalize(
|
||||
&f64_vec(&fx, "esp32_amplitude"),
|
||||
&f64_vec(&fx, "esp32_phase"),
|
||||
HardwareType::Esp32S3,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(esp.amplitude.len(), 56);
|
||||
assert_eq!(esp.phase.len(), 56);
|
||||
let intel = norm
|
||||
.normalize(
|
||||
&f64_vec(&fx, "intel_amplitude"),
|
||||
&f64_vec(&fx, "intel_phase"),
|
||||
HardwareType::Intel5300,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(intel.amplitude.len(), 56);
|
||||
// 64-dim geometry vector.
|
||||
let enc = GeometryEncoder::new(&MeridianGeometryConfig::default());
|
||||
assert_eq!(enc.encode(&[[0.25, 0.5, 0.75]]).len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_meridian_matches_committed_golden() {
|
||||
let got = sha256_le(&meridian_output(&fixture()));
|
||||
let path = golden_dir().join("meridian_output.sha256");
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(expected) => assert_eq!(
|
||||
got,
|
||||
expected.trim(),
|
||||
"native MERIDIAN hash drifted from committed golden \
|
||||
(intentional? delete the .sha256 and regenerate)"
|
||||
),
|
||||
Err(_) => {
|
||||
fs::write(&path, &got).expect("write golden sha256");
|
||||
panic!("no committed golden found; wrote {got}. Re-run to verify parity.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"""ADR-185 P1 — AETHER binding tests, incl. the §4.1 bit-for-bit parity gate.
|
||||
|
||||
The parity test compares the binding's embedding to a committed golden VECTOR
|
||||
produced by the native-Rust reference (`tests/aether_parity.rs`), within a
|
||||
numerical tolerance. It is NOT a byte-hash: the embedding is f32 with
|
||||
transcendental ops, so exact bytes are not reproducible across the CPU
|
||||
architectures this project ships wheels for. A mismatch beyond tolerance is a
|
||||
release blocker, not a warning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose import aether
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
|
||||
# Cross-architecture f32 parity tolerance. The AETHER embedding is pure f32 with
|
||||
# transcendental ops that differ in the last bits across CPUs/libm, so exact
|
||||
# byte equality is not portable across the wheels this project builds. 1e-4 is
|
||||
# ~100x the observed cross-arch drift on unit-normed values and ~100x smaller
|
||||
# than any real algorithm change. Combined atol+rtol so both small and larger
|
||||
# components are bounded. (ADR-185 §4.1.)
|
||||
PARITY_ATOL = 1e-4
|
||||
PARITY_RTOL = 1e-4
|
||||
|
||||
|
||||
def load_input() -> list[list[float]]:
|
||||
return json.loads((GOLDEN / "aether_input.json").read_text())
|
||||
|
||||
|
||||
def assert_embedding_matches_golden(embedding: list[float], golden_name: str) -> None:
|
||||
"""Assert `embedding` matches the committed golden vector.
|
||||
|
||||
Two independent checks, because a per-element tolerance alone is not enough:
|
||||
- **Per element**, within atol+rtol — catches a single component drifting.
|
||||
- **Whole-vector cosine** ≥ 1 - 1e-6 — catches a *coherent* shift that stays
|
||||
inside the per-element bound on every component yet moves the vector as a
|
||||
whole (the failure mode a loose element tolerance would hide).
|
||||
|
||||
NaN/inf are rejected explicitly: `abs(nan - b) > tol` is False, so a bare
|
||||
tolerance check would silently PASS an all-NaN embedding. Every value must be
|
||||
finite first.
|
||||
"""
|
||||
golden = json.loads((GOLDEN / golden_name).read_text())
|
||||
assert len(embedding) == len(golden), (
|
||||
f"{golden_name}: length {len(embedding)} != golden {len(golden)}"
|
||||
)
|
||||
for i, x in enumerate(embedding):
|
||||
assert math.isfinite(x), f"{golden_name}: element {i} is not finite ({x})"
|
||||
|
||||
for i, (a, b) in enumerate(zip(embedding, golden)):
|
||||
tol = PARITY_ATOL + PARITY_RTOL * abs(b)
|
||||
assert abs(a - b) <= tol, (
|
||||
f"{golden_name}: element {i} diverged from native golden beyond "
|
||||
f"tolerance (got {a}, golden {b}, |Δ|={abs(a - b):.3e}) — "
|
||||
"a real regression, not cross-arch f32 drift."
|
||||
)
|
||||
|
||||
dot = sum(a * b for a, b in zip(embedding, golden))
|
||||
na = math.sqrt(sum(a * a for a in embedding))
|
||||
nb = math.sqrt(sum(b * b for b in golden))
|
||||
cosine = dot / (na * nb) if na > 0 and nb > 0 else 0.0
|
||||
assert cosine >= 1.0 - 1e-6, (
|
||||
f"{golden_name}: whole-vector cosine similarity to the golden is "
|
||||
f"{cosine:.9f} (< 1 - 1e-6) — a coherent shift the per-element "
|
||||
"tolerance did not catch."
|
||||
)
|
||||
|
||||
|
||||
def build_extractor() -> aether.EmbeddingExtractor:
|
||||
# Must match the native-Rust reference construction exactly.
|
||||
cfg = aether.AetherConfig(d_model=64, d_proj=128, temperature=0.07, normalize=True)
|
||||
return aether.EmbeddingExtractor(n_subcarriers=56, config=cfg)
|
||||
|
||||
|
||||
def _formula_weights(n: int) -> list[float]:
|
||||
# Byte-identical to aether_weights_parity.rs (k/65536 is exact in f32+f64).
|
||||
return [((i * 1103515245 + 12345) % 65536) / 65536.0 - 0.5 for i in range(n)]
|
||||
|
||||
|
||||
def _write_weight_file(path: Path, weights: list[float]) -> None:
|
||||
# AETHER weight format: b"AETHERW1" + u32 count + LE f32 payload.
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"AETHERW1")
|
||||
f.write(struct.pack("<I", len(weights)))
|
||||
f.write(b"".join(struct.pack("<f", w) for w in weights))
|
||||
|
||||
|
||||
def test_config_roundtrips_fields() -> None:
|
||||
cfg = aether.AetherConfig(d_model=64, d_proj=128, temperature=0.07, normalize=True)
|
||||
assert cfg.d_model == 64
|
||||
assert cfg.d_proj == 128
|
||||
assert abs(cfg.temperature - 0.07) < 1e-6
|
||||
assert cfg.normalize is True
|
||||
|
||||
|
||||
# ─── Constructor input validation (raise ValueError, never panic) ─────────
|
||||
# Bad dimensions used to reach Rust and either panic (surfacing as an opaque
|
||||
# PanicException) or allocate multi-gigabyte matrices that abort the interpreter.
|
||||
|
||||
@pytest.mark.parametrize("kwargs", [
|
||||
{"d_model": 0, "d_proj": 128},
|
||||
{"d_model": 64, "d_proj": 0},
|
||||
{"d_model": 100_000, "d_proj": 128}, # unbounded allocation guard
|
||||
{"d_model": 64, "d_proj": 100_000},
|
||||
])
|
||||
def test_aether_config_rejects_bad_dims(kwargs: dict) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
aether.AetherConfig(**kwargs)
|
||||
|
||||
|
||||
def test_extractor_rejects_zero_heads_instead_of_panicking() -> None:
|
||||
# THE crash codex flagged: n_heads=0 -> `d_model % n_heads` -> panic.
|
||||
cfg = aether.AetherConfig(d_model=64, d_proj=128)
|
||||
with pytest.raises(ValueError):
|
||||
aether.EmbeddingExtractor(n_subcarriers=56, config=cfg, n_heads=0)
|
||||
|
||||
|
||||
def test_extractor_rejects_indivisible_head_count() -> None:
|
||||
# 64 % 5 != 0 trips the native assert; must be a clean ValueError.
|
||||
cfg = aether.AetherConfig(d_model=64, d_proj=128)
|
||||
with pytest.raises(ValueError):
|
||||
aether.EmbeddingExtractor(n_subcarriers=56, config=cfg, n_heads=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kwargs", [
|
||||
{"n_subcarriers": 0},
|
||||
{"n_subcarriers": 100_000},
|
||||
{"n_keypoints": 0},
|
||||
])
|
||||
def test_extractor_rejects_bad_shape(kwargs: dict) -> None:
|
||||
cfg = aether.AetherConfig(d_model=64, d_proj=128)
|
||||
base = {"n_subcarriers": 56, "config": cfg}
|
||||
base.update(kwargs)
|
||||
with pytest.raises(ValueError):
|
||||
aether.EmbeddingExtractor(**base)
|
||||
|
||||
|
||||
def test_valid_extractor_still_constructs() -> None:
|
||||
# The negatives above must not pass by making the constructor reject
|
||||
# everything: a valid config still builds and embeds.
|
||||
cfg = aether.AetherConfig(d_model=64, d_proj=128)
|
||||
ext = aether.EmbeddingExtractor(n_subcarriers=56, config=cfg, n_heads=4)
|
||||
assert len(ext.embed(load_input())) == 128
|
||||
|
||||
|
||||
def test_embedding_shape_and_unit_norm() -> None:
|
||||
emb = build_extractor().embed(load_input())
|
||||
assert len(emb) == 128
|
||||
norm = math.sqrt(sum(x * x for x in emb))
|
||||
assert abs(norm - 1.0) < 1e-4, f"expected unit-norm embedding, got {norm}"
|
||||
|
||||
|
||||
def test_binding_matches_native_golden_within_tolerance() -> None:
|
||||
"""The release-blocking §4.1 gate: binding output == native Rust reference.
|
||||
|
||||
Compares to a committed golden VECTOR within a numerical tolerance, not a
|
||||
SHA-256 of the raw f32 bytes. The embedding is pure f32 and uses
|
||||
transcendental ops (ln/sqrt/cos in the Gaussian init), which are NOT
|
||||
bit-reproducible across CPU architectures or libm implementations. A
|
||||
byte-hash therefore only ever matched the one arch that generated it, and
|
||||
failed on every other wheel this project builds (aarch64, macOS-arm). The
|
||||
tolerance below (1e-4) is orders of magnitude larger than cross-arch f32
|
||||
drift yet far tighter than any real algorithm change, which moves
|
||||
unit-normed elements by ~1e-2 or more. See ADR-185 §4.1.
|
||||
"""
|
||||
emb = build_extractor().embed(load_input())
|
||||
assert_embedding_matches_golden(emb, "aether_embedding.json")
|
||||
|
||||
|
||||
def test_embedding_is_deterministic() -> None:
|
||||
ext = build_extractor()
|
||||
inp = load_input()
|
||||
assert ext.embed(inp) == ext.embed(inp)
|
||||
|
||||
|
||||
def test_cosine_similarity_self_is_one() -> None:
|
||||
v = [0.1 * i - 0.5 for i in range(32)]
|
||||
assert abs(aether.cosine_similarity(v, v) - 1.0) < 1e-5
|
||||
|
||||
|
||||
def test_cosine_similarity_orthogonal_is_zero() -> None:
|
||||
a = [1.0, 0.0, 0.0, 0.0]
|
||||
b = [0.0, 1.0, 0.0, 0.0]
|
||||
assert abs(aether.cosine_similarity(a, b)) < 1e-6
|
||||
|
||||
|
||||
def test_info_nce_loss_identical_batch_is_log_n() -> None:
|
||||
# Identical embeddings → all similarities equal → loss == ln(N).
|
||||
emb = [[1.0, 0.0, 0.0]] * 4
|
||||
loss = aether.info_nce_loss(emb, emb, 0.07)
|
||||
assert abs(loss - math.log(4)) < 0.1
|
||||
|
||||
|
||||
def test_augment_pair_preserves_shape_and_differs() -> None:
|
||||
window = load_input()
|
||||
view_a, view_b = aether.CsiAugmenter().augment_pair(window, seed=42)
|
||||
assert len(view_a) == len(window)
|
||||
assert len(view_b) == len(window)
|
||||
assert len(view_a[0]) == len(window[0])
|
||||
differs = any(
|
||||
abs(x - y) > 1e-6
|
||||
for ra, rb in zip(view_a, view_b)
|
||||
for x, y in zip(ra, rb)
|
||||
)
|
||||
assert differs, "augment_pair should return two distinct views"
|
||||
|
||||
|
||||
def test_missing_feature_message_names_the_real_fix() -> None:
|
||||
# The guard fires only on a from-source build without the feature. Its
|
||||
# message must name the real fix — rebuild with the feature — and must NOT
|
||||
# tell users to `pip install [aether]`, which is an empty extra that cannot
|
||||
# add compiled code to a built wheel.
|
||||
src = (Path(aether.__file__)).read_text()
|
||||
assert "--features aether" in src
|
||||
assert "pip install wifi-densepose[aether]" not in src
|
||||
|
||||
|
||||
# ─── Weight loading (ADR-185 §13.a) ──────────────────────────────────
|
||||
|
||||
def test_load_weights_is_used_and_matches_native_golden() -> None:
|
||||
ext = build_extractor()
|
||||
baseline = ext.embed(load_input()) # random Xavier init
|
||||
|
||||
weights = _formula_weights(ext.param_count)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
wpath = Path(d) / "weights.bin"
|
||||
_write_weight_file(wpath, weights)
|
||||
ext.load_weights(str(wpath))
|
||||
|
||||
loaded = ext.embed(load_input())
|
||||
|
||||
# (1) The loaded weights actually take effect (not a silent no-op).
|
||||
assert any(abs(a - b) > 1e-6 for a, b in zip(baseline, loaded)), (
|
||||
"load_weights had no effect — embedding still equals the random-init baseline"
|
||||
)
|
||||
# (2) Matches the native-Rust reference that loaded the same weights, within
|
||||
# tolerance. See test_binding_matches_native_golden_within_tolerance for
|
||||
# why this is a tolerance compare and not a byte-hash.
|
||||
assert_embedding_matches_golden(loaded, "aether_loaded_embedding.json")
|
||||
|
||||
|
||||
def test_save_then_load_weights_round_trips() -> None:
|
||||
ext = build_extractor()
|
||||
inp = load_input()
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
wpath = Path(d) / "roundtrip.bin"
|
||||
ext.save_weights(str(wpath)) # serialize current (random) weights
|
||||
emb_before = ext.embed(inp)
|
||||
ext2 = build_extractor()
|
||||
ext2.load_weights(str(wpath)) # load them into a fresh extractor
|
||||
assert ext2.embed(inp) == emb_before
|
||||
|
||||
|
||||
def test_load_weights_rejects_bad_magic() -> None:
|
||||
ext = build_extractor()
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
wpath = Path(d) / "bad.bin"
|
||||
wpath.write_bytes(b"NOTAETHER" + b"\x00" * 8)
|
||||
with pytest.raises(ValueError):
|
||||
ext.load_weights(str(wpath))
|
||||
|
||||
|
||||
def test_load_weights_rejects_wrong_param_count() -> None:
|
||||
ext = build_extractor()
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
wpath = Path(d) / "short.bin"
|
||||
_write_weight_file(wpath, [0.1, 0.2, 0.3]) # far too few params
|
||||
with pytest.raises(ValueError):
|
||||
ext.load_weights(str(wpath))
|
||||
@@ -65,7 +65,29 @@ _FIXTURE_MESSAGES = [
|
||||
]
|
||||
|
||||
|
||||
#: Upgrade-request headers captured by the in-process server, so tests
|
||||
#: can assert what the client actually put on the handshake.
|
||||
_CAPTURED_UPGRADE_HEADERS: dict[str, str] = {}
|
||||
|
||||
|
||||
def _upgrade_headers(websocket: Any) -> Any:
|
||||
"""Read the handshake request headers across websockets versions
|
||||
(`websocket.request.headers` >= 14, `websocket.request_headers` <= 13)."""
|
||||
req = getattr(websocket, "request", None)
|
||||
if req is not None and getattr(req, "headers", None) is not None:
|
||||
return req.headers
|
||||
return getattr(websocket, "request_headers", {})
|
||||
|
||||
|
||||
async def _handler(websocket: Any) -> None:
|
||||
_CAPTURED_UPGRADE_HEADERS.clear()
|
||||
try:
|
||||
headers = _upgrade_headers(websocket)
|
||||
auth = headers.get("Authorization") if hasattr(headers, "get") else None
|
||||
if auth is not None:
|
||||
_CAPTURED_UPGRADE_HEADERS["Authorization"] = auth
|
||||
except Exception:
|
||||
pass
|
||||
for msg in _FIXTURE_MESSAGES:
|
||||
await websocket.send(json.dumps(msg))
|
||||
# Send one malformed frame to assert the client logs+drops it
|
||||
@@ -174,6 +196,160 @@ def test_sensing_client_decoder_directly() -> None:
|
||||
assert msg.rssi is None
|
||||
|
||||
|
||||
# ─── Auth: bearer token on the WS upgrade (issue #1395) ──────────────
|
||||
|
||||
|
||||
def _auth_header_from_kwargs(kwargs: dict) -> Any:
|
||||
"""Pull the Authorization value out of whichever header kwarg the
|
||||
installed `websockets` uses (`additional_headers` >= 14,
|
||||
`extra_headers` <= 13). Returns None if no header kwarg was passed."""
|
||||
for key in ("additional_headers", "extra_headers"):
|
||||
if key in kwargs:
|
||||
return dict(kwargs[key]).get("Authorization")
|
||||
return None
|
||||
|
||||
|
||||
class _DummyWS:
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _CapturingConnect:
|
||||
"""Stand-in for `websockets.connect` that records the kwargs it was
|
||||
called with and returns an awaitable yielding a dummy connection."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
|
||||
def __call__(self, url: str, **kwargs: Any) -> Any:
|
||||
self.calls.append((url, kwargs))
|
||||
|
||||
async def _coro() -> _DummyWS:
|
||||
return _DummyWS()
|
||||
|
||||
return _coro()
|
||||
|
||||
@property
|
||||
def last_kwargs(self) -> dict:
|
||||
return self.calls[-1][1]
|
||||
|
||||
|
||||
async def test_token_from_constructor_sets_auth_header(monkeypatch: Any) -> None:
|
||||
from wifi_densepose.client import ws as ws_mod
|
||||
|
||||
fake = _CapturingConnect()
|
||||
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
|
||||
|
||||
async with SensingClient("ws://x/ws/sensing", token="tok-abc"):
|
||||
pass
|
||||
|
||||
assert _auth_header_from_kwargs(fake.last_kwargs) == "Bearer tok-abc"
|
||||
|
||||
|
||||
async def test_token_from_env_sets_auth_header(monkeypatch: Any) -> None:
|
||||
from wifi_densepose.client import ws as ws_mod
|
||||
|
||||
fake = _CapturingConnect()
|
||||
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
|
||||
monkeypatch.setenv("RUVIEW_API_TOKEN", "env-tok-123")
|
||||
|
||||
async with SensingClient("ws://x/ws/sensing"):
|
||||
pass
|
||||
|
||||
assert _auth_header_from_kwargs(fake.last_kwargs) == "Bearer env-tok-123"
|
||||
|
||||
|
||||
async def test_constructor_token_overrides_env(monkeypatch: Any) -> None:
|
||||
from wifi_densepose.client import ws as ws_mod
|
||||
|
||||
fake = _CapturingConnect()
|
||||
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
|
||||
monkeypatch.setenv("RUVIEW_API_TOKEN", "env-tok")
|
||||
|
||||
async with SensingClient("ws://x/ws/sensing", token="ctor-tok"):
|
||||
pass
|
||||
|
||||
assert _auth_header_from_kwargs(fake.last_kwargs) == "Bearer ctor-tok"
|
||||
|
||||
|
||||
async def test_no_token_sends_no_auth_header(monkeypatch: Any) -> None:
|
||||
from wifi_densepose.client import ws as ws_mod
|
||||
|
||||
fake = _CapturingConnect()
|
||||
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
|
||||
monkeypatch.delenv("RUVIEW_API_TOKEN", raising=False)
|
||||
|
||||
async with SensingClient("ws://x/ws/sensing"):
|
||||
pass
|
||||
|
||||
assert _auth_header_from_kwargs(fake.last_kwargs) is None
|
||||
# Auth-disabled path must not smuggle either header kwarg in.
|
||||
assert "additional_headers" not in fake.last_kwargs
|
||||
assert "extra_headers" not in fake.last_kwargs
|
||||
|
||||
|
||||
async def test_empty_token_sends_no_auth_header(monkeypatch: Any) -> None:
|
||||
"""An explicitly empty token (or empty env var) means 'no auth'."""
|
||||
from wifi_densepose.client import ws as ws_mod
|
||||
|
||||
fake = _CapturingConnect()
|
||||
monkeypatch.setattr(ws_mod.websockets, "connect", fake)
|
||||
monkeypatch.setenv("RUVIEW_API_TOKEN", "")
|
||||
|
||||
async with SensingClient("ws://x/ws/sensing"):
|
||||
pass
|
||||
|
||||
assert _auth_header_from_kwargs(fake.last_kwargs) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"header_param,expected",
|
||||
[
|
||||
("additional_headers", "additional_headers"), # websockets >= 14
|
||||
("extra_headers", "extra_headers"), # websockets <= 13
|
||||
],
|
||||
)
|
||||
def test_select_header_kwarg_across_websockets_versions(
|
||||
header_param: str, expected: str
|
||||
) -> None:
|
||||
"""Version-compat: the kwarg is chosen by inspecting the installed
|
||||
`connect` signature, so both the pre-14 (`extra_headers`) and
|
||||
post-14 (`additional_headers`) conventions resolve correctly without
|
||||
two websockets installs."""
|
||||
from wifi_densepose.client.ws import _select_header_kwarg
|
||||
|
||||
# Build a fake `connect` whose signature carries only the one kwarg
|
||||
# the emulated websockets version would expose.
|
||||
ns: dict = {}
|
||||
exec(
|
||||
f"def fake_connect(uri, *, {header_param}=None, ping_interval=None): ...",
|
||||
ns,
|
||||
)
|
||||
assert _select_header_kwarg(ns["fake_connect"]) == expected
|
||||
|
||||
|
||||
def test_select_header_kwarg_matches_installed_websockets() -> None:
|
||||
"""On whatever `websockets` is actually installed, the chosen kwarg
|
||||
must be a real parameter of `websockets.connect`."""
|
||||
import inspect
|
||||
|
||||
import websockets
|
||||
|
||||
from wifi_densepose.client.ws import _select_header_kwarg
|
||||
|
||||
chosen = _select_header_kwarg(websockets.connect)
|
||||
assert chosen in inspect.signature(websockets.connect).parameters
|
||||
|
||||
|
||||
async def test_auth_header_reaches_server_end_to_end(ws_server: str) -> None:
|
||||
"""Real in-process server: assert the bearer actually arrives on the
|
||||
upgrade request (proves the header is wired to the live handshake,
|
||||
not just the connect kwargs)."""
|
||||
async with SensingClient(ws_server, token="e2e-token") as client:
|
||||
await client.recv_one(timeout=2.0)
|
||||
assert _CAPTURED_UPGRADE_HEADERS.get("Authorization") == "Bearer e2e-token"
|
||||
|
||||
|
||||
def test_sensing_client_decoder_handles_None_subfields() -> None:
|
||||
"""When the sensing-server explicitly emits null for HR/BR (no
|
||||
measurement yet), the client should propagate None, not crash."""
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""ADR-185 P3 — MAT binding tests, incl. the §4.1 bit-for-bit parity gate.
|
||||
|
||||
The parity test drives the same committed CSI stream through the binding's
|
||||
DisasterResponse pipeline and asserts the survivor count + triage classes
|
||||
(as a SHA-256 of a canonical string) match the native-Rust golden. A
|
||||
mismatch is a release blocker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose import mat
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
|
||||
|
||||
def fixture() -> dict:
|
||||
return json.loads((GOLDEN / "mat_input.json").read_text())
|
||||
|
||||
|
||||
def build_response() -> mat.DisasterResponse:
|
||||
cfg = mat.DisasterConfig(
|
||||
mat.DisasterType.Earthquake,
|
||||
sensitivity=0.9,
|
||||
confidence_threshold=0.1,
|
||||
max_depth=5.0,
|
||||
)
|
||||
resp = mat.DisasterResponse(cfg)
|
||||
resp.initialize_event(0.0, 0.0, "parity-fixture")
|
||||
resp.add_zone(mat.ScanZone.rectangle("Zone A", 0.0, 0.0, 50.0, 30.0))
|
||||
return resp
|
||||
|
||||
|
||||
def run_scan(resp: mat.DisasterResponse) -> None:
|
||||
for frame in fixture()["stream"]:
|
||||
resp.push_csi_data(frame["amplitude"], frame["phase"])
|
||||
resp.scan_once()
|
||||
|
||||
|
||||
# ─── enums / config ──────────────────────────────────────────────────
|
||||
|
||||
def test_triage_priority_order() -> None:
|
||||
assert mat.TriageStatus.Immediate.priority == 1
|
||||
assert mat.TriageStatus.Delayed.priority == 2
|
||||
assert mat.TriageStatus.Unknown.priority == 5
|
||||
|
||||
|
||||
def test_disaster_config_fields() -> None:
|
||||
cfg = mat.DisasterConfig(mat.DisasterType.Flood, sensitivity=1.5, confidence_threshold=0.3)
|
||||
assert cfg.sensitivity == 1.0 # clamped to [0, 1]
|
||||
assert abs(cfg.confidence_threshold - 0.3) < 1e-9
|
||||
|
||||
|
||||
# ─── pipeline behaviour ──────────────────────────────────────────────
|
||||
|
||||
def test_scan_requires_event() -> None:
|
||||
resp = mat.DisasterResponse(mat.DisasterConfig(mat.DisasterType.Unknown))
|
||||
# No initialize_event / add_zone -> scan_cycle errors "No active event".
|
||||
with pytest.raises(ValueError):
|
||||
resp.scan_once()
|
||||
|
||||
|
||||
def test_push_csi_rejects_mismatched_lengths() -> None:
|
||||
resp = build_response()
|
||||
with pytest.raises(ValueError):
|
||||
resp.push_csi_data([1.0, 2.0], [1.0])
|
||||
|
||||
|
||||
def test_scan_detects_survivor_from_breathing_stream() -> None:
|
||||
resp = build_response()
|
||||
run_scan(resp)
|
||||
survivors = resp.survivors()
|
||||
# The synthetic breathing-modulated stream trips one detection (matches
|
||||
# the native-Rust reference).
|
||||
assert len(survivors) == 1
|
||||
s = survivors[0]
|
||||
assert isinstance(s.id, str) and len(s.id) > 0
|
||||
assert s.triage_status == mat.TriageStatus.Delayed
|
||||
assert 0.0 <= s.confidence <= 1.0
|
||||
# survivors_by_triage is consistent with the survivor's own class.
|
||||
assert len(resp.survivors_by_triage(mat.TriageStatus.Delayed)) == 1
|
||||
assert len(resp.survivors_by_triage(mat.TriageStatus.Immediate)) == 0
|
||||
|
||||
|
||||
# ─── §4.1 bit-for-bit parity gate (release-blocking) ─────────────────
|
||||
|
||||
def test_bit_for_bit_parity_with_native_rust() -> None:
|
||||
resp = build_response()
|
||||
run_scan(resp)
|
||||
survivors = resp.survivors()
|
||||
priorities = sorted(s.triage_status.priority for s in survivors)
|
||||
canon = f"count={len(survivors)};triage_priorities={priorities}"
|
||||
got = hashlib.sha256(canon.encode()).hexdigest()
|
||||
expected = (GOLDEN / "mat_result.sha256").read_text().strip()
|
||||
assert got == expected, (
|
||||
f"Python MAT result diverged from native-Rust golden "
|
||||
f"(canonical form: {canon}; {got} != {expected})"
|
||||
)
|
||||
|
||||
|
||||
def test_base_wheel_import_error_message() -> None:
|
||||
src = Path(mat.__file__).read_text()
|
||||
assert "--features mat" in src
|
||||
assert "pip install wifi-densepose[mat]" not in src
|
||||
@@ -0,0 +1,161 @@
|
||||
"""ADR-185 P2 — MERIDIAN binding tests, incl. the §4.1 bit-for-bit parity gate.
|
||||
|
||||
The parity test packs the binding's concatenated outputs (2× canonical
|
||||
frame, geometry vector, rapid-adapt LoRA weights) to little-endian f32
|
||||
bytes and asserts SHA-256 equality with the golden produced by the
|
||||
native-Rust reference (`tests/meridian_parity.rs`). A mismatch is a
|
||||
release blocker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose import meridian as mer
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
|
||||
|
||||
def fixture() -> dict:
|
||||
return json.loads((GOLDEN / "meridian_input.json").read_text())
|
||||
|
||||
|
||||
# ─── HardwareType / HardwareNormalizer / CanonicalCsiFrame ───────────
|
||||
|
||||
def test_hardware_type_detect() -> None:
|
||||
assert mer.HardwareType.detect(64) == mer.HardwareType.Esp32S3
|
||||
assert mer.HardwareType.detect(30) == mer.HardwareType.Intel5300
|
||||
assert mer.HardwareType.detect(56) == mer.HardwareType.Atheros
|
||||
assert mer.HardwareType.detect(128) == mer.HardwareType.Generic
|
||||
|
||||
|
||||
def test_hardware_type_properties() -> None:
|
||||
assert mer.HardwareType.Esp32S3.subcarrier_count == 64
|
||||
assert mer.HardwareType.Esp32S3.mimo_streams == 1
|
||||
assert mer.HardwareType.Intel5300.mimo_streams == 3
|
||||
|
||||
|
||||
def test_normalize_shapes_and_hardware() -> None:
|
||||
fx = fixture()
|
||||
norm = mer.HardwareNormalizer()
|
||||
assert norm.canonical_subcarriers == 56
|
||||
frame = norm.normalize(fx["esp32_amplitude"], fx["esp32_phase"], mer.HardwareType.Esp32S3)
|
||||
assert len(frame.amplitude) == 56
|
||||
assert len(frame.phase) == 56
|
||||
assert frame.hardware_type == mer.HardwareType.Esp32S3
|
||||
|
||||
|
||||
def test_normalize_rejects_mismatched_lengths() -> None:
|
||||
norm = mer.HardwareNormalizer()
|
||||
with pytest.raises(ValueError):
|
||||
norm.normalize([1.0, 2.0], [1.0], mer.HardwareType.Generic)
|
||||
|
||||
|
||||
# ─── GeometryEncoder ─────────────────────────────────────────────────
|
||||
|
||||
def test_geometry_encode_dim_and_permutation_invariance() -> None:
|
||||
enc = mer.GeometryEncoder(mer.MeridianGeometryConfig())
|
||||
aps = [[0.25, 0.5, 0.75], [1.0, 1.25, 1.5], [2.0, 0.0, -0.5]]
|
||||
v = enc.encode(aps)
|
||||
assert len(v) == 64
|
||||
# DeepSets mean-pool is permutation-invariant.
|
||||
v_perm = enc.encode([aps[2], aps[0], aps[1]])
|
||||
assert max(abs(a - b) for a, b in zip(v, v_perm)) < 1e-5
|
||||
|
||||
|
||||
def test_geometry_encode_rejects_empty_and_bad_shape() -> None:
|
||||
enc = mer.GeometryEncoder()
|
||||
with pytest.raises(ValueError):
|
||||
enc.encode([])
|
||||
with pytest.raises(ValueError):
|
||||
enc.encode([[0.0, 1.0]]) # not 3 coords
|
||||
|
||||
|
||||
# ─── RapidAdaptation ─────────────────────────────────────────────────
|
||||
|
||||
def test_rapid_adaptation_adapt() -> None:
|
||||
fx = fixture()
|
||||
ra = mer.RapidAdaptation(
|
||||
min_calibration_frames=10, lora_rank=4, loss_kind="combined",
|
||||
epochs=5, lr=0.001, lambda_ent=0.5,
|
||||
)
|
||||
for frame in fx["rapid_frames"]:
|
||||
ra.push_frame(frame)
|
||||
assert ra.is_ready()
|
||||
assert ra.buffer_len == 12
|
||||
res = ra.adapt()
|
||||
assert res.frames_used == 12
|
||||
assert res.adaptation_epochs == 5
|
||||
assert len(res.lora_weights) == 2 * 16 * 4 # 2 * fdim * rank
|
||||
|
||||
|
||||
def test_rapid_adaptation_rejects_bad_loss_kind() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
mer.RapidAdaptation(10, 4, loss_kind="nonsense")
|
||||
|
||||
|
||||
def test_rapid_adaptation_empty_buffer_raises() -> None:
|
||||
ra = mer.RapidAdaptation(1, 4)
|
||||
with pytest.raises(ValueError):
|
||||
ra.adapt()
|
||||
|
||||
|
||||
# ─── CrossDomainEvaluator ────────────────────────────────────────────
|
||||
|
||||
def test_cross_domain_evaluator_gap_ratio() -> None:
|
||||
ev = mer.CrossDomainEvaluator(1)
|
||||
preds = [
|
||||
([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), # domain 0, err 1
|
||||
([0.0, 0.0, 0.0], [2.0, 0.0, 0.0]), # domain 1, err 2
|
||||
]
|
||||
m = ev.evaluate(preds, [0, 1])
|
||||
assert abs(m["in_domain_mpjpe"] - 1.0) < 1e-6
|
||||
assert abs(m["cross_domain_mpjpe"] - 2.0) < 1e-6
|
||||
assert abs(m["domain_gap_ratio"] - 2.0) < 1e-6
|
||||
|
||||
|
||||
def test_mpjpe_module_fn() -> None:
|
||||
assert abs(mer.mpjpe([0.0, 0.0, 0.0], [3.0, 4.0, 0.0], 1) - 5.0) < 1e-6
|
||||
|
||||
|
||||
# ─── §4.1 bit-for-bit parity gate (release-blocking) ─────────────────
|
||||
|
||||
def test_bit_for_bit_parity_with_native_rust() -> None:
|
||||
fx = fixture()
|
||||
out: list[float] = []
|
||||
|
||||
norm = mer.HardwareNormalizer()
|
||||
esp = norm.normalize(fx["esp32_amplitude"], fx["esp32_phase"], mer.HardwareType.Esp32S3)
|
||||
out += list(esp.amplitude) + list(esp.phase)
|
||||
intel = norm.normalize(fx["intel_amplitude"], fx["intel_phase"], mer.HardwareType.Intel5300)
|
||||
out += list(intel.amplitude) + list(intel.phase)
|
||||
|
||||
enc = mer.GeometryEncoder(mer.MeridianGeometryConfig())
|
||||
out += list(enc.encode(fx["ap_positions"]))
|
||||
|
||||
ra = mer.RapidAdaptation(
|
||||
min_calibration_frames=10, lora_rank=4, loss_kind="combined",
|
||||
epochs=5, lr=0.001, lambda_ent=0.5,
|
||||
)
|
||||
for frame in fx["rapid_frames"]:
|
||||
ra.push_frame(frame)
|
||||
out += list(ra.adapt().lora_weights)
|
||||
|
||||
packed = b"".join(struct.pack("<f", x) for x in out)
|
||||
got = hashlib.sha256(packed).hexdigest()
|
||||
expected = (GOLDEN / "meridian_output.sha256").read_text().strip()
|
||||
assert got == expected, (
|
||||
f"Python binding MERIDIAN output diverged from native-Rust golden "
|
||||
f"({got} != {expected})"
|
||||
)
|
||||
|
||||
|
||||
def test_base_wheel_import_error_message() -> None:
|
||||
src = Path(mer.__file__).read_text()
|
||||
assert "--features meridian" in src
|
||||
assert "pip install wifi-densepose[meridian]" not in src
|
||||
@@ -28,7 +28,7 @@ from __future__ import annotations
|
||||
# Public Python version follows the wheel version, NOT the Rust core
|
||||
# version. The Rust core version is surfaced separately as
|
||||
# `__rust_version__` for diagnostics.
|
||||
__version__ = "2.0.0a1"
|
||||
__version__ = "2.0.0"
|
||||
|
||||
# Re-export the compiled module's surface. The leading underscore on
|
||||
# `_native` is intentional — it marks the binding module as internal.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""AETHER — contrastive CSI embeddings & re-identification (ADR-024, ADR-185 P1).
|
||||
|
||||
Self-supervised 128-dim L2-normalized embeddings for WiFi CSI: room
|
||||
fingerprinting, person re-identification, and anomaly scoring, computed
|
||||
entirely offline by the Rust core (no server, no network).
|
||||
|
||||
Not in the binary wheels yet (see ruvnet/RuView#1412 — the P6 SOTA
|
||||
bindings are shipped source-build-only for now to keep the base wheel
|
||||
small). Build from source with ``maturin ... --features aether`` (or
|
||||
``--features sota`` for all three P6 subsystems).
|
||||
|
||||
Quick start::
|
||||
|
||||
from wifi_densepose.aether import AetherConfig, EmbeddingExtractor, cosine_similarity
|
||||
|
||||
ext = EmbeddingExtractor(n_subcarriers=56, config=AetherConfig())
|
||||
a = ext.embed(window_a) # list[float], length == config.d_proj (128)
|
||||
b = ext.embed(window_b)
|
||||
score = cosine_similarity(a, b) # re-ID similarity in [-1, 1]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wifi_densepose import _native
|
||||
|
||||
# The AETHER symbols are compiled into `_native` only under the Rust `aether`
|
||||
# feature. The binary wheels do NOT enable it yet (ruvnet/RuView#1412);
|
||||
# it is available from a source build with the feature. Name that fix, not
|
||||
# a pip extra, which cannot add compiled code to a built wheel.
|
||||
if not hasattr(_native, "AetherConfig"):
|
||||
raise ImportError(
|
||||
"wifi_densepose.aether is not in the binary wheels yet "
|
||||
"(see ruvnet/RuView#1412). Build from source with "
|
||||
"`maturin ... --features aether` (or `--features sota`)."
|
||||
)
|
||||
|
||||
AetherConfig = _native.AetherConfig
|
||||
CsiAugmenter = _native.CsiAugmenter
|
||||
EmbeddingExtractor = _native.EmbeddingExtractor
|
||||
info_nce_loss = _native.info_nce_loss
|
||||
cosine_similarity = _native.cosine_similarity
|
||||
|
||||
__all__ = [
|
||||
"AetherConfig",
|
||||
"CsiAugmenter",
|
||||
"EmbeddingExtractor",
|
||||
"info_nce_loss",
|
||||
"cosine_similarity",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Type stubs for the AETHER bindings (ADR-185 P1).
|
||||
|
||||
Present only when the wheel is built with the ``[aether]`` extra. The
|
||||
top-level ``wifi_densepose`` package does not re-export these names, so
|
||||
``mypy --strict`` sees them only via ``from wifi_densepose.aether import ...``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
class AetherConfig:
|
||||
def __init__(
|
||||
self,
|
||||
d_model: int = ...,
|
||||
d_proj: int = ...,
|
||||
temperature: float = ...,
|
||||
normalize: bool = ...,
|
||||
) -> None: ...
|
||||
@property
|
||||
def d_model(self) -> int: ...
|
||||
@property
|
||||
def d_proj(self) -> int: ...
|
||||
@property
|
||||
def temperature(self) -> float: ...
|
||||
@property
|
||||
def normalize(self) -> bool: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class CsiAugmenter:
|
||||
def __init__(self) -> None: ...
|
||||
def augment_pair(
|
||||
self, window: list[list[float]], seed: int
|
||||
) -> tuple[list[list[float]], list[list[float]]]: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class EmbeddingExtractor:
|
||||
def __init__(
|
||||
self,
|
||||
n_subcarriers: int,
|
||||
config: AetherConfig,
|
||||
n_keypoints: int = ...,
|
||||
n_heads: int = ...,
|
||||
n_gnn_layers: int = ...,
|
||||
) -> None: ...
|
||||
def embed(self, csi_features: list[list[float]]) -> list[float]: ...
|
||||
@property
|
||||
def embedding_dim(self) -> int: ...
|
||||
@property
|
||||
def param_count(self) -> int: ...
|
||||
def load_weights(self, path: str) -> None: ...
|
||||
def save_weights(self, path: str) -> None: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
def info_nce_loss(
|
||||
embeddings_a: list[list[float]],
|
||||
embeddings_b: list[list[float]],
|
||||
temperature: float = ...,
|
||||
) -> float: ...
|
||||
def cosine_similarity(a: list[float], b: list[float]) -> float: ...
|
||||
@@ -31,8 +31,10 @@ asyncio.run(main())
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, AsyncIterator, Optional
|
||||
|
||||
@@ -48,6 +50,33 @@ except ImportError: # pragma: no cover
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
#: Environment variable the sensing-server bearer token is read from by
|
||||
#: default. Mirrors the TypeScript MCP client (tools/ruview-mcp).
|
||||
TOKEN_ENV_VAR = "RUVIEW_API_TOKEN"
|
||||
|
||||
|
||||
def _select_header_kwarg(connect_fn: Any) -> str:
|
||||
"""Return the ``websockets.connect`` keyword for extra request headers.
|
||||
|
||||
The keyword was renamed inside the ``websockets>=12`` range this
|
||||
package supports: ``<= 13`` accepts ``extra_headers``, ``>= 14``
|
||||
accepts ``additional_headers``. We inspect the actual signature of
|
||||
the installed ``connect`` rather than guessing from ``__version__``,
|
||||
so a version bump that renames the kwarg again is handled by
|
||||
detection instead of raising ``TypeError`` at connect time.
|
||||
"""
|
||||
try:
|
||||
params = inspect.signature(connect_fn).parameters
|
||||
except (TypeError, ValueError): # pragma: no cover — no introspectable sig
|
||||
return "additional_headers"
|
||||
if "additional_headers" in params:
|
||||
return "additional_headers"
|
||||
if "extra_headers" in params:
|
||||
return "extra_headers"
|
||||
# Neither present (unexpected) — prefer the newer convention.
|
||||
return "additional_headers"
|
||||
|
||||
|
||||
# ─── Typed messages ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -172,12 +201,18 @@ class SensingClient:
|
||||
the ``async with`` in your own retry loop. Auto-reconnect logic is
|
||||
application-specific (e.g., "retry forever" for a long-running
|
||||
automation vs "fail fast" for a CLI tool that should exit).
|
||||
|
||||
Auth: pass ``token=`` to send ``Authorization: Bearer <token>`` on
|
||||
the WS upgrade, for sensing-servers started with ``RUVIEW_API_TOKEN``
|
||||
set. If ``token`` is omitted it defaults to the ``RUVIEW_API_TOKEN``
|
||||
environment variable; when neither is set, no header is sent.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
token: Optional[str] = None,
|
||||
ping_interval: float = 20.0,
|
||||
ping_timeout: float = 20.0,
|
||||
max_size: int = 16 * 1024 * 1024,
|
||||
@@ -188,18 +223,28 @@ class SensingClient:
|
||||
"`pip install \"wifi-densepose[client]\"` to enable the client extras."
|
||||
)
|
||||
self.url = url
|
||||
# Bearer token for auth-enabled sensing-servers. Explicit
|
||||
# constructor argument wins; otherwise fall back to the
|
||||
# RUVIEW_API_TOKEN environment variable. An empty value (unset
|
||||
# env, or "") means "no auth" — no Authorization header is sent.
|
||||
self._token = token if token is not None else os.environ.get(TOKEN_ENV_VAR)
|
||||
self._ping_interval = ping_interval
|
||||
self._ping_timeout = ping_timeout
|
||||
self._max_size = max_size
|
||||
self._ws: Any = None # websockets.WebSocketClientProtocol — typed Any to avoid import cost
|
||||
|
||||
async def __aenter__(self) -> "SensingClient":
|
||||
self._ws = await websockets.connect(
|
||||
self.url,
|
||||
connect_kwargs: dict[str, Any] = dict(
|
||||
ping_interval=self._ping_interval,
|
||||
ping_timeout=self._ping_timeout,
|
||||
max_size=self._max_size,
|
||||
)
|
||||
if self._token:
|
||||
# Python (unlike the browser UI) can set Authorization
|
||||
# directly on the WS upgrade — no ticket workaround needed.
|
||||
header_kwarg = _select_header_kwarg(websockets.connect)
|
||||
connect_kwargs[header_kwarg] = {"Authorization": f"Bearer {self._token}"}
|
||||
self._ws = await websockets.connect(self.url, **connect_kwargs)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""MAT — Mass Casualty Assessment Tool (ADR-024 crate, ADR-185 P3).
|
||||
|
||||
WiFi-based disaster-survivor detection and START-protocol triage from CSI:
|
||||
ingest CSI frames, run a scan cycle, and query detected survivors by triage.
|
||||
|
||||
Not in the binary wheels yet (see ruvnet/RuView#1412 — the P6 SOTA
|
||||
bindings are shipped source-build-only for now to keep the base wheel
|
||||
small). Build from source with ``maturin ... --features mat`` (or
|
||||
``--features sota`` for all three P6 subsystems).
|
||||
|
||||
Quick start::
|
||||
|
||||
from wifi_densepose.mat import DisasterConfig, DisasterResponse, DisasterType, ScanZone
|
||||
|
||||
cfg = DisasterConfig(DisasterType.Earthquake, sensitivity=0.9, confidence_threshold=0.1)
|
||||
resp = DisasterResponse(cfg)
|
||||
resp.initialize_event(0.0, 0.0, "Building A") # required before scanning
|
||||
resp.add_zone(ScanZone.rectangle("North Wing", 0.0, 0.0, 50.0, 30.0))
|
||||
for amp, phase in csi_stream:
|
||||
resp.push_csi_data(amp, phase)
|
||||
resp.scan_once() # one detection cycle
|
||||
for s in resp.survivors():
|
||||
print(s.id, s.triage_status, s.confidence, s.location)
|
||||
|
||||
Honest scope (ADR-185 §3.4): the ADR's Rust-side `scan_once()` wrapper was
|
||||
unnecessary — this binding drives one cycle of the public async
|
||||
`start_scanning()` (with `continuous_monitoring` forced off) on an internal
|
||||
runtime. `initialize_event` + `add_zone` are required before `scan_once`.
|
||||
`Survivor.latest_vitals` returns the latest reading (the Rust accessor is a
|
||||
history). The detection pipeline is real but unvalidated on live rubble.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wifi_densepose import _native
|
||||
|
||||
# MAT symbols are compiled into `_native` only under the Rust `mat` feature.
|
||||
if not hasattr(_native, "DisasterResponse"):
|
||||
raise ImportError(
|
||||
"wifi_densepose.mat is not in the binary wheels yet "
|
||||
"(see ruvnet/RuView#1412). Build from source with "
|
||||
"`maturin ... --features mat` (or `--features sota`)."
|
||||
)
|
||||
|
||||
DisasterType = _native.DisasterType
|
||||
TriageStatus = _native.TriageStatus
|
||||
DisasterConfig = _native.DisasterConfig
|
||||
DisasterResponse = _native.DisasterResponse
|
||||
ScanZone = _native.ScanZone
|
||||
Survivor = _native.Survivor
|
||||
VitalSignsReading = _native.VitalSignsReading
|
||||
|
||||
__all__ = [
|
||||
"DisasterType",
|
||||
"TriageStatus",
|
||||
"DisasterConfig",
|
||||
"DisasterResponse",
|
||||
"ScanZone",
|
||||
"Survivor",
|
||||
"VitalSignsReading",
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Type stubs for the MAT bindings (ADR-185 P3).
|
||||
|
||||
Present only when the wheel is built with the ``[mat]`` extra.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
class DisasterType(enum.Enum):
|
||||
BuildingCollapse = 0
|
||||
Earthquake = 1
|
||||
Landslide = 2
|
||||
Avalanche = 3
|
||||
Flood = 4
|
||||
MineCollapse = 5
|
||||
Industrial = 6
|
||||
TunnelCollapse = 7
|
||||
Unknown = 8
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class TriageStatus(enum.Enum):
|
||||
Immediate = 0
|
||||
Delayed = 1
|
||||
Minor = 2
|
||||
Deceased = 3
|
||||
Unknown = 4
|
||||
@property
|
||||
def priority(self) -> int: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class VitalSignsReading:
|
||||
@property
|
||||
def breathing_rate_bpm(self) -> float | None: ...
|
||||
@property
|
||||
def heartbeat_rate_bpm(self) -> float | None: ...
|
||||
@property
|
||||
def movement_intensity(self) -> float: ...
|
||||
@property
|
||||
def confidence(self) -> float: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class Survivor:
|
||||
@property
|
||||
def id(self) -> str: ...
|
||||
@property
|
||||
def triage_status(self) -> TriageStatus: ...
|
||||
@property
|
||||
def confidence(self) -> float: ...
|
||||
@property
|
||||
def location(self) -> tuple[float, float, float] | None: ...
|
||||
@property
|
||||
def latest_vitals(self) -> VitalSignsReading | None: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class DisasterConfig:
|
||||
def __init__(
|
||||
self,
|
||||
disaster_type: DisasterType,
|
||||
sensitivity: float = ...,
|
||||
confidence_threshold: float = ...,
|
||||
max_depth: float = ...,
|
||||
scan_interval_ms: int = ...,
|
||||
) -> None: ...
|
||||
@property
|
||||
def sensitivity(self) -> float: ...
|
||||
@property
|
||||
def confidence_threshold(self) -> float: ...
|
||||
@property
|
||||
def max_depth(self) -> float: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class ScanZone:
|
||||
@staticmethod
|
||||
def rectangle(
|
||||
name: str, min_x: float, min_y: float, max_x: float, max_y: float
|
||||
) -> ScanZone: ...
|
||||
@staticmethod
|
||||
def circle(name: str, center_x: float, center_y: float, radius: float) -> ScanZone: ...
|
||||
@property
|
||||
def name(self) -> str: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class DisasterResponse:
|
||||
def __init__(self, config: DisasterConfig) -> None: ...
|
||||
def initialize_event(self, x: float, y: float, description: str) -> None: ...
|
||||
def add_zone(self, zone: ScanZone) -> None: ...
|
||||
def push_csi_data(self, amplitudes: list[float], phases: list[float]) -> None: ...
|
||||
def scan_once(self) -> None: ...
|
||||
def survivors(self) -> list[Survivor]: ...
|
||||
def survivors_by_triage(self, status: TriageStatus) -> list[Survivor]: ...
|
||||
def __repr__(self) -> str: ...
|
||||
@@ -0,0 +1,62 @@
|
||||
"""MERIDIAN — cross-environment domain generalization (ADR-027, ADR-185 P2).
|
||||
|
||||
Hardware-invariant CSI normalization, geometry-conditioned deployment,
|
||||
few-shot room adaptation, and cross-domain evaluation — the tch-free
|
||||
inference/adaptation path of Project MERIDIAN, computed by the Rust core.
|
||||
|
||||
Not in the binary wheels yet (see ruvnet/RuView#1412 — the P6 SOTA
|
||||
bindings are shipped source-build-only for now to keep the base wheel
|
||||
small). Build from source with ``maturin ... --features meridian`` (or
|
||||
``--features sota`` for all three P6 subsystems).
|
||||
|
||||
Quick start::
|
||||
|
||||
from wifi_densepose.meridian import HardwareNormalizer, HardwareType
|
||||
|
||||
norm = HardwareNormalizer() # canonical 56 subcarriers
|
||||
hw = HardwareType.detect(64) # -> HardwareType.Esp32S3
|
||||
frame = norm.normalize(amplitude, phase, hw) # -> CanonicalCsiFrame
|
||||
print(len(frame.amplitude), frame.hardware_type)
|
||||
|
||||
Note (honest scope, ADR-185 §3.3): the ADR's ``RapidAdaptation.calibrate``
|
||||
/ ``AdaptationResult.converged`` do not exist in the Rust core — use
|
||||
``push_frame(...)`` then ``adapt()``; the result exposes ``final_loss``,
|
||||
``frames_used``, ``adaptation_epochs``. Training-time types
|
||||
(DomainFactorizer, GradientReversalLayer, VirtualDomainAugmentor) are
|
||||
out of scope for P6 (they need the deferred libtorch training tier).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wifi_densepose import _native
|
||||
|
||||
# MERIDIAN symbols are compiled into `_native` only under the Rust
|
||||
# `meridian` feature; absent in a base wheel (ADR-185 §6 acceptance).
|
||||
if not hasattr(_native, "HardwareNormalizer"):
|
||||
raise ImportError(
|
||||
"wifi_densepose.meridian is not in the binary wheels yet "
|
||||
"(see ruvnet/RuView#1412). Build from source with "
|
||||
"`maturin ... --features meridian` (or `--features sota`)."
|
||||
)
|
||||
|
||||
HardwareType = _native.HardwareType
|
||||
CanonicalCsiFrame = _native.CanonicalCsiFrame
|
||||
HardwareNormalizer = _native.HardwareNormalizer
|
||||
MeridianGeometryConfig = _native.MeridianGeometryConfig
|
||||
GeometryEncoder = _native.GeometryEncoder
|
||||
RapidAdaptation = _native.RapidAdaptation
|
||||
AdaptationResult = _native.AdaptationResult
|
||||
CrossDomainEvaluator = _native.CrossDomainEvaluator
|
||||
mpjpe = _native.mpjpe
|
||||
|
||||
__all__ = [
|
||||
"HardwareType",
|
||||
"CanonicalCsiFrame",
|
||||
"HardwareNormalizer",
|
||||
"MeridianGeometryConfig",
|
||||
"GeometryEncoder",
|
||||
"RapidAdaptation",
|
||||
"AdaptationResult",
|
||||
"CrossDomainEvaluator",
|
||||
"mpjpe",
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Type stubs for the MERIDIAN bindings (ADR-185 P2).
|
||||
|
||||
Present only when the wheel is built with the ``[meridian]`` extra.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
class HardwareType(enum.Enum):
|
||||
Esp32S3 = 0
|
||||
Intel5300 = 1
|
||||
Atheros = 2
|
||||
Generic = 3
|
||||
@staticmethod
|
||||
def detect(subcarrier_count: int) -> HardwareType: ...
|
||||
@property
|
||||
def subcarrier_count(self) -> int: ...
|
||||
@property
|
||||
def mimo_streams(self) -> int: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class CanonicalCsiFrame:
|
||||
@property
|
||||
def amplitude(self) -> list[float]: ...
|
||||
@property
|
||||
def phase(self) -> list[float]: ...
|
||||
@property
|
||||
def hardware_type(self) -> HardwareType: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class HardwareNormalizer:
|
||||
def __init__(self, canonical_subcarriers: int = ...) -> None: ...
|
||||
@staticmethod
|
||||
def detect_hardware(subcarrier_count: int) -> HardwareType: ...
|
||||
@property
|
||||
def canonical_subcarriers(self) -> int: ...
|
||||
def normalize(
|
||||
self, amplitude: list[float], phase: list[float], hardware: HardwareType
|
||||
) -> CanonicalCsiFrame: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class MeridianGeometryConfig:
|
||||
def __init__(
|
||||
self,
|
||||
n_frequencies: int = ...,
|
||||
scale: float = ...,
|
||||
geometry_dim: int = ...,
|
||||
seed: int = ...,
|
||||
) -> None: ...
|
||||
@property
|
||||
def n_frequencies(self) -> int: ...
|
||||
@property
|
||||
def scale(self) -> float: ...
|
||||
@property
|
||||
def geometry_dim(self) -> int: ...
|
||||
@property
|
||||
def seed(self) -> int: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class GeometryEncoder:
|
||||
def __init__(self, config: MeridianGeometryConfig | None = ...) -> None: ...
|
||||
def encode(self, ap_positions: list[list[float]]) -> list[float]: ...
|
||||
@property
|
||||
def geometry_dim(self) -> int: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class AdaptationResult:
|
||||
@property
|
||||
def lora_weights(self) -> list[float]: ...
|
||||
@property
|
||||
def final_loss(self) -> float: ...
|
||||
@property
|
||||
def frames_used(self) -> int: ...
|
||||
@property
|
||||
def adaptation_epochs(self) -> int: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class RapidAdaptation:
|
||||
def __init__(
|
||||
self,
|
||||
min_calibration_frames: int,
|
||||
lora_rank: int,
|
||||
loss_kind: str = ...,
|
||||
epochs: int = ...,
|
||||
lr: float = ...,
|
||||
lambda_ent: float = ...,
|
||||
) -> None: ...
|
||||
def push_frame(self, frame: list[float]) -> None: ...
|
||||
def is_ready(self) -> bool: ...
|
||||
@property
|
||||
def buffer_len(self) -> int: ...
|
||||
def adapt(self) -> AdaptationResult: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
class CrossDomainEvaluator:
|
||||
def __init__(self, n_joints: int) -> None: ...
|
||||
def evaluate(
|
||||
self,
|
||||
predictions: list[tuple[list[float], list[float]]],
|
||||
domain_labels: list[int],
|
||||
) -> dict[str, float]: ...
|
||||
def __repr__(self) -> str: ...
|
||||
|
||||
def mpjpe(pred: list[float], gt: list[float], n_joints: int) -> float: ...
|
||||
@@ -273,6 +273,37 @@
|
||||
],
|
||||
"rationale": "ADR-117 §P5 — the project is registered with PyPI via API token, not OIDC Trusted Publisher. The token is sourced from GCP Secret Manager (see docs/integrations/pypi-release.md). Re-introducing the `id-token: write` permission would suggest a partial OIDC migration that won't actually work without registering the Trusted Publisher on pypi.org first — a silent regression that would 403 on the next publish.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/786"
|
||||
},
|
||||
{
|
||||
"id": "RuView#1387-rvf-filename-collision",
|
||||
"title": "training_api next_model_id(): microsecond timestamp + AtomicU64 counter keeps exported .rvf filenames collision-resistant",
|
||||
"files": ["v2/crates/wifi-densepose-sensing-server/src/training_api.rs"],
|
||||
"require": [
|
||||
"static MODEL_ID_SEQ: AtomicU64",
|
||||
"%Y%m%d_%H%M%S_%6f",
|
||||
"MODEL_ID_SEQ.fetch_add(1, Ordering::Relaxed)",
|
||||
"model_ids_are_unique_per_call"
|
||||
],
|
||||
"forbid": [
|
||||
"/%Y%m%d_%H%M%S(?!_%6f)/"
|
||||
],
|
||||
"rationale": "next_model_id() builds the exported model path as trained-{type}-{ts}-{seq}. A second-resolution timestamp (%Y%m%d_%H%M%S) alone collided for two runs finishing in the same wall-clock second, silently overwriting each other's .rvf artifact and flaking the concurrent model-writing tests on CI (fixed on this branch in 8409f434c). Uniqueness now relies on BOTH microsecond resolution (%6f) AND a process-monotonic AtomicU64 counter appended to the id. Reverting to a bare %Y%m%d_%H%M%S with no sub-second/counter disambiguator reopens the silent-overwrite regression; the forbid uses a negative lookahead so it fires only on a second-resolution format that is NOT followed by _%6f. The model_ids_are_unique_per_call test (1000 back-to-back ids) locks it in.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/1387"
|
||||
},
|
||||
{
|
||||
"id": "RuView#1387-default-wheel-budget-config",
|
||||
"title": "python wheel: empty default Cargo features + optional SOTA crates + maturin strip keep the no-extras wheel under the ADR-117 §5.4 5 MB budget",
|
||||
"files": ["python/Cargo.toml", "python/pyproject.toml"],
|
||||
"require": [
|
||||
"default = []",
|
||||
"optional = true",
|
||||
"strip = true"
|
||||
],
|
||||
"forbid": [
|
||||
"/default\\s*=\\s*\\[[^\\]]*\"(sota|aether|meridian|mat)\"/"
|
||||
],
|
||||
"rationale": "The [aether]/[meridian]/[mat]/[sota] extras map to Cargo features that link heavy crates (mat -> ort/ONNX Runtime, train -> tokio+ruvector, etc.). The DEFAULT wheel must link none of them to stay under the ADR-117 §5.4 5 MB budget, which requires: (1) `default = []` in python/Cargo.toml [features], (2) every SOTA dep declared `optional = true` so it is pulled only by its own feature, and (3) `strip = true` in pyproject [tool.maturin] to drop debug symbols. Flipping default to include a SOTA feature, or making a SOTA dep non-optional, silently balloons the default wheel. NOTE: a fix-marker is a string guard and cannot measure bytes — it protects the CONFIG that keeps the wheel small. The actual numeric 5 MB ceiling is enforced by the `wheel-size-budget` job in .github/workflows/python-ci.yml, which builds the default (no-features) wheel and fails if it exceeds the budget.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/1387"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -154,7 +154,14 @@ export default class TrainingPanel {
|
||||
};
|
||||
await trainingService[method](payload);
|
||||
await this.refresh();
|
||||
} catch (e) { this._set({ loading: false, error: `Training failed: ${e.message}` }); }
|
||||
} catch (e) {
|
||||
// Start was rejected (e.g. server training disabled → HTTP 409). Tear down
|
||||
// the progress socket we opened optimistically and refresh so the button
|
||||
// reflects the real (possibly disabled) state instead of a silent no-op.
|
||||
trainingService.disconnectProgressStream();
|
||||
this._set({ loading: false, error: `Training failed: ${e.message}` });
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async _stopTraining() {
|
||||
@@ -272,13 +279,29 @@ export default class TrainingPanel {
|
||||
form.appendChild(ir('LoRA Profile (opt.)', 'text', this.config.lora_profile_name, v => { this.config.lora_profile_name = v; }));
|
||||
s.appendChild(form);
|
||||
|
||||
// ADR-186 P5: if the server reports in-server training disabled
|
||||
// (enabled:false), the Start buttons must be disabled with a CLI tooltip —
|
||||
// never a silent no-op. Enablement is surfaced on the status payload.
|
||||
const ts = this.state.trainingStatus;
|
||||
const disabled = ts && ts.enabled === false;
|
||||
const cli = (ts && ts.cli) || 'wifi-densepose train-room';
|
||||
if (disabled) {
|
||||
const note = this._el('div', 'tp-empty',
|
||||
`In-server training is disabled on this build. Train from the CLI: ${cli}`);
|
||||
s.appendChild(note);
|
||||
}
|
||||
|
||||
const acts = this._el('div', 'tp-train-actions');
|
||||
const btns = [
|
||||
this._btn('Start Training', 'tp-btn tp-btn-success', () => this._launchTraining('startTraining', { patience: this.config.patience, base_model: this.config.base_model || undefined })),
|
||||
this._btn('Pretrain', 'tp-btn tp-btn-secondary', () => this._launchTraining('startPretraining')),
|
||||
this._btn('LoRA', 'tp-btn tp-btn-secondary', () => this._launchTraining('startLoraTraining', { base_model: this.config.base_model || undefined, profile_name: this.config.lora_profile_name || 'default' }))
|
||||
];
|
||||
btns.forEach(b => { b.disabled = this.state.loading; acts.appendChild(b); });
|
||||
btns.forEach(b => {
|
||||
b.disabled = this.state.loading || disabled;
|
||||
if (disabled) b.title = `In-server training disabled — use: ${cli}`;
|
||||
acts.appendChild(b);
|
||||
});
|
||||
s.appendChild(acts);
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* - Dot-matrix mist body mass, particle trails, WiFi waves, signal field
|
||||
* - Reflective floor, settings dialog, and practical data HUD
|
||||
*/
|
||||
import { withWsTicket } from '../../services/ws-ticket.js';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
@@ -462,7 +463,7 @@ class Observatory {
|
||||
console.log('[Observatory] Sensing server detected at', base, '→', wsUrl);
|
||||
this.settings.dataSource = 'ws';
|
||||
this.settings.wsUrl = wsUrl;
|
||||
this._connectWS(wsUrl);
|
||||
void this._connectWS(wsUrl);
|
||||
} else {
|
||||
tryNext(i + 1);
|
||||
}
|
||||
@@ -472,10 +473,13 @@ class Observatory {
|
||||
tryNext(0);
|
||||
}
|
||||
|
||||
_connectWS(url) {
|
||||
// async: `/ws/sensing` is gated (ADR-272); mint a single-use ticket first.
|
||||
async _connectWS(url) {
|
||||
this._disconnectWS();
|
||||
let wsUrl = url;
|
||||
try { wsUrl = await withWsTicket(url); } catch { /* auth off or pre-ADR-272 server */ }
|
||||
try {
|
||||
this._ws = new WebSocket(url);
|
||||
this._ws = new WebSocket(wsUrl);
|
||||
this._ws.onopen = () => {
|
||||
console.log('[Observatory] WebSocket connected');
|
||||
this._hud.updateSourceBadge('ws', this._ws);
|
||||
|
||||
@@ -86,6 +86,21 @@ export class ApiService {
|
||||
// Process response through interceptors
|
||||
const processedResponse = await this.processResponse(response, url);
|
||||
|
||||
// NOTE: there is deliberately no step-up re-authentication branch here.
|
||||
//
|
||||
// An earlier revision caught the server's RFC 6750 "reauthentication
|
||||
// required" challenge and redirected to /oauth/start. That challenge can
|
||||
// never be issued to a browser: browser sign-in requests `sensing:read`
|
||||
// only and always will (see BROWSER_SIGNIN_SCOPE), so no browser session
|
||||
// holds `sensing:admin`, so the freshness gate the challenge announces is
|
||||
// never reached. Admin work goes through the CLI or a pasted bearer.
|
||||
//
|
||||
// Removed rather than left inert, because it was not merely dead — it
|
||||
// ended in a promise that never settles. If any other 401 ever grew that
|
||||
// header, every caller awaiting this would hang forever with no error.
|
||||
// The server-side guard stays as a fail-closed backstop; the client has
|
||||
// nothing to do about a flow that does not exist.
|
||||
|
||||
// Handle errors
|
||||
if (!processedResponse.ok) {
|
||||
const error = await processedResponse.json().catch(() => ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withWsTicket } from './ws-ticket.js';
|
||||
/**
|
||||
* Sensing WebSocket Service
|
||||
*
|
||||
@@ -65,7 +66,7 @@ class SensingService {
|
||||
|
||||
/** Start the service (connect or simulate). */
|
||||
start() {
|
||||
this._connect();
|
||||
void this._connect();
|
||||
}
|
||||
|
||||
/** Stop the service entirely. */
|
||||
@@ -120,13 +121,26 @@ class SensingService {
|
||||
|
||||
// ---- Connection --------------------------------------------------------
|
||||
|
||||
_connect() {
|
||||
// async because the server gates `/ws/sensing` (ADR-272) and a browser
|
||||
// cannot set an Authorization header on an upgrade — so we mint a
|
||||
// single-use ticket first. Minted per connect attempt, never cached: a
|
||||
// ticket is valid once and expires in seconds, so reusing one across
|
||||
// reconnects would fail on the second attempt.
|
||||
async _connect() {
|
||||
if (this._ws && this._ws.readyState <= WebSocket.OPEN) return;
|
||||
|
||||
this._setState('connecting');
|
||||
|
||||
let url = SENSING_WS_URL;
|
||||
try {
|
||||
this._ws = new WebSocket(SENSING_WS_URL);
|
||||
url = await withWsTicket(SENSING_WS_URL);
|
||||
} catch {
|
||||
// Ticket minting is best-effort: against a server with auth off, or one
|
||||
// predating ADR-272, connecting without a ticket is correct.
|
||||
}
|
||||
|
||||
try {
|
||||
this._ws = new WebSocket(url);
|
||||
} catch (err) {
|
||||
console.warn('[Sensing] WebSocket constructor failed:', err.message);
|
||||
this._fallbackToSimulation();
|
||||
@@ -184,7 +198,7 @@ class SensingService {
|
||||
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
this._reconnectTimer = null;
|
||||
this._connect();
|
||||
void this._connect();
|
||||
}, delay);
|
||||
|
||||
// Only start simulation after several failed attempts so a brief hiccup
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withWsTicket } from './ws-ticket.js';
|
||||
// WebSocket Client for Three.js Visualization - WiFi DensePose
|
||||
// Default endpoint is `/ws/sensing` on the same host the page was served from.
|
||||
// Callers (e.g. viz.html) usually pass an explicit `url` derived from
|
||||
@@ -47,7 +48,9 @@ export class WebSocketClient {
|
||||
}
|
||||
|
||||
// Attempt to connect
|
||||
connect() {
|
||||
// async: `/ws/*` is gated (ADR-272) and a browser cannot set an
|
||||
// Authorization header on an upgrade, so mint a single-use ticket first.
|
||||
async connect() {
|
||||
if (this.state === 'connecting' || this.state === 'connected') {
|
||||
console.warn('[WS-VIZ] Already connected or connecting');
|
||||
return;
|
||||
@@ -56,8 +59,12 @@ export class WebSocketClient {
|
||||
this._setState('connecting');
|
||||
console.log(`[WS-VIZ] Connecting to ${this.url}`);
|
||||
|
||||
// Per attempt, never cached — a ticket is single-use and short-lived.
|
||||
let url = this.url;
|
||||
try { url = await withWsTicket(this.url); } catch { /* auth off or pre-ADR-272 server */ }
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onopen = () => this._handleOpen();
|
||||
@@ -235,7 +242,7 @@ export class WebSocketClient {
|
||||
console.log(`[WS-VIZ] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect();
|
||||
void this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Single-use WebSocket tickets (ADR-272).
|
||||
//
|
||||
// A browser's WebSocket constructor cannot set an `Authorization` header on the
|
||||
// upgrade request. That used to mean the sensing WebSocket stayed reachable
|
||||
// with no credential even when the server had auth switched on — the REST
|
||||
// control plane was locked while the live sensing stream was open.
|
||||
//
|
||||
// The server now gates `/ws/*` and `/api/v1/stream/pose`. Browsers exchange
|
||||
// their stored bearer token at `POST /api/v1/ws-ticket` — an ordinary request,
|
||||
// where headers DO work — for a short-lived, single-use ticket, and pass that
|
||||
// as `?ticket=` on the socket URL.
|
||||
//
|
||||
// Why a token in a URL is acceptable here when it normally is not: the ticket
|
||||
// is consumed on first use, lives ~30 seconds, and authorizes one WebSocket
|
||||
// and nothing else. It cannot be replayed against /api/v1/*. The long-lived
|
||||
// bearer token is still never put in a URL.
|
||||
|
||||
import { API_TOKEN_STORAGE_KEY } from './api.service.js';
|
||||
|
||||
function storedToken() {
|
||||
try {
|
||||
return localStorage.getItem(API_TOKEN_STORAGE_KEY) || null;
|
||||
} catch {
|
||||
// Private browsing / storage disabled — treat as "no token configured".
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a ticket. Returns null when no token is configured (auth is off, so no
|
||||
* ticket is needed) or when the server does not offer the endpoint.
|
||||
*/
|
||||
async function mintTicket() {
|
||||
const token = storedToken();
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/v1/ws-ticket', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
// 404 means a server predating ADR-272: it still exempts WebSockets, so
|
||||
// connecting without a ticket is correct there. Treated as "no ticket
|
||||
// needed" rather than as an error, so the UI works against both.
|
||||
if (resp.status === 404) return null;
|
||||
if (!resp.ok) {
|
||||
console.warn('[ws-ticket] mint failed:', resp.status);
|
||||
return null;
|
||||
}
|
||||
const body = await resp.json();
|
||||
return body.ticket || null;
|
||||
} catch (err) {
|
||||
// Offline, or the server is down. The socket attempt will fail on its own
|
||||
// and the caller's reconnect logic handles it; failing loudly here would
|
||||
// just duplicate that.
|
||||
console.warn('[ws-ticket] mint error:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `url` with a freshly minted ticket appended, or unchanged when no
|
||||
* ticket is available or needed.
|
||||
*
|
||||
* Always call this immediately before opening the socket — a ticket expires in
|
||||
* seconds and is valid exactly once, so one must never be cached or reused
|
||||
* across reconnects.
|
||||
*
|
||||
* @param {string} url ws:// or wss:// URL
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function withWsTicket(url) {
|
||||
const ticket = await mintTicket();
|
||||
if (!ticket) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Executed tests for the WebSocket ticket helper (ADR-272).
|
||||
//
|
||||
// Run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
|
||||
// (a directory argument does not work — Node resolves it as a module)
|
||||
//
|
||||
// WHAT THIS IS AND IS NOT.
|
||||
// This EXECUTES the module in Node with stubbed `fetch` and `localStorage`. It
|
||||
// is strictly more than the `node --check` syntax pass this file replaces, and
|
||||
// strictly less than a browser: it does not exercise a real WebSocket upgrade,
|
||||
// real cookie handling, or the page wiring. The claim "the UI JavaScript has
|
||||
// never been run" is no longer true of this module; "browser-tested" still is
|
||||
// not. Both statements matter and neither should be rounded up.
|
||||
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const STORAGE_KEY = 'ruview-api-token';
|
||||
|
||||
// --- stubs installed before the module under test is imported ---------------
|
||||
let stored = {};
|
||||
let fetchCalls = [];
|
||||
let fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
|
||||
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (k in stored ? stored[k] : null),
|
||||
setItem: (k, v) => { stored[k] = String(v); },
|
||||
removeItem: (k) => { delete stored[k]; },
|
||||
};
|
||||
globalThis.fetch = async (...args) => { fetchCalls.push(args); return fetchImpl(...args); };
|
||||
|
||||
const { withWsTicket } = await import('./ws-ticket.js');
|
||||
|
||||
beforeEach(() => {
|
||||
stored = {};
|
||||
fetchCalls = [];
|
||||
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
|
||||
});
|
||||
|
||||
test('with no stored token the URL is returned unchanged and nothing is fetched', async () => {
|
||||
// Auth is off, so no ticket is needed. Minting one would be a pointless
|
||||
// round-trip on every reconnect.
|
||||
const url = await withWsTicket('ws://host/ws/sensing');
|
||||
assert.equal(url, 'ws://host/ws/sensing');
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
|
||||
test('a minted ticket is appended and the bearer is sent only in the header', async () => {
|
||||
stored[STORAGE_KEY] = 'secret-bearer';
|
||||
const url = await withWsTicket('ws://host/ws/sensing');
|
||||
|
||||
assert.equal(url, 'ws://host/ws/sensing?ticket=T');
|
||||
// The long-lived bearer must never reach a URL — only the bounded ticket does.
|
||||
assert.ok(!url.includes('secret-bearer'), `bearer leaked into URL: ${url}`);
|
||||
|
||||
const [path, init] = fetchCalls[0];
|
||||
assert.equal(path, '/api/v1/ws-ticket');
|
||||
assert.equal(init.method, 'POST');
|
||||
assert.equal(init.headers.Authorization, 'Bearer secret-bearer');
|
||||
});
|
||||
|
||||
test('an existing query string gets & rather than a second ?', async () => {
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
const url = await withWsTicket('ws://host/ws/sensing?foo=1');
|
||||
assert.equal(url, 'ws://host/ws/sensing?foo=1&ticket=T');
|
||||
});
|
||||
|
||||
test('the ticket value is URL-encoded', async () => {
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'a+b/c=d' }) });
|
||||
const url = await withWsTicket('ws://host/ws/sensing');
|
||||
assert.ok(url.endsWith('ticket=a%2Bb%2Fc%3Dd'), url);
|
||||
});
|
||||
|
||||
test('404 means a server predating ADR-272, so connect without a ticket', async () => {
|
||||
// That server still exempts WebSockets, so an unticketed connect is correct.
|
||||
// This is what lets one UI work against both old and new servers, which is
|
||||
// what makes the legacy escape hatch removable rather than permanent.
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
fetchImpl = async () => ({ ok: false, status: 404, json: async () => ({}) });
|
||||
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
|
||||
});
|
||||
|
||||
test('a 503 does not append a ticket and does not throw', async () => {
|
||||
// Ticket store exhausted. The socket attempt will fail on its own and the
|
||||
// caller's reconnect logic handles it; throwing here would duplicate that.
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
fetchImpl = async () => ({ ok: false, status: 503, json: async () => ({}) });
|
||||
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
|
||||
});
|
||||
|
||||
test('a network failure is swallowed rather than breaking the connect path', async () => {
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
fetchImpl = async () => { throw new Error('offline'); };
|
||||
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
|
||||
});
|
||||
|
||||
test('a 200 with no ticket field yields no ticket', async () => {
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({}) });
|
||||
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
|
||||
});
|
||||
|
||||
test('a fresh ticket is minted per call and never reused', async () => {
|
||||
// Tickets are single-use and expire in ~30s, so caching one across reconnects
|
||||
// fails on the second attempt.
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
let n = 0;
|
||||
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: `T${++n}` }) });
|
||||
|
||||
assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T1');
|
||||
assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T2');
|
||||
assert.equal(fetchCalls.length, 2, 'each connect attempt must mint its own');
|
||||
});
|
||||
@@ -1,7 +1,27 @@
|
||||
// RuView Service Worker - Offline caching for the dashboard shell
|
||||
// Strategy: Network-first for API calls, Cache-first for static assets
|
||||
|
||||
const CACHE_NAME = 'ruview-v1';
|
||||
// Bumped from v1: an older SW cached `/oauth/status` cache-first, so browsers
|
||||
// that ran it hold a permanently signed-out answer. `activate` deletes every
|
||||
// cache whose name is not CACHE_NAME, so bumping is what evicts it from clients
|
||||
// already in the field. Bump again if a future change poisons the cache.
|
||||
const CACHE_NAME = 'ruview-v2';
|
||||
|
||||
// Requests whose response depends on the caller's credentials. These must never
|
||||
// be served from the Cache API.
|
||||
//
|
||||
// The Cache API is NOT the HTTP cache: it ignores `Cache-Control` completely, so
|
||||
// the `no-store, no-cache, must-revalidate` the server already sends on
|
||||
// `/oauth/status` has no effect here. A cached signed-out response was returned
|
||||
// to the page forever, and only a hard reload — which bypasses the service
|
||||
// worker entirely — showed the true state. (ADR-271.)
|
||||
const NEVER_CACHE_PREFIXES = ['/oauth/'];
|
||||
|
||||
// What may be served cache-first. Previously cache-first was the *catch-all* for
|
||||
// everything outside `/api/` and `/health/`, which meant any endpoint added at a
|
||||
// new path was frozen on first response. An allowlist fails safe instead: an
|
||||
// unrecognised path goes to the network untouched.
|
||||
const STATIC_ASSET = /\.(?:js|mjs|css|html|json|png|jpe?g|gif|svg|ico|webp|woff2?|ttf|map)$/i;
|
||||
const SHELL_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
@@ -74,25 +94,49 @@ self.addEventListener('fetch', (event) => {
|
||||
// Skip cross-origin requests
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Credentialed endpoints: hands off entirely. Not networkFirst — that still
|
||||
// writes a copy into the cache, which would be replayed the moment the server
|
||||
// is briefly unreachable, silently reinstating a stale sign-in state.
|
||||
if (NEVER_CACHE_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) {
|
||||
// Signing out is the one moment we know cached data belongs to a session
|
||||
// that is ending. Observed, not intercepted — the request itself still goes
|
||||
// straight to the network. `waitUntil` keeps the worker alive for the purge
|
||||
// even though the navigation is what the browser is really waiting on.
|
||||
if (url.pathname === '/oauth/logout') {
|
||||
event.waitUntil(purgeNonShell());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// API calls: network-first with cache fallback
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/health/')) {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first with network fallback
|
||||
event.respondWith(cacheFirst(request));
|
||||
// Static assets and the app shell: cache-first with network fallback.
|
||||
if (request.mode === 'navigate' || STATIC_ASSET.test(url.pathname)) {
|
||||
event.respondWith(cacheFirst(request));
|
||||
}
|
||||
|
||||
// Anything else is left alone and goes to the network as normal.
|
||||
});
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cached = await caches.match(request);
|
||||
// `ignoreSearch` so the shell is a single entry. Sign-in redirects back to
|
||||
// `/ui/?signed_in=<ms>`, which would otherwise mint a fresh cache entry per
|
||||
// sign-in and never hit any of them again.
|
||||
const cached = await caches.match(request, { ignoreSearch: true });
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
// Store under the search-less URL to match how it is looked up above.
|
||||
const key = new URL(request.url);
|
||||
key.search = '';
|
||||
cache.put(key.toString(), response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
@@ -105,20 +149,54 @@ async function cacheFirst(request) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-only, with an explicit offline signal.
|
||||
*
|
||||
* This used to cache every successful `/api/` response and replay it whenever
|
||||
* the network failed. Two things are wrong with that now:
|
||||
*
|
||||
* 1. **Authorization.** API responses are per-user once auth is on, but the
|
||||
* cache is keyed by URL alone and nothing purges it at sign-out. Sign in as
|
||||
* A, load sensing data, sign out, sign in as B, lose the network — B is
|
||||
* served A's data with no authorization check at all. That is the same
|
||||
* defect class as the cached `/oauth/status`: the Cache API happily outlives
|
||||
* the session that produced its contents.
|
||||
* 2. **Correctness.** This is a live sensing dashboard. Replaying a stale pose
|
||||
* or presence reading as if it were current is its own defect — it can show
|
||||
* a room as occupied after the person has left.
|
||||
*
|
||||
* The offline shell (HTML/CSS/JS) is still cached; only the data is not. If
|
||||
* offline data replay is wanted back, it needs a per-session cache key and a
|
||||
* purge on sign-out, not a URL-keyed shared cache.
|
||||
*/
|
||||
async function networkFirst(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
return await fetch(request);
|
||||
} catch {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
return new Response(JSON.stringify({ error: 'offline' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop everything except the static shell.
|
||||
*
|
||||
* Called when the user signs out. Belt-and-braces: nothing user-specific should
|
||||
* be in the cache after the `networkFirst` change above, but a cache populated
|
||||
* by an OLDER worker on this browser can still hold API responses, and that
|
||||
* worker's entries survive into this one under the same name.
|
||||
*/
|
||||
async function purgeNonShell() {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const keys = await cache.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((req) => {
|
||||
const p = new URL(req.url).pathname;
|
||||
return p.startsWith('/api/') || p.startsWith('/health/');
|
||||
})
|
||||
.map((req) => cache.delete(req))
|
||||
);
|
||||
}
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
// Executed tests for the service worker's request routing (ADR-271/272).
|
||||
//
|
||||
// WHY THIS EXISTS.
|
||||
// Browser sign-in appeared to fail: after a successful OAuth round-trip the
|
||||
// settings panel still offered "Sign in with Cognitum", and only a hard reload
|
||||
// showed the truth. The server was correct throughout — `/oauth/status` fell
|
||||
// through to the service worker's cache-first catch-all, so the first
|
||||
// (signed-out) response was stored in the Cache API and replayed forever.
|
||||
//
|
||||
// The Cache API is not the HTTP cache. It ignores `Cache-Control` entirely, so
|
||||
// the `no-store` the server already sent could not prevent this. Nothing in the
|
||||
// Rust suite, the UI unit tests, or a curl probe could observe it: curl has no
|
||||
// service worker, and a hard reload bypasses one. It was only visible by
|
||||
// driving a real browser.
|
||||
//
|
||||
// These tests load the REAL `sw.js` with stubbed worker globals and assert on
|
||||
// which strategy each path is routed to.
|
||||
//
|
||||
// Run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
|
||||
// (a directory argument does not work — Node resolves it as a module)
|
||||
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const SW_SOURCE = readFileSync(fileURLToPath(new URL('./sw.js', import.meta.url)), 'utf8');
|
||||
const ORIGIN = 'http://127.0.0.1:8099';
|
||||
|
||||
/** Load sw.js in a fresh context and return its registered `fetch` listener. */
|
||||
function loadServiceWorker() {
|
||||
const listeners = {};
|
||||
const cachePuts = [];
|
||||
|
||||
// A real in-memory cache, so purge and put behaviour can be observed rather
|
||||
// than assumed.
|
||||
const entries = new Map();
|
||||
const cacheStub = {
|
||||
addAll: async () => {},
|
||||
put: async (req, res) => {
|
||||
const url = String(req.url ?? req);
|
||||
cachePuts.push(url);
|
||||
entries.set(url, res);
|
||||
},
|
||||
keys: async () => Array.from(entries.keys()).map((url) => ({ url })),
|
||||
delete: async (req) => entries.delete(String(req.url ?? req)),
|
||||
match: async () => undefined,
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
self: {
|
||||
addEventListener: (name, fn) => { listeners[name] = fn; },
|
||||
location: { origin: ORIGIN },
|
||||
skipWaiting: () => {},
|
||||
clients: { claim: () => {} },
|
||||
},
|
||||
caches: {
|
||||
open: async () => cacheStub,
|
||||
keys: async () => [],
|
||||
delete: async () => true,
|
||||
match: async () => undefined,
|
||||
},
|
||||
// Never actually reached: every assertion below inspects the routing
|
||||
// decision, not the network.
|
||||
fetch: async () => ({ ok: true, clone: () => ({}) }),
|
||||
URL,
|
||||
Response: class { constructor(body, init) { this.body = body; Object.assign(this, init); } },
|
||||
console,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(SW_SOURCE, sandbox);
|
||||
|
||||
return { listeners, cachePuts, sandbox, entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Route one request and report the decision.
|
||||
* `handled: false` means the SW called neither respondWith nor cache — the
|
||||
* request goes to the network untouched, which is the only safe outcome for a
|
||||
* credentialed endpoint.
|
||||
*/
|
||||
function route(path, opts = {}) {
|
||||
return dispatch(path, opts).handled;
|
||||
}
|
||||
|
||||
/** Route one request and expose everything the worker did with it. */
|
||||
function dispatch(path, { method = 'GET', mode = 'cors', headers = {}, sw = null } = {}) {
|
||||
const worker = sw ?? loadServiceWorker();
|
||||
let handled = false;
|
||||
let responded = null;
|
||||
const waited = [];
|
||||
const request = {
|
||||
url: `${ORIGIN}${path}`,
|
||||
method,
|
||||
mode,
|
||||
headers: { get: (k) => headers[k] ?? headers[k.toLowerCase()] ?? null },
|
||||
};
|
||||
worker.listeners.fetch({
|
||||
request,
|
||||
respondWith: (p) => { handled = true; responded = p; },
|
||||
waitUntil: (p) => { waited.push(p); },
|
||||
});
|
||||
return { handled, responded, waited, worker };
|
||||
}
|
||||
|
||||
let sw;
|
||||
beforeEach(() => { sw = loadServiceWorker(); });
|
||||
|
||||
// --- the defect this file exists for ----------------------------------------
|
||||
|
||||
test('/oauth/status is never handled by the service worker', () => {
|
||||
// The exact request whose cached signed-out copy made sign-in look broken.
|
||||
assert.equal(route('/oauth/status'), false);
|
||||
});
|
||||
|
||||
test('every /oauth/ path bypasses the worker, not just status', () => {
|
||||
// start/callback/logout all carry or clear credentials. A cached redirect or
|
||||
// Set-Cookie replayed later is worse than a cached status.
|
||||
for (const p of ['/oauth/start', '/oauth/callback?code=x&state=y', '/oauth/logout']) {
|
||||
assert.equal(route(p), false, `${p} must go straight to the network`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- the underlying defect: cache-first was the catch-all -------------------
|
||||
|
||||
test('an unrecognised path is left to the network rather than cached', () => {
|
||||
// This is what made the OAuth bug possible in the first place: any endpoint
|
||||
// added outside /api/ was silently frozen on its first response. An
|
||||
// allowlist means the next such endpoint is safe by default.
|
||||
assert.equal(route('/some/future/endpoint'), false);
|
||||
});
|
||||
|
||||
test('static assets are still served cache-first', () => {
|
||||
// The offline shell is the point of the worker; the fix must not disable it.
|
||||
for (const p of ['/app.js', '/style.css', '/components/TabManager.js', '/icons/logo.svg']) {
|
||||
assert.equal(route(p), true, `${p} should be cache-first`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a navigation request is still served cache-first', () => {
|
||||
assert.equal(route('/ui/', { mode: 'navigate' }), true);
|
||||
});
|
||||
|
||||
test('API paths are still routed through the worker', () => {
|
||||
// Handled, but network-only — see the "not written to the cache" test below.
|
||||
assert.equal(route('/api/v1/models'), true);
|
||||
assert.equal(route('/health/live'), true);
|
||||
});
|
||||
|
||||
// --- pre-existing guards, pinned so the rewrite did not drop them -----------
|
||||
|
||||
test('non-GET requests are ignored', () => {
|
||||
assert.equal(route('/api/v1/models', { method: 'POST' }), false);
|
||||
});
|
||||
|
||||
test('websocket upgrades are ignored', () => {
|
||||
assert.equal(route('/ws/sensing', { headers: { Upgrade: 'websocket' } }), false);
|
||||
});
|
||||
|
||||
test('cross-origin requests are ignored', () => {
|
||||
const { listeners } = loadServiceWorker();
|
||||
let handled = false;
|
||||
listeners.fetch({
|
||||
request: {
|
||||
url: 'https://auth.cognitum.one/oauth/authorize',
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
headers: { get: () => null },
|
||||
},
|
||||
respondWith: () => { handled = true; },
|
||||
});
|
||||
assert.equal(handled, false);
|
||||
});
|
||||
|
||||
// --- authenticated API responses must not be retained ------------------------
|
||||
// Filed by the cross-vendor prosecutor in the qe-court round after the
|
||||
// /oauth/status fix: closing the /oauth/ leg left the /api/ leg open.
|
||||
|
||||
test('a successful API response is NOT written to the cache', async () => {
|
||||
// The leak: cache keys are URLs, nothing partitions them by session, and
|
||||
// nothing purged them at sign-out. Sign in as A, fetch sensing data, sign
|
||||
// out, sign in as B, lose the network -> B is served A's data.
|
||||
const { responded, worker } = dispatch('/api/v1/sensing/latest');
|
||||
await responded;
|
||||
assert.deepEqual(worker.cachePuts, [], 'API responses must not be cached');
|
||||
});
|
||||
|
||||
test('an API request with no network returns 503 rather than stale data', async () => {
|
||||
// Also a correctness property, not only an authorization one: replaying a
|
||||
// stale pose reading as current can show a room occupied after the person
|
||||
// has left.
|
||||
const worker = loadServiceWorker();
|
||||
worker.sandbox.fetch = async () => { throw new Error('offline'); };
|
||||
worker.entries.set(`${ORIGIN}/api/v1/sensing/latest`, { stale: true });
|
||||
|
||||
const { responded } = dispatch('/api/v1/sensing/latest', { sw: worker });
|
||||
const res = await responded;
|
||||
assert.equal(res.status, 503);
|
||||
assert.match(String(res.body), /offline/);
|
||||
});
|
||||
|
||||
test('signing out purges cached API data but keeps the offline shell', async () => {
|
||||
const worker = loadServiceWorker();
|
||||
worker.entries.set(`${ORIGIN}/api/v1/sensing/latest`, {});
|
||||
worker.entries.set(`${ORIGIN}/health/live`, {});
|
||||
worker.entries.set(`${ORIGIN}/app.js`, {});
|
||||
|
||||
const { handled, waited } = dispatch('/oauth/logout', { sw: worker });
|
||||
// Observed, not intercepted — the logout request itself must still reach the
|
||||
// server, or signing out would not actually sign anyone out.
|
||||
assert.equal(handled, false, '/oauth/logout must still go to the network');
|
||||
assert.equal(waited.length, 1, 'the purge must be kept alive via waitUntil');
|
||||
await Promise.all(waited);
|
||||
|
||||
const left = Array.from(worker.entries.keys());
|
||||
assert.deepEqual(left, [`${ORIGIN}/app.js`], 'only the static shell should survive');
|
||||
});
|
||||
|
||||
// --- cache hygiene -----------------------------------------------------------
|
||||
|
||||
test('the cache name is bumped so clients holding the poisoned v1 evict it', () => {
|
||||
// `activate` deletes every cache whose name !== CACHE_NAME. Browsers that
|
||||
// already ran the old worker hold a signed-out /oauth/status in `ruview-v1`;
|
||||
// only a name change removes it for them.
|
||||
assert.ok(!/['"]ruview-v1['"]/.test(SW_SOURCE), 'CACHE_NAME must not still be ruview-v1');
|
||||
assert.ok(/CACHE_NAME\s*=\s*['"]ruview-v[2-9]/.test(SW_SOURCE));
|
||||
});
|
||||
@@ -74,6 +74,16 @@ export class QuickSettings {
|
||||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">Cognitum Account</div>
|
||||
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
|
||||
<span id="qs-signin-status" style="font-size: 0.9em; opacity: 0.85;">Checking...</span>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="qs-btn" id="qs-signin" hidden>Sign in with Cognitum</button>
|
||||
<button class="qs-btn-danger" id="qs-signout" hidden>Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">API Access</div>
|
||||
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
|
||||
@@ -103,6 +113,23 @@ export class QuickSettings {
|
||||
// Bind events
|
||||
this.panel.querySelector('.qs-close').addEventListener('click', () => this.close());
|
||||
|
||||
// Re-check sign-in state whenever the page could be showing a stale view:
|
||||
// `pageshow` fires on a back/forward-cache restore (where no script re-runs
|
||||
// and no fetch would otherwise happen), and `visibilitychange` covers
|
||||
// signing in or out in another tab. Opening the panel alone is not enough —
|
||||
// the panel may already be open, or the page may be restored wholesale.
|
||||
window.addEventListener('pageshow', () => { void refreshSignInPanel(this.panel); });
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) void refreshSignInPanel(this.panel);
|
||||
});
|
||||
|
||||
// ADR-271 sign-in. Bound here as well as in refreshSignInPanel so a click
|
||||
// works even if the status fetch has not resolved yet.
|
||||
this.panel.querySelector('#qs-signin')
|
||||
.addEventListener('click', () => { window.location.href = '/oauth/start'; });
|
||||
this.panel.querySelector('#qs-signout')
|
||||
.addEventListener('click', () => { window.location.href = '/oauth/logout'; });
|
||||
|
||||
this.panel.querySelector('#qs-reduced-motion').addEventListener('change', (e) => {
|
||||
document.body.classList.toggle('reduced-motion', e.target.checked);
|
||||
this.saveSetting('reduced-motion', e.target.checked);
|
||||
@@ -211,6 +238,11 @@ export class QuickSettings {
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.panel.classList.add('open');
|
||||
// Refresh on every open, not once at construction: the session may have
|
||||
// been established in another tab, or expired since the page loaded.
|
||||
// Fire-and-forget — a failure renders as a message in the panel, and must
|
||||
// not stop the panel opening.
|
||||
void refreshSignInPanel(this.panel);
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -233,3 +265,66 @@ export class QuickSettings {
|
||||
this.panel?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cognitum browser sign-in (ADR-271) -------------------------------------
|
||||
//
|
||||
// `/oauth/status` is intentionally UNGATED: a signed-out browser cannot ask a
|
||||
// gated endpoint whether sign-in is available. It returns capability flags and,
|
||||
// when a session exists, who it belongs to — never a credential.
|
||||
//
|
||||
// Sign-in is a full-page navigation, not fetch(): the server replies 302 to
|
||||
// auth.cognitum.one, and the browser must follow it and carry the transaction
|
||||
// cookie. An XHR would follow the redirect invisibly and land nowhere useful.
|
||||
export async function refreshSignInPanel(root = document) {
|
||||
const status = root.querySelector('#qs-signin-status');
|
||||
const signIn = root.querySelector('#qs-signin');
|
||||
const signOut = root.querySelector('#qs-signout');
|
||||
if (!status || !signIn || !signOut) return null;
|
||||
|
||||
let info;
|
||||
try {
|
||||
const resp = await fetch('/oauth/status', { credentials: 'same-origin' });
|
||||
// 404 = a server predating ADR-271. Say so plainly rather than offering a
|
||||
// button that will 404.
|
||||
if (resp.status === 404) {
|
||||
status.textContent = 'This server does not support Cognitum sign-in.';
|
||||
signIn.hidden = true;
|
||||
signOut.hidden = true;
|
||||
return null;
|
||||
}
|
||||
if (!resp.ok) throw new Error(`status ${resp.status}`);
|
||||
info = await resp.json();
|
||||
} catch (err) {
|
||||
status.textContent = `Could not reach the server (${err.message}).`;
|
||||
signIn.hidden = true;
|
||||
signOut.hidden = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (info.signed_in) {
|
||||
status.textContent = `Signed in${info.account ? ` as ${info.account}` : ''}${
|
||||
info.scope ? ` - ${info.scope}` : ''
|
||||
}`;
|
||||
signIn.hidden = true;
|
||||
signOut.hidden = false;
|
||||
} else if (info.browser_signin) {
|
||||
status.textContent = info.auth_required
|
||||
? 'This server requires sign-in.'
|
||||
: 'Optional: sign in to use your Cognitum account.';
|
||||
signIn.hidden = false;
|
||||
signOut.hidden = true;
|
||||
} else if (info.auth_required) {
|
||||
// Auth is on but OAuth is not — the static-token panel below is the path.
|
||||
status.textContent = 'This server uses a shared API token (see API Access below).';
|
||||
signIn.hidden = true;
|
||||
signOut.hidden = true;
|
||||
} else {
|
||||
status.textContent = 'This server does not require sign-in.';
|
||||
signIn.hidden = true;
|
||||
signOut.hidden = true;
|
||||
}
|
||||
|
||||
signIn.onclick = () => { window.location.href = '/oauth/start'; };
|
||||
signOut.onclick = () => { window.location.href = '/oauth/logout'; };
|
||||
return info;
|
||||
}
|
||||
|
||||
Generated
+184
-1
@@ -392,6 +392,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -1533,6 +1539,18 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -1969,6 +1987,20 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
@@ -2002,6 +2034,26 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"ff",
|
||||
"generic-array 0.14.7",
|
||||
"group",
|
||||
"pem-rfc7468",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.6"
|
||||
@@ -2160,6 +2212,16 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
@@ -2941,6 +3003,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3145,6 +3208,17 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk"
|
||||
version = "0.18.2"
|
||||
@@ -4278,6 +4352,21 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "katexit"
|
||||
version = "0.1.5"
|
||||
@@ -5594,6 +5683,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -6155,6 +6256,15 @@ dependencies = [
|
||||
"num-integer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.3.1"
|
||||
@@ -6931,6 +7041,16 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
@@ -7511,6 +7631,26 @@ version = "2.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "753a07254fa68db183949ec6c7575d890da4d42404afabc11d610a720fcf570c"
|
||||
|
||||
[[package]]
|
||||
name = "ruview-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"jsonwebtoken",
|
||||
"libc",
|
||||
"p256",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"ureq 2.12.1",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-swarm"
|
||||
version = "0.1.0"
|
||||
@@ -7666,6 +7806,20 @@ dependencies = [
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array 0.14.7",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
@@ -8131,6 +8285,18 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simsimd"
|
||||
version = "5.9.11"
|
||||
@@ -10851,6 +11017,13 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-aether"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-bfld"
|
||||
version = "0.3.1"
|
||||
@@ -10895,6 +11068,8 @@ dependencies = [
|
||||
"ndarray 0.17.2",
|
||||
"num-complex",
|
||||
"predicates",
|
||||
"reqwest 0.12.28",
|
||||
"ruview-auth",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tabled",
|
||||
@@ -11128,26 +11303,35 @@ name = "wifi-densepose-sensing-server"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.21.7",
|
||||
"chrono",
|
||||
"clap",
|
||||
"criterion",
|
||||
"futures-util",
|
||||
"hmac",
|
||||
"jsonwebtoken",
|
||||
"midstreamer-attractor",
|
||||
"midstreamer-temporal-compare",
|
||||
"p256",
|
||||
"proptest",
|
||||
"rand 0.8.5",
|
||||
"rumqttc",
|
||||
"ruvector-mincut",
|
||||
"ruview-auth",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower 0.4.13",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq 2.12.1",
|
||||
"wifi-densepose-aether",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-engine",
|
||||
"wifi-densepose-geo",
|
||||
@@ -11219,7 +11403,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"walkdir",
|
||||
"wifi-densepose-nn",
|
||||
"wifi-densepose-signal",
|
||||
]
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ members = [
|
||||
"crates/wifi-densepose-mat",
|
||||
"crates/wifi-densepose-train",
|
||||
"crates/wifi-densepose-sensing-server",
|
||||
"crates/wifi-densepose-aether", # ADR-185 §13 — AETHER pure-compute leaf (std-only)
|
||||
"crates/wifi-densepose-wifiscan",
|
||||
"crates/wifi-densepose-vitals",
|
||||
"crates/wifi-densepose-ruvector",
|
||||
@@ -28,6 +29,12 @@ members = [
|
||||
# geo + worldgraph extracted to ruvnet/worldgraph submodule (see crates/worldgraph)
|
||||
"crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer
|
||||
"crates/wifi-densepose-calibration", # ADR-151 — per-room calibration & specialist training
|
||||
# ADR-271 — Cognitum OAuth access-token verification. RuView as an OAuth
|
||||
# RESOURCE SERVER: offline ES256/JWKS verification of tokens issued by
|
||||
# auth.cognitum.one, so a user signs in to their own sensing server with
|
||||
# their Cognitum identity instead of a shared static bearer. No login flow
|
||||
# and no outbound Cognitum API calls live here — verification only.
|
||||
"crates/ruview-auth",
|
||||
"crates/nvsim",
|
||||
"crates/nvsim-server",
|
||||
"crates/homecore", # ADR-127 — HOMECORE state machine
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
[package]
|
||||
name = "ruview-auth"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Cognitum OAuth access-token verification for RuView (ADR-271)"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
# Same major as the service that ISSUES these tokens
|
||||
# (cognitum-one/dashboard `services/identity`, workspace `jsonwebtoken = "9"`).
|
||||
# Signature math is delegated to this crate; nothing here hand-rolls crypto.
|
||||
jsonwebtoken = "9"
|
||||
|
||||
# `ureq`, not `reqwest`: `wifi-densepose-sensing-server` — the first consumer —
|
||||
# deliberately chose ureq as "the smallest" HTTP client (see its Cargo.toml).
|
||||
# Adding reqwest here would silently reverse that decision for the whole
|
||||
# dependency graph. Optional so a caller can supply its own transport via
|
||||
# `JwksFetcher` and take no HTTP dependency at all.
|
||||
ureq = { version = "2", default-features = false, features = ["tls", "json"], optional = true }
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# --- `login` feature only (ADR-271 phase 2) -------------------------------
|
||||
# The login flow is an interactive client concern: a browser, a loopback
|
||||
# listener, a token exchange. The sensing server needs none of it and must not
|
||||
# pay for it, so every dependency here is optional and off by default. A server
|
||||
# built with default features gets the verifier and nothing more.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
rand = { version = "0.8", optional = true }
|
||||
sha2 = { workspace = true, optional = true }
|
||||
base64 = { version = "0.21", optional = true }
|
||||
url = { version = "2", optional = true }
|
||||
# Advisory cross-process file lock around the refresh critical section (Unix).
|
||||
libc = { version = "0.2", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["ureq-transport"]
|
||||
ureq-transport = ["dep:ureq"]
|
||||
# PKCE generation only (RFC 7636). Light: rand + sha2 + base64, no HTTP stack.
|
||||
# A resource server that runs its own browser sign-in redirect needs this
|
||||
# WITHOUT the client-side login machinery.
|
||||
pkce = ["dep:rand", "dep:sha2", "dep:base64"]
|
||||
# Interactive OAuth login: PKCE, loopback callback, OOB paste fallback,
|
||||
# credential storage, single-flight refresh. Opt in from a CLI or desktop app.
|
||||
login = ["pkce", "dep:reqwest", "dep:tokio", "dep:url", "dep:libc"]
|
||||
|
||||
[dev-dependencies]
|
||||
# Test-only: sign real ES256 tokens so the negative matrix exercises the same
|
||||
# code path production does, rather than asserting against hand-built strings.
|
||||
jsonwebtoken = "9"
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Keypairs are GENERATED AT TEST RUNTIME, never committed. A checked-in
|
||||
# `-----BEGIN PRIVATE KEY-----` is inert here but it trains scanners and readers
|
||||
# to treat committed key material as normal, and this repo has no such
|
||||
# precedent (zero tracked `.pem` files). Generating also makes the matrix
|
||||
# self-contained: no fixture can drift out of sync with the JWKS it is served by.
|
||||
p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] }
|
||||
base64 = "0.21"
|
||||
@@ -0,0 +1,567 @@
|
||||
//! JWKS fetch + cache, keyed by `kid`.
|
||||
//!
|
||||
//! Ported from `cognitum-one/dashboard` `services/identity/src/jwks.rs` (the
|
||||
//! same team that signs these tokens), with the unknown-`kid` forced refetch
|
||||
//! from `meta-llm/src/auth/oauthBearer.ts`. Like both, this is
|
||||
//! `jsonwebtoken` + `DecodingKey` only — nothing here hand-rolls signature math.
|
||||
//!
|
||||
//! ## Offline behaviour is a feature, not an oversight
|
||||
//!
|
||||
//! RuView runs on Raspberry-Pi-class hardware that loses WAN. On a refetch
|
||||
//! failure we keep serving the keys we already have and log a warning, because
|
||||
//! a signing key that verified a minute ago has not stopped being valid because
|
||||
//! our network blipped — and failing closed there would log every user out of
|
||||
//! their own sensing server whenever their internet wobbled.
|
||||
//!
|
||||
//! We fail closed in exactly one case: **we have never successfully fetched a
|
||||
//! key set.** Then there is nothing to reason with, and admitting a request
|
||||
//! would mean admitting an unverified token.
|
||||
//!
|
||||
//! ## The lock is never held across the network call
|
||||
//!
|
||||
//! [`JwksCache::decoding_key_for`] reads the cache under the lock, RELEASES it,
|
||||
//! does any HTTP, then re-takes the lock only to install the result.
|
||||
//!
|
||||
//! This is not tidiness. An earlier revision held a `std::sync::Mutex` across a
|
||||
//! blocking `ureq` call made from inside async middleware. `Mutex::lock()` in an
|
||||
//! async fn is a real blocking syscall, not a yield point — so one slow or
|
||||
//! unreachable JWKS fetch (up to the 3s timeout, longer if the link is dead)
|
||||
//! blocked EVERY concurrent request on that mutex, including requests carrying
|
||||
//! already-cached, perfectly valid tokens, and parked the tokio worker threads
|
||||
//! they were running on. On Pi-class hardware with few workers that stalls the
|
||||
//! whole server, and it fires on the routine 300s TTL rollover whenever the
|
||||
//! network is degraded — precisely the offline-tolerance case this module
|
||||
//! exists to handle.
|
||||
//!
|
||||
//! The cost of releasing the lock is that two callers can fetch concurrently
|
||||
//! during a rollover. That is a harmless duplicated idempotent GET, and it is
|
||||
//! strictly better than serialising every request behind one socket.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use jsonwebtoken::DecodingKey;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// How long a fetched key set is trusted before a routine re-fetch.
|
||||
/// Identity uses 300 s for the same job; matching it keeps staleness bounded
|
||||
/// without putting an outbound request on every verify.
|
||||
pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Floor between fetch ATTEMPTS — every attempt, not just the unknown-`kid`
|
||||
/// forced refetch, and regardless of whether the attempt succeeded.
|
||||
///
|
||||
/// Without this, two things go wrong. A stream of tokens bearing a bogus `kid`
|
||||
/// becomes an outbound request amplifier pointed at the identity service. And,
|
||||
/// more damagingly, once the cache goes stale (`fetched_at` only advances on
|
||||
/// success) *every* request performs its own fetch — so a lost WAN link turns
|
||||
/// into a self-inflicted stall rather than the graceful degradation this module
|
||||
/// promises.
|
||||
pub const FETCH_MIN_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Wire timeout for a single JWKS fetch. meta-llm uses 3 s; a verify path must
|
||||
/// never be able to hang on a slow upstream.
|
||||
pub const DEFAULT_FETCH_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum JwksError {
|
||||
#[error("JWKS fetch failed: {0}")]
|
||||
Fetch(String),
|
||||
#[error("JWKS document malformed: {0}")]
|
||||
Malformed(String),
|
||||
#[error("JWKS document contained no usable EC keys")]
|
||||
NoUsableKeys,
|
||||
#[error("no key in JWKS matches kid {0:?}")]
|
||||
UnknownKid(String),
|
||||
#[error("token header has no kid")]
|
||||
MissingKid,
|
||||
/// Never fetched successfully — fail closed.
|
||||
#[error("JWKS unavailable and no key set has ever been cached")]
|
||||
NeverFetched,
|
||||
}
|
||||
|
||||
/// How the key set is retrieved. Abstracted so tests run with no network and so
|
||||
/// a host that already owns an HTTP client can supply it.
|
||||
pub trait JwksFetcher: Send + Sync {
|
||||
/// Return the raw JWKS document body.
|
||||
fn fetch(&self, url: &str) -> Result<String, JwksError>;
|
||||
}
|
||||
|
||||
/// One JWK. Only EC P-256 is accepted: identity signs with ES256 and nothing
|
||||
/// else, so parsing RSA here would add a key type we would then have to be
|
||||
/// careful never to verify with.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Jwk {
|
||||
kid: Option<String>,
|
||||
kty: String,
|
||||
crv: Option<String>,
|
||||
x: Option<String>,
|
||||
y: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JwksDocument {
|
||||
keys: Vec<Jwk>,
|
||||
}
|
||||
|
||||
struct CacheState {
|
||||
keys: HashMap<String, DecodingKey>,
|
||||
fetched_at: Option<Instant>,
|
||||
last_attempt_at: Option<Instant>,
|
||||
last_forced_refetch: Option<Instant>,
|
||||
}
|
||||
|
||||
/// `kid`-indexed JWKS cache.
|
||||
pub struct JwksCache {
|
||||
url: String,
|
||||
ttl: Duration,
|
||||
fetcher: Box<dyn JwksFetcher>,
|
||||
state: Mutex<CacheState>,
|
||||
}
|
||||
|
||||
impl JwksCache {
|
||||
pub fn new(url: impl Into<String>, fetcher: Box<dyn JwksFetcher>) -> Self {
|
||||
Self::with_ttl(url, fetcher, DEFAULT_CACHE_TTL)
|
||||
}
|
||||
|
||||
pub fn with_ttl(url: impl Into<String>, fetcher: Box<dyn JwksFetcher>, ttl: Duration) -> Self {
|
||||
Self {
|
||||
url: url.into(),
|
||||
ttl,
|
||||
fetcher,
|
||||
state: Mutex::new(CacheState {
|
||||
keys: HashMap::new(),
|
||||
fetched_at: None,
|
||||
last_attempt_at: None,
|
||||
last_forced_refetch: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch once up front so a misconfigured `jwks_uri` fails at startup rather
|
||||
/// than on a user's first request. Call this from server boot: it is what
|
||||
/// turns "OAuth is misconfigured" into a refusal to serve instead of a
|
||||
/// confusing 401 much later.
|
||||
pub fn warm(&self) -> Result<usize, JwksError> {
|
||||
let fresh = self.fetch_and_parse()?;
|
||||
let n = fresh.len();
|
||||
let mut state = self.state.lock().expect("jwks cache poisoned");
|
||||
state.keys = fresh;
|
||||
state.fetched_at = Some(Instant::now());
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Resolve the verification key for a token header's `kid`.
|
||||
pub fn decoding_key_for(&self, kid: &str) -> Result<DecodingKey, JwksError> {
|
||||
// ---- Phase 1: answer from cache, holding the lock only to read. ----
|
||||
let (fresh, have_any, may_force, may_attempt, stale_fallback) = {
|
||||
let state = self.state.lock().expect("jwks cache poisoned");
|
||||
let fresh = state
|
||||
.fetched_at
|
||||
.map_or(false, |at| at.elapsed() < self.ttl);
|
||||
let cached = state.keys.get(kid).cloned();
|
||||
// A fresh cache that HAS the key is the overwhelmingly common path
|
||||
// and answers without touching anything else.
|
||||
if fresh {
|
||||
if let Some(key) = cached {
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
let may_force = state
|
||||
.last_forced_refetch
|
||||
.map_or(true, |at| at.elapsed() >= FETCH_MIN_INTERVAL);
|
||||
let may_attempt = state
|
||||
.last_attempt_at
|
||||
.map_or(true, |at| at.elapsed() >= FETCH_MIN_INTERVAL);
|
||||
// When the cache is fresh but lacks this kid there is nothing stale
|
||||
// worth serving — identity may have rotated, and a refetch is the
|
||||
// whole point. When it is stale, a previously-valid key beats an
|
||||
// error if we are rate-limited.
|
||||
let stale_fallback = if fresh { None } else { cached };
|
||||
(
|
||||
fresh,
|
||||
state.fetched_at.is_some(),
|
||||
may_force,
|
||||
may_attempt,
|
||||
stale_fallback,
|
||||
)
|
||||
};
|
||||
// Lock released. Everything below may take milliseconds-to-seconds and
|
||||
// MUST NOT hold it — see the module docs.
|
||||
|
||||
// TWO independent rate limiters, because they solve different problems.
|
||||
// Merging them looks tidy and is wrong: a routine refetch would then
|
||||
// suppress the unknown-`kid` path for 30s, delaying pickup of a key
|
||||
// rotation that happened inside the TTL.
|
||||
if fresh {
|
||||
// Fresh cache, unknown kid: identity may have rotated. One forced
|
||||
// refetch per floor, so a flood of junk-`kid` tokens cannot become
|
||||
// an outbound request amplifier pointed at identity.
|
||||
if !may_force {
|
||||
return Err(JwksError::UnknownKid(kid.to_owned()));
|
||||
}
|
||||
self.state
|
||||
.lock()
|
||||
.expect("jwks cache poisoned")
|
||||
.last_forced_refetch = Some(Instant::now());
|
||||
} else if !may_attempt {
|
||||
// Stale cache and we refetched recently. Serve what we have.
|
||||
//
|
||||
// This branch is the fix. `fetched_at` advances only on SUCCESS, so
|
||||
// once the TTL elapsed after the last successful fetch, `fresh` was
|
||||
// permanently false — and the ONLY limiter was gated behind
|
||||
// `if fresh`. Every request therefore performed its own blocking
|
||||
// fetch. On a Pi that loses WAN, the documented deployment, that
|
||||
// turned into a self-inflicted stall 300s after the network went
|
||||
// away, with no attacker involved.
|
||||
//
|
||||
// Serving the stale key is deliberate: one that verified a minute
|
||||
// ago has not stopped being valid because our network blipped.
|
||||
return match stale_fallback {
|
||||
Some(key) => Ok(key),
|
||||
None if have_any => Err(JwksError::UnknownKid(kid.to_owned())),
|
||||
None => Err(JwksError::NeverFetched),
|
||||
};
|
||||
}
|
||||
|
||||
// Recorded BEFORE the fetch and regardless of its outcome. Recording it
|
||||
// after, or only on success, is precisely the bug described above.
|
||||
self.state
|
||||
.lock()
|
||||
.expect("jwks cache poisoned")
|
||||
.last_attempt_at = Some(Instant::now());
|
||||
|
||||
// ---- Phase 2: network, WITHOUT the lock held. ----
|
||||
let fetched = self.fetch_and_parse();
|
||||
|
||||
// ---- Phase 3: install, holding the lock only to write. ----
|
||||
let mut state = self.state.lock().expect("jwks cache poisoned");
|
||||
match fetched {
|
||||
Ok(keys) => {
|
||||
state.keys = keys;
|
||||
state.fetched_at = Some(Instant::now());
|
||||
}
|
||||
Err(e) => {
|
||||
// A key that verified a minute ago has not stopped being valid
|
||||
// because the network blipped.
|
||||
if !have_any {
|
||||
return Err(JwksError::NeverFetched);
|
||||
}
|
||||
tracing::warn!(
|
||||
url = %self.url,
|
||||
error = %e,
|
||||
"JWKS refresh failed; continuing with the previously cached key set"
|
||||
);
|
||||
}
|
||||
}
|
||||
state
|
||||
.keys
|
||||
.get(kid)
|
||||
.cloned()
|
||||
.ok_or_else(|| JwksError::UnknownKid(kid.to_owned()))
|
||||
}
|
||||
|
||||
fn fetch_and_parse(&self) -> Result<HashMap<String, DecodingKey>, JwksError> {
|
||||
let body = self.fetcher.fetch(&self.url)?;
|
||||
parse_jwks(&body)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a JWKS document into `kid` → `DecodingKey`, skipping entries we cannot
|
||||
/// or should not use.
|
||||
fn parse_jwks(body: &str) -> Result<HashMap<String, DecodingKey>, JwksError> {
|
||||
let doc: JwksDocument =
|
||||
serde_json::from_str(body).map_err(|e| JwksError::Malformed(e.to_string()))?;
|
||||
|
||||
let mut out = HashMap::new();
|
||||
for jwk in doc.keys {
|
||||
// EC P-256 only. Anything else is skipped rather than rejected, so a
|
||||
// future key type appearing in the document does not break verification
|
||||
// with the ES256 key sitting next to it.
|
||||
if jwk.kty != "EC" {
|
||||
tracing::debug!(kty = %jwk.kty, "skipping non-EC JWK");
|
||||
continue;
|
||||
}
|
||||
if jwk.crv.as_deref() != Some("P-256") {
|
||||
tracing::debug!(crv = ?jwk.crv, "skipping EC JWK that is not P-256");
|
||||
continue;
|
||||
}
|
||||
let (Some(kid), Some(x), Some(y)) = (jwk.kid, jwk.x, jwk.y) else {
|
||||
tracing::debug!("skipping EC JWK missing kid/x/y");
|
||||
continue;
|
||||
};
|
||||
match DecodingKey::from_ec_components(&x, &y) {
|
||||
Ok(key) => {
|
||||
out.insert(kid, key);
|
||||
}
|
||||
Err(e) => tracing::debug!(kid = %kid, error = %e, "skipping unparseable EC JWK"),
|
||||
}
|
||||
}
|
||||
|
||||
if out.is_empty() {
|
||||
return Err(JwksError::NoUsableKeys);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Blocking `ureq` transport.
|
||||
///
|
||||
/// Blocking on purpose: the sensing server already runs its outbound registry
|
||||
/// fetch inside `tokio::task::spawn_blocking` for the same reason, and an async
|
||||
/// client here would pull in a second HTTP stack.
|
||||
#[cfg(feature = "ureq-transport")]
|
||||
pub struct UreqFetcher {
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ureq-transport")]
|
||||
impl UreqFetcher {
|
||||
pub fn new() -> Self {
|
||||
Self::with_timeout(DEFAULT_FETCH_TIMEOUT)
|
||||
}
|
||||
|
||||
pub fn with_timeout(timeout: Duration) -> Self {
|
||||
Self {
|
||||
agent: ureq::AgentBuilder::new()
|
||||
.timeout_connect(timeout)
|
||||
.timeout_read(timeout)
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ureq-transport")]
|
||||
impl Default for UreqFetcher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ureq-transport")]
|
||||
impl JwksFetcher for UreqFetcher {
|
||||
fn fetch(&self, url: &str) -> Result<String, JwksError> {
|
||||
let resp = self
|
||||
.agent
|
||||
.get(url)
|
||||
.call()
|
||||
.map_err(|e| JwksError::Fetch(e.to_string()))?;
|
||||
resp.into_string()
|
||||
.map_err(|e| JwksError::Fetch(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The live production key, captured 2026-07-22. Public key material — a
|
||||
/// JWKS document is served anonymously to the internet by design.
|
||||
const LIVE_KID: &str = "_jQ62WD8cCiIGkKNQB8Hg4El2TNU5rHIITV4h_ba4YM";
|
||||
const LIVE_JWKS: &str = r#"{"keys":[{"alg":"ES256","crv":"P-256","kid":"_jQ62WD8cCiIGkKNQB8Hg4El2TNU5rHIITV4h_ba4YM","kty":"EC","use":"sig","x":"ixOcTyD66hYA52GE3NeLjMsUhPTVYl1_u6DimRKmxzU","y":"KQw2gxzKBk-FTGpioh0XKcIuaxh5No-Sn_qPbw3BH1M"}]}"#;
|
||||
|
||||
/// Shared handle so a test can swap the served document or take the
|
||||
/// upstream offline *after* the fetcher has been moved into the cache.
|
||||
#[derive(Clone)]
|
||||
struct StubControl {
|
||||
body: Arc<Mutex<String>>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
offline: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl StubControl {
|
||||
fn new(body: &str) -> Self {
|
||||
Self {
|
||||
body: Arc::new(Mutex::new(body.to_owned())),
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
offline: Arc::new(Mutex::new(false)),
|
||||
}
|
||||
}
|
||||
fn calls(&self) -> usize {
|
||||
self.calls.load(Ordering::SeqCst)
|
||||
}
|
||||
fn serve(&self, body: &str) {
|
||||
*self.body.lock().unwrap() = body.to_owned();
|
||||
}
|
||||
fn go_offline(&self) {
|
||||
*self.offline.lock().unwrap() = true;
|
||||
}
|
||||
fn fetcher(&self) -> Box<StubFetcher> {
|
||||
Box::new(StubFetcher(self.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
struct StubFetcher(StubControl);
|
||||
|
||||
impl JwksFetcher for StubFetcher {
|
||||
fn fetch(&self, _url: &str) -> Result<String, JwksError> {
|
||||
self.0.calls.fetch_add(1, Ordering::SeqCst);
|
||||
if *self.0.offline.lock().unwrap() {
|
||||
return Err(JwksError::Fetch("stub offline".into()));
|
||||
}
|
||||
Ok(self.0.body.lock().unwrap().clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_live_production_jwks() {
|
||||
let keys = parse_jwks(LIVE_JWKS).expect("live JWKS parses");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert!(keys.contains_key(LIVE_KID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_document_with_no_usable_keys() {
|
||||
let rsa_only = r#"{"keys":[{"kty":"RSA","kid":"r1","n":"AQAB","e":"AQAB"}]}"#;
|
||||
assert!(matches!(
|
||||
parse_jwks(rsa_only),
|
||||
Err(JwksError::NoUsableKeys)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_a_non_p256_ec_key_rather_than_failing_the_whole_document() {
|
||||
let mixed = r#"{"keys":[
|
||||
{"kty":"EC","crv":"P-384","kid":"wrong-curve","x":"AA","y":"AA"},
|
||||
{"alg":"ES256","crv":"P-256","kid":"_jQ62WD8cCiIGkKNQB8Hg4El2TNU5rHIITV4h_ba4YM","kty":"EC","use":"sig","x":"ixOcTyD66hYA52GE3NeLjMsUhPTVYl1_u6DimRKmxzU","y":"KQw2gxzKBk-FTGpioh0XKcIuaxh5No-Sn_qPbw3BH1M"}
|
||||
]}"#;
|
||||
let keys = parse_jwks(mixed).expect("parses");
|
||||
assert_eq!(keys.len(), 1, "only the P-256 key is usable");
|
||||
assert!(!keys.contains_key("wrong-curve"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_is_an_error_not_a_panic() {
|
||||
assert!(matches!(parse_jwks("{not json"), Err(JwksError::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cached_key_is_served_without_refetching() {
|
||||
let ctl = StubControl::new(LIVE_JWKS);
|
||||
let cache = JwksCache::new("https://stub/jwks.json", ctl.fetcher());
|
||||
|
||||
cache.decoding_key_for(LIVE_KID).expect("first resolves");
|
||||
cache.decoding_key_for(LIVE_KID).expect("second resolves");
|
||||
|
||||
assert_eq!(ctl.calls(), 1, "second call hit the cache");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_fetched_plus_unreachable_upstream_fails_closed() {
|
||||
let ctl = StubControl::new(LIVE_JWKS);
|
||||
ctl.go_offline();
|
||||
let cache = JwksCache::new("https://stub/jwks.json", ctl.fetcher());
|
||||
|
||||
assert!(matches!(
|
||||
cache.decoding_key_for(LIVE_KID),
|
||||
Err(JwksError::NeverFetched)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_previously_cached_key_survives_an_upstream_outage() {
|
||||
// The offline-tolerance property RuView's edge deployment depends on:
|
||||
// a WAN blip must not log every user out of their own sensing server.
|
||||
let ctl = StubControl::new(LIVE_JWKS);
|
||||
let cache = JwksCache::with_ttl(
|
||||
"https://stub/jwks.json",
|
||||
ctl.fetcher(),
|
||||
Duration::from_millis(0), // every lookup treats the cache as stale
|
||||
);
|
||||
|
||||
cache.decoding_key_for(LIVE_KID).expect("warm the cache");
|
||||
ctl.go_offline();
|
||||
|
||||
cache
|
||||
.decoding_key_for(LIVE_KID)
|
||||
.expect("known kid still resolves while upstream is unreachable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_kid_triggers_exactly_one_forced_refetch_then_rate_limits() {
|
||||
let ctl = StubControl::new(LIVE_JWKS);
|
||||
let cache = JwksCache::new("https://stub/jwks.json", ctl.fetcher());
|
||||
|
||||
cache.decoding_key_for(LIVE_KID).expect("warm the cache");
|
||||
assert_eq!(ctl.calls(), 1);
|
||||
|
||||
// First unknown kid: one forced refetch, since rotation may have
|
||||
// happened inside the TTL.
|
||||
assert!(matches!(
|
||||
cache.decoding_key_for("bogus-kid"),
|
||||
Err(JwksError::UnknownKid(_))
|
||||
));
|
||||
assert_eq!(ctl.calls(), 2, "one forced refetch");
|
||||
|
||||
// Subsequent unknown kids inside the floor must NOT amplify: otherwise
|
||||
// a flood of junk-kid tokens becomes a DoS aimed at identity.
|
||||
for _ in 0..20 {
|
||||
let _ = cache.decoding_key_for("bogus-kid");
|
||||
}
|
||||
assert_eq!(
|
||||
ctl.calls(),
|
||||
2,
|
||||
"rate limiter prevented an outbound request per token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_cache_with_a_dead_upstream_does_not_refetch_on_every_request() {
|
||||
// THE BUG THIS GUARDS. `fetched_at` advances only on SUCCESS, and the
|
||||
// only rate limiter used to sit behind `if fresh`. So once the TTL
|
||||
// elapsed after the last successful fetch, `fresh` was permanently
|
||||
// false, the limiter was never consulted, and EVERY request performed
|
||||
// its own blocking 3s-timeout fetch. On a Pi that loses WAN that is a
|
||||
// self-inflicted stall with no attacker present — and an attacker could
|
||||
// force the same state by flooding tokens with an unknown `kid`.
|
||||
//
|
||||
// Before the fix the burst makes 25 further fetches (26 total). After
|
||||
// it, zero: the warm-up's attempt timestamp still covers the burst,
|
||||
// because the limiter now applies to the stale path too.
|
||||
let ctl = StubControl::new(LIVE_JWKS);
|
||||
let cache = JwksCache::with_ttl(
|
||||
"https://stub/jwks.json",
|
||||
ctl.fetcher(),
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
|
||||
cache.decoding_key_for(LIVE_KID).expect("warm the cache");
|
||||
assert_eq!(ctl.calls(), 1);
|
||||
|
||||
ctl.go_offline();
|
||||
std::thread::sleep(Duration::from_millis(10)); // TTL elapses
|
||||
|
||||
for i in 0..25 {
|
||||
// Still answered from the stale cache: a key that verified a moment
|
||||
// ago has not stopped being valid because the network went away.
|
||||
cache
|
||||
.decoding_key_for(LIVE_KID)
|
||||
.unwrap_or_else(|e| panic!("request {i} lost its cached key: {e}"));
|
||||
}
|
||||
assert_eq!(
|
||||
ctl.calls(),
|
||||
1,
|
||||
"the burst must add NO outbound fetches; only the warm-up fetched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_key_is_picked_up_inside_the_ttl() {
|
||||
let ctl = StubControl::new(
|
||||
r#"{"keys":[{"kty":"EC","crv":"P-256","kid":"old","x":"ixOcTyD66hYA52GE3NeLjMsUhPTVYl1_u6DimRKmxzU","y":"KQw2gxzKBk-FTGpioh0XKcIuaxh5No-Sn_qPbw3BH1M"}]}"#,
|
||||
);
|
||||
let cache = JwksCache::new("https://stub/jwks.json", ctl.fetcher());
|
||||
|
||||
cache.decoding_key_for("old").expect("old key resolves");
|
||||
|
||||
// Identity rotates. The TTL has NOT expired, so only the unknown-kid
|
||||
// forced-refetch path can recover — which is exactly what it is for.
|
||||
ctl.serve(LIVE_JWKS);
|
||||
|
||||
cache
|
||||
.decoding_key_for(LIVE_KID)
|
||||
.expect("rotation picked up without waiting out the TTL");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Cognitum OAuth access-token verification for RuView (ADR-271).
|
||||
//!
|
||||
//! RuView is an OAuth **resource server**, not a Cognitum API client: it makes
|
||||
//! no authenticated calls to `cognitum.one`. A user signs in to their *own*
|
||||
//! RuView instance with their Cognitum identity, and this crate verifies the
|
||||
//! resulting access token **offline**, against identity's published JWKS.
|
||||
//!
|
||||
//! Offline is the requirement, not an optimisation — RuView runs on Pi-class
|
||||
//! hardware that loses WAN, and there is no token-introspection endpoint to call
|
||||
//! even when the network is up.
|
||||
//!
|
||||
//! The transport is injected, so this compiles with or without the
|
||||
//! `ureq-transport` feature. With it enabled, pass
|
||||
//! [`UreqFetcher::new()`][jwks::UreqFetcher] instead of writing your own.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use ruview_auth::{
|
||||
//! jwks::{JwksError, JwksFetcher},
|
||||
//! scope, verify_access_token, JwksCache, VerifierConfig,
|
||||
//! };
|
||||
//!
|
||||
//! struct MyFetcher;
|
||||
//! impl JwksFetcher for MyFetcher {
|
||||
//! fn fetch(&self, url: &str) -> Result<String, JwksError> {
|
||||
//! # let _ = url;
|
||||
//! // ... GET `url`, return the body ...
|
||||
//! # unimplemented!()
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! let jwks = JwksCache::new(
|
||||
//! "https://auth.cognitum.one/.well-known/jwks.json",
|
||||
//! Box::new(MyFetcher),
|
||||
//! );
|
||||
//! // Fail at boot, not on a user's first request.
|
||||
//! jwks.warm().expect("JWKS reachable at startup");
|
||||
//!
|
||||
//! let config = VerifierConfig {
|
||||
//! issuer: "https://auth.cognitum.one".to_string(),
|
||||
//! required_scope: scope::SENSING_READ.to_string(),
|
||||
//! // Audience: Cognitum has no `aud`, so `client_id` carries it.
|
||||
//! allowed_client_ids: vec!["ruview".to_string()],
|
||||
//! };
|
||||
//!
|
||||
//! let principal = verify_access_token("<jwt>", &jwks, &config)?;
|
||||
//! println!("{} on account {}", principal.subject, principal.account_id);
|
||||
//! # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
//! ```
|
||||
//!
|
||||
//! ## Scope is the capability boundary
|
||||
//!
|
||||
//! Cognitum access tokens carry no `aud`, and `client_id` is unreliable because
|
||||
//! clients borrow each other's registrations. So the scope claim is the only
|
||||
//! thing separating "may watch the sensing stream" from "may delete the trained
|
||||
//! model". Callers pick [`VerifierConfig::required_scope`] per route:
|
||||
//! [`scope::SENSING_READ`] for streams and inference,
|
||||
//! [`scope::SENSING_ADMIN`] for training, model delete and recording delete.
|
||||
//!
|
||||
//! ## What this crate deliberately does not do
|
||||
//!
|
||||
//! - **No login flow by default.** Obtaining a token (PKCE, loopback, OOB
|
||||
//! paste) lives behind the non-default `login` feature, so a server that only
|
||||
//! verifies never compiles it. See [`login`] and ADR-271's 2026-07-22
|
||||
//! amendment for why it lives here rather than in a second crate.
|
||||
//! - **No revocation check.** There is no introspection endpoint. The 15-minute
|
||||
//! token lifetime *is* the revocation window, which is precisely why
|
||||
//! long-lived setup/workload credentials are refused outright.
|
||||
//! - **No crypto.** Signature math is `jsonwebtoken`'s.
|
||||
|
||||
pub mod jwks;
|
||||
pub mod principal;
|
||||
pub mod verify;
|
||||
|
||||
/// PKCE generation (RFC 7636). Available without the full `login` stack so a
|
||||
/// resource server can drive its own browser redirect.
|
||||
#[cfg(feature = "pkce")]
|
||||
pub mod pkce;
|
||||
|
||||
/// Interactive sign-in (PKCE, loopback, OOB paste, credential storage,
|
||||
/// single-flight refresh). Off by default — a sensing server verifies tokens
|
||||
/// and never obtains them, so it must not pay for the HTTP client this needs.
|
||||
#[cfg(feature = "login")]
|
||||
pub mod login;
|
||||
|
||||
pub use jwks::{JwksCache, JwksError, JwksFetcher};
|
||||
#[cfg(feature = "ureq-transport")]
|
||||
pub use jwks::UreqFetcher;
|
||||
pub use principal::{scope, Principal};
|
||||
pub use verify::{extract_bearer, verify_access_token, VerifierConfig, VerifyError};
|
||||
@@ -0,0 +1,223 @@
|
||||
//! The ephemeral loopback listener the browser redirects back to, plus opening
|
||||
//! the system browser.
|
||||
//!
|
||||
//! A hand-rolled HTTP/1.1 responder rather than a second axum server: it serves
|
||||
//! exactly one GET, then shuts down. Ported from `meta-proxy`
|
||||
//! `src/oauth/{callback_server,browser}.rs`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::process::Stdio;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
pub struct CallbackServer {
|
||||
listener: TcpListener,
|
||||
/// The exact value to send as `redirect_uri`.
|
||||
pub redirect_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CallbackResult {
|
||||
pub code: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
const SUCCESS_PAGE: &str = r#"<html>
|
||||
<body style="background:#0a0a0a; color:#f5f5f5; font-family:system-ui,sans-serif;
|
||||
display:flex; align-items:center; justify-content:center; height:100vh; margin:0;">
|
||||
<div style="text-align:center;">
|
||||
<h1>✓ RuView sign-in complete</h1>
|
||||
<p>You can close this tab and return to your terminal.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
impl CallbackServer {
|
||||
/// Bind `127.0.0.1:0` and derive the redirect URI.
|
||||
///
|
||||
/// The path must be **exactly** `/oauth/callback`: identity's
|
||||
/// `client::validate_redirect_uri` accepts `http://127.0.0.1:<any-port>/oauth/callback`
|
||||
/// and nothing else, so a different path fails the authorize request with a
|
||||
/// redirect-URI mismatch rather than anything that names the real problem.
|
||||
pub async fn bind() -> std::io::Result<Self> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
|
||||
let addr: SocketAddr = listener.local_addr()?;
|
||||
Ok(Self {
|
||||
redirect_uri: format!("http://127.0.0.1:{}/oauth/callback", addr.port()),
|
||||
listener,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.listener.local_addr().map(|a| a.port()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Serve exactly one callback, reply with the success page, return the
|
||||
/// parsed query. Times out so an abandoned browser tab does not hang the
|
||||
/// CLI forever.
|
||||
pub async fn await_callback(&self, wait_for: Duration) -> std::io::Result<CallbackResult> {
|
||||
let (mut stream, _) = timeout(wait_for, self.listener.accept())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"timed out waiting for the OAuth callback — was the browser window closed?",
|
||||
)
|
||||
})??;
|
||||
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = stream.read(&mut buf).await?;
|
||||
let text = String::from_utf8_lossy(&buf[..n]);
|
||||
let target = text
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap_or("/oauth/callback")
|
||||
.to_string();
|
||||
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
SUCCESS_PAGE.len(),
|
||||
SUCCESS_PAGE
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
stream.shutdown().await?;
|
||||
|
||||
Ok(parse_callback_query(&target))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_callback_query(path_and_query: &str) -> CallbackResult {
|
||||
let query = path_and_query.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||||
let mut out = CallbackResult::default();
|
||||
for (k, v) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||
match k.as_ref() {
|
||||
"code" => out.code = Some(v.into_owned()),
|
||||
"state" => out.state = Some(v.into_owned()),
|
||||
"error" => out.error = Some(v.into_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Open `url` in the system browser.
|
||||
///
|
||||
/// Success means the launcher was spawned, not that a window appeared — which
|
||||
/// cannot be determined in general. Callers must print the URL regardless.
|
||||
pub fn open_browser(url: &str) -> std::io::Result<()> {
|
||||
let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
|
||||
("open", vec![url])
|
||||
} else if cfg!(target_os = "windows") {
|
||||
// The empty title argument stops `start` treating a quoted URL as the
|
||||
// window title.
|
||||
("cmd", vec!["/c", "start", "", url])
|
||||
} else {
|
||||
("xdg-open", vec![url])
|
||||
};
|
||||
std::process::Command::new(cmd)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Does this process look like it has no usable browser?
|
||||
///
|
||||
/// Mirrors meta-proxy's detection exactly (`login.rs:105-108`). It is a
|
||||
/// heuristic, which is why `--no-browser` exists: a wrong guess costs the user
|
||||
/// one flag, not a failed login.
|
||||
pub fn looks_headless() -> bool {
|
||||
std::env::var("SSH_CONNECTION").is_ok()
|
||||
|| std::env::var("SSH_TTY").is_ok()
|
||||
|| std::env::var("CONTAINER").is_ok()
|
||||
|| std::path::Path::new("/.dockerenv").exists()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_code_and_state() {
|
||||
let r = parse_callback_query("/oauth/callback?code=abc&state=xyz");
|
||||
assert_eq!(r.code.as_deref(), Some("abc"));
|
||||
assert_eq!(r.state.as_deref(), Some("xyz"));
|
||||
assert!(r.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_denial() {
|
||||
let r = parse_callback_query("/oauth/callback?error=access_denied&state=xyz");
|
||||
assert_eq!(r.error.as_deref(), Some("access_denied"));
|
||||
assert!(r.code.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_encoded_values_are_decoded() {
|
||||
let r = parse_callback_query("/oauth/callback?code=a%2Bb%2Fc&state=s");
|
||||
assert_eq!(r.code.as_deref(), Some("a+b/c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_query_less_callback_yields_nothing_rather_than_panicking() {
|
||||
let r = parse_callback_query("/oauth/callback");
|
||||
assert!(r.code.is_none() && r.state.is_none() && r.error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_redirect_uri_has_the_exact_shape_identity_requires() {
|
||||
let s = CallbackServer::bind().await.unwrap();
|
||||
assert!(s.redirect_uri.starts_with("http://127.0.0.1:"));
|
||||
assert!(s.redirect_uri.ends_with("/oauth/callback"));
|
||||
assert_ne!(s.port(), 0, "must bind a real ephemeral port");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_real_tcp_callback_round_trips() {
|
||||
let server = CallbackServer::bind().await.unwrap();
|
||||
let port = server.port();
|
||||
let client = tokio::spawn(async move {
|
||||
let mut s = tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.unwrap();
|
||||
s.write_all(b"GET /oauth/callback?code=real&state=st HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let n = s.read(&mut buf).await.unwrap();
|
||||
String::from_utf8_lossy(&buf[..n]).to_string()
|
||||
});
|
||||
|
||||
let r = server.await_callback(Duration::from_secs(5)).await.unwrap();
|
||||
assert_eq!(r.code.as_deref(), Some("real"));
|
||||
assert_eq!(r.state.as_deref(), Some("st"));
|
||||
|
||||
let page = client.await.unwrap();
|
||||
assert!(page.contains("200 OK"));
|
||||
assert!(page.contains("RuView sign-in complete"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_abandoned_login_times_out_instead_of_hanging() {
|
||||
let server = CallbackServer::bind().await.unwrap();
|
||||
assert!(server
|
||||
.await_callback(Duration::from_millis(50))
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_browser_never_panics_even_with_no_launcher_present() {
|
||||
// CI containers have no xdg-open; that is a handled condition, not a
|
||||
// failure — the caller prints the URL either way.
|
||||
let _ = open_browser("http://127.0.0.1:1/nope");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! The `auth.cognitum.one` OAuth surface: authorize URL, `POST /oauth/token`
|
||||
//! (`authorization_code` and `refresh_token` grants), and
|
||||
//! `POST /v1/oauth/code-exchange` (the OOB fallback).
|
||||
//!
|
||||
//! Ported from `cognitum-one/meta-proxy` `src/oauth/client.rs`, with the
|
||||
//! refresh grant kept — meta-proxy discards its access token after one use,
|
||||
//! but a RuView session is long-lived and must refresh.
|
||||
//!
|
||||
//! **Target the identity origin, not the console.** metaharness ADR-119 found
|
||||
//! `dashboard.cognitum.one` returns 405 for `POST /oauth/token` (the console
|
||||
//! SPA swallows the route). `auth.cognitum.one` is the correct direct target.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// RuView's registered client (identity migration `0017`).
|
||||
pub const CLIENT_ID: &str = "ruview";
|
||||
|
||||
/// RFC 8252 out-of-band sentinel. Must match
|
||||
/// `services/identity/src/oauth/client.rs::FALLBACK_REDIRECT_URI` exactly.
|
||||
pub const OOB_REDIRECT_URI: &str = "urn:ietf:wg:oauth:2.0:oob";
|
||||
|
||||
pub const DEFAULT_AUTH_BASE_URL: &str = "https://auth.cognitum.one";
|
||||
|
||||
/// Override the issuer origin (staging, a local identity, a mirror).
|
||||
pub const AUTH_URL_ENV: &str = "RUVIEW_COGNITUM_AUTH_URL";
|
||||
|
||||
/// Override the client id.
|
||||
///
|
||||
/// Exists because Cognitum has no dynamic client registration, and products
|
||||
/// have historically borrowed a registered id while their own was pending —
|
||||
/// musica shipped as `meta-proxy` for exactly this reason. RuView has its own
|
||||
/// row now, so this is an escape hatch, not the normal path.
|
||||
pub const CLIENT_ID_ENV: &str = "RUVIEW_COGNITUM_CLIENT_ID";
|
||||
|
||||
pub fn auth_base_url() -> String {
|
||||
std::env::var(AUTH_URL_ENV)
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.map(|v| v.trim().trim_end_matches('/').to_string())
|
||||
.unwrap_or_else(|| DEFAULT_AUTH_BASE_URL.to_string())
|
||||
}
|
||||
|
||||
pub fn client_id() -> String {
|
||||
std::env::var(CLIENT_ID_ENV)
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.unwrap_or_else(|| CLIENT_ID.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OAuthError {
|
||||
#[error("network error talking to the authorization server: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
#[error("authorization server rejected the request: {error} — {description}")]
|
||||
Protocol { error: String, description: String },
|
||||
#[error("unexpected response shape from the authorization server")]
|
||||
UnexpectedShape,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TokenResponse {
|
||||
pub access_token: String,
|
||||
#[serde(default)]
|
||||
pub token_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub account_email: Option<String>,
|
||||
/// The **rotating** refresh token. Identity revokes the presented one and
|
||||
/// returns a replacement; see [`refresh`].
|
||||
#[serde(default)]
|
||||
pub refresh_token: Option<String>,
|
||||
/// Access-token lifetime in seconds (identity issues 900). Absent ⇒ treat
|
||||
/// the token as already needing refresh rather than assuming a default.
|
||||
#[serde(default)]
|
||||
pub expires_in: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ErrorBody {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
#[serde(default)]
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
async fn parse_token_response(resp: reqwest::Response) -> Result<TokenResponse, OAuthError> {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
if status.is_success() {
|
||||
return serde_json::from_str::<TokenResponse>(&body)
|
||||
.map_err(|_| OAuthError::UnexpectedShape);
|
||||
}
|
||||
// A non-JSON error body (an HTML error page, a proxy timeout) must not
|
||||
// panic or masquerade as a protocol error we understand.
|
||||
match serde_json::from_str::<ErrorBody>(&body) {
|
||||
Ok(e) => Err(OAuthError::Protocol {
|
||||
error: e.error.unwrap_or_else(|| status.to_string()),
|
||||
description: e
|
||||
.error_description
|
||||
.unwrap_or_else(|| "no description supplied".into()),
|
||||
}),
|
||||
Err(_) => Err(OAuthError::UnexpectedShape),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `/oauth/authorize` URL.
|
||||
///
|
||||
/// Uses a real URL encoder rather than `format!` so a scope containing a space
|
||||
/// (`"sensing:read sensing:admin"`) is encoded correctly — hand-formatting this
|
||||
/// is how a client ends up sending a truncated scope and getting a baffling
|
||||
/// `Unknown scope`.
|
||||
pub fn authorize_url(redirect_uri: &str, state: &str, code_challenge: &str, scope: &str) -> String {
|
||||
let mut url = url::Url::parse(&format!("{}/oauth/authorize", auth_base_url()))
|
||||
.expect("auth base URL is a valid URL");
|
||||
url.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", &client_id())
|
||||
.append_pair("redirect_uri", redirect_uri)
|
||||
.append_pair("code_challenge", code_challenge)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("state", state)
|
||||
.append_pair("scope", scope);
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
/// `POST /oauth/token`, `grant_type=authorization_code`.
|
||||
pub async fn exchange_code(
|
||||
http: &reqwest::Client,
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<TokenResponse, OAuthError> {
|
||||
let resp = http
|
||||
.post(format!("{}/oauth/token", auth_base_url()))
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", code),
|
||||
("code_verifier", code_verifier),
|
||||
("client_id", &client_id()),
|
||||
("redirect_uri", redirect_uri),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
parse_token_response(resp).await
|
||||
}
|
||||
|
||||
/// `POST /oauth/token`, `grant_type=refresh_token`.
|
||||
///
|
||||
/// **Identity rotates refresh tokens with reuse detection.** The response
|
||||
/// carries a NEW refresh token and spends the old one; presenting a spent token
|
||||
/// revokes the entire session family. Two consequences the caller must honour:
|
||||
///
|
||||
/// 1. Persist the returned `refresh_token` **before** using the new access
|
||||
/// token — a crash in between otherwise strands the session.
|
||||
/// 2. Never retry a failed refresh with the same token. A timeout is not proof
|
||||
/// the server did not consume it.
|
||||
///
|
||||
/// [`super::store::Session::ensure_fresh`] does both; prefer it to calling this
|
||||
/// directly.
|
||||
pub async fn refresh(
|
||||
http: &reqwest::Client,
|
||||
refresh_token: &str,
|
||||
) -> Result<TokenResponse, OAuthError> {
|
||||
let resp = http
|
||||
.post(format!("{}/oauth/token", auth_base_url()))
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", refresh_token),
|
||||
("client_id", &client_id()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
parse_token_response(resp).await
|
||||
}
|
||||
|
||||
/// `POST /v1/oauth/code-exchange` — the OOB manual-paste fallback for hosts
|
||||
/// with no browser and no reachable loopback (SSH into a Pi, a container).
|
||||
pub async fn exchange_manual_code(
|
||||
http: &reqwest::Client,
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
) -> Result<TokenResponse, OAuthError> {
|
||||
#[derive(Serialize)]
|
||||
struct Req<'a> {
|
||||
code: &'a str,
|
||||
code_verifier: &'a str,
|
||||
client_id: &'a str,
|
||||
}
|
||||
let resp = http
|
||||
.post(format!("{}/v1/oauth/code-exchange", auth_base_url()))
|
||||
.json(&Req {
|
||||
code,
|
||||
code_verifier,
|
||||
client_id: &client_id(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
parse_token_response(resp).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn authorize_url_targets_the_identity_origin_not_the_console() {
|
||||
// The console origin 405s POST /oauth/token (metaharness ADR-119).
|
||||
let u = authorize_url("http://127.0.0.1:1/oauth/callback", "s", "c", "sensing:read");
|
||||
assert!(u.starts_with("https://auth.cognitum.one/oauth/authorize"), "{u}");
|
||||
assert!(!u.contains("dashboard.cognitum.one"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_url_carries_every_required_parameter() {
|
||||
let u = authorize_url("http://127.0.0.1:1/oauth/callback", "st8", "chal", "sensing:read");
|
||||
for expected in [
|
||||
"response_type=code",
|
||||
"client_id=ruview",
|
||||
"code_challenge=chal",
|
||||
"code_challenge_method=S256",
|
||||
"state=st8",
|
||||
] {
|
||||
assert!(u.contains(expected), "missing {expected} in {u}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multi_scope_request_is_url_encoded_not_truncated() {
|
||||
// The space in "sensing:read sensing:admin" must survive as %20/+.
|
||||
// Hand-formatting this is how a client silently requests one scope.
|
||||
let u = authorize_url("http://127.0.0.1:1/oauth/callback", "s", "c", "sensing:read sensing:admin");
|
||||
assert!(
|
||||
u.contains("scope=sensing%3Aread+sensing%3Aadmin")
|
||||
|| u.contains("scope=sensing%3Aread%20sensing%3Aadmin"),
|
||||
"scope not encoded correctly: {u}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_oob_sentinel_matches_the_servers_constant_exactly() {
|
||||
// Any drift here fails the headless path with an opaque redirect_uri
|
||||
// mismatch.
|
||||
assert_eq!(OOB_REDIRECT_URI, "urn:ietf:wg:oauth:2.0:oob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_client_id_is_ruviews_own_registration() {
|
||||
assert_eq!(CLIENT_ID, "ruview");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Login orchestration: browser + loopback when possible, OOB paste when not.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::callback::{looks_headless, open_browser, CallbackServer};
|
||||
use super::client::{self, OAuthError};
|
||||
use crate::pkce;
|
||||
use super::store::{self, Session, StoreError};
|
||||
use crate::scope;
|
||||
|
||||
/// How long to wait for the user to finish in the browser.
|
||||
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LoginError {
|
||||
#[error(transparent)]
|
||||
OAuth(#[from] OAuthError),
|
||||
#[error(transparent)]
|
||||
Store(#[from] StoreError),
|
||||
#[error("could not bind a loopback callback listener: {0}")]
|
||||
Bind(#[source] std::io::Error),
|
||||
#[error("waiting for the browser callback failed: {0}")]
|
||||
Callback(#[source] std::io::Error),
|
||||
#[error("the authorization server returned state {got:?}, expected {expected:?} — this login was not the one you started, so it was discarded")]
|
||||
StateMismatch { expected: String, got: String },
|
||||
#[error("the authorization server reported: {0}")]
|
||||
Denied(String),
|
||||
#[error("login cancelled")]
|
||||
Cancelled,
|
||||
#[error("could not read from the terminal: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub struct LoginOptions {
|
||||
/// Where to persist credentials.
|
||||
pub credentials_path: PathBuf,
|
||||
/// Scopes to request. Least privilege by default — `sensing:read` only.
|
||||
pub scope: String,
|
||||
/// Force the OOB paste flow even if a browser looks available.
|
||||
pub no_browser: bool,
|
||||
}
|
||||
|
||||
impl Default for LoginOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
credentials_path: store::default_credentials_path(),
|
||||
// A client registration is a ceiling, not a default (ADR-060 §5).
|
||||
// Routine use asks for read; admin is an explicit escalation.
|
||||
scope: scope::SENSING_READ.to_string(),
|
||||
no_browser: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the login flow and persist the resulting session.
|
||||
///
|
||||
/// `out` receives the human-facing prose (URLs, prompts) so a caller can
|
||||
/// capture it in tests; `input` supplies the pasted code in the OOB path.
|
||||
pub async fn login<W: Write, R: BufRead>(
|
||||
opts: &LoginOptions,
|
||||
out: &mut W,
|
||||
input: &mut R,
|
||||
) -> Result<Session, LoginError> {
|
||||
let http = reqwest::Client::new();
|
||||
let issuer = client::auth_base_url();
|
||||
|
||||
if opts.no_browser || looks_headless() {
|
||||
return manual_login(opts, &http, issuer, out, input).await;
|
||||
}
|
||||
|
||||
match browser_login(opts, &http, issuer.clone(), out).await {
|
||||
Ok(s) => Ok(s),
|
||||
// A loopback bind failure is environmental, not user error — fall back
|
||||
// rather than dead-ending someone who is one paste away from success.
|
||||
Err(LoginError::Bind(e)) => {
|
||||
writeln!(
|
||||
out,
|
||||
"Could not open a local callback listener ({e}); falling back to paste-code sign-in.\n"
|
||||
)?;
|
||||
manual_login(opts, &http, issuer, out, input).await
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn browser_login<W: Write>(
|
||||
opts: &LoginOptions,
|
||||
http: &reqwest::Client,
|
||||
issuer: String,
|
||||
out: &mut W,
|
||||
) -> Result<Session, LoginError> {
|
||||
let server = CallbackServer::bind().await.map_err(LoginError::Bind)?;
|
||||
let req = pkce::generate();
|
||||
let url = client::authorize_url(
|
||||
&server.redirect_uri,
|
||||
&req.state,
|
||||
&req.code_challenge,
|
||||
&opts.scope,
|
||||
);
|
||||
|
||||
writeln!(out, "Opening your browser to sign in to Cognitum…")?;
|
||||
writeln!(out, "If it doesn't open, visit:\n\n {url}\n")?;
|
||||
// Best-effort: the URL is already printed, so a missing launcher is not fatal.
|
||||
let _ = open_browser(&url);
|
||||
|
||||
let cb = server
|
||||
.await_callback(CALLBACK_TIMEOUT)
|
||||
.await
|
||||
.map_err(LoginError::Callback)?;
|
||||
|
||||
if let Some(err) = cb.error {
|
||||
return Err(LoginError::Denied(err));
|
||||
}
|
||||
// CSRF check before the code is spent: a code arriving with the wrong state
|
||||
// did not come from the flow we started.
|
||||
let got = cb.state.unwrap_or_default();
|
||||
if got != req.state {
|
||||
return Err(LoginError::StateMismatch {
|
||||
expected: req.state,
|
||||
got,
|
||||
});
|
||||
}
|
||||
let code = cb.code.ok_or(LoginError::Cancelled)?;
|
||||
|
||||
let token = client::exchange_code(http, &code, &req.code_verifier, &server.redirect_uri).await?;
|
||||
finish(opts, http, token, issuer, out)
|
||||
}
|
||||
|
||||
async fn manual_login<W: Write, R: BufRead>(
|
||||
opts: &LoginOptions,
|
||||
http: &reqwest::Client,
|
||||
issuer: String,
|
||||
out: &mut W,
|
||||
input: &mut R,
|
||||
) -> Result<Session, LoginError> {
|
||||
let req = pkce::generate();
|
||||
let url = client::authorize_url(
|
||||
client::OOB_REDIRECT_URI,
|
||||
&req.state,
|
||||
&req.code_challenge,
|
||||
&opts.scope,
|
||||
);
|
||||
|
||||
writeln!(
|
||||
out,
|
||||
"No local browser available (SSH/container detected, or --no-browser).\n"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
"Open this URL in a browser on any machine and authorize:\n\n {url}\n"
|
||||
)?;
|
||||
write!(out, "Paste the code shown after authorizing: ")?;
|
||||
out.flush()?;
|
||||
|
||||
let mut line = String::new();
|
||||
input.read_line(&mut line)?;
|
||||
let code = line.trim();
|
||||
if code.is_empty() {
|
||||
return Err(LoginError::Cancelled);
|
||||
}
|
||||
|
||||
let token = client::exchange_manual_code(http, code, &req.code_verifier).await?;
|
||||
finish(opts, http, token, issuer, out)
|
||||
}
|
||||
|
||||
fn finish<W: Write>(
|
||||
opts: &LoginOptions,
|
||||
http: &reqwest::Client,
|
||||
token: client::TokenResponse,
|
||||
issuer: String,
|
||||
out: &mut W,
|
||||
) -> Result<Session, LoginError> {
|
||||
let granted = token.scope.clone();
|
||||
let email = token.account_email.clone();
|
||||
let session = Session::from_response(
|
||||
opts.credentials_path.clone(),
|
||||
http.clone(),
|
||||
token,
|
||||
issuer,
|
||||
)?;
|
||||
|
||||
writeln!(out)?;
|
||||
match email {
|
||||
Some(e) => writeln!(out, "Signed in as {e}.")?,
|
||||
None => writeln!(out, "Signed in.")?,
|
||||
}
|
||||
// Report what the server actually granted, not what we asked for. They can
|
||||
// differ, and a user who thinks they hold `sensing:admin` when they don't
|
||||
// will read the eventual 401 as a bug.
|
||||
match granted {
|
||||
Some(s) => writeln!(out, "Granted scope: {s}")?,
|
||||
None => writeln!(out, "Granted scope: (not reported by the server)")?,
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"Credentials saved to {}",
|
||||
opts.credentials_path.display()
|
||||
)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Forget the local session. Returns whether anything was removed.
|
||||
///
|
||||
/// Local-only by design: this makes the machine unable to act as you. Revoking
|
||||
/// server-side is a separate, account-level action.
|
||||
pub fn logout(credentials_path: &std::path::Path) -> Result<bool, StoreError> {
|
||||
store::clear(credentials_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_default_scope_is_read_only() {
|
||||
// ADR-060 §5: a registration is a ceiling, not a default. A session that
|
||||
// streams poses must not casually hold delete capability.
|
||||
assert_eq!(LoginOptions::default().scope, scope::SENSING_READ);
|
||||
assert_ne!(LoginOptions::default().scope, scope::SENSING_ADMIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logout_on_a_machine_that_never_logged_in_is_not_an_error() {
|
||||
let p = std::env::temp_dir().join("ruview-flow-absent-credentials.json");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
assert_eq!(logout(&p).unwrap(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_mismatch_names_both_values_so_it_can_be_diagnosed() {
|
||||
let e = LoginError::StateMismatch {
|
||||
expected: "aaa".into(),
|
||||
got: "bbb".into(),
|
||||
};
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("aaa") && msg.contains("bbb"), "{msg}");
|
||||
assert!(msg.contains("discarded"), "must say the login was refused: {msg}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Interactive Cognitum sign-in (ADR-271 phase 2). Feature `login`.
|
||||
//!
|
||||
//! The counterpart to this crate's verifier: the verifier checks tokens a
|
||||
//! server receives, this obtains one for a user to present.
|
||||
//!
|
||||
//! Ported from `cognitum-one/meta-proxy` `src/oauth/`, cross-checked against
|
||||
//! `musica`'s `cognitum_provider.rs` — the two independent implementations
|
||||
//! against this same authorization server. Where they agree (exact
|
||||
//! `/oauth/callback` redirect path, 60-second refresh skew, OOB fallback on
|
||||
//! SSH/container) this follows both.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! use ruview_auth::login::{login, LoginOptions};
|
||||
//!
|
||||
//! let opts = LoginOptions::default(); // requests sensing:read only
|
||||
//! let mut out = std::io::stdout();
|
||||
//! let mut input = std::io::stdin().lock();
|
||||
//! let session = login(&opts, &mut out, &mut input).await?;
|
||||
//!
|
||||
//! // Always go through ensure_fresh — never read access_token directly.
|
||||
//! let bearer = session.ensure_fresh().await?;
|
||||
//! # let _ = bearer;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! # Two things that will bite if ignored
|
||||
//!
|
||||
//! 1. **Refresh tokens rotate with reuse detection.** Presenting a spent one
|
||||
//! revokes the session family, so refresh is serialised and never retried.
|
||||
//! Use [`store::Session::ensure_fresh`]; do not call [`client::refresh`]
|
||||
//! directly unless you are reimplementing that guarantee.
|
||||
//! 2. **Least scope by default.** [`LoginOptions::default`] asks for
|
||||
//! `sensing:read`. Requesting `sensing:admin` should be a deliberate act for
|
||||
//! an administrative operation, not the standing state of every session.
|
||||
|
||||
pub mod callback;
|
||||
/// Re-exported from the crate root; PKCE is usable without this feature.
|
||||
pub use crate::pkce;
|
||||
pub mod client;
|
||||
pub mod flow;
|
||||
pub mod store;
|
||||
|
||||
pub use client::{OAuthError, TokenResponse, CLIENT_ID, CLIENT_ID_ENV, OOB_REDIRECT_URI};
|
||||
pub use flow::{login, logout, LoginError, LoginOptions};
|
||||
pub use store::{
|
||||
default_credentials_path, Session, StoreError, StoredCredentials, CREDENTIALS_PATH_ENV,
|
||||
};
|
||||
@@ -0,0 +1,744 @@
|
||||
//! Stored credentials and the refresh critical section.
|
||||
//!
|
||||
//! # Why refresh is the dangerous part
|
||||
//!
|
||||
//! Identity **rotates refresh tokens with reuse detection**: presenting one
|
||||
//! returns a replacement and spends the original, and presenting a spent token
|
||||
//! revokes the whole session family. So the two obvious implementations are
|
||||
//! both wrong:
|
||||
//!
|
||||
//! * *Refresh concurrently* — two tasks present the same token, the second
|
||||
//! looks like replay, and the user is logged out.
|
||||
//! * *Retry a failed refresh with the same token* — a timeout is not evidence
|
||||
//! the server didn't consume it. Retrying is precisely the replay the server
|
||||
//! is watching for.
|
||||
//!
|
||||
//! [`Session::ensure_fresh`] therefore holds an async mutex **across the
|
||||
//! await**, re-checks expiry after acquiring it (the task that waited may find
|
||||
//! the work already done), persists the rotated token **before** returning, and
|
||||
//! never retries.
|
||||
//!
|
||||
//! ## The in-process mutex is not enough
|
||||
//!
|
||||
//! Every CLI invocation is a NEW process with its own `Session` and its own
|
||||
//! mutex, all sharing one credential file. Two `wifi-densepose` commands run
|
||||
//! close together inside the refresh window would each load the same refresh
|
||||
//! token and each present it — and the second is replay, so the user is logged
|
||||
//! out for running two commands at once.
|
||||
//!
|
||||
//! So the critical section is also guarded by an advisory **file lock**, taken
|
||||
//! NON-BLOCKING. If another process holds it, that process is already
|
||||
//! refreshing: we wait briefly and re-read the file rather than queue up to do
|
||||
//! the same work with a token that is about to be spent. Blocking on the lock
|
||||
//! would also park the async executor — the same mistake this crate had to fix
|
||||
//! in `jwks.rs`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::client::{self, OAuthError, TokenResponse};
|
||||
|
||||
/// How many times to re-read the credential file while another process holds
|
||||
/// the refresh lock, before giving up on it and refreshing ourselves.
|
||||
const RELOAD_ATTEMPTS: usize = 20;
|
||||
/// Gap between those re-reads. 20 x 150ms = 3s, comfortably longer than a
|
||||
/// healthy token exchange and shorter than a user notices.
|
||||
const RELOAD_INTERVAL: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
|
||||
/// Refresh this many seconds before `exp`. Matches the figure meta-proxy and
|
||||
/// musica independently arrived at against the same 15-minute token.
|
||||
const REFRESH_SKEW_SECS: i64 = 60;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
#[error("no stored credentials — run `wifi-densepose login` first")]
|
||||
NotLoggedIn,
|
||||
#[error("credential file {path} is unreadable: {source}")]
|
||||
Unreadable {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("credential file {path} is malformed; run `wifi-densepose login` again")]
|
||||
Malformed { path: PathBuf },
|
||||
#[error("could not write credentials to {path}: {source}")]
|
||||
Unwritable {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("session expired and could not be refreshed — run `wifi-densepose login` again: {0}")]
|
||||
RefreshFailed(#[from] OAuthError),
|
||||
#[error("the authorization server returned no refresh token; re-login is required")]
|
||||
NoRefreshToken,
|
||||
}
|
||||
|
||||
/// The persisted session. Deliberately small: this file holds live credentials.
|
||||
///
|
||||
/// `Debug` is hand-written and REDACTING — a derived impl prints both tokens in
|
||||
/// full, and this type is the obvious thing to log when a session misbehaves.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct StoredCredentials {
|
||||
pub schema_version: u8,
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
/// Unix seconds. Absent ⇒ treated as already expired, never as "valid".
|
||||
pub expires_at: Option<i64>,
|
||||
pub scope: Option<String>,
|
||||
pub account_email: Option<String>,
|
||||
pub issuer: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for StoredCredentials {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("StoredCredentials")
|
||||
.field("schema_version", &self.schema_version)
|
||||
.field("access_token", &"<redacted>")
|
||||
.field("refresh_token", &self.refresh_token.as_ref().map(|_| "<redacted>"))
|
||||
.field("expires_at", &self.expires_at)
|
||||
.field("scope", &self.scope)
|
||||
.field("account_email", &self.account_email)
|
||||
.field("issuer", &self.issuer)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl StoredCredentials {
|
||||
pub const SCHEMA_VERSION: u8 = 1;
|
||||
|
||||
fn from_response(t: TokenResponse, issuer: String) -> Self {
|
||||
let expires_at = t.expires_in.map(|s| now_unix() + s);
|
||||
// Identity's /oauth/token response has no top-level `scope` field, but
|
||||
// the access token itself carries a `scope` claim — and that claim is
|
||||
// the authoritative one, since it is what a resource server actually
|
||||
// gates on. Falling back to it turns "(not reported by the server)"
|
||||
// into the real answer. Envelope first on the off chance a future
|
||||
// response does carry one.
|
||||
let scope = t
|
||||
.scope
|
||||
.clone()
|
||||
.or_else(|| scope_from_access_token(&t.access_token));
|
||||
Self {
|
||||
schema_version: Self::SCHEMA_VERSION,
|
||||
access_token: t.access_token,
|
||||
refresh_token: t.refresh_token,
|
||||
expires_at,
|
||||
scope,
|
||||
account_email: t.account_email,
|
||||
issuer,
|
||||
}
|
||||
}
|
||||
|
||||
/// The granted scope, falling back to the access token's own claim.
|
||||
///
|
||||
/// Resolved at *read* time, not just at write time, so credential files
|
||||
/// written before the fallback existed — or by any client that stores only
|
||||
/// what the token response carried — still report correctly instead of
|
||||
/// showing "(not reported)" forever. The token is the authoritative source
|
||||
/// either way; the stored field is a convenience copy.
|
||||
pub fn effective_scope(&self) -> Option<String> {
|
||||
self.scope
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| scope_from_access_token(&self.access_token))
|
||||
}
|
||||
|
||||
/// Does the access token need replacing?
|
||||
///
|
||||
/// A missing `expires_at` counts as expired. Guessing a lifetime here would
|
||||
/// mean confidently sending a token the server may have expired minutes ago.
|
||||
pub fn needs_refresh(&self) -> bool {
|
||||
match self.expires_at {
|
||||
None => true,
|
||||
Some(exp) => now_unix() + REFRESH_SKEW_SECS >= exp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the `scope` claim out of an access token **for display only**.
|
||||
///
|
||||
/// # This is NOT verification
|
||||
///
|
||||
/// It base64-decodes the JWT payload and does not check the signature, the
|
||||
/// issuer, `exp`, `typ`, or anything else. Its only legitimate use is telling a
|
||||
/// user what they just consented to, for a token this process received over TLS
|
||||
/// directly from the issuer moments ago.
|
||||
///
|
||||
/// Never use it to make an authorization decision. Anything that gates access
|
||||
/// must go through [`crate::verify::verify_access_token`], which checks the
|
||||
/// signature against identity's published JWKS. A client reading its own freshly
|
||||
/// issued token is a fundamentally different situation from a server reading a
|
||||
/// token a stranger handed it.
|
||||
///
|
||||
/// Returns `None` rather than guessing if the token is not a well-formed JWT —
|
||||
/// an unreadable scope must present as unknown, never as empty (which would
|
||||
/// read as "you were granted nothing").
|
||||
fn scope_from_access_token(jwt: &str) -> Option<String> {
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
|
||||
let payload_b64 = jwt.split('.').nth(1)?;
|
||||
let bytes = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
|
||||
let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
claims
|
||||
.get("scope")?
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Default credential path: `~/.ruview/credentials.json`, overridable.
|
||||
pub const CREDENTIALS_PATH_ENV: &str = "RUVIEW_CREDENTIALS_PATH";
|
||||
|
||||
pub fn default_credentials_path() -> PathBuf {
|
||||
if let Ok(p) = std::env::var(CREDENTIALS_PATH_ENV) {
|
||||
if !p.trim().is_empty() {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
}
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_else(|_| ".".to_string());
|
||||
Path::new(&home).join(".ruview").join("credentials.json")
|
||||
}
|
||||
|
||||
/// Write credentials atomically and `0600`.
|
||||
///
|
||||
/// Same discipline the seed applies to its cloud key and meta-proxy to its
|
||||
/// config: temp file in the destination directory, restrict the mode *before*
|
||||
/// the rename, then rename. A partial credential file is worse than none, and a
|
||||
/// world-readable one is a live session anyone on the box can steal.
|
||||
pub fn save(path: &Path, creds: &StoredCredentials) -> Result<(), StoreError> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|source| StoreError::Unwritable {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(creds).expect("credentials serialize");
|
||||
let tmp = path.with_extension("tmp");
|
||||
|
||||
// Create with 0600 ALREADY SET, rather than write-then-chmod.
|
||||
//
|
||||
// `fs::write` creates at `0666 & !umask` — 0644 on a default umask — so the
|
||||
// refresh token was world-readable at a predictable path for the window
|
||||
// between the write and the chmod. `save` runs on every silent refresh
|
||||
// (REFRESH_SKEW_SECS against a 15-minute token), so that window recurred
|
||||
// every few minutes, and the refresh token is the highest-value credential
|
||||
// here: identity rotates with reuse detection, so a thief who presents it
|
||||
// first takes the session family and logs the real user out.
|
||||
write_private(&tmp, &json).map_err(|source| StoreError::Unwritable {
|
||||
path: tmp.clone(),
|
||||
source,
|
||||
})?;
|
||||
std::fs::rename(&tmp, path).map_err(|source| StoreError::Unwritable {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `bytes` to a file that is never readable by anyone else, at any point.
|
||||
///
|
||||
/// `create_new` also means a pre-existing `.tmp` — a symlink planted by a local
|
||||
/// attacker, or a leftover from a crash — is an error rather than a target.
|
||||
#[cfg(unix)]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let _ = std::fs::remove_file(path); // clear our own leftover, not a race
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
std::fs::write(path, bytes)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn restrict_permissions(path: &Path) -> Result<(), StoreError> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
|
||||
StoreError::Unwritable {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn restrict_permissions(path: &Path) -> Result<(), StoreError> {
|
||||
// Windows: inherit the user profile directory's ACL. `icacls` would be the
|
||||
// stricter equivalent; noted rather than silently pretended.
|
||||
let _ = path;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<StoredCredentials, StoreError> {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(StoreError::NotLoggedIn),
|
||||
Err(source) => {
|
||||
return Err(StoreError::Unreadable {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
};
|
||||
serde_json::from_slice(&bytes).map_err(|_| StoreError::Malformed {
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove stored credentials. Idempotent.
|
||||
///
|
||||
/// This forgets the local copy; it does not revoke server-side. That is a
|
||||
/// deliberate split (meta-proxy makes the same one): "this machine can no
|
||||
/// longer act as me" is the fail-secure local action, and revocation is a
|
||||
/// separate, account-level decision.
|
||||
pub fn clear(path: &Path) -> Result<bool, StoreError> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(source) => Err(StoreError::Unwritable {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// An advisory, cross-process exclusive lock on the credential file.
|
||||
///
|
||||
/// Unix only. On other platforms this is a no-op and the cross-process race
|
||||
/// remains — stated rather than silently pretended, since a lock that does
|
||||
/// nothing while claiming to protect is worse than none.
|
||||
struct FileLock {
|
||||
#[cfg(unix)]
|
||||
file: std::fs::File,
|
||||
}
|
||||
|
||||
impl FileLock {
|
||||
/// `None` if another process holds it. Never blocks.
|
||||
#[cfg(unix)]
|
||||
fn try_acquire(credentials_path: &Path) -> Option<Self> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let path = credentials_path.with_extension("lock");
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.ok()?;
|
||||
// LOCK_EX | LOCK_NB
|
||||
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if rc == 0 {
|
||||
Some(Self { file })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn try_acquire(_credentials_path: &Path) -> Option<Self> {
|
||||
Some(Self {})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for FileLock {
|
||||
fn drop(&mut self) {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
// Released on close anyway; explicit so the intent is legible.
|
||||
unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) };
|
||||
}
|
||||
}
|
||||
|
||||
/// A live session that refreshes itself, safely, at most once at a time.
|
||||
#[derive(Clone)]
|
||||
pub struct Session {
|
||||
path: PathBuf,
|
||||
http: reqwest::Client,
|
||||
inner: Arc<Mutex<StoredCredentials>>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn load_from(path: PathBuf, http: reqwest::Client) -> Result<Self, StoreError> {
|
||||
let creds = load(&path)?;
|
||||
Ok(Self {
|
||||
path,
|
||||
http,
|
||||
inner: Arc::new(Mutex::new(creds)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_response(
|
||||
path: PathBuf,
|
||||
http: reqwest::Client,
|
||||
token: TokenResponse,
|
||||
issuer: String,
|
||||
) -> Result<Self, StoreError> {
|
||||
let creds = StoredCredentials::from_response(token, issuer);
|
||||
save(&path, &creds)?;
|
||||
Ok(Self {
|
||||
path,
|
||||
http,
|
||||
inner: Arc::new(Mutex::new(creds)),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn snapshot(&self) -> StoredCredentials {
|
||||
self.inner.lock().await.clone()
|
||||
}
|
||||
|
||||
/// Return a non-expired access token, refreshing if needed.
|
||||
///
|
||||
/// The mutex is held **across the network call** on purpose. That
|
||||
/// serialises refreshes, which is the entire point: identity's reuse
|
||||
/// detection turns a concurrent second refresh into a session revocation.
|
||||
/// The re-check after acquiring means a task that queued behind another's
|
||||
/// refresh returns the fresh token instead of spending the rotated one.
|
||||
pub async fn ensure_fresh(&self) -> Result<String, StoreError> {
|
||||
let mut guard = self.inner.lock().await;
|
||||
|
||||
if !guard.needs_refresh() {
|
||||
return Ok(guard.access_token.clone());
|
||||
}
|
||||
|
||||
// Cross-process guard. Non-blocking on purpose: a busy lock means
|
||||
// another process is mid-refresh, so the useful move is to wait for its
|
||||
// result rather than race it with a token it is about to spend.
|
||||
let _file_lock: Option<FileLock> = match FileLock::try_acquire(&self.path) {
|
||||
Some(lock) => Some(lock),
|
||||
None => {
|
||||
for _ in 0..RELOAD_ATTEMPTS {
|
||||
tokio::time::sleep(RELOAD_INTERVAL).await;
|
||||
if let Ok(fresh) = load(&self.path) {
|
||||
if !fresh.needs_refresh() {
|
||||
let token = fresh.access_token.clone();
|
||||
*guard = fresh;
|
||||
return Ok(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The other process died or is wedged. Fall through and refresh
|
||||
// ourselves — the lock is advisory, not a correctness barrier.
|
||||
tracing::warn!(
|
||||
"another process held the credential lock without completing a refresh; \
|
||||
proceeding"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let Some(refresh_token) = guard.refresh_token.clone() else {
|
||||
return Err(StoreError::NoRefreshToken);
|
||||
};
|
||||
|
||||
// Deliberately not retried. A timeout is not evidence the server did
|
||||
// not consume the token, and re-presenting it is exactly the replay
|
||||
// that revokes the session.
|
||||
let refreshed = client::refresh(&self.http, &refresh_token).await?;
|
||||
|
||||
let issuer = guard.issuer.clone();
|
||||
let mut next = StoredCredentials::from_response(refreshed, issuer);
|
||||
// Identity always returns a replacement, but if it ever omitted one,
|
||||
// dropping the old token would strand the session with no way back.
|
||||
if next.refresh_token.is_none() {
|
||||
next.refresh_token = Some(refresh_token);
|
||||
}
|
||||
|
||||
// Persist BEFORE handing the new access token out: a crash between the
|
||||
// two otherwise leaves a rotated-away token on disk and a live one only
|
||||
// in memory.
|
||||
save(&self.path, &next)?;
|
||||
let token = next.access_token.clone();
|
||||
*guard = next;
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn creds(expires_at: Option<i64>) -> StoredCredentials {
|
||||
StoredCredentials {
|
||||
schema_version: StoredCredentials::SCHEMA_VERSION,
|
||||
access_token: "at".into(),
|
||||
refresh_token: Some("rt".into()),
|
||||
expires_at,
|
||||
scope: Some("sensing:read".into()),
|
||||
account_email: Some("a@b.c".into()),
|
||||
issuer: "https://auth.test".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_with_no_expiry_is_treated_as_expired() {
|
||||
// Guessing a lifetime would mean confidently sending a token the
|
||||
// server may have expired minutes ago.
|
||||
assert!(creds(None).needs_refresh());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_freshly_issued_token_does_not_need_refreshing() {
|
||||
assert!(!creds(Some(now_unix() + 900)).needs_refresh());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_is_triggered_inside_the_skew_window() {
|
||||
// 30s left, 60s skew — refresh now rather than racing expiry mid-request.
|
||||
assert!(creds(Some(now_unix() + 30)).needs_refresh());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_expired_token_needs_refreshing() {
|
||||
assert!(creds(Some(now_unix() - 1)).needs_refresh());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_credential_lock_is_exclusive_and_non_blocking() {
|
||||
// Guards the cross-process race: two CLI invocations inside the refresh
|
||||
// window used to be able to present the same rotating refresh token,
|
||||
// which identity treats as replay and answers by revoking the session.
|
||||
let dir = std::env::temp_dir().join(format!("ruview-auth-lock-{}", std::process::id()));
|
||||
let path = dir.join("credentials.json");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let first = FileLock::try_acquire(&path).expect("first acquire succeeds");
|
||||
assert!(
|
||||
FileLock::try_acquire(&path).is_none(),
|
||||
"a second holder must be refused, and refused WITHOUT blocking"
|
||||
);
|
||||
drop(first);
|
||||
assert!(
|
||||
FileLock::try_acquire(&path).is_some(),
|
||||
"the lock must be released on drop"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_debug_never_prints_token_material() {
|
||||
// A derived Debug prints both tokens in full, and this is the obvious
|
||||
// type to log when a session misbehaves.
|
||||
// Distinctive values — an earlier version of this test used "at"/"rt",
|
||||
// which collide with `expires_at` and produce a false failure.
|
||||
let mut c = creds(Some(1));
|
||||
c.access_token = "SECRET-ACCESS-VALUE".into();
|
||||
c.refresh_token = Some("SECRET-REFRESH-VALUE".into());
|
||||
let rendered = format!("{c:?}");
|
||||
assert!(
|
||||
!rendered.contains("SECRET-ACCESS-VALUE"),
|
||||
"access token leaked: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("SECRET-REFRESH-VALUE"),
|
||||
"refresh token leaked: {rendered}"
|
||||
);
|
||||
assert!(rendered.contains("<redacted>"));
|
||||
// Non-secret fields stay visible or the type is useless for debugging.
|
||||
assert!(rendered.contains("https://auth.test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_round_trips() {
|
||||
let dir = std::env::temp_dir().join(format!("ruview-auth-test-{}", std::process::id()));
|
||||
let path = dir.join("credentials.json");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
save(&path, &creds(Some(123))).unwrap();
|
||||
let back = load(&path).unwrap();
|
||||
assert_eq!(back.access_token, "at");
|
||||
assert_eq!(back.expires_at, Some(123));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_saved_credential_file_is_not_readable_by_anyone_else() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("ruview-auth-perm-{}", std::process::id()));
|
||||
let path = dir.join("credentials.json");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
save(&path, &creds(Some(1))).unwrap();
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "credentials must be 0600, got {mode:o}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_temp_file_is_never_world_readable_even_for_an_instant() {
|
||||
// The test above checks the FINAL file. It passed while `save` wrote via
|
||||
// `fs::write` (0644 under a default umask) and chmodded afterwards — so
|
||||
// the refresh token sat world-readable at a predictable path in between,
|
||||
// on every silent refresh. Asserting on the destination could never see
|
||||
// that; this asserts on the temp file `save` actually creates.
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("ruview-auth-tmpperm-{}", std::process::id()));
|
||||
let path = dir.join("credentials.json");
|
||||
let tmp = path.with_extension("tmp");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
write_private(&tmp, b"secret").unwrap();
|
||||
let mode = std::fs::metadata(&tmp).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "temp file must be created 0600, got {mode:o}");
|
||||
assert_eq!(mode & 0o077, 0, "group/other must have no access at all");
|
||||
|
||||
// A leftover from a crashed run is cleared and replaced, and the
|
||||
// replacement is 0600 too — the mode must not be inherited from
|
||||
// whatever was there before.
|
||||
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o666));
|
||||
write_private(&tmp, b"replacement").unwrap();
|
||||
let mode = std::fs::metadata(&tmp).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "a replaced temp file must also be 0600, got {mode:o}");
|
||||
assert_eq!(std::fs::read(&tmp).unwrap(), b"replacement", "must replace, not append");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_a_missing_file_says_not_logged_in_rather_than_erroring_obscurely() {
|
||||
let path = std::env::temp_dir().join("ruview-auth-definitely-absent.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(matches!(load(&path), Err(StoreError::NotLoggedIn)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_credential_file_is_reported_as_malformed_not_as_absent() {
|
||||
let dir = std::env::temp_dir().join(format!("ruview-auth-bad-{}", std::process::id()));
|
||||
let path = dir.join("credentials.json");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(&path, b"{not json").unwrap();
|
||||
assert!(matches!(load(&path), Err(StoreError::Malformed { .. })));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_is_idempotent() {
|
||||
let dir = std::env::temp_dir().join(format!("ruview-auth-clear-{}", std::process::id()));
|
||||
let path = dir.join("credentials.json");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
save(&path, &creds(Some(1))).unwrap();
|
||||
assert!(clear(&path).unwrap(), "first clear removes the file");
|
||||
assert!(!clear(&path).unwrap(), "second clear is a no-op, not an error");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_leaves_no_temp_file_behind() {
|
||||
let dir = std::env::temp_dir().join(format!("ruview-auth-tmp-{}", std::process::id()));
|
||||
let path = dir.join("credentials.json");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
save(&path, &creds(Some(1))).unwrap();
|
||||
assert!(!path.with_extension("tmp").exists(), "temp file must be renamed away");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// The scope-from-token fallback. Split out so its "display only, never an
|
||||
/// authorization input" contract is pinned by name.
|
||||
#[cfg(test)]
|
||||
mod scope_display_tests {
|
||||
use super::*;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
|
||||
fn jwt_with_payload(payload: serde_json::Value) -> String {
|
||||
// Header and signature are irrelevant here — that is the whole point:
|
||||
// this path never inspects them, so the test must not imply it does.
|
||||
format!(
|
||||
"eyJhbGciOiJFUzI1NiJ9.{}.not-a-real-signature",
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap())
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_scope_claim_when_the_envelope_omits_it() {
|
||||
// The live behaviour that motivated this: identity's /oauth/token
|
||||
// response carries no top-level `scope`, but the token does.
|
||||
let t = jwt_with_payload(serde_json::json!({"scope": "sensing:read"}));
|
||||
assert_eq!(scope_from_access_token(&t).as_deref(), Some("sensing:read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_multi_scope_claim_intact() {
|
||||
let t = jwt_with_payload(serde_json::json!({"scope": "sensing:read sensing:admin"}));
|
||||
assert_eq!(
|
||||
scope_from_access_token(&t).as_deref(),
|
||||
Some("sensing:read sensing:admin")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unparseable_token_reads_as_unknown_not_as_empty() {
|
||||
// "" would render as "you were granted nothing", which is a different
|
||||
// and wrong claim.
|
||||
assert_eq!(scope_from_access_token("not-a-jwt"), None);
|
||||
assert_eq!(scope_from_access_token(""), None);
|
||||
assert_eq!(scope_from_access_token("a.!!!not-base64!!!.c"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_scope_claim_reads_as_unknown() {
|
||||
let t = jwt_with_payload(serde_json::json!({"scope": ""}));
|
||||
assert_eq!(scope_from_access_token(&t), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_with_no_scope_claim_reads_as_unknown() {
|
||||
let t = jwt_with_payload(serde_json::json!({"sub": "u1"}));
|
||||
assert_eq!(scope_from_access_token(&t), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_response_envelope_wins_when_it_does_carry_a_scope() {
|
||||
let token = TokenResponse {
|
||||
access_token: jwt_with_payload(serde_json::json!({"scope": "from:token"})),
|
||||
token_type: None,
|
||||
account_email: None,
|
||||
refresh_token: None,
|
||||
expires_in: Some(900),
|
||||
scope: Some("from:envelope".into()),
|
||||
};
|
||||
let c = StoredCredentials::from_response(token, "https://auth.test".into());
|
||||
assert_eq!(c.scope.as_deref(), Some("from:envelope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_claim_is_used_when_the_envelope_is_silent() {
|
||||
let token = TokenResponse {
|
||||
access_token: jwt_with_payload(serde_json::json!({"scope": "sensing:read"})),
|
||||
token_type: None,
|
||||
account_email: None,
|
||||
refresh_token: None,
|
||||
expires_in: Some(900),
|
||||
scope: None,
|
||||
};
|
||||
let c = StoredCredentials::from_response(token, "https://auth.test".into());
|
||||
assert_eq!(c.scope.as_deref(), Some("sensing:read"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! OAuth 2.0 PKCE (RFC 7636) generation.
|
||||
//!
|
||||
//! Ported from `cognitum-one/meta-proxy` `src/oauth/pkce.rs`, itself ported from
|
||||
//! `dashboard/apps/cli`. Kept byte-compatible on purpose: a verifier generated
|
||||
//! here has to validate against the same `services/identity` code every other
|
||||
//! Cognitum client already talks to.
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// One login attempt's PKCE pair plus its CSRF `state`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PkceRequest {
|
||||
pub state: String,
|
||||
pub code_verifier: String,
|
||||
pub code_challenge: String,
|
||||
}
|
||||
|
||||
fn random_url_safe_token(byte_len: usize) -> String {
|
||||
let mut bytes = vec![0u8; byte_len];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
pub fn challenge_from_verifier(verifier: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
|
||||
}
|
||||
|
||||
/// Fresh `state` + verifier/challenge for one login attempt.
|
||||
///
|
||||
/// 32 random bytes each: base64url-encodes to 43 characters, comfortably inside
|
||||
/// RFC 7636 §4.1's 43–128 range without padding.
|
||||
pub fn generate() -> PkceRequest {
|
||||
let state = random_url_safe_token(32);
|
||||
let code_verifier = random_url_safe_token(32);
|
||||
let code_challenge = challenge_from_verifier(&code_verifier);
|
||||
PkceRequest {
|
||||
state,
|
||||
code_verifier,
|
||||
code_challenge,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_the_rfc7636_appendix_b_worked_example() {
|
||||
// The spec's own vector. If this drifts, our S256 is not S256 and the
|
||||
// server will reject every exchange — worth pinning to the standard
|
||||
// rather than to our own output.
|
||||
assert_eq!(
|
||||
challenge_from_verifier("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
|
||||
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verifier_length_is_within_rfc7636_bounds() {
|
||||
let r = generate();
|
||||
assert!(
|
||||
r.code_verifier.len() >= 43 && r.code_verifier.len() <= 128,
|
||||
"len {}",
|
||||
r.code_verifier.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_is_derived_from_the_verifier_it_ships_with() {
|
||||
let r = generate();
|
||||
assert_eq!(challenge_from_verifier(&r.code_verifier), r.code_challenge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_attempts_share_nothing() {
|
||||
let (a, b) = (generate(), generate());
|
||||
assert_ne!(a.state, b.state);
|
||||
assert_ne!(a.code_verifier, b.code_verifier);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! The authenticated caller, and the scopes it consented to.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// RuView's own scopes, registered on the `ruview` OAuth client
|
||||
/// (identity migration `0016`, ADR-060).
|
||||
///
|
||||
/// Split by **blast radius**, not by endpoint count: the question is whether a
|
||||
/// leaked token can destroy something, not how many routes it covers.
|
||||
pub mod scope {
|
||||
/// Observe: sensing/pose streams, one-shot inference, reading metadata.
|
||||
///
|
||||
/// Not "harmless" — for a presence and vital-signs sensor, read access tells
|
||||
/// the holder who is home. It is *non-destructive*, which is a weaker claim.
|
||||
pub const SENSING_READ: &str = "sensing:read";
|
||||
|
||||
/// Mutate or destroy: training, model delete, recording delete.
|
||||
///
|
||||
/// Irreversible: a deleted model or labelled capture may represent days of
|
||||
/// collection, and a training run burns hours of CPU on a Pi.
|
||||
pub const SENSING_ADMIN: &str = "sensing:admin";
|
||||
}
|
||||
|
||||
/// A verified caller. Constructed only by
|
||||
/// [`crate::verify::verify_access_token`] — there is deliberately no public
|
||||
/// constructor, so a `Principal` in hand always means a signature was checked.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Principal {
|
||||
/// `sub` — the identity user id.
|
||||
pub subject: String,
|
||||
/// `account_id` — the billing tenant (the user's Firebase UID; ADR-045).
|
||||
/// Required and non-empty; see the verifier for why.
|
||||
pub account_id: String,
|
||||
pub org_id: String,
|
||||
pub workspace_id: String,
|
||||
/// `client_id` — which OAuth client obtained this token.
|
||||
///
|
||||
/// **Attribution and logging only — never an authorization input.** Clients
|
||||
/// borrow each other's registrations when their own has not been deployed
|
||||
/// yet (musica ships `DEFAULT_CLIENT_ID = "meta-proxy"`), so this claim does
|
||||
/// not reliably identify the product holding the token.
|
||||
pub client_id: String,
|
||||
/// `jti` — unique per token; use for request-log correlation.
|
||||
pub token_id: String,
|
||||
/// The consented scopes, split on whitespace.
|
||||
scopes: BTreeSet<String>,
|
||||
/// `exp`, unix seconds.
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
impl Principal {
|
||||
pub(crate) fn new(
|
||||
subject: String,
|
||||
account_id: String,
|
||||
org_id: String,
|
||||
workspace_id: String,
|
||||
client_id: String,
|
||||
token_id: String,
|
||||
scope_claim: &str,
|
||||
expires_at: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
subject,
|
||||
account_id,
|
||||
org_id,
|
||||
workspace_id,
|
||||
client_id,
|
||||
token_id,
|
||||
scopes: scope_claim.split_whitespace().map(str::to_owned).collect(),
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this principal hold `scope`?
|
||||
///
|
||||
/// Exact match only. There is **no prefix or hierarchy rule** — holding
|
||||
/// `sensing:admin` does not imply `sensing:read`, and a token that needs
|
||||
/// both must have consented to both. Implying one scope from another is how
|
||||
/// a consent screen ends up meaning less than it said.
|
||||
pub fn has_scope(&self, scope: &str) -> bool {
|
||||
self.scopes.contains(scope)
|
||||
}
|
||||
|
||||
/// Scopes, sorted — for logging.
|
||||
pub fn scopes(&self) -> impl Iterator<Item = &str> {
|
||||
self.scopes.iter().map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn principal_with(scope: &str) -> Principal {
|
||||
Principal::new(
|
||||
"sub".into(),
|
||||
"acct".into(),
|
||||
"org".into(),
|
||||
"ws".into(),
|
||||
"ruview".into(),
|
||||
"jti".into(),
|
||||
scope,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_scope_matches_a_single_consented_scope() {
|
||||
assert!(principal_with("sensing:read").has_scope(scope::SENSING_READ));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_scope_matches_within_a_whitespace_separated_list() {
|
||||
let p = principal_with("sensing:read sensing:admin");
|
||||
assert!(p.has_scope(scope::SENSING_READ));
|
||||
assert!(p.has_scope(scope::SENSING_ADMIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_does_not_imply_read() {
|
||||
// Guards the "no hierarchy" rule above. If someone later adds prefix
|
||||
// matching to be helpful, this fails and they have to read the comment.
|
||||
assert!(!principal_with("sensing:admin").has_scope(scope::SENSING_READ));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_scope_grants_nothing() {
|
||||
let p = principal_with("inference");
|
||||
assert!(!p.has_scope(scope::SENSING_READ));
|
||||
assert!(!p.has_scope(scope::SENSING_ADMIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_scope_claim_grants_nothing() {
|
||||
assert!(!principal_with("").has_scope(scope::SENSING_READ));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_prefix_of_a_real_scope_does_not_match() {
|
||||
assert!(!principal_with("sensing").has_scope(scope::SENSING_READ));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Cognitum OAuth access-token verification (ADR-271).
|
||||
//!
|
||||
//! The accept-rule is ported from `meta-llm/src/auth/oauthBearer.ts` (ADR-045),
|
||||
//! the only other resource-server-side verifier of these tokens in the org.
|
||||
//! Divergence from it would be a bug, not a preference — a token meta-llm
|
||||
//! rejects must not be one RuView accepts.
|
||||
//!
|
||||
//! ## The trust chain, narrowly
|
||||
//!
|
||||
//! 1. Only identity's ES256 key — fetched from the published JWKS by `kid` —
|
||||
//! can sign an accepted token. No shared secret, no static PEM to leak.
|
||||
//! 2. **The algorithm is fixed to ES256 by this code.** The token header's `alg`
|
||||
//! is only ever *compared against* that allowlist, never used to *select* an
|
||||
//! algorithm. That is what makes `alg: none` and RSA-substitution
|
||||
//! non-starters rather than things we defend against case by case.
|
||||
//! 3. Signature math is `jsonwebtoken`'s. This module owns claim policy only.
|
||||
//!
|
||||
//! ## Why `setup` and `workload` tokens are refused outright
|
||||
//!
|
||||
//! Identity also issues long-lived *setup* (365-day) and *workload* credentials.
|
||||
//! Their revocation lives in identity's `oauth_setup_tokens` table, and RuView —
|
||||
//! like meta-llm — has **no database and no way to check it**. A 15-minute
|
||||
//! access token needs no revocation round-trip because it expires faster than
|
||||
//! any realistic revocation propagates; a 365-day one does. Accepting one would
|
||||
//! mean honouring a credential that may already have been revoked, so we don't.
|
||||
//!
|
||||
//! ## There is no `aud` claim, and no `iss` claim either
|
||||
//!
|
||||
//! Verified against real production tokens: the claim set is exactly
|
||||
//! `typ, sub, account_id, org_id, workspace_id, client_id, scope, family_id,
|
||||
//! jti, iat, exp, setup, workload`. No audience. No issuer.
|
||||
//!
|
||||
//! **What binds a token to its issuer, then?** The JWKS. We accept only
|
||||
//! signatures made by a key served from the configured `jwks_uri`, so
|
||||
//! possession of a valid signature *is* proof of issuer. Adding an `iss` claim
|
||||
//! check on top would not strengthen that — and requiring a claim identity does
|
||||
//! not emit rejects every genuine token, which is exactly what an earlier
|
||||
//! revision of this module did.
|
||||
//!
|
||||
//! **`client_id` is Cognitum's stand-in for `aud`.** `cognitum-one/freetokens`
|
||||
//! (live) documents the contract — *"Cognitum access tokens intentionally use
|
||||
//! custom `client_id` rather than a registered JWT `aud` claim"* — and rejects
|
||||
//! any token whose `client_id` is not its own. This verifier does the same via
|
||||
//! [`VerifierConfig::allowed_client_ids`].
|
||||
//!
|
||||
//! An earlier revision treated `client_id` as unusable because clients borrow
|
||||
//! each other's registrations (musica shipped as `meta-proxy` while its own was
|
||||
//! pending) and relied on scope alone. That was reasoning from a transitional
|
||||
//! state: RuView has its own registered client, and accepting a token minted for
|
||||
//! any Cognitum product is a weaker position than the platform intends.
|
||||
//!
|
||||
//! So there are now TWO boundaries, not one: audience (`client_id`) and
|
||||
//! capability (`scope`). Neither is optional garnish.
|
||||
|
||||
use jsonwebtoken::{decode, decode_header, Algorithm, Validation};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::jwks::{JwksCache, JwksError};
|
||||
use crate::principal::Principal;
|
||||
|
||||
/// The `typ` identity stamps on ordinary interactive access tokens
|
||||
/// (`jwt.rs`'s `TOKEN_TYP_ACCESS`).
|
||||
const TYP_ACCESS: &str = "access";
|
||||
|
||||
/// Clock leeway for `exp`/`iat`.
|
||||
///
|
||||
/// Deliberately small. Against a 15-minute token a generous window is a real
|
||||
/// extension of a revoked credential's life, so this absorbs ordinary NTP jitter
|
||||
/// and nothing more. Hosts without a battery-backed clock (Pi-class) need real
|
||||
/// time sync — see [`VerifyError::ExpiredOrNotYetValid`], which is reported
|
||||
/// distinctly so "your clock is wrong" is diagnosable rather than presenting as
|
||||
/// a generic 401.
|
||||
const CLOCK_LEEWAY_SECS: u64 = 30;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VerifyError {
|
||||
#[error("authorization header is missing or not a Bearer token")]
|
||||
MissingBearer,
|
||||
#[error("token is not a well-formed JWT: {0}")]
|
||||
Malformed(String),
|
||||
#[error("token algorithm is not ES256")]
|
||||
WrongAlgorithm,
|
||||
#[error("could not resolve a verification key: {0}")]
|
||||
Jwks(#[from] JwksError),
|
||||
#[error("token signature is not valid for identity's published key")]
|
||||
BadSignature,
|
||||
/// `exp`/`iat` outside the accepted window. Distinct from `BadSignature` on
|
||||
/// purpose: on an RTC-less host this is usually a clock-sync problem, not an
|
||||
/// attack, and an operator needs to be able to tell those apart.
|
||||
#[error("token is expired or not yet valid (check host clock sync)")]
|
||||
ExpiredOrNotYetValid,
|
||||
#[error("token type {found:?} is not an interactive access token")]
|
||||
WrongTokenType { found: Option<String> },
|
||||
#[error("long-lived setup/workload credentials are not accepted (unverifiable revocation)")]
|
||||
LongLivedCredential,
|
||||
#[error("token carries no account_id and cannot be attributed")]
|
||||
MissingAccountId,
|
||||
#[error("token does not carry the required scope {required:?}")]
|
||||
MissingScope { required: String },
|
||||
/// Minted for a different Cognitum product. `client_id` is the platform's
|
||||
/// audience mechanism in the absence of `aud`.
|
||||
#[error("token was issued to client {found:?}, which this server does not accept")]
|
||||
WrongAudience { found: String },
|
||||
}
|
||||
|
||||
/// Identity's access-token claims. Mirrors `AccessTokenClaims` in
|
||||
/// `dashboard/services/identity/src/jwt.rs`.
|
||||
///
|
||||
/// `typ` is `Option` because identity types it that way — absence must be
|
||||
/// treated as "not an access token", never as a default.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AccessTokenClaims {
|
||||
#[serde(default)]
|
||||
typ: Option<String>,
|
||||
sub: String,
|
||||
#[serde(default)]
|
||||
account_id: Option<String>,
|
||||
#[serde(default)]
|
||||
org_id: String,
|
||||
#[serde(default)]
|
||||
workspace_id: String,
|
||||
#[serde(default)]
|
||||
client_id: String,
|
||||
#[serde(default)]
|
||||
scope: String,
|
||||
#[serde(default)]
|
||||
jti: String,
|
||||
exp: i64,
|
||||
/// Long-lived, non-rotating setup credential. Absent on older tokens.
|
||||
#[serde(default)]
|
||||
setup: bool,
|
||||
/// Machine workload credential. Absent on older tokens.
|
||||
#[serde(default)]
|
||||
workload: bool,
|
||||
}
|
||||
|
||||
/// Verifier configuration.
|
||||
pub struct VerifierConfig {
|
||||
/// The authorization server's origin, e.g. `https://auth.cognitum.one`.
|
||||
///
|
||||
/// **Not validated against a claim** — Cognitum access tokens carry no
|
||||
/// `iss` (see module docs); the JWKS provides the issuer binding. This is
|
||||
/// here for logging and for deriving the default `jwks_uri`, so that the
|
||||
/// configured issuer and the keys we trust cannot drift apart silently.
|
||||
pub issuer: String,
|
||||
/// The scope a caller must hold for the route being served.
|
||||
pub required_scope: String,
|
||||
/// `client_id` values whose tokens this server accepts — the AUDIENCE check.
|
||||
///
|
||||
/// Cognitum tokens carry no `aud`; the platform uses `client_id` for this
|
||||
/// instead. `freetokens` (cognitum-one/freetokens, live) states the contract
|
||||
/// plainly — *"Cognitum access tokens intentionally use custom `client_id`
|
||||
/// rather than a registered JWT `aud` claim"* — and enforces
|
||||
/// `payload.client_id !== OAUTH_CLIENT_ID` on every request.
|
||||
///
|
||||
/// Empty means accept any client, which is what an earlier revision did on
|
||||
/// the reasoning that clients borrow each other's registrations (musica
|
||||
/// shipped as `meta-proxy` while its own was pending). That was a
|
||||
/// transitional state, not the model: RuView has its own registered client,
|
||||
/// so leaving this empty means accepting a token minted for ANY Cognitum
|
||||
/// product. Configure it.
|
||||
pub allowed_client_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Verify a raw JWT and produce a [`Principal`].
|
||||
///
|
||||
/// Every rejection path returns a typed error; none of them return a partially
|
||||
/// trusted principal.
|
||||
pub fn verify_access_token(
|
||||
token: &str,
|
||||
jwks: &JwksCache,
|
||||
config: &VerifierConfig,
|
||||
) -> Result<Principal, VerifyError> {
|
||||
let header = decode_header(token).map_err(|e| VerifyError::Malformed(e.to_string()))?;
|
||||
|
||||
// Compared, never selected. `decode` below independently enforces the same
|
||||
// allowlist; this early check exists so the failure is legible.
|
||||
if header.alg != Algorithm::ES256 {
|
||||
return Err(VerifyError::WrongAlgorithm);
|
||||
}
|
||||
let kid = header.kid.ok_or(JwksError::MissingKid)?;
|
||||
let key = jwks.decoding_key_for(&kid)?;
|
||||
|
||||
let mut validation = Validation::new(Algorithm::ES256);
|
||||
validation.leeway = CLOCK_LEEWAY_SECS;
|
||||
validation.validate_exp = true;
|
||||
// No `aud` and no `iss` validation — Cognitum access tokens carry neither.
|
||||
// See "What binds a token to its issuer" in the module docs: the JWKS is
|
||||
// the binding, and requiring a claim identity does not emit rejects every
|
||||
// real token. meta-llm's verifier makes the same two omissions.
|
||||
validation.validate_aud = false;
|
||||
validation.set_required_spec_claims(&["exp"]);
|
||||
|
||||
let data = decode::<AccessTokenClaims>(token, &key, &validation).map_err(map_jwt_error)?;
|
||||
let claims = data.claims;
|
||||
|
||||
// ---- Claim policy. Mirrors meta-llm's oauthBearer.ts accept-rule. ----
|
||||
|
||||
if claims.typ.as_deref() != Some(TYP_ACCESS) {
|
||||
return Err(VerifyError::WrongTokenType { found: claims.typ });
|
||||
}
|
||||
// AUDIENCE. Cognitum's stand-in for `aud` (see VerifierConfig docs).
|
||||
if !config.allowed_client_ids.is_empty()
|
||||
&& !config
|
||||
.allowed_client_ids
|
||||
.iter()
|
||||
.any(|c| c == &claims.client_id)
|
||||
{
|
||||
return Err(VerifyError::WrongAudience {
|
||||
found: claims.client_id,
|
||||
});
|
||||
}
|
||||
if claims.setup || claims.workload {
|
||||
// Belt and braces alongside the `typ` check: identity stamps these as
|
||||
// booleans as well, and a credential that sets either must never be
|
||||
// honoured here regardless of how it types itself.
|
||||
return Err(VerifyError::LongLivedCredential);
|
||||
}
|
||||
|
||||
let account_id = claims.account_id.filter(|a| !a.is_empty());
|
||||
let Some(account_id) = account_id else {
|
||||
// meta-llm requires this so a token cannot bill an account it doesn't
|
||||
// belong to. RuView's reason is attribution: an unattributable principal
|
||||
// cannot appear in an audit trail, which is most of the point of moving
|
||||
// off a shared static bearer.
|
||||
return Err(VerifyError::MissingAccountId);
|
||||
};
|
||||
|
||||
let principal = Principal::new(
|
||||
claims.sub,
|
||||
account_id,
|
||||
claims.org_id,
|
||||
claims.workspace_id,
|
||||
claims.client_id,
|
||||
claims.jti,
|
||||
&claims.scope,
|
||||
claims.exp,
|
||||
);
|
||||
|
||||
if !principal.has_scope(&config.required_scope) {
|
||||
return Err(VerifyError::MissingScope {
|
||||
required: config.required_scope.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(principal)
|
||||
}
|
||||
|
||||
/// Extract a bearer token from an `Authorization` header value.
|
||||
///
|
||||
/// The scheme is matched **case-insensitively** per RFC 7235 §2.1, and leading
|
||||
/// whitespace before the token is tolerated. This mirrors what
|
||||
/// `wifi-densepose-sensing-server`'s existing `bearer_auth` already does
|
||||
/// deliberately, so a client sending `bearer`/`BEARER` is not rejected by one
|
||||
/// layer and accepted by the other. The token itself is never normalised.
|
||||
pub fn extract_bearer(header_value: &str) -> Result<&str, VerifyError> {
|
||||
let (scheme, token) = header_value
|
||||
.split_once(' ')
|
||||
.ok_or(VerifyError::MissingBearer)?;
|
||||
if !scheme.eq_ignore_ascii_case("Bearer") {
|
||||
return Err(VerifyError::MissingBearer);
|
||||
}
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
return Err(VerifyError::MissingBearer);
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn map_jwt_error(e: jsonwebtoken::errors::Error) -> VerifyError {
|
||||
use jsonwebtoken::errors::ErrorKind;
|
||||
match e.kind() {
|
||||
ErrorKind::InvalidSignature => VerifyError::BadSignature,
|
||||
ErrorKind::ExpiredSignature | ErrorKind::ImmatureSignature => {
|
||||
VerifyError::ExpiredOrNotYetValid
|
||||
}
|
||||
ErrorKind::InvalidAlgorithm | ErrorKind::InvalidAlgorithmName => {
|
||||
VerifyError::WrongAlgorithm
|
||||
}
|
||||
_ => VerifyError::Malformed(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_bearer_accepts_a_well_formed_header() {
|
||||
assert_eq!(extract_bearer("Bearer abc.def.ghi").unwrap(), "abc.def.ghi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_bearer_rejects_a_missing_prefix() {
|
||||
assert!(matches!(
|
||||
extract_bearer("abc.def.ghi"),
|
||||
Err(VerifyError::MissingBearer)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_bearer_accepts_any_scheme_casing() {
|
||||
// RFC 7235 §2.1: the auth-scheme is case-insensitive. The sensing
|
||||
// server's own middleware already matches it that way on purpose, and
|
||||
// the two layers must not disagree about what a valid header looks like.
|
||||
for header in ["Bearer t.o.k", "bearer t.o.k", "BEARER t.o.k"] {
|
||||
assert_eq!(extract_bearer(header).unwrap(), "t.o.k", "for {header:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_bearer_tolerates_extra_space_before_the_token() {
|
||||
assert_eq!(extract_bearer("Bearer t.o.k").unwrap(), "t.o.k");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_bearer_rejects_a_different_scheme() {
|
||||
assert!(matches!(
|
||||
extract_bearer("Basic dXNlcjpwYXNz"),
|
||||
Err(VerifyError::MissingBearer)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_bearer_rejects_an_empty_token() {
|
||||
assert!(matches!(
|
||||
extract_bearer("Bearer "),
|
||||
Err(VerifyError::MissingBearer)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
//! The verifier accept/reject matrix — gates G-1 and G-2 of the ADR-271 plan.
|
||||
//!
|
||||
//! Every token here is a **real ES256 JWT signed at test time** with a
|
||||
//! freshly generated key, so these exercise the same code path production does
|
||||
//! rather than asserting against hand-built strings. No network: the JWKS is
|
||||
//! served from a stub.
|
||||
//!
|
||||
//! Keypairs are **generated at test runtime, never committed**. A checked-in
|
||||
//! `-----BEGIN PRIVATE KEY-----` would be inert here, but it trains scanners and
|
||||
//! readers to treat committed key material as normal, and this repo has no such
|
||||
//! precedent. Generating also means no fixture can drift out of sync with the
|
||||
//! JWKS document it is served by — the two are derived from the same key.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use p256::ecdsa::SigningKey;
|
||||
use p256::pkcs8::{EncodePrivateKey, LineEnding};
|
||||
use ruview_auth::{
|
||||
jwks::{JwksError, JwksFetcher},
|
||||
scope, verify_access_token, JwksCache, VerifierConfig, VerifyError,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
const TEST_KID: &str = "test-key-1";
|
||||
const TEST_ISSUER: &str = "https://auth.test.local";
|
||||
|
||||
/// A generated P-256 keypair: the PKCS#8 PEM to sign with, and the JWK
|
||||
/// coordinates to serve in the stub JWKS.
|
||||
struct TestKey {
|
||||
pkcs8_pem: String,
|
||||
x: String,
|
||||
y: String,
|
||||
}
|
||||
|
||||
fn generate_key() -> TestKey {
|
||||
let signing = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
|
||||
let pem = signing
|
||||
.to_pkcs8_pem(LineEnding::LF)
|
||||
.expect("PKCS#8 encode")
|
||||
.to_string();
|
||||
let point = signing.verifying_key().to_encoded_point(false);
|
||||
TestKey {
|
||||
pkcs8_pem: pem,
|
||||
x: URL_SAFE_NO_PAD.encode(point.x().expect("P-256 x")),
|
||||
y: URL_SAFE_NO_PAD.encode(point.y().expect("P-256 y")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The key the stub JWKS publishes — i.e. "identity's signing key".
|
||||
fn primary_key() -> &'static TestKey {
|
||||
static K: OnceLock<TestKey> = OnceLock::new();
|
||||
K.get_or_init(generate_key)
|
||||
}
|
||||
|
||||
/// A *different* valid P-256 key, published nowhere — for the forged-signature
|
||||
/// case. Distinct from a malformed token: this is a real, well-formed ES256
|
||||
/// signature that simply is not identity's.
|
||||
fn other_key() -> &'static TestKey {
|
||||
static K: OnceLock<TestKey> = OnceLock::new();
|
||||
K.get_or_init(generate_key)
|
||||
}
|
||||
|
||||
/// `alg: none`, precomputed (jsonwebtoken will not encode one, which is itself
|
||||
/// reassuring). Claims are otherwise entirely valid.
|
||||
const ALG_NONE_TOKEN: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwia2lkIjoidGVzdC1rZXktMSJ9.eyJ0eXAiOiJhY2Nlc3MiLCJzdWIiOiJzIiwiYWNjb3VudF9pZCI6ImEiLCJvcmdfaWQiOiJvIiwid29ya3NwYWNlX2lkIjoidyIsImNsaWVudF9pZCI6InJ1dmlldyIsInNjb3BlIjoic2Vuc2luZzpyZWFkIiwianRpIjoiaiIsImlhdCI6NDEwMjQ0NDgwMCwiZXhwIjo0MTAyNDQ4NDAwLCJzZXR1cCI6ZmFsc2UsIndvcmtsb2FkIjpmYWxzZSwiaXNzIjoiaHR0cHM6Ly9hdXRoLnRlc3QubG9jYWwifQ.";
|
||||
|
||||
struct StaticJwks(String);
|
||||
|
||||
impl JwksFetcher for StaticJwks {
|
||||
fn fetch(&self, _url: &str) -> Result<String, JwksError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn jwks_serving_test_key() -> JwksCache {
|
||||
let key = primary_key();
|
||||
let doc = json!({
|
||||
"keys": [{
|
||||
"alg": "ES256", "crv": "P-256", "kty": "EC", "use": "sig",
|
||||
"kid": TEST_KID, "x": key.x, "y": key.y
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
JwksCache::new("https://stub/jwks.json", Box::new(StaticJwks(doc)))
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
/// A claim set matching identity's real `AccessTokenClaims`, valid unless a
|
||||
/// test overrides a field.
|
||||
fn valid_claims() -> serde_json::Value {
|
||||
json!({
|
||||
"typ": "access",
|
||||
"sub": "0f8fad5b-d9cb-469f-a165-70867728950e",
|
||||
"account_id": "firebase-uid-abc123",
|
||||
"org_id": "org-1",
|
||||
"workspace_id": "ws-1",
|
||||
"client_id": "ruview",
|
||||
"scope": "sensing:read",
|
||||
"family_id": "fam-1",
|
||||
"jti": "jti-1",
|
||||
"iat": now() - 10,
|
||||
"exp": now() + 900, // identity's real 15-minute TTL
|
||||
"setup": false,
|
||||
"workload": false,
|
||||
// NOTE: no `iss`. Real Cognitum access tokens carry none — verified
|
||||
// against production. An earlier fixture added one, the verifier was
|
||||
// built to require it, and the whole suite passed while rejecting every
|
||||
// genuine token. Fixtures mirror production or they prove nothing.
|
||||
})
|
||||
}
|
||||
|
||||
fn sign(claims: &serde_json::Value) -> String {
|
||||
sign_with(claims, primary_key())
|
||||
}
|
||||
|
||||
fn sign_with(claims: &serde_json::Value, key: &TestKey) -> String {
|
||||
let mut header = Header::new(jsonwebtoken::Algorithm::ES256);
|
||||
header.kid = Some(TEST_KID.to_string());
|
||||
let enc = EncodingKey::from_ec_pem(key.pkcs8_pem.as_bytes()).expect("generated key parses");
|
||||
encode(&header, claims, &enc).expect("signs")
|
||||
}
|
||||
|
||||
fn config_for(required_scope: &str) -> VerifierConfig {
|
||||
VerifierConfig {
|
||||
issuer: TEST_ISSUER.to_string(),
|
||||
required_scope: required_scope.to_string(),
|
||||
// Mirrors production: RuView accepts only tokens minted for itself.
|
||||
allowed_client_ids: vec!["ruview".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(token: &str, required_scope: &str) -> Result<ruview_auth::Principal, VerifyError> {
|
||||
verify_access_token(token, &jwks_serving_test_key(), &config_for(required_scope))
|
||||
}
|
||||
|
||||
// ─────────────────────────── accept ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_valid_access_token_is_accepted_and_fully_attributed() {
|
||||
let principal = verify(&sign(&valid_claims()), scope::SENSING_READ).expect("accepted");
|
||||
|
||||
assert_eq!(principal.subject, "0f8fad5b-d9cb-469f-a165-70867728950e");
|
||||
assert_eq!(principal.account_id, "firebase-uid-abc123");
|
||||
assert_eq!(principal.org_id, "org-1");
|
||||
assert_eq!(principal.client_id, "ruview");
|
||||
assert_eq!(principal.token_id, "jti-1");
|
||||
assert!(principal.has_scope(scope::SENSING_READ));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_holding_both_scopes_satisfies_either_requirement() {
|
||||
let mut c = valid_claims();
|
||||
c["scope"] = json!("sensing:read sensing:admin");
|
||||
let token = sign(&c);
|
||||
|
||||
assert!(verify(&token, scope::SENSING_READ).is_ok());
|
||||
assert!(verify(&token, scope::SENSING_ADMIN).is_ok());
|
||||
}
|
||||
|
||||
// ─────────────────── signature / algorithm ────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_token_signed_by_a_different_key_is_rejected() {
|
||||
let token = sign_with(&valid_claims(), other_key());
|
||||
assert!(matches!(
|
||||
verify(&token, scope::SENSING_READ),
|
||||
Err(VerifyError::BadSignature)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alg_none_is_rejected() {
|
||||
// The classic downgrade. It is rejected two layers deep:
|
||||
//
|
||||
// 1. `jsonwebtoken`'s `Algorithm` enum has **no `none` variant**, so the
|
||||
// header fails to deserialize at all — `none` is unrepresentable, not
|
||||
// merely disallowed. That is why the variant here is `Malformed` rather
|
||||
// than `WrongAlgorithm`: we never get far enough to compare algorithms.
|
||||
// 2. Even if it parsed, `Validation::new(ES256)` would reject it, since
|
||||
// `alg` is only ever compared against an allowlist, never used to
|
||||
// select an algorithm.
|
||||
//
|
||||
// The assertion below pins layer 1. If a future `jsonwebtoken` ever adds a
|
||||
// `none` variant this flips to `WrongAlgorithm` and the failure is a prompt
|
||||
// to re-verify layer 2 still holds — which is exactly when we'd want to look.
|
||||
let result = verify(ALG_NONE_TOKEN, scope::SENSING_READ);
|
||||
assert!(result.is_err(), "alg:none must never authenticate");
|
||||
assert!(
|
||||
matches!(result, Err(VerifyError::Malformed(_))),
|
||||
"expected rejection at header parse; got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tampered_payload_invalidates_the_signature() {
|
||||
let token = sign(&valid_claims());
|
||||
let mut parts: Vec<&str> = token.split('.').collect();
|
||||
// Swap the payload for one claiming admin scope, keeping the signature.
|
||||
let mut c = valid_claims();
|
||||
c["scope"] = json!("sensing:admin");
|
||||
let forged = sign(&c);
|
||||
let forged_payload = forged.split('.').nth(1).unwrap().to_string();
|
||||
parts[1] = &forged_payload;
|
||||
let spliced = parts.join(".");
|
||||
|
||||
assert!(matches!(
|
||||
verify(&spliced, scope::SENSING_READ),
|
||||
Err(VerifyError::BadSignature)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_kid_is_rejected() {
|
||||
let mut header = Header::new(jsonwebtoken::Algorithm::ES256);
|
||||
header.kid = Some("a-kid-we-have-never-seen".to_string());
|
||||
let key = EncodingKey::from_ec_pem(primary_key().pkcs8_pem.as_bytes()).unwrap();
|
||||
let token = encode(&header, &valid_claims(), &key).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
verify(&token, scope::SENSING_READ),
|
||||
Err(VerifyError::Jwks(JwksError::UnknownKid(_)))
|
||||
));
|
||||
}
|
||||
|
||||
// ───────────────────────── time ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn an_expired_token_is_rejected_distinguishably() {
|
||||
let mut c = valid_claims();
|
||||
c["iat"] = json!(now() - 2000);
|
||||
c["exp"] = json!(now() - 1000);
|
||||
|
||||
// Distinct from BadSignature on purpose: on an RTC-less Pi this is usually
|
||||
// a clock-sync fault, and an operator must be able to tell them apart.
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::ExpiredOrNotYetValid)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_expiring_just_inside_the_leeway_is_still_accepted() {
|
||||
let mut c = valid_claims();
|
||||
c["exp"] = json!(now() - 5); // within the 30s leeway
|
||||
assert!(verify(&sign(&c), scope::SENSING_READ).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_expired_beyond_the_leeway_is_rejected() {
|
||||
let mut c = valid_claims();
|
||||
c["exp"] = json!(now() - 120);
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::ExpiredOrNotYetValid)
|
||||
));
|
||||
}
|
||||
|
||||
// ────────────────────── issuer / type ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_token_with_no_iss_claim_is_accepted_because_cognitum_issues_none() {
|
||||
// THE regression test for this module's worst bug to date.
|
||||
//
|
||||
// An earlier revision required and validated `iss`. Cognitum access tokens
|
||||
// have no `iss` claim, so that rejected every real token — while the suite
|
||||
// stayed green, because the fixtures had an `iss` the real thing lacks.
|
||||
// `valid_claims()` now mirrors production, so this passing means the
|
||||
// verifier accepts the shape that actually exists.
|
||||
let claims = valid_claims();
|
||||
assert!(
|
||||
claims.get("iss").is_none(),
|
||||
"fixture must mirror production, which emits no iss"
|
||||
);
|
||||
verify(&sign(&claims), scope::SENSING_READ).expect("a real-shaped token must verify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrelated_iss_claim_does_not_change_the_outcome() {
|
||||
// If identity ever starts emitting `iss`, we neither require nor reject on
|
||||
// it — the JWKS is the issuer binding. This pins that adding the claim
|
||||
// cannot silently start failing tokens.
|
||||
let mut c = valid_claims();
|
||||
c["iss"] = json!("https://something.else.example");
|
||||
verify(&sign(&c), scope::SENSING_READ)
|
||||
.expect("issuer binding is the JWKS, not a claim");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_jwks_is_what_actually_binds_a_token_to_its_issuer() {
|
||||
// The complement of the two above: with no `iss` check, the signature is
|
||||
// the ONLY thing standing between us and a forged token. A different key
|
||||
// must therefore be refused — otherwise removing the issuer check would
|
||||
// have removed the boundary entirely.
|
||||
let token = sign_with(&valid_claims(), other_key());
|
||||
assert!(matches!(
|
||||
verify(&token, scope::SENSING_READ),
|
||||
Err(VerifyError::BadSignature)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inference_typed_token_is_not_an_access_token() {
|
||||
let mut c = valid_claims();
|
||||
c["typ"] = json!("inference");
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::WrongTokenType { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_with_no_typ_claim_is_rejected() {
|
||||
let mut c = valid_claims();
|
||||
c.as_object_mut().unwrap().remove("typ");
|
||||
// Absence must never be read as a default.
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::WrongTokenType { found: None })
|
||||
));
|
||||
}
|
||||
|
||||
// ──────────── long-lived credentials (unverifiable revocation) ────────────
|
||||
|
||||
#[test]
|
||||
fn a_setup_token_is_refused_even_when_typed_as_access() {
|
||||
// A 365-day credential whose revocation lives in a table RuView cannot read.
|
||||
let mut c = valid_claims();
|
||||
c["setup"] = json!(true);
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::LongLivedCredential)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_workload_token_is_refused_even_when_typed_as_access() {
|
||||
let mut c = valid_claims();
|
||||
c["workload"] = json!(true);
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::LongLivedCredential)
|
||||
));
|
||||
}
|
||||
|
||||
// ───────────────────── attribution ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_token_without_account_id_cannot_be_attributed() {
|
||||
let mut c = valid_claims();
|
||||
c.as_object_mut().unwrap().remove("account_id");
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::MissingAccountId)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_account_id_is_treated_as_absent() {
|
||||
let mut c = valid_claims();
|
||||
c["account_id"] = json!("");
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::MissingAccountId)
|
||||
));
|
||||
}
|
||||
|
||||
// ───────────── G-2: scope is the capability boundary ─────────────
|
||||
|
||||
#[test]
|
||||
fn g2_a_genuinely_valid_token_from_another_cognitum_product_cannot_reach_the_sensing_surface() {
|
||||
// THE highest-value test in this suite.
|
||||
//
|
||||
// Correctly signed, unexpired, right issuer, right `typ` — a real token a
|
||||
// user legitimately holds for meta-proxy/completions. Cognitum access tokens
|
||||
// carry no `aud`, and cross-product identity is intended, so NOTHING about
|
||||
// the signature or the identity claims distinguishes it. Only scope does.
|
||||
//
|
||||
// A naive verifier accepts this. If this test ever passes-by-accepting,
|
||||
// an `inference` token has become a key to someone's home sensor.
|
||||
let mut c = valid_claims();
|
||||
c["client_id"] = json!("meta-proxy");
|
||||
c["scope"] = json!("inference");
|
||||
|
||||
// Rejected on AUDIENCE now (client_id), which is the stronger of the two
|
||||
// reasons — it fires before scope is even considered.
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::WrongAudience { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_minted_for_another_cognitum_product_is_refused_even_with_the_right_scope() {
|
||||
// The audience check standing alone. Same user, same signature, correct
|
||||
// sensing:read scope — but minted for freetokens, so not for this server.
|
||||
// `cognitum-one/freetokens` enforces the mirror image of this.
|
||||
let mut c = valid_claims();
|
||||
c["client_id"] = json!("freetokens");
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::WrongAudience { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_audience_list_accepts_any_client() {
|
||||
// The documented opt-out (RUVIEW_OAUTH_CLIENT_IDS=*). Pinned so the
|
||||
// behaviour is deliberate rather than accidental.
|
||||
let mut c = valid_claims();
|
||||
c["client_id"] = json!("some-other-product");
|
||||
let cfg = VerifierConfig {
|
||||
issuer: TEST_ISSUER.to_string(),
|
||||
required_scope: scope::SENSING_READ.to_string(),
|
||||
allowed_client_ids: vec![],
|
||||
};
|
||||
verify_access_token(&sign(&c), &jwks_serving_test_key(), &cfg)
|
||||
.expect("an empty allowlist means accept any client");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_allowed_clients_are_honoured() {
|
||||
// Migration case: accepting a borrowed registration alongside our own.
|
||||
let mut c = valid_claims();
|
||||
c["client_id"] = json!("meta-proxy");
|
||||
let cfg = VerifierConfig {
|
||||
issuer: TEST_ISSUER.to_string(),
|
||||
required_scope: scope::SENSING_READ.to_string(),
|
||||
allowed_client_ids: vec!["ruview".into(), "meta-proxy".into()],
|
||||
};
|
||||
verify_access_token(&sign(&c), &jwks_serving_test_key(), &cfg).expect("both accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn g2_a_read_scoped_session_cannot_reach_the_admin_surface() {
|
||||
// The routine case the least-scope rule exists for: a dashboard streaming
|
||||
// poses must not be able to delete the model it streams through.
|
||||
let token = sign(&valid_claims()); // scope: sensing:read
|
||||
assert!(matches!(
|
||||
verify(&token, scope::SENSING_ADMIN),
|
||||
Err(VerifyError::MissingScope { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn g2_an_admin_scoped_token_does_not_implicitly_grant_read() {
|
||||
// No hierarchy: consent means exactly what it said.
|
||||
let mut c = valid_claims();
|
||||
c["scope"] = json!("sensing:admin");
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::MissingScope { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_with_no_scope_at_all_grants_nothing() {
|
||||
let mut c = valid_claims();
|
||||
c["scope"] = json!("");
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::MissingScope { .. })
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "wifi-densepose-aether"
|
||||
description = "AETHER pure-compute stack (ADR-024): contrastive CSI embedding, CSI-to-pose transformer, SONA drift/LoRA, and quantization — std-only, no async/server deps so the Python `[aether]` wheel stays lean (ADR-185 §3.2)"
|
||||
version = "0.3.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
# Intentionally dependency-free: this crate is the leaf hoisted out of
|
||||
# `wifi-densepose-sensing-server` (ADR-185 §13) precisely so that binding it into
|
||||
# the `wifi_densepose[aether]` wheel does not pull the Axum/tokio/worldgraph/
|
||||
# ruvector server tree. Keep it std-only.
|
||||
[dependencies]
|
||||
|
||||
# Test-only: parses the committed golden fixtures shared with the Python parity
|
||||
# tests. Never linked into the library or the wheel.
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
|
||||
[lib]
|
||||
name = "wifi_densepose_aether"
|
||||
path = "src/lib.rs"
|
||||
+143
@@ -822,8 +822,73 @@ impl EmbeddingExtractor {
|
||||
self.projection = proj;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize all weights (transformer + projection) to `path`.
|
||||
///
|
||||
/// Format (little-endian, zero-dep so the std-only leaf crate stays
|
||||
/// dependency-free): 8-byte magic `AETHERW1`, then a `u32` parameter
|
||||
/// count, then that many `f32` values — exactly `flatten_weights()`.
|
||||
///
|
||||
/// This is the counterpart of [`Self::load_weights`]. It enables loading a
|
||||
/// *real trained* checkpoint once one exists (ADR-185 §13.a); it does not
|
||||
/// itself make the default (random-init) extractor trained.
|
||||
pub fn save_weights<P: AsRef<std::path::Path>>(&self, path: P) -> std::io::Result<()> {
|
||||
let weights = self.flatten_weights();
|
||||
let mut buf = Vec::with_capacity(WEIGHT_HEADER_LEN + weights.len() * 4);
|
||||
buf.extend_from_slice(WEIGHT_MAGIC);
|
||||
buf.extend_from_slice(&(weights.len() as u32).to_le_bytes());
|
||||
for v in &weights {
|
||||
buf.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
std::fs::write(path, buf)
|
||||
}
|
||||
|
||||
/// Load weights previously written by [`Self::save_weights`] (or any file in
|
||||
/// that format) into this extractor, replacing the current (random-init or
|
||||
/// prior) weights.
|
||||
///
|
||||
/// Errors (never panics) on: unreadable file, a payload shorter than the
|
||||
/// header, a wrong magic, a truncated/oversized payload, or a parameter
|
||||
/// count that does not match this extractor's architecture (delegated to
|
||||
/// [`Self::unflatten_weights`]).
|
||||
pub fn load_weights<P: AsRef<std::path::Path>>(&mut self, path: P) -> Result<(), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("failed to read weight file: {e}"))?;
|
||||
if bytes.len() < WEIGHT_HEADER_LEN {
|
||||
return Err(format!(
|
||||
"weight file too short: {} bytes < {WEIGHT_HEADER_LEN}-byte header",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
if &bytes[0..8] != WEIGHT_MAGIC {
|
||||
return Err("bad magic: not an AETHER weight file (expected 'AETHERW1')".to_string());
|
||||
}
|
||||
let count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
|
||||
let expected_len = WEIGHT_HEADER_LEN + count * 4;
|
||||
if bytes.len() != expected_len {
|
||||
return Err(format!(
|
||||
"weight payload size mismatch: header declares {count} params ({expected_len} bytes), file is {} bytes",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
let mut weights = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let o = WEIGHT_HEADER_LEN + i * 4;
|
||||
weights.push(f32::from_le_bytes([
|
||||
bytes[o],
|
||||
bytes[o + 1],
|
||||
bytes[o + 2],
|
||||
bytes[o + 3],
|
||||
]));
|
||||
}
|
||||
self.unflatten_weights(&weights)
|
||||
}
|
||||
}
|
||||
|
||||
/// Magic prefix for AETHER weight files (see [`EmbeddingExtractor::save_weights`]).
|
||||
const WEIGHT_MAGIC: &[u8; 8] = b"AETHERW1";
|
||||
/// 8-byte magic + 4-byte `u32` param count.
|
||||
const WEIGHT_HEADER_LEN: usize = 12;
|
||||
|
||||
// ── CSI feature statistics ─────────────────────────────────────────────────
|
||||
|
||||
/// Compute mean and variance of all values in a CSI feature matrix.
|
||||
@@ -1219,6 +1284,84 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weight save/load (ADR-185 §13.a) ────────────────────────────────
|
||||
|
||||
/// Deterministic, non-random weight pattern. Values are `k/65536 - 0.5`
|
||||
/// with `k ∈ [0, 65535]`, i.e. multiples of 2⁻¹⁶ — exactly representable
|
||||
/// in both f32 and f64 so a cross-language (Rust ↔ Python) fixture using
|
||||
/// the same formula produces byte-identical weights.
|
||||
fn deterministic_weights(n: usize) -> Vec<f32> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let k = (i as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345) % 65_536;
|
||||
k as f32 / 65_536.0 - 0.5
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_weights_actually_replaces_weights_and_round_trips() {
|
||||
let mut ext = EmbeddingExtractor::new(small_config(), small_embed_config());
|
||||
let csi = make_csi(4, 16, 42);
|
||||
let baseline = ext.extract(&csi); // random Xavier init
|
||||
|
||||
// Build a source extractor with deterministic non-default weights and
|
||||
// serialize it.
|
||||
let det = deterministic_weights(ext.param_count());
|
||||
let mut src = EmbeddingExtractor::new(small_config(), small_embed_config());
|
||||
src.unflatten_weights(&det).unwrap();
|
||||
let src_emb = src.extract(&csi);
|
||||
|
||||
let path = std::env::temp_dir()
|
||||
.join(format!("aether_wtest_{}_{:p}.bin", std::process::id(), &ext));
|
||||
src.save_weights(&path).unwrap();
|
||||
|
||||
// Load into the random-init extractor.
|
||||
ext.load_weights(&path).unwrap();
|
||||
let loaded_emb = ext.extract(&csi);
|
||||
|
||||
// (1) The loaded weights are ACTUALLY used — output moved away from the
|
||||
// random-init baseline (proves load is not a silent no-op).
|
||||
let differs = baseline
|
||||
.iter()
|
||||
.zip(&loaded_emb)
|
||||
.any(|(a, b)| (a - b).abs() > 1e-6);
|
||||
assert!(
|
||||
differs,
|
||||
"load_weights had no effect: embedding still equals the random-init baseline"
|
||||
);
|
||||
|
||||
// (2) It matches the source extractor whose weights we saved (round-trip).
|
||||
for (a, b) in src_emb.iter().zip(&loaded_emb) {
|
||||
assert!((a - b).abs() < 1e-6, "loaded embedding != source: {a} vs {b}");
|
||||
}
|
||||
|
||||
// (3) The weights are bit-identical after the file round-trip.
|
||||
assert_eq!(ext.flatten_weights(), det);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_weights_rejects_bad_magic_and_wrong_count() {
|
||||
let mut ext = EmbeddingExtractor::new(small_config(), small_embed_config());
|
||||
let base = std::env::temp_dir().join(format!("aether_wbad_{}.bin", std::process::id()));
|
||||
|
||||
// Bad magic.
|
||||
std::fs::write(&base, b"NOPEMAGIC\x00\x00\x00").unwrap();
|
||||
assert!(ext.load_weights(&base).is_err());
|
||||
|
||||
// Right magic, wrong param count for this architecture.
|
||||
let mut bad = Vec::new();
|
||||
bad.extend_from_slice(WEIGHT_MAGIC);
|
||||
bad.extend_from_slice(&3u32.to_le_bytes());
|
||||
bad.extend_from_slice(&[0u8; 12]); // 3 f32s — won't match param_count
|
||||
std::fs::write(&base, &bad).unwrap();
|
||||
assert!(ext.load_weights(&base).is_err());
|
||||
|
||||
std::fs::remove_file(&base).ok();
|
||||
}
|
||||
|
||||
// ── FingerprintIndex tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -0,0 +1,28 @@
|
||||
//! AETHER pure-compute stack (ADR-024 / ADR-185 §3.2).
|
||||
//!
|
||||
//! This crate is the dependency-free leaf hoisted out of
|
||||
//! `wifi-densepose-sensing-server` so that the Python `wifi_densepose[aether]`
|
||||
//! wheel can bind the contrastive-embedding surface without linking the server's
|
||||
//! Axum / tokio / worldgraph / ruvector tree (which blew the ADR-117 §5.4 ≤5 MB
|
||||
//! wheel budget).
|
||||
//!
|
||||
//! Modules:
|
||||
//! - [`embedding`] — AETHER contrastive CSI embedding: `EmbeddingConfig`,
|
||||
//! `EmbeddingExtractor`, `ProjectionHead`, `CsiAugmenter`, `info_nce_loss`,
|
||||
//! fingerprint indices.
|
||||
//! - [`graph_transformer`] — CSI-to-pose transformer primitives
|
||||
//! (`CsiToPoseTransformer`, `TransformerConfig`, `Linear`).
|
||||
//! - [`sona`] — self-organizing drift detection + LoRA adaptation + EWC.
|
||||
//! - [`sparse_inference`] — quantization helpers used by the embedding path.
|
||||
//!
|
||||
//! `wifi-densepose-sensing-server` re-exports these modules so its own code and
|
||||
//! public API are unchanged.
|
||||
|
||||
// `embedding` carries a couple of not-yet-read fields (e.g. `PoseEncoder.d_proj`);
|
||||
// this mirrors the `#[allow(dead_code)]` the module had at its previous home in
|
||||
// `wifi-densepose-sensing-server`.
|
||||
#[allow(dead_code)]
|
||||
pub mod embedding;
|
||||
pub mod graph_transformer;
|
||||
pub mod sona;
|
||||
pub mod sparse_inference;
|
||||
@@ -0,0 +1,115 @@
|
||||
//! ADR-185 §4.1 — NATIVE half of the AETHER parity gate, and the half that runs
|
||||
//! in CI.
|
||||
//!
|
||||
//! The committed golden vectors live under `python/tests/golden/` and are
|
||||
//! shared with `python/tests/test_aether.py` (the binding half). That pytest
|
||||
//! runs in python-ci; the native reference tests in `python/tests/*.rs` link
|
||||
//! against the PyO3 crate and are NOT run by any workflow. This test closes that
|
||||
//! gap: it recomputes the embedding through THIS std-only crate — no PyO3, no
|
||||
//! marshalling — and asserts it matches the same golden within tolerance.
|
||||
//!
|
||||
//! Together with the pytest half: native≈golden AND binding≈golden ⇒
|
||||
//! binding≈native, portably. And because this side has no marshalling, a
|
||||
//! binding-specific defect baked into the golden would surface here as a native
|
||||
//! mismatch — which is the failure mode a binding-derived golden + pytest-only
|
||||
//! CI would otherwise hide.
|
||||
//!
|
||||
//! Runs under the repo's `cargo test --workspace` (this crate is a member).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use wifi_densepose_aether::embedding::{EmbeddingConfig, EmbeddingExtractor};
|
||||
use wifi_densepose_aether::graph_transformer::TransformerConfig;
|
||||
|
||||
// Same tolerance as the Python and .rs parity tests. f32 + transcendentals are
|
||||
// not bit-reproducible across arch, so the golden (generated on one machine) is
|
||||
// compared within a bound, not by hash.
|
||||
const PARITY_ATOL: f32 = 1e-4;
|
||||
const PARITY_RTOL: f32 = 1e-4;
|
||||
|
||||
fn golden_dir() -> PathBuf {
|
||||
// This crate lives at v2/crates/wifi-densepose-aether; the shared golden
|
||||
// fixtures are the single source of truth under python/tests/golden.
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../python/tests/golden")
|
||||
}
|
||||
|
||||
fn read_vec(name: &str) -> Vec<f32> {
|
||||
let raw = std::fs::read_to_string(golden_dir().join(name))
|
||||
.unwrap_or_else(|e| panic!("read {name}: {e}"));
|
||||
serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {name}: {e}"))
|
||||
}
|
||||
|
||||
fn load_input() -> Vec<Vec<f32>> {
|
||||
let raw = std::fs::read_to_string(golden_dir().join("aether_input.json"))
|
||||
.expect("read aether_input.json");
|
||||
let rows: Vec<Vec<f64>> = serde_json::from_str(&raw).expect("parse aether_input.json");
|
||||
rows.into_iter()
|
||||
.map(|r| r.into_iter().map(|x| x as f32).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extractor() -> EmbeddingExtractor {
|
||||
let e = EmbeddingConfig { d_model: 64, d_proj: 128, temperature: 0.07, normalize: true };
|
||||
let t = TransformerConfig {
|
||||
n_subcarriers: 56,
|
||||
n_keypoints: 17,
|
||||
d_model: 64,
|
||||
n_heads: 4,
|
||||
n_gnn_layers: 2,
|
||||
};
|
||||
EmbeddingExtractor::new(t, e)
|
||||
}
|
||||
|
||||
fn formula_weights(n: usize) -> Vec<f32> {
|
||||
(0..n)
|
||||
.map(|i| ((i as u64 * 1103515245 + 12345) % 65536) as f32 / 65536.0 - 0.5)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_matches_golden(embedding: &[f32], name: &str) {
|
||||
let golden = read_vec(name);
|
||||
assert_eq!(embedding.len(), golden.len(), "{name}: length mismatch");
|
||||
for (i, (&a, &b)) in embedding.iter().zip(&golden).enumerate() {
|
||||
assert!(a.is_finite(), "{name}: element {i} is not finite ({a})");
|
||||
let tol = PARITY_ATOL + PARITY_RTOL * b.abs();
|
||||
assert!(
|
||||
(a - b).abs() <= tol,
|
||||
"{name}: element {i} diverged beyond tolerance \
|
||||
(got {a}, golden {b}, |Δ|={}) — real regression, not arch drift",
|
||||
(a - b).abs()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_base_embedding_matches_committed_golden() {
|
||||
let emb = extractor().extract(&load_input());
|
||||
assert_matches_golden(&emb, "aether_embedding.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_loaded_embedding_matches_committed_golden() {
|
||||
let input = load_input();
|
||||
let mut ext = extractor();
|
||||
let baseline = ext.extract(&input);
|
||||
|
||||
let weights = formula_weights(ext.param_count());
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(b"AETHERW1");
|
||||
buf.extend_from_slice(&(weights.len() as u32).to_le_bytes());
|
||||
for w in &weights {
|
||||
buf.extend_from_slice(&w.to_le_bytes());
|
||||
}
|
||||
let wpath = std::env::temp_dir().join(format!("aether_golden_parity_{}.bin", std::process::id()));
|
||||
std::fs::write(&wpath, &buf).expect("write weights");
|
||||
ext.load_weights(&wpath).expect("load_weights");
|
||||
let _ = std::fs::remove_file(&wpath);
|
||||
|
||||
let loaded = ext.extract(&input);
|
||||
assert!(
|
||||
baseline.iter().zip(&loaded).any(|(a, b)| (a - b).abs() > 1e-6),
|
||||
"load_weights had no effect vs the random-init baseline"
|
||||
);
|
||||
assert_matches_golden(&loaded, "aether_loaded_embedding.json");
|
||||
}
|
||||
@@ -56,6 +56,14 @@ csv = "1.3"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
|
||||
# ADR-271 phase 2 — `login`/`logout`/`whoami`. The `login` feature carries the
|
||||
# interactive half (PKCE, loopback, OOB paste, credential store, refresh);
|
||||
# the sensing server depends on this same crate with default features and
|
||||
# gets only the verifier.
|
||||
ruview-auth = { path = "../ruview-auth", features = ["login"] }
|
||||
# Only for constructing the HTTP client hands to Session.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
thiserror = "2.0"
|
||||
|
||||
# Time
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! `wifi-densepose login` / `logout` / `whoami` — Cognitum sign-in (ADR-271).
|
||||
//!
|
||||
//! Signing in yields a Cognitum access token that a RuView sensing server
|
||||
//! verifies offline against `auth.cognitum.one`'s published JWKS. It replaces
|
||||
//! sharing one `RUVIEW_API_TOKEN` string between everyone who needs access:
|
||||
//! requests become attributable to a person, and destructive routes can be
|
||||
//! separated from read-only ones by scope.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Args;
|
||||
use ruview_auth::login::{self, LoginOptions};
|
||||
use ruview_auth::scope;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct LoginArgs {
|
||||
/// Also request `sensing:admin` — the capability to train models and delete
|
||||
/// models and recordings.
|
||||
///
|
||||
/// Off by default on purpose. A session that only streams poses has no
|
||||
/// business holding delete capability, and a token that carries it is a
|
||||
/// bigger loss if it leaks. Ask for it when you are about to do
|
||||
/// administrative work, not as a matter of habit.
|
||||
#[arg(long)]
|
||||
pub admin: bool,
|
||||
|
||||
/// Skip the browser and use the paste-a-code flow.
|
||||
///
|
||||
/// Detected automatically over SSH and inside containers; this forces it.
|
||||
#[arg(long)]
|
||||
pub no_browser: bool,
|
||||
|
||||
/// Where to store credentials. Defaults to `~/.ruview/credentials.json`.
|
||||
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
|
||||
pub credentials_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct LogoutArgs {
|
||||
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
|
||||
pub credentials_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct WhoamiArgs {
|
||||
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
|
||||
pub credentials_path: Option<PathBuf>,
|
||||
|
||||
/// Refresh the access token now if it has expired, instead of only
|
||||
/// reporting that it will be refreshed on next use.
|
||||
///
|
||||
/// Refreshing rotates the stored refresh token — identity spends the old
|
||||
/// one — so this is a real state change, not a read. That is why it is a
|
||||
/// flag rather than something `whoami` does silently.
|
||||
#[arg(long)]
|
||||
pub refresh: bool,
|
||||
}
|
||||
|
||||
fn path_or_default(p: Option<PathBuf>) -> PathBuf {
|
||||
p.unwrap_or_else(login::default_credentials_path)
|
||||
}
|
||||
|
||||
/// What `login` asks the authorization server for.
|
||||
///
|
||||
/// Extracted so it is testable on its own. `LoginOptions::default()` has its own
|
||||
/// least-privilege test in the library, but this command does NOT go through
|
||||
/// that default — it builds the scope string itself, so the library test says
|
||||
/// nothing about what the CLI actually requests.
|
||||
fn requested_scope(admin: bool) -> String {
|
||||
if admin {
|
||||
// Admin implies read: there is no scope hierarchy server-side, so a
|
||||
// session that needs both must consent to both explicitly.
|
||||
format!("{} {}", scope::SENSING_READ, scope::SENSING_ADMIN)
|
||||
} else {
|
||||
scope::SENSING_READ.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login_cmd(args: LoginArgs) -> anyhow::Result<()> {
|
||||
let scope = requested_scope(args.admin);
|
||||
|
||||
let opts = LoginOptions {
|
||||
credentials_path: path_or_default(args.credentials_path),
|
||||
scope,
|
||||
no_browser: args.no_browser,
|
||||
};
|
||||
|
||||
let mut out = std::io::stdout();
|
||||
let stdin = std::io::stdin();
|
||||
let mut input = stdin.lock();
|
||||
|
||||
login::login(&opts, &mut out, &mut input).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn logout_cmd(args: LogoutArgs) -> anyhow::Result<()> {
|
||||
let path = path_or_default(args.credentials_path);
|
||||
if login::logout(&path)? {
|
||||
println!("Signed out — {} removed.", path.display());
|
||||
} else {
|
||||
println!("Not signed in; nothing to remove.");
|
||||
}
|
||||
// Deliberately local-only. This makes the machine unable to act as you;
|
||||
// revoking the session for every device is an account-level action.
|
||||
println!("Note: this forgets the local credential only. It does not revoke the session server-side.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn whoami_cmd(args: WhoamiArgs) -> anyhow::Result<()> {
|
||||
let path = path_or_default(args.credentials_path);
|
||||
let mut creds = ruview_auth::login::store::load(&path)?;
|
||||
|
||||
if args.refresh && creds.needs_refresh() {
|
||||
println!("Access token expired — refreshing…");
|
||||
let session = ruview_auth::login::Session::load_from(path.clone(), reqwest::Client::new())?;
|
||||
// Goes through ensure_fresh, so it inherits the single-flight guarantee
|
||||
// and the persist-before-return ordering rather than reimplementing a
|
||||
// second, subtly different refresh path.
|
||||
session.ensure_fresh().await?;
|
||||
creds = session.snapshot().await;
|
||||
println!("Refreshed.\n");
|
||||
}
|
||||
|
||||
println!("Credentials: {}", path.display());
|
||||
println!("Issuer: {}", creds.issuer);
|
||||
match &creds.account_email {
|
||||
Some(e) => println!("Account: {e}"),
|
||||
None => println!("Account: (not reported)"),
|
||||
}
|
||||
// Falls back to the token's own claim, so a file written before that
|
||||
// fallback existed still reports its real scope.
|
||||
match creds.effective_scope() {
|
||||
Some(s) => println!("Scope: {s}"),
|
||||
None => println!("Scope: (not reported)"),
|
||||
}
|
||||
// State, not just contents: an expired-looking session is the single most
|
||||
// common reason a command starts 401ing, so say it plainly here rather than
|
||||
// letting the user infer it from a failure elsewhere.
|
||||
if creds.needs_refresh() {
|
||||
println!("Status: access token expired or expiring — pass --refresh to renew it now");
|
||||
} else {
|
||||
println!("Status: access token valid");
|
||||
}
|
||||
if creds.refresh_token.is_none() {
|
||||
println!("Warning: no refresh token stored; you will need to sign in again when this expires");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_plain_login_asks_for_read_only() {
|
||||
// The whole point of splitting the scopes (ADR-060) is that streaming
|
||||
// poses must not carry the capability to delete recordings. If this
|
||||
// ever returns admin by default, every session silently becomes
|
||||
// destructive-capable and nothing else in the suite would notice.
|
||||
let s = requested_scope(false);
|
||||
assert_eq!(s, scope::SENSING_READ);
|
||||
assert!(!s.contains(scope::SENSING_ADMIN), "read-only login leaked admin: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_login_asks_for_both_because_there_is_no_hierarchy() {
|
||||
// The authorization server grants exactly what is requested; admin does
|
||||
// not imply read. Asking for admin alone would produce a session that
|
||||
// cannot stream.
|
||||
let s = requested_scope(true);
|
||||
assert!(s.split_whitespace().any(|x| x == scope::SENSING_READ), "{s}");
|
||||
assert!(s.split_whitespace().any(|x| x == scope::SENSING_ADMIN), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_credentials_path_is_honoured_over_the_default() {
|
||||
// `--credentials-path` also carries the RUVIEW_CREDENTIALS_PATH env
|
||||
// binding; silently ignoring it would write credentials somewhere the
|
||||
// operator did not choose.
|
||||
let p = PathBuf::from("/tmp/ruview-cli-explicit-credentials.json");
|
||||
assert_eq!(path_or_default(Some(p.clone())), p);
|
||||
assert_eq!(path_or_default(None), login::default_credentials_path());
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
pub mod auth;
|
||||
pub mod calibrate;
|
||||
pub mod calibrate_api;
|
||||
pub mod room;
|
||||
@@ -50,6 +51,16 @@ pub struct Cli {
|
||||
/// Top-level commands
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// Sign in to Cognitum (ADR-271). Stores a token this machine can present
|
||||
/// to a RuView sensing server instead of sharing one static API token.
|
||||
Login(auth::LoginArgs),
|
||||
|
||||
/// Forget the locally stored Cognitum credentials.
|
||||
Logout(auth::LogoutArgs),
|
||||
|
||||
/// Show the stored Cognitum session: account, scope, and whether it is live.
|
||||
Whoami(auth::WhoamiArgs),
|
||||
|
||||
/// Empty-room baseline calibration (ADR-135).
|
||||
/// Captures CSI frames via UDP and saves a per-subcarrier statistical
|
||||
/// baseline used for real-time motion z-scoring and CIR reference.
|
||||
|
||||
@@ -18,6 +18,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Login(args) => {
|
||||
wifi_densepose_cli::auth::login_cmd(args).await?;
|
||||
}
|
||||
Commands::Logout(args) => {
|
||||
wifi_densepose_cli::auth::logout_cmd(args).await?;
|
||||
}
|
||||
Commands::Whoami(args) => {
|
||||
wifi_densepose_cli::auth::whoami_cmd(args).await?;
|
||||
}
|
||||
Commands::Calibrate(args) => {
|
||||
wifi_densepose_cli::calibrate::execute(args).await?;
|
||||
}
|
||||
|
||||
@@ -12,15 +12,25 @@ categories = ["science", "algorithms"]
|
||||
readme = "README.md"
|
||||
|
||||
[features]
|
||||
default = ["std", "api", "ruvector"]
|
||||
default = ["std", "api", "ruvector", "ml"]
|
||||
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
|
||||
std = []
|
||||
# ONNX-backed ML detection (debris + vital-signs classifiers). Pulls
|
||||
# `wifi-densepose-nn` (and, via its default `onnx` feature, the `ort`
|
||||
# ONNX Runtime + its download/reqwest stack) ONLY when enabled. The
|
||||
# survivor-detection/triage pipeline works without it (ML is an optional
|
||||
# enhancement, off unless `DetectionConfig::enable_ml`), so consumers that
|
||||
# don't need ONNX — e.g. the ADR-185 `wifi-densepose-py` `[mat]` wheel —
|
||||
# build `--no-default-features` and drop the entire ort/reqwest tree,
|
||||
# keeping the wheel within the ADR-117 §5.4 budget.
|
||||
ml = ["dep:wifi-densepose-nn"]
|
||||
# REST/WebSocket surface. Pulls the web stack (axum, futures-util) only when
|
||||
# enabled, and enables the `serde` FEATURE (not just `dep:serde`) so the
|
||||
# `cfg_attr(feature = "serde", ...)` derives on domain types are actually
|
||||
# active when the API is on (review finding 5: `api = ["dep:serde"]` enabled
|
||||
# the dependency but left every `feature = "serde"` cfg dead).
|
||||
api = ["serde", "dep:axum", "dep:futures-util"]
|
||||
# The REST surface exposes ML status (`ml_ready`), so `api` implies `ml`.
|
||||
api = ["ml", "serde", "dep:axum", "dep:futures-util"]
|
||||
# Real ESP32 serial CSI ingest. Pulls the native `serialport` crate (libudev on
|
||||
# Linux) only when enabled, so the default/no-default appliance build stays free
|
||||
# of native serial deps. With the feature OFF, the ESP32 serial *parser* still
|
||||
@@ -36,14 +46,18 @@ serde = ["dep:serde", "chrono/serde", "geo/use-serde"]
|
||||
# Workspace dependencies
|
||||
wifi-densepose-core = { version = "0.3.0", path = "../wifi-densepose-core" }
|
||||
wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false }
|
||||
wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn" }
|
||||
wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn", optional = true }
|
||||
ruvector-solver = { workspace = true, optional = true }
|
||||
ruvector-temporal-tensor = { workspace = true, optional = true }
|
||||
|
||||
# Async runtime — required by the core integration layer (UDP CSI receiver,
|
||||
# hardware adapter, scan loop in `DisasterResponse::start_scanning`), not just
|
||||
# the REST API, so it is deliberately NOT gated behind `api`.
|
||||
tokio = { version = "1.35", features = ["rt", "sync", "time"] }
|
||||
# `macros` is needed by `tokio::select!` in integration/hardware_adapter.rs.
|
||||
# It was previously satisfied only by feature-unification from the (now
|
||||
# optional) `wifi-densepose-nn` dep; declare it explicitly so a
|
||||
# `--no-default-features` build (the ADR-185 [mat] wheel) still compiles.
|
||||
tokio = { version = "1.35", features = ["rt", "sync", "time", "macros"] }
|
||||
async-trait = "0.1"
|
||||
|
||||
# Web framework (REST API) — only compiled with the `api` feature.
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::{
|
||||
MovementClassifier, MovementClassifierConfig,
|
||||
};
|
||||
use crate::domain::{ScanZone, VitalSignsReading};
|
||||
#[cfg(feature = "ml")]
|
||||
use crate::ml::{MlDetectionConfig, MlDetectionPipeline, MlDetectionResult};
|
||||
use crate::{DisasterConfig, MatError};
|
||||
|
||||
@@ -26,9 +27,10 @@ pub struct DetectionConfig {
|
||||
pub enable_heartbeat: bool,
|
||||
/// Minimum overall confidence to report detection
|
||||
pub min_confidence: f64,
|
||||
/// Enable ML-enhanced detection
|
||||
/// Enable ML-enhanced detection (requires the `ml` feature to have any effect)
|
||||
pub enable_ml: bool,
|
||||
/// ML detection configuration (if enabled)
|
||||
#[cfg(feature = "ml")]
|
||||
pub ml_config: Option<MlDetectionConfig>,
|
||||
}
|
||||
|
||||
@@ -42,6 +44,7 @@ impl Default for DetectionConfig {
|
||||
enable_heartbeat: false,
|
||||
min_confidence: 0.3,
|
||||
enable_ml: false,
|
||||
#[cfg(feature = "ml")]
|
||||
ml_config: None,
|
||||
}
|
||||
}
|
||||
@@ -64,6 +67,7 @@ impl DetectionConfig {
|
||||
}
|
||||
|
||||
/// Enable ML-enhanced detection with the given configuration
|
||||
#[cfg(feature = "ml")]
|
||||
pub fn with_ml(mut self, ml_config: MlDetectionConfig) -> Self {
|
||||
self.enable_ml = true;
|
||||
self.ml_config = Some(ml_config);
|
||||
@@ -71,6 +75,7 @@ impl DetectionConfig {
|
||||
}
|
||||
|
||||
/// Enable ML-enhanced detection with default configuration
|
||||
#[cfg(feature = "ml")]
|
||||
pub fn with_default_ml(mut self) -> Self {
|
||||
self.enable_ml = true;
|
||||
self.ml_config = Some(MlDetectionConfig::default());
|
||||
@@ -147,12 +152,14 @@ pub struct DetectionPipeline {
|
||||
movement_classifier: MovementClassifier,
|
||||
data_buffer: parking_lot::RwLock<CsiDataBuffer>,
|
||||
/// Optional ML detection pipeline
|
||||
#[cfg(feature = "ml")]
|
||||
ml_pipeline: Option<MlDetectionPipeline>,
|
||||
}
|
||||
|
||||
impl DetectionPipeline {
|
||||
/// Create a new detection pipeline
|
||||
pub fn new(config: DetectionConfig) -> Self {
|
||||
#[cfg(feature = "ml")]
|
||||
let ml_pipeline = if config.enable_ml {
|
||||
config.ml_config.clone().map(MlDetectionPipeline::new)
|
||||
} else {
|
||||
@@ -164,12 +171,14 @@ impl DetectionPipeline {
|
||||
heartbeat_detector: HeartbeatDetector::new(config.heartbeat.clone()),
|
||||
movement_classifier: MovementClassifier::new(config.movement.clone()),
|
||||
data_buffer: parking_lot::RwLock::new(CsiDataBuffer::new(config.sample_rate)),
|
||||
#[cfg(feature = "ml")]
|
||||
ml_pipeline,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize ML models asynchronously (if enabled)
|
||||
#[cfg(feature = "ml")]
|
||||
pub async fn initialize_ml(&mut self) -> Result<(), MatError> {
|
||||
if let Some(ref mut ml) = self.ml_pipeline {
|
||||
ml.initialize().await.map_err(MatError::from)?;
|
||||
@@ -178,6 +187,7 @@ impl DetectionPipeline {
|
||||
}
|
||||
|
||||
/// Check if ML pipeline is ready
|
||||
#[cfg(feature = "ml")]
|
||||
pub fn ml_ready(&self) -> bool {
|
||||
self.ml_pipeline.as_ref().is_none_or(|ml| ml.is_ready())
|
||||
}
|
||||
@@ -210,13 +220,23 @@ impl DetectionPipeline {
|
||||
// `buffer` guard dropped here
|
||||
};
|
||||
|
||||
// If ML is enabled and ready, enhance with ML predictions
|
||||
let enhanced_reading = if self.config.enable_ml && self.ml_ready() {
|
||||
// Snapshot the buffer under the lock, then drop the guard before await.
|
||||
let buffer_snapshot = { self.data_buffer.read().clone() };
|
||||
self.enhance_with_ml(reading, &buffer_snapshot).await?
|
||||
} else {
|
||||
reading
|
||||
// If ML is enabled and ready, enhance with ML predictions (only
|
||||
// compiled under the `ml` feature; the base build is signal-only).
|
||||
let enhanced_reading = {
|
||||
#[cfg(feature = "ml")]
|
||||
{
|
||||
if self.config.enable_ml && self.ml_ready() {
|
||||
// Snapshot the buffer under the lock, then drop the guard before await.
|
||||
let buffer_snapshot = { self.data_buffer.read().clone() };
|
||||
self.enhance_with_ml(reading, &buffer_snapshot).await?
|
||||
} else {
|
||||
reading
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "ml"))]
|
||||
{
|
||||
reading
|
||||
}
|
||||
};
|
||||
|
||||
// Check minimum confidence
|
||||
@@ -230,6 +250,7 @@ impl DetectionPipeline {
|
||||
}
|
||||
|
||||
/// Enhance detection results with ML predictions
|
||||
#[cfg(feature = "ml")]
|
||||
async fn enhance_with_ml(
|
||||
&self,
|
||||
traditional_reading: Option<VitalSignsReading>,
|
||||
@@ -262,6 +283,7 @@ impl DetectionPipeline {
|
||||
}
|
||||
|
||||
/// Get the latest ML detection results (if ML is enabled)
|
||||
#[cfg(feature = "ml")]
|
||||
pub async fn get_ml_results(&self) -> Option<MlDetectionResult> {
|
||||
let ml = match &self.ml_pipeline {
|
||||
Some(ml) => ml,
|
||||
@@ -346,6 +368,7 @@ impl DetectionPipeline {
|
||||
self.movement_classifier = MovementClassifier::new(config.movement.clone());
|
||||
|
||||
// Update ML pipeline if configuration changed
|
||||
#[cfg(feature = "ml")]
|
||||
if config.enable_ml != self.config.enable_ml || config.ml_config != self.config.ml_config {
|
||||
self.ml_pipeline = if config.enable_ml {
|
||||
config.ml_config.clone().map(MlDetectionPipeline::new)
|
||||
@@ -358,6 +381,7 @@ impl DetectionPipeline {
|
||||
}
|
||||
|
||||
/// Get the ML pipeline (if enabled)
|
||||
#[cfg(feature = "ml")]
|
||||
pub fn ml_pipeline(&self) -> Option<&MlDetectionPipeline> {
|
||||
self.ml_pipeline.as_ref()
|
||||
}
|
||||
|
||||
@@ -87,6 +87,10 @@ pub mod detection;
|
||||
pub mod domain;
|
||||
pub mod integration;
|
||||
pub mod localization;
|
||||
/// ONNX-backed ML detection. Requires the `ml` feature (pulls
|
||||
/// `wifi-densepose-nn` + `ort`). The core survivor-detection/triage
|
||||
/// pipeline works without it.
|
||||
#[cfg(feature = "ml")]
|
||||
pub mod ml;
|
||||
pub mod tracking;
|
||||
|
||||
@@ -130,6 +134,7 @@ pub use integration::{
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
|
||||
pub use api::{create_router, AppState};
|
||||
|
||||
#[cfg(feature = "ml")]
|
||||
pub use ml::{
|
||||
AttenuationPrediction,
|
||||
BreathingClassification,
|
||||
@@ -207,6 +212,7 @@ pub enum MatError {
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// Machine learning error
|
||||
#[cfg(feature = "ml")]
|
||||
#[error("ML error: {0}")]
|
||||
Ml(#[from] ml::MlError),
|
||||
}
|
||||
@@ -592,8 +598,6 @@ pub mod prelude {
|
||||
AssociationResult,
|
||||
BreathingPattern,
|
||||
Coordinates3D,
|
||||
DebrisClassification,
|
||||
DebrisModel,
|
||||
DetectionEvent,
|
||||
DetectionObservation,
|
||||
// Detection
|
||||
@@ -614,11 +618,6 @@ pub mod prelude {
|
||||
// Localization
|
||||
LocalizationService,
|
||||
MatError,
|
||||
MaterialType,
|
||||
// ML types
|
||||
MlDetectionConfig,
|
||||
MlDetectionPipeline,
|
||||
MlDetectionResult,
|
||||
Priority,
|
||||
Result,
|
||||
ScanZone,
|
||||
@@ -631,12 +630,17 @@ pub mod prelude {
|
||||
TrackerConfig,
|
||||
TrackingEvent,
|
||||
TriageStatus,
|
||||
UncertaintyEstimate,
|
||||
VitalSignsClassifier,
|
||||
VitalSignsDetector,
|
||||
VitalSignsReading,
|
||||
ZoneBounds,
|
||||
};
|
||||
|
||||
// ONNX-backed ML types — only when the `ml` feature is enabled.
|
||||
#[cfg(feature = "ml")]
|
||||
pub use crate::{
|
||||
DebrisClassification, DebrisModel, MaterialType, MlDetectionConfig, MlDetectionPipeline,
|
||||
MlDetectionResult, UncertaintyEstimate, VitalSignsClassifier,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -41,6 +41,12 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
# CLI
|
||||
clap = { workspace = true }
|
||||
|
||||
# ADR-185 §3.2/§13: AETHER pure-compute stack (embedding / graph_transformer /
|
||||
# sona / sparse_inference), hoisted into a std-only leaf crate and re-exported
|
||||
# from `lib.rs` so the Python `[aether]` wheel can bind it without this server's
|
||||
# Axum/tokio/worldgraph/ruvector tree.
|
||||
wifi-densepose-aether = { version = "0.3.0", path = "../wifi-densepose-aether" }
|
||||
|
||||
# Multi-BSSID WiFi scanning pipeline (ADR-022 Phase 3)
|
||||
wifi-densepose-wifiscan = { version = "0.3.0", path = "../wifi-densepose-wifiscan" }
|
||||
|
||||
@@ -85,6 +91,17 @@ ureq = { version = "2", default-features = false, features = ["tls", "json"
|
||||
sha2 = "0.10"
|
||||
thiserror = "1"
|
||||
|
||||
# ADR-271 — Cognitum OAuth access-token verification. Reuses the `ureq`
|
||||
# transport above rather than pulling a second HTTP stack: `ruview-auth`'s
|
||||
# JWKS fetch sits behind a trait, and its default feature is the ureq one.
|
||||
ruview-auth = { path = "../ruview-auth", features = ["pkce"] }
|
||||
# ADR-271 browser sign-in: signed transaction + session cookies.
|
||||
hmac = "0.12"
|
||||
subtle = "2"
|
||||
base64 = "0.21"
|
||||
# ADR-272 — unpredictable single-use WebSocket tickets.
|
||||
rand = "0.8"
|
||||
|
||||
# ADR-115 §3.8 — MQTT publisher (HA-DISCO).
|
||||
# Gated behind the `mqtt` feature so the default binary stays small for users
|
||||
# who don't need Home Assistant integration. `rumqttc` is the chosen Rust MQTT
|
||||
@@ -109,6 +126,10 @@ matter = []
|
||||
tempfile = "3.10"
|
||||
# `tower::ServiceExt::oneshot` for in-process Router tests (bearer_auth).
|
||||
tower = { workspace = true }
|
||||
# ADR-186 P6 — real-socket WebSocket client for the `/ws/train/progress`
|
||||
# 101-upgrade + live-progress-frame test. Pinned to the version already resolved
|
||||
# in the workspace lock (via homecore-api) so this adds no new lock entry.
|
||||
tokio-tungstenite = "0.24"
|
||||
# ADR-115 P9 — micro-benchmarks for MQTT hot paths + semantic bus.
|
||||
# Heavy dep tree (~80 transitive crates) so it's dev-only; benches live
|
||||
# behind --features mqtt because they bench the mqtt module.
|
||||
@@ -118,6 +139,12 @@ criterion = { version = "0.5", features = ["html_reports"] }
|
||||
# (random Unicode, control chars, etc.). Pinned to a small version that
|
||||
# doesn't pull in proptest-derive (we don't need it).
|
||||
proptest = { version = "1.5", default-features = false, features = ["std"] }
|
||||
# ADR-271 — sign real ES256 tokens so the middleware's OAuth path is exercised
|
||||
# end to end (router → middleware → verifier), not just mocked at the seam.
|
||||
# Keys are generated at test runtime; none are committed.
|
||||
jsonwebtoken = "9"
|
||||
p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] }
|
||||
base64 = "0.21"
|
||||
|
||||
[[bench]]
|
||||
name = "mqtt_throughput"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,13 +9,12 @@
|
||||
//! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099)
|
||||
|
||||
pub mod bearer_auth;
|
||||
pub mod browser_session;
|
||||
pub mod ws_ticket;
|
||||
pub mod cli;
|
||||
pub mod dataset;
|
||||
pub mod edge_registry;
|
||||
#[allow(dead_code)]
|
||||
pub mod embedding;
|
||||
pub mod error_response;
|
||||
pub mod graph_transformer;
|
||||
pub mod host_validation;
|
||||
pub mod introspection;
|
||||
pub mod matter;
|
||||
@@ -29,8 +28,6 @@ pub mod semantic;
|
||||
pub mod rufield_surface;
|
||||
pub mod rvf_container;
|
||||
pub mod rvf_pipeline;
|
||||
pub mod sona;
|
||||
pub mod sparse_inference;
|
||||
#[allow(dead_code)]
|
||||
pub mod trainer;
|
||||
pub mod vital_signs;
|
||||
@@ -42,3 +39,12 @@ pub mod vendor_origin_plume;
|
||||
pub mod vendor_remaining;
|
||||
/// ADR-270 provider registry and canonical event helpers.
|
||||
pub mod vendor_rf;
|
||||
|
||||
// ADR-185 §3.2/§13: the AETHER pure-compute stack (contrastive embedding,
|
||||
// CSI-to-pose transformer, SONA, quantization) was hoisted into the std-only
|
||||
// `wifi-densepose-aether` leaf crate so the Python `[aether]` wheel can bind it
|
||||
// without this crate's Axum/tokio/worldgraph/ruvector tree. Re-exported here so
|
||||
// this crate's own code (`crate::embedding`, `crate::graph_transformer`,
|
||||
// `crate::sona`) and public API (`wifi_densepose_sensing_server::embedding`, …)
|
||||
// are unchanged.
|
||||
pub use wifi_densepose_aether::{embedding, graph_transformer, sona, sparse_inference};
|
||||
|
||||
@@ -20,8 +20,14 @@ mod multistatic_bridge;
|
||||
mod mediatek_csi;
|
||||
mod qualcomm_csi;
|
||||
mod realtek_radar;
|
||||
mod path_safety;
|
||||
pub mod pose;
|
||||
mod rvf_container;
|
||||
// ADR-186 (TRAIN-RECONNECT): the in-server training pipeline was written but
|
||||
// never declared as a module, so it was orphaned / uncompiled. Declaring it
|
||||
// here compiles it against the real `AppStateInner` and wires its `routes()`
|
||||
// (including `/ws/train/progress`) into the live router below.
|
||||
mod training_api;
|
||||
mod rvf_pipeline;
|
||||
mod tracker_bridge;
|
||||
pub mod types;
|
||||
@@ -1120,11 +1126,13 @@ struct AppStateInner {
|
||||
recording_current_id: Option<String>,
|
||||
/// Shutdown signal for the recording writer task.
|
||||
recording_stop_tx: Option<tokio::sync::watch::Sender<bool>>,
|
||||
// ── Training fields ─────────────────────────────────────────────────────
|
||||
/// Training status: "idle", "running", "completed", "failed".
|
||||
training_status: String,
|
||||
/// Training configuration, if any.
|
||||
training_config: Option<serde_json::Value>,
|
||||
// ── Training fields (ADR-186 TRAIN-RECONNECT) ────────────────────────────
|
||||
/// Live training state (shared status snapshot + cooperative cancel flag +
|
||||
/// background task handle) for the in-server trainer in `training_api`.
|
||||
training_state: training_api::TrainingState,
|
||||
/// Fan-out channel the background training job publishes progress JSON to;
|
||||
/// the `/ws/train/progress` WebSocket handler subscribes to it.
|
||||
training_progress_tx: broadcast::Sender<String>,
|
||||
// ── Adaptive classifier (environment-tuned) ──────────────────────────
|
||||
/// Trained adaptive model (loaded from data/adaptive_model.json or trained at runtime).
|
||||
adaptive_model: Option<adaptive_classifier::AdaptiveModel>,
|
||||
@@ -1248,6 +1256,87 @@ const FRAME_HISTORY_CAPACITY: usize = 100;
|
||||
|
||||
type SharedState = Arc<RwLock<AppStateInner>>;
|
||||
|
||||
#[cfg(test)]
|
||||
impl AppStateInner {
|
||||
/// Minimal, dependency-free `AppStateInner` for in-process router tests
|
||||
/// (ADR-186 P6). Uses the same field constructors as the real state seeding
|
||||
/// in `main()` but with trivial values and no CLI/config inputs, so tests can
|
||||
/// build the training router without the full server boot.
|
||||
pub(crate) fn minimal() -> Self {
|
||||
AppStateInner {
|
||||
latest_update: None,
|
||||
rssi_history: VecDeque::new(),
|
||||
frame_history: VecDeque::new(),
|
||||
tick: 0,
|
||||
source: "test".to_string(),
|
||||
last_esp32_frame: None,
|
||||
latest_realtek_radar: None,
|
||||
last_realtek_frame: None,
|
||||
latest_mediatek_csi: None,
|
||||
last_mediatek_frame: None,
|
||||
latest_qualcomm_csi: None,
|
||||
last_qualcomm_frame: None,
|
||||
latest_vendor_rf: BTreeMap::new(),
|
||||
tx: broadcast::channel::<String>(16).0,
|
||||
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
|
||||
intro_tx: broadcast::channel::<String>(16).0,
|
||||
total_detections: 0,
|
||||
start_time: std::time::Instant::now(),
|
||||
vital_detector: VitalSignDetector::new(10.0),
|
||||
latest_vitals: VitalSigns::default(),
|
||||
rvf_info: None,
|
||||
save_rvf_path: None,
|
||||
progressive_loader: None,
|
||||
active_sona_profile: None,
|
||||
model_loaded: false,
|
||||
smoothed_person_score: 0.0,
|
||||
prev_person_count: 0,
|
||||
smoothed_motion: 0.0,
|
||||
current_motion_level: "absent".to_string(),
|
||||
debounce_counter: 0,
|
||||
debounce_candidate: "absent".to_string(),
|
||||
baseline_motion: 0.0,
|
||||
baseline_frames: 0,
|
||||
smoothed_hr: 0.0,
|
||||
smoothed_br: 0.0,
|
||||
smoothed_hr_conf: 0.0,
|
||||
smoothed_br_conf: 0.0,
|
||||
hr_buffer: VecDeque::with_capacity(8),
|
||||
br_buffer: VecDeque::with_capacity(8),
|
||||
edge_vitals: None,
|
||||
latest_wasm_events: None,
|
||||
discovered_models: Vec::new(),
|
||||
active_model_id: None,
|
||||
recordings: Vec::new(),
|
||||
recording_active: false,
|
||||
recording_start_time: None,
|
||||
recording_current_id: None,
|
||||
recording_stop_tx: None,
|
||||
training_state: training_api::TrainingState::default(),
|
||||
training_progress_tx: broadcast::channel::<String>(256).0,
|
||||
adaptive_model: None,
|
||||
node_states: HashMap::new(),
|
||||
pose_tracker: PoseTracker::new(),
|
||||
last_tracker_instant: None,
|
||||
multistatic_fuser: MultistaticFuser::new(),
|
||||
engine_bridge: engine_bridge::EngineBridge::new(
|
||||
wifi_densepose_bfld::PrivacyMode::PrivateHome,
|
||||
1,
|
||||
"default",
|
||||
"Default Room",
|
||||
None,
|
||||
),
|
||||
field_model: None,
|
||||
p95_variance: RollingP95::new(600, 60),
|
||||
p95_motion_band_power: RollingP95::new(600, 60),
|
||||
p95_spectral_power: RollingP95::new(600, 60),
|
||||
dedup_factor: 3.0,
|
||||
data_dir: std::path::PathBuf::from("data"),
|
||||
field_surface: Arc::new(RwLock::new(rufield_surface::FieldSurface::from_env())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ESP32 Edge Vitals Packet (ADR-039, magic 0xC511_0002) ────────────────────
|
||||
|
||||
/// Decoded vitals packet from ESP32 edge processing pipeline.
|
||||
@@ -4973,54 +5062,12 @@ fn scan_recording_files() -> Vec<serde_json::Value> {
|
||||
}
|
||||
|
||||
// ── Training Endpoints ──────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/v1/train/status — get training status.
|
||||
async fn train_status(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
Json(serde_json::json!({
|
||||
"status": s.training_status,
|
||||
"config": s.training_config,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/train/start — start a training run.
|
||||
async fn train_start(
|
||||
State(state): State<SharedState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let mut s = state.write().await;
|
||||
if s.training_status == "running" {
|
||||
return Json(serde_json::json!({
|
||||
"error": "training already running",
|
||||
"success": false,
|
||||
}));
|
||||
}
|
||||
s.training_status = "running".to_string();
|
||||
s.training_config = Some(body.clone());
|
||||
info!("Training started with config: {}", body);
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"status": "running",
|
||||
"message": "Training pipeline started. Use GET /api/v1/train/status to monitor.",
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/train/stop — stop the current training run.
|
||||
async fn train_stop(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let mut s = state.write().await;
|
||||
if s.training_status != "running" {
|
||||
return Json(serde_json::json!({
|
||||
"error": "no training in progress",
|
||||
"success": false,
|
||||
}));
|
||||
}
|
||||
s.training_status = "idle".to_string();
|
||||
info!("Training stopped");
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"status": "idle",
|
||||
}))
|
||||
}
|
||||
//
|
||||
// ADR-186 (TRAIN-RECONNECT): the former stub handlers here flipped a status
|
||||
// string and logged one line without ever starting a job (issue #1233). They
|
||||
// are replaced by the real `training_api` router, merged into the app below,
|
||||
// which runs the pure-Rust trainer on a background task and streams live
|
||||
// progress over `/ws/train/progress`.
|
||||
|
||||
// ── Adaptive classifier endpoints ────────────────────────────────────────────
|
||||
|
||||
@@ -7673,6 +7720,10 @@ async fn main() {
|
||||
// ADR-044 §5.3: load persisted runtime config from the data directory.
|
||||
let data_dir = std::path::PathBuf::from("data");
|
||||
let runtime_config = load_runtime_config(&data_dir);
|
||||
// ADR-271: resolve (or generate + persist) the browser-session signing key
|
||||
// before any request can arrive. Zero-config for a single appliance; the
|
||||
// env var still wins for a multi-instance deployment that must share one.
|
||||
wifi_densepose_sensing_server::browser_session::init_secret(&data_dir);
|
||||
info!(
|
||||
"Loaded runtime config: dedup_factor={:.2}",
|
||||
runtime_config.dedup_factor
|
||||
@@ -7822,9 +7873,9 @@ async fn main() {
|
||||
recording_start_time: None,
|
||||
recording_current_id: None,
|
||||
recording_stop_tx: None,
|
||||
// Training
|
||||
training_status: "idle".to_string(),
|
||||
training_config: None,
|
||||
// Training (ADR-186 TRAIN-RECONNECT)
|
||||
training_state: training_api::TrainingState::default(),
|
||||
training_progress_tx: broadcast::channel::<String>(256).0,
|
||||
adaptive_model:
|
||||
adaptive_classifier::AdaptiveModel::load(&adaptive_classifier::model_path())
|
||||
.ok()
|
||||
@@ -7920,9 +7971,33 @@ async fn main() {
|
||||
// #443: optional bearer-token auth on `/api/v1/*`. `RUVIEW_API_TOKEN`
|
||||
// unset/empty ⇒ middleware is a no-op (LAN-mode default preserved); set ⇒
|
||||
// every `/api/v1/*` request must carry `Authorization: Bearer <token>`.
|
||||
let bearer_auth_state = wifi_densepose_sensing_server::bearer_auth::AuthState::from_env();
|
||||
//
|
||||
// ADR-271: additionally, `RUVIEW_OAUTH_ISSUER` enables Cognitum OAuth
|
||||
// verification alongside (not instead of) the static token.
|
||||
//
|
||||
// FAIL CLOSED. If OAuth was requested but cannot work — empty issuer, or a
|
||||
// JWKS we cannot fetch at boot — we exit rather than serve. Starting anyway
|
||||
// would silently downgrade an operator who asked for OAuth to either an
|
||||
// open API or a single-shared-secret one, and they would have no signal
|
||||
// that it happened. A loud death at boot is the kind thing here.
|
||||
let bearer_auth_state =
|
||||
match wifi_densepose_sensing_server::bearer_auth::AuthState::from_env() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"API auth: OAuth was requested but cannot be initialised: {e}. \
|
||||
Refusing to start — unset RUVIEW_OAUTH_ISSUER to run without it."
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
if bearer_auth_state.is_enabled() {
|
||||
info!("API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)");
|
||||
if bearer_auth_state.oauth_enabled() {
|
||||
info!("API auth: ON for /api/v1/* — Cognitum OAuth (ADR-271){}",
|
||||
if bearer_auth_state.static_token_enabled() { " + static RUVIEW_API_TOKEN" } else { "" });
|
||||
} else {
|
||||
info!("API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)");
|
||||
}
|
||||
if bind_ip.is_unspecified() {
|
||||
warn!(
|
||||
"API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments",
|
||||
@@ -7931,7 +8006,7 @@ async fn main() {
|
||||
}
|
||||
} else {
|
||||
info!(
|
||||
"API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN=<token> to enforce bearer auth."
|
||||
"API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN=<token> or RUVIEW_OAUTH_ISSUER=<issuer> to enforce auth."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7969,6 +8044,18 @@ async fn main() {
|
||||
// so a client on :8765 can stream signed RuField FieldEvents alongside
|
||||
// `/ws/sensing`. Merged with its own FieldState (different state type).
|
||||
.merge(rufield_surface::router(field_surface.clone()))
|
||||
// ADR-272 FIX: this router had NO auth layer at all. `/ws/sensing` and
|
||||
// `/ws/field` on the dedicated WS port accepted unauthenticated
|
||||
// upgrades even with auth ON — and this is the port the UI actually
|
||||
// uses (ui/services/sensing.service.js maps HTTP 8080 -> WS 8765), so
|
||||
// gating only the HTTP port protected a path the browser never takes.
|
||||
// Applied AFTER the merge so it covers the RuField routes too.
|
||||
// AuthState shares its TicketStore via Arc, so a ticket minted at
|
||||
// POST /api/v1/ws-ticket on the HTTP port is redeemable here.
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
bearer_auth_state.clone(),
|
||||
wifi_densepose_sensing_server::bearer_auth::require_bearer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
host_allowlist.clone(),
|
||||
wifi_densepose_sensing_server::host_validation::require_allowed_host,
|
||||
@@ -8043,6 +8130,18 @@ async fn main() {
|
||||
)
|
||||
// Stream endpoints
|
||||
.route("/api/v1/stream/status", get(stream_status))
|
||||
// ADR-272 — browsers cannot set Authorization on a WebSocket upgrade,
|
||||
// so they exchange their credential here for a 30s single-use ticket.
|
||||
.route("/api/v1/ws-ticket", axum::routing::post(ws_ticket_handler))
|
||||
// ADR-271 browser sign-in. Deliberately NOT under /api/v1/*: these are
|
||||
// how a browser obtains a credential, so gating them would deadlock.
|
||||
.route("/oauth/start", get(oauth_start))
|
||||
.route("/oauth/callback", get(oauth_callback))
|
||||
.route("/oauth/logout", get(oauth_logout))
|
||||
// Ungated on purpose: a signed-OUT browser needs to discover whether
|
||||
// sign-in is available, and it cannot ask a gated endpoint that.
|
||||
// Returns only capability + who-you-are, never a credential.
|
||||
.route("/oauth/status", get(oauth_status))
|
||||
.route("/api/v1/stream/pose", get(ws_pose_handler))
|
||||
// Sensing WebSocket on the HTTP port so the UI can reach it without a second port
|
||||
.route("/ws/sensing", get(ws_sensing_handler))
|
||||
@@ -8065,10 +8164,12 @@ async fn main() {
|
||||
.route("/api/v1/recording/start", post(start_recording))
|
||||
.route("/api/v1/recording/stop", post(stop_recording))
|
||||
.route("/api/v1/recording/{id}", delete(delete_recording))
|
||||
// Training endpoints
|
||||
.route("/api/v1/train/status", get(train_status))
|
||||
.route("/api/v1/train/start", post(train_start))
|
||||
.route("/api/v1/train/stop", post(train_stop))
|
||||
// Training endpoints (ADR-186 TRAIN-RECONNECT): the real in-server
|
||||
// trainer + `/ws/train/progress` stream. Merged while the router is
|
||||
// still `Router<SharedState>` (before `.with_state`) so these routes
|
||||
// share `AppStateInner` and `/api/v1/train/*` sits under the bearer gate
|
||||
// applied below (like the rest of `/api/v1/*`).
|
||||
.merge(training_api::routes())
|
||||
// Adaptive classifier endpoints
|
||||
.route("/api/v1/adaptive/train", post(adaptive_train))
|
||||
.route("/api/v1/adaptive/status", get(adaptive_status))
|
||||
@@ -8096,19 +8197,27 @@ async fn main() {
|
||||
// is unset/empty the middleware is a no-op — the default stays
|
||||
// LAN-mode-friendly. `/health*`, `/ws/sensing`, and `/ui/*` are never
|
||||
// gated (orchestrator probes + local browsers).
|
||||
// ADR-272: the ws-ticket handler needs the store the middleware owns.
|
||||
.layer(axum::Extension(bearer_auth_state.clone()))
|
||||
.with_state(state.clone())
|
||||
// ADR-262 P3: additive RuField surface (`/api/field` + `/ws/field`).
|
||||
// Merged AFTER `.with_state` (so http_app is already `Router<()>` and
|
||||
// can absorb the field router's own `FieldState`).
|
||||
.merge(rufield_surface::router(field_surface.clone()))
|
||||
// Opt-in bearer auth (#443) + ADR-272 WebSocket gating.
|
||||
//
|
||||
// Applied AFTER the merge, and that ordering is load-bearing: axum
|
||||
// `.layer()` wraps only what is already registered, so while this sat
|
||||
// above the merge, `/ws/field` bypassed authentication entirely —
|
||||
// measured 101 on an unauthenticated upgrade with auth ON. Adding
|
||||
// routes after an auth layer silently exempts them, which is exactly
|
||||
// the failure mode ADR-272 exists to prevent.
|
||||
//
|
||||
// Unset RUVIEW_API_TOKEN/RUVIEW_OAUTH_ISSUER still makes this a no-op.
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
bearer_auth_state.clone(),
|
||||
wifi_densepose_sensing_server::bearer_auth::require_bearer,
|
||||
))
|
||||
.with_state(state.clone())
|
||||
// ADR-262 P3: additive RuField surface (`/api/field` + `/ws/field`).
|
||||
// Merged AFTER `.with_state` (so http_app is already `Router<()>` and
|
||||
// can absorb the field router's own `FieldState`). These routes sit
|
||||
// OUTSIDE `/api/v1/*` so they are not bearer-gated, but the
|
||||
// host-validation layer below still applies (it is added last, so it
|
||||
// runs first, over the whole merged router). The surface's own §10
|
||||
// egress gate is what keeps above-policy classes off the wire.
|
||||
.merge(rufield_surface::router(field_surface.clone()))
|
||||
// DNS-rebinding defense: applied last so it runs first on the request
|
||||
// path (axum layers run outermost-in). Rejects requests whose `Host`
|
||||
// header is not in the allowlist before any handler — including
|
||||
@@ -9063,3 +9172,502 @@ mod observatory_persons_field_position_tests {
|
||||
assert!((p.motion_score - 55.0).abs() < 1e-6, "motion_score stays real");
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/ws-ticket` — mint a single-use WebSocket ticket (ADR-272).
|
||||
///
|
||||
/// Reached only through the auth middleware, so an unauthenticated caller
|
||||
/// cannot mint one. The ticket inherits the caller's scopes, so a
|
||||
/// `sensing:read` session cannot produce a ticket that outranks itself.
|
||||
///
|
||||
/// Exists because a browser's `WebSocket` constructor cannot set an
|
||||
/// `Authorization` header. Native clients do not need this — they send a bearer
|
||||
/// on the upgrade directly.
|
||||
async fn ws_ticket_handler(
|
||||
axum::Extension(auth): axum::Extension<wifi_densepose_sensing_server::bearer_auth::AuthState>,
|
||||
request: axum::extract::Request,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
use wifi_densepose_sensing_server::ws_ticket::TicketGrant;
|
||||
|
||||
// Present when the caller authenticated with OAuth; absent when they used
|
||||
// the legacy static token, which predates scopes and carries full authority.
|
||||
let principal = request.extensions().get::<ruview_auth::Principal>();
|
||||
let grant = TicketGrant {
|
||||
scopes: principal.map(|p| p.scopes().collect::<Vec<_>>().join(" ")),
|
||||
subject: principal.map(|p| p.subject.clone()),
|
||||
};
|
||||
|
||||
match auth.tickets().issue(grant) {
|
||||
Some(ticket) => (
|
||||
axum::http::StatusCode::OK,
|
||||
axum::Json(serde_json::json!({
|
||||
"ticket": ticket,
|
||||
"expires_in_secs": wifi_densepose_sensing_server::ws_ticket::TICKET_TTL.as_secs(),
|
||||
"usage": "append as ?ticket=<value> to the WebSocket URL; valid once",
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
// Refusing beats growing the store without bound.
|
||||
None => (
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"too many outstanding WebSocket tickets; retry shortly\n",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ADR-271 browser sign-in ------------------------------------------------
|
||||
//
|
||||
// Ported from cognitum-one/freetokens (`src/auth/oauth.ts`, live). The browser
|
||||
// never holds an OAuth token: this server does the exchange and issues its own
|
||||
// signed session cookie. Closes the gap where `wifi-densepose login` wrote a
|
||||
// file no browser could read.
|
||||
|
||||
fn request_is_tls(headers: &axum::http::HeaderMap) -> bool {
|
||||
// Behind a reverse proxy the TLS terminates upstream, so trust the standard
|
||||
// forwarding header when present. Conservative default: not TLS, which only
|
||||
// ever omits `Secure` — it never adds a cookie where it shouldn't be.
|
||||
headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|p| p.eq_ignore_ascii_case("https"))
|
||||
.unwrap_or(false)
|
||||
|| wifi_densepose_sensing_server::browser_session::public_base_url().starts_with("https://")
|
||||
}
|
||||
|
||||
async fn oauth_start(
|
||||
axum::Extension(auth): axum::Extension<wifi_densepose_sensing_server::bearer_auth::AuthState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
use wifi_densepose_sensing_server::browser_session as bs;
|
||||
|
||||
let Some(issuer) = auth.oauth_issuer() else {
|
||||
return (
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"OAuth is not enabled on this server (set RUVIEW_OAUTH_ISSUER)\n",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let secure = request_is_tls(&headers);
|
||||
// Least privilege: a browser session asks for read. Admin work goes through
|
||||
// the CLI, which requires an explicit --admin. See BROWSER_SIGNIN_SCOPE for
|
||||
// what widening this would cost.
|
||||
match bs::begin(&issuer, &auth.primary_client_id(), bs::BROWSER_SIGNIN_SCOPE, secure) {
|
||||
Ok((location, cookie)) => (
|
||||
axum::http::StatusCode::FOUND,
|
||||
[
|
||||
(axum::http::header::LOCATION, location),
|
||||
(axum::http::header::SET_COOKIE, cookie),
|
||||
],
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (axum::http::StatusCode::SERVICE_UNAVAILABLE, format!("{e}\n")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct OAuthCallbackQuery {
|
||||
code: Option<String>,
|
||||
state: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn oauth_callback(
|
||||
axum::Extension(auth): axum::Extension<wifi_densepose_sensing_server::bearer_auth::AuthState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
axum::extract::Query(q): axum::extract::Query<OAuthCallbackQuery>,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
use wifi_densepose_sensing_server::browser_session as bs;
|
||||
|
||||
let secure = request_is_tls(&headers);
|
||||
let bad = |code: axum::http::StatusCode, msg: String| {
|
||||
(code, [(axum::http::header::SET_COOKIE, bs::clear_transaction(secure))], msg)
|
||||
.into_response()
|
||||
};
|
||||
|
||||
if let Some(err) = q.error {
|
||||
return bad(axum::http::StatusCode::BAD_REQUEST, format!("Cognitum declined the sign-in: {err}\n"));
|
||||
}
|
||||
let (Some(code), Some(state)) = (q.code, q.state) else {
|
||||
return bad(axum::http::StatusCode::BAD_REQUEST, "Incomplete sign-in response\n".into());
|
||||
};
|
||||
let cookie_header = headers
|
||||
.get(axum::http::header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
// CSRF check BEFORE the single-use code is spent.
|
||||
let verifier = match bs::verifier_for_callback(&cookie_header, &state) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return bad(axum::http::StatusCode::BAD_REQUEST, format!("{e}\n")),
|
||||
};
|
||||
|
||||
let Some(issuer) = auth.oauth_issuer() else {
|
||||
return bad(axum::http::StatusCode::SERVICE_UNAVAILABLE, "OAuth is not enabled\n".into());
|
||||
};
|
||||
let client_id = auth.primary_client_id();
|
||||
|
||||
// `ureq` is blocking; spawn_blocking so a slow token endpoint cannot park an
|
||||
// async worker (the same mistake this codebase had to fix in jwks.rs).
|
||||
let exchange = tokio::task::spawn_blocking(move || {
|
||||
ureq::post(&format!("{issuer}/oauth/token"))
|
||||
.send_form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", &code),
|
||||
("code_verifier", &verifier),
|
||||
("client_id", &client_id),
|
||||
("redirect_uri", &bs::redirect_uri()),
|
||||
])
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|r| r.into_string().map_err(|e| e.to_string()))
|
||||
})
|
||||
.await;
|
||||
|
||||
let body = match exchange {
|
||||
Ok(Ok(b)) => b,
|
||||
Ok(Err(e)) => return bad(axum::http::StatusCode::BAD_GATEWAY, format!("token exchange failed: {e}\n")),
|
||||
Err(e) => return bad(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("token exchange task failed: {e}\n")),
|
||||
};
|
||||
let access_token = match serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("access_token")?.as_str().map(str::to_owned))
|
||||
{
|
||||
Some(t) => t,
|
||||
None => return bad(axum::http::StatusCode::BAD_GATEWAY, "token endpoint returned no access_token\n".into()),
|
||||
};
|
||||
|
||||
// Verify with the SAME verifier that gates every other request — signature,
|
||||
// audience, typ, expiry, scope. A browser sign-in must not be a softer path.
|
||||
let principal = match auth.verify_for_browser(&access_token) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return bad(axum::http::StatusCode::UNAUTHORIZED, format!("{e}\n")),
|
||||
};
|
||||
|
||||
let session_cookie = match bs::issue(&principal, secure) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return bad(axum::http::StatusCode::SERVICE_UNAVAILABLE, format!("{e}\n")),
|
||||
};
|
||||
tracing::info!(sub = %principal.subject, "browser sign-in complete");
|
||||
|
||||
// Clear the spent transaction as well as issuing the session. A consumed
|
||||
// OAuth transaction has no further use, and leaving it to age out for ten
|
||||
// minutes means every subsequent request carries a dead cookie.
|
||||
(
|
||||
axum::http::StatusCode::FOUND,
|
||||
// AppendHeaders, NOT an array: the array form REPLACES same-name
|
||||
// headers, so a second Set-Cookie silently overwrites the first — which
|
||||
// would drop the session cookie and make sign-in a no-op.
|
||||
axum::response::AppendHeaders([
|
||||
(axum::http::header::LOCATION, format!("/ui/?signed_in={}", now_millis())),
|
||||
(axum::http::header::SET_COOKIE, session_cookie),
|
||||
(axum::http::header::SET_COOKIE, bs::clear_transaction(secure)),
|
||||
]),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn oauth_logout(headers: axum::http::HeaderMap) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
// Local only: forgets this browser's session. Revoking the Cognitum session
|
||||
// for every device is an account-level action at auth.cognitum.one.
|
||||
let secure = request_is_tls(&headers);
|
||||
use wifi_densepose_sensing_server::browser_session as bs;
|
||||
(
|
||||
axum::http::StatusCode::FOUND,
|
||||
axum::response::AppendHeaders([
|
||||
// Cache-busting query so the landing page is re-fetched rather than
|
||||
// restored from the back/forward cache with a stale panel.
|
||||
(axum::http::header::LOCATION, format!("/ui/?signed_out={}", now_millis())),
|
||||
(axum::http::header::SET_COOKIE, bs::clear_session(secure)),
|
||||
(axum::http::header::SET_COOKIE, bs::clear_transaction(secure)),
|
||||
]),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn now_millis() -> u128 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `GET /oauth/status` — what a signed-out browser needs to render the right UI.
|
||||
///
|
||||
/// Deliberately ungated and deliberately thin: capability flags and, if a live
|
||||
/// session exists, who it belongs to. No token, no scope escalation hints, no
|
||||
/// server configuration beyond "is sign-in possible here".
|
||||
async fn oauth_status(
|
||||
axum::Extension(auth): axum::Extension<wifi_densepose_sensing_server::bearer_auth::AuthState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> axum::Json<serde_json::Value> {
|
||||
use wifi_densepose_sensing_server::browser_session as bs;
|
||||
let raw = headers
|
||||
.get(axum::http::header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok());
|
||||
let session = raw.and_then(bs::from_cookie_header);
|
||||
axum::Json(serde_json::json!({
|
||||
"auth_required": auth.is_enabled(),
|
||||
"oauth_enabled": auth.oauth_enabled(),
|
||||
"browser_signin": auth.oauth_enabled() && bs::is_configured(),
|
||||
"signed_in": session.is_some(),
|
||||
"account": session.as_ref().map(|s| s.account_id.clone()),
|
||||
"scope": session.as_ref().map(|s| s.scope.clone()),
|
||||
}))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod adr186_http_tests {
|
||||
//! ADR-186 P6: HTTP-level tests that build the real `training_api` router
|
||||
//! and drive it in-process, guarding against the module being orphaned again
|
||||
//! (`training_api::routes()` cannot compile unless the module is declared).
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// Serializes tests that read/toggle the process-global
|
||||
/// `RUVIEW_DISABLE_SERVER_TRAINING` env var, so the disabled-path test cannot
|
||||
/// flip enablement while an enabled-path test is mid-request.
|
||||
static TRAIN_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn test_state() -> SharedState {
|
||||
Arc::new(RwLock::new(AppStateInner::minimal()))
|
||||
}
|
||||
|
||||
/// The `/ws/train/progress` route is registered and reaches the WebSocket
|
||||
/// handler (issue #1233 was a 404). Over `oneshot` there is no real socket to
|
||||
/// upgrade, so axum returns 426 Upgrade Required — which still distinguishes a
|
||||
/// wired WS endpoint (426) from an orphaned/absent route (404). The genuine
|
||||
/// 101 handshake is asserted by `ws_train_progress_live_101_and_frame`.
|
||||
#[tokio::test]
|
||||
async fn ws_train_progress_route_is_wired_not_404() {
|
||||
let app = training_api::routes().with_state(test_state());
|
||||
let req = Request::builder()
|
||||
.uri("/ws/train/progress")
|
||||
.header("connection", "upgrade")
|
||||
.header("upgrade", "websocket")
|
||||
.header("sec-websocket-version", "13")
|
||||
.header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_ne!(resp.status(), StatusCode::NOT_FOUND, "route must not 404");
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UPGRADE_REQUIRED,
|
||||
"a wired WS route returns 426 under oneshot — got {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
/// ADR-186 §7 acceptance: over a real socket, `/ws/train/progress` completes a
|
||||
/// genuine 101 WebSocket handshake and, after a `POST /api/v1/train/start`,
|
||||
/// delivers at least one real `progress` frame to the connected client.
|
||||
#[tokio::test]
|
||||
async fn ws_train_progress_live_101_and_frame() {
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_tungstenite::tungstenite::Message as TMsg;
|
||||
|
||||
let _env_lock = TRAIN_ENV_LOCK.lock().unwrap(); // enablement must stay ON
|
||||
let shared = test_state();
|
||||
{
|
||||
let mut s = shared.write().await;
|
||||
for i in 0..40 {
|
||||
let sub: Vec<f64> = (0..56)
|
||||
.map(|k| 10.0 + ((i as f64) * 0.3 + (k as f64) * 0.1).sin() * 2.0)
|
||||
.collect();
|
||||
s.frame_history.push_back(sub);
|
||||
}
|
||||
}
|
||||
|
||||
// Serve the training router on an ephemeral port.
|
||||
let app = training_api::routes().with_state(shared.clone());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
|
||||
// A successful `connect_async` IS the 101 handshake (it errors otherwise).
|
||||
let (mut ws, resp) =
|
||||
tokio_tungstenite::connect_async(format!("ws://{addr}/ws/train/progress"))
|
||||
.await
|
||||
.expect("WebSocket handshake should succeed (101)");
|
||||
assert_eq!(resp.status().as_u16(), 101, "handshake must be 101");
|
||||
|
||||
// Drive training via a real HTTP POST over a fresh TCP connection.
|
||||
let body = r#"{"dataset_ids":[],"config":{"epochs":3,"batch_size":8,"warmup_epochs":1,"early_stopping_patience":10}}"#;
|
||||
let req = format!(
|
||||
"POST /api/v1/train/start HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let mut post = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
post.write_all(req.as_bytes()).await.unwrap();
|
||||
post.flush().await.unwrap();
|
||||
|
||||
// Read WS frames until a `progress` frame arrives (or a 10s ceiling).
|
||||
let mut got_progress = false;
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(2), ws.next()).await {
|
||||
Ok(Some(Ok(TMsg::Text(txt)))) => {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) {
|
||||
if v.get("type").and_then(|t| t.as_str()) == Some("progress") {
|
||||
got_progress = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(Ok(_))) => {}
|
||||
Ok(Some(Err(_))) | Ok(None) => break,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
got_progress,
|
||||
"should receive a real progress frame over the live WS after POST start"
|
||||
);
|
||||
// NOTE: deliberately no directory-diff cleanup here. `data/models` is
|
||||
// gitignored, and deleting by dir-diff would race concurrent model-writing
|
||||
// tests (it could remove a `.rvf` another test is asserting exists).
|
||||
}
|
||||
|
||||
/// Full HTTP round-trip: POST /api/v1/train/start → poll /api/v1/train/status
|
||||
/// until completion → a real `.rvf` model artifact exists on disk, and real
|
||||
/// progress frames were streamed on the broadcast channel.
|
||||
#[tokio::test]
|
||||
async fn http_train_start_produces_model_and_streams() {
|
||||
let _env_lock = TRAIN_ENV_LOCK.lock().unwrap(); // enablement must stay ON
|
||||
let shared = test_state();
|
||||
// Seed synthetic frames so training's fallback path has data (no files).
|
||||
{
|
||||
let mut s = shared.write().await;
|
||||
for i in 0..40 {
|
||||
let sub: Vec<f64> = (0..56)
|
||||
.map(|k| 10.0 + ((i as f64) * 0.3 + (k as f64) * 0.1).sin() * 2.0)
|
||||
.collect();
|
||||
s.frame_history.push_back(sub);
|
||||
}
|
||||
}
|
||||
let mut progress_rx = {
|
||||
let s = shared.read().await;
|
||||
s.training_progress_tx.subscribe()
|
||||
};
|
||||
|
||||
let models_dir = std::path::PathBuf::from(training_api::MODELS_DIR);
|
||||
let before: std::collections::HashSet<std::path::PathBuf> = std::fs::read_dir(&models_dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
|
||||
let app = training_api::routes().with_state(shared.clone());
|
||||
|
||||
// POST start.
|
||||
let body = serde_json::json!({
|
||||
"dataset_ids": [],
|
||||
"config": {"epochs": 3, "batch_size": 8, "warmup_epochs": 1, "early_stopping_patience": 10}
|
||||
});
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/train/start")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "start should be accepted");
|
||||
|
||||
// Poll status until the job reports completion.
|
||||
let mut completed = false;
|
||||
for _ in 0..250 {
|
||||
let req = Request::builder()
|
||||
.uri("/api/v1/train/status")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 65536).await.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
// Status also carries the P5 enablement flag.
|
||||
assert_eq!(v.get("enabled"), Some(&serde_json::Value::Bool(true)));
|
||||
if v.get("active") == Some(&serde_json::Value::Bool(false))
|
||||
&& v.get("phase").and_then(|p| p.as_str()) == Some("completed")
|
||||
{
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(completed, "training should reach the completed phase");
|
||||
|
||||
// Real progress frames were streamed.
|
||||
let mut saw_progress = false;
|
||||
while progress_rx.try_recv().is_ok() {
|
||||
saw_progress = true;
|
||||
}
|
||||
assert!(saw_progress, "expected streamed progress frames over the WS channel");
|
||||
|
||||
// A new .rvf artifact was written by the run.
|
||||
let after: std::collections::HashSet<std::path::PathBuf> = std::fs::read_dir(&models_dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
let new_models: Vec<_> = after
|
||||
.difference(&before)
|
||||
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("rvf"))
|
||||
.cloned()
|
||||
.collect();
|
||||
assert!(
|
||||
!new_models.is_empty(),
|
||||
"training should write a new .rvf model artifact under {}",
|
||||
models_dir.display()
|
||||
);
|
||||
// No deletion here: removing by dir-diff would race concurrent
|
||||
// model-writing tests. `data/models` is gitignored.
|
||||
}
|
||||
|
||||
/// P5 fallback guarantee: with server training disabled, POST start returns a
|
||||
/// structured `{enabled:false, cli:...}` 409 — never a silent success.
|
||||
#[tokio::test]
|
||||
async fn http_train_start_disabled_returns_structured_409() {
|
||||
// Serialize against the enabled-path tests so our env toggle can't race
|
||||
// their in-flight requests.
|
||||
let _env_lock = TRAIN_ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var("RUVIEW_DISABLE_SERVER_TRAINING", "1");
|
||||
|
||||
let app = training_api::routes().with_state(test_state());
|
||||
let body = serde_json::json!({"dataset_ids": [], "config": {"epochs": 1}});
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/train/start")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 65536).await.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
|
||||
std::env::remove_var("RUVIEW_DISABLE_SERVER_TRAINING");
|
||||
|
||||
assert_eq!(status, StatusCode::CONFLICT, "disabled start must be 4xx/409");
|
||||
assert_eq!(v.get("enabled"), Some(&serde_json::Value::Bool(false)));
|
||||
assert_eq!(
|
||||
v.get("cli").and_then(|c| c.as_str()),
|
||||
Some("wifi-densepose train-room"),
|
||||
"must point at the CLI fallback, never a silent success"
|
||||
);
|
||||
assert_ne!(
|
||||
v.get("success"),
|
||||
Some(&serde_json::Value::Bool(true)),
|
||||
"must never claim success:true when disabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,22 +26,23 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::{IntoResponse, Json},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::recording::{RecordedFrame, RECORDINGS_DIR};
|
||||
use crate::rvf_container::RvfBuilder;
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
@@ -49,6 +50,28 @@ use crate::rvf_container::RvfBuilder;
|
||||
/// Directory for trained model output.
|
||||
pub const MODELS_DIR: &str = "data/models";
|
||||
|
||||
/// Directory the training loop reads recorded CSI datasets from. Each
|
||||
/// `dataset_id` maps to `{RECORDINGS_DIR}/{dataset_id}.csi.jsonl`.
|
||||
pub const RECORDINGS_DIR: &str = "data/recordings";
|
||||
|
||||
/// Monotonic per-process counter appended to exported model filenames so two
|
||||
/// runs that complete in the same wall-clock microsecond still get distinct
|
||||
/// paths (prevents silent overwrite; keeps concurrent runs from colliding).
|
||||
static MODEL_ID_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Build a process-unique model id `trained-{type}-{ts_micros}-{seq}`. A
|
||||
/// second-resolution timestamp alone collided for runs finishing in the same
|
||||
/// second (silent overwrite); microseconds + the monotonic counter guarantee
|
||||
/// uniqueness even for same-microsecond concurrent completions.
|
||||
fn next_model_id(training_type: &str) -> String {
|
||||
format!(
|
||||
"trained-{}-{}-{}",
|
||||
training_type,
|
||||
chrono::Utc::now().format("%Y%m%d_%H%M%S_%6f"),
|
||||
MODEL_ID_SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
|
||||
/// Number of COCO keypoints.
|
||||
const N_KEYPOINTS: usize = 17;
|
||||
/// Dimensions per keypoint in the target vector (x, y, z).
|
||||
@@ -67,6 +90,25 @@ const N_GLOBAL_FEATURES: usize = 3;
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A single recorded CSI frame line, as stored in the `.csi.jsonl` datasets the
|
||||
/// training loop consumes.
|
||||
///
|
||||
/// This mirrors the on-disk JSONL schema and is intentionally self-contained so
|
||||
/// the trainer does not couple to the (separate, orphaned) `recording.rs`
|
||||
/// module. Only the fields the feature extractor needs are read; `rssi` /
|
||||
/// `noise_floor` / `features` are carried for schema fidelity.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecordedFrame {
|
||||
pub timestamp: f64,
|
||||
pub subcarriers: Vec<f64>,
|
||||
#[serde(default)]
|
||||
pub rssi: f64,
|
||||
#[serde(default)]
|
||||
pub noise_floor: f64,
|
||||
#[serde(default)]
|
||||
pub features: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Training configuration submitted with a start request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrainingConfig {
|
||||
@@ -229,24 +271,45 @@ pub struct TrainingProgress {
|
||||
}
|
||||
|
||||
/// Runtime training state stored in `AppStateInner`.
|
||||
///
|
||||
/// `status` and `cancel` are shared handles (not owned snapshots) so the
|
||||
/// background training job can update progress and observe stop requests
|
||||
/// **without holding a reference to the full `AppStateInner`**. That decoupling
|
||||
/// is what makes the training core ([`run_training_job`]) unit-testable in
|
||||
/// isolation from the ~60-field server state.
|
||||
pub struct TrainingState {
|
||||
/// Current status snapshot.
|
||||
pub status: TrainingStatus,
|
||||
/// Handle to the background training task (for cancellation).
|
||||
/// Live status snapshot, shared with the running training job.
|
||||
pub status: Arc<Mutex<TrainingStatus>>,
|
||||
/// Cooperative stop flag; `stop_training` sets it and the job loop observes it.
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
/// Handle to the background training task.
|
||||
pub task_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Default for TrainingState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: TrainingStatus::default(),
|
||||
status: Arc::new(Mutex::new(TrainingStatus::default())),
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
task_handle: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrainingState {
|
||||
/// Clone of the current status snapshot.
|
||||
pub fn snapshot(&self) -> TrainingStatus {
|
||||
self.status.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Whether a training job is currently active.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.status.lock().unwrap().active
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared application state type.
|
||||
pub type AppState = Arc<RwLock<super::AppStateInner>>;
|
||||
pub type AppState = Arc<tokio::sync::RwLock<super::AppStateInner>>;
|
||||
|
||||
/// Feature normalization statistics computed from the training set.
|
||||
/// Stored alongside the model weights inside the .rvf container so that
|
||||
@@ -317,11 +380,11 @@ async fn load_recording_frames(dataset_ids: &[String]) -> Vec<RecordedFrame> {
|
||||
all_frames
|
||||
}
|
||||
|
||||
/// Attempt to collect frames from the live frame_history buffer in AppState.
|
||||
/// Each `Vec<f64>` in frame_history is a subcarrier amplitude vector.
|
||||
async fn load_frames_from_history(state: &AppState) -> Vec<RecordedFrame> {
|
||||
let s = state.read().await;
|
||||
let history: &VecDeque<Vec<f64>> = &s.frame_history;
|
||||
/// Build fallback training frames from a snapshot of the live `frame_history`
|
||||
/// buffer. Each `Vec<f64>` is one frame's subcarrier amplitude vector. Passed as
|
||||
/// an owned snapshot (not a live `AppState` borrow) so the training core stays
|
||||
/// state-free and independently testable.
|
||||
fn frames_from_history(history: &[Vec<f64>]) -> Vec<RecordedFrame> {
|
||||
history
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -938,13 +1001,15 @@ fn deterministic_shuffle(n: usize, seed: u64) -> Vec<usize> {
|
||||
/// linear model via mini-batch gradient descent.
|
||||
///
|
||||
/// On completion, exports a `.rvf` container with real calibrated weights.
|
||||
async fn real_training_loop(
|
||||
state: AppState,
|
||||
async fn run_training_job(
|
||||
status: Arc<Mutex<TrainingStatus>>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
progress_tx: broadcast::Sender<String>,
|
||||
config: TrainingConfig,
|
||||
dataset_ids: Vec<String>,
|
||||
history_snapshot: Vec<Vec<f64>>,
|
||||
training_type: &str,
|
||||
) {
|
||||
) -> Option<PathBuf> {
|
||||
let total_epochs = config.epochs;
|
||||
let patience = config.early_stopping_patience;
|
||||
let mut best_pck = 0.0f64;
|
||||
@@ -978,7 +1043,7 @@ async fn real_training_loop(
|
||||
let mut frames = load_recording_frames(&dataset_ids).await;
|
||||
if frames.is_empty() {
|
||||
info!("No recordings found for dataset_ids; falling back to live frame_history");
|
||||
frames = load_frames_from_history(&state).await;
|
||||
frames = frames_from_history(&history_snapshot);
|
||||
}
|
||||
|
||||
if frames.len() < 10 {
|
||||
@@ -999,11 +1064,12 @@ async fn real_training_loop(
|
||||
if let Ok(json) = serde_json::to_string(&fail) {
|
||||
let _ = progress_tx.send(json);
|
||||
}
|
||||
let mut s = state.write().await;
|
||||
s.training_state.status.active = false;
|
||||
s.training_state.status.phase = "failed".to_string();
|
||||
s.training_state.task_handle = None;
|
||||
return;
|
||||
{
|
||||
let mut st = status.lock().unwrap();
|
||||
st.active = false;
|
||||
st.phase = "failed".to_string();
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
info!("Loaded {} frames for training", frames.len());
|
||||
@@ -1079,13 +1145,10 @@ async fn real_training_loop(
|
||||
// ── Phase 5: Training loop ───────────────────────────────────────────────
|
||||
|
||||
for epoch in 1..=total_epochs {
|
||||
// Check cancellation.
|
||||
{
|
||||
let s = state.read().await;
|
||||
if !s.training_state.status.active {
|
||||
info!("Training cancelled at epoch {epoch}");
|
||||
break;
|
||||
}
|
||||
// Check cancellation (cooperative stop flag set by `stop_training`).
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
info!("Training cancelled at epoch {epoch}");
|
||||
break;
|
||||
}
|
||||
|
||||
let phase = if epoch <= config.warmup_epochs {
|
||||
@@ -1245,10 +1308,10 @@ async fn real_training_loop(
|
||||
let remaining = total_epochs.saturating_sub(epoch);
|
||||
let eta_secs = (remaining as f64 * secs_per_epoch) as u64;
|
||||
|
||||
// Update shared state.
|
||||
// Update the shared status snapshot (read by GET /api/v1/train/status).
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.status = TrainingStatus {
|
||||
let mut st = status.lock().unwrap();
|
||||
*st = TrainingStatus {
|
||||
active: true,
|
||||
epoch,
|
||||
total_epochs,
|
||||
@@ -1297,15 +1360,12 @@ async fn real_training_loop(
|
||||
|
||||
// ── Phase 6: Export .rvf model ───────────────────────────────────────────
|
||||
|
||||
let completed_phase;
|
||||
{
|
||||
let s = state.read().await;
|
||||
completed_phase = if s.training_state.status.active {
|
||||
"completed"
|
||||
} else {
|
||||
"cancelled"
|
||||
};
|
||||
}
|
||||
let completed_phase = if cancel.load(Ordering::Relaxed) {
|
||||
"cancelled"
|
||||
} else {
|
||||
"completed"
|
||||
};
|
||||
let mut written_rvf: Option<PathBuf> = None;
|
||||
|
||||
// Emit completion message.
|
||||
let completion = TrainingProgress {
|
||||
@@ -1326,11 +1386,7 @@ async fn real_training_loop(
|
||||
if let Err(e) = tokio::fs::create_dir_all(MODELS_DIR).await {
|
||||
error!("Failed to create models directory: {e}");
|
||||
} else {
|
||||
let model_id = format!(
|
||||
"trained-{}-{}",
|
||||
training_type,
|
||||
chrono::Utc::now().format("%Y%m%d_%H%M%S")
|
||||
);
|
||||
let model_id = next_model_id(training_type);
|
||||
let rvf_path = PathBuf::from(MODELS_DIR).join(format!("{model_id}.rvf"));
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
@@ -1407,28 +1463,32 @@ async fn real_training_loop(
|
||||
}),
|
||||
);
|
||||
|
||||
if let Err(e) = builder.write_to_file(&rvf_path) {
|
||||
error!("Failed to write trained model RVF: {e}");
|
||||
} else {
|
||||
info!(
|
||||
"Trained model saved: {} ({} params, pck_torso_h@0.2={:.4})",
|
||||
rvf_path.display(),
|
||||
total_params,
|
||||
best_pck
|
||||
);
|
||||
match builder.write_to_file(&rvf_path) {
|
||||
Err(e) => {
|
||||
error!("Failed to write trained model RVF: {e}");
|
||||
}
|
||||
Ok(()) => {
|
||||
info!(
|
||||
"Trained model saved: {} ({} params, pck_torso_h@0.2={:.4})",
|
||||
rvf_path.display(),
|
||||
total_params,
|
||||
best_pck
|
||||
);
|
||||
written_rvf = Some(rvf_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark training as inactive.
|
||||
// Mark training as inactive in the shared status snapshot.
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.status.active = false;
|
||||
s.training_state.status.phase = completed_phase.to_string();
|
||||
s.training_state.task_handle = None;
|
||||
let mut st = status.lock().unwrap();
|
||||
st.active = false;
|
||||
st.phase = completed_phase.to_string();
|
||||
}
|
||||
|
||||
info!("Real {training_type} training finished: phase={completed_phase}");
|
||||
written_rvf
|
||||
}
|
||||
|
||||
// ── Public inference function ────────────────────────────────────────────────
|
||||
@@ -1559,56 +1619,151 @@ fn default_keypoints() -> Vec<[f64; 4]> {
|
||||
vec![[320.0, 240.0, 0.0, 0.0]; N_KEYPOINTS]
|
||||
}
|
||||
|
||||
// ── Server-training enablement gate (ADR-186 P5) ─────────────────────────────
|
||||
|
||||
/// Env var that opts a deployment out of in-server training (e.g. the
|
||||
/// lightweight appliance image without recordings). When set truthy, the start
|
||||
/// endpoints return a structured `enabled:false` response pointing at the CLI —
|
||||
/// never a silent `success:true` no-op.
|
||||
const DISABLE_ENV: &str = "RUVIEW_DISABLE_SERVER_TRAINING";
|
||||
|
||||
/// Whether in-server training is enabled for this deployment.
|
||||
fn server_training_enabled() -> bool {
|
||||
training_enabled_from_env(std::env::var(DISABLE_ENV).ok().as_deref())
|
||||
}
|
||||
|
||||
/// Pure decision (unit-testable without touching process env): enabled unless
|
||||
/// the flag is a truthy disable value.
|
||||
fn training_enabled_from_env(flag: Option<&str>) -> bool {
|
||||
match flag {
|
||||
Some(v) => {
|
||||
let v = v.trim();
|
||||
!(v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes"))
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured, honest "server training is off for this build — use the CLI"
|
||||
/// response (HTTP 409). Guarantees no silent no-op in the disabled config.
|
||||
fn disabled_response() -> Response {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"enabled": false,
|
||||
"reason": "In-server training is disabled for this deployment.",
|
||||
"cli": "wifi-densepose train-room",
|
||||
// `detail` is surfaced verbatim by the dashboard's API client.
|
||||
"detail": "In-server training is disabled on this build. Train from the CLI: wifi-densepose train-room",
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ── Axum handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
async fn start_training(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<StartTrainingRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// Check if training is already active.
|
||||
{
|
||||
let s = state.read().await;
|
||||
if s.training_state.status.active {
|
||||
return Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": "Training is already active. Stop it first.",
|
||||
"current_epoch": s.training_state.status.epoch,
|
||||
"total_epochs": s.training_state.status.total_epochs,
|
||||
}));
|
||||
}
|
||||
) -> Response {
|
||||
if !server_training_enabled() {
|
||||
return disabled_response();
|
||||
}
|
||||
|
||||
let config = body.config.clone();
|
||||
let dataset_ids = body.dataset_ids.clone();
|
||||
match spawn_training_job(&state, config, body.dataset_ids.clone(), "supervised").await {
|
||||
Ok(()) => Json(serde_json::json!({
|
||||
"status": "started",
|
||||
"type": "supervised",
|
||||
"dataset_ids": body.dataset_ids,
|
||||
"config": body.config,
|
||||
}))
|
||||
.into_response(),
|
||||
Err(active) => Json(active_error(&active)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// Mark training as active and spawn background task.
|
||||
let progress_tx;
|
||||
{
|
||||
/// Snapshot of the already-running job returned when a start is rejected.
|
||||
fn active_error(snap: &TrainingStatus) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"status": "error",
|
||||
"message": "Training is already active. Stop it first.",
|
||||
"current_epoch": snap.epoch,
|
||||
"total_epochs": snap.total_epochs,
|
||||
})
|
||||
}
|
||||
|
||||
/// Seed the shared status, snapshot `frame_history`, and spawn the background
|
||||
/// training job. Returns `Err(current_status)` if a job is already active.
|
||||
///
|
||||
/// Centralises the single-job guard + spawn used by the supervised, pretrain,
|
||||
/// and LoRA start handlers so they cannot diverge.
|
||||
/// Atomically claim the single training slot.
|
||||
///
|
||||
/// Checks `active` and sets it `true` **in one `status` lock scope**, so two
|
||||
/// concurrent callers cannot both observe the slot free — the first claims it,
|
||||
/// the second gets `Err(current_status)`. Returns the seeded status on success.
|
||||
///
|
||||
/// This is the fix for a TOCTOU race: the previous code checked `is_active()`
|
||||
/// under a `state` READ lock, released it, and only afterward set `active`.
|
||||
/// A `tokio::RwLock` read lock is shared, so two starts could both hold it, both
|
||||
/// see the slot inactive, both proceed — spawning two jobs that then share and
|
||||
/// overwrite one status/cancel and orphan a task handle. The claim's atomicity
|
||||
/// lives on the `status` mutex, not the coarse `state` lock, which also keeps it
|
||||
/// unit-testable without a full `AppState`.
|
||||
fn claim_training_slot(
|
||||
status: &Mutex<TrainingStatus>,
|
||||
config: &TrainingConfig,
|
||||
) -> Result<(), TrainingStatus> {
|
||||
let mut st = status.lock().unwrap();
|
||||
if st.active {
|
||||
return Err(st.clone());
|
||||
}
|
||||
*st = TrainingStatus {
|
||||
active: true,
|
||||
total_epochs: config.epochs,
|
||||
lr: config.learning_rate,
|
||||
patience_remaining: config.early_stopping_patience,
|
||||
phase: "initializing".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn spawn_training_job(
|
||||
state: &AppState,
|
||||
config: TrainingConfig,
|
||||
dataset_ids: Vec<String>,
|
||||
training_type: &'static str,
|
||||
) -> Result<(), TrainingStatus> {
|
||||
// Grab the shared handles under a read lock; the RwLock is only guarding
|
||||
// access to the Arcs, not the single-job decision.
|
||||
let (progress_tx, status, cancel, history_snapshot) = {
|
||||
let s = state.read().await;
|
||||
progress_tx = s.training_progress_tx.clone();
|
||||
}
|
||||
(
|
||||
s.training_progress_tx.clone(),
|
||||
s.training_state.status.clone(),
|
||||
s.training_state.cancel.clone(),
|
||||
s.frame_history.iter().cloned().collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.status = TrainingStatus {
|
||||
active: true,
|
||||
epoch: 0,
|
||||
total_epochs: config.epochs,
|
||||
train_loss: 0.0,
|
||||
val_pck: 0.0,
|
||||
val_oks: 0.0,
|
||||
lr: config.learning_rate,
|
||||
best_pck: 0.0,
|
||||
best_epoch: 0,
|
||||
patience_remaining: config.early_stopping_patience,
|
||||
eta_secs: None,
|
||||
phase: "initializing".to_string(),
|
||||
};
|
||||
}
|
||||
// Atomic check-and-set on the status mutex. This — not the read lock above —
|
||||
// is what serialises concurrent starts (see `claim_training_slot`).
|
||||
claim_training_slot(&status, &config)?;
|
||||
cancel.store(false, Ordering::Relaxed);
|
||||
|
||||
let state_clone = state.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
real_training_loop(state_clone, progress_tx, config, dataset_ids, "supervised").await;
|
||||
run_training_job(
|
||||
status,
|
||||
cancel,
|
||||
progress_tx,
|
||||
config,
|
||||
dataset_ids,
|
||||
history_snapshot,
|
||||
training_type,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
{
|
||||
@@ -1616,57 +1771,58 @@ async fn start_training(
|
||||
s.training_state.task_handle = Some(handle);
|
||||
}
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "started",
|
||||
"type": "supervised",
|
||||
"dataset_ids": body.dataset_ids,
|
||||
"config": body.config,
|
||||
}))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop_training(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let mut s = state.write().await;
|
||||
if !s.training_state.status.active {
|
||||
let s = state.read().await;
|
||||
if !s.training_state.is_active() {
|
||||
return Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": "No training is currently active.",
|
||||
}));
|
||||
}
|
||||
|
||||
s.training_state.status.active = false;
|
||||
s.training_state.status.phase = "stopping".to_string();
|
||||
|
||||
// The background task checks the active flag and will exit.
|
||||
// We do not abort the handle -- we let it finish the current batch gracefully.
|
||||
// Set the cooperative stop flag; the background job observes it between
|
||||
// epochs and exits gracefully after the current batch. We do not abort the
|
||||
// task handle.
|
||||
s.training_state.cancel.store(true, Ordering::Relaxed);
|
||||
{
|
||||
let mut st = s.training_state.status.lock().unwrap();
|
||||
st.phase = "stopping".to_string();
|
||||
}
|
||||
let snap = s.training_state.snapshot();
|
||||
|
||||
info!("Training stop requested");
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "stopping",
|
||||
"epoch": s.training_state.status.epoch,
|
||||
"best_pck": s.training_state.status.best_pck,
|
||||
"epoch": snap.epoch,
|
||||
"best_pck": snap.best_pck,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn training_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
Json(serde_json::to_value(&s.training_state.status).unwrap_or_default())
|
||||
let mut value = serde_json::to_value(s.training_state.snapshot()).unwrap_or_default();
|
||||
// Surface the enablement flag so the dashboard can honestly disable the
|
||||
// Start button (with a CLI tooltip) without first firing a POST (ADR-186 P5).
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert(
|
||||
"enabled".to_string(),
|
||||
serde_json::Value::Bool(server_training_enabled()),
|
||||
);
|
||||
}
|
||||
Json(value)
|
||||
}
|
||||
|
||||
async fn start_pretrain(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<PretrainRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
{
|
||||
let s = state.read().await;
|
||||
if s.training_state.status.active {
|
||||
return Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": "Training is already active. Stop it first.",
|
||||
}));
|
||||
}
|
||||
) -> Response {
|
||||
if !server_training_enabled() {
|
||||
return disabled_response();
|
||||
}
|
||||
|
||||
let config = TrainingConfig {
|
||||
epochs: body.epochs,
|
||||
learning_rate: body.lr,
|
||||
@@ -1675,56 +1831,26 @@ async fn start_pretrain(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let progress_tx;
|
||||
{
|
||||
let s = state.read().await;
|
||||
progress_tx = s.training_progress_tx.clone();
|
||||
match spawn_training_job(&state, config, body.dataset_ids.clone(), "pretrain").await {
|
||||
Ok(()) => Json(serde_json::json!({
|
||||
"status": "started",
|
||||
"type": "pretrain",
|
||||
"epochs": body.epochs,
|
||||
"lr": body.lr,
|
||||
"dataset_ids": body.dataset_ids,
|
||||
}))
|
||||
.into_response(),
|
||||
Err(active) => Json(active_error(&active)).into_response(),
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.status = TrainingStatus {
|
||||
active: true,
|
||||
total_epochs: body.epochs,
|
||||
phase: "initializing".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let state_clone = state.clone();
|
||||
let dataset_ids = body.dataset_ids.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
real_training_loop(state_clone, progress_tx, config, dataset_ids, "pretrain").await;
|
||||
});
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.task_handle = Some(handle);
|
||||
}
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "started",
|
||||
"type": "pretrain",
|
||||
"epochs": body.epochs,
|
||||
"lr": body.lr,
|
||||
"dataset_ids": body.dataset_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn start_lora_training(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<LoraTrainRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
{
|
||||
let s = state.read().await;
|
||||
if s.training_state.status.active {
|
||||
return Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": "Training is already active. Stop it first.",
|
||||
}));
|
||||
}
|
||||
) -> Response {
|
||||
if !server_training_enabled() {
|
||||
return disabled_response();
|
||||
}
|
||||
|
||||
let config = TrainingConfig {
|
||||
epochs: body.epochs,
|
||||
learning_rate: 0.0005, // lower LR for LoRA
|
||||
@@ -1735,42 +1861,19 @@ async fn start_lora_training(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let progress_tx;
|
||||
{
|
||||
let s = state.read().await;
|
||||
progress_tx = s.training_progress_tx.clone();
|
||||
match spawn_training_job(&state, config, body.dataset_ids.clone(), "lora").await {
|
||||
Ok(()) => Json(serde_json::json!({
|
||||
"status": "started",
|
||||
"type": "lora",
|
||||
"base_model_id": body.base_model_id,
|
||||
"profile_name": body.profile_name,
|
||||
"rank": body.rank,
|
||||
"epochs": body.epochs,
|
||||
"dataset_ids": body.dataset_ids,
|
||||
}))
|
||||
.into_response(),
|
||||
Err(active) => Json(active_error(&active)).into_response(),
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.status = TrainingStatus {
|
||||
active: true,
|
||||
total_epochs: body.epochs,
|
||||
phase: "initializing".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let state_clone = state.clone();
|
||||
let dataset_ids = body.dataset_ids.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
real_training_loop(state_clone, progress_tx, config, dataset_ids, "lora").await;
|
||||
});
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.task_handle = Some(handle);
|
||||
}
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "started",
|
||||
"type": "lora",
|
||||
"base_model_id": body.base_model_id,
|
||||
"profile_name": body.profile_name,
|
||||
"rank": body.rank,
|
||||
"epochs": body.epochs,
|
||||
"dataset_ids": body.dataset_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── WebSocket handler for training progress ──────────────────────────────────
|
||||
@@ -1792,8 +1895,11 @@ async fn handle_train_ws_client(mut socket: WebSocket, state: AppState) {
|
||||
|
||||
// Send current status immediately.
|
||||
{
|
||||
let s = state.read().await;
|
||||
if let Ok(json) = serde_json::to_string(&s.training_state.status) {
|
||||
let snapshot = {
|
||||
let s = state.read().await;
|
||||
s.training_state.snapshot()
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&snapshot) {
|
||||
let msg = serde_json::json!({
|
||||
"type": "status",
|
||||
"data": serde_json::from_str::<serde_json::Value>(&json).unwrap_or_default(),
|
||||
@@ -1869,6 +1975,60 @@ mod tests {
|
||||
assert_eq!(status.phase, "idle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_training_slot_admits_exactly_one_concurrent_start() {
|
||||
// Regression test for the single-job TOCTOU race. Many threads race to
|
||||
// claim one slot at the same instant (a barrier maximises contention);
|
||||
// the status mutex must admit EXACTLY ONE. A split check-then-set (the
|
||||
// old shape) would let several through under load — verified by
|
||||
// temporarily reverting the atomicity, which drops this from 1.
|
||||
use std::sync::atomic::{AtomicUsize, Ordering as O};
|
||||
use std::sync::{Arc, Barrier};
|
||||
|
||||
let status = Arc::new(Mutex::new(TrainingStatus::default()));
|
||||
let config = TrainingConfig::default();
|
||||
let winners = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
const N: usize = 32;
|
||||
let barrier = Arc::new(Barrier::new(N));
|
||||
let mut handles = Vec::with_capacity(N);
|
||||
for _ in 0..N {
|
||||
let status = status.clone();
|
||||
let config = config.clone();
|
||||
let winners = winners.clone();
|
||||
let barrier = barrier.clone();
|
||||
handles.push(std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
if claim_training_slot(&status, &config).is_ok() {
|
||||
winners.fetch_add(1, O::SeqCst);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
winners.load(O::SeqCst),
|
||||
1,
|
||||
"exactly one concurrent start may claim the single training slot"
|
||||
);
|
||||
assert!(
|
||||
status.lock().unwrap().active,
|
||||
"the slot must be marked active after a successful claim"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_training_slot_rejects_when_already_active() {
|
||||
let status = Arc::new(Mutex::new(TrainingStatus::default()));
|
||||
let config = TrainingConfig::default();
|
||||
assert!(claim_training_slot(&status, &config).is_ok(), "first claim wins");
|
||||
let err = claim_training_slot(&status, &config)
|
||||
.expect_err("second claim must be refused while active");
|
||||
assert!(err.active, "the rejection carries the active status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn training_progress_serializes() {
|
||||
let progress = TrainingProgress {
|
||||
@@ -2132,4 +2292,169 @@ mod tests {
|
||||
assert_eq!(parsed.n_features, 2);
|
||||
assert_eq!(parsed.mean, vec![1.0, 2.0]);
|
||||
}
|
||||
|
||||
/// Build a small deterministic set of synthetic CSI frames with enough
|
||||
/// variation that feature extraction is non-degenerate.
|
||||
fn synthetic_history(n: usize, n_sub: usize) -> Vec<Vec<f64>> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
(0..n_sub)
|
||||
.map(|k| 10.0 + ((i as f64) * 0.3 + (k as f64) * 0.1).sin() * 2.0)
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// ADR-186 P3/P6 end-to-end: the real (state-free) training core must
|
||||
/// (a) stream real progress events over the broadcast channel and
|
||||
/// (b) actually write a `.rvf` model artifact on completion — not merely
|
||||
/// flip a status flag. This is the regression guard that keeps the trainer
|
||||
/// wired (the module was previously orphaned / uncompiled — ADR-186 §1.3).
|
||||
#[tokio::test]
|
||||
async fn training_job_streams_real_progress_and_writes_model() {
|
||||
let history = synthetic_history(40, 56);
|
||||
|
||||
let (tx, mut rx) = broadcast::channel::<String>(1024);
|
||||
let status = Arc::new(Mutex::new(TrainingStatus::default()));
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let config = TrainingConfig {
|
||||
epochs: 3,
|
||||
batch_size: 8,
|
||||
warmup_epochs: 1,
|
||||
early_stopping_patience: 10,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Empty dataset_ids → falls back to the in-memory history snapshot, so
|
||||
// this test does not depend on the recordings directory.
|
||||
let rvf = run_training_job(
|
||||
status.clone(),
|
||||
cancel,
|
||||
tx,
|
||||
config,
|
||||
Vec::new(),
|
||||
history,
|
||||
"supervised",
|
||||
)
|
||||
.await;
|
||||
|
||||
// (b) A real model artifact was produced and exists on disk.
|
||||
let rvf_path = rvf.expect("training must produce an .rvf model artifact");
|
||||
assert!(
|
||||
rvf_path.exists(),
|
||||
"rvf artifact should exist at {}",
|
||||
rvf_path.display()
|
||||
);
|
||||
|
||||
// (a) Real progress frames were streamed, at least one carrying an epoch.
|
||||
let mut n_frames = 0usize;
|
||||
let mut saw_epoch = false;
|
||||
let mut saw_completed = false;
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
n_frames += 1;
|
||||
let v: serde_json::Value = serde_json::from_str(&msg).unwrap();
|
||||
if v.get("epoch").and_then(|e| e.as_u64()).unwrap_or(0) >= 1 {
|
||||
saw_epoch = true;
|
||||
}
|
||||
if v.get("phase").and_then(|p| p.as_str()) == Some("completed") {
|
||||
saw_completed = true;
|
||||
}
|
||||
}
|
||||
assert!(n_frames > 0, "expected streamed progress frames, got none");
|
||||
assert!(saw_epoch, "expected at least one epoch-tagged progress frame");
|
||||
assert!(saw_completed, "expected a terminal 'completed' progress frame");
|
||||
|
||||
// Final shared status reflects genuine completion, not just a flag flip:
|
||||
// real epochs ran (the loop wrote per-epoch status) and a finite loss was
|
||||
// computed from the real gradient-descent pass.
|
||||
let final_status = status.lock().unwrap().clone();
|
||||
assert!(!final_status.active, "job should be inactive when finished");
|
||||
assert_eq!(final_status.phase, "completed");
|
||||
assert!(
|
||||
final_status.epoch >= 1,
|
||||
"at least one real training epoch should have run"
|
||||
);
|
||||
assert!(
|
||||
final_status.train_loss.is_finite(),
|
||||
"a finite training loss should have been computed"
|
||||
);
|
||||
|
||||
// Keep the test hermetic — remove the artifact it wrote.
|
||||
let _ = std::fs::remove_file(&rvf_path);
|
||||
}
|
||||
|
||||
/// ADR-186 P4 (path safety): a `dataset_id` containing directory traversal
|
||||
/// is rejected before any file is opened, so the loader returns no frames
|
||||
/// rather than reading an arbitrary file.
|
||||
#[tokio::test]
|
||||
async fn load_recording_frames_rejects_path_traversal() {
|
||||
let frames = load_recording_frames(&["../../etc/passwd".to_string()]).await;
|
||||
assert!(
|
||||
frames.is_empty(),
|
||||
"path-traversal dataset_id must yield no frames"
|
||||
);
|
||||
}
|
||||
|
||||
/// Exported model ids must be unique per call — a second-resolution
|
||||
/// timestamp alone collided for runs finishing in the same wall-clock second
|
||||
/// (silently overwriting each other's `.rvf`, which also flaked the
|
||||
/// concurrent model-writing tests on CI). Guards against regressing the
|
||||
/// filename scheme back to non-unique.
|
||||
#[test]
|
||||
fn model_ids_are_unique_per_call() {
|
||||
let ids: Vec<String> = (0..1000).map(|_| next_model_id("supervised")).collect();
|
||||
let unique: std::collections::HashSet<&String> = ids.iter().collect();
|
||||
assert_eq!(unique.len(), ids.len(), "every model id must be distinct");
|
||||
assert!(ids[0].starts_with("trained-supervised-"));
|
||||
}
|
||||
|
||||
/// ADR-186 P5: the enablement gate is enabled by default and only disabled
|
||||
/// by an explicit truthy opt-out, so a `--no-default-features` / default
|
||||
/// build always has server training ON (no silent regression to disabled).
|
||||
#[test]
|
||||
fn training_enablement_gate() {
|
||||
assert!(training_enabled_from_env(None), "default is enabled");
|
||||
assert!(training_enabled_from_env(Some("0")), "0 keeps it enabled");
|
||||
assert!(training_enabled_from_env(Some("")), "empty keeps it enabled");
|
||||
assert!(!training_enabled_from_env(Some("1")), "1 disables");
|
||||
assert!(!training_enabled_from_env(Some("true")), "true disables");
|
||||
assert!(!training_enabled_from_env(Some("YES")), "case-insensitive");
|
||||
assert!(!training_enabled_from_env(Some(" 1 ")), "trims whitespace");
|
||||
}
|
||||
|
||||
/// A job that is cancelled before it starts still exits cleanly and reports
|
||||
/// the `cancelled` terminal phase (drives `stop_training`'s cooperative flag).
|
||||
#[tokio::test]
|
||||
async fn training_job_honors_cancellation() {
|
||||
let history = synthetic_history(40, 56);
|
||||
let (tx, _rx) = broadcast::channel::<String>(1024);
|
||||
let status = Arc::new(Mutex::new(TrainingStatus::default()));
|
||||
let cancel = Arc::new(AtomicBool::new(true)); // pre-cancelled
|
||||
|
||||
let config = TrainingConfig {
|
||||
epochs: 50,
|
||||
batch_size: 8,
|
||||
warmup_epochs: 1,
|
||||
early_stopping_patience: 10,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let rvf = run_training_job(
|
||||
status.clone(),
|
||||
cancel,
|
||||
tx,
|
||||
config,
|
||||
Vec::new(),
|
||||
history,
|
||||
"supervised",
|
||||
)
|
||||
.await;
|
||||
|
||||
// Cancelled before the first epoch → no model, terminal phase cancelled.
|
||||
assert!(rvf.is_none(), "cancelled run should not export a model");
|
||||
let final_status = status.lock().unwrap().clone();
|
||||
assert!(!final_status.active);
|
||||
assert_eq!(final_status.phase, "cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user