mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ce3bd090b | |||
| 42485495ed | |||
| 9aae7f04ff | |||
| a1a59baf72 | |||
| f783df234e | |||
| e1e10ad7be | |||
| 99700c7851 | |||
| 89babb00a9 | |||
| 544b746895 | |||
| 56327d0931 | |||
| 1ed0bc57ef | |||
| f7cc68bd5c | |||
| 89cceaf835 | |||
| 6ce50d5158 | |||
| c72bbc15dd | |||
| 9b9754778f | |||
| 43737941cb | |||
| 4a704acc02 | |||
| 9299a3b137 | |||
| 347698b67c | |||
| 705a167ffe | |||
| b09625ece7 | |||
| 0547fd7344 | |||
| f67a880a1a | |||
| 7a05417493 | |||
| 3347e258e6 | |||
| eb68e07a2c | |||
| 6300b1cbd2 | |||
| 7d6d66941a | |||
| d9dfea2dac | |||
| 6d3fb88677 | |||
| 12635a85b2 | |||
| 88bd88ed0f | |||
| 714dae9a2c | |||
| 31fb3d53f6 | |||
| c2bd33e649 | |||
| 92cbeb0c34 | |||
| 499cec7914 |
+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:
|
||||
|
||||
@@ -34,9 +34,8 @@
|
||||
# dedicated follow-up commit (drop `password:`, add the OIDC id-token
|
||||
# permission + `environment: pypi`) so there is no capability gap between.
|
||||
#
|
||||
# Q3 (witness hash v2 — open in ADR-117 §11.3) MUST be resolved
|
||||
# before the first v2.0.0 publish. When v2 lands, add a parallel
|
||||
# step that verifies the v2 hash against the Rust pipeline.
|
||||
# Production publishing fails closed until the ADR-117 §11.3 v2 witness
|
||||
# hash exists. TestPyPI remains usable to validate release artifacts.
|
||||
|
||||
name: pip-release
|
||||
|
||||
@@ -83,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
|
||||
@@ -152,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)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
@@ -236,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:
|
||||
@@ -264,7 +314,7 @@ jobs:
|
||||
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
|
||||
@@ -275,6 +325,7 @@ jobs:
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
verbose: true
|
||||
|
||||
publish-tombstone:
|
||||
name: Publish v1.99 tombstone
|
||||
@@ -299,7 +350,7 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -291,3 +291,8 @@ harness/**/ruvector.db
|
||||
# ruvector runtime/hook DB — never tracked (any depth)
|
||||
ruvector.db
|
||||
**/ruvector.db
|
||||
|
||||
# sensing-server runtime artifacts written by its test suite (trained model
|
||||
# snapshots + the generated session-secret) — never tracked
|
||||
v2/crates/wifi-densepose-sensing-server/data/
|
||||
*.proptest-regressions
|
||||
|
||||
+7
-2
@@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **`ruview-unified` increment 3 — Gaussian update-loop completion, separable delay-Doppler, and property-tested boundary hardening.** (1) `GaussianMap::merge_overlapping` (ADR-275 step 5: mutual-Mahalanobis + semantic-compatibility dedup catching drift the insert-time gate misses) and lifetime-aware decay (`τ_eff = τ·(1+ln(1+lifetime/τ))` — confirmed structures outlive transients at equal nominal τ). (2) `delay_doppler_map` reimplemented separably (`O(B²S+S²B)`), proven equivalent to the direct reference to <1e-10 and **measured 8.3× faster** (520 µs vs 4.34 ms at 56×8). (3) `tests/security_boundaries.rs` — 8 `proptest` properties over the boundary surfaces (arbitrary values incl. NaN/±inf via `f64::from_bits`) that found and fixed three input-controlled defects: a BLE-CS phase-unwrap infinite loop on non-finite phases and an ~1e299-iteration loop on finite-huge phases (now O(1) modular unwrap + plausibility bound), and a subnormal Gaussian scale overflowing `1/σ²` to NaN density (now physical σ/occupancy bounds). (4) New criterion benches for all increment-2 hot paths (`to_canonical` 38 µs, `ble_cs_range` 481 ns, AoI planner 647 ns/200 regions, coherent fusion 1.5 µs/32 members, factorized pose 521 ns). ruview-unified now 98 tests (87 lib + 3 acceptance + 8 security), 0 failed, clippy-clean.
|
||||
- **`ruview-unified` increment 2 — native frame contract + programmable perception (ADR-279..282).** (1) `RfFrameV2` becomes the authoritative RF record: native complex IQ with explicit validity masks, declared `PhaseState`, TX/RX poses + antenna geometry in one building frame, calibration/quality state, and a provenance rule enforced at construction — `Synthetic ⇒ L0Simulation` and `Measured ⇒ ≥ L1CapturedReplay` can never alias (the public L0–L5 evidence ladder is now a type); the 56-bin canonical tensor is demoted to a derived compatibility view (`to_canonical`, mask-aware gap-filling through the same normalization path as every adapter; native samples proven byte-untouched). (2) Active sensing control plane (`control.rs`): ETSI-ISAC-vocabulary `SensingTask` admission (raw export always refused; identity requires consent), `SensingAction`/`InformationGoal`, an age-of-information `ActiveSensingPlanner` (priority = uncertainty × change rate × criticality ÷ cost; **measured 95% sensing-traffic reduction** vs uniform refresh on a 20-region scenario), fail-closed `CoherentSensorGroup` fusion gates (time/phase/geometry bounds; five denial paths tested), policy-authorized RIS/movable-antenna actuation receipts, and purpose-scoped `TaskSufficientRepresentation` leakage validation. (3) New modality surfaces: BLE Channel Sounding adapter + `ble_cs_range` treating phase-slope and RTT as **separate cross-validated evidence** (exact distance recovery on synthetic tones; relay-style divergence flagged, never averaged), delay-Doppler-native `FieldAxis` + `delay_doppler_map` (unit-peak tone test), IEEE P3162 synthetic-aperture import profile. (4) RePos-factorized pose head (relative skeleton on the content representation, root on the geometry-conditioned one, calibrated per-joint uncertainties): held-out-room MPJPE 0.0003 m vs 0.2534 m for the monolithic baseline in the room-shortcut leakage experiment; ≤2% structured-adapter budget (740 params). (5) Age gate input now `log(1+age_ms)` per the age-aware-CSI recipe (gradient check re-proven); Gaussian primitives gained `first_seen_ns`/`doppler_variance`/bounded `source_receipts` lineage; `PartitionKey` gained a `session` dimension and `SplitManifest` certifies disjointness across all seven dimensions. 87 tests, 0 failed; crate clippy-clean. Docker images unaffected (no shipped binary consumes the crate yet); Python proof re-verified PASS.
|
||||
- **`ruview-unified` — unified RF spatial world model, P1 (ADR-273..278).** New v2 workspace leaf crate implementing the five-pillar architecture: (1) canonical `RfTensor` (`links × 56 bins × 8 snapshots`, complex, validated at the boundary) plus a fail-closed hardware adapter registry with reference adapters for 802.11 CSI (consumes `wifi-densepose-core::CsiFrame`), FMCW radar cubes (fast-time DFT), UWB CIR, and 5G SRS (comb de-interleave); (2) a universal RF foundation encoder — window-median + CFO-aligned tokenizer, masked-reconstruction pretraining with a hand-derived backward pass verified against central finite differences (174 params sampled, max rel err 1.31e-5), the ADR-273 fusion contract `z = Enc(CSI) ⊙ σ(AgeEnc) + GeomEnc(pose)`, and ≤1% task adapters (presence 129 / activity 268 / localization 387 / anomaly 2 vs a 40,856-param backbone); (3) an RF-aware Gaussian spatial memory — anisotropic primitives with per-band×angle reflectivity, confidence-weighted fusion, exponential decay, spatial-hash + semantic queries, closed-form (erf) Beer–Lambert channel-gain queries that degrade to exact Friis on an empty map, inverse gain updates that learn an unseen 6 dB obstruction to <0.5 dB in 20 link observations, and a JITOMA-style task-gated scene graph; (4) a physics-guided synthetic RF world generator — Allen–Berkley image method (order ≤2), complex-permittivity Fresnel materials, bistatic person scattering with *emergent* Doppler (proven against the analytic phase rate), seeded ChaCha20 domain randomization of physics + hardware nuisances (gain/CFO/phase noise/packet loss/interference); (5) an edge sensing control plane — 802.11bf/ETSI-ISAC-aligned purposes and zones, fail-closed authorization, a double-gated identity purpose, retention bounds, and a `BoundedEvent`-only trust boundary that makes raw RF export unrepresentable. Anti-leakage evaluation (`StrictSplit` by room/day/person/chipset/firmware/layout with an independent disjointness verifier, ECE, selective risk, degradation) plus an end-to-end acceptance pipeline: presence F1 1.00 on held-out rooms *and* held-out chipset, degradation 0.0, ECE 0.012, p95 tokenize+encode 2.0 ms debug / 105 µs release — **all SYNTHETIC** (honest labeling propagates from `RfModality::Synthetic` through `Provenance.synthetic`). Criterion benches with an optimization pass: segment-corridor candidate search took `channel_gain` from 139 µs → 27 µs (O(1) in map size; hash/linear crossover at ~4k Gaussians reported honestly), `observe_link` 305 µs → 74 µs, precomputed DFT twiddles 4.9×. 66 unit + 3 acceptance tests, 0 failed.
|
||||
|
||||
### Changed
|
||||
- **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha` → `5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. Pip-release now authenticates via Trusted Publishing (see the entry below). **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload (ADR-184 P3) is still gated on the one-time manual Trusted Publisher registration on pypi.org that only the repo owner can perform. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client).
|
||||
- **CI (ADR-184): `pip-release.yml` publish job migrated to PyPI OIDC Trusted Publishing** (commit `cc153e8b5`; refs #785, completes ADR-117). The release workflow now authenticates to PyPI via short-lived OIDC tokens (`id-token: write`) instead of a long-lived `PYPI_API_TOKEN` secret. **Not yet active**: publishing will fail until the matching Trusted Publisher is registered manually on pypi.org (a one-time, per-project step that cannot be automated from CI) — ADR-184 P1 tracks this as the remaining gate (status recorded in `dfc4c1abd`).
|
||||
- **`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
|
||||
|
||||
@@ -25,6 +25,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `vendor/rufield` (submodule) | **RuField MFS** — the open spec for camera-free multimodal field sensing (ADR-260). A common `FieldEvent`/`FieldTensor`/`FusionGraph`/`PrivacyClass`/`ProvenanceReceipt` model *above* WiFi CSI/CIR/BFLD, UWB, BLE Channel Sounding, mmWave radar, ultrasound, subsonic, infrared, and quantum sensors. Lives in its own repo ([github.com/ruvnet/rufield](https://github.com/ruvnet/rufield)), vendored here under `vendor/rufield`. Not a `v2/` workspace member. v0.1 reference stack = 7 crates (`rufield-core`/`-provenance`/`-privacy`/`-adapters`/`-fusion`/`-bench`/`-viewer`), 72 tests/0 failed; `rufield-viewer` is an Axum + vanilla-JS read-only dashboard (`cargo run -p rufield-viewer`) completing ADR-260 §27.9. The WiFi-CSI modality is now **real-replay-backed** via `CsiReplayAdapter` (ingests real captured `.csi.jsonl` → fused presence/breathing inferences; replay-from-file, unlabeled CSI-variance proxy, not validated accuracy); mmWave/thermal + all synthetic-bench F1 numbers remain **SYNTHETIC** (no live hardware — live streaming + labeled accuracy are roadmap). |
|
||||
| `wifi-densepose-rufield` | ADR-262 P1 **anti-corruption bridge** — converts RuView WiFi-CSI sensing output (`SensingSnapshot` mirroring `SensingUpdate` + `TrustedOutput`, owned primitives, no dep on `wifi-densepose-sensing-server`) into **signed RuField `FieldEvent`s** (`Modality::WifiCsi`, real `timestamp_ns`, sha256 + ed25519 provenance, `synthetic=false`). The single coupling point between RuView and the standalone RuField MFS spec (§5.4); path-deps the `vendor/rufield` submodule crates (`rufield-core`/`-provenance`/`-privacy`/`-fusion`). **Critical §3.3 privacy mapping** (`map_privacy`): maps RuView class → RuField P0–P5 by **information content, never byte value**, fail-closed (`Derived → P4/P5`, never P1; `demoted` floors to ≥ P2). 15 tests / 0 failed (round-trip / `is_fusable` / fusion-ingest / privacy-safety / determinism). P1 plumbing — not wired into the live server (P3), no accuracy claim. |
|
||||
| `ruview-swarm` | Drone swarm control system (ADR-148) — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4 compat, Ruflo AI-agent integration |
|
||||
| `ruview-unified` | ADR-273..282 **unified RF spatial world model**: authoritative native `RfFrameV2` frame contract (native IQ never overwritten, phase-state/evidence-ladder/provenance invariants) with the canonical `RfTensor` as a derived view; fail-closed hardware adapter registry (WiFi CSI / FMCW cube / UWB CIR / 5G SRS / BLE Channel Sounding with phase-vs-RTT cross-validated ranging); universal RF foundation encoder (masked-reconstruction pretraining with finite-difference-verified backprop, `z = Enc ⊙ σ(AgeEnc(log age)) + Geom` fusion, ≤1% scalar / <2% structured task adapters incl. RePos-factorized pose); RF-aware Gaussian spatial memory (fusion/decay/channel-gain queries + inverse updates, lineage receipts, task-gated scene graph); physics-guided synthetic RF world generator (image-method multipath, Fresnel materials, emergent Doppler, seeded domain randomization); edge sensing control plane (802.11bf/ETSI-ISAC purposes/zones/tasks, AoI active-sensing planner, fail-closed coherent-aperture fusion, governed RIS actuation; raw RF structurally unexportable); delay-Doppler-native transforms. Pure Rust leaf; all accuracy numbers SYNTHETIC (evidence level L0) until real-data validation. |
|
||||
|
||||
### RuvSense Modules (`signal/src/ruvsense/`)
|
||||
| Module | Purpose |
|
||||
@@ -62,7 +63,7 @@ All 5 ruvector crates integrated in workspace:
|
||||
- `ruvector-attention` → `model.rs` (apply_spatial_attention) + `bvp.rs`
|
||||
|
||||
### Architecture Decisions
|
||||
182 ADRs in `docs/adr/` (numbered ADR-001 through ADR-265, with gaps). Key ones:
|
||||
205 ADRs in `docs/adr/` (numbered ADR-001 through ADR-282, with gaps). Key ones:
|
||||
- ADR-014: SOTA signal processing (Accepted)
|
||||
- ADR-015: MM-Fi + Wi-Pose training datasets (Accepted)
|
||||
- ADR-016: RuVector training pipeline integration (Accepted — complete)
|
||||
@@ -81,6 +82,16 @@ All 5 ruvector crates integrated in workspace:
|
||||
- ADR-263: `@ruvnet/ruview` npm harness deep review + optimization strategy (Proposed)
|
||||
- ADR-264: `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` deep review + optimization strategy (Proposed)
|
||||
- ADR-265: RuView npm distribution strategy — CI gate, provenance, version single-sourcing (Proposed)
|
||||
- ADR-273: Unified RF spatial world model — umbrella + anti-leakage evaluation protocol + acceptance gates (Accepted — P1 implemented in `ruview-unified`)
|
||||
- ADR-274: Universal RF foundation encoder + hardware adapter registry (Accepted — P1 implemented)
|
||||
- ADR-275: RF-aware Gaussian spatial memory — fusion, decay, channel-gain queries, inverse updates, task-gated scene graph (Accepted — P1 implemented)
|
||||
- ADR-276: Physics-guided synthetic RF world generator — randomize physics, not textures (Accepted — P1 implemented)
|
||||
- ADR-277: Edge sensing control plane — purposes/zones/retention/identity double-gate; raw RF unexportable (Accepted — P1 implemented)
|
||||
- ADR-278: Radar inverse rendering + differentiable RF SLAM research program — RISE/DiffRadar/GeRaF reproduction gates (Proposed)
|
||||
- ADR-279: Native RF frame contract — `RfFrameV2` authoritative, canonical tensor demoted to derived view; 7 invariants; split manifest with session dimension (Accepted — implemented)
|
||||
- ADR-280: Active sensing & programmable perception — sensing tasks/actions, AoI freshness scheduler (95% traffic reduction measured), fail-closed coherent-aperture fusion, governed RIS actuation, task-sufficient representations (Accepted — implemented)
|
||||
- ADR-281: BLE Channel Sounding (phase vs RTT cross-validated ranging), delay-Doppler-native tensors, IEEE P3162 import profile, RePos factorized pose (Accepted — implemented)
|
||||
- ADR-282: Ecosystem positioning — RuView as edge RF perception runtime; RuField/RuVector/MetaHarness layering; mandatory L0–L5 evidence ladder (Accepted)
|
||||
|
||||
### Supported Hardware
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://cognitum.one/seed">
|
||||
<img src="assets/seed.png" alt="Cognitum Seed" width="100%">
|
||||
<a href="https://cognitum.one/marketplace/musica">
|
||||
<img src="assets/musica-promo.png" alt="Cognitum Musica" width="100%">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -638,11 +638,12 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
|
||||
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
|
||||
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
|
||||
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 182 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 205 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
|
||||
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
|
||||
| [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
|
||||
| `ruview-swarm` | Drone swarm control system (ADR-148) — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4/ArduPilot compatibility, Ruflo AI-agent integration |
|
||||
| `ruview-unified` | Unified RF spatial world model ([ADR-273](docs/adr/ADR-273-unified-rf-spatial-world-model.md)..[277](docs/adr/ADR-277-edge-sensing-control-plane.md)) — canonical RF tensor + hardware adapters (WiFi CSI / FMCW radar / UWB / 5G SRS), universal RF foundation encoder with ≤1% task adapters, RF-aware Gaussian spatial memory with channel-gain queries + inverse updates, physics-guided synthetic RF worlds, and an 802.11bf/ETSI-ISAC-aligned sensing policy plane (raw RF structurally unexportable). All accuracy numbers SYNTHETIC until real-data validation. |
|
||||
| [Medical Examples](examples/medical/README.md) | Contactless blood pressure, heart rate, breathing rate via 60 GHz mmWave radar — $15 hardware, no wearable |
|
||||
| [Extended Documentation](docs/readme-details.md) | Latest additions, key features, installation, quick start, signal processing, training, CLI, testing, deployment, and changelog |
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1,123 @@
|
||||
# ADR-273: Unified RF Spatial World Model — one shared representation, not another isolated RF classifier
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (new v2 workspace crate `ruview-unified`; 66 unit + 3 acceptance-pipeline tests, 0 failed; criterion benches) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | `v2/crates/ruview-unified/` (new leaf crate; single internal dep on `wifi-densepose-core` for `CsiFrame`) |
|
||||
| **Sub-ADRs** | ADR-274 (universal RF encoder + adapter registry), ADR-275 (RF-aware Gaussian spatial memory), ADR-276 (physics-guided synthetic RF worlds), ADR-277 (edge sensing control plane), ADR-278 (radar inverse rendering research program) |
|
||||
| **Relates to** | ADR-152 (WiFi-Pose SOTA intake: geometry conditioning), ADR-153 (802.11bf protocol model), ADR-260/262 (RuField MFS + bridge), ADR-135/136 (calibration + canonical frame provenance), ADR-024 (AETHER), ADR-027 (MERIDIAN domain generalization) |
|
||||
| **Scope** | Decide the target architecture for RuView + RuVector sensing through 2026-H2: one persistent, queryable spatial world model that vision, WiFi CSI, cellular CFR/SRS, radar, geometry, semantics, uncertainty, and time all update — and the priority order for building it. |
|
||||
|
||||
---
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Every number in this ADR family is one of:
|
||||
|
||||
- **MEASURED-SYNTHETIC** — produced by this repo's tests/benches on data from the ADR-276 physics generator. Reproducible: `cd v2 && cargo test -p ruview-unified` / `cargo bench -p ruview-unified`. **No claim of real-world accuracy is made or implied.**
|
||||
- **MEASURED-CODE** — a structural property of the implementation (parameter counts, gradient-check error, determinism), verified by a named test.
|
||||
- **EXTERNAL-UNVERIFIED** — a number reported by an external paper/preprint (WiFo-2, WiLHPE, RISE, DiffRadar, HybridSim, OAI SRS demo, …) that this repo has **not** reproduced. These motivated design choices; they are never presented as our results.
|
||||
|
||||
## 1. Context
|
||||
|
||||
Through mid-2026 the field moved decisively away from task-specific RF classifiers:
|
||||
|
||||
1. **RF foundation models** (WiFo-2 scaling across 11.6 B CSI points/12 tasks; WiLLM's dataset adapters + shared self-supervised transformer; age-aware CSI fusion) — the architectural signal: *standardize heterogeneous CSI, pretrain with masked reconstruction, attach small task adapters* (all EXTERNAL-UNVERIFIED).
|
||||
2. **Gaussian fields as spatial memory** (EmbodiedSplat online semantic 3-D Gaussian mapping; TGSFormer bounded temporal Gaussian memory; July's physics-informed channel-gain mapping with incremental Gaussian insertion) — the missing bridge between RuView sensing and a queryable digital twin.
|
||||
3. **Synthetic RF worlds** (WaveVerse phase-coherent ray tracing; HybridSim's 92 % vs 54 % synthetic-to-real gap when *physics parameters*, not textures, are randomized) — the fastest path out of data scarcity.
|
||||
4. **Standards became actionable**: IEEE 802.11bf-2025 published (2025-09), 802.11bk (320 MHz positioning), ETSI ISAC architecture (2026-02) + security report (19 privacy/security issue classes), 3GPP Rel-20 sensing studies, OAI SRS xApp localization demo.
|
||||
5. **Generalization lessons**: PerceptAlign (condition on TX/RX geometry), RePos (factor root-relative pose from absolute localization), JITOMA (task-gated scene memory).
|
||||
|
||||
RuView already has the ingredients (calibration ADR-151, canonical frames ADR-136, ruvsense multistatic stack, RuField bridge ADR-262) but they update **separate** state. The decision is to converge on **one shared representation with persistent scene memory**.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Build the unified model as five pillars in strict priority order (scored 35 % business value / 25 % readiness / 20 % defensibility / 20 % strategic learning):
|
||||
|
||||
| # | Pillar | Score | Sub-ADR | P1 status |
|
||||
|---|--------|-------|---------|-----------|
|
||||
| 1 | Universal RF foundation encoder + hardware adapter registry | 4.7 | ADR-274 | **implemented** |
|
||||
| 2 | RF-aware Gaussian spatial memory | 4.5 | ADR-275 | **implemented** |
|
||||
| 3 | Age/geometry/uncertainty-aware inference (folded into the encoder contract) | 4.4 | ADR-274 §3 | **implemented** |
|
||||
| 4 | Physics-guided synthetic RF world generator | 4.1 | ADR-276 | **implemented** |
|
||||
| 5 | Edge sensing control plane (802.11bf / ETSI ISAC aligned) | 3.9* | ADR-277 | **implemented** (policy engine; O-RAN xApp is roadmap) |
|
||||
| 6 | Radar inverse rendering + differentiable RF SLAM | 3.6 | ADR-278 | research program (not implemented) |
|
||||
|
||||
\* the 3.9-scored item is the O-RAN SRS xApp; its *policy plane* and its *SRS adapter seam* ship in P1 because they are cheap and gate everything else.
|
||||
|
||||
The representation contract every pillar shares:
|
||||
|
||||
```text
|
||||
z = Encoder(RF tokens) ⊙ σ(AgeEncoder(age)) + GeometryEncoder(sensor_pose)
|
||||
```
|
||||
|
||||
served from one canonical tensor (`RfTensor`, ADR-274 §2) and persisted into one scene memory (`GaussianMap` + task-gated `SceneGraph`, ADR-275).
|
||||
|
||||
## 3. Architecture (implemented, `v2/crates/ruview-unified/src/`)
|
||||
|
||||
```text
|
||||
vendor captures ──▶ adapters.rs (WiFi CSI / FMCW cube / UWB CIR / 5G SRS)
|
||||
│ normalize: layout → gain → phase (ADR-274 §2.3)
|
||||
▼
|
||||
tensor.rs RfTensor (links × 56 bins × 8 snapshots, complex)
|
||||
│
|
||||
tokenizer.rs amplitude/delay/Doppler/phase/age/geometry/
|
||||
│ clock/uncertainty tokens (CFO-aligned,
|
||||
│ median-scale-normalized)
|
||||
▼
|
||||
encoder.rs + pretrain.rs masked-reconstruction pretraining,
|
||||
│ exact hand-derived backprop (gradient-checked)
|
||||
▼
|
||||
┌── heads.rs ≤1 % task adapters (presence/activity/localization/anomaly)
|
||||
│
|
||||
├── gaussian/ RF-aware Gaussian memory: fusion, decay, channel-gain
|
||||
│ queries, inverse updates, task-gated scene graph
|
||||
│
|
||||
└── policy.rs purposes/zones/retention/identity gating; BoundedEvent
|
||||
is the only exportable type (raw RF unrepresentable)
|
||||
```
|
||||
|
||||
`synth/` (ADR-276) generates the labeled physics worlds that train and gate all of it; `eval.rs` implements the anti-leakage protocol below.
|
||||
|
||||
## 4. The non-negotiable evaluation protocol (anti-leakage)
|
||||
|
||||
The biggest failure mode in this field is **domain leakage disguised as accuracy**: random frame splits let a model recognize the room, session, person, device, or trajectory. Bigger models make it worse. Therefore:
|
||||
|
||||
- **No result counts unless the test set holds out complete** rooms, days, people, chipsets, firmware versions, and antenna layouts. `eval::StrictSplit` constructs such splits and `verify()` independently proves disjointness (`eval.rs`; test `verify_catches_a_manufactured_leak`).
|
||||
- Track **relative degradation** known→unknown (`relative_degradation`, gate < 20 %), **calibration** (`expected_calibration_error`), and **abstention quality** (`selective_metrics` — an uncertain result must become *no decision*, not a confident guess).
|
||||
- Every synthetic number is labeled SYNTHETIC in test output and in these ADRs.
|
||||
|
||||
## 5. Acceptance gates — P1 (synthetic analogue) results
|
||||
|
||||
The ADR's acceptance test (frozen shared encoder, adapters < 1 % of backbone, unseen rooms/chipsets/layouts) is implemented end-to-end in `tests/e2e_acceptance.rs`. **MEASURED-SYNTHETIC** results on the ADR-276 generator (8 rooms × 20 windows × 3 links, seed 273273):
|
||||
|
||||
| Gate (ADR target) | P1 synthetic result | Verdict |
|
||||
|---|---|---|
|
||||
| Presence F1 ≥ 0.90, unseen rooms | **1.0000** (rooms 6–7 held out of pretraining *and* head training) | pass |
|
||||
| Presence F1 ≥ 0.90, unseen chipset | **1.0000** (`chip-2` held out; per-room random gain/phase/CFO/noise) | pass |
|
||||
| Cross-environment degradation < 20 % | **0.0000** | pass |
|
||||
| Adapter budget < 1 % of backbone | presence 129 / activity 268 / localization 387 / anomaly 2 params vs 40,856-param backbone (< 408) | pass (MEASURED-CODE) |
|
||||
| Edge latency p95 < 50 ms | **2.0 ms** debug profile (tokenize+encode); 105 µs encode / 67 µs tokenize release (criterion) | pass |
|
||||
| Held-out ECE | **0.0122**; abstention risk monotone in threshold | pass |
|
||||
| Raw RF never crosses the trust boundary | structural: only `policy::BoundedEvent` exports (no tensor-carrying variant exists) | pass |
|
||||
| Every output carries uncertainty, provenance, model version, purpose | enforced at `BoundedEvent::new` (construction fails otherwise) | pass |
|
||||
|
||||
**Honest reading**: a synthetic world where presence ⇔ a moving scatterer is *separable by construction*; F1 = 1.0 here validates the **pipeline and the anti-leakage machinery**, not real-world performance. The real-data gate (5 unseen rooms, 2 unseen chipsets, 2 unseen layouts, measured CSI) is P2 and remains open.
|
||||
|
||||
## 6. Consequences
|
||||
|
||||
- RuView gains a single, tested substrate that all future sensing work (vision fusion, SRS xApp, radar) updates instead of forking.
|
||||
- The synthetic-first discipline means every accuracy claim is grade-labeled; publishing an unlabeled number is now a process violation.
|
||||
- The Gaussian memory becomes the integration point for RuVector (vector retrieval → graph constraints → geometric verification; the LLM plans the query, the renderer verifies the answer).
|
||||
- Cost: a new crate to maintain (~4.6 k lines incl. tests); mitigations: zero heavy deps, deterministic tests, files < 500 lines each.
|
||||
|
||||
## 7. Roadmap after P1
|
||||
|
||||
| Phase | Content | Gate |
|
||||
|-------|---------|------|
|
||||
| P2 | Replay real `.csi.jsonl` (rvCSI / ADR-262 corpus) through the WiFi adapter; calibrate the anomaly head on real empty-room captures | strict-split F1/ECE on measured data, reported with degradation vs synthetic |
|
||||
| P3 | Wire `GaussianMap` into `wifi-densepose-sensing-server` behind the ADR-277 boundary; RuVector embedding of Gaussian clusters | live map consistency + bounded-event-only egress audit |
|
||||
| P4 | OAI SRS xApp feeding `CellularSrsAdapter` (the adapter + registry seam already exists) | 0.5 m p90 localization under *non-random* splits |
|
||||
| P5 | ADR-278 radar inverse rendering reproduction (RISE first) |
|
||||
@@ -0,0 +1,95 @@
|
||||
# ADR-274: Universal RF foundation encoder + hardware adapter registry
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (`ruview-unified`: `tensor.rs`, `adapters.rs`, `tokenizer.rs`, `encoder.rs`, `pretrain.rs`, `heads.rs`, `eval.rs`) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-136 (`CanonicalFrame` provenance — the WiFi adapter consumes `wifi-densepose-core::CsiFrame` directly), ADR-152 §2 (geometry conditioning intake), ADR-016/017 (ruvector integration points) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades as in ADR-273 §0. Every number below is MEASURED-CODE or MEASURED-SYNTHETIC unless marked EXTERNAL-UNVERIFIED.
|
||||
|
||||
## 1. Context
|
||||
|
||||
WiFo-2 and WiLLM (EXTERNAL-UNVERIFIED) demonstrated that heterogeneous CSI standardization + masked-reconstruction pretraining + small task adapters beats per-task models, and the age-aware CSI line showed a cheap win from encoding sample freshness multiplicatively. RuView has four incompatible capture families today (802.11 CSI, FMCW radar cubes, UWB CIR, and — via O-RAN — 5G SRS). Each previously implied its own model.
|
||||
|
||||
## 2. Decision — canonical tensor + adapter registry
|
||||
|
||||
### 2.1 Canonical tensor
|
||||
|
||||
All modalities normalize to `RfTensor` (`tensor.rs`): complex `(links × 56 bins × 8 snapshots)` plus carrier/bandwidth, per-link `LinkGeometry`, `sample_age_s`, `clock_quality ∈ [0,1]`, `uncertainty ∈ [0,1]`, `device_id`, and a `CalibrationMeta` contract. 56 bins = usable 20 MHz 802.11n subcarriers (and the existing 114→56 interpolation in `wifi-densepose-train`), so the most common source resamples trivially.
|
||||
|
||||
**Boundary rule**: `RfTensor::new` is the only constructor and validates every field (finite samples, geometry/link arity, ranges). Downstream code assumes validity. Tests: `tensor.rs::tests` (4).
|
||||
|
||||
### 2.2 Normalization pipeline (every adapter, 3 stages)
|
||||
|
||||
1. **Layout** — vendor shape → `(links, bins, snapshots)`; FMCW gets a fast-time DFT to range bins; SRS gets comb de-interleaving; then linear complex resampling to canonical dims.
|
||||
2. **Amplitude** — per-link division by median amplitude (chipset gain invariance; offset recorded in `CalibrationMeta.gain_offset_db`).
|
||||
3. **Phase** — per (link, snapshot), remove constant offset + least-squares linear ramp across bins (CFO residual + sampling-time offset), with unwrapping. Skipped for delay-domain modalities (radar range profiles, UWB taps) where a detrend would erase ToF structure.
|
||||
|
||||
Measured (test `wifi_adapter_normalizes_shape_gain_and_phase`): a synthetic capture with per-link gains ×3.7/×7.4 and phase ramp `0.9 + 0.11·bin` comes out with median amplitude 1.0 ± 1e-9 and residual phase < 1e-4 rad (the ~7 µrad residue is second-order chord-vs-arc error from complex resampling). The radar adapter localizes a fast-time beat tone to the analytically expected canonical range bin (`radar_adapter_localizes_beat_tone_to_range_bin`).
|
||||
|
||||
### 2.3 Registry
|
||||
|
||||
`AdapterRegistry` maps hardware id → `dyn RfAdapter`, **fail-closed** (unknown hardware is an error; wrong modality is a typed `ModalityMismatch`). Reference adapters ship for `esp32s3-csi`, `mr60bha2` (FMCW), `dw3000` (UWB), `oai-srs-xapp` (5G SRS) — the last being the ADR-273 P4 seam.
|
||||
|
||||
## 3. Decision — encoder, fusion contract, adapters
|
||||
|
||||
### 3.1 Tokenizer
|
||||
|
||||
One token per (link, 8-bin subcarrier group); 24 features: log-amplitudes, delay-spectrum DFT (4), Doppler DFT bins 1–4 (log-compressed `ln(1+100·mag)`), temporal amplitude deviation (`ln(1+20·std)`), phase velocity, sample age, link distance/height/azimuth, clock quality, uncertainty (`tokenizer.rs`, layout table on `RfToken`).
|
||||
|
||||
Two hardware-invariance steps precede feature extraction, and both were *forced by measurement*, not aesthetics (see §5 evidence trail):
|
||||
|
||||
- **window-median amplitude normalization** — raw Friis-scale features (~1e-3) left every head unable to learn;
|
||||
- **CFO alignment** — per link, each snapshot is de-rotated by `arg Σ_b H[b,s]·H̄[b,0]`; carrier-frequency-offset drift is a *common* rotation and cancels, while a moving scatterer's frequency-selective perturbation survives (test `motion_raises_doppler_and_variance_features` uses a bin-dependent perturbation precisely so alignment cannot cancel it).
|
||||
|
||||
### 3.2 Encoder + pretraining
|
||||
|
||||
Pure-Rust, exactly differentiable (`encoder.rs`):
|
||||
|
||||
```text
|
||||
h_i = tanh(W1·x_i + b1) token embedding
|
||||
c = mean_i h_i permutation-invariant pool
|
||||
m = tanh(W2·c + b2); g = tanh(W2b·m + b2b)
|
||||
gate = σ(age_w·age + age_b) multiplicative freshness gate
|
||||
z = g ⊙ gate + Wg·geo + bg ← the ADR-273 fusion contract, verbatim
|
||||
```
|
||||
|
||||
Masked-reconstruction pretraining (`pretrain.rs`): mask 25 % of tokens, reconstruct each from `[z ; sinusoidal-position]` via a linear head discarded at deployment; SGD.
|
||||
|
||||
**Proof of the backward pass** (MEASURED-CODE, `gradients_match_finite_differences`): analytic gradients of **all 12 parameter groups** vs central finite differences — 174 sampled parameters, max relative error **1.31e-5**, with the absolute floor at central-difference roundoff (≈5e-11). Training halves masked loss and beats the constant-predictor variance baseline (`0.2757 → 0.0966` vs baseline `0.1550`; `pretraining_reduces_masked_loss_and_beats_mean_baseline`). Same seed ⇒ bit-identical weights (`training_is_deterministic`).
|
||||
|
||||
Backbone at deployment config (d_model 128): **40,856 parameters** (hand-count asserted in `param_count_matches_hand_computation`).
|
||||
|
||||
### 3.3 Two representation views (the PerceptAlign lesson, applied)
|
||||
|
||||
- `encode()` → full `z` (geometry-conditioned) — for localization/channel-prediction heads where sensor pose is signal.
|
||||
- `encode_content()` → `[g ⊙ gate ; mean token features]` — for environment-invariant heads (presence/activity/anomaly). The additive `Wg·geo` term is a **room-specific offset a linear adapter would memorize** — measured: with it, held-out-room presence F1 was 0.00 while training F1 fit; without it plus the pooled-statistics skip connection, held-out F1 is 1.00 (SYNTHETIC, ADR-273 §5).
|
||||
|
||||
### 3.4 Task adapters, ≤ 1 % budget
|
||||
|
||||
`heads.rs`: presence (logistic, 129 params), activity (rank-2 LoRA-style factorized softmax, 268), localization (linear ℝ³, 387), anomaly (2 calibration statistics on reconstruction error). All < 408 = 1 % of the 40,856-param backbone, asserted in `every_head_fits_the_one_percent_budget_at_deployment_config`. Convex heads train full-batch (deterministic); tests show they fit separable/multiclass toys to ≥ 95 %.
|
||||
|
||||
### 3.5 Anti-leakage evaluation (ADR-273 §4)
|
||||
|
||||
`eval.rs`: `PartitionKey` (room/day/person/chipset/firmware/layout), `StrictSplit::holdout` + independent `verify()`, ECE, coverage/selective-risk, degradation ratio, F1. Six unit tests including a manufactured-leak detection test.
|
||||
|
||||
## 4. Alternatives considered
|
||||
|
||||
- **Candle/ONNX backbone now** — rejected for P1: the deliverable is a *proven contract* (gradient-checked fusion formula, budget enforcement, leakage protocol); porting to `wifi-densepose-nn` backends is mechanical once real-data P2 justifies scale.
|
||||
- **Per-modality encoders with late fusion** — rejected: reproduces the isolated-classifier status quo ADR-273 exists to end.
|
||||
- **Full transformer attention** — deferred: mean-pool + 2 mixing layers passed every P1 gate; attention is a P2 measurement question, not a default.
|
||||
|
||||
## 5. Evidence trail (what the measurements changed)
|
||||
|
||||
P1 development falsified two comfortable assumptions, recorded here because the *fixes are the ADR*:
|
||||
|
||||
1. Raw-scale tokens: presence head stuck at F1 0.47 even on training rooms → window-median normalization + CFO alignment (train F1 → 0.76).
|
||||
2. Geometry-additive `z` for invariant tasks: held-out-room F1 0.00 → content view + pooled-statistic skip (held-out F1 → 1.00) — i.e. *the leak the eval protocol was designed to catch, caught in our own architecture first*.
|
||||
|
||||
## 6. Consequences
|
||||
|
||||
One encoder now serves presence, activity, localization, respiration-class, channel prediction, and anomaly through < 1 % adapters; new hardware lands as an adapter, not a model. Cost: the pure-Rust trainer is CPU-bound (fine at 40 k params; a P2 scale-up moves to `wifi-densepose-nn`).
|
||||
@@ -0,0 +1,79 @@
|
||||
# ADR-275: RF-aware Gaussian spatial memory — the persistent scene representation
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (`ruview-unified/src/gaussian/`: `primitive.rs`, `map.rs`, `gain.rs`, `graph.rs`; 16 unit tests, criterion benches) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-030 (persistent field model — superseded in direction by this), ADR-134 (CIR/ISTA), ADR-147 (OccWorld priors), ADR-261 (RuVector graph-ANN — the retrieval layer this memory will index into) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. The July 2026 external motivators (EmbodiedSplat ~5 fps online semantic Gaussian mapping, ~67× memory efficiency; TGSFormer bounded temporal Gaussian memory; physics-informed channel-gain mapping with incremental Gaussian insertion; JITOMA task-gated activation) are EXTERNAL-UNVERIFIED throughout.
|
||||
|
||||
## 1. Context
|
||||
|
||||
RuView's spatial state is currently scattered (pose tracker state, field-model eigenstructure, worldgraph tracks). Vision-side SOTA converged on Gaussian fields as the common continuous scene memory, and — the July signal that matters here — the representation crossed into RF: propagation geometry, opacity, attenuation, and scattering as Gaussian primitives, updated *incrementally* when the environment changes. That is exactly the bridge from RuView sensing to a queryable digital twin: one store that answers both geometric questions ("what is near the sofa") and RF questions ("which object caused the channel anomaly", "where did multipath change").
|
||||
|
||||
## 2. Decision — the primitive
|
||||
|
||||
`RfGaussian` (`primitive.rs`) carries all six ADR-273 attribute groups:
|
||||
|
||||
1. **Geometry**: position, per-axis scale (σ), unit-quaternion orientation → anisotropic metric `Σ⁻¹ = R·diag(1/σ²)·Rᵀ`.
|
||||
2. **Semantics**: 16-d embedding (RuVector-alignable).
|
||||
3. **RF response**: reflectivity `[4 bands × 4 incident-angle bins]` (2.4/5/6/60 GHz), plus `occupancy` = peak extinction coefficient (nepers/m) used by the gain model.
|
||||
4. **Motion**: signed Doppler m/s + `{Static, Slow, Fast}` class.
|
||||
5. **Trust/lifecycle**: confidence ∈ [0,1], timestamp, decay τ, `Provenance {device, model_version, synthetic}`.
|
||||
6. **Links**: typed references into the scene graph / RuVector entities.
|
||||
|
||||
Validated constructor (quaternion normalized, ranges checked); anisotropy and rotation are proven behaviorally (thin axis decays ≥ 80× faster at 0.3 m — the analytic ratio is 86; a 90° quaternion rotates the metric with it).
|
||||
|
||||
## 3. Decision — the map
|
||||
|
||||
`GaussianMap` (`map.rs`): spatial-hash grid (1 m default pitch) over a flat store.
|
||||
|
||||
- **Fusion, not accumulation**: an insert within Mahalanobis² 9 of a same-entity-kind Gaussian merges — confidence-weighted position/scale/occupancy/semantics/reflectivity/Doppler, noisy-OR confidence (`c₁+c₂−c₁c₂`), newest provenance wins, links union. Test: two 0.5-confidence observations 0.1 m apart fuse to one Gaussian at the weighted midpoint with confidence 0.75.
|
||||
- **Decay + static persistence** (update-loop step 7): exponential confidence decay per Gaussian τ, **stretched by observed lifetime** — `τ_eff = τ·(1 + ln(1 + lifetime/τ))` with `lifetime = last_seen − first_seen` — so a wall confirmed over 30 min outlives a once-seen transient at equal nominal τ (test `long_lived_structure_outlives_transients_at_equal_tau`); prune below 0.02; deterministic (replay test).
|
||||
- **Merge pass** (update-loop step 5): `merge_overlapping` collapses pairs that are *mutually* inside each other's Mahalanobis gate **and** semantically compatible (cosine ≥ 0.7, or both unlabeled) — orthogonal-semantic overlaps stay separate (test `merge_pass_collapses_mutual_overlaps_but_respects_semantics`). This catches drift the insert-time gate (±1 cell neighborhood only) misses.
|
||||
- **Queries**: radius (hash + linear reference impl, equivalence-tested on 100-Gaussian grids), kNN (expanding ring), semantic cosine top-k, and the segment-corridor query below.
|
||||
|
||||
## 4. Decision — channel gain as a first-class query + inverse update
|
||||
|
||||
`gain.rs` implements the RF query surface:
|
||||
|
||||
```text
|
||||
H(tx,rx,f) = (λ/4πd)·e^{-j2πd/λ} · exp(−Σ_g occ_g·I_g)
|
||||
```
|
||||
|
||||
with `I_g` the **closed-form** line integral of each Gaussian's density along the TX→RX segment (1-D Gaussian integral via erf; derivation in the module doc).
|
||||
|
||||
**Exactness anchors (MEASURED-CODE):**
|
||||
|
||||
- Empty map ⇒ **exact Friis** amplitude (< 1e-15) and propagation phase (`empty_map_returns_exact_friis`).
|
||||
- Closed-form line integral matches 1 mm trapezoid quadrature through a rotated anisotropic Gaussian to < 1e-6 (`line_integral_matches_numeric_quadrature`).
|
||||
- On-path absorber attenuates strictly monotonically in occupancy; a 10σ off-path absorber changes LoS gain < 1e-6 dB.
|
||||
|
||||
**Inverse update** (`observe_link`) — the incremental-mapping move: measured link amplitude → target optical depth `τ* = ln(friis/measured)`; a projected-gradient step distributes the residual over intersected Gaussians proportional to their path integrals (exact Newton along the link at lr = 1), clamped at occupancy ≥ 0; if nothing intersects and attenuation is demanded, a compact absorber is spawned at the midpoint sized to close the residual. **Measured**: from an empty map, 20 observations of a link with an unseen 0.7-neper (≈6.1 dB) obstruction converge to < 0.06 neper residual and < 0.5 dB prediction error (`inverse_update_learns_a_wall_from_link_residuals`).
|
||||
|
||||
## 5. Decision — task-gated scene graph
|
||||
|
||||
`graph.rs`: sparse typed nodes (`Object/Room/PersonClass/Device/Event` — person *classes* only; identity lives behind ADR-277's double gate) and relations (`Contains/Near/CausedBy/ObservedBy`). The only sanctioned read is `activate(relevant_kinds, seeds, max_nodes)` — bounded BFS that reports truncation instead of silently scanning (the JITOMA lesson). Tests: an "which object caused the anomaly" activation pulls exactly {event, object, room} and gates out devices/person-classes; the node budget is enforced and truncation is flagged.
|
||||
|
||||
## 6. Performance (criterion, release, this machine)
|
||||
|
||||
| Benchmark | Result | Note |
|
||||
|---|---|---|
|
||||
| `channel_gain`, 1 k Gaussians | **26.9 µs** | was 139 µs with the midpoint-ball candidate query |
|
||||
| `channel_gain`, 16 k Gaussians | **27.7 µs** | ~O(1) in map size after the corridor rewrite |
|
||||
| segment corridor query, hash vs linear | 24 µs vs 6 µs (1 k) / 24 µs vs **163 µs** (16 k) | crossover ≈ 4 k Gaussians — reported honestly; both paths kept + equivalence-tested |
|
||||
| radius query, hash vs linear | 4.3 µs vs 101 µs @ 16 k (23×) | hash loses at 1 k (4.0 vs 1.9 µs) — small maps are brute-force territory |
|
||||
| `observe_link` inverse update | **74 µs** | was 305 µs pre-optimization |
|
||||
| map insert+fuse (64 Gaussians, in observe bench setup) | included above | |
|
||||
|
||||
The optimization pass replaced a midpoint-ball candidate search (`(2·(L/2+3)+1)³ ≈ 9,300` cell lookups on a 14 m link) with an AABB sweep prefiltered by cell-centre-to-segment distance (bound `margin + √3/2·cell`), after a first corridor attempt (per-sample cube inserts into a BTreeSet) measured *worse* (1.2 ms) and was discarded — kept in this record as the honest negative result.
|
||||
|
||||
## 7. Consequences
|
||||
|
||||
- The map answers "where is a person likely", "where did multipath change", and "which object caused a channel anomaly" (gain residual → `CausedBy` edge) from one store.
|
||||
- RuVector integration (ADR-261) becomes: vector search retrieves candidate Gaussians/nodes → graph traversal enforces relations → the gain model *verifies* answers against geometry. The LLM plans the query; it never invents the spatial answer.
|
||||
- Not yet done (P3): live wiring into `wifi-densepose-sensing-server`, visual/depth Gaussian ingestion, and RuVector index sync.
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-276: Physics-guided synthetic RF world generator — randomize physics, not textures
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (`ruview-unified/src/synth/`: `room.rs`, `raytrace.rs`, `generator.rs`; 10 unit tests + the ADR-273 acceptance pipeline consumes it end-to-end) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-015 (MM-Fi/Wi-Pose datasets), ADR-089 (nvsim — the determinism pattern this follows), ADR-135 (empty-room baselines the generator can emulate) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. WaveVerse (released simulator, phase-coherent ray tracing) and HybridSim (92.07 % vs 54.22 % synthetic-only→real activity recognition when physics is modeled explicitly) are EXTERNAL-UNVERIFIED motivators. Every output of this generator is stamped `RfModality::Synthetic` and every number derived from it is labeled SYNTHETIC — that stamp survives into `Provenance.synthetic` at the ADR-277 export boundary.
|
||||
|
||||
## 1. Context
|
||||
|
||||
RuView's scarcest resource is labeled, *diverse* RF data: rooms, materials, antenna placements, people, chipsets. The 2026 evidence says synthetic RF transfers **when the physics is explicit and the randomization hits physical parameters** (permittivity, geometry, kinematics, hardware nuisances) rather than cosmetic noise. A physics generator also gives the ADR-273 acceptance machinery something it can never get from captures alone: *ground truth by construction* and unlimited strict-split diversity.
|
||||
|
||||
## 2. Decision — physics core
|
||||
|
||||
### 2.1 Rooms and materials (`room.rs`)
|
||||
|
||||
Shoebox rooms `[0,Lx]×[0,Ly]×[0,Lz]`, one wall material with **complex permittivity** `ε = ε_r − j·σ/(ωε₀)` and normal-incidence Fresnel reflection `Γ = (1−√ε)/(1+√ε)`. Presets (concrete/drywall/glass, ITU-R P.2040 ballpark) plus a perfect absorber for test isolation. Measured sanity: concrete at 2.4 GHz gives |Γ| ≈ 0.39–0.45 with phase inversion; |Γ| < 1 for all passive presets; ε_r = 1, σ = 0 gives Γ = 0 exactly. People are validated-in-room point scatterers with constant velocity and RCS.
|
||||
|
||||
### 2.2 Multipath (`raytrace.rs`)
|
||||
|
||||
Allen–Berkley image method, reflection order ≤ 2 (per-axis images `±x + 2nL`, bounce count `|2n|` / `|2n−1|`), plus single-bounce bistatic person scattering with amplitude `√(σ_rcs/4π)/(d₁·d₂)` (bistatic radar equation, amplitude form):
|
||||
|
||||
```text
|
||||
H(f) = Σ_paths Γ^order · (c/f)/(4π) · s_p · e^{−j2πf·d_p/c}
|
||||
```
|
||||
|
||||
**Doppler is never injected** — it emerges from the person's path length changing between snapshots.
|
||||
|
||||
**Physics gates (MEASURED-CODE):**
|
||||
|
||||
| Gate | Test | Result |
|
||||
|---|---|---|
|
||||
| Direct path ≡ Friis | `direct_path_is_exact_friis` | < 1e-15 per subcarrier (absorber walls) |
|
||||
| Reciprocity `H(a→b) = H(b→a)` | `channel_is_reciprocal` | < 1e-12, with person + concrete walls |
|
||||
| Image geometry | `first_order_reflection_matches_mirror_geometry` | floor/ceiling bounce at exactly the mirror distance; 1 direct + 6 first-order + second-order set |
|
||||
| Doppler | `moving_person_produces_the_analytic_doppler_phase_rate` | residual-phase rotation matches `−2πf·Δd/c` to < 1e-6 rad across 4 steps |
|
||||
|
||||
## 3. Decision — domain randomization (`generator.rs`)
|
||||
|
||||
Per room, seeded ChaCha20 (nvsim discipline — same seed ⇒ byte-identical corpus, cross-machine):
|
||||
|
||||
- **Physics**: dimensions 4–10 × 3–8 × 2.4–3.2 m; ε_r ∈ [2,7], σ ∈ [0.002,0.1] S/m; random TX/RX placements; person start/heading/speed/RCS.
|
||||
- **Hardware nuisances** (what breaks naive models in the field): per-room gain ×0.5–2, static phase offset, **CFO drift** ±0.3 rad/snapshot, thermal noise, 5 % packet loss (snapshot re-delivery), 3 % wideband interference bursts.
|
||||
- **Provenance for strict splits**: every window carries a full `PartitionKey` (room/day/person/chipset/firmware/layout) so ADR-273 §4 holdouts exist by construction.
|
||||
|
||||
Measured: byte-determinism per seed (and divergence across seeds); presence windows carry > 5× the temporal amplitude variance of empty windows (actual measured ratio on the test corpus is far higher); labels/keys complete.
|
||||
|
||||
The CFO nuisance earned its keep immediately: it *defeated the first tokenizer* (empty rooms looked like motion) and forced the CFO-alignment step now documented in ADR-274 §3.1 — exactly the class of failure a physics-parameter randomizer exists to surface before real deployments do.
|
||||
|
||||
## 4. What this generator is NOT
|
||||
|
||||
- Not a WaveVerse replacement: order-2 specular + point scatterers, no diffraction, no diffuse scattering, no angle-dependent Fresnel, no antenna patterns. These are refinements to add *when a P2 real-data gap analysis demands them*, not before.
|
||||
- Not evidence of real-world accuracy: the ADR-273 acceptance numbers on this data validate the pipeline; the synthetic→real transfer claim (HybridSim-style) is untested here and stays EXTERNAL-UNVERIFIED until P2 replay experiments.
|
||||
|
||||
## 5. Performance
|
||||
|
||||
Criterion (release): 1 room × 4 windows × 3 links generates in **3.1 ms** (≈ 260 µs/window) — corpus generation is never the bottleneck; the 8-room acceptance corpus builds in well under a second even in debug.
|
||||
|
||||
## 6. Consequences
|
||||
|
||||
- Every pipeline stage gains a deterministic, physics-proven test bed; regressions in adapters/tokenizer/encoder now fail loudly against ground truth.
|
||||
- Data scarcity stops gating architecture work: strict-split experiments (rooms/chipsets/layouts) run in CI.
|
||||
- The honest-labeling chain (`RfModality::Synthetic` → `Provenance.synthetic` → SYNTHETIC-graded ADR claims) is structural, not editorial.
|
||||
@@ -0,0 +1,61 @@
|
||||
# ADR-277: Edge sensing control plane — purposes, zones, retention, and a trust boundary raw RF cannot cross
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (`ruview-unified/src/policy.rs`; 5 unit tests + the acceptance-pipeline export test) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-153 (802.11bf protocol model), ADR-141/120 (BFLD privacy control plane + privacy classes), ADR-262 §3.3 (RuField P0–P5 fail-closed mapping — the same philosophy, applied to sensing outputs), ADR-032 (mesh security hardening) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. Standards status (EXTERNAL, checkable): IEEE 802.11bf-2025 published 2025-09; IEEE 802.11bk addresses ≤ 320 MHz positioning; ETSI published an ISAC architecture 2026-02 (monostatic/bistatic/multistatic/network/device sensing) followed by a security report identifying **19 privacy and security issue classes**; 3GPP Release 20 sensing studies are active. The OpenAirInterface SRS-xApp demo (0.12 m MAE under a **random** split) is EXTERNAL-UNVERIFIED and its split methodology is exactly the leakage ADR-273 §4 rejects — we cite the *implementation path*, not the number.
|
||||
|
||||
## 1. Context
|
||||
|
||||
Sensing purposes and sensing zones are becoming first-class authorization objects in the standards (802.11bf sensing sessions; ETSI ISAC purposes/exposure). Meanwhile the ETSI security report's issue classes make one thing clear: a sensing stack without a policy plane is a liability. RuView already fails closed at other boundaries (ADR-262 §3.3 maps privacy by information content, never byte value); this ADR gives sensing *outputs* the same discipline, on-device, before any transport.
|
||||
|
||||
## 2. Decision — three structural rules
|
||||
|
||||
### 2.1 Raw RF never leaves the trust boundary
|
||||
|
||||
The only exportable type is `BoundedEvent` — typed verdicts only (`Presence(bool)`, `ActivityClass(u8)`, `RespirationBpm(f64)`, `Location([f64;3])`, `AnomalyScore(f64)`). **No variant can carry RF samples, so raw CSI/radar export is unrepresentable, not merely forbidden**; `TrustBoundary::export` is the single egress and there is deliberately no API that serializes an `RfTensor` outward. External systems receive bounded events + uncertainty, never signal history.
|
||||
|
||||
### 2.2 Fail closed, everywhere
|
||||
|
||||
`PolicyEngine::authorize`: unknown zone ⇒ deny; purpose not granted in the zone ⇒ deny; **identity recognition is double-gated** — it must be in the zone's `allowed_purposes` *and* the zone must set `identity_explicitly_enabled` (either alone denies). Retention: an event older than the zone's `retention_s` at export time is dropped with a typed `PolicyDenied`. Tests cover every branch, including the manufactured cases (identity granted-but-not-enabled; enabled-but-not-granted; stale event).
|
||||
|
||||
### 2.3 Every output is accountable (ADR-273 acceptance item 8)
|
||||
|
||||
`BoundedEvent::new` is the only constructor and *fails* without: uncertainty ∈ [0,1], provenance (device + `synthetic` flag — the ADR-276 honest label survives export), a non-zero model version, timestamp, purpose, and zone. The acceptance test (`outputs_leave_only_through_the_policy_boundary_fully_attributed`) runs the full pipeline — synthetic world → encoder → presence head → event → export — and asserts the attribution and the denial of an ungranted purpose on the same zone.
|
||||
|
||||
## 3. Purpose taxonomy
|
||||
|
||||
`SensingPurpose`: `Presence, Activity, Vitals, Localization, PoseTracking, IdentityRecognition, ChannelDiagnostics` — deliberately aligned with the ETSI ISAC sensing-service classes and WLAN-sensing use cases so a future 802.11bf sensing-session negotiation or ISAC exposure API maps 1:1 onto zone grants. Person *identity* is additionally kept out of the ADR-275 scene graph by type (`EntityKind::PersonClass`, never a person id) — the graph cannot leak what it cannot store.
|
||||
|
||||
## 4. O-RAN / cellular path (roadmap, seams shipped)
|
||||
|
||||
The P1 control plane is transport-agnostic and already fronts the cellular seam:
|
||||
|
||||
- `CellularSrsAdapter` (`oai-srs-xapp`, ADR-274 §2.3) normalizes comb-sampled SRS frequency responses into the canonical tensor — the data-plane contract an OAI xApp needs.
|
||||
- P4 (ADR-273 §7) places the sensing application beside the DU for sub-ms I/Q–CSI–SRS access, with the xApp performing wider-area fusion; **every output of that path still exits through this ADR's `TrustBoundary`**, and its localization claims will be reported only under strict splits (the OAI demo's random split is the cautionary example, not the target).
|
||||
|
||||
## 5. Alternatives considered
|
||||
|
||||
- **Reuse BFLD's privacy classes directly** — rejected: BFLD (ADR-120) classifies *captures*; this plane authorizes *outputs by purpose and zone*. They compose (a BFLD-classified capture feeding a head still exits through `TrustBoundary`), and ADR-262's `map_privacy` remains the capture-side mapping.
|
||||
- **Config-file allow-lists without types** — rejected: the 19 ETSI issue classes are mostly "the code path existed" failures; unrepresentability beats configuration.
|
||||
|
||||
## 5.5 Boundary hardening (property-tested)
|
||||
|
||||
`tests/security_boundaries.rs` drives every validated constructor and every authorization gate with `proptest` over arbitrary values — including NaN/±inf smuggled via `f64::from_bits` — and asserts the *contract* (valid object **or** typed error, never a panic, never a permissive default). Three real defects surfaced and were fixed, all input-controlled denial-of-service or NaN-propagation:
|
||||
|
||||
1. `ble_cs_range` unwrap looped forever on a **non-finite** phase (`+inf − x = +inf`); a **finite-but-huge** phase (1e300 rad) made the same loop run ~1e299 iterations. Fixed by rejecting implausible phases (> 1e6 rad) and replacing the loop-based unwrap with O(1) modular arithmetic.
|
||||
2. A **subnormal** Gaussian scale (5e-324) passed `> 0` but overflowed `1/σ²` to ∞, making the density at the primitive's own centre NaN. Fixed with physical plausibility bounds (σ ∈ [1e-6, 1e4] m, occupancy ∈ [0, 1e6] nepers/m).
|
||||
|
||||
The eight properties now proven: tensor/Gaussian/BoundedEvent constructors never panic; `ble_cs_range` never panics and yields only finite non-negative distances; the policy engine is fail-closed for every (purpose, grants, zone) triple; raw export is unreachable for every task configuration; coherent fusion rejects every non-finite or out-of-bounds sync state; occupancy representations can never retain identity.
|
||||
|
||||
## 6. Consequences
|
||||
|
||||
- Enterprise/telecom conversations get a concrete artifact: a privacy manifest is a serialization of zones + purposes + retention (all types already `serde`).
|
||||
- Every future surface (sensing-server WS, RuField bridge, SRS xApp, MCP tools) must route sensing outputs through `TrustBoundary` — added to the pre-merge security-review checklist item 12.
|
||||
- Cost: purposes are coarse (no per-consumer grants yet); P3 adds consumer identity when the sensing-server wiring lands.
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR-278: Radar inverse rendering + differentiable RF SLAM — a gated research program, not a dependency
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed — research program (deliberately **no code in P1**) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 (pillar 6, score 3.6 — highest strategic value, highest hardware + reproduction risk) |
|
||||
| **Relates to** | ADR-275 (the Gaussian memory these methods would write into), ADR-263/264 (RTL8720F radar platform + wire protocol), ADR-021 (mmWave vitals hardware), ADR-276 (synthetic worlds as the reproduction sandbox) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Everything numeric in this ADR is **EXTERNAL-UNVERIFIED** — reported by fresh papers/preprints that this repo has not reproduced. That is the point of this ADR: to fix the reproduction gates *before* any of these numbers are allowed to influence the roadmap as if they were ours.
|
||||
|
||||
## 1. Context — what the field reports (July 2026)
|
||||
|
||||
| System | Claim (theirs) | Availability | Risk read |
|
||||
|---|---|---|---|
|
||||
| **RISE** | Single static mmWave radar + multipath inversion → joint room layout + furniture; 16 cm scene Chamfer (baseline 40 cm), 58 % furniture IoU | code available | most reproducible; static sensor matches our appliance posture |
|
||||
| **DiffRadar** | Radar SLAM + Gaussian fields + differentiable rendering; 0.129 m vs 0.823 m ATE, 94.78 % vs 42.59 % map consistency, 70 fps, 40 MB maps | fresh preprint | treat as **reproduction target, not component** — numbers are single-team, single-venue |
|
||||
| **GeRaF** | Differentiable RF renderer + SDF + reflectivity, near-range reconstruction; ~32 h on one H100 for 50 k iterations | published setup | offline calibration / digital-twin tool only; unsuitable for continuous adaptation |
|
||||
|
||||
The strategic pull is real: all three converge on *inverse rendering into continuous scene representations* — exactly the ADR-275 memory. The risks are equally real: single-source numbers, mmWave hardware variance, and compute profiles (GeRaF) incompatible with edge deployment.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
1. **No production dependency** on any of these systems or their claims. ADR-275's gain model + inverse update is the only RF-inverse machinery in the deployment path.
|
||||
2. **Reproduction order: RISE → DiffRadar → GeRaF-lite**, each on one controlled test site, each gated (§3) before the next starts. RISE first because a static radar matches the RuView appliance posture and its inversion writes naturally into `RfGaussian` (occupancy + reflectivity fields already exist for it).
|
||||
3. **Sandbox-first**: before hardware, each method's core inversion is exercised against ADR-276 synthetic worlds extended with a radar-cube output mode (the `FmcwRadarCube` adapter already normalizes such cubes), so failures separate into "our reimplementation" vs "their claim" cleanly.
|
||||
4. **Integration contract**: any reproduced system emits into `GaussianMap` via the existing primitive — no parallel scene store. SLAM trajectories, if any, become `Provenance`-stamped map updates subject to ADR-277 export rules like everything else.
|
||||
|
||||
## 3. Gates (each phase passes all or the program pauses)
|
||||
|
||||
| Gate | Threshold | Split discipline |
|
||||
|---|---|---|
|
||||
| G1 RISE-repro (synthetic) | layout Chamfer within 2× of paper's on our synthetic rooms | held-out room geometries |
|
||||
| G2 RISE-repro (one real site) | qualitative layout recovery + quantified Chamfer vs measured floor plan; report *our* number, whatever it is | site never used in tuning |
|
||||
| G3 DiffRadar-repro | ATE and map consistency on our trajectory rig; publish the delta vs paper | held-out trajectories |
|
||||
| G4 Edge viability | inversion or map-update loop ≤ 50 ms p95 on target hardware, or explicit reclassification as offline-calibration tooling (GeRaF's honest category) | — |
|
||||
|
||||
A gate failure is a *result*, recorded in this ADR's log — the program exists to convert EXTERNAL-UNVERIFIED into MEASURED, in either direction.
|
||||
|
||||
## 4. Consequences
|
||||
|
||||
- The roadmap cannot silently absorb preprint numbers; anything radar-inverse must pass through §3.
|
||||
- ADR-275's primitive already reserves the fields (per-band × angle reflectivity, occupancy, motion) these methods need, so a successful reproduction integrates without schema churn.
|
||||
- Cost of delay is accepted: pillar 6 scored lowest on readiness, and P1–P4 (encoder, memory, synth, control plane, SRS) do not depend on it.
|
||||
@@ -0,0 +1,54 @@
|
||||
# ADR-279: Native RF frame contract — `RfFrameV2` is authoritative, the canonical tensor is a derived view
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **implemented** (`ruview-unified/src/frame.rs`; 5 invariant tests) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 (amends ADR-274 §2) |
|
||||
| **Relates to** | ADR-136 (`CanonicalFrame` — extended, not replaced), ADR-262 (provenance discipline), ADR-282 (evidence ladder policy) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. This ADR is a **correction** to ADR-274 §2, adopted before any measured-data debt accumulates.
|
||||
|
||||
## 1. Context — the architectural correction
|
||||
|
||||
ADR-274 made the 56-bin × 8-snapshot canonical `RfTensor` the adapter output, which is right for *compatibility* but wrong as the *authoritative* format: resampling every device into one fixed tensor discards bandwidth (a 320 MHz 802.11bk capture and a 20 MHz 802.11n capture become indistinguishable), antenna structure, phase state, and hardware-specific information a foundation encoder should learn from (the WiLLM lesson: lightweight per-device adapters into a shared *latent*, not a shared *tensor*). RuView's own history proves the cost of premature canonicalization — MERIDIAN's normalizer is useful precisely because the native data was still around.
|
||||
|
||||
## 2. Decision — `RfFrameV2`
|
||||
|
||||
The authoritative record preserves the native capture. Fields per the implementation: schema version, frame id, timestamp, modality (now including `WifiCir`, `WifiBfReport`, `FmcwRangeAzimuth`, `FmcwDopplerAzimuth` alongside CSI/SRS/FMCW/UWB/BLE-CS), **declared native axes** (`FieldAxis`: time/frequency/delay/Doppler/range/azimuth/elevation/antenna/polarization), centre frequency, bandwidth, sample rate, arbitrary-rank `native_shape` + `native_iq` + explicit `valid_mask`, TX/RX `Pose3` in one building frame, `AntennaElement` geometry, `sample_age_ns`, `CalibrationState` with a **declared `PhaseState`** (`Raw | Sanitized | Calibrated | Unavailable`), `SignalQuality`, and `FrameProvenance`.
|
||||
|
||||
Seven required invariants, each enforced in the validated constructor or proven by a test:
|
||||
|
||||
1. **Native samples are never overwritten** — `to_canonical(&self)` is read-only; `canonical_view_is_derived_and_native_is_untouched` asserts byte-identical native IQ + mask after derivation.
|
||||
2. Subcarrier/antenna masks are explicit (`valid_mask`, arity-checked).
|
||||
3. Phase declares its state — consumers branch on `PhaseState` instead of guessing whether detrending happened.
|
||||
4. TX/RX geometry uses one building coordinate system (`Pose3`).
|
||||
5. Results retain source identity via `receipt_id` (consumed by the Gaussian memory's `source_receipts` lineage, ADR-275).
|
||||
6. **Synthetic and measured frames can never share a provenance class**, strengthened to an evidence rule: `Synthetic ⇒ exactly L0Simulation`, `Measured ⇒ ≥ L1CapturedReplay` — both directions rejected at construction (`synthetic_and_measured_provenance_can_never_alias`).
|
||||
7. Sample age is carried through the whole path (frame → tensor → age gate → `BoundedEvent`).
|
||||
|
||||
## 3. The canonical tensor is demoted to a compatibility view
|
||||
|
||||
`RfFrameV2::to_canonical()` derives the ADR-274 tensor **through the exact same normalization code path as every adapter** (`adapters::normalize_grid` — one normalization, many entry points), after mask-aware gap-filling (invalid bins interpolated from nearest valid neighbors on the complex plane). Rank ≠ 3 frames have no canonical projection and say so with a typed error. The existing ESP32/Intel/Atheros 114→56 projections stay as-is; they simply stop being the storage format.
|
||||
|
||||
## 4. The mandatory split manifest
|
||||
|
||||
The brief's leakage rule is now code: `PartitionKey` gains a `session` dimension (packet-session leakage is as real as room leakage) and `eval::SplitManifest` certifies per-dimension disjointness across **all seven** dimensions (room/day/person/chipset/firmware/layout/session):
|
||||
|
||||
```text
|
||||
train_rooms ∩ test_rooms = ∅ … train_sessions ∩ test_sessions = ∅
|
||||
```
|
||||
|
||||
`fully_disjoint()` is the bar for reporting a result as leakage-resistant; a room-holdout split that still shares people *says so* in its manifest instead of masquerading (test `split_manifest_certifies_per_dimension_disjointness`). The hidden real-world test set requirement (never accessible to synthetic generation/calibration) is process, recorded in ADR-282 §4.
|
||||
|
||||
## 5. Consequences
|
||||
|
||||
- New hardware (PicoScenes, Intel, Atheros, Realtek radar, 320 MHz 802.11bk) lands as an `RfFrameV2` producer + latent adapter; nothing is lost at ingest. Vendor conformance receipt = the constructor's invariants (native shape preserved, phase state declared, timestamps monotonic, geometry present, loss measured, synthetic flag correct).
|
||||
- The encoder input contract (ADR-274) is unchanged *today* (it consumes the derived view); migrating the tokenizer to native-resolution tokens is the flagged follow-up once real multi-bandwidth data exists (P2).
|
||||
- Storage cost rises (native + derived); accepted — the derived view can always be recomputed, the native never can be.
|
||||
|
||||
## 6. Verification
|
||||
|
||||
`cargo test -p ruview-unified frame::` — 5 tests: provenance aliasing, shape/mask/axes arity, derived-view purity + gap-filling, rank/geometry rejection, P3162 import-profile validation (`SyntheticApertureSoundingDataset`, ADR-281 §5). All MEASURED-CODE.
|
||||
@@ -0,0 +1,59 @@
|
||||
# ADR-280: Active sensing and programmable perception — tasks, freshness, coherence, and governed actuation
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **implemented** (`ruview-unified/src/control.rs`; 6 test suites incl. a measured ≥70 % traffic-reduction gate) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273; extends ADR-277 |
|
||||
| **Relates to** | ADR-277 (policy engine — every contract here composes with it), ADR-262 (P0–P5 privacy classes, reused verbatim), ADR-148 (`ruview-swarm` — the mobile-agent consumer of sensing actions) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. External motivators — ESI-Bench's act-to-uncover formalization, LuLIS's 256-coherent-RF-chain distributed aperture, ETSI's cooperative-ISAC and AI/data-handling work items, age-of-information digital-twin scheduling, semantic/task-sufficient communication architectures — are all EXTERNAL-UNVERIFIED. Everything asserted about *our* behavior is a named test.
|
||||
|
||||
## 1. Context
|
||||
|
||||
The important shift is from **passive sensing** (accept whatever measurements arrive) to **programmable perception**: the system chooses where, when, how, and at what fidelity to sense, then changes the radio environment or moves sensing agents to resolve uncertainty. Simultaneously, the dominant failure mode across the emerging systems is **hidden synchronization and calibration dependence** — shared clocks, known antenna poses, stable phase silently assumed, confidently wrong when violated. Both belong in the control plane, fail-closed, before capture begins.
|
||||
|
||||
## 2. Decision — the evidence-aware sensing task (`SensingTask`)
|
||||
|
||||
ETSI-ISAC-vocabulary contract: purpose, target zone, modalities, requested resolution, latency bound, minimum confidence (below which results become *no decision*), raw + result retention, authorized consumers, consent reference. `admit_task` composes with the ADR-277 engine and is fail-closed on every branch; two rules deserve record:
|
||||
|
||||
- `raw_export_allowed` **exists in the contract** (ISAC vocabulary compatibility) but is **always refused** (`task_admission_is_fail_closed`): ADR-277 §2.1 made raw export unrepresentable, and a config flag does not reopen it.
|
||||
- Identity-purpose tasks without a consent reference are refused before the zone check even runs.
|
||||
|
||||
## 3. Decision — sensing actions (`SensingAction` + `InformationGoal`)
|
||||
|
||||
An action is a deliberate act of evidence-gathering against a stated hypothesis ("the east corridor holds one stationary person or two closely spaced people"), bounded by latency, energy, and a **privacy ceiling** (`PrivacyClass` P0–P5, the ADR-262 ladder). Actions are what the planner (§4), a MetaHarness agent, or a swarm drone consume.
|
||||
|
||||
## 4. Decision — age-of-information scheduler (`ActiveSensingPlanner`)
|
||||
|
||||
A spatial twin is only useful when it knows which parts are stale. Per region: `SpatialStateFreshness` (last observation, expected change rate, uncertainty growth, business criticality, sensing cost), with
|
||||
|
||||
```text
|
||||
priority = uncertainty(age) × change_rate × criticality ÷ cost
|
||||
```
|
||||
|
||||
The planner emits at most the highest-priority action above threshold per cycle. **Measured** (`planner_reduces_sensing_traffic_versus_uniform_refresh`): 20 regions / 100 ticks, one hot region — 100 observations vs 2,000 under uniform refresh = **95 % sensing-traffic reduction** while the hot region stays observed. (The brief's "50–90 %" was an architectural estimate; this is a synthetic-scenario measurement, sensitive to how concentrated change is.) Priority ordering is proven separately (`planner_prioritizes_stale_critical_regions`: emergency-exit > server-room > storage).
|
||||
|
||||
## 5. Decision — coherent distributed apertures fail closed (`CoherentSensorGroup`)
|
||||
|
||||
No coherent fusion unless the group can *prove* compatibility: every member must report sync state, be within the group's time-error and phase-error bounds, and match the calibrated baseline geometry hash; unknown reporters are rejected too. Five denial paths, each tested (`coherent_fusion_fails_closed`): missing member, clock drift, phase drift, geometry change since calibration, non-member injection. This is the antidote to the hidden-synchronization failure mode — a building-scale WiFi aperture (the LuLIS direction) degrades to incoherent processing rather than producing confident nonsense.
|
||||
|
||||
## 6. Decision — programmable radio environments are governed actuators
|
||||
|
||||
RIS / movable / fluid antennas change **which rooms and people are observable**, so actuation is governed like sensing: `request_actuation` is the only way to obtain an `ActuationReceipt`, it verifies the state is supported *and* that the affected zone grants the purpose under the ADR-277 engine (`actuation_requires_policy_authorization`: steering a beam for an ungranted purpose is denied). Receipts carry requested/applied state, time, controller, purpose — the audit trail the RIS governance requirement demands.
|
||||
|
||||
## 7. Decision — task-sufficient representations are leakage-checked
|
||||
|
||||
Semantic compression ("transmit occupancy uncertainty, not CSI") must remain **task-scoped**: a representation sufficient for anonymous occupancy may not retain identity. `TaskSufficientRepresentation` carries source lineage, an information bound, an explicit `excluded_information` list, and a privacy class; `validate_representation` enforces per-purpose ceilings (Presence/Diagnostics ≤ P2 excluding identity+vitals; Activity/Localization ≤ P3 excluding identity; Vitals/Pose ≤ P4; Identity = P5) and refuses lineage-free orphans (`task_sufficient_representation_is_leakage_checked`).
|
||||
|
||||
## 8. Standards alignment (the strongest strategic seam)
|
||||
|
||||
The vocabulary here — sensing task/service/entity, measurement configuration, sensing data/result/consumer/purpose, retention, result exposure — is deliberately the emerging ETSI ISAC data-plane vocabulary, positioning this crate as an open reference implementation candidate for ISAC data handling rather than a parallel dialect. Charging/mobility management are explicitly out of scope until a cellular deployment exists.
|
||||
|
||||
## 9. Consequences
|
||||
|
||||
- MetaHarness/OaK-style agents get a typed surface: read freshness, plan actions, receive receipts — spatial memory meets agentic planning without touching raw RF.
|
||||
- Distributed-aperture work (P4+) inherits a fusion gate that already fails closed.
|
||||
- Not implemented (honest scope): information-gain *estimation* is caller-supplied (the planner uses staleness heuristics, not mutual information); RIS drivers, actual multi-AP coherence measurement, and OTFS waveform control are hardware-dependent roadmap items.
|
||||
@@ -0,0 +1,49 @@
|
||||
# ADR-281: New modality surfaces — BLE Channel Sounding, delay-Doppler-native tensors, P3162 import, and factorized pose
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **implemented** (`adapters.rs` BLE CS + ranging evidence, `tensor.rs::delay_doppler_map`, `frame.rs` P3162 import profile, `heads.rs` factorized pose; 8 new test suites) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273; extends ADR-274 |
|
||||
| **Relates to** | ADR-279 (`FieldAxis` native axes), ADR-152 (geometry conditioning intake), ADR-021/263 (radar hardware) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. Bluetooth SIG cm-level claims vs the ~20–50 cm practical review, OTFS ISAC field trials, IEEE P3162, PerceptAlign's >60 % cross-domain error reduction, and RePos's 10–21 % MPJPE gains are EXTERNAL-UNVERIFIED design inputs. Our numbers below are MEASURED-CODE / MEASURED-SYNTHETIC.
|
||||
|
||||
## 1. BLE Channel Sounding (§2) — likely the fastest path to consumer-scale spatial anchoring
|
||||
|
||||
`BleCsFrame` carries per-frequency-step round-trip tone phases plus optional RTT. Two rules:
|
||||
|
||||
- **Phase-based ranging and RTT are separate evidence sources.** `ble_cs_range` computes both — `d_phase = |dθ/df|·c/4π` from the unwrapped phase-vs-frequency slope, `d_rtt = rtt·c/2` — and *cross-validates* instead of averaging. Agreement raises confidence; divergence beyond 0.5 m yields `RangingAnomaly::Divergent` (multipath bias, relay attack, timing fault, or calibration problem) with confidence capped ≤ 0.2. Measured: exact recovery at 1.5/5/12 m (< 1 µm error on clean synthetic phases, 40 steps × 1 MHz); a relay-style RTT inflation to ~51 m against a 5 m phase estimate is flagged, not blended (`ble_cs_flags_relay_style_divergence_instead_of_averaging`).
|
||||
- The tensor view (`BleCsAdapter`, `nrf54-cs` in the registry) **never detrends phase** — the ranging ramp *is* the measurement; the preserved ramp is asserted in test.
|
||||
|
||||
Single-source evidence (no RTT) is capped at confidence 0.5 — one mechanism alone is never high-trust ranging.
|
||||
|
||||
## 2. Delay-Doppler-native support (§3)
|
||||
|
||||
`FieldAxis` (ADR-279) makes delay/Doppler first-class native axes so OTFS-style captures are stored natively, and `RfTensor::delay_doppler_map` provides the standard transform for frequency-time tensors: IDFT over bins (→ delay) × DFT over snapshots (→ Doppler). Measured: a synthetic scatterer at (delay 7, Doppler 3) produces a unit peak with < 1e-9 leakage everywhere else. The transform is implemented **separably** (delay IDFT per snapshot, then Doppler DFT per delay row — `O(B²S + S²B)` vs the direct form's `O(B²S²)`), proven equivalent to the direct reference to < 1e-10 and **measured 8.3× faster** (520 µs vs 4.34 ms at 56×8 in the criterion bench). Rule: derived features may be small, but delay-Doppler maps are not collapsed into scalar motion energy before provenance and local storage.
|
||||
|
||||
## 3. IEEE P3162 synthetic-aperture import (§5)
|
||||
|
||||
`SyntheticApertureSoundingDataset` (frequency range, aperture poses, directional PDP, coordinate system, processing-manifest hash) is the validated import profile — the calibration bridge between measured environments, Sionna-class simulators, and learned RF scene models. Schema + validation only; parsers arrive with the first real dataset.
|
||||
|
||||
## 4. Factorized pose (RePos) + log-age gating
|
||||
|
||||
`FactorizedPoseHead` separates what generalizes from what conditions:
|
||||
|
||||
- **relative skeleton** branch reads the environment-invariant content representation (cannot learn room-position shortcuts);
|
||||
- **root localization** branch reads the geometry-conditioned representation (sensor pose is signal there — the PerceptAlign lesson);
|
||||
- `absolute = root + relative` (`PoseOutput::absolute_joints_m`), with **calibrated per-joint and root residual σ** so every pose output carries uncertainty (ADR-273 item 8).
|
||||
|
||||
**The leakage experiment** (`factorized_pose_resists_room_shortcut_leakage`): training rooms where room position *correlates* with body scale (the trap real deployments set), held-out room breaking the correlation — factorized MPJPE **0.0003 m** vs monolithic absolute-head **0.2534 m** (845× worse), on a toy that isolates the mechanism. MEASURED-CODE for the mechanism; not a pose-accuracy claim.
|
||||
|
||||
Budget: the structured pose head is the largest adapter at **740 params vs the 40,856-param backbone (1.8 %)** — documented ceiling for structured heads is **< 2 %** (scalar heads keep the 1 % gate), both asserted in `every_head_fits_the_one_percent_budget_at_deployment_config`.
|
||||
|
||||
Age gating now matches the age-aware-CSI recipe exactly: the freshness gate input is `log(1 + sample_age_ms)` (`encoder::age_feature`), giving millisecond and multi-second staleness comparable input scale; the finite-difference gradient check re-proves the backward pass through the changed input.
|
||||
|
||||
## 5. Consequences
|
||||
|
||||
- Bluetooth/UWB anchors slot in as *geometric* evidence while WiFi carries ambient activity — the complement strategy, in code.
|
||||
- The Gaussian primitive gained the lifecycle fields the update-loop spec requires (`first_seen_ns`, `doppler_variance`, bounded `source_receipts` lineage merged on fusion) — static structure is distinguishable from transients by lifetime, and every primitive traces to source frames.
|
||||
- Roadmap, explicitly not done: real nRF54 CS capture path, OTFS waveform generation, P3162 file parsing, pose heads on real MM-Fi-style data.
|
||||
@@ -0,0 +1,62 @@
|
||||
# ADR-282: Ecosystem positioning — RuView is the camera-free RF perception runtime, not the whole spatial OS
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted (positioning + evidence-ladder policy; ladder implemented as `frame::EvidenceLevel`) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-260/262 (RuField), ADR-261 (RuVector), ADR-182 (MetaHarness-minted harness), ADR-279 (provenance/evidence types), ADR-187 (honest labeling precedent) |
|
||||
|
||||
## 1. Context
|
||||
|
||||
RuView currently occupies a valuable but ambiguous position: the README's breadth invites reading every capability as field-validated, and the platform sometimes speaks as if it were the complete spatial intelligence operating system. The defensible identity is narrower and stronger.
|
||||
|
||||
## 2. Decision — the layered identity
|
||||
|
||||
> **RuView is an open, edge-native RF perception runtime that turns heterogeneous radio measurements into governed spatial observations.**
|
||||
|
||||
It is *not* positioned as a complete world model, robotics platform, digital twin, or universal spatial OS. The stack divides:
|
||||
|
||||
| Layer | Responsibility | Owner |
|
||||
|---|---|---|
|
||||
| Applications | healthcare, buildings, robotics, security, retail, industrial | application systems |
|
||||
| Agent & decision | query planning, active sensing, automation, policy | **MetaHarness** |
|
||||
| Spatial memory & reasoning | persistent objects, Gaussian fields, scene graphs, temporal memory | **RuVector** (fed by `ruview-unified::gaussian`) |
|
||||
| Governed sensing plane | evidence, privacy, calibration, lineage, sensing tasks | **RuField** (bridged per ADR-262; contracts in ADR-277/279/280) |
|
||||
| Perception & edge inference | native capture, adapters, shared encoder, task heads, uncertainty, P0 containment | **RuView** |
|
||||
| Radio & physical sensors | WiFi CSI/CIR/BF, radar, UWB, BLE CS, cellular SRS | hardware |
|
||||
|
||||
Competitive posture follows from the layer: **complement vision platforms** (coverage where cameras are unavailable, unwanted, or ineffective — never "replaces cameras universally"); one shared encoder + spatial field across CSI and radar; BLE/UWB as geometric anchors with WiFi for ambient sensing; and against 6G ISAC, be the practical open implementation of the sensing data plane on hardware that exists today.
|
||||
|
||||
## 3. Decision — strengths to invest, weaknesses to fix
|
||||
|
||||
Invest (already differentiated): low-cost ambient perception on commodity radios; camera-free coverage (with the explicit caveat that camera-free ≠ privacy-preserving — that is what ADR-277/280 gates are for); edge-first execution; existing application surfaces (HA/Matter/HomeKit), to be extended toward ROS 2, OpenUSD, MQTT Sparkplug, OPC UA, BIM/digital-twin connectors as demand proves out.
|
||||
|
||||
Fix (each has a concrete ADR): platform/world-model claim mixing → this ADR's ladder; no persistent spatial representation → ADR-275 (feed RuVector, don't contain everything in the sensing server); ESP32-specific pipeline risk → ADR-279 adapters; stream-only operation → ADR-280 sensing tasks.
|
||||
|
||||
## 4. Decision — the public evidence ladder (mandatory)
|
||||
|
||||
`frame::EvidenceLevel` is now a type, and its use is policy:
|
||||
|
||||
| Level | Meaning |
|
||||
|---|---|
|
||||
| L0 | Simulation only |
|
||||
| L1 | Captured replay |
|
||||
| L2 | Controlled laboratory |
|
||||
| L3 | Held-out room + subject validation |
|
||||
| L4 | Multi-site field pilot |
|
||||
| L5 | Production operational evidence |
|
||||
|
||||
Rules: (a) every capability row in README/registry carries exactly one level; (b) `ProvenanceClass::Synthetic` frames are L0 *by type* and measured frames are ≥ L1 — the constructor rejects both aliasing directions (ADR-279 invariant 6); (c) a level upgrade requires the corresponding artifact (a replay corpus, a lab protocol, a strict-split manifest per ADR-279 §4, a pilot report); (d) the hidden real-world test set used for L3+ claims is never accessible to synthetic generation, augmentation, or calibration. Everything shipped in ADR-273..281 is **L0** except the adapter/contract layers, which are code-level (no accuracy claim to grade).
|
||||
|
||||
## 5. Commercial focus (bounded claims per vertical)
|
||||
|
||||
Elder care (decision support and anomaly escalation, **not** diagnosis); smart buildings (occupancy/utilization; value = energy + space + safety − cost); industrial safety (works in dust/darkness/occlusion; **not** a certified safety system until field-validated); security (through-wall occupancy with the surveillance-governance gates of ADR-277/280 as a feature, not friction); robotics (RuView is probabilistic exteroception, never ground truth).
|
||||
|
||||
## 6. The moat
|
||||
|
||||
Not any single detector: the *combination* of broad hardware support (ADR-279 adapters), heterogeneous data with provenance, cross-environment pretrained encoders under anti-leakage evaluation (ADR-273 §4), calibration/uncertainty discipline, privacy-preserving edge execution (ADR-277/280), cryptographic evidence (RuField bridge), persistent spatial memory (ADR-275 → RuVector), and open integration. Harder to reproduce than any model.
|
||||
|
||||
## 7. Acceptance test (ecosystem-fit)
|
||||
|
||||
RuView fits the mature stack when a **frozen** encoder ingests WiFi CSI, radar, and Bluetooth measurements from previously unseen hardware, emits RuField-compliant observations, updates a persistent RuVector spatial model, and supports an agent query with: ≤ 0.5 m p90 localization; < 20 % degradation across unseen rooms; explicit uncertainty on every result; complete calibration + provenance lineage; no P0 RF leaving the edge; replay/lab/live evidence clearly separated; successful fusion with a standard robotics or digital-twin platform. Tracked as the L4 gate; the synthetic analogue machinery already exists (`tests/e2e_acceptance.rs`).
|
||||
@@ -132,6 +132,16 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-263](ADR-263-ruview-npm-harness-deep-review.md) | `@ruvnet/ruview` npm harness — deep review + optimization strategy | Proposed |
|
||||
| [ADR-264](ADR-264-rvagent-mcp-and-cli-npm-deep-review.md) | `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` — deep review + optimization strategy | Proposed |
|
||||
| [ADR-265](ADR-265-ruview-npm-distribution-strategy.md) | RuView npm distribution strategy — CI gate, provenance, version single-sourcing, namespace | Proposed |
|
||||
| [ADR-273](ADR-273-unified-rf-spatial-world-model.md) | Unified RF spatial world model — umbrella, anti-leakage protocol, acceptance gates | Accepted (P1 implemented) |
|
||||
| [ADR-274](ADR-274-universal-rf-encoder-adapter-registry.md) | Universal RF foundation encoder + hardware adapter registry | Accepted (P1 implemented) |
|
||||
| [ADR-275](ADR-275-rf-aware-gaussian-spatial-memory.md) | RF-aware Gaussian spatial memory | Accepted (P1 implemented) |
|
||||
| [ADR-276](ADR-276-physics-guided-synthetic-rf-worlds.md) | Physics-guided synthetic RF world generator | Accepted (P1 implemented) |
|
||||
| [ADR-277](ADR-277-edge-sensing-control-plane.md) | Edge sensing control plane (802.11bf / ETSI ISAC aligned) | Accepted (P1 implemented) |
|
||||
| [ADR-278](ADR-278-radar-inverse-rendering-research-program.md) | Radar inverse rendering + differentiable RF SLAM research program | Proposed |
|
||||
| [ADR-279](ADR-279-native-rf-frame-contract.md) | Native RF frame contract — `RfFrameV2` authoritative, canonical tensor derived | Accepted (implemented) |
|
||||
| [ADR-280](ADR-280-active-sensing-programmable-perception.md) | Active sensing & programmable perception control plane | Accepted (implemented) |
|
||||
| [ADR-281](ADR-281-ble-cs-delay-doppler-pose-factorization.md) | BLE Channel Sounding, delay-Doppler tensors, P3162 import, factorized pose | Accepted (implemented) |
|
||||
| [ADR-282](ADR-282-ruview-ecosystem-positioning.md) | Ecosystem positioning + mandatory L0–L5 evidence ladder | Accepted |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:`
|
||||
|
||||
@@ -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
+191
@@ -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"
|
||||
@@ -7535,6 +7675,22 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-unified"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ndarray 0.17.2",
|
||||
"num-complex",
|
||||
"proptest",
|
||||
"rand 0.8.5",
|
||||
"rand_chacha 0.3.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-hardware",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@@ -7666,6 +7822,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 +8301,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"
|
||||
@@ -10902,6 +11084,8 @@ dependencies = [
|
||||
"ndarray 0.17.2",
|
||||
"num-complex",
|
||||
"predicates",
|
||||
"reqwest 0.12.28",
|
||||
"ruview-auth",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tabled",
|
||||
@@ -11135,18 +11319,25 @@ 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",
|
||||
|
||||
@@ -29,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
|
||||
@@ -72,6 +78,11 @@ members = [
|
||||
"crates/homecore-assist", # ADR-133 — HOMECORE voice assistant + ruflo bridge
|
||||
"crates/homecore-server", # iter-9 — HOMECORE integration binary (all 8 crates wired together)
|
||||
"crates/ruview-swarm", # ADR-148 — drone swarm control system
|
||||
# ADR-273..277 — unified RF spatial world model: canonical RF tensor +
|
||||
# hardware adapters, universal foundation encoder (masked-reconstruction
|
||||
# pretraining, ≤1% task adapters), RF-aware Gaussian spatial memory,
|
||||
# physics-guided synthetic RF worlds, edge sensing control plane.
|
||||
"crates/ruview-unified",
|
||||
# ADR-262 P1 — anti-corruption bridge converting RuView WiFi-CSI sensing
|
||||
# output into signed RuField FieldEvents. Path-deps the `vendor/rufield`
|
||||
# submodule crates (rufield-core/-provenance/-privacy/-fusion); single
|
||||
|
||||
@@ -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,745 @@
|
||||
//! 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);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[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,49 @@
|
||||
[package]
|
||||
name = "ruview-unified"
|
||||
description = "Unified RF spatial world model (ADR-273): canonical RF tensor + hardware adapters, universal RF foundation encoder, RF-aware Gaussian spatial memory, physics-guided synthetic RF worlds, and the edge sensing control plane"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords = ["wifi", "csi", "rf-sensing", "gaussian-splatting", "world-model"]
|
||||
categories = ["science", "simulation"]
|
||||
|
||||
# `ruview-unified` is deliberately a *thin-dependency* crate: pure-Rust math,
|
||||
# deterministic ChaCha20 randomness (nvsim pattern — same seed ⇒ byte-identical
|
||||
# output on every machine), and a single internal dependency on
|
||||
# `wifi-densepose-core` so the WiFi adapter consumes the real `CsiFrame`
|
||||
# boundary type instead of a parallel invention. No GPU, no ONNX, no tokio.
|
||||
[dependencies]
|
||||
wifi-densepose-core = { workspace = true }
|
||||
ndarray = { workspace = true }
|
||||
num-complex = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
# Deterministic PRNG for domain randomization + weight init (see nvsim §Pass 4
|
||||
# for the rationale: default features off drops the getrandom OS-entropy path,
|
||||
# keeping the crate WASM-ready and reproducible).
|
||||
rand = { version = "0.8", default-features = false }
|
||||
rand_chacha = { version = "0.3", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
# Dev-only: bridges real ESP32 ADR-018 UDP captures (wifi-densepose-hardware's
|
||||
# already-proven Esp32CsiParser) into wifi_densepose_core::CsiFrame for the
|
||||
# `esp32_live_hardware_test` example. Does not affect the published dependency
|
||||
# graph — the crate's real dependency stays thin-dependency (see above).
|
||||
wifi-densepose-hardware = { workspace = true }
|
||||
|
||||
[[bench]]
|
||||
name = "unified_bench"
|
||||
harness = false
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
missing_docs = "warn"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Criterion benchmarks for the unified RF spatial world model hot paths
|
||||
//! (ADR-273 §7): encoder inference (the edge-latency budget), tokenization
|
||||
//! (with vs without precomputed DFT twiddles), Gaussian map queries (spatial
|
||||
//! hash vs linear-scan baseline), channel-gain evaluation, and synthetic
|
||||
//! window generation.
|
||||
#![allow(missing_docs)]
|
||||
|
||||
use criterion::{criterion_group, criterion_main, BatchSize, Criterion};
|
||||
|
||||
use ruview_unified::encoder::{EncoderConfig, RfEncoder};
|
||||
use ruview_unified::gaussian::map::GaussianMap;
|
||||
use ruview_unified::gaussian::primitive::{Provenance, RfGaussian};
|
||||
use ruview_unified::gaussian::{channel_gain, observe_link};
|
||||
use ruview_unified::math::{dft_magnitudes, DftPlan};
|
||||
use ruview_unified::synth::{SynthConfig, SynthGenerator};
|
||||
use ruview_unified::tokenizer::RfTokenizer;
|
||||
|
||||
fn synth_corpus() -> Vec<ruview_unified::synth::LabeledWindow> {
|
||||
SynthGenerator::new(SynthConfig {
|
||||
seed: 11,
|
||||
n_rooms: 2,
|
||||
windows_per_room: 4,
|
||||
links: 3,
|
||||
snapshot_dt_s: 0.05,
|
||||
})
|
||||
.generate()
|
||||
}
|
||||
|
||||
fn bench_encoder_forward(c: &mut Criterion) {
|
||||
let corpus = synth_corpus();
|
||||
let tokenizer = RfTokenizer::new();
|
||||
let window = tokenizer.tokenize(&corpus[0].tensor);
|
||||
let encoder = RfEncoder::new(EncoderConfig::default(), 42);
|
||||
c.bench_function("encoder_encode_window_21tok_d128", |b| {
|
||||
b.iter(|| std::hint::black_box(encoder.encode(&window)));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_tokenizer(c: &mut Criterion) {
|
||||
let corpus = synth_corpus();
|
||||
let tokenizer = RfTokenizer::new();
|
||||
c.bench_function("tokenize_3link_56bin_8snap", |b| {
|
||||
b.iter(|| std::hint::black_box(tokenizer.tokenize(&corpus[0].tensor)));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_dft_plan_vs_naive(c: &mut Criterion) {
|
||||
use num_complex::Complex64;
|
||||
let x: Vec<Complex64> =
|
||||
(0..8).map(|i| Complex64::new((i as f64).sin(), (i as f64).cos())).collect();
|
||||
let plan = DftPlan::new(8, 4);
|
||||
c.bench_function("dft8x4_naive", |b| {
|
||||
b.iter(|| std::hint::black_box(dft_magnitudes(&x, 4)));
|
||||
});
|
||||
c.bench_function("dft8x4_planned", |b| {
|
||||
b.iter(|| std::hint::black_box(plan.magnitudes(&x)));
|
||||
});
|
||||
}
|
||||
|
||||
fn populated_map(n_side: usize) -> GaussianMap {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
for x in 0..n_side {
|
||||
for y in 0..n_side {
|
||||
let g = RfGaussian::new(
|
||||
[x as f64 * 1.5, y as f64 * 1.5, 1.0],
|
||||
[0.3, 0.3, 0.3],
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
0.4,
|
||||
0.9,
|
||||
0,
|
||||
600.0,
|
||||
Provenance { device_id: "bench".into(), model_version: 1, synthetic: true },
|
||||
)
|
||||
.expect("valid");
|
||||
map.insert(g);
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn bench_gaussian_map(c: &mut Criterion) {
|
||||
// Two sizes to show the hash/linear crossover honestly: at 1,024
|
||||
// Gaussians a brute-force scan is competitive for small-radius queries;
|
||||
// at 16,384 the hash wins decisively.
|
||||
for (label, side) in [("1k", 32usize), ("16k", 128)] {
|
||||
let map = populated_map(side);
|
||||
let centre = [side as f64 * 0.75, side as f64 * 0.75, 1.0];
|
||||
c.bench_function(&format!("map{label}_query_radius_hash"), |b| {
|
||||
b.iter(|| std::hint::black_box(map.query_radius(centre, 3.0)));
|
||||
});
|
||||
c.bench_function(&format!("map{label}_query_radius_linear"), |b| {
|
||||
b.iter(|| std::hint::black_box(map.query_radius_linear(centre, 3.0)));
|
||||
});
|
||||
c.bench_function(&format!("map{label}_segment_corridor_hash"), |b| {
|
||||
b.iter(|| {
|
||||
std::hint::black_box(map.query_near_segment(
|
||||
[0.0, 0.0, 1.0],
|
||||
[10.0, 10.0, 1.0],
|
||||
3.0,
|
||||
))
|
||||
});
|
||||
});
|
||||
c.bench_function(&format!("map{label}_segment_corridor_linear"), |b| {
|
||||
b.iter(|| {
|
||||
std::hint::black_box(map.query_near_segment_linear(
|
||||
[0.0, 0.0, 1.0],
|
||||
[10.0, 10.0, 1.0],
|
||||
3.0,
|
||||
))
|
||||
});
|
||||
});
|
||||
c.bench_function(&format!("map{label}_channel_gain"), |b| {
|
||||
b.iter(|| {
|
||||
std::hint::black_box(channel_gain(
|
||||
&map,
|
||||
[0.0, 0.0, 1.0],
|
||||
[10.0, 10.0, 1.0],
|
||||
2.437e9,
|
||||
))
|
||||
});
|
||||
});
|
||||
}
|
||||
c.bench_function("map_observe_link_inverse_update", |b| {
|
||||
b.iter_batched(
|
||||
|| populated_map(8),
|
||||
|mut m| {
|
||||
std::hint::black_box(observe_link(
|
||||
&mut m,
|
||||
[0.0, 0.0, 1.0],
|
||||
[10.0, 10.0, 1.0],
|
||||
2.437e9,
|
||||
1e-4,
|
||||
0.7,
|
||||
1,
|
||||
))
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_increment2_paths(c: &mut Criterion) {
|
||||
use num_complex::Complex64;
|
||||
use ruview_unified::adapters::{ble_cs_range, BleCsFrame};
|
||||
use ruview_unified::control::{
|
||||
ActiveSensingPlanner, CoherentSensorGroup, MemberSyncState, SpatialStateFreshness,
|
||||
SpatialZone,
|
||||
};
|
||||
use ruview_unified::frame::{
|
||||
AntennaElement, CalibrationState, EvidenceLevel, FieldAxis, FrameProvenance, PhaseState,
|
||||
Pose3, ProvenanceClass, RfFrameV2, SignalQuality,
|
||||
};
|
||||
use ruview_unified::heads::FactorizedPoseHead;
|
||||
use ruview_unified::tensor::{LinkGeometry, RfModality};
|
||||
|
||||
// Delay-Doppler: separable vs direct.
|
||||
let corpus = synth_corpus();
|
||||
c.bench_function("delay_doppler_separable_56x8", |b| {
|
||||
b.iter(|| std::hint::black_box(corpus[0].tensor.delay_doppler_map(0).unwrap()));
|
||||
});
|
||||
c.bench_function("delay_doppler_direct_56x8", |b| {
|
||||
b.iter(|| std::hint::black_box(corpus[0].tensor.delay_doppler_map_direct(0).unwrap()));
|
||||
});
|
||||
|
||||
// Native frame → canonical derived view (114 subcarriers, 12 snapshots).
|
||||
let n = 114 * 12;
|
||||
let frame = RfFrameV2::new(
|
||||
1,
|
||||
0,
|
||||
RfModality::WifiCsi,
|
||||
vec![FieldAxis::Antenna, FieldAxis::Frequency, FieldAxis::Time],
|
||||
2.437e9,
|
||||
20e6,
|
||||
100.0,
|
||||
vec![1, 114, 12],
|
||||
(0..n).map(|i| Complex64::new(1.0 + 0.01 * (i % 13) as f64, 0.001 * (i % 7) as f64)).collect(),
|
||||
vec![true; n],
|
||||
Some(Pose3 { position_m: [0.0, 0.0, 2.0], orientation: [1.0, 0.0, 0.0, 0.0] }),
|
||||
Some(Pose3 { position_m: [4.0, 0.0, 2.0], orientation: [1.0, 0.0, 0.0, 0.0] }),
|
||||
vec![AntennaElement { position_m: [0.0; 3], gain_dbi: 2.0 }],
|
||||
5_000_000,
|
||||
CalibrationState {
|
||||
phase_state: PhaseState::Raw,
|
||||
gain_calibrated: false,
|
||||
clock_ppm: 12.0,
|
||||
baseline_id: None,
|
||||
confidence: 0.8,
|
||||
},
|
||||
SignalQuality { rssi_dbm: -45.0, noise_floor_dbm: -92.0, packet_loss: 0.02, interference: 0.05 },
|
||||
FrameProvenance {
|
||||
class: ProvenanceClass::Measured,
|
||||
evidence: EvidenceLevel::L2Lab,
|
||||
device_id: "bench".into(),
|
||||
firmware: "fw".into(),
|
||||
receipt_id: 1,
|
||||
},
|
||||
)
|
||||
.expect("valid frame");
|
||||
let geo = vec![LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.0, 2.0] }];
|
||||
c.bench_function("rfframe_to_canonical_114x12", |b| {
|
||||
b.iter(|| std::hint::black_box(frame.to_canonical(geo.clone()).unwrap()));
|
||||
});
|
||||
|
||||
// BLE CS ranging (40 steps).
|
||||
let cs = {
|
||||
let c_light = 299_792_458.0;
|
||||
let steps: Vec<f64> = (0..40).map(|k| 2.402e9 + 1e6 * k as f64).collect();
|
||||
let phases: Vec<f64> = steps
|
||||
.iter()
|
||||
.map(|f| (-4.0 * std::f64::consts::PI * f * 5.0 / c_light)
|
||||
.rem_euclid(2.0 * std::f64::consts::PI))
|
||||
.collect();
|
||||
BleCsFrame { frequency_steps_hz: steps, phase_samples_rad: phases, round_trip_time_ns: Some(33.36) }
|
||||
};
|
||||
c.bench_function("ble_cs_range_40steps", |b| {
|
||||
b.iter(|| std::hint::black_box(ble_cs_range(&cs).unwrap()));
|
||||
});
|
||||
|
||||
// AoI planner over 200 regions.
|
||||
let mut planner = ActiveSensingPlanner::new(0.01);
|
||||
for i in 0..200 {
|
||||
planner.upsert_region(SpatialStateFreshness {
|
||||
region_id: format!("r{i}"),
|
||||
region: SpatialZone { id: format!("r{i}"), min_m: [0.0; 3], max_m: [5.0; 3] },
|
||||
last_observed_ns: (i as u64) * 1_000_000,
|
||||
expected_change_rate: 0.01 + (i % 7) as f64 * 0.05,
|
||||
uncertainty_growth_rate: 0.1,
|
||||
business_criticality: 1.0 + (i % 3) as f64,
|
||||
sensing_cost: 1.0,
|
||||
});
|
||||
}
|
||||
c.bench_function("aoi_planner_next_action_200regions", |b| {
|
||||
b.iter(|| {
|
||||
std::hint::black_box(planner.next_action(60_000_000_000, RfModality::WifiCsi))
|
||||
});
|
||||
});
|
||||
|
||||
// Coherent fusion gate, 32 members.
|
||||
let group = CoherentSensorGroup {
|
||||
group_id: "g".into(),
|
||||
members: (0..32).map(|i| format!("ap-{i}")).collect(),
|
||||
maximum_time_error_ns: 50.0,
|
||||
maximum_phase_error_rad: 0.2,
|
||||
baseline_geometry_hash: 7,
|
||||
};
|
||||
let states: Vec<MemberSyncState> = (0..32)
|
||||
.map(|i| MemberSyncState {
|
||||
member_id: format!("ap-{i}"),
|
||||
time_error_ns: 10.0,
|
||||
phase_error_rad: 0.05,
|
||||
geometry_hash: 7,
|
||||
})
|
||||
.collect();
|
||||
c.bench_function("coherent_group_can_fuse_32members", |b| {
|
||||
b.iter(|| std::hint::black_box(group.can_fuse(&states).is_ok()));
|
||||
});
|
||||
|
||||
// Factorized pose predict at deployment dims.
|
||||
let head = FactorizedPoseHead::new(152, 128, 2);
|
||||
let content = vec![0.1f64; 152];
|
||||
let full = vec![0.1f64; 128];
|
||||
c.bench_function("factorized_pose_predict_d152_d128", |b| {
|
||||
b.iter(|| std::hint::black_box(head.predict(&content, &full)));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_synth_generation(c: &mut Criterion) {
|
||||
c.bench_function("synth_generate_1room_4windows_3links", |b| {
|
||||
b.iter(|| {
|
||||
std::hint::black_box(
|
||||
SynthGenerator::new(SynthConfig {
|
||||
seed: 3,
|
||||
n_rooms: 1,
|
||||
windows_per_room: 4,
|
||||
links: 3,
|
||||
snapshot_dt_s: 0.05,
|
||||
})
|
||||
.generate(),
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_encoder_forward,
|
||||
bench_tokenizer,
|
||||
bench_dft_plan_vs_naive,
|
||||
bench_gaussian_map,
|
||||
bench_increment2_paths,
|
||||
bench_synth_generation
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Hardware-in-the-loop test: feeds REAL ADR-018 CSI frames from a live
|
||||
//! ESP32 node through `WifiCsiAdapter`, not synthetic data.
|
||||
//!
|
||||
//! The PR that introduced this crate is explicit that every reported number
|
||||
//! is generator-produced (L0) and real-data validation (P2) is future work.
|
||||
//! This example closes part of that gap for the one adapter that matters
|
||||
//! most: it binds the real ADR-018 UDP port, parses live packets with the
|
||||
//! already-proven `wifi_densepose_hardware::Esp32CsiParser` (the same parser
|
||||
//! `aggregator`/`sensing-server` use in production), converts each frame into
|
||||
//! the exact `wifi_densepose_core::types::CsiFrame` the adapter expects, and
|
||||
//! runs it through `AdapterRegistry::normalize`.
|
||||
//!
|
||||
//! Usage: `cargo run -p ruview-unified --example esp32_live_hardware_test --
|
||||
//! --bind 0.0.0.0:5005 --frames 8`
|
||||
//! (point a live ESP32 CSI node's UDP target at this host's IP:5005 first).
|
||||
|
||||
use std::net::UdpSocket;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use ndarray::Array2;
|
||||
use num_complex::Complex64;
|
||||
use wifi_densepose_core::types::{AntennaConfig, CsiFrame as CoreCsiFrame, CsiMetadata, DeviceId, FrequencyBand};
|
||||
use wifi_densepose_hardware::{Esp32CsiParser, ParseError};
|
||||
|
||||
use ruview_unified::adapters::{AdapterRegistry, RawCapture};
|
||||
use ruview_unified::tensor::LinkGeometry;
|
||||
|
||||
/// Inverse of `ruview_unified::adapters`'s (now-fixed) channel->frequency
|
||||
/// map: recovers the 802.11 channel number from the real per-frame
|
||||
/// `channel_freq_mhz` the hardware parser already computes correctly. Used
|
||||
/// here only to populate `CsiMetadata::channel` for the adapter under test —
|
||||
/// exercising the fix end-to-end against a real, independently-computed
|
||||
/// frequency instead of a value this same test invented.
|
||||
fn freq_mhz_to_band_and_channel(freq_mhz: u32) -> (FrequencyBand, u8) {
|
||||
if freq_mhz == 2484 {
|
||||
(FrequencyBand::Band2_4GHz, 14)
|
||||
} else if (2412..=2472).contains(&freq_mhz) {
|
||||
(FrequencyBand::Band2_4GHz, ((freq_mhz - 2407) / 5) as u8)
|
||||
} else if (5000..6000).contains(&freq_mhz) {
|
||||
(FrequencyBand::Band5GHz, ((freq_mhz - 5000) / 5) as u8)
|
||||
} else {
|
||||
(FrequencyBand::Band6GHz, ((freq_mhz.saturating_sub(5950)) / 5) as u8)
|
||||
}
|
||||
}
|
||||
|
||||
fn to_core_frame(hw: wifi_densepose_hardware::CsiFrame) -> CoreCsiFrame {
|
||||
let (band, channel) = freq_mhz_to_band_and_channel(hw.metadata.channel_freq_mhz);
|
||||
let mut meta = CsiMetadata::new(DeviceId::new(format!("esp32-node-{}", hw.metadata.node_id)), band, channel);
|
||||
meta.bandwidth_mhz = match hw.metadata.bandwidth {
|
||||
wifi_densepose_hardware::Bandwidth::Bw20 => 20,
|
||||
wifi_densepose_hardware::Bandwidth::Bw40 => 40,
|
||||
wifi_densepose_hardware::Bandwidth::Bw80 => 80,
|
||||
wifi_densepose_hardware::Bandwidth::Bw160 => 160,
|
||||
};
|
||||
meta.antenna_config = AntennaConfig::new(1, hw.metadata.n_antennas.max(1));
|
||||
meta.rssi_dbm = hw.metadata.rssi_dbm;
|
||||
meta.noise_floor_dbm = hw.metadata.noise_floor_dbm;
|
||||
meta.sequence_number = hw.metadata.sequence;
|
||||
|
||||
// Single spatial stream (n_antennas isn't broken out per-antenna in the
|
||||
// ADR-018 wire format consumed here) x real subcarrier count.
|
||||
let n_bins = hw.subcarriers.len().max(1);
|
||||
let data = Array2::from_shape_fn((1, n_bins), |(_, b)| {
|
||||
hw.subcarriers.get(b).map_or(Complex64::new(0.0, 0.0), |sc| {
|
||||
Complex64::new(f64::from(sc.i), f64::from(sc.q))
|
||||
})
|
||||
});
|
||||
CoreCsiFrame::new(meta, data)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let bind = std::env::args()
|
||||
.collect::<Vec<_>>()
|
||||
.windows(2)
|
||||
.find(|w| w[0] == "--bind")
|
||||
.map_or_else(|| "0.0.0.0:5005".to_string(), |w| w[1].clone());
|
||||
let want_frames: usize = std::env::args()
|
||||
.collect::<Vec<_>>()
|
||||
.windows(2)
|
||||
.find(|w| w[0] == "--frames")
|
||||
.and_then(|w| w[1].parse().ok())
|
||||
.unwrap_or(8);
|
||||
|
||||
let socket = UdpSocket::bind(&bind).expect("bind UDP socket");
|
||||
socket.set_read_timeout(Some(std::time::Duration::from_secs(30))).unwrap();
|
||||
eprintln!("Listening on {bind} for real ESP32 ADR-018 CSI frames (need {want_frames})...");
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
let mut core_frames: Vec<CoreCsiFrame> = Vec::new();
|
||||
let mut real_channel_freq_hz: Option<f64> = None;
|
||||
|
||||
while core_frames.len() < want_frames {
|
||||
let (n, _src) = socket.recv_from(&mut buf).expect("recv (timed out — is a live node targeting this host?)");
|
||||
match Esp32CsiParser::parse_frame(&buf[..n]) {
|
||||
Ok((hw_frame, _consumed)) => {
|
||||
// Lock onto the first node/shape seen so all frames in the
|
||||
// window share (n_links, n_bins), as the adapter requires.
|
||||
if let Some(first) = core_frames.first() {
|
||||
let n_bins = hw_frame.subcarriers.len();
|
||||
if n_bins != first.num_subcarriers() {
|
||||
eprintln!(" [skip: shape changed mid-window ({n_bins} vs {})]", first.num_subcarriers());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if real_channel_freq_hz.is_none() {
|
||||
real_channel_freq_hz = Some(f64::from(hw_frame.metadata.channel_freq_mhz) * 1e6);
|
||||
}
|
||||
eprintln!(
|
||||
" [captured frame {}: sc={} rssi={} node={}]",
|
||||
core_frames.len() + 1,
|
||||
hw_frame.subcarriers.len(),
|
||||
hw_frame.metadata.rssi_dbm,
|
||||
hw_frame.metadata.node_id,
|
||||
);
|
||||
core_frames.push(to_core_frame(hw_frame));
|
||||
}
|
||||
Err(ParseError::NonCsiPacket { .. }) => {}
|
||||
Err(e) => eprintln!(" [parse error: {e}]"),
|
||||
}
|
||||
}
|
||||
|
||||
let raw = RawCapture::WifiCsi {
|
||||
frames: core_frames,
|
||||
links: vec![LinkGeometry { tx_pos: [0.0, 0.0, 1.0], rx_pos: [3.0, 0.0, 1.0] }],
|
||||
age_s: 0.05,
|
||||
clock_quality: 0.5, // free-running ESP32 crystal, not disciplined — honest, not 1.0
|
||||
};
|
||||
|
||||
let registry = AdapterRegistry::with_reference_adapters();
|
||||
let now_ns = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64;
|
||||
match registry.normalize("esp32s3-csi", &raw) {
|
||||
Ok(tensor) => {
|
||||
let real_hz = real_channel_freq_hz.unwrap();
|
||||
println!("=== RfTensor from REAL ESP32 hardware (not synthetic) ===");
|
||||
println!("dims (links, bins, snapshots): {:?}", tensor.data.dim());
|
||||
println!("adapter-computed center_freq_hz : {:.0}", tensor.center_freq_hz);
|
||||
println!("real per-frame channel_freq_hz : {:.0} (from hardware parser)", real_hz);
|
||||
println!(
|
||||
"match within 1 MHz: {} (this is the ADR-018 channel-aware fix — the pre-fix \
|
||||
code always reported the fixed-band constant, 2437000000 Hz for 2.4 GHz, \
|
||||
regardless of the real channel)",
|
||||
(tensor.center_freq_hz - real_hz).abs() < 1e6
|
||||
);
|
||||
println!("bandwidth_hz: {:.0}", tensor.bandwidth_hz);
|
||||
println!("uncertainty (from real SNR): {:.3}", tensor.uncertainty);
|
||||
println!("device_id: {}", tensor.device_id);
|
||||
println!("timestamp_ns (now, for reference): {now_ns}");
|
||||
println!("No panic on real hardware data, including subcarrier counts != CANONICAL_BINS=56.");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("ADAPTER REJECTED real hardware data: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,978 @@
|
||||
//! Hardware adapters — vendor formats in, canonical [`RfTensor`] out
|
||||
//! (ADR-274 §2.3).
|
||||
//!
|
||||
//! Each adapter performs the same three-stage normalization so every
|
||||
//! downstream consumer sees hardware-invariant data:
|
||||
//!
|
||||
//! 1. **Layout**: reshape the vendor capture to `(links, bins, snapshots)`
|
||||
//! (for FMCW radar this includes the fast-time DFT to range bins), then
|
||||
//! resample both the bin and snapshot axes to
|
||||
//! [`CANONICAL_BINS`] × [`CANONICAL_SNAPSHOTS`].
|
||||
//! 2. **Amplitude**: divide each link by its median amplitude, removing
|
||||
//! front-end gain differences between chipsets (the offset is recorded in
|
||||
//! [`CalibrationMeta::gain_offset_db`] for provenance).
|
||||
//! 3. **Phase**: per (link, snapshot), remove the constant phase offset (CFO
|
||||
//! residual) and the linear ramp across bins (sampling-time offset) via
|
||||
//! least squares — unless the capture declares itself phase-calibrated.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ndarray::{Array3, Axis};
|
||||
use num_complex::Complex64;
|
||||
use wifi_densepose_core::types::{CsiFrame, FrequencyBand};
|
||||
|
||||
use crate::math::{linear_slope, median, resample_complex};
|
||||
use crate::tensor::{
|
||||
CalibrationMeta, LinkGeometry, RfModality, RfTensor, CANONICAL_BINS, CANONICAL_SNAPSHOTS,
|
||||
};
|
||||
use crate::{Result, UnifiedError};
|
||||
|
||||
/// A raw capture from some hardware front-end, before normalization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RawCapture {
|
||||
/// 802.11 CSI: one [`CsiFrame`] per temporal snapshot (all frames must
|
||||
/// share the spatial-stream count) plus per-stream geometry.
|
||||
WifiCsi {
|
||||
/// Snapshots, oldest first.
|
||||
frames: Vec<CsiFrame>,
|
||||
/// One entry per spatial stream (link).
|
||||
links: Vec<LinkGeometry>,
|
||||
/// Age of the oldest snapshot at capture handoff, seconds.
|
||||
age_s: f64,
|
||||
/// Clock quality in `[0, 1]`.
|
||||
clock_quality: f64,
|
||||
},
|
||||
/// FMCW radar cube `(rx_channels, fast_time_samples, chirps)` before the
|
||||
/// range FFT, plus chirp parameters.
|
||||
FmcwRadarCube {
|
||||
/// Raw ADC cube.
|
||||
cube: Array3<Complex64>,
|
||||
/// Per-RX-channel geometry.
|
||||
links: Vec<LinkGeometry>,
|
||||
/// Carrier centre frequency, Hz.
|
||||
center_freq_hz: f64,
|
||||
/// Sweep bandwidth, Hz.
|
||||
bandwidth_hz: f64,
|
||||
/// Age in seconds.
|
||||
age_s: f64,
|
||||
/// Device identifier.
|
||||
device_id: String,
|
||||
},
|
||||
/// UWB channel impulse response `(links, taps, repeats)`.
|
||||
UwbCir {
|
||||
/// CIR taps.
|
||||
taps: Array3<Complex64>,
|
||||
/// Per-link geometry.
|
||||
links: Vec<LinkGeometry>,
|
||||
/// Carrier centre frequency, Hz.
|
||||
center_freq_hz: f64,
|
||||
/// Bandwidth, Hz.
|
||||
bandwidth_hz: f64,
|
||||
/// Age in seconds.
|
||||
age_s: f64,
|
||||
/// Device identifier.
|
||||
device_id: String,
|
||||
},
|
||||
/// Bluetooth 6.0 Channel Sounding capture: per-frequency-step phase
|
||||
/// samples plus optional round-trip timing (ADR-281 §2). Phase-based
|
||||
/// ranging and RTT are **separate evidence sources** — see
|
||||
/// [`ble_cs_range`] for the agreement/divergence contract.
|
||||
BleCs {
|
||||
/// The channel-sounding frame.
|
||||
frame: BleCsFrame,
|
||||
/// Initiator→reflector link geometry (single link).
|
||||
links: Vec<LinkGeometry>,
|
||||
/// Age in seconds.
|
||||
age_s: f64,
|
||||
/// Device identifier.
|
||||
device_id: String,
|
||||
},
|
||||
/// 5G NR uplink SRS frequency response `(links, comb_res, symbols)` with
|
||||
/// a comb factor (only every `comb`-th subcarrier is sounded).
|
||||
CellularSrs {
|
||||
/// Comb-sampled frequency response.
|
||||
comb_res: Array3<Complex64>,
|
||||
/// SRS comb factor (2 or 4).
|
||||
comb: usize,
|
||||
/// Per-link geometry.
|
||||
links: Vec<LinkGeometry>,
|
||||
/// Carrier centre frequency, Hz.
|
||||
center_freq_hz: f64,
|
||||
/// Sounded bandwidth, Hz.
|
||||
bandwidth_hz: f64,
|
||||
/// Age in seconds.
|
||||
age_s: f64,
|
||||
/// Device identifier.
|
||||
device_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl RawCapture {
|
||||
/// Modality this capture belongs to.
|
||||
#[must_use]
|
||||
pub fn modality(&self) -> RfModality {
|
||||
match self {
|
||||
Self::WifiCsi { .. } => RfModality::WifiCsi,
|
||||
Self::FmcwRadarCube { .. } => RfModality::FmcwRadar,
|
||||
Self::UwbCir { .. } => RfModality::UwbCir,
|
||||
Self::BleCs { .. } => RfModality::BleCs,
|
||||
Self::CellularSrs { .. } => RfModality::CellularSrs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bluetooth Channel Sounding frame (ADR-281 §2): tone phases across
|
||||
/// frequency steps plus optional round-trip timing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BleCsFrame {
|
||||
/// Sounded frequencies, Hz (uniformly spaced, ascending).
|
||||
pub frequency_steps_hz: Vec<f64>,
|
||||
/// Measured round-trip tone phase at each step, radians (wrapped).
|
||||
pub phase_samples_rad: Vec<f64>,
|
||||
/// Round-trip time measurement, ns, when the mode includes RTT.
|
||||
pub round_trip_time_ns: Option<f64>,
|
||||
}
|
||||
|
||||
/// Anomaly classes when the two CS evidence sources disagree.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RangingAnomaly {
|
||||
/// Phase-based and RTT distances diverge beyond tolerance — multipath
|
||||
/// bias, a relay attack, a timing fault, or a calibration problem.
|
||||
Divergent,
|
||||
}
|
||||
|
||||
/// Distance evidence from one CS exchange. Phase-based ranging and RTT
|
||||
/// are deliberately separate: agreement raises confidence, disagreement
|
||||
/// is surfaced as an anomaly instead of silently averaged away.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RangingEvidence {
|
||||
/// Distance from the unwrapped phase-vs-frequency slope, metres.
|
||||
pub phase_distance_m: f64,
|
||||
/// Distance from round-trip timing, metres (when measured).
|
||||
pub rtt_distance_m: Option<f64>,
|
||||
/// Whether the two sources agree within tolerance.
|
||||
pub agreement: bool,
|
||||
/// Confidence in `[0, 1]` (decays with divergence).
|
||||
pub confidence: f64,
|
||||
/// Set when the sources diverge.
|
||||
pub anomaly: Option<RangingAnomaly>,
|
||||
}
|
||||
|
||||
/// Agreement tolerance between phase and RTT distances, metres.
|
||||
const CS_AGREEMENT_TOLERANCE_M: f64 = 0.5;
|
||||
|
||||
/// Extracts ranging evidence from a CS frame.
|
||||
///
|
||||
/// Physics: the round-trip tone phase is `θ(f) = −4π·f·d/c (mod 2π)`, so
|
||||
/// the unwrapped slope gives `d = |dθ/df|·c/(4π)`. RTT gives
|
||||
/// `d = rtt·c/2` independently. Cross-validation is the security value:
|
||||
/// a relay attack that defeats one mechanism generally cannot fake both
|
||||
/// consistently.
|
||||
pub fn ble_cs_range(frame: &BleCsFrame) -> Result<RangingEvidence> {
|
||||
let n = frame.frequency_steps_hz.len();
|
||||
if n < 2 || frame.phase_samples_rad.len() != n {
|
||||
return Err(UnifiedError::ShapeMismatch(format!(
|
||||
"CS frame needs >= 2 steps with matching phases, got {n} steps / {} phases",
|
||||
frame.phase_samples_rad.len()
|
||||
)));
|
||||
}
|
||||
// Boundary validation BEFORE unwrapping. Two DoS classes were found
|
||||
// here by `tests/security_boundaries.rs` property testing:
|
||||
// (a) a non-finite phase makes a loop-based unwrap spin forever
|
||||
// (+inf minus anything stays +inf);
|
||||
// (b) a *finite but huge* phase (e.g. 1e300 rad) makes a loop-based
|
||||
// unwrap take O(|Δ|/2π) ≈ 1e299 iterations.
|
||||
// Defense: reject implausible values (a tone phase is physically
|
||||
// meaningful mod 2π; |p| > 1e6 rad is garbage), and unwrap in O(1)
|
||||
// via modular arithmetic instead of a loop.
|
||||
const MAX_PLAUSIBLE_PHASE_RAD: f64 = 1e6;
|
||||
if frame
|
||||
.phase_samples_rad
|
||||
.iter()
|
||||
.any(|p| !p.is_finite() || p.abs() > MAX_PLAUSIBLE_PHASE_RAD)
|
||||
|| frame.frequency_steps_hz.iter().any(|f| !f.is_finite())
|
||||
{
|
||||
return Err(UnifiedError::InvalidInput(
|
||||
"CS frame contains non-finite or implausible phase/frequency samples".into(),
|
||||
));
|
||||
}
|
||||
if let Some(rtt) = frame.round_trip_time_ns {
|
||||
if !rtt.is_finite() {
|
||||
return Err(UnifiedError::InvalidInput("non-finite round-trip time".into()));
|
||||
}
|
||||
}
|
||||
let df = frame.frequency_steps_hz[1] - frame.frequency_steps_hz[0];
|
||||
if !(df.is_finite() && df > 0.0) {
|
||||
return Err(UnifiedError::InvalidInput("frequency steps must ascend uniformly".into()));
|
||||
}
|
||||
// O(1) unwrap per step: shift each raw phase by the multiple of 2π
|
||||
// that lands it within ±π of its predecessor.
|
||||
let tau = 2.0 * std::f64::consts::PI;
|
||||
let mut unwrapped = Vec::with_capacity(n);
|
||||
let mut prev = frame.phase_samples_rad[0];
|
||||
unwrapped.push(prev);
|
||||
for &p in &frame.phase_samples_rad[1..] {
|
||||
let delta = (p - prev + std::f64::consts::PI).rem_euclid(tau) - std::f64::consts::PI;
|
||||
let v = prev + delta;
|
||||
unwrapped.push(v);
|
||||
prev = v;
|
||||
}
|
||||
let slope_per_step = crate::math::linear_slope(&unwrapped);
|
||||
let c = 299_792_458.0;
|
||||
let phase_distance_m = (slope_per_step / df).abs() * c / (4.0 * std::f64::consts::PI);
|
||||
|
||||
let rtt_distance_m = frame.round_trip_time_ns.map(|rtt| rtt * 1e-9 * c / 2.0);
|
||||
let (agreement, confidence, anomaly) = match rtt_distance_m {
|
||||
None => (true, 0.5, None), // single-source evidence: capped confidence
|
||||
Some(d_rtt) => {
|
||||
let divergence = (phase_distance_m - d_rtt).abs();
|
||||
if divergence <= CS_AGREEMENT_TOLERANCE_M {
|
||||
(true, (1.0 - divergence / CS_AGREEMENT_TOLERANCE_M).mul_add(0.5, 0.5), None)
|
||||
} else {
|
||||
(false, (-divergence).exp().min(0.2), Some(RangingAnomaly::Divergent))
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(RangingEvidence { phase_distance_m, rtt_distance_m, agreement, confidence, anomaly })
|
||||
}
|
||||
|
||||
/// A hardware adapter: normalizes one family of raw captures into the
|
||||
/// canonical tensor. Object-safe so the registry can hold heterogeneous
|
||||
/// adapters behind one interface.
|
||||
pub trait RfAdapter: Send + Sync {
|
||||
/// Modality this adapter accepts.
|
||||
fn modality(&self) -> RfModality;
|
||||
/// Stable hardware identifier, e.g. `"esp32s3-csi"`, `"iwl5300"`.
|
||||
fn hardware_id(&self) -> &str;
|
||||
/// Normalize a raw capture into the canonical tensor.
|
||||
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor>;
|
||||
}
|
||||
|
||||
/// Shared stage 1–3 pipeline: resample to canonical dims, per-link median
|
||||
/// amplitude normalization, per-(link, snapshot) phase detrend.
|
||||
///
|
||||
/// Crate-visible so [`crate::frame::RfFrameV2::to_canonical`] derives the
|
||||
/// compatibility view through the exact same code path as every adapter
|
||||
/// (ADR-279 §3 — one normalization, many entry points).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn normalize_grid(
|
||||
modality: RfModality,
|
||||
mut grid: Array3<Complex64>, // (links, bins, snapshots) at source resolution
|
||||
links: Vec<LinkGeometry>,
|
||||
center_freq_hz: f64,
|
||||
bandwidth_hz: f64,
|
||||
age_s: f64,
|
||||
timestamp_ns: u64,
|
||||
device_id: String,
|
||||
clock_quality: f64,
|
||||
uncertainty: f64,
|
||||
phase_calibrated: bool,
|
||||
) -> Result<RfTensor> {
|
||||
let (n_links, n_bins, n_snaps) = grid.dim();
|
||||
if n_links == 0 || n_bins == 0 || n_snaps == 0 {
|
||||
return Err(UnifiedError::ShapeMismatch("empty raw capture".into()));
|
||||
}
|
||||
|
||||
// Stage 1: resample bin axis then snapshot axis to canonical dims.
|
||||
if n_bins != CANONICAL_BINS {
|
||||
let mut resampled = Array3::zeros((n_links, CANONICAL_BINS, n_snaps));
|
||||
for l in 0..n_links {
|
||||
for s in 0..n_snaps {
|
||||
let col: Vec<Complex64> = (0..n_bins).map(|b| grid[[l, b, s]]).collect();
|
||||
for (b, v) in resample_complex(&col, CANONICAL_BINS).into_iter().enumerate() {
|
||||
resampled[[l, b, s]] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
grid = resampled;
|
||||
}
|
||||
let n_snaps_now = grid.dim().2;
|
||||
if n_snaps_now != CANONICAL_SNAPSHOTS {
|
||||
let mut resampled = Array3::zeros((n_links, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
for l in 0..n_links {
|
||||
for b in 0..CANONICAL_BINS {
|
||||
let row: Vec<Complex64> = (0..n_snaps_now).map(|s| grid[[l, b, s]]).collect();
|
||||
for (s, v) in resample_complex(&row, CANONICAL_SNAPSHOTS).into_iter().enumerate() {
|
||||
resampled[[l, b, s]] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
grid = resampled;
|
||||
}
|
||||
|
||||
// Stage 2: per-link median amplitude normalization (gain invariance).
|
||||
let mut gain_offset_db = 0.0;
|
||||
for l in 0..n_links {
|
||||
let amps: Vec<f64> = grid.index_axis(Axis(0), l).iter().map(|z| z.norm()).collect();
|
||||
let med = median(&s);
|
||||
if med > 0.0 {
|
||||
gain_offset_db += -20.0 * med.log10();
|
||||
grid.index_axis_mut(Axis(0), l).mapv_inplace(|z| z / med);
|
||||
}
|
||||
}
|
||||
gain_offset_db /= n_links as f64;
|
||||
|
||||
// Stage 3: phase sanitization — remove per-(link, snapshot) constant
|
||||
// offset and linear ramp across bins (CFO residual + sampling-time
|
||||
// offset). Skipped when the front-end already phase-calibrates.
|
||||
if !phase_calibrated {
|
||||
for l in 0..n_links {
|
||||
for s in 0..CANONICAL_SNAPSHOTS {
|
||||
// Unwrap phase across bins before fitting the ramp.
|
||||
let mut phases = Vec::with_capacity(CANONICAL_BINS);
|
||||
let mut prev = grid[[l, 0, s]].arg();
|
||||
phases.push(prev);
|
||||
for b in 1..CANONICAL_BINS {
|
||||
let mut p = grid[[l, b, s]].arg();
|
||||
while p - prev > std::f64::consts::PI {
|
||||
p -= 2.0 * std::f64::consts::PI;
|
||||
}
|
||||
while p - prev < -std::f64::consts::PI {
|
||||
p += 2.0 * std::f64::consts::PI;
|
||||
}
|
||||
phases.push(p);
|
||||
prev = p;
|
||||
}
|
||||
let slope = linear_slope(&phases);
|
||||
let mean = phases.iter().sum::<f64>() / CANONICAL_BINS as f64;
|
||||
let mid = (CANONICAL_BINS as f64 - 1.0) / 2.0;
|
||||
for b in 0..CANONICAL_BINS {
|
||||
let correction = mean + slope * (b as f64 - mid);
|
||||
let rot = Complex64::new(correction.cos(), -correction.sin());
|
||||
grid[[l, b, s]] *= rot;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RfTensor::new(
|
||||
modality,
|
||||
center_freq_hz,
|
||||
bandwidth_hz,
|
||||
grid,
|
||||
links,
|
||||
age_s,
|
||||
timestamp_ns,
|
||||
device_id,
|
||||
clock_quality,
|
||||
uncertainty,
|
||||
CalibrationMeta {
|
||||
clock_ppm: (1.0 - clock_quality) * 40.0,
|
||||
phase_calibrated: true, // after stage 3 the tensor is detrended
|
||||
gain_offset_db,
|
||||
baseline_id: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 802.11 CSI adapter (ESP32-S3 / Intel-style spatial-stream CSI).
|
||||
pub struct WifiCsiAdapter {
|
||||
hardware_id: String,
|
||||
}
|
||||
|
||||
impl WifiCsiAdapter {
|
||||
/// New adapter for the given hardware id (e.g. `"esp32s3-csi"`).
|
||||
#[must_use]
|
||||
pub fn new(hardware_id: impl Into<String>) -> Self {
|
||||
Self { hardware_id: hardware_id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// IEEE 802.11 channel-number → center-frequency, in MHz.
|
||||
///
|
||||
/// `CsiMetadata::frequency_band` alone only identifies which ~100 MHz-wide
|
||||
/// band a capture came from (a fixed per-band constant), not the actual
|
||||
/// channel — using the band constant directly misreports every channel
|
||||
/// except the one it happens to match (2.4 GHz channel 6, 5 GHz channel 36),
|
||||
/// by up to tens of MHz on 2.4 GHz and hundreds of MHz on 5/6 GHz. Falls back
|
||||
/// to the band constant only when the channel number is out of the known
|
||||
/// range (e.g. `0`, meaning "unknown").
|
||||
fn channel_center_freq_mhz(band: FrequencyBand, channel: u8) -> f64 {
|
||||
match band {
|
||||
FrequencyBand::Band2_4GHz => match channel {
|
||||
1..=13 => 2407.0 + 5.0 * f64::from(channel),
|
||||
14 => 2484.0,
|
||||
_ => f64::from(band.center_frequency_mhz()),
|
||||
},
|
||||
FrequencyBand::Band5GHz if channel > 0 => 5000.0 + 5.0 * f64::from(channel),
|
||||
FrequencyBand::Band6GHz if channel > 0 => 5950.0 + 5.0 * f64::from(channel),
|
||||
FrequencyBand::Band5GHz | FrequencyBand::Band6GHz => f64::from(band.center_frequency_mhz()),
|
||||
}
|
||||
}
|
||||
|
||||
impl RfAdapter for WifiCsiAdapter {
|
||||
fn modality(&self) -> RfModality {
|
||||
RfModality::WifiCsi
|
||||
}
|
||||
|
||||
fn hardware_id(&self) -> &str {
|
||||
&self.hardware_id
|
||||
}
|
||||
|
||||
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
|
||||
let RawCapture::WifiCsi { frames, links, age_s, clock_quality } = raw else {
|
||||
return Err(UnifiedError::ModalityMismatch {
|
||||
adapter: self.hardware_id.clone(),
|
||||
got: raw.modality(),
|
||||
});
|
||||
};
|
||||
if frames.is_empty() {
|
||||
return Err(UnifiedError::ShapeMismatch("no CSI frames".into()));
|
||||
}
|
||||
let n_links = frames[0].num_spatial_streams();
|
||||
let n_bins = frames[0].num_subcarriers();
|
||||
for f in frames {
|
||||
if f.num_spatial_streams() != n_links || f.num_subcarriers() != n_bins {
|
||||
return Err(UnifiedError::ShapeMismatch(
|
||||
"inconsistent CSI frame shapes within window".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut grid = Array3::zeros((n_links, n_bins, frames.len()));
|
||||
for (s, f) in frames.iter().enumerate() {
|
||||
for l in 0..n_links {
|
||||
for b in 0..n_bins {
|
||||
grid[[l, b, s]] = f.data[[l, b]];
|
||||
}
|
||||
}
|
||||
}
|
||||
let meta = &frames[0].metadata;
|
||||
// SNR → uncertainty: 0 dB SNR ⇒ 1.0 (noise floor), ≥ 40 dB ⇒ 0.0.
|
||||
let snr_db =
|
||||
frames.iter().map(|f| f.metadata.snr_db()).sum::<f64>() / frames.len() as f64;
|
||||
let uncertainty = (1.0 - snr_db / 40.0).clamp(0.0, 1.0);
|
||||
let center_freq_hz = channel_center_freq_mhz(meta.frequency_band, meta.channel) * 1e6;
|
||||
let ts = &meta.timestamp;
|
||||
let timestamp_ns = u64::try_from(ts.seconds).unwrap_or(0) * 1_000_000_000 + u64::from(ts.nanos);
|
||||
normalize_grid(
|
||||
RfModality::WifiCsi,
|
||||
grid,
|
||||
links.clone(),
|
||||
center_freq_hz,
|
||||
f64::from(meta.bandwidth_mhz) * 1e6,
|
||||
*age_s,
|
||||
timestamp_ns,
|
||||
meta.device_id.as_str().to_string(),
|
||||
*clock_quality,
|
||||
uncertainty,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// FMCW radar adapter: fast-time DFT to range bins, then shared pipeline.
|
||||
pub struct FmcwRadarAdapter {
|
||||
hardware_id: String,
|
||||
}
|
||||
|
||||
impl FmcwRadarAdapter {
|
||||
/// New adapter for the given radar front-end id (e.g. `"mr60bha2"`).
|
||||
#[must_use]
|
||||
pub fn new(hardware_id: impl Into<String>) -> Self {
|
||||
Self { hardware_id: hardware_id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RfAdapter for FmcwRadarAdapter {
|
||||
fn modality(&self) -> RfModality {
|
||||
RfModality::FmcwRadar
|
||||
}
|
||||
|
||||
fn hardware_id(&self) -> &str {
|
||||
&self.hardware_id
|
||||
}
|
||||
|
||||
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
|
||||
let RawCapture::FmcwRadarCube { cube, links, center_freq_hz, bandwidth_hz, age_s, device_id } =
|
||||
raw
|
||||
else {
|
||||
return Err(UnifiedError::ModalityMismatch {
|
||||
adapter: self.hardware_id.clone(),
|
||||
got: raw.modality(),
|
||||
});
|
||||
};
|
||||
let (n_rx, n_fast, n_chirps) = cube.dim();
|
||||
if n_rx == 0 || n_fast == 0 || n_chirps == 0 {
|
||||
return Err(UnifiedError::ShapeMismatch("empty radar cube".into()));
|
||||
}
|
||||
// Fast-time DFT: beat-frequency bin b ↔ range bin b. Positive-range
|
||||
// half only (b < n_fast/2) mirrors what commercial FMCW parts export.
|
||||
let n_range = (n_fast / 2).max(1);
|
||||
let mut grid = Array3::zeros((n_rx, n_range, n_chirps));
|
||||
for l in 0..n_rx {
|
||||
for c in 0..n_chirps {
|
||||
for b in 0..n_range {
|
||||
let mut acc = Complex64::new(0.0, 0.0);
|
||||
for t in 0..n_fast {
|
||||
let ang =
|
||||
-2.0 * std::f64::consts::PI * (b as f64) * (t as f64) / (n_fast as f64);
|
||||
acc += cube[[l, t, c]] * Complex64::new(ang.cos(), ang.sin());
|
||||
}
|
||||
grid[[l, b, c]] = acc / n_fast as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Range profiles are already phase-meaningful per bin; the linear
|
||||
// detrend would erase target range information, so radar declares
|
||||
// itself phase-calibrated and only amplitude-normalizes.
|
||||
normalize_grid(
|
||||
RfModality::FmcwRadar,
|
||||
grid,
|
||||
links.clone(),
|
||||
*center_freq_hz,
|
||||
*bandwidth_hz,
|
||||
*age_s,
|
||||
0,
|
||||
device_id.clone(),
|
||||
0.9,
|
||||
0.1,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// UWB CIR adapter: taps are already a delay-domain profile.
|
||||
pub struct UwbCirAdapter {
|
||||
hardware_id: String,
|
||||
}
|
||||
|
||||
impl UwbCirAdapter {
|
||||
/// New adapter for the given UWB chip id (e.g. `"dw3000"`).
|
||||
#[must_use]
|
||||
pub fn new(hardware_id: impl Into<String>) -> Self {
|
||||
Self { hardware_id: hardware_id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RfAdapter for UwbCirAdapter {
|
||||
fn modality(&self) -> RfModality {
|
||||
RfModality::UwbCir
|
||||
}
|
||||
|
||||
fn hardware_id(&self) -> &str {
|
||||
&self.hardware_id
|
||||
}
|
||||
|
||||
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
|
||||
let RawCapture::UwbCir { taps, links, center_freq_hz, bandwidth_hz, age_s, device_id } = raw
|
||||
else {
|
||||
return Err(UnifiedError::ModalityMismatch {
|
||||
adapter: self.hardware_id.clone(),
|
||||
got: raw.modality(),
|
||||
});
|
||||
};
|
||||
normalize_grid(
|
||||
RfModality::UwbCir,
|
||||
taps.clone(),
|
||||
links.clone(),
|
||||
*center_freq_hz,
|
||||
*bandwidth_hz,
|
||||
*age_s,
|
||||
0,
|
||||
device_id.clone(),
|
||||
0.95, // UWB timestamps are hardware-disciplined
|
||||
0.05,
|
||||
true, // delay-domain taps: detrending would destroy ToF structure
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 5G NR SRS adapter: de-comb by interpolating the unsounded subcarriers.
|
||||
pub struct CellularSrsAdapter {
|
||||
hardware_id: String,
|
||||
}
|
||||
|
||||
impl CellularSrsAdapter {
|
||||
/// New adapter for the given gNB/xApp source id (e.g. `"oai-srs-xapp"`).
|
||||
#[must_use]
|
||||
pub fn new(hardware_id: impl Into<String>) -> Self {
|
||||
Self { hardware_id: hardware_id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RfAdapter for CellularSrsAdapter {
|
||||
fn modality(&self) -> RfModality {
|
||||
RfModality::CellularSrs
|
||||
}
|
||||
|
||||
fn hardware_id(&self) -> &str {
|
||||
&self.hardware_id
|
||||
}
|
||||
|
||||
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
|
||||
let RawCapture::CellularSrs {
|
||||
comb_res,
|
||||
comb,
|
||||
links,
|
||||
center_freq_hz,
|
||||
bandwidth_hz,
|
||||
age_s,
|
||||
device_id,
|
||||
} = raw
|
||||
else {
|
||||
return Err(UnifiedError::ModalityMismatch {
|
||||
adapter: self.hardware_id.clone(),
|
||||
got: raw.modality(),
|
||||
});
|
||||
};
|
||||
if *comb == 0 {
|
||||
return Err(UnifiedError::InvalidInput("SRS comb factor must be >= 1".into()));
|
||||
}
|
||||
let (n_links, n_res, n_sym) = comb_res.dim();
|
||||
if n_links == 0 || n_res == 0 || n_sym == 0 {
|
||||
return Err(UnifiedError::ShapeMismatch("empty SRS capture".into()));
|
||||
}
|
||||
// De-comb: the comb-sampled response is a uniform subsampling of the
|
||||
// full band, so linear interpolation onto comb×n_res bins restores a
|
||||
// dense grid before canonical resampling.
|
||||
let dense_bins = n_res * comb;
|
||||
let mut grid = Array3::zeros((n_links, dense_bins, n_sym));
|
||||
for l in 0..n_links {
|
||||
for s in 0..n_sym {
|
||||
let col: Vec<Complex64> = (0..n_res).map(|b| comb_res[[l, b, s]]).collect();
|
||||
for (b, v) in resample_complex(&col, dense_bins).into_iter().enumerate() {
|
||||
grid[[l, b, s]] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
normalize_grid(
|
||||
RfModality::CellularSrs,
|
||||
grid,
|
||||
links.clone(),
|
||||
*center_freq_hz,
|
||||
*bandwidth_hz,
|
||||
*age_s,
|
||||
0,
|
||||
device_id.clone(),
|
||||
0.99, // gNB reference clock
|
||||
0.1,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bluetooth Channel Sounding adapter: tone phasors over frequency steps
|
||||
/// become the canonical bin axis (delay structure preserved — the ranging
|
||||
/// ramp *is* the signal, so phase detrending is skipped).
|
||||
pub struct BleCsAdapter {
|
||||
hardware_id: String,
|
||||
}
|
||||
|
||||
impl BleCsAdapter {
|
||||
/// New adapter for the given CS radio id (e.g. `"nrf54-cs"`).
|
||||
#[must_use]
|
||||
pub fn new(hardware_id: impl Into<String>) -> Self {
|
||||
Self { hardware_id: hardware_id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RfAdapter for BleCsAdapter {
|
||||
fn modality(&self) -> RfModality {
|
||||
RfModality::BleCs
|
||||
}
|
||||
|
||||
fn hardware_id(&self) -> &str {
|
||||
&self.hardware_id
|
||||
}
|
||||
|
||||
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
|
||||
let RawCapture::BleCs { frame, links, age_s, device_id } = raw else {
|
||||
return Err(UnifiedError::ModalityMismatch {
|
||||
adapter: self.hardware_id.clone(),
|
||||
got: raw.modality(),
|
||||
});
|
||||
};
|
||||
let n = frame.frequency_steps_hz.len();
|
||||
if n < 2 || frame.phase_samples_rad.len() != n {
|
||||
return Err(UnifiedError::ShapeMismatch("malformed CS frame".into()));
|
||||
}
|
||||
let mut grid = Array3::zeros((1, n, 1));
|
||||
for (b, theta) in frame.phase_samples_rad.iter().enumerate() {
|
||||
grid[[0, b, 0]] = Complex64::from_polar(1.0, *theta);
|
||||
}
|
||||
let centre = (frame.frequency_steps_hz[0] + frame.frequency_steps_hz[n - 1]) / 2.0;
|
||||
let bandwidth = frame.frequency_steps_hz[n - 1] - frame.frequency_steps_hz[0];
|
||||
normalize_grid(
|
||||
RfModality::BleCs,
|
||||
grid,
|
||||
links.clone(),
|
||||
centre,
|
||||
bandwidth.max(1.0),
|
||||
*age_s,
|
||||
0,
|
||||
device_id.clone(),
|
||||
0.9,
|
||||
0.15,
|
||||
true, // the phase ramp is the measurement; never detrend it
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry mapping hardware ids to adapters (ADR-274 §2.4). Lookup is
|
||||
/// fail-closed: unknown hardware is an error, never a silent default.
|
||||
#[derive(Default)]
|
||||
pub struct AdapterRegistry {
|
||||
adapters: HashMap<String, Box<dyn RfAdapter>>,
|
||||
}
|
||||
|
||||
impl AdapterRegistry {
|
||||
/// Empty registry.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registry pre-populated with the four reference adapters.
|
||||
#[must_use]
|
||||
pub fn with_reference_adapters() -> Self {
|
||||
let mut r = Self::new();
|
||||
r.register(Box::new(WifiCsiAdapter::new("esp32s3-csi")));
|
||||
r.register(Box::new(FmcwRadarAdapter::new("mr60bha2")));
|
||||
r.register(Box::new(UwbCirAdapter::new("dw3000")));
|
||||
r.register(Box::new(CellularSrsAdapter::new("oai-srs-xapp")));
|
||||
r.register(Box::new(BleCsAdapter::new("nrf54-cs")));
|
||||
r
|
||||
}
|
||||
|
||||
/// Registers an adapter under its hardware id (replaces any previous).
|
||||
pub fn register(&mut self, adapter: Box<dyn RfAdapter>) {
|
||||
self.adapters.insert(adapter.hardware_id().to_string(), adapter);
|
||||
}
|
||||
|
||||
/// Normalizes a capture with the adapter registered for `hardware_id`.
|
||||
pub fn normalize(&self, hardware_id: &str, raw: &RawCapture) -> Result<RfTensor> {
|
||||
self.adapters
|
||||
.get(hardware_id)
|
||||
.ok_or_else(|| UnifiedError::UnknownHardware(hardware_id.to_string()))?
|
||||
.normalize(raw)
|
||||
}
|
||||
|
||||
/// Registered hardware ids (sorted, for deterministic display).
|
||||
#[must_use]
|
||||
pub fn hardware_ids(&self) -> Vec<&str> {
|
||||
let mut ids: Vec<&str> = self.adapters.keys().map(String::as_str).collect();
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ndarray::Array2;
|
||||
use wifi_densepose_core::types::{CsiMetadata, DeviceId, FrequencyBand};
|
||||
|
||||
fn test_links(n: usize) -> Vec<LinkGeometry> {
|
||||
(0..n)
|
||||
.map(|i| LinkGeometry {
|
||||
tx_pos: [0.0, 0.0, 2.0],
|
||||
rx_pos: [4.0, i as f64 * 0.05, 2.0],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// CSI frames with a known linear phase ramp + constant offset and a
|
||||
/// gain factor — exactly what stages 2–3 must remove.
|
||||
fn ramped_frames(n_frames: usize, n_streams: usize, n_sub: usize) -> Vec<CsiFrame> {
|
||||
(0..n_frames)
|
||||
.map(|s| {
|
||||
let meta = CsiMetadata::new(
|
||||
DeviceId::new("esp32s3-a1"),
|
||||
FrequencyBand::Band2_4GHz,
|
||||
6,
|
||||
);
|
||||
let data = Array2::from_shape_fn((n_streams, n_sub), |(l, b)| {
|
||||
let gain = 3.7 * (1.0 + l as f64);
|
||||
let phase = 0.9 + 0.11 * b as f64 + 0.01 * s as f64;
|
||||
Complex64::new(0.0, phase).exp() * gain
|
||||
});
|
||||
CsiFrame::new(meta, data)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wifi_adapter_normalizes_shape_gain_and_phase() {
|
||||
let adapter = WifiCsiAdapter::new("esp32s3-csi");
|
||||
let raw = RawCapture::WifiCsi {
|
||||
frames: ramped_frames(12, 2, 114),
|
||||
links: test_links(2),
|
||||
age_s: 0.02,
|
||||
clock_quality: 0.7,
|
||||
};
|
||||
let t = adapter.normalize(&raw).expect("normalizes");
|
||||
assert_eq!(t.dims(), (2, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
assert_eq!(t.modality, RfModality::WifiCsi);
|
||||
|
||||
// Gain invariance: median amplitude per link ≈ 1 after stage 2.
|
||||
for l in 0..2 {
|
||||
let amps: Vec<f64> =
|
||||
t.data.index_axis(Axis(0), l).iter().map(|z| z.norm()).collect();
|
||||
assert!((median(&s) - 1.0).abs() < 1e-9, "link {l} median {:?}", median(&s));
|
||||
}
|
||||
|
||||
// Phase sanitization: the constant-plus-ramp phase must be gone.
|
||||
// Bound is 1e-4 rad: complex linear resampling (114→56 bins,
|
||||
// 12→8 snapshots) leaves second-order chord-vs-arc phase residue
|
||||
// of a few µrad on top of the exact detrend.
|
||||
for l in 0..2 {
|
||||
for s in 0..CANONICAL_SNAPSHOTS {
|
||||
for b in 0..CANONICAL_BINS {
|
||||
assert!(
|
||||
t.data[[l, b, s]].arg().abs() < 1e-4,
|
||||
"residual phase at ({l},{b},{s}): {}",
|
||||
t.data[[l, b, s]].arg()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radar_adapter_localizes_beat_tone_to_range_bin() {
|
||||
// Beat tone at fast-time bin 9 of 64 ⇒ range profile peak at bin 9,
|
||||
// which the canonical resampler maps to 9 · (56−1)/(32−1) ≈ 16.
|
||||
let n_fast = 64;
|
||||
let cube = Array3::from_shape_fn((1, n_fast, 16), |(_, t, _)| {
|
||||
let ang = 2.0 * std::f64::consts::PI * 9.0 * t as f64 / n_fast as f64;
|
||||
Complex64::new(ang.cos(), ang.sin())
|
||||
});
|
||||
let adapter = FmcwRadarAdapter::new("mr60bha2");
|
||||
let raw = RawCapture::FmcwRadarCube {
|
||||
cube,
|
||||
links: test_links(1),
|
||||
center_freq_hz: 60e9,
|
||||
bandwidth_hz: 1e9,
|
||||
age_s: 0.0,
|
||||
device_id: "mr60".into(),
|
||||
};
|
||||
let t = adapter.normalize(&raw).expect("normalizes");
|
||||
assert_eq!(t.dims(), (1, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
let amps: Vec<f64> =
|
||||
(0..CANONICAL_BINS).map(|b| t.data[[0, b, 0]].norm()).collect();
|
||||
let peak = amps
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.unwrap()
|
||||
.0;
|
||||
let expected = (9.0 * (CANONICAL_BINS as f64 - 1.0) / 31.0).round() as usize;
|
||||
assert!(
|
||||
peak.abs_diff(expected) <= 1,
|
||||
"range peak at bin {peak}, expected ≈{expected}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srs_adapter_decombs_and_normalizes() {
|
||||
let adapter = CellularSrsAdapter::new("oai-srs-xapp");
|
||||
let comb_res = Array3::from_shape_fn((1, 24, 4), |(_, b, _)| {
|
||||
Complex64::new(1.0 + 0.01 * b as f64, 0.0)
|
||||
});
|
||||
let raw = RawCapture::CellularSrs {
|
||||
comb_res,
|
||||
comb: 2,
|
||||
links: test_links(1),
|
||||
center_freq_hz: 3.5e9,
|
||||
bandwidth_hz: 40e6,
|
||||
age_s: 0.001,
|
||||
device_id: "gnb-1".into(),
|
||||
};
|
||||
let t = adapter.normalize(&raw).expect("normalizes");
|
||||
assert_eq!(t.dims(), (1, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
assert_eq!(t.modality, RfModality::CellularSrs);
|
||||
}
|
||||
|
||||
/// Synthesizes CS phases for a known distance: θ(f) = −4π·f·d/c.
|
||||
fn cs_frame(distance_m: f64, rtt_ns: Option<f64>) -> BleCsFrame {
|
||||
let c = 299_792_458.0;
|
||||
let steps: Vec<f64> = (0..40).map(|k| 2.402e9 + 1e6 * k as f64).collect();
|
||||
let phases: Vec<f64> = steps
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let theta = -4.0 * std::f64::consts::PI * f * distance_m / c;
|
||||
theta.rem_euclid(2.0 * std::f64::consts::PI)
|
||||
})
|
||||
.collect();
|
||||
BleCsFrame { frequency_steps_hz: steps, phase_samples_rad: phases, round_trip_time_ns: rtt_ns }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ble_cs_phase_ranging_recovers_exact_distance() {
|
||||
let c = 299_792_458.0;
|
||||
for d in [1.5, 5.0, 12.0] {
|
||||
let rtt_ns = 2.0 * d / c * 1e9;
|
||||
let ev = ble_cs_range(&cs_frame(d, Some(rtt_ns))).expect("evidence");
|
||||
assert!(
|
||||
(ev.phase_distance_m - d).abs() < 1e-6,
|
||||
"phase ranging {} vs true {d}",
|
||||
ev.phase_distance_m
|
||||
);
|
||||
assert!((ev.rtt_distance_m.unwrap() - d).abs() < 1e-9);
|
||||
assert!(ev.agreement, "consistent sources must agree");
|
||||
assert!(ev.confidence > 0.9);
|
||||
assert!(ev.anomaly.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ble_cs_flags_relay_style_divergence_instead_of_averaging() {
|
||||
// Phase says 5 m; a relay/timing fault inflates RTT to ~51 m.
|
||||
let ev = ble_cs_range(&cs_frame(5.0, Some(340.0))).expect("evidence");
|
||||
assert!((ev.phase_distance_m - 5.0).abs() < 1e-6);
|
||||
assert!(ev.rtt_distance_m.unwrap() > 50.0);
|
||||
assert!(!ev.agreement);
|
||||
assert_eq!(ev.anomaly, Some(RangingAnomaly::Divergent));
|
||||
assert!(ev.confidence < 0.25, "divergent evidence must not be trusted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ble_cs_adapter_produces_canonical_tensor() {
|
||||
let adapter = BleCsAdapter::new("nrf54-cs");
|
||||
let raw = RawCapture::BleCs {
|
||||
frame: cs_frame(3.0, None),
|
||||
links: test_links(1),
|
||||
age_s: 0.01,
|
||||
device_id: "nrf54-a".into(),
|
||||
};
|
||||
let t = adapter.normalize(&raw).expect("normalizes");
|
||||
assert_eq!(t.dims(), (1, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
assert_eq!(t.modality, RfModality::BleCs);
|
||||
// The ranging ramp must survive (no detrend): phase varies across bins.
|
||||
let p0 = t.data[[0, 0, 0]].arg();
|
||||
let p_mid = t.data[[0, CANONICAL_BINS / 2, 0]].arg();
|
||||
assert!((p0 - p_mid).abs() > 1e-3, "phase ramp must be preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_is_fail_closed_and_type_safe() {
|
||||
let registry = AdapterRegistry::with_reference_adapters();
|
||||
assert_eq!(
|
||||
registry.hardware_ids(),
|
||||
vec!["dw3000", "esp32s3-csi", "mr60bha2", "nrf54-cs", "oai-srs-xapp"]
|
||||
);
|
||||
|
||||
// Unknown hardware ⇒ error, never a default adapter.
|
||||
let raw = RawCapture::UwbCir {
|
||||
taps: Array3::from_elem((1, 32, 4), Complex64::new(1.0, 0.0)),
|
||||
links: test_links(1),
|
||||
center_freq_hz: 6.5e9,
|
||||
bandwidth_hz: 500e6,
|
||||
age_s: 0.0,
|
||||
device_id: "dw".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
registry.normalize("unknown-chip", &raw),
|
||||
Err(UnifiedError::UnknownHardware(_))
|
||||
));
|
||||
|
||||
// Wrong modality for the adapter ⇒ typed mismatch error.
|
||||
assert!(matches!(
|
||||
registry.normalize("esp32s3-csi", &raw),
|
||||
Err(UnifiedError::ModalityMismatch { .. })
|
||||
));
|
||||
|
||||
// Right adapter succeeds.
|
||||
assert!(registry.normalize("dw3000", &raw).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
//! Programmable perception — the active sensing control plane (ADR-280).
|
||||
//!
|
||||
//! The shift this module implements: from *passive* sensing (accept
|
||||
//! whatever measurements arrive) to *programmable* perception (the system
|
||||
//! chooses where, when, how, and at what fidelity to sense, then resolves
|
||||
//! uncertainty deliberately). Five contracts:
|
||||
//!
|
||||
//! 1. [`SensingTask`] — the evidence-aware task contract (ETSI ISAC
|
||||
//! sensing-task vocabulary: purpose, area, resolution, latency,
|
||||
//! confidence, retention, consumers, consent).
|
||||
//! 2. [`SensingAction`] + [`InformationGoal`] — a request to actively
|
||||
//! gather evidence against a hypothesis, bounded by latency, energy,
|
||||
//! and a privacy ceiling.
|
||||
//! 3. [`ActiveSensingPlanner`] over [`SpatialStateFreshness`] — age-of-
|
||||
//! information scheduling: refresh what is stale, changing, and
|
||||
//! important, not everything uniformly.
|
||||
//! 4. [`CoherentSensorGroup`] — distributed-aperture fusion is allowed
|
||||
//! **only** when time, phase, and geometry compatibility is proven;
|
||||
//! out-of-bounds members fail closed (the dominant failure mode of
|
||||
//! emerging systems is hidden synchronization/calibration dependence).
|
||||
//! 5. [`FieldActuator`] + [`ActuationReceipt`] — programmable radio
|
||||
//! environments (RIS, movable antennas) are actuators whose state
|
||||
//! changes alter *who is observable*, so actuation demands the same
|
||||
//! policy authorization and auditability as sensing itself.
|
||||
//!
|
||||
//! Plus [`TaskSufficientRepresentation`] — semantic, task-scoped
|
||||
//! compression whose leakage rules are validated, not assumed.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::policy::{PolicyEngine, SensingPurpose};
|
||||
use crate::tensor::RfModality;
|
||||
use crate::{Result, UnifiedError};
|
||||
|
||||
/// RuField-aligned privacy classes (ADR-262 §3.3 vocabulary).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum PrivacyClass {
|
||||
/// Raw signal — never leaves the trust boundary.
|
||||
P0,
|
||||
/// Heavily aggregated, non-personal.
|
||||
P1,
|
||||
/// Anonymous presence/occupancy grade.
|
||||
P2,
|
||||
/// Behavioral inference grade.
|
||||
P3,
|
||||
/// Derived personal inference grade.
|
||||
P4,
|
||||
/// Identity-bound grade.
|
||||
P5,
|
||||
}
|
||||
|
||||
/// Axis-aligned spatial zone in the building frame.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpatialZone {
|
||||
/// Zone identifier (matches ADR-277 `PrivacyZone` ids).
|
||||
pub id: String,
|
||||
/// Minimum corner, metres.
|
||||
pub min_m: [f64; 3],
|
||||
/// Maximum corner, metres.
|
||||
pub max_m: [f64; 3],
|
||||
}
|
||||
|
||||
impl SpatialZone {
|
||||
/// Whether a point lies inside the zone.
|
||||
#[must_use]
|
||||
pub fn contains(&self, p: [f64; 3]) -> bool {
|
||||
(0..3).all(|k| p[k] >= self.min_m[k] && p[k] <= self.max_m[k])
|
||||
}
|
||||
}
|
||||
|
||||
/// The evidence-aware sensing task contract (ADR-280 §2). Enforced
|
||||
/// *before capture begins*, not applied later as metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SensingTask {
|
||||
/// Task identifier.
|
||||
pub task_id: u128,
|
||||
/// Purpose (drives ADR-277 zone authorization).
|
||||
pub purpose: SensingPurpose,
|
||||
/// Target area.
|
||||
pub target_area: SpatialZone,
|
||||
/// Modalities the task may use.
|
||||
pub modalities: Vec<RfModality>,
|
||||
/// Requested spatial resolution, metres.
|
||||
pub requested_resolution_m: f64,
|
||||
/// Maximum acceptable result latency, ms.
|
||||
pub maximum_latency_ms: u32,
|
||||
/// Minimum confidence below which results become *no decision*.
|
||||
pub minimum_confidence: f64,
|
||||
/// Raw (P0) retention bound, seconds — local only.
|
||||
pub raw_retention_seconds: u64,
|
||||
/// Result retention bound, seconds.
|
||||
pub result_retention_seconds: u64,
|
||||
/// Principals allowed to consume results.
|
||||
pub authorized_consumers: Vec<String>,
|
||||
/// Consent reference, when the purpose requires one.
|
||||
pub consent_reference: Option<String>,
|
||||
/// Requested raw export. Kept in the contract for ISAC-vocabulary
|
||||
/// compatibility, but see [`PolicyEngine`]-backed admission: ADR-277's
|
||||
/// structural rule means this is **always refused** today.
|
||||
pub raw_export_allowed: bool,
|
||||
}
|
||||
|
||||
/// Admits a sensing task against the ADR-277 policy engine. Fail-closed:
|
||||
/// unknown zone, ungranted purpose, identity single-gate, raw export, and
|
||||
/// missing-consent identity tasks all deny.
|
||||
pub fn admit_task(engine: &PolicyEngine, task: &SensingTask) -> Result<()> {
|
||||
if task.raw_export_allowed {
|
||||
return Err(UnifiedError::PolicyDenied(
|
||||
"raw RF export is structurally disabled (ADR-277 §2.1); \
|
||||
the contract field exists for ISAC vocabulary compatibility only"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if !(task.minimum_confidence.is_finite() && (0.0..=1.0).contains(&task.minimum_confidence)) {
|
||||
return Err(UnifiedError::InvalidInput("minimum_confidence must be in [0,1]".into()));
|
||||
}
|
||||
if !(task.requested_resolution_m.is_finite() && task.requested_resolution_m > 0.0) {
|
||||
return Err(UnifiedError::InvalidInput("requested_resolution_m must be finite and > 0".into()));
|
||||
}
|
||||
if task.maximum_latency_ms == 0 {
|
||||
return Err(UnifiedError::InvalidInput("maximum_latency_ms must be > 0".into()));
|
||||
}
|
||||
if task.modalities.is_empty() {
|
||||
return Err(UnifiedError::InvalidInput("a sensing task must declare at least one modality".into()));
|
||||
}
|
||||
if task.purpose == SensingPurpose::IdentityRecognition && task.consent_reference.is_none() {
|
||||
return Err(UnifiedError::PolicyDenied(
|
||||
"identity recognition tasks require a consent reference".into(),
|
||||
));
|
||||
}
|
||||
engine.authorize(&task.target_area.id, task.purpose)
|
||||
}
|
||||
|
||||
/// What an active sensing request is trying to learn.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InformationGoal {
|
||||
/// Human-readable hypothesis under test.
|
||||
pub hypothesis: String,
|
||||
/// Current uncertainty in `[0, 1]`.
|
||||
pub current_uncertainty: f64,
|
||||
/// Target uncertainty in `[0, 1]` (must be below current).
|
||||
pub target_uncertainty: f64,
|
||||
/// Expected information gain of the action (heuristic units).
|
||||
pub expected_information_gain: f64,
|
||||
}
|
||||
|
||||
/// A deliberate act of sensing (ADR-280 §3).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SensingAction {
|
||||
/// Action identifier.
|
||||
pub action_id: String,
|
||||
/// Region to observe.
|
||||
pub target_region: SpatialZone,
|
||||
/// Modality to use.
|
||||
pub modality: RfModality,
|
||||
/// Goal that justifies the action.
|
||||
pub desired_information: InformationGoal,
|
||||
/// Latency budget, ms.
|
||||
pub maximum_latency_ms: u32,
|
||||
/// Energy budget, joules.
|
||||
pub energy_budget_j: f64,
|
||||
/// Highest privacy class the action may produce.
|
||||
pub privacy_ceiling: PrivacyClass,
|
||||
}
|
||||
|
||||
/// Freshness state of one spatial region (age-of-information model,
|
||||
/// ADR-280 §4).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpatialStateFreshness {
|
||||
/// Region identifier.
|
||||
pub region_id: String,
|
||||
/// Region geometry.
|
||||
pub region: SpatialZone,
|
||||
/// Last observation, ns since epoch.
|
||||
pub last_observed_ns: u64,
|
||||
/// Expected change rate (events/s scale factor).
|
||||
pub expected_change_rate: f64,
|
||||
/// Uncertainty growth per second of staleness.
|
||||
pub uncertainty_growth_rate: f64,
|
||||
/// Business criticality weight (≥ 0).
|
||||
pub business_criticality: f64,
|
||||
/// Cost of sensing this region (energy/traffic units, > 0).
|
||||
pub sensing_cost: f64,
|
||||
}
|
||||
|
||||
impl SpatialStateFreshness {
|
||||
/// Uncertainty accumulated since the last observation, capped at 1.
|
||||
#[must_use]
|
||||
pub fn uncertainty_at(&self, now_ns: u64) -> f64 {
|
||||
let age_s = now_ns.saturating_sub(self.last_observed_ns) as f64 / 1e9;
|
||||
(self.uncertainty_growth_rate * age_s).min(1.0)
|
||||
}
|
||||
|
||||
/// Refresh priority: `uncertainty × change rate × criticality ÷ cost`.
|
||||
#[must_use]
|
||||
pub fn priority(&self, now_ns: u64) -> f64 {
|
||||
self.uncertainty_at(now_ns) * self.expected_change_rate * self.business_criticality
|
||||
/ self.sensing_cost.max(1e-9)
|
||||
}
|
||||
}
|
||||
|
||||
/// Age-of-information sensing scheduler: refreshes regions in priority
|
||||
/// order instead of uniformly.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ActiveSensingPlanner {
|
||||
regions: Vec<SpatialStateFreshness>,
|
||||
/// Priority below which a region is not worth sensing this cycle.
|
||||
pub priority_threshold: f64,
|
||||
}
|
||||
|
||||
impl ActiveSensingPlanner {
|
||||
/// New planner with a priority threshold.
|
||||
#[must_use]
|
||||
pub fn new(priority_threshold: f64) -> Self {
|
||||
Self { regions: Vec::new(), priority_threshold }
|
||||
}
|
||||
|
||||
/// Registers or replaces a region.
|
||||
pub fn upsert_region(&mut self, region: SpatialStateFreshness) {
|
||||
if let Some(r) = self.regions.iter_mut().find(|r| r.region_id == region.region_id) {
|
||||
*r = region;
|
||||
} else {
|
||||
self.regions.push(region);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a region observed at `now_ns`.
|
||||
pub fn mark_observed(&mut self, region_id: &str, now_ns: u64) {
|
||||
if let Some(r) = self.regions.iter_mut().find(|r| r.region_id == region_id) {
|
||||
r.last_observed_ns = now_ns;
|
||||
}
|
||||
}
|
||||
|
||||
/// Highest-priority region above the threshold, as a concrete
|
||||
/// [`SensingAction`]; `None` when nothing is worth sensing.
|
||||
#[must_use]
|
||||
pub fn next_action(&self, now_ns: u64, modality: RfModality) -> Option<SensingAction> {
|
||||
let best = self
|
||||
.regions
|
||||
.iter()
|
||||
.map(|r| (r.priority(now_ns), r))
|
||||
.filter(|(p, _)| *p >= self.priority_threshold)
|
||||
.max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))?;
|
||||
let (priority, region) = best;
|
||||
let uncertainty = region.uncertainty_at(now_ns);
|
||||
Some(SensingAction {
|
||||
action_id: format!("aoi-{}-{now_ns}", region.region_id),
|
||||
target_region: region.region.clone(),
|
||||
modality,
|
||||
desired_information: InformationGoal {
|
||||
hypothesis: format!("state of region {} is stale", region.region_id),
|
||||
current_uncertainty: uncertainty,
|
||||
target_uncertainty: (uncertainty * 0.2).min(0.05),
|
||||
expected_information_gain: priority,
|
||||
},
|
||||
maximum_latency_ms: 500,
|
||||
energy_budget_j: region.sensing_cost,
|
||||
privacy_ceiling: PrivacyClass::P2,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Clock/phase/geometry sync state reported by one member of a
|
||||
/// distributed aperture.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemberSyncState {
|
||||
/// Member identifier.
|
||||
pub member_id: String,
|
||||
/// Measured time error vs the group reference, ns.
|
||||
pub time_error_ns: f64,
|
||||
/// Measured phase error vs the group reference, rad.
|
||||
pub phase_error_rad: f64,
|
||||
/// Hash of the member's calibrated baseline geometry.
|
||||
pub geometry_hash: u64,
|
||||
}
|
||||
|
||||
/// A coherent sensing group (ADR-280 §5): no coherent fusion unless
|
||||
/// time, phase, and geometry compatibility is *proven*.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CoherentSensorGroup {
|
||||
/// Group identifier.
|
||||
pub group_id: String,
|
||||
/// Member identifiers.
|
||||
pub members: Vec<String>,
|
||||
/// Maximum tolerated time error, ns.
|
||||
pub maximum_time_error_ns: f64,
|
||||
/// Maximum tolerated phase error, rad.
|
||||
pub maximum_phase_error_rad: f64,
|
||||
/// Required baseline geometry hash (all members must match).
|
||||
pub baseline_geometry_hash: u64,
|
||||
}
|
||||
|
||||
impl CoherentSensorGroup {
|
||||
/// Fail-closed fusion gate: every group member must report, be within
|
||||
/// time and phase bounds, and match the baseline geometry hash.
|
||||
/// Unknown reporters, missing members, or any out-of-bounds member
|
||||
/// deny fusion with a typed error.
|
||||
pub fn can_fuse(&self, states: &[MemberSyncState]) -> Result<()> {
|
||||
for member in &self.members {
|
||||
let Some(s) = states.iter().find(|s| &s.member_id == member) else {
|
||||
return Err(UnifiedError::PolicyDenied(format!(
|
||||
"coherent fusion denied: member {member:?} did not report sync state"
|
||||
)));
|
||||
};
|
||||
if !s.time_error_ns.is_finite() || s.time_error_ns.abs() > self.maximum_time_error_ns {
|
||||
return Err(UnifiedError::PolicyDenied(format!(
|
||||
"coherent fusion denied: {member:?} time error {} ns exceeds {} ns",
|
||||
s.time_error_ns, self.maximum_time_error_ns
|
||||
)));
|
||||
}
|
||||
if !s.phase_error_rad.is_finite()
|
||||
|| s.phase_error_rad.abs() > self.maximum_phase_error_rad
|
||||
{
|
||||
return Err(UnifiedError::PolicyDenied(format!(
|
||||
"coherent fusion denied: {member:?} phase error {} rad exceeds {} rad",
|
||||
s.phase_error_rad, self.maximum_phase_error_rad
|
||||
)));
|
||||
}
|
||||
if s.geometry_hash != self.baseline_geometry_hash {
|
||||
return Err(UnifiedError::PolicyDenied(format!(
|
||||
"coherent fusion denied: {member:?} geometry hash mismatch"
|
||||
)));
|
||||
}
|
||||
}
|
||||
for s in states {
|
||||
if !self.members.contains(&s.member_id) {
|
||||
return Err(UnifiedError::PolicyDenied(format!(
|
||||
"coherent fusion denied: {:?} is not a group member",
|
||||
s.member_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Kind of radio-environment actuator.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ActuatorKind {
|
||||
/// Reconfigurable intelligent surface.
|
||||
Ris,
|
||||
/// Mechanically movable antenna.
|
||||
MovableAntenna,
|
||||
/// Fluid antenna.
|
||||
FluidAntenna,
|
||||
}
|
||||
|
||||
/// A programmable radio-environment actuator (ADR-280 §6).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FieldActuator {
|
||||
/// Actuator identifier.
|
||||
pub actuator_id: String,
|
||||
/// Kind.
|
||||
pub kind: ActuatorKind,
|
||||
/// Pose in the building frame.
|
||||
pub pose_m: [f64; 3],
|
||||
/// Named states the actuator supports.
|
||||
pub supported_states: Vec<String>,
|
||||
/// Zone whose observability this actuator changes.
|
||||
pub affected_zone_id: String,
|
||||
}
|
||||
|
||||
/// Audit receipt for an applied actuation. Constructed only by
|
||||
/// [`request_actuation`] — there is no other way to obtain one, so every
|
||||
/// state change that alters observability is policy-checked and logged.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActuationReceipt {
|
||||
/// State that was requested.
|
||||
pub requested_state: String,
|
||||
/// State actually applied.
|
||||
pub applied_state: String,
|
||||
/// Application time, ns.
|
||||
pub applied_ns: u64,
|
||||
/// Controller identity.
|
||||
pub controller_id: String,
|
||||
/// Purpose under which the actuation was authorized.
|
||||
pub purpose: SensingPurpose,
|
||||
}
|
||||
|
||||
/// Requests an actuator state change. Denied unless (a) the actuator
|
||||
/// supports the state and (b) the affected zone grants the purpose under
|
||||
/// the ADR-277 engine — changing an RIS configuration can change *which
|
||||
/// rooms and people are observable*, so it is governed like sensing.
|
||||
pub fn request_actuation(
|
||||
engine: &PolicyEngine,
|
||||
actuator: &FieldActuator,
|
||||
state: &str,
|
||||
purpose: SensingPurpose,
|
||||
controller_id: &str,
|
||||
now_ns: u64,
|
||||
) -> Result<ActuationReceipt> {
|
||||
if !actuator.supported_states.iter().any(|s| s == state) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"actuator {:?} does not support state {state:?}",
|
||||
actuator.actuator_id
|
||||
)));
|
||||
}
|
||||
engine.authorize(&actuator.affected_zone_id, purpose)?;
|
||||
Ok(ActuationReceipt {
|
||||
requested_state: state.to_string(),
|
||||
applied_state: state.to_string(),
|
||||
applied_ns: now_ns,
|
||||
controller_id: controller_id.to_string(),
|
||||
purpose,
|
||||
})
|
||||
}
|
||||
|
||||
/// A task-scoped semantic compression of observations (ADR-280 §7):
|
||||
/// transmit only the information the current physical task needs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskSufficientRepresentation {
|
||||
/// Task this representation serves.
|
||||
pub task_id: u128,
|
||||
/// Source frame receipt ids (lineage).
|
||||
pub source_receipts: Vec<u128>,
|
||||
/// Compressed semantic state.
|
||||
pub semantic_state: Vec<f32>,
|
||||
/// Claimed information bound, bits.
|
||||
pub information_bound_bits: f64,
|
||||
/// Information classes *explicitly* excluded (e.g. `"identity"`,
|
||||
/// `"vitals"`, `"trajectory-history"`).
|
||||
pub excluded_information: Vec<String>,
|
||||
/// Privacy class of the representation.
|
||||
pub privacy_class: PrivacyClass,
|
||||
}
|
||||
|
||||
/// Purpose-scoped leakage validation: compression must remain task
|
||||
/// scoped. A representation sufficient for anonymous occupancy must not
|
||||
/// retain identity information; each purpose has a privacy-class ceiling
|
||||
/// and a set of information classes it must exclude.
|
||||
pub fn validate_representation(
|
||||
rep: &TaskSufficientRepresentation,
|
||||
purpose: SensingPurpose,
|
||||
) -> Result<()> {
|
||||
let (ceiling, must_exclude): (PrivacyClass, &[&str]) = match purpose {
|
||||
SensingPurpose::Presence | SensingPurpose::ChannelDiagnostics => {
|
||||
(PrivacyClass::P2, &["identity", "vitals"])
|
||||
}
|
||||
SensingPurpose::Activity | SensingPurpose::Localization => {
|
||||
(PrivacyClass::P3, &["identity"])
|
||||
}
|
||||
SensingPurpose::Vitals | SensingPurpose::PoseTracking => (PrivacyClass::P4, &["identity"]),
|
||||
SensingPurpose::IdentityRecognition => (PrivacyClass::P5, &[]),
|
||||
};
|
||||
if rep.privacy_class > ceiling {
|
||||
return Err(UnifiedError::PolicyDenied(format!(
|
||||
"representation class {:?} exceeds ceiling {ceiling:?} for purpose {purpose:?}",
|
||||
rep.privacy_class
|
||||
)));
|
||||
}
|
||||
for class in must_exclude {
|
||||
if !rep.excluded_information.iter().any(|e| e == class) {
|
||||
return Err(UnifiedError::PolicyDenied(format!(
|
||||
"purpose {purpose:?} requires the representation to explicitly exclude {class:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if rep.source_receipts.is_empty() {
|
||||
return Err(UnifiedError::InvalidInput(
|
||||
"task-sufficient representation must carry source lineage".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::policy::PrivacyZone;
|
||||
|
||||
fn zone(id: &str) -> SpatialZone {
|
||||
SpatialZone { id: id.into(), min_m: [0.0; 3], max_m: [5.0, 4.0, 3.0] }
|
||||
}
|
||||
|
||||
fn engine_with(purposes: &[SensingPurpose]) -> PolicyEngine {
|
||||
let mut e = PolicyEngine::new();
|
||||
e.upsert_zone(PrivacyZone {
|
||||
id: "lab".into(),
|
||||
allowed_purposes: purposes.iter().copied().collect(),
|
||||
retention_s: 3600,
|
||||
identity_explicitly_enabled: false,
|
||||
});
|
||||
e
|
||||
}
|
||||
|
||||
fn task(purpose: SensingPurpose, raw_export: bool) -> SensingTask {
|
||||
SensingTask {
|
||||
task_id: 1,
|
||||
purpose,
|
||||
target_area: zone("lab"),
|
||||
modalities: vec![RfModality::WifiCsi],
|
||||
requested_resolution_m: 0.5,
|
||||
maximum_latency_ms: 100,
|
||||
minimum_confidence: 0.8,
|
||||
raw_retention_seconds: 60,
|
||||
result_retention_seconds: 3600,
|
||||
authorized_consumers: vec!["ha-bridge".into()],
|
||||
consent_reference: None,
|
||||
raw_export_allowed: raw_export,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_admission_is_fail_closed() {
|
||||
let engine = engine_with(&[SensingPurpose::Presence]);
|
||||
assert!(admit_task(&engine, &task(SensingPurpose::Presence, false)).is_ok());
|
||||
// Raw export is refused regardless of any other grant.
|
||||
assert!(matches!(
|
||||
admit_task(&engine, &task(SensingPurpose::Presence, true)),
|
||||
Err(UnifiedError::PolicyDenied(_))
|
||||
));
|
||||
// Ungranted purpose denied.
|
||||
assert!(admit_task(&engine, &task(SensingPurpose::Localization, false)).is_err());
|
||||
// Identity without consent denied before even reaching the zone check.
|
||||
assert!(admit_task(&engine, &task(SensingPurpose::IdentityRecognition, false)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_prioritizes_stale_critical_regions() {
|
||||
let mut planner = ActiveSensingPlanner::new(0.01);
|
||||
let mk = |id: &str, change: f64, crit: f64, cost: f64| SpatialStateFreshness {
|
||||
region_id: id.into(),
|
||||
region: zone(id),
|
||||
last_observed_ns: 0,
|
||||
expected_change_rate: change,
|
||||
uncertainty_growth_rate: 0.05,
|
||||
business_criticality: crit,
|
||||
sensing_cost: cost,
|
||||
};
|
||||
planner.upsert_region(mk("server-room", 0.1, 5.0, 1.0));
|
||||
planner.upsert_region(mk("emergency-exit", 0.5, 8.0, 1.0));
|
||||
planner.upsert_region(mk("storage", 0.01, 0.5, 1.0));
|
||||
|
||||
let now = 10_000_000_000; // 10 s of staleness everywhere
|
||||
let action = planner.next_action(now, RfModality::WifiCsi).expect("something stale");
|
||||
assert_eq!(action.target_region.id, "emergency-exit", "highest priority wins");
|
||||
|
||||
// After observing it, the next-highest region is selected.
|
||||
planner.mark_observed("emergency-exit", now);
|
||||
let action = planner.next_action(now, RfModality::WifiCsi).expect("next region");
|
||||
assert_eq!(action.target_region.id, "server-room");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_reduces_sensing_traffic_versus_uniform_refresh() {
|
||||
// 20 regions, one hot (changes often, critical), the rest cold.
|
||||
let mut planner = ActiveSensingPlanner::new(0.05);
|
||||
for i in 0..20 {
|
||||
let hot = i == 0;
|
||||
planner.upsert_region(SpatialStateFreshness {
|
||||
region_id: format!("r{i}"),
|
||||
region: zone("lab"),
|
||||
last_observed_ns: 0,
|
||||
expected_change_rate: if hot { 1.0 } else { 0.01 },
|
||||
uncertainty_growth_rate: 0.2,
|
||||
business_criticality: if hot { 5.0 } else { 0.5 },
|
||||
sensing_cost: 1.0,
|
||||
});
|
||||
}
|
||||
// Simulate 100 scheduling ticks, 1 s apart. Uniform refresh would
|
||||
// sense 20 regions × 100 ticks = 2000 observations; the planner
|
||||
// senses at most one region per tick and only above threshold.
|
||||
let mut actions = 0;
|
||||
for tick in 1..=100u64 {
|
||||
let now = tick * 1_000_000_000;
|
||||
if let Some(a) = planner.next_action(now, RfModality::WifiCsi) {
|
||||
planner.mark_observed(&a.target_region.id, now);
|
||||
actions += 1;
|
||||
}
|
||||
}
|
||||
let uniform = 20 * 100;
|
||||
let reduction = 1.0 - actions as f64 / uniform as f64;
|
||||
println!("AoI planner: {actions} observations vs {uniform} uniform ({reduction:.2} reduction)");
|
||||
assert!(
|
||||
reduction >= 0.70,
|
||||
"planner must cut sensing traffic by >= 70 % in sparse environments, got {reduction:.2}"
|
||||
);
|
||||
assert!(actions > 0, "the hot region must still be observed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coherent_fusion_fails_closed() {
|
||||
let group = CoherentSensorGroup {
|
||||
group_id: "aisle-3".into(),
|
||||
members: vec!["ap-1".into(), "ap-2".into()],
|
||||
maximum_time_error_ns: 50.0,
|
||||
maximum_phase_error_rad: 0.2,
|
||||
baseline_geometry_hash: 0xBEEF,
|
||||
};
|
||||
let ok = |id: &str| MemberSyncState {
|
||||
member_id: id.into(),
|
||||
time_error_ns: 10.0,
|
||||
phase_error_rad: 0.05,
|
||||
geometry_hash: 0xBEEF,
|
||||
};
|
||||
assert!(group.can_fuse(&[ok("ap-1"), ok("ap-2")]).is_ok());
|
||||
|
||||
// Missing member ⇒ deny.
|
||||
assert!(group.can_fuse(&[ok("ap-1")]).is_err());
|
||||
// Clock out of bounds ⇒ deny.
|
||||
let mut drift = ok("ap-2");
|
||||
drift.time_error_ns = 400.0;
|
||||
assert!(group.can_fuse(&[ok("ap-1"), drift]).is_err());
|
||||
// Phase out of bounds ⇒ deny.
|
||||
let mut phase = ok("ap-2");
|
||||
phase.phase_error_rad = 1.0;
|
||||
assert!(group.can_fuse(&[ok("ap-1"), phase]).is_err());
|
||||
// Geometry changed since calibration ⇒ deny.
|
||||
let mut moved = ok("ap-2");
|
||||
moved.geometry_hash = 0xDEAD;
|
||||
assert!(group.can_fuse(&[ok("ap-1"), moved]).is_err());
|
||||
// A non-member reporting in ⇒ deny.
|
||||
assert!(group.can_fuse(&[ok("ap-1"), ok("ap-2"), ok("rogue")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actuation_requires_policy_authorization() {
|
||||
let engine = engine_with(&[SensingPurpose::Presence]);
|
||||
let ris = FieldActuator {
|
||||
actuator_id: "ris-7".into(),
|
||||
kind: ActuatorKind::Ris,
|
||||
pose_m: [2.0, 0.0, 2.5],
|
||||
supported_states: vec!["beam-east".into(), "beam-west".into()],
|
||||
affected_zone_id: "lab".into(),
|
||||
};
|
||||
// Authorized purpose + supported state ⇒ receipt.
|
||||
let receipt =
|
||||
request_actuation(&engine, &ris, "beam-east", SensingPurpose::Presence, "ctl-1", 99)
|
||||
.expect("authorized actuation");
|
||||
assert_eq!(receipt.applied_state, "beam-east");
|
||||
assert_eq!(receipt.purpose, SensingPurpose::Presence);
|
||||
|
||||
// Unsupported state ⇒ deny.
|
||||
assert!(request_actuation(&engine, &ris, "beam-up", SensingPurpose::Presence, "c", 0)
|
||||
.is_err());
|
||||
// Purpose not granted in the affected zone ⇒ deny (an RIS cannot be
|
||||
// steered to observe a zone for a purpose the zone never granted).
|
||||
assert!(request_actuation(&engine, &ris, "beam-east", SensingPurpose::Vitals, "c", 0)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_sufficient_representation_is_leakage_checked() {
|
||||
let rep = |class: PrivacyClass, excluded: &[&str]| TaskSufficientRepresentation {
|
||||
task_id: 5,
|
||||
source_receipts: vec![1, 2],
|
||||
semantic_state: vec![0.1, 0.9],
|
||||
information_bound_bits: 8.0,
|
||||
excluded_information: excluded.iter().map(|s| (*s).to_string()).collect(),
|
||||
privacy_class: class,
|
||||
};
|
||||
// Occupancy-grade representation excluding identity + vitals: fine.
|
||||
assert!(validate_representation(
|
||||
&rep(PrivacyClass::P2, &["identity", "vitals"]),
|
||||
SensingPurpose::Presence
|
||||
)
|
||||
.is_ok());
|
||||
// Same purpose but the representation forgot to exclude identity: deny.
|
||||
assert!(validate_representation(
|
||||
&rep(PrivacyClass::P2, &["vitals"]),
|
||||
SensingPurpose::Presence
|
||||
)
|
||||
.is_err());
|
||||
// Class above the purpose ceiling: deny.
|
||||
assert!(validate_representation(
|
||||
&rep(PrivacyClass::P4, &["identity", "vitals"]),
|
||||
SensingPurpose::Presence
|
||||
)
|
||||
.is_err());
|
||||
// No lineage: deny.
|
||||
let mut orphan = rep(PrivacyClass::P2, &["identity", "vitals"]);
|
||||
orphan.source_receipts.clear();
|
||||
assert!(validate_representation(&orphan, SensingPurpose::Presence).is_err());
|
||||
}
|
||||
|
||||
/// Only `Presence` was ever exercised above; the other three
|
||||
/// ceiling/exclusion-set branches (Activity/Localization at P3,
|
||||
/// Vitals/PoseTracking at P4, IdentityRecognition at P5) had zero test
|
||||
/// coverage — a bug in any of them would go undetected.
|
||||
#[test]
|
||||
fn task_sufficient_representation_covers_every_purpose_branch() {
|
||||
let rep = |class: PrivacyClass, excluded: &[&str]| TaskSufficientRepresentation {
|
||||
task_id: 6,
|
||||
source_receipts: vec![1],
|
||||
semantic_state: vec![0.2],
|
||||
information_bound_bits: 4.0,
|
||||
excluded_information: excluded.iter().map(|s| (*s).to_string()).collect(),
|
||||
privacy_class: class,
|
||||
};
|
||||
|
||||
for purpose in [SensingPurpose::Activity, SensingPurpose::Localization] {
|
||||
// P3 ceiling excluding identity: fine.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P3, &["identity"]), purpose).is_ok());
|
||||
// Forgot to exclude identity: deny.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P3, &[]), purpose).is_err());
|
||||
// Above the P3 ceiling: deny.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P4, &["identity"]), purpose).is_err());
|
||||
}
|
||||
|
||||
for purpose in [SensingPurpose::Vitals, SensingPurpose::PoseTracking] {
|
||||
// P4 ceiling excluding identity: fine.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P4, &["identity"]), purpose).is_ok());
|
||||
// Forgot to exclude identity: deny.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P4, &[]), purpose).is_err());
|
||||
// Above the P4 ceiling: deny.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P5, &["identity"]), purpose).is_err());
|
||||
}
|
||||
|
||||
// IdentityRecognition: P5 ceiling, nothing required to be excluded.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P5, &[]), SensingPurpose::IdentityRecognition)
|
||||
.is_ok());
|
||||
// Still bounded — no class exceeds P5, so exercise the lineage guard instead.
|
||||
let mut orphan = rep(PrivacyClass::P5, &[]);
|
||||
orphan.source_receipts.clear();
|
||||
assert!(validate_representation(&orphan, SensingPurpose::IdentityRecognition).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Universal RF foundation encoder (ADR-274 §3).
|
||||
//!
|
||||
//! A deliberately small, pure-Rust, exactly-differentiable network. The
|
||||
//! representation contract is the one ADR-273 fixes:
|
||||
//!
|
||||
//! ```text
|
||||
//! z = Encoder(tokens) ⊙ σ(AgeEncoder(age)) + GeometryEncoder(sensor_pose)
|
||||
//! ```
|
||||
//!
|
||||
//! Architecture (all f64, weights row-major):
|
||||
//!
|
||||
//! ```text
|
||||
//! h_i = tanh(W1·x_i + b1) token embedding (unmasked tokens)
|
||||
//! c = mean_i h_i permutation-invariant context pool
|
||||
//! m = tanh(W2·c + b2) context mixing 1
|
||||
//! g = tanh(W2b·m + b2b) context mixing 2
|
||||
//! gate = σ(age_w·age + age_b) multiplicative freshness gate
|
||||
//! z = g ⊙ gate + Wg·geo + bg fused window representation
|
||||
//! ```
|
||||
//!
|
||||
//! Pretraining (see [`crate::pretrain`]) reconstructs *masked* tokens from
|
||||
//! `[z ; position_encoding(j)]` through a linear head `W3, b3` that is
|
||||
//! discarded at deployment. The backward pass is hand-derived and verified
|
||||
//! against central finite differences in `pretrain::tests` — the gradient
|
||||
//! check is the crate's proof that this module computes what it claims.
|
||||
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
|
||||
use crate::math::{sigmoid, xavier_init};
|
||||
|
||||
/// Age input transform for the freshness gate (ADR-281 §4, the age-aware
|
||||
/// CSI recipe): `log(1 + sample_age_ms)` — log-scaling keeps millisecond
|
||||
/// and multi-second staleness on comparable input scales.
|
||||
#[must_use]
|
||||
pub fn age_feature(age_s: f64) -> f64 {
|
||||
(1.0 + age_s * 1000.0).ln()
|
||||
}
|
||||
use crate::tokenizer::{position_encoding, RfToken, TokenizedWindow, D_IN, D_POS};
|
||||
|
||||
/// Dense row-major matrix with a bias vector (one linear layer).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Linear {
|
||||
/// Output rows.
|
||||
pub rows: usize,
|
||||
/// Input columns.
|
||||
pub cols: usize,
|
||||
/// Row-major weights, `rows × cols`.
|
||||
pub w: Vec<f64>,
|
||||
/// Bias, length `rows`.
|
||||
pub b: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Linear {
|
||||
/// Xavier-initialized layer.
|
||||
#[must_use]
|
||||
pub fn new(rng: &mut ChaCha20Rng, rows: usize, cols: usize) -> Self {
|
||||
Self { rows, cols, w: xavier_init(rng, rows, cols), b: vec![0.0; rows] }
|
||||
}
|
||||
|
||||
/// Zeroed layer with the same shape (gradient accumulator).
|
||||
#[must_use]
|
||||
pub fn zeros_like(&self) -> Self {
|
||||
Self { rows: self.rows, cols: self.cols, w: vec![0.0; self.w.len()], b: vec![0.0; self.rows] }
|
||||
}
|
||||
|
||||
/// `y = W·x + b`.
|
||||
#[must_use]
|
||||
pub fn forward(&self, x: &[f64]) -> Vec<f64> {
|
||||
debug_assert_eq!(x.len(), self.cols);
|
||||
let mut y = self.b.clone();
|
||||
for r in 0..self.rows {
|
||||
let row = &self.w[r * self.cols..(r + 1) * self.cols];
|
||||
let mut acc = 0.0;
|
||||
for (wv, xv) in row.iter().zip(x) {
|
||||
acc += wv * xv;
|
||||
}
|
||||
y[r] += acc;
|
||||
}
|
||||
y
|
||||
}
|
||||
|
||||
/// `x_grad = Wᵀ·dy` (input gradient).
|
||||
#[must_use]
|
||||
pub fn backward_input(&self, dy: &[f64]) -> Vec<f64> {
|
||||
let mut dx = vec![0.0; self.cols];
|
||||
for r in 0..self.rows {
|
||||
let row = &self.w[r * self.cols..(r + 1) * self.cols];
|
||||
for (c, wv) in row.iter().enumerate() {
|
||||
dx[c] += wv * dy[r];
|
||||
}
|
||||
}
|
||||
dx
|
||||
}
|
||||
|
||||
/// Accumulates `dW += dy ⊗ x`, `db += dy` into `grad`.
|
||||
pub fn accumulate_grad(&self, grad: &mut Linear, dy: &[f64], x: &[f64]) {
|
||||
for r in 0..self.rows {
|
||||
grad.b[r] += dy[r];
|
||||
let row = &mut grad.w[r * self.cols..(r + 1) * self.cols];
|
||||
for (c, xv) in x.iter().enumerate() {
|
||||
row[c] += dy[r] * xv;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SGD step: `p -= lr·g`.
|
||||
pub fn sgd(&mut self, grad: &Linear, lr: f64) {
|
||||
for (p, g) in self.w.iter_mut().zip(&grad.w) {
|
||||
*p -= lr * g;
|
||||
}
|
||||
for (p, g) in self.b.iter_mut().zip(&grad.b) {
|
||||
*p -= lr * g;
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of parameters (weights + biases).
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
self.w.len() + self.b.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Encoder hyper-parameters. The default is the deployment config; tests use
|
||||
/// tiny configs for the finite-difference gradient check.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct EncoderConfig {
|
||||
/// Token feature dimension.
|
||||
pub d_in: usize,
|
||||
/// Position-encoding dimension (pretraining decoder only).
|
||||
pub d_pos: usize,
|
||||
/// Model (representation) dimension.
|
||||
pub d_model: usize,
|
||||
}
|
||||
|
||||
impl Default for EncoderConfig {
|
||||
fn default() -> Self {
|
||||
Self { d_in: D_IN, d_pos: D_POS, d_model: 128 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Window-level context consumed by the fusion stage.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WindowContext {
|
||||
/// Sample age in seconds.
|
||||
pub age_s: f64,
|
||||
/// Geometry summary (mean TX xyz, mean RX xyz, decametres).
|
||||
pub geometry: [f64; 6],
|
||||
}
|
||||
|
||||
impl From<&TokenizedWindow> for WindowContext {
|
||||
fn from(w: &TokenizedWindow) -> Self {
|
||||
Self { age_s: w.age_s, geometry: w.geometry }
|
||||
}
|
||||
}
|
||||
|
||||
/// Intermediate activations kept for the backward pass.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ForwardCache {
|
||||
/// Indices of unmasked tokens (context providers).
|
||||
pub unmasked: Vec<usize>,
|
||||
/// Embeddings `h_i` for unmasked tokens (parallel to `unmasked`).
|
||||
pub h: Vec<Vec<f64>>,
|
||||
/// Pooled context `c`.
|
||||
pub c: Vec<f64>,
|
||||
/// Mixing activations `m`, `g`.
|
||||
pub m: Vec<f64>,
|
||||
/// Second mixing output.
|
||||
pub g: Vec<f64>,
|
||||
/// Freshness gate `σ(age_w·age + age_b)`.
|
||||
pub gate: Vec<f64>,
|
||||
/// Fused representation `z`.
|
||||
pub z: Vec<f64>,
|
||||
/// Window context used.
|
||||
pub ctx: WindowContext,
|
||||
}
|
||||
|
||||
/// The universal RF foundation encoder.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RfEncoder {
|
||||
/// Config.
|
||||
pub cfg: EncoderConfig,
|
||||
/// Token embedding.
|
||||
pub w1: Linear,
|
||||
/// Context mixing 1.
|
||||
pub w2: Linear,
|
||||
/// Context mixing 2.
|
||||
pub w2b: Linear,
|
||||
/// Age gate weight (elementwise on the scalar age).
|
||||
pub age_w: Vec<f64>,
|
||||
/// Age gate bias.
|
||||
pub age_b: Vec<f64>,
|
||||
/// Geometry encoder.
|
||||
pub wg: Linear,
|
||||
/// Masked-token reconstruction head (pretraining only; not counted as a
|
||||
/// deployment adapter).
|
||||
pub w3: Linear,
|
||||
}
|
||||
|
||||
impl RfEncoder {
|
||||
/// Deterministically initialized encoder.
|
||||
#[must_use]
|
||||
pub fn new(cfg: EncoderConfig, seed: u64) -> Self {
|
||||
let mut rng = crate::math::seeded_rng(seed);
|
||||
let h = cfg.d_model;
|
||||
Self {
|
||||
cfg,
|
||||
w1: Linear::new(&mut rng, h, cfg.d_in),
|
||||
w2: Linear::new(&mut rng, h, h),
|
||||
w2b: Linear::new(&mut rng, h, h),
|
||||
age_w: xavier_init(&mut rng, h, 1),
|
||||
age_b: vec![0.0; h],
|
||||
wg: Linear::new(&mut rng, h, 6),
|
||||
w3: Linear::new(&mut rng, cfg.d_in, h + cfg.d_pos),
|
||||
}
|
||||
}
|
||||
|
||||
/// Total trainable parameters (the "backbone" for the ≤1 % adapter
|
||||
/// budget of ADR-273's acceptance test).
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
self.w1.param_count()
|
||||
+ self.w2.param_count()
|
||||
+ self.w2b.param_count()
|
||||
+ self.age_w.len()
|
||||
+ self.age_b.len()
|
||||
+ self.wg.param_count()
|
||||
+ self.w3.param_count()
|
||||
}
|
||||
|
||||
/// Forward pass over the unmasked token set, producing the fused window
|
||||
/// representation `z` and the cache needed for backprop.
|
||||
///
|
||||
/// `masked` lists token indices excluded from the context pool (empty at
|
||||
/// inference time).
|
||||
#[must_use]
|
||||
pub fn forward(&self, tokens: &[RfToken], masked: &[usize], ctx: WindowContext) -> ForwardCache {
|
||||
let h_dim = self.cfg.d_model;
|
||||
let unmasked: Vec<usize> =
|
||||
(0..tokens.len()).filter(|i| !masked.contains(i)).collect();
|
||||
assert!(!unmasked.is_empty(), "cannot encode a fully masked window");
|
||||
|
||||
let mut h = Vec::with_capacity(unmasked.len());
|
||||
let mut c = vec![0.0; h_dim];
|
||||
for &i in &unmasked {
|
||||
let mut hi = self.w1.forward(&tokens[i].features);
|
||||
for v in &mut hi {
|
||||
*v = v.tanh();
|
||||
}
|
||||
for (cv, hv) in c.iter_mut().zip(&hi) {
|
||||
*cv += hv;
|
||||
}
|
||||
h.push(hi);
|
||||
}
|
||||
for cv in &mut c {
|
||||
*cv /= unmasked.len() as f64;
|
||||
}
|
||||
|
||||
let mut m = self.w2.forward(&c);
|
||||
for v in &mut m {
|
||||
*v = v.tanh();
|
||||
}
|
||||
let mut g = self.w2b.forward(&m);
|
||||
for v in &mut g {
|
||||
*v = v.tanh();
|
||||
}
|
||||
|
||||
let age_feat = age_feature(ctx.age_s);
|
||||
let gate: Vec<f64> = self
|
||||
.age_w
|
||||
.iter()
|
||||
.zip(&self.age_b)
|
||||
.map(|(w, b)| sigmoid(w * age_feat + b))
|
||||
.collect();
|
||||
|
||||
let geo = self.wg.forward(&ctx.geometry);
|
||||
let z: Vec<f64> =
|
||||
(0..h_dim).map(|k| g[k] * gate[k] + geo[k]).collect();
|
||||
|
||||
ForwardCache { unmasked, h, c, m, g, gate, z, ctx }
|
||||
}
|
||||
|
||||
/// Inference entry point: encode a full window (nothing masked) into the
|
||||
/// fused representation `z = g ⊙ gate + Wg·geo + bg`.
|
||||
///
|
||||
/// Use `z` for geometry-conditioned tasks (localization, channel
|
||||
/// prediction) where sensor pose is signal, not nuisance.
|
||||
#[must_use]
|
||||
pub fn encode(&self, window: &TokenizedWindow) -> Vec<f64> {
|
||||
self.forward(&window.tokens, &[], WindowContext::from(window)).z
|
||||
}
|
||||
|
||||
/// Environment-invariant content representation
|
||||
/// `[g ⊙ gate ; mean_i(x_i)]` — the fused `z` *without* the additive
|
||||
/// geometry term, concatenated with the window-mean token features
|
||||
/// (dimension `d_model + d_in`).
|
||||
///
|
||||
/// Two deliberate choices, both anti-leakage (ADR-273 §5):
|
||||
/// - The PerceptAlign lesson: geometry should *condition* spatial tasks,
|
||||
/// but for environment-invariant heads (presence, activity, anomaly)
|
||||
/// the additive `Wg·geo` term is a room-specific offset a linear
|
||||
/// adapter would memorize.
|
||||
/// - The skip connection exposes pooled token statistics (Doppler
|
||||
/// energy, temporal variance, freshness) whose *semantics are
|
||||
/// identical in every room*, so a small head can generalize across
|
||||
/// environments instead of re-deriving them through mixing layers that
|
||||
/// entangle them with room-specific fading structure.
|
||||
#[must_use]
|
||||
pub fn encode_content(&self, window: &TokenizedWindow) -> Vec<f64> {
|
||||
let cache = self.forward(&window.tokens, &[], WindowContext::from(window));
|
||||
let mut out: Vec<f64> =
|
||||
(0..self.cfg.d_model).map(|k| cache.g[k] * cache.gate[k]).collect();
|
||||
let n = window.tokens.len().max(1) as f64;
|
||||
let mut mean = vec![0.0; self.cfg.d_in];
|
||||
for t in &window.tokens {
|
||||
for (m, v) in mean.iter_mut().zip(&t.features) {
|
||||
*m += v / n;
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&mean);
|
||||
out
|
||||
}
|
||||
|
||||
/// Dimension of the [`Self::encode_content`] representation.
|
||||
#[must_use]
|
||||
pub fn content_dim(&self) -> usize {
|
||||
self.cfg.d_model + self.cfg.d_in
|
||||
}
|
||||
|
||||
/// Reconstruction of masked token `j` from the cache: `W3·[z ; pos(j)]`.
|
||||
#[must_use]
|
||||
pub fn reconstruct(&self, cache: &ForwardCache, token_idx: usize) -> Vec<f64> {
|
||||
let mut u = cache.z.clone();
|
||||
u.extend_from_slice(&position_encoding(token_idx)[..self.cfg.d_pos]);
|
||||
self.w3.forward(&u)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn toy_tokens(n: usize) -> Vec<RfToken> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let mut f = [0.0f64; D_IN];
|
||||
for (k, v) in f.iter_mut().enumerate() {
|
||||
*v = ((i * 31 + k * 7) % 13) as f64 / 13.0 - 0.5;
|
||||
}
|
||||
RfToken { features: f, link: 0, group: i }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_is_deterministic_given_seed() {
|
||||
let a = RfEncoder::new(EncoderConfig::default(), 42);
|
||||
let b = RfEncoder::new(EncoderConfig::default(), 42);
|
||||
assert_eq!(a.w1.w, b.w1.w);
|
||||
let tokens = toy_tokens(6);
|
||||
let ctx = WindowContext { age_s: 0.1, geometry: [0.1; 6] };
|
||||
assert_eq!(a.forward(&tokens, &[], ctx).z, b.forward(&tokens, &[], ctx).z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_count_matches_hand_computation() {
|
||||
let e = RfEncoder::new(EncoderConfig::default(), 1);
|
||||
let h = 128;
|
||||
let expected = (h * D_IN + h) // w1
|
||||
+ (h * h + h) // w2
|
||||
+ (h * h + h) // w2b
|
||||
+ h + h // age_w, age_b
|
||||
+ (h * 6 + h) // wg
|
||||
+ (D_IN * (h + D_POS) + D_IN); // w3
|
||||
assert_eq!(e.param_count(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_windows_are_gated_toward_geometry_prior() {
|
||||
// As age → ∞ with negative gate logits, σ → 0 or 1 per unit; what we
|
||||
// verify is the *contract*: z depends on age only through the gate,
|
||||
// so two ages produce different z while geometry contribution stays.
|
||||
let e = RfEncoder::new(EncoderConfig::default(), 3);
|
||||
let tokens = toy_tokens(8);
|
||||
let fresh = e.forward(&tokens, &[], WindowContext { age_s: 0.0, geometry: [0.2; 6] });
|
||||
let stale = e.forward(&tokens, &[], WindowContext { age_s: 9.0, geometry: [0.2; 6] });
|
||||
assert_ne!(fresh.z, stale.z);
|
||||
// Same age, different geometry ⇒ additive path shifts z.
|
||||
let moved = e.forward(&tokens, &[], WindowContext { age_s: 0.0, geometry: [0.4; 6] });
|
||||
assert_ne!(fresh.z, moved.z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masking_excludes_tokens_from_context() {
|
||||
let e = RfEncoder::new(EncoderConfig::default(), 5);
|
||||
let tokens = toy_tokens(8);
|
||||
let ctx = WindowContext { age_s: 0.1, geometry: [0.0; 6] };
|
||||
let full = e.forward(&tokens, &[], ctx);
|
||||
let masked = e.forward(&tokens, &[2, 5], ctx);
|
||||
assert_eq!(masked.unmasked.len(), 6);
|
||||
assert_ne!(full.z, masked.z);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Anti-leakage evaluation protocol (ADR-273 §5).
|
||||
//!
|
||||
//! The biggest failure mode of RF sensing results is domain leakage
|
||||
//! disguised as accuracy: random frame splits let a model recognize the
|
||||
//! room, session, person, device, or trajectory instead of learning
|
||||
//! transferable physics. The fix is structural, not statistical:
|
||||
//!
|
||||
//! - [`StrictSplit`] holds out **complete values** of a partition dimension
|
||||
//! (room / day / person / chipset / firmware / antenna layout) and proves
|
||||
//! the train and test sides share none of them.
|
||||
//! - [`expected_calibration_error`] and [`selective_metrics`] make
|
||||
//! confidence quality and abstention first-class metrics: for
|
||||
//! safety-relevant uses an uncertain result must become *no decision*.
|
||||
//! - [`relative_degradation`] tracks known→unknown condition degradation
|
||||
//! (the ADR-273 acceptance gate is < 20 %).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Provenance key of one sample — every dimension that could leak identity
|
||||
/// or environment structure into a random split.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct PartitionKey {
|
||||
/// Room / environment identifier.
|
||||
pub room: String,
|
||||
/// Capture day (coarse session time).
|
||||
pub day: String,
|
||||
/// Person identifier (or "none").
|
||||
pub person: String,
|
||||
/// Chipset family.
|
||||
pub chipset: String,
|
||||
/// Firmware version.
|
||||
pub firmware: String,
|
||||
/// Antenna layout identifier.
|
||||
pub layout: String,
|
||||
/// Capture session identifier (packet-session leakage is as real as
|
||||
/// room leakage — ADR-279 §4 split manifest).
|
||||
pub session: String,
|
||||
}
|
||||
|
||||
/// Which dimension of [`PartitionKey`] a split holds out.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PartitionDim {
|
||||
/// Hold out complete rooms.
|
||||
Room,
|
||||
/// Hold out complete days.
|
||||
Day,
|
||||
/// Hold out complete people.
|
||||
Person,
|
||||
/// Hold out complete chipsets.
|
||||
Chipset,
|
||||
/// Hold out complete firmware versions.
|
||||
Firmware,
|
||||
/// Hold out complete antenna layouts.
|
||||
Layout,
|
||||
/// Hold out complete capture sessions.
|
||||
Session,
|
||||
}
|
||||
|
||||
impl PartitionDim {
|
||||
fn value<'a>(&self, k: &'a PartitionKey) -> &'a str {
|
||||
match self {
|
||||
Self::Room => &k.room,
|
||||
Self::Day => &k.day,
|
||||
Self::Person => &k.person,
|
||||
Self::Chipset => &k.chipset,
|
||||
Self::Firmware => &k.firmware,
|
||||
Self::Layout => &k.layout,
|
||||
Self::Session => &k.session,
|
||||
}
|
||||
}
|
||||
|
||||
/// All partition dimensions, for exhaustive manifest checks.
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::Room,
|
||||
Self::Day,
|
||||
Self::Person,
|
||||
Self::Chipset,
|
||||
Self::Firmware,
|
||||
Self::Layout,
|
||||
Self::Session,
|
||||
];
|
||||
}
|
||||
|
||||
/// The mandatory split manifest (ADR-279 §4): per-dimension disjointness
|
||||
/// certificates for a train/test split. A result is only reportable as
|
||||
/// leakage-resistant along the dimensions this manifest certifies.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SplitManifest {
|
||||
/// `(dimension, train∩test == ∅)` for every partition dimension.
|
||||
pub disjoint: Vec<(PartitionDim, bool)>,
|
||||
}
|
||||
|
||||
impl SplitManifest {
|
||||
/// Builds the manifest for an arbitrary index split.
|
||||
#[must_use]
|
||||
pub fn build(keys: &[PartitionKey], train: &[usize], test: &[usize]) -> Self {
|
||||
let disjoint = PartitionDim::ALL
|
||||
.iter()
|
||||
.map(|dim| {
|
||||
let train_vals: BTreeSet<&str> =
|
||||
train.iter().map(|&i| dim.value(&keys[i])).collect();
|
||||
let test_vals: BTreeSet<&str> =
|
||||
test.iter().map(|&i| dim.value(&keys[i])).collect();
|
||||
(*dim, train_vals.is_disjoint(&test_vals))
|
||||
})
|
||||
.collect();
|
||||
Self { disjoint }
|
||||
}
|
||||
|
||||
/// True iff the given dimension is certified disjoint.
|
||||
#[must_use]
|
||||
pub fn is_disjoint(&self, dim: PartitionDim) -> bool {
|
||||
self.disjoint.iter().any(|(d, ok)| *d == dim && *ok)
|
||||
}
|
||||
|
||||
/// True iff every dimension is disjoint (the full anti-leakage bar:
|
||||
/// `train_rooms ∩ test_rooms = ∅` … `train_sessions ∩ test_sessions = ∅`).
|
||||
#[must_use]
|
||||
pub fn fully_disjoint(&self) -> bool {
|
||||
self.disjoint.iter().all(|(_, ok)| *ok)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mean per-joint position error (metres) between two joint sets.
|
||||
///
|
||||
/// # Panics
|
||||
/// If the slices have different lengths or are empty.
|
||||
#[must_use]
|
||||
pub fn mpjpe(pred: &[[f64; 3]], truth: &[[f64; 3]]) -> f64 {
|
||||
assert_eq!(pred.len(), truth.len());
|
||||
assert!(!pred.is_empty());
|
||||
pred.iter()
|
||||
.zip(truth)
|
||||
.map(|(p, t)| ((p[0] - t[0]).powi(2) + (p[1] - t[1]).powi(2) + (p[2] - t[2]).powi(2)).sqrt())
|
||||
.sum::<f64>()
|
||||
/ pred.len() as f64
|
||||
}
|
||||
|
||||
/// A train/test index split with a proof-of-disjointness certificate.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StrictSplit {
|
||||
/// Training sample indices.
|
||||
pub train: Vec<usize>,
|
||||
/// Held-out test sample indices.
|
||||
pub test: Vec<usize>,
|
||||
/// Dimension that was held out.
|
||||
pub dim: PartitionDim,
|
||||
}
|
||||
|
||||
impl StrictSplit {
|
||||
/// Splits by holding out every sample whose `dim` value is in
|
||||
/// `holdout_values`. Guaranteed disjoint by construction; [`Self::verify`]
|
||||
/// re-checks it independently (belt and braces for downstream callers
|
||||
/// that mutate splits).
|
||||
#[must_use]
|
||||
pub fn holdout(keys: &[PartitionKey], dim: PartitionDim, holdout_values: &[&str]) -> Self {
|
||||
let held: BTreeSet<&str> = holdout_values.iter().copied().collect();
|
||||
let mut train = Vec::new();
|
||||
let mut test = Vec::new();
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
if held.contains(dim.value(k)) {
|
||||
test.push(i);
|
||||
} else {
|
||||
train.push(i);
|
||||
}
|
||||
}
|
||||
Self { train, test, dim }
|
||||
}
|
||||
|
||||
/// Independently verifies that no held-out dimension value appears on
|
||||
/// the training side (and vice versa). Returns `false` on any leak.
|
||||
#[must_use]
|
||||
pub fn verify(&self, keys: &[PartitionKey]) -> bool {
|
||||
let train_vals: BTreeSet<&str> =
|
||||
self.train.iter().map(|&i| self.dim.value(&keys[i])).collect();
|
||||
let test_vals: BTreeSet<&str> =
|
||||
self.test.iter().map(|&i| self.dim.value(&keys[i])).collect();
|
||||
train_vals.is_disjoint(&test_vals)
|
||||
}
|
||||
}
|
||||
|
||||
/// Expected Calibration Error over equal-width confidence bins.
|
||||
///
|
||||
/// `probs[i]` is the predicted probability of the positive class;
|
||||
/// `labels[i]` the truth. ECE = Σ (|bin|/N) · |accuracy(bin) − confidence(bin)|.
|
||||
#[must_use]
|
||||
pub fn expected_calibration_error(probs: &[f64], labels: &[bool], n_bins: usize) -> f64 {
|
||||
assert_eq!(probs.len(), labels.len());
|
||||
assert!(n_bins > 0);
|
||||
let n = probs.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut ece = 0.0;
|
||||
for b in 0..n_bins {
|
||||
let lo = b as f64 / n_bins as f64;
|
||||
let hi = (b + 1) as f64 / n_bins as f64;
|
||||
let mut count = 0usize;
|
||||
let mut conf_sum = 0.0;
|
||||
let mut acc_sum = 0.0;
|
||||
for (p, &y) in probs.iter().zip(labels) {
|
||||
// Confidence of the *predicted* class.
|
||||
let (conf, pred) = if *p >= 0.5 { (*p, true) } else { (1.0 - *p, false) };
|
||||
let in_bin = if b == n_bins - 1 { conf >= lo && conf <= hi } else { conf >= lo && conf < hi };
|
||||
if in_bin {
|
||||
count += 1;
|
||||
conf_sum += conf;
|
||||
acc_sum += f64::from(pred == y);
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
let cf = count as f64;
|
||||
ece += (cf / n as f64) * ((acc_sum / cf) - (conf_sum / cf)).abs();
|
||||
}
|
||||
}
|
||||
ece
|
||||
}
|
||||
|
||||
/// Coverage/selective-risk pair at a confidence threshold `tau`: predictions
|
||||
/// with confidence below `tau` abstain (become *no decision*).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SelectiveMetrics {
|
||||
/// Fraction of samples on which a decision was made.
|
||||
pub coverage: f64,
|
||||
/// Error rate among decided samples (0 when nothing decided).
|
||||
pub selective_risk: f64,
|
||||
}
|
||||
|
||||
/// Computes coverage and selective risk at threshold `tau` ∈ [0.5, 1].
|
||||
#[must_use]
|
||||
pub fn selective_metrics(probs: &[f64], labels: &[bool], tau: f64) -> SelectiveMetrics {
|
||||
assert_eq!(probs.len(), labels.len());
|
||||
let mut decided = 0usize;
|
||||
let mut wrong = 0usize;
|
||||
for (p, &y) in probs.iter().zip(labels) {
|
||||
let (conf, pred) = if *p >= 0.5 { (*p, true) } else { (1.0 - *p, false) };
|
||||
if conf >= tau {
|
||||
decided += 1;
|
||||
if pred != y {
|
||||
wrong += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
SelectiveMetrics {
|
||||
coverage: decided as f64 / probs.len().max(1) as f64,
|
||||
selective_risk: if decided == 0 { 0.0 } else { wrong as f64 / decided as f64 },
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative degradation from a known-condition metric to an
|
||||
/// unknown-condition metric (higher-is-better metrics such as F1).
|
||||
/// ADR-273's acceptance gate: `< 0.20`.
|
||||
#[must_use]
|
||||
pub fn relative_degradation(known: f64, unknown: f64) -> f64 {
|
||||
if known <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(known - unknown) / known
|
||||
}
|
||||
|
||||
/// Binary F1 score.
|
||||
#[must_use]
|
||||
pub fn f1_score(predictions: &[bool], labels: &[bool]) -> f64 {
|
||||
assert_eq!(predictions.len(), labels.len());
|
||||
let mut tp = 0.0;
|
||||
let mut fp = 0.0;
|
||||
let mut fn_ = 0.0;
|
||||
for (&p, &y) in predictions.iter().zip(labels) {
|
||||
match (p, y) {
|
||||
(true, true) => tp += 1.0,
|
||||
(true, false) => fp += 1.0,
|
||||
(false, true) => fn_ += 1.0,
|
||||
(false, false) => {}
|
||||
}
|
||||
}
|
||||
if tp == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let precision = tp / (tp + fp);
|
||||
let recall = tp / (tp + fn_);
|
||||
2.0 * precision * recall / (precision + recall)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn keys() -> Vec<PartitionKey> {
|
||||
let mut out = Vec::new();
|
||||
for room in ["room-a", "room-b", "room-c"] {
|
||||
for day in ["d1", "d2"] {
|
||||
for person in ["p1", "p2"] {
|
||||
out.push(PartitionKey {
|
||||
room: room.into(),
|
||||
day: day.into(),
|
||||
person: person.into(),
|
||||
chipset: "esp32s3".into(),
|
||||
firmware: "v1.2".into(),
|
||||
layout: "L".into(),
|
||||
session: format!("{room}-{day}-{person}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_split_holds_out_complete_rooms() {
|
||||
let keys = keys();
|
||||
let split = StrictSplit::holdout(&keys, PartitionDim::Room, &["room-c"]);
|
||||
assert_eq!(split.test.len(), 4);
|
||||
assert_eq!(split.train.len(), 8);
|
||||
assert!(split.verify(&keys), "disjointness certificate must hold");
|
||||
for &i in &split.test {
|
||||
assert_eq!(keys[i].room, "room-c");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_catches_a_manufactured_leak() {
|
||||
let keys = keys();
|
||||
let mut split = StrictSplit::holdout(&keys, PartitionDim::Room, &["room-c"]);
|
||||
// Manufacture a leak: push a room-c sample into training.
|
||||
split.train.push(split.test[0]);
|
||||
assert!(!split.verify(&keys), "leak must be detected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_manifest_certifies_per_dimension_disjointness() {
|
||||
let keys = keys();
|
||||
let split = StrictSplit::holdout(&keys, PartitionDim::Room, &["room-c"]);
|
||||
let manifest = SplitManifest::build(&keys, &split.train, &split.test);
|
||||
// Rooms are disjoint (and sessions, which embed the room)…
|
||||
assert!(manifest.is_disjoint(PartitionDim::Room));
|
||||
assert!(manifest.is_disjoint(PartitionDim::Session));
|
||||
// …but people/days/chipsets are shared, and the manifest says so
|
||||
// instead of letting the split masquerade as fully leakage-free.
|
||||
assert!(!manifest.is_disjoint(PartitionDim::Person));
|
||||
assert!(!manifest.is_disjoint(PartitionDim::Chipset));
|
||||
assert!(!manifest.fully_disjoint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpjpe_basics() {
|
||||
let a = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
|
||||
let b = [[0.0, 0.0, 0.1], [1.0, 0.0, 0.0]];
|
||||
assert!((mpjpe(&a, &b) - 0.05).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ece_is_low_for_calibrated_and_high_for_overconfident() {
|
||||
// Calibrated: p = 0.8 predictions that are right 80 % of the time.
|
||||
let mut probs = Vec::new();
|
||||
let mut labels = Vec::new();
|
||||
for i in 0..100 {
|
||||
probs.push(0.8);
|
||||
labels.push(i % 10 < 8);
|
||||
}
|
||||
let ece = expected_calibration_error(&probs, &labels, 10);
|
||||
assert!(ece < 0.02, "calibrated ECE should be tiny, got {ece}");
|
||||
|
||||
// Overconfident: p = 0.99 but only 60 % correct.
|
||||
let mut probs = Vec::new();
|
||||
let mut labels = Vec::new();
|
||||
for i in 0..100 {
|
||||
probs.push(0.99);
|
||||
labels.push(i % 10 < 6);
|
||||
}
|
||||
let ece = expected_calibration_error(&probs, &labels, 10);
|
||||
assert!(ece > 0.3, "overconfident ECE should be large, got {ece}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abstention_trades_coverage_for_risk() {
|
||||
// Confident predictions are correct; near-0.5 ones are coin flips.
|
||||
let mut probs = Vec::new();
|
||||
let mut labels = Vec::new();
|
||||
for i in 0..50 {
|
||||
probs.push(0.95);
|
||||
labels.push(true);
|
||||
probs.push(0.55);
|
||||
labels.push(i % 2 == 0); // half wrong at low confidence
|
||||
}
|
||||
let loose = selective_metrics(&probs, &labels, 0.5);
|
||||
let strict = selective_metrics(&probs, &labels, 0.9);
|
||||
assert!(strict.coverage < loose.coverage);
|
||||
assert!(
|
||||
strict.selective_risk < loose.selective_risk,
|
||||
"raising the threshold must lower risk: {strict:?} vs {loose:?}"
|
||||
);
|
||||
assert!((strict.selective_risk - 0.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_and_degradation_basics() {
|
||||
let preds = [true, true, false, true];
|
||||
let labels = [true, false, false, true];
|
||||
// tp=2 fp=1 fn=0 → precision 2/3, recall 1 → F1 = 0.8.
|
||||
assert!((f1_score(&preds, &labels) - 0.8).abs() < 1e-12);
|
||||
assert!((relative_degradation(0.95, 0.85) - 0.105_263).abs() < 1e-4);
|
||||
assert!((relative_degradation(0.0, 0.5)).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
//! Native RF frame contract — `RfFrameV2` (ADR-279).
|
||||
//!
|
||||
//! The ADR-273 P1 canonical tensor (`RfTensor`, 56 bins × 8 snapshots) is
|
||||
//! useful for compatibility, but it must **not** be the authoritative data
|
||||
//! format: resampling every device into one fixed tensor discards
|
||||
//! bandwidth, antenna, phase-state, and hardware-specific information.
|
||||
//! `RfFrameV2` preserves the **native complex tensor** with explicit
|
||||
//! validity masks, phase state, geometry, calibration, quality, and
|
||||
//! provenance; the canonical tensor is demoted to a *derived view*
|
||||
//! ([`RfFrameV2::to_canonical`]) computed on demand and never written back.
|
||||
//!
|
||||
//! Required invariants (ADR-279 §2, each enforced by a constructor check or
|
||||
//! a test):
|
||||
//! 1. Native complex samples are never overwritten by normalized samples
|
||||
//! (`to_canonical` takes `&self`; test proves byte-stability).
|
||||
//! 2. Subcarrier/antenna masks are explicit (`valid_mask`).
|
||||
//! 3. Phase declares its state: raw, sanitized, calibrated, or unavailable.
|
||||
//! 4. TX/RX geometry uses one building coordinate frame (`Pose3`).
|
||||
//! 5. Results retain source frame ids + model version (via `receipt_id`).
|
||||
//! 6. Synthetic and measured frames can never share a provenance class,
|
||||
//! and a synthetic frame can never claim evidence above L0.
|
||||
//! 7. Sample age is carried through the whole inference path.
|
||||
|
||||
use num_complex::Complex64;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::tensor::{LinkGeometry, RfModality, RfTensor};
|
||||
use crate::{Result, UnifiedError};
|
||||
|
||||
/// Current schema version of [`RfFrameV2`].
|
||||
pub const SCHEMA_VERSION: u16 = 2;
|
||||
|
||||
/// A pose in the building coordinate frame (metres, unit quaternion).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Pose3 {
|
||||
/// Position `[x, y, z]`, metres.
|
||||
pub position_m: [f64; 3],
|
||||
/// Orientation quaternion `[w, x, y, z]`.
|
||||
pub orientation: [f64; 4],
|
||||
}
|
||||
|
||||
/// One antenna element of an array.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AntennaElement {
|
||||
/// Element position relative to the device pose, metres.
|
||||
pub position_m: [f64; 3],
|
||||
/// Element gain, dBi.
|
||||
pub gain_dbi: f64,
|
||||
}
|
||||
|
||||
/// Declared state of the phase axis — consumers must branch on this
|
||||
/// instead of guessing whether detrending already happened.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PhaseState {
|
||||
/// As captured; CFO/STO artifacts present.
|
||||
Raw,
|
||||
/// Linear ramp + constant offset removed (ADR-274 stage 3).
|
||||
Sanitized,
|
||||
/// Hardware/baseline calibrated upstream.
|
||||
Calibrated,
|
||||
/// Magnitude-only capture (e.g. some vendor RSSI/BF reports).
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Calibration state carried by every native frame.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CalibrationState {
|
||||
/// Phase axis state.
|
||||
pub phase_state: PhaseState,
|
||||
/// Whether amplitude gain has been calibrated.
|
||||
pub gain_calibrated: bool,
|
||||
/// Oscillator drift, ppm.
|
||||
pub clock_ppm: f64,
|
||||
/// Empty-room baseline applied, if any (ADR-135).
|
||||
pub baseline_id: Option<String>,
|
||||
/// Calibration confidence in `[0, 1]`.
|
||||
pub confidence: f64,
|
||||
}
|
||||
|
||||
/// Front-end quality indicators.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SignalQuality {
|
||||
/// Received signal strength, dBm.
|
||||
pub rssi_dbm: f64,
|
||||
/// Noise floor, dBm.
|
||||
pub noise_floor_dbm: f64,
|
||||
/// Fraction of expected packets lost in the capture window `[0, 1]`.
|
||||
pub packet_loss: f64,
|
||||
/// Interference score `[0, 1]` (0 = clean).
|
||||
pub interference: f64,
|
||||
}
|
||||
|
||||
/// Whether the evidence is measured or synthetic. The two classes can
|
||||
/// never alias: there is no third variant and no default.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProvenanceClass {
|
||||
/// Captured from real hardware.
|
||||
Measured,
|
||||
/// Produced by a simulator/generator (ADR-276).
|
||||
Synthetic,
|
||||
}
|
||||
|
||||
/// The public evidence ladder (ADR-282 §4): every capability and every
|
||||
/// dataset carries exactly one level.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum EvidenceLevel {
|
||||
/// Level 0 — simulation only.
|
||||
L0Simulation,
|
||||
/// Level 1 — captured replay of real signals.
|
||||
L1CapturedReplay,
|
||||
/// Level 2 — controlled laboratory.
|
||||
L2Lab,
|
||||
/// Level 3 — held-out room and subject validation.
|
||||
L3HeldOutValidation,
|
||||
/// Level 4 — multi-site field pilot.
|
||||
L4MultisiteField,
|
||||
/// Level 5 — production operational evidence.
|
||||
L5Production,
|
||||
}
|
||||
|
||||
/// Frame provenance: class + evidence level + source identity.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FrameProvenance {
|
||||
/// Measured vs synthetic (invariant 6).
|
||||
pub class: ProvenanceClass,
|
||||
/// Evidence-ladder level.
|
||||
pub evidence: EvidenceLevel,
|
||||
/// Capturing device identifier.
|
||||
pub device_id: String,
|
||||
/// Firmware version string.
|
||||
pub firmware: String,
|
||||
/// Receipt id linking results back to this frame (invariant 5).
|
||||
pub receipt_id: u128,
|
||||
}
|
||||
|
||||
/// Native axes a frame's tensor may be laid out over (delay-Doppler-native
|
||||
/// modalities such as OTFS ISAC must not be collapsed into scalar motion
|
||||
/// energy before storage — ADR-281 §3).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FieldAxis {
|
||||
/// Sample time.
|
||||
Time,
|
||||
/// Subcarrier / frequency.
|
||||
Frequency,
|
||||
/// Delay (multipath arrival).
|
||||
Delay,
|
||||
/// Doppler shift.
|
||||
Doppler,
|
||||
/// Radar range bin.
|
||||
Range,
|
||||
/// Azimuth angle.
|
||||
Azimuth,
|
||||
/// Elevation angle.
|
||||
Elevation,
|
||||
/// Antenna element.
|
||||
Antenna,
|
||||
/// Polarization.
|
||||
Polarization,
|
||||
}
|
||||
|
||||
/// The authoritative native RF frame (ADR-279 §2).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RfFrameV2 {
|
||||
/// Schema version ([`SCHEMA_VERSION`]).
|
||||
pub schema_version: u16,
|
||||
/// Unique frame id.
|
||||
pub frame_id: u128,
|
||||
/// Capture timestamp, ns since epoch.
|
||||
pub timestamp_ns: u64,
|
||||
/// Modality.
|
||||
pub modality: RfModality,
|
||||
/// Native axis semantics, one entry per dimension of `native_shape`.
|
||||
pub axes: Vec<FieldAxis>,
|
||||
/// Carrier centre frequency, Hz.
|
||||
pub centre_frequency_hz: f64,
|
||||
/// Occupied bandwidth, Hz.
|
||||
pub bandwidth_hz: f64,
|
||||
/// Native sample rate along the time-like axis, Hz.
|
||||
pub sample_rate_hz: f64,
|
||||
/// Native tensor shape (arbitrary rank), row-major over `native_iq`.
|
||||
pub native_shape: Vec<usize>,
|
||||
/// Native complex samples — **never overwritten** (invariant 1).
|
||||
pub native_iq: Vec<Complex64>,
|
||||
/// Per-sample validity mask (invariant 2), same length as `native_iq`.
|
||||
pub valid_mask: Vec<bool>,
|
||||
/// Transmitter pose in the building frame, when known.
|
||||
pub transmitter_pose: Option<Pose3>,
|
||||
/// Receiver pose in the building frame, when known.
|
||||
pub receiver_pose: Option<Pose3>,
|
||||
/// Antenna elements of the capturing array.
|
||||
pub antenna_geometry: Vec<AntennaElement>,
|
||||
/// Age of the capture at hand-off, ns (invariant 7).
|
||||
pub sample_age_ns: u64,
|
||||
/// Calibration state (invariant 3).
|
||||
pub calibration: CalibrationState,
|
||||
/// Signal quality.
|
||||
pub quality: SignalQuality,
|
||||
/// Provenance (invariants 5–6).
|
||||
pub provenance: FrameProvenance,
|
||||
}
|
||||
|
||||
impl RfFrameV2 {
|
||||
/// Validated constructor — the only way to build a native frame.
|
||||
///
|
||||
/// Enforced here: shape/product/mask arity, finite samples on valid
|
||||
/// positions, positive frequencies, axes rank match, and the
|
||||
/// provenance-class ⇄ evidence-level consistency rule:
|
||||
/// `Synthetic ⇒ exactly L0Simulation`, `Measured ⇒ at least
|
||||
/// L1CapturedReplay` — so synthetic evidence can never masquerade as
|
||||
/// field evidence, and vice versa (invariant 6).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
frame_id: u128,
|
||||
timestamp_ns: u64,
|
||||
modality: RfModality,
|
||||
axes: Vec<FieldAxis>,
|
||||
centre_frequency_hz: f64,
|
||||
bandwidth_hz: f64,
|
||||
sample_rate_hz: f64,
|
||||
native_shape: Vec<usize>,
|
||||
native_iq: Vec<Complex64>,
|
||||
valid_mask: Vec<bool>,
|
||||
transmitter_pose: Option<Pose3>,
|
||||
receiver_pose: Option<Pose3>,
|
||||
antenna_geometry: Vec<AntennaElement>,
|
||||
sample_age_ns: u64,
|
||||
calibration: CalibrationState,
|
||||
quality: SignalQuality,
|
||||
provenance: FrameProvenance,
|
||||
) -> Result<Self> {
|
||||
let expected: usize = native_shape.iter().product();
|
||||
if native_shape.is_empty() || expected == 0 {
|
||||
return Err(UnifiedError::ShapeMismatch("empty native shape".into()));
|
||||
}
|
||||
if native_iq.len() != expected {
|
||||
return Err(UnifiedError::ShapeMismatch(format!(
|
||||
"native_iq has {} samples, shape {:?} implies {expected}",
|
||||
native_iq.len(),
|
||||
native_shape
|
||||
)));
|
||||
}
|
||||
if valid_mask.len() != expected {
|
||||
return Err(UnifiedError::ShapeMismatch(format!(
|
||||
"valid_mask has {} entries, expected {expected}",
|
||||
valid_mask.len()
|
||||
)));
|
||||
}
|
||||
if axes.len() != native_shape.len() {
|
||||
return Err(UnifiedError::ShapeMismatch(format!(
|
||||
"{} axes declared for rank-{} tensor",
|
||||
axes.len(),
|
||||
native_shape.len()
|
||||
)));
|
||||
}
|
||||
if !(centre_frequency_hz.is_finite()
|
||||
&& centre_frequency_hz > 0.0
|
||||
&& bandwidth_hz.is_finite()
|
||||
&& bandwidth_hz > 0.0
|
||||
&& sample_rate_hz.is_finite()
|
||||
&& sample_rate_hz > 0.0)
|
||||
{
|
||||
return Err(UnifiedError::InvalidInput(
|
||||
"frequencies and sample rate must be finite and positive".into(),
|
||||
));
|
||||
}
|
||||
for (z, ok) in native_iq.iter().zip(&valid_mask) {
|
||||
if *ok && (!z.re.is_finite() || !z.im.is_finite()) {
|
||||
return Err(UnifiedError::InvalidInput(
|
||||
"non-finite sample marked valid".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !(0.0..=1.0).contains(&calibration.confidence) {
|
||||
return Err(UnifiedError::InvalidInput(
|
||||
"calibration confidence must be in [0,1]".into(),
|
||||
));
|
||||
}
|
||||
match (provenance.class, provenance.evidence) {
|
||||
(ProvenanceClass::Synthetic, EvidenceLevel::L0Simulation) => {}
|
||||
(ProvenanceClass::Synthetic, level) => {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"synthetic frames are L0Simulation by definition, got {level:?}"
|
||||
)));
|
||||
}
|
||||
(ProvenanceClass::Measured, EvidenceLevel::L0Simulation) => {
|
||||
return Err(UnifiedError::InvalidInput(
|
||||
"measured frames cannot claim L0Simulation".into(),
|
||||
));
|
||||
}
|
||||
(ProvenanceClass::Measured, _) => {}
|
||||
}
|
||||
Ok(Self {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
frame_id,
|
||||
timestamp_ns,
|
||||
modality,
|
||||
axes,
|
||||
centre_frequency_hz,
|
||||
bandwidth_hz,
|
||||
sample_rate_hz,
|
||||
native_shape,
|
||||
native_iq,
|
||||
valid_mask,
|
||||
transmitter_pose,
|
||||
receiver_pose,
|
||||
antenna_geometry,
|
||||
sample_age_ns,
|
||||
calibration,
|
||||
quality,
|
||||
provenance,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fraction of valid samples.
|
||||
#[must_use]
|
||||
pub fn valid_fraction(&self) -> f64 {
|
||||
self.valid_mask.iter().filter(|v| **v).count() as f64 / self.valid_mask.len() as f64
|
||||
}
|
||||
|
||||
/// Derived compatibility view (ADR-279 §3): projects a rank-3
|
||||
/// `(links, bins, snapshots)` native frame into the ADR-274 canonical
|
||||
/// tensor. Invalid samples are filled by linear interpolation from the
|
||||
/// nearest valid bins on the same `(link, snapshot)` column before
|
||||
/// resampling. The native frame is untouched (`&self`).
|
||||
pub fn to_canonical(&self, links: Vec<LinkGeometry>) -> Result<RfTensor> {
|
||||
if self.native_shape.len() != 3 {
|
||||
return Err(UnifiedError::ShapeMismatch(format!(
|
||||
"canonical view needs a rank-3 (links, bins, snapshots) frame, got rank {}",
|
||||
self.native_shape.len()
|
||||
)));
|
||||
}
|
||||
let (n_links, n_bins, n_snaps) =
|
||||
(self.native_shape[0], self.native_shape[1], self.native_shape[2]);
|
||||
if links.len() != n_links {
|
||||
return Err(UnifiedError::ShapeMismatch(format!(
|
||||
"geometry for {} links, frame has {n_links}",
|
||||
links.len()
|
||||
)));
|
||||
}
|
||||
// Gap-fill invalid bins per (link, snapshot) column, then hand a
|
||||
// dense grid to the shared normalization used by every adapter.
|
||||
let mut grid = ndarray::Array3::zeros((n_links, n_bins, n_snaps));
|
||||
for l in 0..n_links {
|
||||
for s in 0..n_snaps {
|
||||
let at = |b: usize| l * n_bins * n_snaps + b * n_snaps + s;
|
||||
let valid: Vec<usize> = (0..n_bins).filter(|b| self.valid_mask[at(*b)]).collect();
|
||||
if valid.is_empty() {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"link {l} snapshot {s} has no valid bins"
|
||||
)));
|
||||
}
|
||||
for b in 0..n_bins {
|
||||
let v = if self.valid_mask[at(b)] {
|
||||
self.native_iq[at(b)]
|
||||
} else {
|
||||
// Nearest valid neighbors, linear on the complex plane.
|
||||
let before = valid.iter().rev().find(|x| **x < b);
|
||||
let after = valid.iter().find(|x| **x > b);
|
||||
match (before, after) {
|
||||
(Some(&lo), Some(&hi)) => {
|
||||
let t = (b - lo) as f64 / (hi - lo) as f64;
|
||||
self.native_iq[at(lo)] * (1.0 - t) + self.native_iq[at(hi)] * t
|
||||
}
|
||||
(Some(&lo), None) => self.native_iq[at(lo)],
|
||||
(None, Some(&hi)) => self.native_iq[at(hi)],
|
||||
(None, None) => unreachable!("valid is non-empty"),
|
||||
}
|
||||
};
|
||||
grid[[l, b, s]] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
let uncertainty = {
|
||||
let snr = self.quality.rssi_dbm - self.quality.noise_floor_dbm;
|
||||
(1.0 - snr / 40.0).clamp(0.0, 1.0)
|
||||
};
|
||||
crate::adapters::normalize_grid(
|
||||
self.modality,
|
||||
grid,
|
||||
links,
|
||||
self.centre_frequency_hz,
|
||||
self.bandwidth_hz,
|
||||
self.sample_age_ns as f64 / 1e9,
|
||||
self.timestamp_ns,
|
||||
self.provenance.device_id.clone(),
|
||||
(1.0 - self.calibration.clock_ppm / 40.0).clamp(0.0, 1.0),
|
||||
uncertainty,
|
||||
matches!(self.calibration.phase_state, PhaseState::Sanitized | PhaseState::Calibrated),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// IEEE P3162-profile synthetic-aperture channel-sounding import
|
||||
/// (ADR-281 §5): the calibration bridge between measured environments,
|
||||
/// simulators, and learned RF scene models.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyntheticApertureSoundingDataset {
|
||||
/// Sounded frequency range `[low, high]`, Hz.
|
||||
pub frequency_range_hz: [f64; 2],
|
||||
/// Aperture element poses (building frame).
|
||||
pub aperture_geometry: Vec<Pose3>,
|
||||
/// Directional power-delay profile, `(direction, delay)` row-major.
|
||||
pub directional_pdp: Vec<f64>,
|
||||
/// PDP shape.
|
||||
pub pdp_shape: [usize; 2],
|
||||
/// Coordinate system identifier (P3162 vocabulary).
|
||||
pub coordinate_system: String,
|
||||
/// Hash of the processing manifest that produced the dataset.
|
||||
pub processing_manifest_hash: u64,
|
||||
}
|
||||
|
||||
impl SyntheticApertureSoundingDataset {
|
||||
/// Validated constructor.
|
||||
pub fn new(
|
||||
frequency_range_hz: [f64; 2],
|
||||
aperture_geometry: Vec<Pose3>,
|
||||
directional_pdp: Vec<f64>,
|
||||
pdp_shape: [usize; 2],
|
||||
coordinate_system: impl Into<String>,
|
||||
processing_manifest_hash: u64,
|
||||
) -> Result<Self> {
|
||||
if !(frequency_range_hz[0] > 0.0 && frequency_range_hz[1] > frequency_range_hz[0]) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"frequency range must be ordered and positive, got {frequency_range_hz:?}"
|
||||
)));
|
||||
}
|
||||
if aperture_geometry.is_empty() {
|
||||
return Err(UnifiedError::InvalidInput("empty aperture geometry".into()));
|
||||
}
|
||||
if directional_pdp.len() != pdp_shape[0] * pdp_shape[1] {
|
||||
return Err(UnifiedError::ShapeMismatch(format!(
|
||||
"PDP has {} entries, shape {pdp_shape:?} implies {}",
|
||||
directional_pdp.len(),
|
||||
pdp_shape[0] * pdp_shape[1]
|
||||
)));
|
||||
}
|
||||
if directional_pdp.iter().any(|v| !v.is_finite() || *v < 0.0) {
|
||||
return Err(UnifiedError::InvalidInput("PDP entries must be finite power".into()));
|
||||
}
|
||||
Ok(Self {
|
||||
frequency_range_hz,
|
||||
aperture_geometry,
|
||||
directional_pdp,
|
||||
pdp_shape,
|
||||
coordinate_system: coordinate_system.into(),
|
||||
processing_manifest_hash,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tensor::{CANONICAL_BINS, CANONICAL_SNAPSHOTS};
|
||||
|
||||
fn quality() -> SignalQuality {
|
||||
SignalQuality { rssi_dbm: -45.0, noise_floor_dbm: -92.0, packet_loss: 0.02, interference: 0.05 }
|
||||
}
|
||||
|
||||
fn calibration(phase: PhaseState) -> CalibrationState {
|
||||
CalibrationState {
|
||||
phase_state: phase,
|
||||
gain_calibrated: false,
|
||||
clock_ppm: 12.0,
|
||||
baseline_id: None,
|
||||
confidence: 0.8,
|
||||
}
|
||||
}
|
||||
|
||||
fn provenance(class: ProvenanceClass, evidence: EvidenceLevel) -> FrameProvenance {
|
||||
FrameProvenance {
|
||||
class,
|
||||
evidence,
|
||||
device_id: "esp32s3-a1".into(),
|
||||
firmware: "fw-2.1".into(),
|
||||
receipt_id: 42,
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(shape: Vec<usize>, mask_off: &[usize]) -> RfFrameV2 {
|
||||
let n: usize = shape.iter().product();
|
||||
let iq: Vec<Complex64> = (0..n)
|
||||
.map(|i| Complex64::new(1.0 + 0.01 * (i % 13) as f64, 0.002 * (i % 7) as f64))
|
||||
.collect();
|
||||
let mut mask = vec![true; n];
|
||||
for &i in mask_off {
|
||||
mask[i] = false;
|
||||
}
|
||||
RfFrameV2::new(
|
||||
7,
|
||||
1_000,
|
||||
RfModality::WifiCsi,
|
||||
vec![FieldAxis::Antenna, FieldAxis::Frequency, FieldAxis::Time],
|
||||
2.437e9,
|
||||
20e6,
|
||||
100.0,
|
||||
shape,
|
||||
iq,
|
||||
mask,
|
||||
Some(Pose3 { position_m: [0.0, 0.0, 2.0], orientation: [1.0, 0.0, 0.0, 0.0] }),
|
||||
Some(Pose3 { position_m: [4.0, 0.0, 2.0], orientation: [1.0, 0.0, 0.0, 0.0] }),
|
||||
vec![AntennaElement { position_m: [0.0; 3], gain_dbi: 2.0 }],
|
||||
5_000_000,
|
||||
calibration(PhaseState::Raw),
|
||||
quality(),
|
||||
provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab),
|
||||
)
|
||||
.expect("valid frame")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_and_measured_provenance_can_never_alias() {
|
||||
let build = |class, evidence| {
|
||||
RfFrameV2::new(
|
||||
1,
|
||||
0,
|
||||
RfModality::Synthetic,
|
||||
vec![FieldAxis::Frequency],
|
||||
2.4e9,
|
||||
20e6,
|
||||
100.0,
|
||||
vec![4],
|
||||
vec![Complex64::new(1.0, 0.0); 4],
|
||||
vec![true; 4],
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
0,
|
||||
calibration(PhaseState::Sanitized),
|
||||
quality(),
|
||||
provenance(class, evidence),
|
||||
)
|
||||
};
|
||||
// Synthetic above L0 is refused.
|
||||
assert!(build(ProvenanceClass::Synthetic, EvidenceLevel::L3HeldOutValidation).is_err());
|
||||
// Measured claiming L0 is refused.
|
||||
assert!(build(ProvenanceClass::Measured, EvidenceLevel::L0Simulation).is_err());
|
||||
// The two legal pairings work.
|
||||
assert!(build(ProvenanceClass::Synthetic, EvidenceLevel::L0Simulation).is_ok());
|
||||
assert!(build(ProvenanceClass::Measured, EvidenceLevel::L1CapturedReplay).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_enforces_shape_mask_and_axes_arity() {
|
||||
let n = 2 * 10 * 4;
|
||||
let iq = vec![Complex64::new(1.0, 0.0); n];
|
||||
let bad_mask = RfFrameV2::new(
|
||||
1,
|
||||
0,
|
||||
RfModality::WifiCsi,
|
||||
vec![FieldAxis::Antenna, FieldAxis::Frequency, FieldAxis::Time],
|
||||
2.4e9,
|
||||
20e6,
|
||||
100.0,
|
||||
vec![2, 10, 4],
|
||||
iq.clone(),
|
||||
vec![true; n - 1],
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
0,
|
||||
calibration(PhaseState::Raw),
|
||||
quality(),
|
||||
provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab),
|
||||
);
|
||||
assert!(matches!(bad_mask, Err(UnifiedError::ShapeMismatch(_))));
|
||||
|
||||
let bad_axes = RfFrameV2::new(
|
||||
1,
|
||||
0,
|
||||
RfModality::WifiCsi,
|
||||
vec![FieldAxis::Frequency],
|
||||
2.4e9,
|
||||
20e6,
|
||||
100.0,
|
||||
vec![2, 10, 4],
|
||||
iq,
|
||||
vec![true; n],
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
0,
|
||||
calibration(PhaseState::Raw),
|
||||
quality(),
|
||||
provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab),
|
||||
);
|
||||
assert!(matches!(bad_axes, Err(UnifiedError::ShapeMismatch(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_view_is_derived_and_native_is_untouched() {
|
||||
// 114-subcarrier native with two masked-out bins.
|
||||
let f = frame(vec![1, 114, 12], &[5 * 12, 60 * 12 + 3]);
|
||||
let native_before = f.native_iq.clone();
|
||||
let mask_before = f.valid_mask.clone();
|
||||
|
||||
let t = f
|
||||
.to_canonical(vec![LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.0, 2.0] }])
|
||||
.expect("derived view");
|
||||
assert_eq!(t.dims(), (1, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
assert!(t.data.iter().all(|z| z.re.is_finite() && z.im.is_finite()));
|
||||
|
||||
// Invariant 1: the native samples and mask are byte-identical after
|
||||
// deriving the view — normalization never writes back.
|
||||
assert_eq!(f.native_iq, native_before);
|
||||
assert_eq!(f.valid_mask, mask_before);
|
||||
assert!((f.valid_fraction() - (114.0 * 12.0 - 2.0) / (114.0 * 12.0)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_view_rejects_wrong_rank_or_geometry() {
|
||||
let f = frame(vec![2, 10, 4], &[]);
|
||||
assert!(f.to_canonical(vec![]).is_err());
|
||||
// Rank-1 frame has no canonical projection.
|
||||
let flat = RfFrameV2::new(
|
||||
9,
|
||||
0,
|
||||
RfModality::WifiCsi,
|
||||
vec![FieldAxis::Frequency],
|
||||
2.4e9,
|
||||
20e6,
|
||||
100.0,
|
||||
vec![80],
|
||||
vec![Complex64::new(1.0, 0.0); 80],
|
||||
vec![true; 80],
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
0,
|
||||
calibration(PhaseState::Raw),
|
||||
quality(),
|
||||
provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab),
|
||||
)
|
||||
.expect("rank-1 frame is a valid native frame");
|
||||
assert!(flat
|
||||
.to_canonical(vec![LinkGeometry { tx_pos: [0.0; 3], rx_pos: [1.0, 0.0, 0.0] }])
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_aperture_profile_validates() {
|
||||
let ok = SyntheticApertureSoundingDataset::new(
|
||||
[3.0e9, 10.0e9],
|
||||
vec![Pose3 { position_m: [0.0; 3], orientation: [1.0, 0.0, 0.0, 0.0] }],
|
||||
vec![0.5; 8 * 16],
|
||||
[8, 16],
|
||||
"P3162-spherical",
|
||||
0xABCD,
|
||||
);
|
||||
assert!(ok.is_ok());
|
||||
assert!(SyntheticApertureSoundingDataset::new(
|
||||
[10.0e9, 3.0e9], // unordered
|
||||
vec![Pose3 { position_m: [0.0; 3], orientation: [1.0, 0.0, 0.0, 0.0] }],
|
||||
vec![0.5; 4],
|
||||
[2, 2],
|
||||
"x",
|
||||
0
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Channel-gain queries against the Gaussian map, and the inverse update
|
||||
//! that makes the map *learn* from measured links (ADR-275 §4).
|
||||
//!
|
||||
//! Physics model: free-space Friis amplitude with Beer–Lambert extinction
|
||||
//! through the Gaussians,
|
||||
//!
|
||||
//! ```text
|
||||
//! H(tx,rx,f) = (λ / 4πd) · e^{-j·2πd/λ} · exp(-Σ_g occ_g · I_g)
|
||||
//! ```
|
||||
//!
|
||||
//! where `I_g = ∫₀ᴸ exp(-½·q_g(o + t·u)) dt` is the closed-form line
|
||||
//! integral of Gaussian `g`'s unnormalized density along the TX→RX segment
|
||||
//! (a 1-D Gaussian integral, evaluated with `erf`). Two exactness anchors
|
||||
//! make this testable: an **empty map returns exact Friis**, and adding an
|
||||
//! absorber strictly, monotonically reduces gain.
|
||||
|
||||
use num_complex::Complex64;
|
||||
|
||||
use super::map::GaussianMap;
|
||||
use super::primitive::{Provenance, RfGaussian};
|
||||
use crate::math::erf;
|
||||
|
||||
const C: f64 = 299_792_458.0;
|
||||
|
||||
/// Closed-form line integral of `exp(-½·(x-μ)ᵀΣ⁻¹(x-μ))` along the segment
|
||||
/// `o → o + L·u` (`u` unit).
|
||||
///
|
||||
/// With `a = uᵀΣ⁻¹u`, `b = uᵀΣ⁻¹(μ−o)`, `c = (μ−o)ᵀΣ⁻¹(μ−o)`:
|
||||
/// `q(t) = a·(t − b/a)² + (c − b²/a)`, so
|
||||
/// `I = e^{-(c−b²/a)/2} · √(π/2a) · [erf(√(a/2)(L−t₀)) + erf(√(a/2)·t₀)]`.
|
||||
#[must_use]
|
||||
pub fn line_integral(g: &RfGaussian, o: [f64; 3], u: [f64; 3], len: f64) -> f64 {
|
||||
let si = g.inv_covariance();
|
||||
let mo = [g.position[0] - o[0], g.position[1] - o[1], g.position[2] - o[2]];
|
||||
let mut a = 0.0;
|
||||
let mut b = 0.0;
|
||||
let mut c = 0.0;
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
a += u[i] * si[i][j] * u[j];
|
||||
b += u[i] * si[i][j] * mo[j];
|
||||
c += mo[i] * si[i][j] * mo[j];
|
||||
}
|
||||
}
|
||||
if a <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let t0 = b / a;
|
||||
let peak = (-0.5 * (c - b * b / a)).exp();
|
||||
let s = (a / 2.0).sqrt();
|
||||
peak * (std::f64::consts::PI / (2.0 * a)).sqrt() * (erf(s * (len - t0)) + erf(s * t0))
|
||||
}
|
||||
|
||||
/// Total optical depth (nepers) of the map along a TX→RX segment, plus the
|
||||
/// per-Gaussian integrals for gradient use: `(τ, [(idx, I_g)])`.
|
||||
#[must_use]
|
||||
pub fn optical_depth(map: &GaussianMap, tx: [f64; 3], rx: [f64; 3]) -> (f64, Vec<(usize, f64)>) {
|
||||
let d = [rx[0] - tx[0], rx[1] - tx[1], rx[2] - tx[2]];
|
||||
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
if len < 1e-9 {
|
||||
return (0.0, Vec::new());
|
||||
}
|
||||
let u = [d[0] / len, d[1] / len, d[2] / len];
|
||||
// Candidate set: Gaussians within a 3 m corridor of the segment (3σ for
|
||||
// σ ≤ 1 m primitives), gathered by walking only the hash cells along
|
||||
// the link instead of a ball around its midpoint — see
|
||||
// `GaussianMap::query_near_segment` for the measured effect.
|
||||
let candidates = map.query_near_segment(tx, rx, 3.0);
|
||||
let mut tau = 0.0;
|
||||
let mut parts = Vec::new();
|
||||
for i in candidates {
|
||||
let g = &map.gaussians()[i];
|
||||
let integral = line_integral(g, tx, u, len);
|
||||
if integral > 1e-12 {
|
||||
tau += g.occupancy * integral;
|
||||
parts.push((i, integral));
|
||||
}
|
||||
}
|
||||
(tau, parts)
|
||||
}
|
||||
|
||||
/// Complex channel gain between two points at carrier `freq_hz`.
|
||||
///
|
||||
/// Empty map ⇒ exact free-space Friis amplitude `λ/(4πd)` with propagation
|
||||
/// phase `e^{-j2πd/λ}`.
|
||||
#[must_use]
|
||||
pub fn channel_gain(map: &GaussianMap, tx: [f64; 3], rx: [f64; 3], freq_hz: f64) -> Complex64 {
|
||||
let d = ((rx[0] - tx[0]).powi(2) + (rx[1] - tx[1]).powi(2) + (rx[2] - tx[2]).powi(2)).sqrt();
|
||||
if d < 1e-9 {
|
||||
return Complex64::new(1.0, 0.0);
|
||||
}
|
||||
let lambda = C / freq_hz;
|
||||
let amp = lambda / (4.0 * std::f64::consts::PI * d);
|
||||
let phase = -2.0 * std::f64::consts::PI * d / lambda;
|
||||
let (tau, _) = optical_depth(map, tx, rx);
|
||||
Complex64::from_polar(amp * (-tau).exp(), phase)
|
||||
}
|
||||
|
||||
/// Power gain in dB.
|
||||
#[must_use]
|
||||
pub fn gain_db(map: &GaussianMap, tx: [f64; 3], rx: [f64; 3], freq_hz: f64) -> f64 {
|
||||
20.0 * channel_gain(map, tx, rx, freq_hz).norm().log10()
|
||||
}
|
||||
|
||||
/// One inverse-update step from a measured link amplitude (ADR-275 §4.2 —
|
||||
/// the physics-informed incremental mapping move: when the environment
|
||||
/// changes, adjust or add a *compact* set of Gaussians instead of remapping).
|
||||
///
|
||||
/// The measured optical depth is `τ* = ln(friis_amp / measured_amp)`; the
|
||||
/// map's depth is `τ = Σ occ_g·I_g`. A projected-gradient step moves the
|
||||
/// intersected occupancies toward the residual `r = τ* − τ`:
|
||||
///
|
||||
/// `occ_g += lr · r · I_g / (Σ I_g² + ε)`, clamped at 0 —
|
||||
///
|
||||
/// which is exact Newton along this link's direction at `lr = 1`. When no
|
||||
/// Gaussian meaningfully intersects the segment and the residual demands
|
||||
/// attenuation, a new isotropic Gaussian is spawned at the segment midpoint
|
||||
/// sized to explain the residual. Returns the post-update residual (nepers).
|
||||
pub fn observe_link(
|
||||
map: &mut GaussianMap,
|
||||
tx: [f64; 3],
|
||||
rx: [f64; 3],
|
||||
freq_hz: f64,
|
||||
measured_amp: f64,
|
||||
lr: f64,
|
||||
timestamp_ns: u64,
|
||||
) -> f64 {
|
||||
assert!(measured_amp > 0.0, "measured amplitude must be positive");
|
||||
let d = ((rx[0] - tx[0]).powi(2) + (rx[1] - tx[1]).powi(2) + (rx[2] - tx[2]).powi(2)).sqrt();
|
||||
let lambda = C / freq_hz;
|
||||
let friis = lambda / (4.0 * std::f64::consts::PI * d);
|
||||
let tau_target = (friis / measured_amp).ln();
|
||||
|
||||
let (tau, parts) = optical_depth(map, tx, rx);
|
||||
let residual = tau_target - tau;
|
||||
|
||||
let sum_i2: f64 = parts.iter().map(|(_, i)| i * i).sum();
|
||||
if sum_i2 > 1e-9 {
|
||||
for (idx, integral) in &parts {
|
||||
let g = &mut map.gaussians_mut()[*idx];
|
||||
g.occupancy = (g.occupancy + lr * residual * integral / sum_i2).max(0.0);
|
||||
g.timestamp_ns = g.timestamp_ns.max(timestamp_ns);
|
||||
}
|
||||
map.rebuild_grid();
|
||||
} else if residual > 0.05 {
|
||||
// Nothing on the path can explain the attenuation: spawn a compact
|
||||
// absorber at the midpoint sized to close the residual exactly.
|
||||
let mid = [(tx[0] + rx[0]) / 2.0, (tx[1] + rx[1]) / 2.0, (tx[2] + rx[2]) / 2.0];
|
||||
let mut g = RfGaussian::new(
|
||||
mid,
|
||||
[0.3, 0.3, 0.3],
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
0.0,
|
||||
0.6,
|
||||
timestamp_ns,
|
||||
300.0,
|
||||
Provenance { device_id: "gain-inverse".into(), model_version: 1, synthetic: false },
|
||||
)
|
||||
.expect("spawn parameters are statically valid");
|
||||
let dvec = [rx[0] - tx[0], rx[1] - tx[1], rx[2] - tx[2]];
|
||||
let u = [dvec[0] / d, dvec[1] / d, dvec[2] / d];
|
||||
let unit_integral = line_integral(&g, tx, u, d);
|
||||
if unit_integral > 1e-9 {
|
||||
g.occupancy = residual / unit_integral;
|
||||
map.insert(g);
|
||||
}
|
||||
}
|
||||
|
||||
let (tau_after, _) = optical_depth(map, tx, rx);
|
||||
tau_target - tau_after
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gaussian::primitive::Provenance;
|
||||
|
||||
fn prov() -> Provenance {
|
||||
Provenance { device_id: "gain-test".into(), model_version: 1, synthetic: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_map_returns_exact_friis() {
|
||||
let map = GaussianMap::new(1.0);
|
||||
let f = 2.437e9;
|
||||
let tx = [0.0, 0.0, 1.0];
|
||||
let rx = [4.0, 3.0, 1.0]; // d = 5
|
||||
let h = channel_gain(&map, tx, rx, f);
|
||||
let lambda = C / f;
|
||||
let expected_amp = lambda / (4.0 * std::f64::consts::PI * 5.0);
|
||||
assert!((h.norm() - expected_amp).abs() < 1e-15, "amplitude must be exact Friis");
|
||||
let expected_phase = -2.0 * std::f64::consts::PI * 5.0 / lambda;
|
||||
// Compare phasors (phase is only defined mod 2π).
|
||||
let diff = (h / Complex64::from_polar(expected_amp, expected_phase)) - 1.0;
|
||||
assert!(diff.norm() < 1e-9, "propagation phase must match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absorber_on_path_reduces_gain_monotonically() {
|
||||
let f = 5.18e9;
|
||||
let tx = [0.0, 0.0, 1.0];
|
||||
let rx = [6.0, 0.0, 1.0];
|
||||
let mut last = f64::INFINITY;
|
||||
for occ in [0.0, 0.2, 0.5, 1.0, 2.0] {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
let g = RfGaussian::new(
|
||||
[3.0, 0.0, 1.0],
|
||||
[0.4, 0.4, 0.4],
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
occ,
|
||||
0.9,
|
||||
0,
|
||||
300.0,
|
||||
prov(),
|
||||
)
|
||||
.expect("valid");
|
||||
map.insert(g);
|
||||
let db = gain_db(&map, tx, rx, f);
|
||||
assert!(db < last + 1e-12, "gain must fall as occupancy rises");
|
||||
last = db;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_path_absorber_barely_matters() {
|
||||
let f = 5.18e9;
|
||||
let tx = [0.0, 0.0, 1.0];
|
||||
let rx = [6.0, 0.0, 1.0];
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
map.insert(
|
||||
RfGaussian::new(
|
||||
[3.0, 4.0, 1.0], // 4 m off the LoS, σ = 0.4 ⇒ 10σ away
|
||||
[0.4, 0.4, 0.4],
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
2.0,
|
||||
0.9,
|
||||
0,
|
||||
300.0,
|
||||
prov(),
|
||||
)
|
||||
.expect("valid"),
|
||||
);
|
||||
let clean = gain_db(&GaussianMap::new(1.0), tx, rx, f);
|
||||
let with = gain_db(&map, tx, rx, f);
|
||||
assert!((clean - with).abs() < 1e-6, "off-path matter must not attenuate LoS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_integral_matches_numeric_quadrature() {
|
||||
let g = RfGaussian::new(
|
||||
[2.0, 0.5, 1.0],
|
||||
[0.5, 0.8, 0.3],
|
||||
[0.9, 0.1, 0.2, 0.1], // arbitrary rotation
|
||||
1.0,
|
||||
0.9,
|
||||
0,
|
||||
300.0,
|
||||
prov(),
|
||||
)
|
||||
.expect("valid");
|
||||
let o = [0.0, 0.0, 1.0];
|
||||
let u = [1.0, 0.0, 0.0];
|
||||
let len = 6.0;
|
||||
// Trapezoid quadrature at 1 mm steps as ground truth.
|
||||
let n = 6000;
|
||||
let mut acc = 0.0;
|
||||
for i in 0..=n {
|
||||
let t = len * i as f64 / n as f64;
|
||||
let w = if i == 0 || i == n { 0.5 } else { 1.0 };
|
||||
acc += w * g.density_at([o[0] + t * u[0], o[1] + t * u[1], o[2] + t * u[2]]);
|
||||
}
|
||||
acc *= len / n as f64;
|
||||
let closed = line_integral(&g, o, u, len);
|
||||
assert!(
|
||||
(closed - acc).abs() < 1e-6,
|
||||
"closed form {closed} vs quadrature {acc}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_update_learns_a_wall_from_link_residuals() {
|
||||
let f = 2.437e9;
|
||||
let tx = [0.0, 0.0, 1.0];
|
||||
let rx = [6.0, 0.0, 1.0];
|
||||
let lambda = C / f;
|
||||
let friis = lambda / (4.0 * std::f64::consts::PI * 6.0);
|
||||
// Ground truth: an unseen absorber imposes τ = 0.7 nepers (≈ 6.1 dB).
|
||||
let measured = friis * (-0.7f64).exp();
|
||||
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
let mut residual = f64::INFINITY;
|
||||
for step in 0..20 {
|
||||
residual = observe_link(&mut map, tx, rx, f, measured, 0.7, step);
|
||||
}
|
||||
assert!(!map.is_empty(), "an absorber must have been spawned");
|
||||
assert!(
|
||||
residual.abs() < 0.06,
|
||||
"residual after convergence must be < 0.06 nepers (≈0.5 dB), got {residual}"
|
||||
);
|
||||
let predicted_db = gain_db(&map, tx, rx, f);
|
||||
let measured_db = 20.0 * measured.log10();
|
||||
assert!(
|
||||
(predicted_db - measured_db).abs() < 0.5,
|
||||
"map prediction {predicted_db:.2} dB must match measurement {measured_db:.2} dB"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Task-gated scene graph (ADR-275 §5) — sparse symbolic relations over the
|
||||
//! continuous Gaussian field, activated *only* as far as the current task
|
||||
//! requires (bounded active memory, the JITOMA lesson: stop trying to
|
||||
//! remember everything at once).
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Kind of entity a scene node represents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum EntityKind {
|
||||
/// A physical object (furniture, appliance).
|
||||
Object,
|
||||
/// A room / zone.
|
||||
Room,
|
||||
/// A person *class* (never an identity — identity inference is gated by
|
||||
/// the ADR-277 policy engine, not stored in the scene graph).
|
||||
PersonClass,
|
||||
/// A sensing device.
|
||||
Device,
|
||||
/// A discrete event (channel anomaly, fall alert, …).
|
||||
Event,
|
||||
}
|
||||
|
||||
/// Relation kinds on scene edges.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RelationKind {
|
||||
/// Spatial containment (room contains object).
|
||||
Contains,
|
||||
/// Spatial adjacency.
|
||||
Near,
|
||||
/// Causal attribution (event caused by object/person-class).
|
||||
CausedBy,
|
||||
/// Sensing coverage (device observes room/object).
|
||||
ObservedBy,
|
||||
}
|
||||
|
||||
/// A node in the scene graph.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SceneNode {
|
||||
/// Stable identifier.
|
||||
pub id: String,
|
||||
/// Entity kind.
|
||||
pub kind: EntityKind,
|
||||
/// Indices of the Gaussians in the map that ground this node.
|
||||
pub gaussian_refs: Vec<usize>,
|
||||
}
|
||||
|
||||
/// A directed, typed edge.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SceneEdge {
|
||||
/// Source node id.
|
||||
pub from: String,
|
||||
/// Target node id.
|
||||
pub to: String,
|
||||
/// Relation.
|
||||
pub relation: RelationKind,
|
||||
}
|
||||
|
||||
/// The sparse relational layer over the Gaussian field.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SceneGraph {
|
||||
nodes: HashMap<String, SceneNode>,
|
||||
edges: Vec<SceneEdge>,
|
||||
}
|
||||
|
||||
/// A bounded, task-relevant activation of the graph.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActiveSubgraph {
|
||||
/// Activated node ids in BFS order from the seeds.
|
||||
pub nodes: Vec<String>,
|
||||
/// Edges with both endpoints activated.
|
||||
pub edges: Vec<SceneEdge>,
|
||||
/// True when the activation hit `max_nodes` before exhausting reachable
|
||||
/// relevant nodes (callers can widen the budget deliberately).
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
impl SceneGraph {
|
||||
/// Empty graph.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Number of nodes.
|
||||
#[must_use]
|
||||
pub fn node_count(&self) -> usize {
|
||||
self.nodes.len()
|
||||
}
|
||||
|
||||
/// Inserts or replaces a node.
|
||||
pub fn upsert_node(&mut self, node: SceneNode) {
|
||||
self.nodes.insert(node.id.clone(), node);
|
||||
}
|
||||
|
||||
/// Adds an edge (endpoints need not exist yet; dangling edges are
|
||||
/// simply never activated).
|
||||
pub fn add_edge(&mut self, from: impl Into<String>, to: impl Into<String>, relation: RelationKind) {
|
||||
self.edges.push(SceneEdge { from: from.into(), to: to.into(), relation });
|
||||
}
|
||||
|
||||
/// Node lookup.
|
||||
#[must_use]
|
||||
pub fn node(&self, id: &str) -> Option<&SceneNode> {
|
||||
self.nodes.get(id)
|
||||
}
|
||||
|
||||
/// Task-gated activation: BFS from `seed_ids`, following edges in both
|
||||
/// directions, keeping only nodes whose kind is in `relevant_kinds`,
|
||||
/// visiting at most `max_nodes` nodes. This is the *only* sanctioned way
|
||||
/// for reasoning code to read the graph — full scans are deliberately
|
||||
/// not offered on the public surface.
|
||||
#[must_use]
|
||||
pub fn activate(
|
||||
&self,
|
||||
relevant_kinds: &[EntityKind],
|
||||
seed_ids: &[&str],
|
||||
max_nodes: usize,
|
||||
) -> ActiveSubgraph {
|
||||
let relevant: BTreeSet<EntityKind> = relevant_kinds.iter().copied().collect();
|
||||
let mut visited: BTreeSet<&str> = BTreeSet::new();
|
||||
let mut queue: VecDeque<&str> = VecDeque::new();
|
||||
let mut activated: Vec<String> = Vec::new();
|
||||
let mut truncated = false;
|
||||
|
||||
for &s in seed_ids {
|
||||
if let Some(n) = self.nodes.get(s) {
|
||||
if relevant.contains(&n.kind) && visited.insert(s) {
|
||||
queue.push_back(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(id) = queue.pop_front() {
|
||||
if activated.len() >= max_nodes {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
activated.push(id.to_string());
|
||||
for e in &self.edges {
|
||||
let neighbor = if e.from == id {
|
||||
Some(e.to.as_str())
|
||||
} else if e.to == id {
|
||||
Some(e.from.as_str())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(nb) = neighbor else { continue };
|
||||
let Some(node) = self.nodes.get(nb) else { continue };
|
||||
if relevant.contains(&node.kind) && !visited.contains(nb) {
|
||||
// Re-borrow with the graph's lifetime for the queue.
|
||||
let (key, _) = self.nodes.get_key_value(nb).expect("just found");
|
||||
visited.insert(key);
|
||||
queue.push_back(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !queue.is_empty() {
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
let in_set: BTreeSet<&str> = activated.iter().map(String::as_str).collect();
|
||||
let edges = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| in_set.contains(e.from.as_str()) && in_set.contains(e.to.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
ActiveSubgraph { nodes: activated, edges, truncated }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn demo_graph() -> SceneGraph {
|
||||
let mut g = SceneGraph::new();
|
||||
for (id, kind) in [
|
||||
("kitchen", EntityKind::Room),
|
||||
("living", EntityKind::Room),
|
||||
("fridge", EntityKind::Object),
|
||||
("sofa", EntityKind::Object),
|
||||
("esp32-a", EntityKind::Device),
|
||||
("person-class-1", EntityKind::PersonClass),
|
||||
("anomaly-7", EntityKind::Event),
|
||||
] {
|
||||
g.upsert_node(SceneNode { id: id.into(), kind, gaussian_refs: vec![] });
|
||||
}
|
||||
g.add_edge("kitchen", "fridge", RelationKind::Contains);
|
||||
g.add_edge("living", "sofa", RelationKind::Contains);
|
||||
g.add_edge("kitchen", "esp32-a", RelationKind::ObservedBy);
|
||||
g.add_edge("anomaly-7", "fridge", RelationKind::CausedBy);
|
||||
g.add_edge("person-class-1", "living", RelationKind::Near);
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_is_task_scoped() {
|
||||
let g = demo_graph();
|
||||
// Task: "which object caused the channel anomaly?" — events, objects,
|
||||
// rooms are relevant; devices and person classes are not.
|
||||
let active = g.activate(
|
||||
&[EntityKind::Event, EntityKind::Object, EntityKind::Room],
|
||||
&["anomaly-7"],
|
||||
10,
|
||||
);
|
||||
assert!(active.nodes.contains(&"anomaly-7".to_string()));
|
||||
assert!(active.nodes.contains(&"fridge".to_string()));
|
||||
assert!(active.nodes.contains(&"kitchen".to_string()));
|
||||
assert!(!active.nodes.iter().any(|n| n == "esp32-a"), "devices are gated out");
|
||||
assert!(!active.nodes.iter().any(|n| n == "person-class-1"));
|
||||
assert!(!active.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_respects_the_node_budget() {
|
||||
let g = demo_graph();
|
||||
let active = g.activate(
|
||||
&[
|
||||
EntityKind::Event,
|
||||
EntityKind::Object,
|
||||
EntityKind::Room,
|
||||
EntityKind::Device,
|
||||
EntityKind::PersonClass,
|
||||
],
|
||||
&["anomaly-7"],
|
||||
2,
|
||||
);
|
||||
assert_eq!(active.nodes.len(), 2, "bounded active memory");
|
||||
assert!(active.truncated, "truncation must be reported, not silent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_and_irrelevant_seeds_yield_empty() {
|
||||
let g = demo_graph();
|
||||
let active = g.activate(&[EntityKind::Object], &["nonexistent"], 10);
|
||||
assert!(active.nodes.is_empty());
|
||||
// Seed exists but its kind is not relevant ⇒ empty activation.
|
||||
let active = g.activate(&[EntityKind::Object], &["kitchen"], 10);
|
||||
assert!(active.nodes.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
//! The Gaussian map: spatial-hash-indexed storage with fusion, decay, and
|
||||
//! spatial/semantic queries (ADR-275 §3).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::primitive::{RfGaussian, SEMANTIC_DIM};
|
||||
|
||||
/// Confidence floor below which a decayed Gaussian is pruned.
|
||||
pub const PRUNE_CONFIDENCE: f64 = 0.02;
|
||||
|
||||
/// Squared-Mahalanobis merge gate: an incoming Gaussian whose centre lies
|
||||
/// within this metric distance of an existing one *of the same entity kind*
|
||||
/// fuses instead of inserting (3² = within 3σ).
|
||||
pub const MERGE_MAHALANOBIS_SQ: f64 = 9.0;
|
||||
|
||||
/// Persistent Gaussian scene memory with an O(1) spatial-hash index.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GaussianMap {
|
||||
gaussians: Vec<RfGaussian>,
|
||||
/// Cell → indices. Rebuilt on decay/prune, updated on insert.
|
||||
grid: HashMap<(i64, i64, i64), Vec<usize>>,
|
||||
cell_size: f64,
|
||||
}
|
||||
|
||||
impl GaussianMap {
|
||||
/// New map. `cell_size` is the spatial-hash pitch in metres; it should
|
||||
/// be on the order of the largest expected Gaussian extent (≈ 1 m for
|
||||
/// rooms).
|
||||
#[must_use]
|
||||
pub fn new(cell_size: f64) -> Self {
|
||||
Self { gaussians: Vec::new(), grid: HashMap::new(), cell_size: cell_size.max(1e-3) }
|
||||
}
|
||||
|
||||
/// Number of live Gaussians.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.gaussians.len()
|
||||
}
|
||||
|
||||
/// Whether the map is empty.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.gaussians.is_empty()
|
||||
}
|
||||
|
||||
/// Read-only view of the store.
|
||||
#[must_use]
|
||||
pub fn gaussians(&self) -> &[RfGaussian] {
|
||||
&self.gaussians
|
||||
}
|
||||
|
||||
/// Mutable access for the inverse-gain updater (crate-internal).
|
||||
pub(crate) fn gaussians_mut(&mut self) -> &mut Vec<RfGaussian> {
|
||||
&mut self.gaussians
|
||||
}
|
||||
|
||||
fn cell_of(&self, p: [f64; 3]) -> (i64, i64, i64) {
|
||||
(
|
||||
(p[0] / self.cell_size).floor() as i64,
|
||||
(p[1] / self.cell_size).floor() as i64,
|
||||
(p[2] / self.cell_size).floor() as i64,
|
||||
)
|
||||
}
|
||||
|
||||
/// Rebuilds the spatial index from scratch (after decay/prune or
|
||||
/// occupancy edits that may have moved nothing — cheap: O(n)).
|
||||
pub(crate) fn rebuild_grid(&mut self) {
|
||||
self.grid.clear();
|
||||
for (i, g) in self.gaussians.iter().enumerate() {
|
||||
let cell = self.cell_of(g.position);
|
||||
self.grid.entry(cell).or_default().push(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a Gaussian, fusing with an existing same-kind neighbor when
|
||||
/// the merge gate fires (ADR-275 §3.2).
|
||||
///
|
||||
/// Fusion is confidence-weighted: position, occupancy, semantics,
|
||||
/// reflectivity, and Doppler average with weights `(c_old, c_new)`;
|
||||
/// confidence combines as noisy-OR `c = c₁ + c₂ − c₁c₂` (two independent
|
||||
/// pieces of evidence); the newer timestamp and provenance win.
|
||||
/// Returns the index of the stored (new or fused) Gaussian.
|
||||
pub fn insert(&mut self, g: RfGaussian) -> usize {
|
||||
// Candidate neighbors from the 3×3×3 cell neighborhood.
|
||||
let cell = self.cell_of(g.position);
|
||||
let mut best: Option<usize> = None;
|
||||
let mut best_d = MERGE_MAHALANOBIS_SQ;
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
let Some(idxs) = self.grid.get(&(cell.0 + dx, cell.1 + dy, cell.2 + dz))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for &i in idxs {
|
||||
let existing = &self.gaussians[i];
|
||||
let same_kind = match (existing.links.first(), g.links.first()) {
|
||||
(Some(a), Some(b)) => a.kind == b.kind,
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same_kind {
|
||||
continue;
|
||||
}
|
||||
let d = existing.mahalanobis_sq(g.position);
|
||||
if d < best_d {
|
||||
best_d = d;
|
||||
best = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(i) = best {
|
||||
let old_cell = self.cell_of(self.gaussians[i].position);
|
||||
{
|
||||
let e = &mut self.gaussians[i];
|
||||
let (wa, wb) = (e.confidence, g.confidence);
|
||||
let wsum = (wa + wb).max(1e-12);
|
||||
for k in 0..3 {
|
||||
e.position[k] = (wa * e.position[k] + wb * g.position[k]) / wsum;
|
||||
e.scale[k] = (wa * e.scale[k] + wb * g.scale[k]) / wsum;
|
||||
}
|
||||
e.occupancy = (wa * e.occupancy + wb * g.occupancy) / wsum;
|
||||
for k in 0..SEMANTIC_DIM {
|
||||
e.semantic[k] =
|
||||
((f64::from(e.semantic[k]) * wa + f64::from(g.semantic[k]) * wb) / wsum) as f32;
|
||||
}
|
||||
for b in 0..e.reflectivity.len() {
|
||||
for a in 0..e.reflectivity[b].len() {
|
||||
e.reflectivity[b][a] =
|
||||
(wa * e.reflectivity[b][a] + wb * g.reflectivity[b][a]) / wsum;
|
||||
}
|
||||
}
|
||||
e.doppler_mps = (wa * e.doppler_mps + wb * g.doppler_mps) / wsum;
|
||||
e.doppler_variance = (wa * e.doppler_variance + wb * g.doppler_variance) / wsum;
|
||||
e.confidence = (wa + wb - wa * wb).clamp(0.0, 1.0);
|
||||
e.first_seen_ns = e.first_seen_ns.min(g.first_seen_ns);
|
||||
if g.timestamp_ns >= e.timestamp_ns {
|
||||
e.timestamp_ns = g.timestamp_ns;
|
||||
e.provenance = g.provenance;
|
||||
e.motion = g.motion;
|
||||
}
|
||||
for r in g.source_receipts {
|
||||
if !e.source_receipts.contains(&r)
|
||||
&& e.source_receipts.len() < super::primitive::MAX_SOURCE_RECEIPTS
|
||||
{
|
||||
e.source_receipts.push(r);
|
||||
}
|
||||
}
|
||||
for link in g.links {
|
||||
if !e.links.contains(&link) {
|
||||
e.links.push(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-index if fusion moved the centre across a cell boundary.
|
||||
let new_cell = self.cell_of(self.gaussians[i].position);
|
||||
if new_cell != old_cell {
|
||||
if let Some(v) = self.grid.get_mut(&old_cell) {
|
||||
v.retain(|&x| x != i);
|
||||
}
|
||||
self.grid.entry(new_cell).or_default().push(i);
|
||||
}
|
||||
i
|
||||
} else {
|
||||
let idx = self.gaussians.len();
|
||||
let cell = self.cell_of(g.position);
|
||||
self.gaussians.push(g);
|
||||
self.grid.entry(cell).or_default().push(idx);
|
||||
idx
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies exponential confidence decay up to `now_ns` and prunes below
|
||||
/// [`PRUNE_CONFIDENCE`]. Deterministic: same inputs, same result.
|
||||
///
|
||||
/// **Static persistence** (ADR-275 update-loop step 7): the effective
|
||||
/// decay constant is stretched by how long the Gaussian has been
|
||||
/// repeatedly observed — `τ_eff = τ · (1 + ln(1 + lifetime/τ))` with
|
||||
/// `lifetime = last_seen − first_seen`. A wall confirmed for hours
|
||||
/// outlives a transient echo seen once, even at equal nominal τ.
|
||||
pub fn decay(&mut self, now_ns: u64) {
|
||||
for g in &mut self.gaussians {
|
||||
let dt_s = (now_ns.saturating_sub(g.timestamp_ns)) as f64 / 1e9;
|
||||
let lifetime_s = (g.timestamp_ns.saturating_sub(g.first_seen_ns)) as f64 / 1e9;
|
||||
// `RfGaussian::new` validates `decay_tau_s > 0`, but the field is
|
||||
// mutable after construction (`gaussians_mut`); re-clamp here so a
|
||||
// stray zero/negative value can't turn this division into NaN
|
||||
// instead of a merely-fast decay.
|
||||
let tau = g.decay_tau_s.max(1e-6);
|
||||
let tau_eff = tau * (1.0 + (1.0 + lifetime_s / tau).ln());
|
||||
g.confidence *= (-dt_s / tau_eff).exp();
|
||||
}
|
||||
self.gaussians.retain(|g| g.confidence >= PRUNE_CONFIDENCE);
|
||||
self.rebuild_grid();
|
||||
}
|
||||
|
||||
/// Merge pass (ADR-275 update-loop step 5): collapses pairs whose
|
||||
/// centres lie inside each other's merge gate *mutually* and whose
|
||||
/// semantics are compatible (cosine ≥ 0.7, or both unlabeled). The
|
||||
/// survivor absorbs the partner with the same confidence-weighted rule
|
||||
/// as [`Self::insert`] fusion. Returns the number of merges performed.
|
||||
pub fn merge_overlapping(&mut self) -> usize {
|
||||
let mut merged = 0usize;
|
||||
let mut removed = vec![false; self.gaussians.len()];
|
||||
for i in 0..self.gaussians.len() {
|
||||
if removed[i] {
|
||||
continue;
|
||||
}
|
||||
for j in (i + 1)..self.gaussians.len() {
|
||||
if removed[j] {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = (&self.gaussians[i], &self.gaussians[j]);
|
||||
// Same entity-kind gate as `insert` (§3.2): never conflate
|
||||
// Gaussians linked to different entity kinds (e.g. a Room
|
||||
// structure and a PersonClass detection sitting within each
|
||||
// other's merge gate near a doorway).
|
||||
let same_kind = match (a.links.first(), b.links.first()) {
|
||||
(Some(la), Some(lb)) => la.kind == lb.kind,
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same_kind {
|
||||
continue;
|
||||
}
|
||||
let mutual = a.mahalanobis_sq(b.position) < MERGE_MAHALANOBIS_SQ
|
||||
&& b.mahalanobis_sq(a.position) < MERGE_MAHALANOBIS_SQ;
|
||||
if !mutual {
|
||||
continue;
|
||||
}
|
||||
let (na, nb): (f64, f64) = (
|
||||
a.semantic.iter().map(|v| f64::from(*v).powi(2)).sum(),
|
||||
b.semantic.iter().map(|v| f64::from(*v).powi(2)).sum(),
|
||||
);
|
||||
let compatible = if na < 1e-12 && nb < 1e-12 {
|
||||
true // both unlabeled: pure geometry merge
|
||||
} else if na < 1e-12 || nb < 1e-12 {
|
||||
false // one labeled, one not: keep separate
|
||||
} else {
|
||||
let dot: f64 = a
|
||||
.semantic
|
||||
.iter()
|
||||
.zip(&b.semantic)
|
||||
.map(|(x, y)| f64::from(*x) * f64::from(*y))
|
||||
.sum();
|
||||
dot / (na.sqrt() * nb.sqrt()) >= 0.7
|
||||
};
|
||||
if !compatible {
|
||||
continue;
|
||||
}
|
||||
// Fuse j into i (same math as insert-fusion).
|
||||
let partner = self.gaussians[j].clone();
|
||||
let e = &mut self.gaussians[i];
|
||||
let (wa, wb) = (e.confidence, partner.confidence);
|
||||
let wsum = (wa + wb).max(1e-12);
|
||||
for k in 0..3 {
|
||||
e.position[k] = (wa * e.position[k] + wb * partner.position[k]) / wsum;
|
||||
e.scale[k] = (wa * e.scale[k] + wb * partner.scale[k]) / wsum;
|
||||
}
|
||||
e.occupancy = (wa * e.occupancy + wb * partner.occupancy) / wsum;
|
||||
for k in 0..SEMANTIC_DIM {
|
||||
e.semantic[k] = ((f64::from(e.semantic[k]) * wa
|
||||
+ f64::from(partner.semantic[k]) * wb)
|
||||
/ wsum) as f32;
|
||||
}
|
||||
e.confidence = (wa + wb - wa * wb).clamp(0.0, 1.0);
|
||||
e.first_seen_ns = e.first_seen_ns.min(partner.first_seen_ns);
|
||||
e.timestamp_ns = e.timestamp_ns.max(partner.timestamp_ns);
|
||||
for r in partner.source_receipts {
|
||||
if !e.source_receipts.contains(&r)
|
||||
&& e.source_receipts.len() < super::primitive::MAX_SOURCE_RECEIPTS
|
||||
{
|
||||
e.source_receipts.push(r);
|
||||
}
|
||||
}
|
||||
for link in partner.links {
|
||||
if !e.links.contains(&link) {
|
||||
e.links.push(link);
|
||||
}
|
||||
}
|
||||
removed[j] = true;
|
||||
merged += 1;
|
||||
}
|
||||
}
|
||||
if merged > 0 {
|
||||
let mut keep = removed.iter().map(|r| !r);
|
||||
self.gaussians.retain(|_| keep.next().unwrap_or(true));
|
||||
self.rebuild_grid();
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
/// Indices of Gaussians whose centres lie within `radius` of `p`, via
|
||||
/// the spatial hash (only the covered cell neighborhood is scanned).
|
||||
#[must_use]
|
||||
pub fn query_radius(&self, p: [f64; 3], radius: f64) -> Vec<usize> {
|
||||
let r_cells = (radius / self.cell_size).ceil() as i64;
|
||||
let c = self.cell_of(p);
|
||||
let mut out = Vec::new();
|
||||
let r2 = radius * radius;
|
||||
for dx in -r_cells..=r_cells {
|
||||
for dy in -r_cells..=r_cells {
|
||||
for dz in -r_cells..=r_cells {
|
||||
let Some(idxs) = self.grid.get(&(c.0 + dx, c.1 + dy, c.2 + dz)) else {
|
||||
continue;
|
||||
};
|
||||
for &i in idxs {
|
||||
let q = self.gaussians[i].position;
|
||||
let d2 = (q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + (q[2] - p[2]).powi(2);
|
||||
if d2 <= r2 {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// Reference implementation of [`Self::query_radius`] by linear scan —
|
||||
/// kept for the equivalence test and the benchmark baseline.
|
||||
#[must_use]
|
||||
pub fn query_radius_linear(&self, p: [f64; 3], radius: f64) -> Vec<usize> {
|
||||
let r2 = radius * radius;
|
||||
let mut out: Vec<usize> = self
|
||||
.gaussians
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, g)| {
|
||||
let q = g.position;
|
||||
(q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + (q[2] - p[2]).powi(2) <= r2
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// Indices of Gaussians whose centres lie within `margin` of the
|
||||
/// segment `a → b`, by walking only the hash cells along the corridor —
|
||||
/// the hot path of [`super::gain::optical_depth`].
|
||||
///
|
||||
/// Sweeps the segment's margin-inflated AABB once; each cell is
|
||||
/// prefiltered by its *centre's* distance to the segment (bound:
|
||||
/// `margin + √3/2·cell`, which covers any point inside the cell) before
|
||||
/// the hash lookup, so no per-sample set building and no duplicate
|
||||
/// visits. Candidates are then exact-filtered by centre-to-segment
|
||||
/// distance. See `benches/unified_bench.rs` for the measured effect on
|
||||
/// `channel_gain` versus both the midpoint-ball query this replaced and
|
||||
/// the linear-scan baseline.
|
||||
#[must_use]
|
||||
pub fn query_near_segment(&self, a: [f64; 3], b: [f64; 3], margin: f64) -> Vec<usize> {
|
||||
let lo = |axis: usize| a[axis].min(b[axis]) - margin;
|
||||
let hi = |axis: usize| a[axis].max(b[axis]) + margin;
|
||||
let c_lo: Vec<i64> = (0..3).map(|k| (lo(k) / self.cell_size).floor() as i64).collect();
|
||||
let c_hi: Vec<i64> = (0..3).map(|k| (hi(k) / self.cell_size).floor() as i64).collect();
|
||||
let cell_bound = margin + 0.87 * self.cell_size; // √3/2 ≈ 0.866
|
||||
let mut out: Vec<usize> = Vec::new();
|
||||
for cx in c_lo[0]..=c_hi[0] {
|
||||
for cy in c_lo[1]..=c_hi[1] {
|
||||
for cz in c_lo[2]..=c_hi[2] {
|
||||
let centre = [
|
||||
(cx as f64 + 0.5) * self.cell_size,
|
||||
(cy as f64 + 0.5) * self.cell_size,
|
||||
(cz as f64 + 0.5) * self.cell_size,
|
||||
];
|
||||
if Self::dist_point_segment(centre, a, b) > cell_bound {
|
||||
continue;
|
||||
}
|
||||
let Some(idxs) = self.grid.get(&(cx, cy, cz)) else { continue };
|
||||
for &i in idxs {
|
||||
if Self::dist_point_segment(self.gaussians[i].position, a, b) <= margin {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// Reference implementation of [`Self::query_near_segment`] by linear
|
||||
/// scan — equivalence-tested and benchmarked as the baseline.
|
||||
#[must_use]
|
||||
pub fn query_near_segment_linear(&self, a: [f64; 3], b: [f64; 3], margin: f64) -> Vec<usize> {
|
||||
(0..self.gaussians.len())
|
||||
.filter(|&i| Self::dist_point_segment(self.gaussians[i].position, a, b) <= margin)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Distance from a point to a segment.
|
||||
fn dist_point_segment(p: [f64; 3], a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||
let ap = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
|
||||
let denom = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2];
|
||||
let t = if denom < 1e-18 {
|
||||
0.0
|
||||
} else {
|
||||
((ap[0] * ab[0] + ap[1] * ab[1] + ap[2] * ab[2]) / denom).clamp(0.0, 1.0)
|
||||
};
|
||||
let q = [a[0] + t * ab[0], a[1] + t * ab[1], a[2] + t * ab[2]];
|
||||
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// `k` nearest Gaussians to `p` by centre distance (expanding-ring
|
||||
/// search over the hash grid).
|
||||
#[must_use]
|
||||
pub fn query_nearest(&self, p: [f64; 3], k: usize) -> Vec<usize> {
|
||||
if self.gaussians.is_empty() || k == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut radius = self.cell_size;
|
||||
loop {
|
||||
let hits = self.query_radius(p, radius);
|
||||
if hits.len() >= k || radius > 1e4 {
|
||||
let mut scored: Vec<(f64, usize)> = hits
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let q = self.gaussians[i].position;
|
||||
let d2 = (q[0] - p[0]).powi(2)
|
||||
+ (q[1] - p[1]).powi(2)
|
||||
+ (q[2] - p[2]).powi(2);
|
||||
(d2, i)
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.truncate(k);
|
||||
return scored.into_iter().map(|(_, i)| i).collect();
|
||||
}
|
||||
radius *= 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `k` most semantically similar Gaussians by cosine similarity.
|
||||
#[must_use]
|
||||
pub fn query_semantic(&self, embedding: &[f32; SEMANTIC_DIM], k: usize) -> Vec<usize> {
|
||||
let norm_q: f64 = embedding.iter().map(|v| f64::from(*v).powi(2)).sum::<f64>().sqrt();
|
||||
let mut scored: Vec<(f64, usize)> = self
|
||||
.gaussians
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, g)| {
|
||||
let dot: f64 = g
|
||||
.semantic
|
||||
.iter()
|
||||
.zip(embedding)
|
||||
.map(|(a, b)| f64::from(*a) * f64::from(*b))
|
||||
.sum();
|
||||
let norm_g: f64 =
|
||||
g.semantic.iter().map(|v| f64::from(*v).powi(2)).sum::<f64>().sqrt();
|
||||
let sim = if norm_g * norm_q > 0.0 { dot / (norm_g * norm_q) } else { -1.0 };
|
||||
(sim, i)
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.truncate(k);
|
||||
scored.into_iter().map(|(_, i)| i).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gaussian::primitive::Provenance;
|
||||
|
||||
fn prov() -> Provenance {
|
||||
Provenance { device_id: "map-test".into(), model_version: 1, synthetic: true }
|
||||
}
|
||||
|
||||
fn g_at(p: [f64; 3], confidence: f64, ts: u64) -> RfGaussian {
|
||||
RfGaussian::new(p, [0.3, 0.3, 0.3], [1.0, 0.0, 0.0, 0.0], 0.4, confidence, ts, 60.0, prov())
|
||||
.expect("valid gaussian")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearby_same_kind_observations_fuse() {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
let a = map.insert(g_at([1.0, 1.0, 1.0], 0.5, 10));
|
||||
let b = map.insert(g_at([1.1, 1.0, 1.0], 0.5, 20));
|
||||
assert_eq!(a, b, "second observation must fuse, not duplicate");
|
||||
assert_eq!(map.len(), 1);
|
||||
let g = &map.gaussians()[0];
|
||||
// Confidence-weighted midpoint and noisy-OR confidence.
|
||||
assert!((g.position[0] - 1.05).abs() < 1e-9);
|
||||
assert!((g.confidence - 0.75).abs() < 1e-9);
|
||||
assert_eq!(g.timestamp_ns, 20);
|
||||
|
||||
// A far observation creates a new Gaussian.
|
||||
map.insert(g_at([5.0, 5.0, 1.0], 0.5, 30));
|
||||
assert_eq!(map.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_prunes_stale_gaussians_deterministically() {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
map.insert(g_at([0.0; 3], 0.9, 0)); // tau = 60 s
|
||||
map.insert(g_at([4.0, 0.0, 0.0], 0.9, 240_000_000_000)); // fresh
|
||||
// 240 s later: first has decayed by e^{-4} ⇒ 0.9·0.0183 ≈ 0.016 < floor.
|
||||
map.decay(240_000_000_000);
|
||||
assert_eq!(map.len(), 1);
|
||||
assert!((map.gaussians()[0].position[0] - 4.0).abs() < 1e-12);
|
||||
|
||||
// Determinism: replay the same sequence, get the same state.
|
||||
let mut replay = GaussianMap::new(1.0);
|
||||
replay.insert(g_at([0.0; 3], 0.9, 0));
|
||||
replay.insert(g_at([4.0, 0.0, 0.0], 0.9, 240_000_000_000));
|
||||
replay.decay(240_000_000_000);
|
||||
assert_eq!(replay.len(), map.len());
|
||||
assert_eq!(replay.gaussians()[0].confidence, map.gaussians()[0].confidence);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_lived_structure_outlives_transients_at_equal_tau() {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
// A wall confirmed over 30 minutes: first_seen 0, last update at
|
||||
// t = 1800 s. A transient echo seen once at t = 1800 s. Same τ = 60 s.
|
||||
let mut wall = g_at([0.0, 0.0, 1.0], 0.9, 1_800_000_000_000);
|
||||
wall.first_seen_ns = 0;
|
||||
let transient = g_at([6.0, 0.0, 1.0], 0.9, 1_800_000_000_000);
|
||||
map.insert(wall);
|
||||
map.insert(transient);
|
||||
|
||||
// 5 minutes after the last observation (τ = 60 s ⇒ transient decays
|
||||
// by e^{-5} ≈ 0.0067 < prune floor; the wall's stretched τ_eff keeps it).
|
||||
map.decay(2_100_000_000_000);
|
||||
assert_eq!(map.len(), 1, "only the long-lived structure survives");
|
||||
assert!((map.gaussians()[0].position[0]).abs() < 1e-9, "survivor is the wall");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_pass_collapses_mutual_overlaps_but_respects_semantics() {
|
||||
// Two overlapping unlabeled Gaussians that insert-fusion misses
|
||||
// because its gate only scans the ±1-cell neighborhood: with a
|
||||
// 0.05 m cell pitch, 0.40 m spacing is far outside the cell window
|
||||
// yet well inside the 3σ Mahalanobis merge gate (σ = 0.3).
|
||||
let mut map2 = GaussianMap::new(0.05);
|
||||
map2.insert(g_at([1.00, 0.0, 1.0], 0.5, 10));
|
||||
map2.insert(g_at([1.40, 0.0, 1.0], 0.5, 20));
|
||||
assert_eq!(map2.len(), 2, "insert kept them separate (cell-local gate)");
|
||||
let merged = map2.merge_overlapping();
|
||||
assert_eq!(merged, 1, "merge pass collapses the mutual overlap");
|
||||
assert_eq!(map2.len(), 1);
|
||||
let g = &map2.gaussians()[0];
|
||||
assert!((g.position[0] - 1.20).abs() < 1e-9, "confidence-weighted midpoint");
|
||||
assert!((g.confidence - 0.75).abs() < 1e-9, "noisy-OR confidence");
|
||||
|
||||
// Semantically incompatible pair does NOT merge.
|
||||
let mut c = g_at([5.0, 0.0, 1.0], 0.5, 30);
|
||||
c.semantic[0] = 1.0;
|
||||
let mut d = g_at([5.2, 0.0, 1.0], 0.5, 40);
|
||||
d.semantic[1] = 1.0; // orthogonal embedding
|
||||
map2.insert(c);
|
||||
map2.insert(d);
|
||||
let before = map2.len();
|
||||
assert_eq!(map2.merge_overlapping(), 0, "orthogonal semantics must not merge");
|
||||
assert_eq!(map2.len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_hash_query_matches_linear_scan() {
|
||||
let mut map = GaussianMap::new(0.7);
|
||||
// Grid of well-separated Gaussians (spacing 2 m ≫ merge gate at σ=0.3).
|
||||
for x in 0..10 {
|
||||
for y in 0..10 {
|
||||
map.insert(g_at([x as f64 * 2.0, y as f64 * 2.0, 1.0], 0.9, 0));
|
||||
}
|
||||
}
|
||||
assert_eq!(map.len(), 100);
|
||||
for (centre, radius) in
|
||||
[([5.0, 5.0, 1.0], 3.0), ([0.0, 0.0, 1.0], 1.5), ([18.0, 18.0, 1.0], 5.0)]
|
||||
{
|
||||
assert_eq!(
|
||||
map.query_radius(centre, radius),
|
||||
map.query_radius_linear(centre, radius),
|
||||
"hash and linear scans must agree at {centre:?} r={radius}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_corridor_query_matches_linear_scan() {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
for x in 0..12 {
|
||||
for y in 0..12 {
|
||||
for z in 0..2 {
|
||||
map.insert(g_at(
|
||||
[x as f64 * 1.7, y as f64 * 1.7, 0.8 + z as f64 * 1.4],
|
||||
0.9,
|
||||
0,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (a, b, m) in [
|
||||
([0.0, 0.0, 1.0], [18.0, 18.0, 1.5], 3.0),
|
||||
([2.0, 15.0, 1.0], [15.0, 2.0, 2.0], 1.5),
|
||||
([5.0, 5.0, 1.0], [5.0, 5.0, 1.0], 2.0), // degenerate segment
|
||||
] {
|
||||
assert_eq!(
|
||||
map.query_near_segment(a, b, m),
|
||||
map.query_near_segment_linear(a, b, m),
|
||||
"corridor hash walk and linear scan must agree for {a:?}→{b:?} m={m}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_and_semantic_queries() {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
map.insert(g_at([0.0, 0.0, 0.0], 0.9, 0));
|
||||
map.insert(g_at([3.0, 0.0, 0.0], 0.9, 0));
|
||||
let mut tagged = g_at([9.0, 9.0, 0.0], 0.9, 0);
|
||||
tagged.semantic[0] = 1.0;
|
||||
tagged.semantic[1] = 0.5;
|
||||
map.insert(tagged);
|
||||
|
||||
let near = map.query_nearest([0.2, 0.0, 0.0], 2);
|
||||
assert_eq!(near.len(), 2);
|
||||
assert_eq!(near[0], 0, "closest first");
|
||||
|
||||
let mut q = [0.0f32; SEMANTIC_DIM];
|
||||
q[0] = 1.0;
|
||||
q[1] = 0.5;
|
||||
let sem = map.query_semantic(&q, 1);
|
||||
assert_eq!(sem, vec![2], "semantic query finds the tagged Gaussian");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! RF-aware Gaussian spatial memory (ADR-275).
|
||||
//!
|
||||
//! The persistent scene representation: anisotropic 3-D Gaussians that carry
|
||||
//! *both* geometric/semantic state (position, scale, orientation, occupancy,
|
||||
//! semantic embedding) *and* RF state (per-band × incident-angle
|
||||
//! reflectivity, Doppler/motion, extinction), plus the bookkeeping a world
|
||||
//! model needs (confidence, provenance, timestamp, decay, entity links).
|
||||
//!
|
||||
//! Three capabilities distinguish this from a point cloud:
|
||||
//!
|
||||
//! 1. **Fusion**: observations of the same region merge by
|
||||
//! confidence-weighted precision updates instead of accumulating
|
||||
//! duplicates ([`map::GaussianMap::insert`]).
|
||||
//! 2. **RF queries**: the map predicts complex channel gain between any two
|
||||
//! points via Beer–Lambert transmittance through the Gaussians
|
||||
//! ([`gain::channel_gain`]) — an empty map degrades to *exact* free-space
|
||||
//! Friis, and measured-vs-predicted residuals update the map inversely
|
||||
//! ([`gain::observe_link`]).
|
||||
//! 3. **Task-gated activation**: reasoning code touches a bounded subgraph
|
||||
//! ([`graph::SceneGraph::activate`]), never the full memory.
|
||||
|
||||
pub mod gain;
|
||||
pub mod graph;
|
||||
pub mod map;
|
||||
pub mod primitive;
|
||||
|
||||
pub use gain::{channel_gain, gain_db, observe_link};
|
||||
pub use graph::{EntityKind, RelationKind, SceneEdge, SceneGraph, SceneNode};
|
||||
pub use map::GaussianMap;
|
||||
pub use primitive::{Band, EntityLink, MotionState, Provenance, RfGaussian};
|
||||
@@ -0,0 +1,351 @@
|
||||
//! The RF-aware Gaussian primitive (ADR-275 §2).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Result, UnifiedError};
|
||||
|
||||
/// Frequency bands with distinct material interaction (index into the
|
||||
/// reflectivity table).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Band {
|
||||
/// 2.4 GHz (WiFi b/g/n, BLE).
|
||||
Ghz2_4,
|
||||
/// 5–6 GHz (WiFi a/ac/ax, NR n77/n78-ish).
|
||||
Ghz5,
|
||||
/// 6–8 GHz (WiFi 6E/7, UWB).
|
||||
Ghz6,
|
||||
/// 60 GHz (mmWave radar / 802.11ad).
|
||||
Ghz60,
|
||||
}
|
||||
|
||||
impl Band {
|
||||
/// Number of modeled bands.
|
||||
pub const COUNT: usize = 4;
|
||||
|
||||
/// Table index.
|
||||
#[must_use]
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Ghz2_4 => 0,
|
||||
Self::Ghz5 => 1,
|
||||
Self::Ghz6 => 2,
|
||||
Self::Ghz60 => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Band for a carrier frequency in Hz (nearest-band bucketing).
|
||||
#[must_use]
|
||||
pub fn from_freq_hz(f: f64) -> Self {
|
||||
if f < 4.0e9 {
|
||||
Self::Ghz2_4
|
||||
} else if f < 6.0e9 {
|
||||
Self::Ghz5
|
||||
} else if f < 2.0e10 {
|
||||
Self::Ghz6
|
||||
} else {
|
||||
Self::Ghz60
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of incident-angle bins in the reflectivity table (0°–90° in 4
|
||||
/// bins of 22.5°).
|
||||
pub const ANGLE_BINS: usize = 4;
|
||||
|
||||
/// Semantic embedding width carried by each Gaussian.
|
||||
pub const SEMANTIC_DIM: usize = 16;
|
||||
|
||||
/// Coarse motion state of the matter a Gaussian represents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MotionState {
|
||||
/// Structural / furniture: no observed Doppler.
|
||||
Static,
|
||||
/// Slow biological motion (breathing, posture shifts).
|
||||
Slow,
|
||||
/// Walking-speed or faster motion.
|
||||
Fast,
|
||||
}
|
||||
|
||||
/// Where a Gaussian's evidence came from (ADR-273 acceptance item 8: every
|
||||
/// output carries provenance and model version).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Provenance {
|
||||
/// Device that observed the evidence.
|
||||
pub device_id: String,
|
||||
/// Version of the model that produced the update.
|
||||
pub model_version: u32,
|
||||
/// Whether the evidence is synthetic (ADR-276 honest labeling).
|
||||
pub synthetic: bool,
|
||||
}
|
||||
|
||||
/// What kind of entity a Gaussian links to in the scene graph / RuVector.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EntityLink {
|
||||
/// Kind of the linked entity.
|
||||
pub kind: super::graph::EntityKind,
|
||||
/// Stable identifier of the entity.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// One anisotropic RF-aware Gaussian (ADR-275 §2.1) — the six attribute
|
||||
/// groups from the ADR: geometry, semantics, RF response, motion,
|
||||
/// trust/lifecycle, and entity links.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RfGaussian {
|
||||
/// Centre, metres, room frame.
|
||||
pub position: [f64; 3],
|
||||
/// Standard deviations along the principal axes, metres (> 0).
|
||||
pub scale: [f64; 3],
|
||||
/// Orientation quaternion `[w, x, y, z]` (normalized on construction).
|
||||
pub orientation: [f64; 4],
|
||||
/// Peak extinction coefficient (nepers/metre at the centre) — the
|
||||
/// Beer–Lambert density used by the gain model. `>= 0`.
|
||||
pub occupancy: f64,
|
||||
/// Visual/semantic embedding.
|
||||
pub semantic: [f32; SEMANTIC_DIM],
|
||||
/// RF reflectivity by `[band][incident-angle bin]`, `0..=1` amplitude.
|
||||
pub reflectivity: [[f64; ANGLE_BINS]; Band::COUNT],
|
||||
/// Radial velocity estimate, m/s (signed).
|
||||
pub doppler_mps: f64,
|
||||
/// Doppler estimate variance, (m/s)² (ADR-281 lifecycle fields).
|
||||
pub doppler_variance: f64,
|
||||
/// Coarse motion class.
|
||||
pub motion: MotionState,
|
||||
/// Confidence in `[0, 1]`.
|
||||
pub confidence: f64,
|
||||
/// First-observed timestamp, ns since epoch (static structure is
|
||||
/// distinguishable from transients by lifetime, not just decay τ).
|
||||
pub first_seen_ns: u64,
|
||||
/// Last-updated timestamp, ns since epoch.
|
||||
pub timestamp_ns: u64,
|
||||
/// Confidence e-folding time in seconds (decay clock).
|
||||
pub decay_tau_s: f64,
|
||||
/// Evidence provenance.
|
||||
pub provenance: Provenance,
|
||||
/// Receipt ids of the source frames that shaped this Gaussian
|
||||
/// (measurement→inference lineage; capped on fusion).
|
||||
pub source_receipts: Vec<u128>,
|
||||
/// Links into the scene graph / RuVector entities.
|
||||
pub links: Vec<EntityLink>,
|
||||
}
|
||||
|
||||
/// Maximum receipts retained per Gaussian after fusion (bounded lineage).
|
||||
pub const MAX_SOURCE_RECEIPTS: usize = 16;
|
||||
|
||||
impl RfGaussian {
|
||||
/// Validated constructor: normalizes the quaternion and range-checks
|
||||
/// every numeric field (boundary rule — the map assumes validity).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
position: [f64; 3],
|
||||
scale: [f64; 3],
|
||||
orientation: [f64; 4],
|
||||
occupancy: f64,
|
||||
confidence: f64,
|
||||
timestamp_ns: u64,
|
||||
decay_tau_s: f64,
|
||||
provenance: Provenance,
|
||||
) -> Result<Self> {
|
||||
for v in position.iter().chain(scale.iter()).chain(orientation.iter()) {
|
||||
if !v.is_finite() {
|
||||
return Err(UnifiedError::InvalidInput("non-finite Gaussian field".into()));
|
||||
}
|
||||
}
|
||||
// Physical plausibility bounds, not just positivity: a subnormal
|
||||
// scale (e.g. 5e-324) passes `> 0` but overflows `1/σ²` to ∞ and
|
||||
// turns the density at the Gaussian's own centre into NaN (found
|
||||
// by `tests/security_boundaries.rs` property testing); a
|
||||
// kilometre-scale σ is equally meaningless indoors.
|
||||
const MIN_SCALE_M: f64 = 1e-6;
|
||||
const MAX_SCALE_M: f64 = 1e4;
|
||||
if scale.iter().any(|s| !(*s >= MIN_SCALE_M && *s <= MAX_SCALE_M)) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"scale must be within [{MIN_SCALE_M}, {MAX_SCALE_M}] m, got {scale:?}"
|
||||
)));
|
||||
}
|
||||
let norm =
|
||||
orientation.iter().map(|q| q * q).sum::<f64>().sqrt();
|
||||
if norm < 1e-6 {
|
||||
return Err(UnifiedError::InvalidInput("degenerate quaternion".into()));
|
||||
}
|
||||
let orientation = [
|
||||
orientation[0] / norm,
|
||||
orientation[1] / norm,
|
||||
orientation[2] / norm,
|
||||
orientation[3] / norm,
|
||||
];
|
||||
// Extinction beyond 1e6 nepers/m is physically meaningless and
|
||||
// only serves to smuggle ∞ into downstream gain products.
|
||||
if !(occupancy.is_finite() && (0.0..=1e6).contains(&occupancy)) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"occupancy must be in [0, 1e6] nepers/m, got {occupancy}"
|
||||
)));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&confidence) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"confidence must be in [0,1], got {confidence}"
|
||||
)));
|
||||
}
|
||||
if !(decay_tau_s.is_finite() && decay_tau_s > 0.0) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"decay_tau_s must be > 0, got {decay_tau_s}"
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
position,
|
||||
scale,
|
||||
orientation,
|
||||
occupancy,
|
||||
semantic: [0.0; SEMANTIC_DIM],
|
||||
reflectivity: [[0.0; ANGLE_BINS]; Band::COUNT],
|
||||
doppler_mps: 0.0,
|
||||
doppler_variance: 0.0,
|
||||
motion: MotionState::Static,
|
||||
confidence,
|
||||
first_seen_ns: timestamp_ns,
|
||||
timestamp_ns,
|
||||
decay_tau_s,
|
||||
provenance,
|
||||
source_receipts: Vec::new(),
|
||||
links: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Rotation matrix (row-major 3×3) from the unit quaternion.
|
||||
#[must_use]
|
||||
pub fn rotation(&self) -> [[f64; 3]; 3] {
|
||||
let [w, x, y, z] = self.orientation;
|
||||
[
|
||||
[1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - w * z), 2.0 * (x * z + w * y)],
|
||||
[2.0 * (x * y + w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - w * x)],
|
||||
[2.0 * (x * z - w * y), 2.0 * (y * z + w * x), 1.0 - 2.0 * (x * x + y * y)],
|
||||
]
|
||||
}
|
||||
|
||||
/// Inverse covariance `Σ⁻¹ = R·diag(1/σ²)·Rᵀ` (row-major 3×3).
|
||||
#[must_use]
|
||||
pub fn inv_covariance(&self) -> [[f64; 3]; 3] {
|
||||
let r = self.rotation();
|
||||
let inv_s2 = [
|
||||
1.0 / (self.scale[0] * self.scale[0]),
|
||||
1.0 / (self.scale[1] * self.scale[1]),
|
||||
1.0 / (self.scale[2] * self.scale[2]),
|
||||
];
|
||||
let mut out = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
let mut acc = 0.0;
|
||||
for (k, is2) in inv_s2.iter().enumerate() {
|
||||
acc += r[i][k] * is2 * r[j][k];
|
||||
}
|
||||
out[i][j] = acc;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Unnormalized density `exp(-½·dᵀΣ⁻¹d)` at a point (1 at the centre).
|
||||
#[must_use]
|
||||
pub fn density_at(&self, p: [f64; 3]) -> f64 {
|
||||
let d = [p[0] - self.position[0], p[1] - self.position[1], p[2] - self.position[2]];
|
||||
let si = self.inv_covariance();
|
||||
let mut q = 0.0;
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
q += d[i] * si[i][j] * d[j];
|
||||
}
|
||||
}
|
||||
(-0.5 * q).exp()
|
||||
}
|
||||
|
||||
/// Squared Mahalanobis distance of a point from this Gaussian.
|
||||
#[must_use]
|
||||
pub fn mahalanobis_sq(&self, p: [f64; 3]) -> f64 {
|
||||
let d = [p[0] - self.position[0], p[1] - self.position[1], p[2] - self.position[2]];
|
||||
let si = self.inv_covariance();
|
||||
let mut q = 0.0;
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
q += d[i] * si[i][j] * d[j];
|
||||
}
|
||||
}
|
||||
q
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn prov() -> Provenance {
|
||||
Provenance { device_id: "test".into(), model_version: 1, synthetic: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_validates_and_normalizes() {
|
||||
let g = RfGaussian::new(
|
||||
[1.0, 2.0, 1.0],
|
||||
[0.5, 0.5, 1.0],
|
||||
[2.0, 0.0, 0.0, 0.0], // non-unit quaternion → normalized
|
||||
0.5,
|
||||
0.9,
|
||||
0,
|
||||
300.0,
|
||||
prov(),
|
||||
)
|
||||
.expect("valid");
|
||||
assert!((g.orientation[0] - 1.0).abs() < 1e-12);
|
||||
|
||||
assert!(RfGaussian::new([0.0; 3], [0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 0.0], 0.1, 0.5, 0, 60.0, prov())
|
||||
.is_err());
|
||||
assert!(RfGaussian::new([0.0; 3], [1.0; 3], [1.0, 0.0, 0.0, 0.0], -0.1, 0.5, 0, 60.0, prov())
|
||||
.is_err());
|
||||
assert!(RfGaussian::new([0.0; 3], [1.0; 3], [1.0, 0.0, 0.0, 0.0], 0.1, 1.5, 0, 60.0, prov())
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn density_peaks_at_centre_and_respects_anisotropy() {
|
||||
let g = RfGaussian::new(
|
||||
[0.0; 3],
|
||||
[1.0, 0.1, 1.0], // thin along y
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
0.5,
|
||||
0.9,
|
||||
0,
|
||||
300.0,
|
||||
prov(),
|
||||
)
|
||||
.expect("valid");
|
||||
assert!((g.density_at([0.0; 3]) - 1.0).abs() < 1e-12);
|
||||
// Same offset along x (wide) vs y (thin): y decays far faster.
|
||||
// Analytic ratio at 0.3 m: exp(-0.045)/exp(-4.5) ≈ 86.
|
||||
assert!(g.density_at([0.3, 0.0, 0.0]) > g.density_at([0.0, 0.3, 0.0]) * 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_gaussian_rotates_its_metric() {
|
||||
// 90° rotation about z maps the thin y-axis onto x.
|
||||
let s = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let g = RfGaussian::new(
|
||||
[0.0; 3],
|
||||
[1.0, 0.1, 1.0],
|
||||
[s, 0.0, 0.0, s], // quaternion for Rz(90°)
|
||||
0.5,
|
||||
0.9,
|
||||
0,
|
||||
300.0,
|
||||
prov(),
|
||||
)
|
||||
.expect("valid");
|
||||
assert!(g.density_at([0.0, 0.3, 0.0]) > g.density_at([0.3, 0.0, 0.0]) * 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn band_bucketing() {
|
||||
assert_eq!(Band::from_freq_hz(2.437e9), Band::Ghz2_4);
|
||||
assert_eq!(Band::from_freq_hz(5.18e9), Band::Ghz5);
|
||||
assert_eq!(Band::from_freq_hz(6.5e9), Band::Ghz6);
|
||||
assert_eq!(Band::from_freq_hz(60e9), Band::Ghz60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
//! Task adapters ("heads") on the frozen encoder representation `z`
|
||||
//! (ADR-274 §4).
|
||||
//!
|
||||
//! The ADR-273 acceptance test allows adapters **smaller than 1 % of the
|
||||
//! backbone parameters** — enforced here by [`within_adapter_budget`] and by
|
||||
//! a unit test over every head at the deployment config. Multi-class heads
|
||||
//! use a low-rank (LoRA-style) factorization to stay inside the budget.
|
||||
|
||||
use crate::math::{sigmoid, softmax};
|
||||
|
||||
/// True iff the adapter fits the ADR-273 budget: `params(adapter) <
|
||||
/// 1 % · params(backbone)`.
|
||||
#[must_use]
|
||||
pub fn within_adapter_budget(backbone_params: usize, adapter_params: usize) -> bool {
|
||||
adapter_params * 100 < backbone_params
|
||||
}
|
||||
|
||||
/// Binary presence head: logistic regression on `z`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PresenceHead {
|
||||
/// Weights, length `d_model`.
|
||||
pub w: Vec<f64>,
|
||||
/// Bias.
|
||||
pub b: f64,
|
||||
}
|
||||
|
||||
impl PresenceHead {
|
||||
/// Zero-initialized head (logistic regression is convex; zero init is
|
||||
/// the standard, deterministic choice).
|
||||
#[must_use]
|
||||
pub fn new(d_model: usize) -> Self {
|
||||
Self { w: vec![0.0; d_model], b: 0.0 }
|
||||
}
|
||||
|
||||
/// Parameter count.
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
self.w.len() + 1
|
||||
}
|
||||
|
||||
/// P(present | z).
|
||||
#[must_use]
|
||||
pub fn predict_prob(&self, z: &[f64]) -> f64 {
|
||||
sigmoid(self.w.iter().zip(z).map(|(w, x)| w * x).sum::<f64>() + self.b)
|
||||
}
|
||||
|
||||
/// Full-batch gradient-descent training (convex ⇒ deterministic
|
||||
/// convergence). Returns the final mean cross-entropy.
|
||||
pub fn train(&mut self, zs: &[Vec<f64>], labels: &[bool], lr: f64, epochs: usize) -> f64 {
|
||||
assert_eq!(zs.len(), labels.len());
|
||||
let n = zs.len() as f64;
|
||||
let mut final_ce = f64::INFINITY;
|
||||
for _ in 0..epochs {
|
||||
let mut gw = vec![0.0; self.w.len()];
|
||||
let mut gb = 0.0;
|
||||
let mut ce = 0.0;
|
||||
for (z, &y) in zs.iter().zip(labels) {
|
||||
let p = self.predict_prob(z);
|
||||
let t = if y { 1.0 } else { 0.0 };
|
||||
ce -= t * p.max(1e-12).ln() + (1.0 - t) * (1.0 - p).max(1e-12).ln();
|
||||
let err = (p - t) / n;
|
||||
for (g, x) in gw.iter_mut().zip(z) {
|
||||
*g += err * x;
|
||||
}
|
||||
gb += err;
|
||||
}
|
||||
for (w, g) in self.w.iter_mut().zip(&gw) {
|
||||
*w -= lr * g;
|
||||
}
|
||||
self.b -= lr * gb;
|
||||
final_ce = ce / n;
|
||||
}
|
||||
final_ce
|
||||
}
|
||||
}
|
||||
|
||||
/// Low-rank multi-class head: `logits = U·(V·z) + b` with rank `r`
|
||||
/// (LoRA-style factorization keeps `C`-class heads inside the 1 % budget).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActivityHead {
|
||||
/// Classes.
|
||||
pub classes: usize,
|
||||
/// Rank.
|
||||
pub rank: usize,
|
||||
/// `classes × rank`, row-major.
|
||||
pub u: Vec<f64>,
|
||||
/// `rank × d_model`, row-major.
|
||||
pub v: Vec<f64>,
|
||||
/// Per-class bias.
|
||||
pub b: Vec<f64>,
|
||||
d_model: usize,
|
||||
}
|
||||
|
||||
impl ActivityHead {
|
||||
/// Deterministic small-value init (breaks the U/V symmetry without RNG).
|
||||
#[must_use]
|
||||
pub fn new(d_model: usize, classes: usize, rank: usize) -> Self {
|
||||
let u = (0..classes * rank)
|
||||
.map(|i| 0.01 * ((i % 7) as f64 - 3.0))
|
||||
.collect();
|
||||
let v = (0..rank * d_model)
|
||||
.map(|i| 0.01 * ((i % 5) as f64 - 2.0))
|
||||
.collect();
|
||||
Self { classes, rank, u, v, b: vec![0.0; classes], d_model }
|
||||
}
|
||||
|
||||
/// Parameter count.
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
self.u.len() + self.v.len() + self.b.len()
|
||||
}
|
||||
|
||||
/// Class probabilities.
|
||||
#[must_use]
|
||||
pub fn predict(&self, z: &[f64]) -> Vec<f64> {
|
||||
let mut probs = self.logits(z);
|
||||
softmax(&mut probs);
|
||||
probs
|
||||
}
|
||||
|
||||
fn logits(&self, z: &[f64]) -> Vec<f64> {
|
||||
let mut vz = vec![0.0; self.rank];
|
||||
for r in 0..self.rank {
|
||||
let row = &self.v[r * self.d_model..(r + 1) * self.d_model];
|
||||
vz[r] = row.iter().zip(z).map(|(a, b)| a * b).sum();
|
||||
}
|
||||
let mut logits = self.b.clone();
|
||||
for c in 0..self.classes {
|
||||
let row = &self.u[c * self.rank..(c + 1) * self.rank];
|
||||
logits[c] += row.iter().zip(&vz).map(|(a, b)| a * b).sum::<f64>();
|
||||
}
|
||||
logits
|
||||
}
|
||||
|
||||
/// Full-batch softmax cross-entropy training. Returns final mean CE.
|
||||
pub fn train(&mut self, zs: &[Vec<f64>], labels: &[usize], lr: f64, epochs: usize) -> f64 {
|
||||
assert_eq!(zs.len(), labels.len());
|
||||
let n = zs.len() as f64;
|
||||
let mut final_ce = f64::INFINITY;
|
||||
for _ in 0..epochs {
|
||||
let mut gu = vec![0.0; self.u.len()];
|
||||
let mut gv = vec![0.0; self.v.len()];
|
||||
let mut gb = vec![0.0; self.b.len()];
|
||||
let mut ce = 0.0;
|
||||
for (z, &y) in zs.iter().zip(labels) {
|
||||
let mut vz = vec![0.0; self.rank];
|
||||
for r in 0..self.rank {
|
||||
let row = &self.v[r * self.d_model..(r + 1) * self.d_model];
|
||||
vz[r] = row.iter().zip(z).map(|(a, b)| a * b).sum();
|
||||
}
|
||||
let mut p = self.b.clone();
|
||||
for c in 0..self.classes {
|
||||
let row = &self.u[c * self.rank..(c + 1) * self.rank];
|
||||
p[c] += row.iter().zip(&vz).map(|(a, b)| a * b).sum::<f64>();
|
||||
}
|
||||
softmax(&mut p);
|
||||
ce -= p[y].max(1e-12).ln();
|
||||
// dlogits = p − one_hot(y), scaled by 1/n.
|
||||
let mut dvz = vec![0.0; self.rank];
|
||||
for c in 0..self.classes {
|
||||
let dl = (p[c] - if c == y { 1.0 } else { 0.0 }) / n;
|
||||
gb[c] += dl;
|
||||
for r in 0..self.rank {
|
||||
gu[c * self.rank + r] += dl * vz[r];
|
||||
dvz[r] += dl * self.u[c * self.rank + r];
|
||||
}
|
||||
}
|
||||
for r in 0..self.rank {
|
||||
for (d, zv) in z.iter().enumerate() {
|
||||
gv[r * self.d_model + d] += dvz[r] * zv;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (w, g) in self.u.iter_mut().zip(&gu) {
|
||||
*w -= lr * g;
|
||||
}
|
||||
for (w, g) in self.v.iter_mut().zip(&gv) {
|
||||
*w -= lr * g;
|
||||
}
|
||||
for (w, g) in self.b.iter_mut().zip(&gb) {
|
||||
*w -= lr * g;
|
||||
}
|
||||
final_ce = ce / n;
|
||||
}
|
||||
final_ce
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear localization head: `xyz = W·z + b` (metres).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalizationHead {
|
||||
/// `3 × d_model` weights.
|
||||
pub w: Vec<f64>,
|
||||
/// Bias.
|
||||
pub b: [f64; 3],
|
||||
d_model: usize,
|
||||
}
|
||||
|
||||
impl LocalizationHead {
|
||||
/// Zero-initialized (linear least squares; convex).
|
||||
#[must_use]
|
||||
pub fn new(d_model: usize) -> Self {
|
||||
Self { w: vec![0.0; 3 * d_model], b: [0.0; 3], d_model }
|
||||
}
|
||||
|
||||
/// Parameter count.
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
self.w.len() + 3
|
||||
}
|
||||
|
||||
/// Predicted position.
|
||||
#[must_use]
|
||||
pub fn predict(&self, z: &[f64]) -> [f64; 3] {
|
||||
let mut out = self.b;
|
||||
for (axis, o) in out.iter_mut().enumerate() {
|
||||
let row = &self.w[axis * self.d_model..(axis + 1) * self.d_model];
|
||||
*o += row.iter().zip(z).map(|(a, b)| a * b).sum::<f64>();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Full-batch MSE training. Returns final mean squared error (m²).
|
||||
pub fn train(&mut self, zs: &[Vec<f64>], targets: &[[f64; 3]], lr: f64, epochs: usize) -> f64 {
|
||||
assert_eq!(zs.len(), targets.len());
|
||||
let n = zs.len() as f64;
|
||||
let mut final_mse = f64::INFINITY;
|
||||
for _ in 0..epochs {
|
||||
let mut gw = vec![0.0; self.w.len()];
|
||||
let mut gb = [0.0; 3];
|
||||
let mut mse = 0.0;
|
||||
for (z, t) in zs.iter().zip(targets) {
|
||||
let pred = self.predict(z);
|
||||
for axis in 0..3 {
|
||||
let err = pred[axis] - t[axis];
|
||||
mse += err * err;
|
||||
let scale = 2.0 * err / (3.0 * n);
|
||||
gb[axis] += scale;
|
||||
for (d, zv) in z.iter().enumerate() {
|
||||
gw[axis * self.d_model + d] += scale * zv;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (w, g) in self.w.iter_mut().zip(&gw) {
|
||||
*w -= lr * g;
|
||||
}
|
||||
for (axis, g) in gb.iter().enumerate() {
|
||||
self.b[axis] -= lr * g;
|
||||
}
|
||||
final_mse = mse / (3.0 * n);
|
||||
}
|
||||
final_mse
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of skeleton joints (COCO-17 convention, matching the ruvsense
|
||||
/// pose tracker).
|
||||
pub const NUM_JOINTS: usize = 17;
|
||||
|
||||
/// Generic low-rank linear regressor `y = U·(V·x) + b` — the shared
|
||||
/// building block for structured heads that must stay inside the adapter
|
||||
/// budget.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LowRankLinear {
|
||||
/// Output dimension.
|
||||
pub out: usize,
|
||||
/// Rank.
|
||||
pub rank: usize,
|
||||
d_in: usize,
|
||||
/// `out × rank`, row-major.
|
||||
pub u: Vec<f64>,
|
||||
/// `rank × d_in`, row-major.
|
||||
pub v: Vec<f64>,
|
||||
/// Bias, length `out`.
|
||||
pub b: Vec<f64>,
|
||||
}
|
||||
|
||||
impl LowRankLinear {
|
||||
/// Deterministic small-value init (breaks U/V symmetry without RNG).
|
||||
#[must_use]
|
||||
pub fn new(d_in: usize, out: usize, rank: usize) -> Self {
|
||||
let u = (0..out * rank).map(|i| 0.01 * ((i % 7) as f64 - 3.0)).collect();
|
||||
let v = (0..rank * d_in).map(|i| 0.01 * ((i % 5) as f64 - 2.0)).collect();
|
||||
Self { out, rank, d_in, u, v, b: vec![0.0; out] }
|
||||
}
|
||||
|
||||
/// Parameter count.
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
self.u.len() + self.v.len() + self.b.len()
|
||||
}
|
||||
|
||||
/// Prediction.
|
||||
#[must_use]
|
||||
pub fn predict(&self, x: &[f64]) -> Vec<f64> {
|
||||
let mut vx = vec![0.0; self.rank];
|
||||
for r in 0..self.rank {
|
||||
let row = &self.v[r * self.d_in..(r + 1) * self.d_in];
|
||||
vx[r] = row.iter().zip(x).map(|(a, b)| a * b).sum();
|
||||
}
|
||||
let mut y = self.b.clone();
|
||||
for o in 0..self.out {
|
||||
let row = &self.u[o * self.rank..(o + 1) * self.rank];
|
||||
y[o] += row.iter().zip(&vx).map(|(a, b)| a * b).sum::<f64>();
|
||||
}
|
||||
y
|
||||
}
|
||||
|
||||
/// Full-batch MSE training; returns the final mean squared error.
|
||||
pub fn train(&mut self, xs: &[Vec<f64>], ys: &[Vec<f64>], lr: f64, epochs: usize) -> f64 {
|
||||
assert_eq!(xs.len(), ys.len());
|
||||
let n = xs.len() as f64;
|
||||
let mut final_mse = f64::INFINITY;
|
||||
for _ in 0..epochs {
|
||||
let mut gu = vec![0.0; self.u.len()];
|
||||
let mut gv = vec![0.0; self.v.len()];
|
||||
let mut gb = vec![0.0; self.b.len()];
|
||||
let mut mse = 0.0;
|
||||
for (x, y) in xs.iter().zip(ys) {
|
||||
let mut vx = vec![0.0; self.rank];
|
||||
for r in 0..self.rank {
|
||||
let row = &self.v[r * self.d_in..(r + 1) * self.d_in];
|
||||
vx[r] = row.iter().zip(x).map(|(a, b)| a * b).sum();
|
||||
}
|
||||
let mut dvx = vec![0.0; self.rank];
|
||||
for o in 0..self.out {
|
||||
let row = &self.u[o * self.rank..(o + 1) * self.rank];
|
||||
let pred = self.b[o]
|
||||
+ row.iter().zip(&vx).map(|(a, b)| a * b).sum::<f64>();
|
||||
let err = pred - y[o];
|
||||
mse += err * err;
|
||||
let scale = 2.0 * err / (self.out as f64 * n);
|
||||
gb[o] += scale;
|
||||
for r in 0..self.rank {
|
||||
gu[o * self.rank + r] += scale * vx[r];
|
||||
dvx[r] += scale * self.u[o * self.rank + r];
|
||||
}
|
||||
}
|
||||
for r in 0..self.rank {
|
||||
for (d, xv) in x.iter().enumerate() {
|
||||
gv[r * self.d_in + d] += dvx[r] * xv;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (w, g) in self.u.iter_mut().zip(&gu) {
|
||||
*w -= lr * g;
|
||||
}
|
||||
for (w, g) in self.v.iter_mut().zip(&gv) {
|
||||
*w -= lr * g;
|
||||
}
|
||||
for (w, g) in self.b.iter_mut().zip(&gb) {
|
||||
*w -= lr * g;
|
||||
}
|
||||
final_mse = mse / (self.out as f64 * n);
|
||||
}
|
||||
final_mse
|
||||
}
|
||||
}
|
||||
|
||||
/// Factorized pose estimate (ADR-281 §4, the RePos lesson): root-relative
|
||||
/// skeleton and absolute root are separate quantities with separate
|
||||
/// uncertainties.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoseOutput {
|
||||
/// Root-relative joint positions, metres.
|
||||
pub relative_joints_m: [[f64; 3]; NUM_JOINTS],
|
||||
/// Absolute root position, metres, building frame.
|
||||
pub root_position_m: [f64; 3],
|
||||
/// Per-joint 1σ uncertainty, metres (calibrated from training residuals).
|
||||
pub joint_uncertainty_m: [f64; NUM_JOINTS],
|
||||
/// Root 1σ uncertainty, metres.
|
||||
pub root_uncertainty_m: f64,
|
||||
}
|
||||
|
||||
impl PoseOutput {
|
||||
/// Absolute joints: `root + relative` (the RePos composition).
|
||||
#[must_use]
|
||||
pub fn absolute_joints_m(&self) -> [[f64; 3]; NUM_JOINTS] {
|
||||
let mut out = self.relative_joints_m;
|
||||
for j in out.iter_mut() {
|
||||
for k in 0..3 {
|
||||
j[k] += self.root_position_m[k];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Factorized pose head: the **relative skeleton** branch reads the
|
||||
/// environment-invariant content representation (so it cannot learn
|
||||
/// room-specific position shortcuts), while the **root localization**
|
||||
/// branch reads the geometry-conditioned representation (where sensor
|
||||
/// pose is signal). This is the anti-leakage factorization measured in
|
||||
/// `tests::factorized_pose_resists_room_shortcut_leakage`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FactorizedPoseHead {
|
||||
/// Relative-skeleton regressor on the content representation.
|
||||
pub relative: LowRankLinear,
|
||||
/// Root regressor on the geometry-conditioned representation.
|
||||
pub root: LowRankLinear,
|
||||
/// Calibrated per-joint residual σ, metres.
|
||||
pub joint_residual_std_m: [f64; NUM_JOINTS],
|
||||
/// Calibrated root residual σ, metres.
|
||||
pub root_residual_std_m: f64,
|
||||
}
|
||||
|
||||
impl FactorizedPoseHead {
|
||||
/// New head for the given representation dims and rank.
|
||||
#[must_use]
|
||||
pub fn new(d_content: usize, d_full: usize, rank: usize) -> Self {
|
||||
Self {
|
||||
relative: LowRankLinear::new(d_content, NUM_JOINTS * 3, rank),
|
||||
root: LowRankLinear::new(d_full, 3, rank),
|
||||
joint_residual_std_m: [f64::INFINITY; NUM_JOINTS],
|
||||
root_residual_std_m: f64::INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameter count (both branches + calibration statistics).
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
self.relative.param_count() + self.root.param_count() + NUM_JOINTS + 1
|
||||
}
|
||||
|
||||
/// Trains both branches and calibrates residual uncertainties.
|
||||
/// Returns `(relative_mse, root_mse)` in m².
|
||||
pub fn train(
|
||||
&mut self,
|
||||
content_zs: &[Vec<f64>],
|
||||
full_zs: &[Vec<f64>],
|
||||
relative_targets: &[[[f64; 3]; NUM_JOINTS]],
|
||||
root_targets: &[[f64; 3]],
|
||||
lr: f64,
|
||||
epochs: usize,
|
||||
) -> (f64, f64) {
|
||||
let rel_flat: Vec<Vec<f64>> = relative_targets
|
||||
.iter()
|
||||
.map(|j| j.iter().flatten().copied().collect())
|
||||
.collect();
|
||||
let root_flat: Vec<Vec<f64>> = root_targets.iter().map(|r| r.to_vec()).collect();
|
||||
let rel_mse = self.relative.train(content_zs, &rel_flat, lr, epochs);
|
||||
let root_mse = self.root.train(full_zs, &root_flat, lr, epochs);
|
||||
|
||||
// Calibrate per-joint residual σ on the training set.
|
||||
let n = content_zs.len().max(1) as f64;
|
||||
let mut joint_sq = [0.0f64; NUM_JOINTS];
|
||||
let mut root_sq = 0.0;
|
||||
for i in 0..content_zs.len() {
|
||||
let rel = self.relative.predict(&content_zs[i]);
|
||||
for j in 0..NUM_JOINTS {
|
||||
let mut d2 = 0.0;
|
||||
for k in 0..3 {
|
||||
d2 += (rel[j * 3 + k] - relative_targets[i][j][k]).powi(2);
|
||||
}
|
||||
joint_sq[j] += d2;
|
||||
}
|
||||
let root = self.root.predict(&full_zs[i]);
|
||||
root_sq += (0..3).map(|k| (root[k] - root_targets[i][k]).powi(2)).sum::<f64>();
|
||||
}
|
||||
for j in 0..NUM_JOINTS {
|
||||
self.joint_residual_std_m[j] = (joint_sq[j] / n).sqrt();
|
||||
}
|
||||
self.root_residual_std_m = (root_sq / n).sqrt();
|
||||
(rel_mse, root_mse)
|
||||
}
|
||||
|
||||
/// Predicts a factorized pose with calibrated uncertainties.
|
||||
#[must_use]
|
||||
pub fn predict(&self, content_z: &[f64], full_z: &[f64]) -> PoseOutput {
|
||||
let rel = self.relative.predict(content_z);
|
||||
let root = self.root.predict(full_z);
|
||||
let mut relative_joints_m = [[0.0; 3]; NUM_JOINTS];
|
||||
for j in 0..NUM_JOINTS {
|
||||
for k in 0..3 {
|
||||
relative_joints_m[j][k] = rel[j * 3 + k];
|
||||
}
|
||||
}
|
||||
PoseOutput {
|
||||
relative_joints_m,
|
||||
root_position_m: [root[0], root[1], root[2]],
|
||||
joint_uncertainty_m: self.joint_residual_std_m,
|
||||
root_uncertainty_m: self.root_residual_std_m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Anomaly head: z-scores the encoder's masked-reconstruction error against
|
||||
/// a calibration distribution of *normal* windows. No learned weights —
|
||||
/// two calibration statistics.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnomalyHead {
|
||||
/// Calibration mean of reconstruction error.
|
||||
pub mean: f64,
|
||||
/// Calibration standard deviation.
|
||||
pub std: f64,
|
||||
}
|
||||
|
||||
impl AnomalyHead {
|
||||
/// Calibrates from reconstruction errors of known-normal windows.
|
||||
///
|
||||
/// # Panics
|
||||
/// If fewer than 2 calibration samples are provided.
|
||||
#[must_use]
|
||||
pub fn calibrate(normal_errors: &[f64]) -> Self {
|
||||
assert!(normal_errors.len() >= 2, "need >= 2 calibration errors");
|
||||
let n = normal_errors.len() as f64;
|
||||
let mean = normal_errors.iter().sum::<f64>() / n;
|
||||
let var = normal_errors.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / (n - 1.0);
|
||||
Self { mean, std: var.sqrt().max(1e-12) }
|
||||
}
|
||||
|
||||
/// Parameter count (two statistics).
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
/// Anomaly z-score for a window's reconstruction error.
|
||||
#[must_use]
|
||||
pub fn score(&self, recon_error: f64) -> f64 {
|
||||
(recon_error - self.mean) / self.std
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::encoder::{EncoderConfig, RfEncoder};
|
||||
|
||||
#[test]
|
||||
fn every_head_fits_the_one_percent_budget_at_deployment_config() {
|
||||
let enc = RfEncoder::new(EncoderConfig::default(), 1);
|
||||
let backbone = enc.param_count();
|
||||
let d = EncoderConfig::default().d_model;
|
||||
|
||||
let presence = PresenceHead::new(d).param_count();
|
||||
let activity = ActivityHead::new(d, 4, 2).param_count();
|
||||
let localization = LocalizationHead::new(d).param_count();
|
||||
let anomaly = AnomalyHead { mean: 0.0, std: 1.0 }.param_count();
|
||||
|
||||
for (name, p) in [
|
||||
("presence", presence),
|
||||
("activity", activity),
|
||||
("localization", localization),
|
||||
("anomaly", anomaly),
|
||||
] {
|
||||
assert!(
|
||||
within_adapter_budget(backbone, p),
|
||||
"{name} head has {p} params, budget is <{}",
|
||||
backbone / 100
|
||||
);
|
||||
}
|
||||
// The structured pose head is the largest adapter; its documented
|
||||
// budget is < 2 % of the backbone (ADR-281 §4).
|
||||
let pose = FactorizedPoseHead::new(enc.content_dim(), d, 2).param_count();
|
||||
assert!(pose * 100 < backbone * 2, "pose head {pose} params vs 2 % of {backbone}");
|
||||
println!(
|
||||
"backbone {backbone} params; heads: presence {presence}, activity {activity}, \
|
||||
localization {localization}, anomaly {anomaly} (budget < {}), pose {pose} (< 2 %)",
|
||||
backbone / 100
|
||||
);
|
||||
}
|
||||
|
||||
/// The RePos leakage experiment: in the training rooms, room position
|
||||
/// correlates with body scale (small people in room A, tall in room B).
|
||||
/// A monolithic absolute-pose head exploits the room feature as a
|
||||
/// shortcut and collapses in an unseen room that breaks the
|
||||
/// correlation; the factorized head's skeleton branch never sees room
|
||||
/// features and generalizes.
|
||||
#[test]
|
||||
fn factorized_pose_resists_room_shortcut_leakage() {
|
||||
use crate::eval::mpjpe;
|
||||
|
||||
// 17 fixed joint directions (deterministic).
|
||||
let dirs: Vec<[f64; 3]> = (0..NUM_JOINTS)
|
||||
.map(|j| {
|
||||
let a = j as f64 * 0.37;
|
||||
[a.cos() * 0.3, a.sin() * 0.3, 0.1 * ((j % 5) as f64 - 2.0)]
|
||||
})
|
||||
.collect();
|
||||
let skeleton = |scale: f64| {
|
||||
let mut joints = [[0.0; 3]; NUM_JOINTS];
|
||||
for (j, d) in dirs.iter().enumerate() {
|
||||
for k in 0..3 {
|
||||
joints[j][k] = d[k] * (1.0 + 0.5 * scale);
|
||||
}
|
||||
}
|
||||
joints
|
||||
};
|
||||
// content z = [scale, 1]; full z = [scale, room_x/3, 1].
|
||||
let sample = |scale: f64, room_x: f64| {
|
||||
let content = vec![scale, 1.0];
|
||||
let full = vec![scale, room_x / 3.0, 1.0];
|
||||
let root = [room_x + 0.5, 2.0, 1.0];
|
||||
(content, full, skeleton(scale), root)
|
||||
};
|
||||
|
||||
// Training: room A (x=0) only small scales, room B (x=3) only large —
|
||||
// the leakage trap.
|
||||
let mut content_zs = Vec::new();
|
||||
let mut full_zs = Vec::new();
|
||||
let mut rels = Vec::new();
|
||||
let mut roots = Vec::new();
|
||||
for i in 0..30 {
|
||||
let s = -1.0 + i as f64 / 30.0; // [-1, 0)
|
||||
let (c, f, r, ro) = sample(s, 0.0);
|
||||
content_zs.push(c);
|
||||
full_zs.push(f);
|
||||
rels.push(r);
|
||||
roots.push(ro);
|
||||
let s = i as f64 / 30.0; // [0, 1)
|
||||
let (c, f, r, ro) = sample(s, 3.0);
|
||||
content_zs.push(c);
|
||||
full_zs.push(f);
|
||||
rels.push(r);
|
||||
roots.push(ro);
|
||||
}
|
||||
|
||||
let mut head = FactorizedPoseHead::new(2, 3, 2);
|
||||
let (rel_mse, root_mse) = head.train(&content_zs, &full_zs, &rels, &roots, 0.3, 3000);
|
||||
assert!(rel_mse < 1e-3, "relative branch must fit, mse {rel_mse}");
|
||||
assert!(root_mse < 1e-3, "root branch must fit, mse {root_mse}");
|
||||
|
||||
// Monolithic baseline: absolute joints regressed from the full
|
||||
// (room-conditioned) representation.
|
||||
let abs_targets: Vec<Vec<f64>> = rels
|
||||
.iter()
|
||||
.zip(&roots)
|
||||
.map(|(rel, root)| {
|
||||
rel.iter().flat_map(|j| (0..3).map(move |k| j[k] + root[k])).collect()
|
||||
})
|
||||
.collect();
|
||||
let mut monolithic = LowRankLinear::new(3, NUM_JOINTS * 3, 2);
|
||||
monolithic.train(&full_zs, &abs_targets, 0.3, 3000);
|
||||
|
||||
// Held-out room (x=6) with the correlation broken: both scales.
|
||||
let mut fact_err = 0.0;
|
||||
let mut mono_err = 0.0;
|
||||
let mut count = 0.0;
|
||||
for i in 0..20 {
|
||||
let s = -1.0 + i as f64 / 10.0; // [-1, 1)
|
||||
let (c, f, rel, root) = sample(s, 6.0);
|
||||
let truth: Vec<[f64; 3]> =
|
||||
rel.iter().zip(std::iter::repeat(root)).map(|(j, r)| {
|
||||
[j[0] + r[0], j[1] + r[1], j[2] + r[2]]
|
||||
}).collect();
|
||||
|
||||
let pose = head.predict(&c, &f);
|
||||
let fact_abs = pose.absolute_joints_m();
|
||||
fact_err += mpjpe(&fact_abs, &truth);
|
||||
|
||||
let mono = monolithic.predict(&f);
|
||||
let mono_abs: Vec<[f64; 3]> = (0..NUM_JOINTS)
|
||||
.map(|j| [mono[j * 3], mono[j * 3 + 1], mono[j * 3 + 2]])
|
||||
.collect();
|
||||
mono_err += mpjpe(&mono_abs, &truth);
|
||||
count += 1.0;
|
||||
|
||||
// ADR-273 acceptance item: every output carries uncertainty.
|
||||
assert!(pose.root_uncertainty_m.is_finite());
|
||||
assert!(pose.joint_uncertainty_m.iter().all(|u| u.is_finite() && *u >= 0.0));
|
||||
}
|
||||
fact_err /= count;
|
||||
mono_err /= count;
|
||||
println!(
|
||||
"held-out room MPJPE: factorized {fact_err:.4} m vs monolithic {mono_err:.4} m"
|
||||
);
|
||||
assert!(fact_err < 0.15, "factorized head must generalize, MPJPE {fact_err}");
|
||||
assert!(
|
||||
mono_err > 1.5 * fact_err,
|
||||
"monolithic head must show the shortcut collapse: {mono_err} vs {fact_err}"
|
||||
);
|
||||
}
|
||||
|
||||
fn separable_data(n: usize, d: usize) -> (Vec<Vec<f64>>, Vec<bool>) {
|
||||
let mut zs = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
for i in 0..n {
|
||||
let y = i % 2 == 0;
|
||||
let offset = if y { 1.0 } else { -1.0 };
|
||||
let z: Vec<f64> =
|
||||
(0..d).map(|k| offset * (0.5 + (k as f64 / d as f64)) + 0.1 * ((i * k) % 3) as f64).collect();
|
||||
zs.push(z);
|
||||
ys.push(y);
|
||||
}
|
||||
(zs, ys)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_head_learns_separable_data() {
|
||||
let (zs, ys) = separable_data(60, 16);
|
||||
let mut head = PresenceHead::new(16);
|
||||
let ce = head.train(&zs, &ys, 0.5, 200);
|
||||
assert!(ce < 0.1, "cross-entropy should collapse on separable data, got {ce}");
|
||||
let correct = zs
|
||||
.iter()
|
||||
.zip(&ys)
|
||||
.filter(|(z, &y)| (head.predict_prob(z) > 0.5) == y)
|
||||
.count();
|
||||
assert_eq!(correct, zs.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_head_learns_multiclass_toy() {
|
||||
let d = 16;
|
||||
let mut zs = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
for i in 0..90 {
|
||||
let c = i % 3;
|
||||
let z: Vec<f64> = (0..d)
|
||||
.map(|k| if k % 3 == c { 1.0 } else { 0.0 } + 0.05 * ((i + k) % 5) as f64)
|
||||
.collect();
|
||||
zs.push(z);
|
||||
ys.push(c);
|
||||
}
|
||||
let mut head = ActivityHead::new(d, 3, 2);
|
||||
let ce = head.train(&zs, &ys, 0.5, 400);
|
||||
assert!(ce < 0.3, "multiclass CE should drop, got {ce}");
|
||||
let acc = zs
|
||||
.iter()
|
||||
.zip(&ys)
|
||||
.filter(|(z, &y)| {
|
||||
let p = head.predict(z);
|
||||
p.iter().enumerate().max_by(|a, b| a.1.partial_cmp(b.1).unwrap()).unwrap().0 == y
|
||||
})
|
||||
.count() as f64
|
||||
/ zs.len() as f64;
|
||||
assert!(acc > 0.95, "accuracy {acc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anomaly_head_zscores_against_calibration() {
|
||||
let normal: Vec<f64> = (0..50).map(|i| 0.10 + 0.001 * (i % 7) as f64).collect();
|
||||
let head = AnomalyHead::calibrate(&normal);
|
||||
assert!(head.score(0.103).abs() < 3.0, "in-distribution error must not alarm");
|
||||
assert!(head.score(0.5) > 10.0, "gross error must alarm loudly");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! # ruview-unified — Unified RF Spatial World Model (ADR-273)
|
||||
//!
|
||||
//! One shared representation where WiFi CSI, cellular SRS, FMCW radar, UWB
|
||||
//! CIR, geometry, semantics, uncertainty, and time all update the same
|
||||
//! persistent scene memory, instead of another isolated RF classifier.
|
||||
//!
|
||||
//! The crate implements the five ADR-273 pillars as bounded modules:
|
||||
//!
|
||||
//! | Pillar | Module | ADR |
|
||||
//! |--------|--------|-----|
|
||||
//! | Canonical RF tensor + hardware adapter registry | [`tensor`], [`adapters`] | ADR-274 |
|
||||
//! | Universal RF foundation encoder (masked-reconstruction pretraining, age/geometry/uncertainty fusion, ≤1 % task adapters) | [`tokenizer`], [`encoder`], [`pretrain`], [`heads`] | ADR-274 |
|
||||
//! | Anti-leakage evaluation: strict partitions, calibration, abstention | [`eval`] | ADR-273 §5 |
|
||||
//! | RF-aware Gaussian spatial memory + task-gated scene graph | [`gaussian`] | ADR-275 |
|
||||
//! | Physics-guided synthetic RF world generator | [`synth`] | ADR-276 |
|
||||
//! | Edge sensing control plane (802.11bf / ETSI ISAC-aligned policy) | [`policy`] | ADR-277 |
|
||||
//!
|
||||
//! ## Design commitments
|
||||
//!
|
||||
//! - **Deterministic**: every stochastic step (weight init, masking, domain
|
||||
//! randomization) is seeded ChaCha20; same seed ⇒ identical results across
|
||||
//! machines (the nvsim commitment).
|
||||
//! - **Proven, not asserted**: the encoder's backward pass is verified against
|
||||
//! finite differences; the ray tracer is verified against Friis, reciprocity,
|
||||
//! and image-method geometry; the Gaussian gain model degrades to exact
|
||||
//! free-space when the map is empty.
|
||||
//! - **Honest labeling**: every accuracy number produced by this crate's tests
|
||||
//! is SYNTHETIC (generated by [`synth`]) until validated on measured data.
|
||||
//! - **Privacy fail-closed**: raw RF never crosses the trust boundary; only
|
||||
//! [`policy::BoundedEvent`] (uncertainty + provenance + model version +
|
||||
//! purpose, all mandatory) is exportable, and unknown zones/purposes deny.
|
||||
|
||||
// The numeric kernels (encoder forward/backward, low-rank heads) index
|
||||
// several parallel arrays per iteration; index loops are the clearest and
|
||||
// equally fast form there.
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
pub mod adapters;
|
||||
pub mod control;
|
||||
pub mod encoder;
|
||||
pub mod eval;
|
||||
pub mod frame;
|
||||
pub mod gaussian;
|
||||
pub mod heads;
|
||||
pub mod math;
|
||||
pub mod policy;
|
||||
pub mod pretrain;
|
||||
pub mod synth;
|
||||
pub mod tensor;
|
||||
pub mod tokenizer;
|
||||
|
||||
pub use adapters::{AdapterRegistry, RawCapture, RfAdapter};
|
||||
pub use encoder::{EncoderConfig, RfEncoder, WindowContext};
|
||||
pub use tensor::{CalibrationMeta, LinkGeometry, RfModality, RfTensor};
|
||||
|
||||
/// Errors produced at the crate's system boundaries.
|
||||
///
|
||||
/// Input validation happens at construction ([`RfTensor::new`]) and at
|
||||
/// adapter normalization; downstream modules may assume validated tensors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UnifiedError {
|
||||
/// A numeric field was non-finite or out of its documented range.
|
||||
#[error("invalid input at boundary: {0}")]
|
||||
InvalidInput(String),
|
||||
/// Tensor shape does not match its declared link geometry.
|
||||
#[error("shape mismatch: {0}")]
|
||||
ShapeMismatch(String),
|
||||
/// An adapter was handed a capture of the wrong modality.
|
||||
#[error("modality mismatch: adapter {adapter} cannot normalize {got:?}")]
|
||||
ModalityMismatch {
|
||||
/// Hardware id of the adapter that rejected the capture.
|
||||
adapter: String,
|
||||
/// Modality of the capture that was offered.
|
||||
got: tensor::RfModality,
|
||||
},
|
||||
/// No adapter registered for the requested hardware id.
|
||||
#[error("no adapter registered for hardware id {0:?}")]
|
||||
UnknownHardware(String),
|
||||
/// Model/data dimension disagreement (programmer error surfaced safely).
|
||||
#[error("dimension mismatch: {0}")]
|
||||
DimensionMismatch(String),
|
||||
/// The sensing control plane denied the operation (fail-closed).
|
||||
#[error("policy denied: {0}")]
|
||||
PolicyDenied(String),
|
||||
}
|
||||
|
||||
/// Crate-wide result alias.
|
||||
pub type Result<T> = std::result::Result<T, UnifiedError>;
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Small, dependency-free numeric kernels shared across the crate.
|
||||
//!
|
||||
//! Everything here is deterministic and exact enough to be tested against
|
||||
//! closed forms: `erf` is Abramowitz–Stegun 7.1.26 (|ε| ≤ 1.5e-7), the DFT is
|
||||
//! the O(n²) definition (n ≤ 64 throughout this crate, so an FFT dependency
|
||||
//! would buy nothing), and the resampler is linear interpolation on the
|
||||
//! complex plane (amplitude/phase-continuous for the small bin ratios the
|
||||
//! adapters use).
|
||||
|
||||
use num_complex::Complex64;
|
||||
use rand::Rng;
|
||||
use rand_chacha::rand_core::SeedableRng;
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
|
||||
/// Error function, Abramowitz & Stegun 7.1.26 rational approximation.
|
||||
///
|
||||
/// Maximum absolute error 1.5e-7 — far below the opacity resolution the
|
||||
/// Gaussian gain model needs.
|
||||
#[must_use]
|
||||
pub fn erf(x: f64) -> f64 {
|
||||
let sign = if x < 0.0 { -1.0 } else { 1.0 };
|
||||
let x = x.abs();
|
||||
let t = 1.0 / (1.0 + 0.327_591_1 * x);
|
||||
let poly = t
|
||||
* (0.254_829_592
|
||||
+ t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
|
||||
sign * (1.0 - poly * (-x * x).exp())
|
||||
}
|
||||
|
||||
/// Numerically stable logistic sigmoid.
|
||||
#[must_use]
|
||||
pub fn sigmoid(x: f64) -> f64 {
|
||||
if x >= 0.0 {
|
||||
1.0 / (1.0 + (-x).exp())
|
||||
} else {
|
||||
let e = x.exp();
|
||||
e / (1.0 + e)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-place stable softmax.
|
||||
pub fn softmax(v: &mut [f64]) {
|
||||
let max = v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let mut sum = 0.0;
|
||||
for x in v.iter_mut() {
|
||||
*x = (*x - max).exp();
|
||||
sum += *x;
|
||||
}
|
||||
for x in v.iter_mut() {
|
||||
*x /= sum;
|
||||
}
|
||||
}
|
||||
|
||||
/// Magnitudes of the first `k` DFT coefficients of `x` (definition-form DFT).
|
||||
///
|
||||
/// Used for delay-domain (across subcarriers) and Doppler-domain (across
|
||||
/// snapshots) token features. `k ≤ x.len()` is enforced by the callers.
|
||||
#[must_use]
|
||||
pub fn dft_magnitudes(x: &[Complex64], k: usize) -> Vec<f64> {
|
||||
let n = x.len();
|
||||
let mut out = Vec::with_capacity(k);
|
||||
for bin in 0..k {
|
||||
let mut acc = Complex64::new(0.0, 0.0);
|
||||
for (t, v) in x.iter().enumerate() {
|
||||
let ang = -2.0 * std::f64::consts::PI * (bin as f64) * (t as f64) / (n as f64);
|
||||
acc += v * Complex64::new(ang.cos(), ang.sin());
|
||||
}
|
||||
out.push(acc.norm() / n as f64);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Precomputed twiddle table for repeated fixed-size DFTs.
|
||||
///
|
||||
/// The naive [`dft_magnitudes`] recomputes `cos`/`sin` per sample; the
|
||||
/// tokenizer calls the transform once per token, so the table amortizes the
|
||||
/// trig. The optimization is *proven equivalent* in `tokenizer::tests` and
|
||||
/// its speedup is measured in `benches/unified_bench.rs`.
|
||||
pub struct DftPlan {
|
||||
n: usize,
|
||||
k: usize,
|
||||
/// Row-major `k × n` twiddles: `exp(-2πi·bin·t/n)`.
|
||||
twiddles: Vec<Complex64>,
|
||||
}
|
||||
|
||||
impl DftPlan {
|
||||
/// Builds a plan for length-`n` inputs and `k` output bins.
|
||||
#[must_use]
|
||||
pub fn new(n: usize, k: usize) -> Self {
|
||||
let mut twiddles = Vec::with_capacity(k * n);
|
||||
for bin in 0..k {
|
||||
for t in 0..n {
|
||||
let ang = -2.0 * std::f64::consts::PI * (bin as f64) * (t as f64) / (n as f64);
|
||||
twiddles.push(Complex64::new(ang.cos(), ang.sin()));
|
||||
}
|
||||
}
|
||||
Self { n, k, twiddles }
|
||||
}
|
||||
|
||||
/// DFT magnitudes via the precomputed table; identical (to f64 rounding)
|
||||
/// to [`dft_magnitudes`] on the same input.
|
||||
///
|
||||
/// # Panics
|
||||
/// If `x.len()` differs from the planned length.
|
||||
#[must_use]
|
||||
pub fn magnitudes(&self, x: &[Complex64]) -> Vec<f64> {
|
||||
assert_eq!(x.len(), self.n, "DftPlan length mismatch");
|
||||
let mut out = Vec::with_capacity(self.k);
|
||||
for bin in 0..self.k {
|
||||
let row = &self.twiddles[bin * self.n..(bin + 1) * self.n];
|
||||
let mut acc = Complex64::new(0.0, 0.0);
|
||||
for (v, w) in x.iter().zip(row) {
|
||||
acc += v * w;
|
||||
}
|
||||
out.push(acc.norm() / self.n as f64);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear interpolation of a complex series onto `m` uniformly spaced points.
|
||||
///
|
||||
/// Interpolates real and imaginary parts independently — adequate for the
|
||||
/// small resampling ratios (≤ 2×) the adapters perform, and exactly identity
|
||||
/// when `m == x.len()`.
|
||||
#[must_use]
|
||||
pub fn resample_complex(x: &[Complex64], m: usize) -> Vec<Complex64> {
|
||||
let n = x.len();
|
||||
if n == m {
|
||||
return x.to_vec();
|
||||
}
|
||||
if n == 1 {
|
||||
return vec![x[0]; m];
|
||||
}
|
||||
if m == 1 {
|
||||
// `(m - 1)` would divide by zero below; a single output point is the
|
||||
// mean of the series rather than an arbitrary NaN-poisoned sample.
|
||||
let sum: Complex64 = x.iter().copied().sum();
|
||||
return vec![sum / n as f64];
|
||||
}
|
||||
let mut out = Vec::with_capacity(m);
|
||||
for j in 0..m {
|
||||
let pos = (j as f64) * ((n - 1) as f64) / ((m - 1) as f64);
|
||||
let i0 = pos.floor() as usize;
|
||||
let i1 = (i0 + 1).min(n - 1);
|
||||
let frac = pos - i0 as f64;
|
||||
out.push(x[i0] * (1.0 - frac) + x[i1] * frac);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Median of a slice (copies; slices here are ≤ a few hundred elements).
|
||||
#[must_use]
|
||||
pub fn median(values: &[f64]) -> f64 {
|
||||
if values.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut v: Vec<f64> = values.to_vec();
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mid = v.len() / 2;
|
||||
if v.len() % 2 == 0 {
|
||||
(v[mid - 1] + v[mid]) / 2.0
|
||||
} else {
|
||||
v[mid]
|
||||
}
|
||||
}
|
||||
|
||||
/// Least-squares slope of `y` against index `0..n` (used to detrend the
|
||||
/// linear phase ramp that sampling-time offset imprints across subcarriers).
|
||||
#[must_use]
|
||||
pub fn linear_slope(y: &[f64]) -> f64 {
|
||||
let n = y.len();
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let nf = n as f64;
|
||||
let mean_x = (nf - 1.0) / 2.0;
|
||||
let mean_y = y.iter().sum::<f64>() / nf;
|
||||
let mut num = 0.0;
|
||||
let mut den = 0.0;
|
||||
for (i, v) in y.iter().enumerate() {
|
||||
let dx = i as f64 - mean_x;
|
||||
num += dx * (v - mean_y);
|
||||
den += dx * dx;
|
||||
}
|
||||
num / den
|
||||
}
|
||||
|
||||
/// Deterministic RNG from a u64 seed (ChaCha20, the nvsim convention).
|
||||
#[must_use]
|
||||
pub fn seeded_rng(seed: u64) -> ChaCha20Rng {
|
||||
ChaCha20Rng::seed_from_u64(seed)
|
||||
}
|
||||
|
||||
/// Xavier/Glorot-uniform init for a `rows × cols` weight matrix, flattened
|
||||
/// row-major. Deterministic given the RNG state.
|
||||
pub fn xavier_init(rng: &mut ChaCha20Rng, rows: usize, cols: usize) -> Vec<f64> {
|
||||
let limit = (6.0 / (rows + cols) as f64).sqrt();
|
||||
(0..rows * cols).map(|_| rng.gen_range(-limit..limit)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn erf_matches_known_values() {
|
||||
// erf(0)=0, erf(∞)→1, erf(1)=0.8427007929 (tabulated).
|
||||
// Tolerances are the A&S 7.1.26 approximation bound (1.5e-7), not
|
||||
// machine epsilon — at x=0 the rational polynomial leaves ~1e-9.
|
||||
assert!(erf(0.0).abs() < 2e-7);
|
||||
assert!((erf(1.0) - 0.842_700_792_9).abs() < 2e-7);
|
||||
assert!((erf(-1.0) + 0.842_700_792_9).abs() < 2e-7);
|
||||
assert!((erf(3.0) - 0.999_977_909_5).abs() < 2e-7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigmoid_is_stable_and_symmetric() {
|
||||
assert!((sigmoid(0.0) - 0.5).abs() < 1e-12);
|
||||
assert!((sigmoid(500.0) - 1.0).abs() < 1e-12);
|
||||
assert!(sigmoid(-500.0) >= 0.0);
|
||||
assert!((sigmoid(2.0) + sigmoid(-2.0) - 1.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dft_finds_pure_tone() {
|
||||
// x[t] = exp(2πi·3t/16) has all its energy in bin 3.
|
||||
let n = 16;
|
||||
let x: Vec<Complex64> = (0..n)
|
||||
.map(|t| {
|
||||
let ang = 2.0 * std::f64::consts::PI * 3.0 * t as f64 / n as f64;
|
||||
Complex64::new(ang.cos(), ang.sin())
|
||||
})
|
||||
.collect();
|
||||
let mags = dft_magnitudes(&x, 8);
|
||||
assert!((mags[3] - 1.0).abs() < 1e-9);
|
||||
for (i, m) in mags.iter().enumerate() {
|
||||
if i != 3 {
|
||||
assert!(*m < 1e-9, "leakage at bin {i}: {m}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dft_plan_matches_naive() {
|
||||
let mut rng = seeded_rng(7);
|
||||
let x: Vec<Complex64> = (0..24)
|
||||
.map(|_| Complex64::new(rng.gen_range(-1.0..1.0), rng.gen_range(-1.0..1.0)))
|
||||
.collect();
|
||||
let plan = DftPlan::new(24, 10);
|
||||
let a = dft_magnitudes(&x, 10);
|
||||
let b = plan.magnitudes(&x);
|
||||
for (u, v) in a.iter().zip(&b) {
|
||||
assert!((u - v).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resample_identity_and_endpoints() {
|
||||
let x: Vec<Complex64> = (0..10).map(|i| Complex64::new(i as f64, -(i as f64))).collect();
|
||||
assert_eq!(resample_complex(&x, 10), x);
|
||||
let y = resample_complex(&x, 25);
|
||||
assert_eq!(y.len(), 25);
|
||||
assert!((y[0] - x[0]).norm() < 1e-12);
|
||||
assert!((y[24] - x[9]).norm() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slope_recovers_linear_ramp() {
|
||||
let y: Vec<f64> = (0..50).map(|i| 0.37 * i as f64 + 2.0).collect();
|
||||
assert!((linear_slope(&y) - 0.37).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeded_rng_is_deterministic() {
|
||||
let mut a = seeded_rng(42);
|
||||
let mut b = seeded_rng(42);
|
||||
let va: Vec<f64> = (0..8).map(|_| a.gen_range(-1.0..1.0)).collect();
|
||||
let vb: Vec<f64> = (0..8).map(|_| b.gen_range(-1.0..1.0)).collect();
|
||||
assert_eq!(va, vb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Edge sensing control plane (ADR-277) — purposes, zones, retention,
|
||||
//! identity gating, and the export trust boundary.
|
||||
//!
|
||||
//! Aligned with the sensing-service vocabulary of IEEE 802.11bf-2025 and
|
||||
//! the ETSI ISAC architecture (sensing purpose + sensing zone as first-class
|
||||
//! authorization objects; the ETSI security report's issue classes motivate
|
||||
//! the fail-closed defaults). Three hard rules, all enforced structurally:
|
||||
//!
|
||||
//! 1. **Raw RF never leaves the trust boundary.** The only exportable type
|
||||
//! is [`BoundedEvent`] — it cannot carry a tensor, and
|
||||
//! [`TrustBoundary::export`] is the only egress. There is deliberately
|
||||
//! no API that serializes an [`crate::tensor::RfTensor`] outward.
|
||||
//! 2. **Fail closed.** Unknown zone ⇒ deny. Purpose not granted ⇒ deny.
|
||||
//! Identity inference ⇒ deny unless the zone *explicitly* enables it in
|
||||
//! addition to granting the purpose.
|
||||
//! 3. **Every output is accountable.** A [`BoundedEvent`] cannot be built
|
||||
//! without uncertainty, provenance, model version, and purpose
|
||||
//! (ADR-273 acceptance item 8).
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::gaussian::primitive::Provenance;
|
||||
use crate::{Result, UnifiedError};
|
||||
|
||||
/// Sensing purposes (ETSI ISAC sensing-service classes, WLAN-sensing
|
||||
/// aligned). Ordering matters only for display.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum SensingPurpose {
|
||||
/// Someone is / is not present.
|
||||
Presence,
|
||||
/// Coarse activity class.
|
||||
Activity,
|
||||
/// Respiration / heart-rate class vitals.
|
||||
Vitals,
|
||||
/// Position estimation.
|
||||
Localization,
|
||||
/// Skeletal pose tracking.
|
||||
PoseTracking,
|
||||
/// Identity recognition — the high-risk purpose; doubly gated.
|
||||
IdentityRecognition,
|
||||
/// RF channel diagnostics (no human inference).
|
||||
ChannelDiagnostics,
|
||||
}
|
||||
|
||||
/// A spatial sensing zone and what it permits.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PrivacyZone {
|
||||
/// Zone identifier (maps to rooms/regions in the scene graph).
|
||||
pub id: String,
|
||||
/// Purposes granted in this zone.
|
||||
pub allowed_purposes: BTreeSet<SensingPurpose>,
|
||||
/// Maximum event age at export, seconds (retention bound).
|
||||
pub retention_s: u64,
|
||||
/// Second factor for identity: even if `IdentityRecognition` is in
|
||||
/// `allowed_purposes`, it is denied unless this is also true.
|
||||
pub identity_explicitly_enabled: bool,
|
||||
}
|
||||
|
||||
/// Payload of a bounded event — semantically typed results only; no
|
||||
/// variant can carry raw RF samples.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum EventValue {
|
||||
/// Presence verdict.
|
||||
Presence(bool),
|
||||
/// Activity class index.
|
||||
ActivityClass(u8),
|
||||
/// Respiration rate, breaths/minute.
|
||||
RespirationBpm(f64),
|
||||
/// Position estimate, metres, room frame.
|
||||
Location([f64; 3]),
|
||||
/// Anomaly z-score.
|
||||
AnomalyScore(f64),
|
||||
}
|
||||
|
||||
/// The only type allowed across the trust boundary. Construction validates
|
||||
/// that the accountability fields are present and sane.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BoundedEvent {
|
||||
/// Purpose under which this event was produced.
|
||||
pub purpose: SensingPurpose,
|
||||
/// Typed result.
|
||||
pub value: EventValue,
|
||||
/// Mandatory uncertainty in `[0, 1]` (1 = no information).
|
||||
pub uncertainty: f64,
|
||||
/// Evidence provenance (device, model, synthetic flag).
|
||||
pub provenance: Provenance,
|
||||
/// Model version that produced the inference.
|
||||
pub model_version: u32,
|
||||
/// Event timestamp, ns since epoch.
|
||||
pub timestamp_ns: u64,
|
||||
/// Zone the event was sensed in.
|
||||
pub zone_id: String,
|
||||
}
|
||||
|
||||
impl BoundedEvent {
|
||||
/// Validated constructor — the only way to build an exportable event.
|
||||
pub fn new(
|
||||
purpose: SensingPurpose,
|
||||
value: EventValue,
|
||||
uncertainty: f64,
|
||||
provenance: Provenance,
|
||||
model_version: u32,
|
||||
timestamp_ns: u64,
|
||||
zone_id: impl Into<String>,
|
||||
) -> Result<Self> {
|
||||
if !(0.0..=1.0).contains(&uncertainty) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"uncertainty must be in [0,1], got {uncertainty}"
|
||||
)));
|
||||
}
|
||||
if model_version == 0 {
|
||||
return Err(UnifiedError::InvalidInput(
|
||||
"model_version 0 (unassigned) is not exportable".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
purpose,
|
||||
value,
|
||||
uncertainty,
|
||||
provenance,
|
||||
model_version,
|
||||
timestamp_ns,
|
||||
zone_id: zone_id.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The policy engine: zone registry + authorization checks.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PolicyEngine {
|
||||
zones: HashMap<String, PrivacyZone>,
|
||||
}
|
||||
|
||||
impl PolicyEngine {
|
||||
/// Empty engine (denies everything until zones are configured).
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registers or replaces a zone.
|
||||
pub fn upsert_zone(&mut self, zone: PrivacyZone) {
|
||||
self.zones.insert(zone.id.clone(), zone);
|
||||
}
|
||||
|
||||
/// Authorizes sensing for `purpose` in `zone_id`. Fail-closed on every
|
||||
/// branch: unknown zone, ungranted purpose, and the identity double
|
||||
/// gate all deny.
|
||||
pub fn authorize(&self, zone_id: &str, purpose: SensingPurpose) -> Result<()> {
|
||||
let zone = self
|
||||
.zones
|
||||
.get(zone_id)
|
||||
.ok_or_else(|| UnifiedError::PolicyDenied(format!("unknown zone {zone_id:?}")))?;
|
||||
if !zone.allowed_purposes.contains(&purpose) {
|
||||
return Err(UnifiedError::PolicyDenied(format!(
|
||||
"purpose {purpose:?} not granted in zone {zone_id:?}"
|
||||
)));
|
||||
}
|
||||
if purpose == SensingPurpose::IdentityRecognition && !zone.identity_explicitly_enabled {
|
||||
return Err(UnifiedError::PolicyDenied(format!(
|
||||
"identity recognition requires explicit enablement in zone {zone_id:?}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The egress point. Holds the policy engine and a monotonically supplied
|
||||
/// "now"; the **only** public method emits [`BoundedEvent`]s — raw RF has no
|
||||
/// path through here by construction.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TrustBoundary {
|
||||
engine: PolicyEngine,
|
||||
}
|
||||
|
||||
impl TrustBoundary {
|
||||
/// New boundary over a configured engine.
|
||||
#[must_use]
|
||||
pub fn new(engine: PolicyEngine) -> Self {
|
||||
Self { engine }
|
||||
}
|
||||
|
||||
/// Zone-config passthrough.
|
||||
pub fn engine_mut(&mut self) -> &mut PolicyEngine {
|
||||
&mut self.engine
|
||||
}
|
||||
|
||||
/// Exports an event if — and only if — the zone grants its purpose and
|
||||
/// the event is inside the zone's retention window at `now_ns`.
|
||||
/// Returns the event back on success so callers can hand it to a
|
||||
/// transport; on denial the event is dropped with a typed error.
|
||||
pub fn export(&self, event: BoundedEvent, now_ns: u64) -> Result<BoundedEvent> {
|
||||
self.engine.authorize(&event.zone_id, event.purpose)?;
|
||||
let zone = self
|
||||
.engine
|
||||
.zones
|
||||
.get(&event.zone_id)
|
||||
.expect("authorize verified the zone exists");
|
||||
let age_s = now_ns.saturating_sub(event.timestamp_ns) / 1_000_000_000;
|
||||
if age_s > zone.retention_s {
|
||||
return Err(UnifiedError::PolicyDenied(format!(
|
||||
"event age {age_s}s exceeds zone retention {}s",
|
||||
zone.retention_s
|
||||
)));
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn prov() -> Provenance {
|
||||
Provenance { device_id: "esp32s3-a1".into(), model_version: 3, synthetic: false }
|
||||
}
|
||||
|
||||
fn zone(purposes: &[SensingPurpose], identity: bool) -> PrivacyZone {
|
||||
PrivacyZone {
|
||||
id: "living-room".into(),
|
||||
allowed_purposes: purposes.iter().copied().collect(),
|
||||
retention_s: 3600,
|
||||
identity_explicitly_enabled: identity,
|
||||
}
|
||||
}
|
||||
|
||||
fn event(purpose: SensingPurpose, ts: u64) -> BoundedEvent {
|
||||
BoundedEvent::new(
|
||||
purpose,
|
||||
EventValue::Presence(true),
|
||||
0.12,
|
||||
prov(),
|
||||
3,
|
||||
ts,
|
||||
"living-room",
|
||||
)
|
||||
.expect("valid event")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_zone_denies() {
|
||||
let boundary = TrustBoundary::new(PolicyEngine::new());
|
||||
let err = boundary.export(event(SensingPurpose::Presence, 0), 0).unwrap_err();
|
||||
assert!(matches!(err, UnifiedError::PolicyDenied(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungranted_purpose_denies() {
|
||||
let mut engine = PolicyEngine::new();
|
||||
engine.upsert_zone(zone(&[SensingPurpose::Presence], false));
|
||||
let boundary = TrustBoundary::new(engine);
|
||||
assert!(boundary.export(event(SensingPurpose::Presence, 0), 0).is_ok());
|
||||
assert!(matches!(
|
||||
boundary.export(event(SensingPurpose::Localization, 0), 0),
|
||||
Err(UnifiedError::PolicyDenied(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_needs_both_grant_and_explicit_enable() {
|
||||
// Granted in purposes but NOT explicitly enabled ⇒ deny.
|
||||
let mut engine = PolicyEngine::new();
|
||||
engine.upsert_zone(zone(
|
||||
&[SensingPurpose::Presence, SensingPurpose::IdentityRecognition],
|
||||
false,
|
||||
));
|
||||
assert!(matches!(
|
||||
engine.authorize("living-room", SensingPurpose::IdentityRecognition),
|
||||
Err(UnifiedError::PolicyDenied(_))
|
||||
));
|
||||
// Both factors present ⇒ allow.
|
||||
engine.upsert_zone(zone(
|
||||
&[SensingPurpose::Presence, SensingPurpose::IdentityRecognition],
|
||||
true,
|
||||
));
|
||||
assert!(engine.authorize("living-room", SensingPurpose::IdentityRecognition).is_ok());
|
||||
// Explicit flag alone (purpose not granted) ⇒ still deny.
|
||||
engine.upsert_zone(zone(&[SensingPurpose::Presence], true));
|
||||
assert!(engine.authorize("living-room", SensingPurpose::IdentityRecognition).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_bound_is_enforced() {
|
||||
let mut engine = PolicyEngine::new();
|
||||
engine.upsert_zone(zone(&[SensingPurpose::Presence], false));
|
||||
let boundary = TrustBoundary::new(engine);
|
||||
let e = event(SensingPurpose::Presence, 0);
|
||||
// Within retention (1 h): fine.
|
||||
assert!(boundary.export(e.clone(), 3_500 * 1_000_000_000).is_ok());
|
||||
// Beyond retention: denied.
|
||||
assert!(matches!(
|
||||
boundary.export(e, 3_700 * 1_000_000_000),
|
||||
Err(UnifiedError::PolicyDenied(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accountability_fields_are_mandatory() {
|
||||
// Out-of-range uncertainty refuses construction.
|
||||
assert!(BoundedEvent::new(
|
||||
SensingPurpose::Presence,
|
||||
EventValue::Presence(true),
|
||||
1.5,
|
||||
prov(),
|
||||
3,
|
||||
0,
|
||||
"z"
|
||||
)
|
||||
.is_err());
|
||||
// Unassigned model version refuses construction.
|
||||
assert!(BoundedEvent::new(
|
||||
SensingPurpose::Presence,
|
||||
EventValue::Presence(true),
|
||||
0.1,
|
||||
prov(),
|
||||
0,
|
||||
0,
|
||||
"z"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Masked-reconstruction pretraining for the RF foundation encoder
|
||||
//! (ADR-274 §3.2), with an exact hand-derived backward pass.
|
||||
//!
|
||||
//! The correctness argument is not "the loss went down" alone: the analytic
|
||||
//! gradients of *every* parameter group are verified against central finite
|
||||
//! differences (`tests::gradients_match_finite_differences`), which pins the
|
||||
//! backward pass to the forward pass to ~1e-8 relative error. The training
|
||||
//! loop is then ordinary SGD.
|
||||
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::encoder::{ForwardCache, Linear, RfEncoder, WindowContext};
|
||||
use crate::math::seeded_rng;
|
||||
use crate::tokenizer::{position_encoding, RfToken, TokenizedWindow};
|
||||
|
||||
/// Gradient accumulator mirroring [`RfEncoder`]'s parameter groups.
|
||||
pub struct EncoderGrads {
|
||||
/// Token embedding grads.
|
||||
pub w1: Linear,
|
||||
/// Context mixing 1 grads.
|
||||
pub w2: Linear,
|
||||
/// Context mixing 2 grads.
|
||||
pub w2b: Linear,
|
||||
/// Age gate weight grads.
|
||||
pub age_w: Vec<f64>,
|
||||
/// Age gate bias grads.
|
||||
pub age_b: Vec<f64>,
|
||||
/// Geometry encoder grads.
|
||||
pub wg: Linear,
|
||||
/// Reconstruction head grads.
|
||||
pub w3: Linear,
|
||||
}
|
||||
|
||||
impl EncoderGrads {
|
||||
fn zeros(enc: &RfEncoder) -> Self {
|
||||
Self {
|
||||
w1: enc.w1.zeros_like(),
|
||||
w2: enc.w2.zeros_like(),
|
||||
w2b: enc.w2b.zeros_like(),
|
||||
age_w: vec![0.0; enc.age_w.len()],
|
||||
age_b: vec![0.0; enc.age_b.len()],
|
||||
wg: enc.wg.zeros_like(),
|
||||
w3: enc.w3.zeros_like(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mean-squared masked-reconstruction loss for one window under a fixed mask.
|
||||
#[must_use]
|
||||
pub fn masked_loss(enc: &RfEncoder, tokens: &[RfToken], masked: &[usize], ctx: WindowContext) -> f64 {
|
||||
let cache = enc.forward(tokens, masked, ctx);
|
||||
loss_from_cache(enc, &cache, tokens, masked)
|
||||
}
|
||||
|
||||
fn loss_from_cache(
|
||||
enc: &RfEncoder,
|
||||
cache: &ForwardCache,
|
||||
tokens: &[RfToken],
|
||||
masked: &[usize],
|
||||
) -> f64 {
|
||||
let d = enc.cfg.d_in;
|
||||
let mut loss = 0.0;
|
||||
for &j in masked {
|
||||
let xhat = enc.reconstruct(cache, j);
|
||||
for k in 0..d {
|
||||
loss += (xhat[k] - tokens[j].features[k]).powi(2);
|
||||
}
|
||||
}
|
||||
loss / (masked.len() as f64 * d as f64)
|
||||
}
|
||||
|
||||
/// Loss and analytic gradients for one window under a fixed mask.
|
||||
///
|
||||
/// Derivation (matching the forward pass in [`RfEncoder::forward`]):
|
||||
/// `∂L/∂x̂_j = 2(x̂_j − x_j)/(|M|·D)`; the reconstruction input is
|
||||
/// `u_j = [z ; pos(j)]`, so `∂L/∂z = Σ_j W3[:, :H]ᵀ ∂L/∂x̂_j`; the fusion
|
||||
/// `z = g⊙gate + Wg·geo + bg` splits the gradient into the tanh chain
|
||||
/// (`g → m → c → h_i → W1`) and the gate/geometry paths.
|
||||
#[must_use]
|
||||
pub fn masked_loss_and_grads(
|
||||
enc: &RfEncoder,
|
||||
tokens: &[RfToken],
|
||||
masked: &[usize],
|
||||
ctx: WindowContext,
|
||||
) -> (f64, EncoderGrads) {
|
||||
let h_dim = enc.cfg.d_model;
|
||||
let d = enc.cfg.d_in;
|
||||
let cache = enc.forward(tokens, masked, ctx);
|
||||
let mut grads = EncoderGrads::zeros(enc);
|
||||
|
||||
let norm = 1.0 / (masked.len() as f64 * d as f64);
|
||||
let mut dz = vec![0.0; h_dim];
|
||||
let mut loss = 0.0;
|
||||
for &j in masked {
|
||||
let mut u = cache.z.clone();
|
||||
u.extend_from_slice(&position_encoding(j)[..enc.cfg.d_pos]);
|
||||
let xhat = enc.w3.forward(&u);
|
||||
let mut dxhat = vec![0.0; d];
|
||||
for k in 0..d {
|
||||
let err = xhat[k] - tokens[j].features[k];
|
||||
loss += err * err;
|
||||
dxhat[k] = 2.0 * err * norm;
|
||||
}
|
||||
enc.w3.accumulate_grad(&mut grads.w3, &dxhat, &u);
|
||||
let du = enc.w3.backward_input(&dxhat);
|
||||
for k in 0..h_dim {
|
||||
dz[k] += du[k];
|
||||
}
|
||||
}
|
||||
loss *= norm;
|
||||
|
||||
// Fusion: z = g ⊙ gate + Wg·geo + bg. The gate input is the
|
||||
// log-scaled age feature, matching the forward pass.
|
||||
let age_feat = crate::encoder::age_feature(ctx.age_s);
|
||||
let mut dg = vec![0.0; h_dim];
|
||||
for k in 0..h_dim {
|
||||
let dgate = dz[k] * cache.g[k];
|
||||
dg[k] = dz[k] * cache.gate[k];
|
||||
let dsig = cache.gate[k] * (1.0 - cache.gate[k]);
|
||||
grads.age_w[k] += dgate * dsig * age_feat;
|
||||
grads.age_b[k] += dgate * dsig;
|
||||
}
|
||||
enc.wg.accumulate_grad(&mut grads.wg, &dz, &ctx.geometry);
|
||||
|
||||
// g = tanh(W2b·m + b2b).
|
||||
let dg_pre: Vec<f64> = (0..h_dim).map(|k| dg[k] * (1.0 - cache.g[k] * cache.g[k])).collect();
|
||||
enc.w2b.accumulate_grad(&mut grads.w2b, &dg_pre, &cache.m);
|
||||
let dm = enc.w2b.backward_input(&dg_pre);
|
||||
|
||||
// m = tanh(W2·c + b2).
|
||||
let dm_pre: Vec<f64> = (0..h_dim).map(|k| dm[k] * (1.0 - cache.m[k] * cache.m[k])).collect();
|
||||
enc.w2.accumulate_grad(&mut grads.w2, &dm_pre, &cache.c);
|
||||
let dc = enc.w2.backward_input(&dm_pre);
|
||||
|
||||
// c = mean of h_i; h_i = tanh(W1·x_i + b1).
|
||||
let inv_n = 1.0 / cache.unmasked.len() as f64;
|
||||
for (slot, &i) in cache.unmasked.iter().enumerate() {
|
||||
let hi = &cache.h[slot];
|
||||
let dh_pre: Vec<f64> =
|
||||
(0..h_dim).map(|k| dc[k] * inv_n * (1.0 - hi[k] * hi[k])).collect();
|
||||
enc.w1.accumulate_grad(&mut grads.w1, &dh_pre, &tokens[i].features);
|
||||
}
|
||||
|
||||
(loss, grads)
|
||||
}
|
||||
|
||||
/// Applies one SGD step.
|
||||
pub fn apply_grads(enc: &mut RfEncoder, grads: &EncoderGrads, lr: f64) {
|
||||
enc.w1.sgd(&grads.w1, lr);
|
||||
enc.w2.sgd(&grads.w2, lr);
|
||||
enc.w2b.sgd(&grads.w2b, lr);
|
||||
for (p, g) in enc.age_w.iter_mut().zip(&grads.age_w) {
|
||||
*p -= lr * g;
|
||||
}
|
||||
for (p, g) in enc.age_b.iter_mut().zip(&grads.age_b) {
|
||||
*p -= lr * g;
|
||||
}
|
||||
enc.wg.sgd(&grads.wg, lr);
|
||||
enc.w3.sgd(&grads.w3, lr);
|
||||
}
|
||||
|
||||
/// Pretraining hyper-parameters.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PretrainConfig {
|
||||
/// Fraction of tokens masked per window (≥1 token is always masked and
|
||||
/// ≥1 always kept).
|
||||
pub mask_fraction: f64,
|
||||
/// SGD learning rate.
|
||||
pub lr: f64,
|
||||
/// Epochs over the window set.
|
||||
pub epochs: usize,
|
||||
/// Mask-sampling seed (weight init is seeded separately at
|
||||
/// [`RfEncoder::new`]).
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl Default for PretrainConfig {
|
||||
fn default() -> Self {
|
||||
Self { mask_fraction: 0.25, lr: 0.05, epochs: 30, seed: 0x5EED }
|
||||
}
|
||||
}
|
||||
|
||||
/// What pretraining measured (reported honestly, not smoothed).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PretrainReport {
|
||||
/// Mean masked loss over the corpus before any update (fixed eval mask).
|
||||
pub initial_loss: f64,
|
||||
/// Mean masked loss after the final epoch (same fixed eval mask).
|
||||
pub final_loss: f64,
|
||||
/// Epochs run.
|
||||
pub epochs: usize,
|
||||
}
|
||||
|
||||
fn sample_mask(rng: &mut rand_chacha::ChaCha20Rng, n_tokens: usize, fraction: f64) -> Vec<usize> {
|
||||
// A window with fewer than 2 tokens has nothing left to reconstruct from
|
||||
// once one token is masked; return an empty mask rather than panicking
|
||||
// (`clamp(1, n_tokens - 1)` is invalid once `n_tokens - 1 < 1`).
|
||||
if n_tokens < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
let n_mask = ((n_tokens as f64 * fraction).round() as usize).clamp(1, n_tokens - 1);
|
||||
let mut idx: Vec<usize> = (0..n_tokens).collect();
|
||||
idx.shuffle(rng);
|
||||
idx.truncate(n_mask);
|
||||
idx.sort_unstable();
|
||||
idx
|
||||
}
|
||||
|
||||
/// Runs masked-reconstruction SGD over `windows`, mutating `enc` in place.
|
||||
pub fn pretrain(
|
||||
enc: &mut RfEncoder,
|
||||
windows: &[TokenizedWindow],
|
||||
cfg: &PretrainConfig,
|
||||
) -> PretrainReport {
|
||||
assert!(!windows.is_empty(), "pretrain needs at least one window");
|
||||
let mut rng = seeded_rng(cfg.seed);
|
||||
|
||||
// Fixed evaluation masks so initial/final losses are comparable.
|
||||
let eval_masks: Vec<Vec<usize>> = windows
|
||||
.iter()
|
||||
.map(|w| sample_mask(&mut rng, w.tokens.len(), cfg.mask_fraction))
|
||||
.collect();
|
||||
let eval = |e: &RfEncoder| {
|
||||
let mut total = 0.0;
|
||||
let mut n = 0usize;
|
||||
for (w, m) in windows.iter().zip(&eval_masks) {
|
||||
if m.is_empty() {
|
||||
continue;
|
||||
}
|
||||
total += masked_loss(e, &w.tokens, m, WindowContext::from(w));
|
||||
n += 1;
|
||||
}
|
||||
if n == 0 { 0.0 } else { total / n as f64 }
|
||||
};
|
||||
|
||||
let initial_loss = eval(enc);
|
||||
let mut order: Vec<usize> = (0..windows.len()).collect();
|
||||
for _ in 0..cfg.epochs {
|
||||
order.shuffle(&mut rng);
|
||||
for &wi in &order {
|
||||
let w = &windows[wi];
|
||||
if w.tokens.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let mask = sample_mask(&mut rng, w.tokens.len(), cfg.mask_fraction);
|
||||
let (_, grads) =
|
||||
masked_loss_and_grads(enc, &w.tokens, &mask, WindowContext::from(w));
|
||||
apply_grads(enc, &grads, cfg.lr);
|
||||
}
|
||||
}
|
||||
let final_loss = eval(enc);
|
||||
PretrainReport { initial_loss, final_loss, epochs: cfg.epochs }
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random corpus where tokens within a window share a
|
||||
/// latent factor — masked tokens are predictable from context, so a correct
|
||||
/// learner must beat the constant predictor. Used by tests and benches.
|
||||
#[must_use]
|
||||
pub fn correlated_toy_windows(n_windows: usize, tokens_per_window: usize, seed: u64) -> Vec<TokenizedWindow> {
|
||||
use crate::tokenizer::{RfToken, D_IN};
|
||||
let mut rng = seeded_rng(seed);
|
||||
(0..n_windows)
|
||||
.map(|_| {
|
||||
let latent: f64 = rng.gen_range(-1.0..1.0);
|
||||
let tokens = (0..tokens_per_window)
|
||||
.map(|k| {
|
||||
let mut f = [0.0f64; D_IN];
|
||||
for (d, v) in f.iter_mut().enumerate() {
|
||||
// Smooth deterministic function of (latent, token, dim)
|
||||
// plus small noise: reconstructable from context.
|
||||
*v = 0.6 * (latent * (1.0 + d as f64 / 8.0) + k as f64 * 0.3).sin()
|
||||
+ rng.gen_range(-0.05..0.05);
|
||||
}
|
||||
RfToken { features: f, link: 0, group: k }
|
||||
})
|
||||
.collect();
|
||||
TokenizedWindow {
|
||||
tokens,
|
||||
age_s: rng.gen_range(0.0..0.5),
|
||||
geometry: [0.1, 0.0, 0.2, 0.4, 0.0, 0.2],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::encoder::EncoderConfig;
|
||||
|
||||
/// Central finite differences over EVERY parameter group. This is the
|
||||
/// crate's proof that the backward pass matches the forward pass.
|
||||
#[test]
|
||||
fn gradients_match_finite_differences() {
|
||||
let cfg = EncoderConfig { d_in: 24, d_pos: 6, d_model: 7 };
|
||||
let mut enc = RfEncoder::new(cfg, 11);
|
||||
let windows = correlated_toy_windows(1, 5, 21);
|
||||
let tokens = &windows[0].tokens;
|
||||
let ctx = WindowContext { age_s: 0.3, geometry: [0.1, -0.2, 0.3, 0.0, 0.2, -0.1] };
|
||||
let masked = vec![1, 3];
|
||||
|
||||
let (_, grads) = masked_loss_and_grads(&enc, tokens, &masked, ctx);
|
||||
|
||||
let eps = 1e-6;
|
||||
let mut checked = 0usize;
|
||||
let mut max_rel = 0.0f64;
|
||||
|
||||
// Closure-free param walker: (getter, analytic grad) pairs by index.
|
||||
// Group 0: w1.w, 1: w1.b, 2: w2.w, 3: w2.b, 4: w2b.w, 5: w2b.b,
|
||||
// 6: age_w, 7: age_b, 8: wg.w, 9: wg.b, 10: w3.w, 11: w3.b.
|
||||
for group in 0..12 {
|
||||
let len = match group {
|
||||
0 => enc.w1.w.len(),
|
||||
1 => enc.w1.b.len(),
|
||||
2 => enc.w2.w.len(),
|
||||
3 => enc.w2.b.len(),
|
||||
4 => enc.w2b.w.len(),
|
||||
5 => enc.w2b.b.len(),
|
||||
6 => enc.age_w.len(),
|
||||
7 => enc.age_b.len(),
|
||||
8 => enc.wg.w.len(),
|
||||
9 => enc.wg.b.len(),
|
||||
10 => enc.w3.w.len(),
|
||||
_ => enc.w3.b.len(),
|
||||
};
|
||||
// Sample a spread of indices per group to keep the test fast
|
||||
// while touching every group.
|
||||
let stride = (len / 17).max(1);
|
||||
for idx in (0..len).step_by(stride) {
|
||||
fn param_at(e: &mut RfEncoder, group: usize, idx: usize) -> &mut f64 {
|
||||
match group {
|
||||
0 => &mut e.w1.w[idx],
|
||||
1 => &mut e.w1.b[idx],
|
||||
2 => &mut e.w2.w[idx],
|
||||
3 => &mut e.w2.b[idx],
|
||||
4 => &mut e.w2b.w[idx],
|
||||
5 => &mut e.w2b.b[idx],
|
||||
6 => &mut e.age_w[idx],
|
||||
7 => &mut e.age_b[idx],
|
||||
8 => &mut e.wg.w[idx],
|
||||
9 => &mut e.wg.b[idx],
|
||||
10 => &mut e.w3.w[idx],
|
||||
_ => &mut e.w3.b[idx],
|
||||
}
|
||||
}
|
||||
let analytic = match group {
|
||||
0 => grads.w1.w[idx],
|
||||
1 => grads.w1.b[idx],
|
||||
2 => grads.w2.w[idx],
|
||||
3 => grads.w2.b[idx],
|
||||
4 => grads.w2b.w[idx],
|
||||
5 => grads.w2b.b[idx],
|
||||
6 => grads.age_w[idx],
|
||||
7 => grads.age_b[idx],
|
||||
8 => grads.wg.w[idx],
|
||||
9 => grads.wg.b[idx],
|
||||
10 => grads.w3.w[idx],
|
||||
_ => grads.w3.b[idx],
|
||||
};
|
||||
|
||||
let orig = *param_at(&mut enc, group, idx);
|
||||
*param_at(&mut enc, group, idx) = orig + eps;
|
||||
let lp = masked_loss(&enc, tokens, &masked, ctx);
|
||||
*param_at(&mut enc, group, idx) = orig - eps;
|
||||
let lm = masked_loss(&enc, tokens, &masked, ctx);
|
||||
*param_at(&mut enc, group, idx) = orig;
|
||||
|
||||
let numeric = (lp - lm) / (2.0 * eps);
|
||||
let denom = analytic.abs().max(numeric.abs()).max(1e-8);
|
||||
let rel = (analytic - numeric).abs() / denom;
|
||||
// Accept either a tight relative match or an absolute
|
||||
// difference at the central-difference roundoff floor
|
||||
// (ε_machine·|L|/ε ≈ 5e-11) — tiny gradients hit the floor.
|
||||
assert!(
|
||||
rel < 1e-5 || (analytic - numeric).abs() < 1e-9,
|
||||
"group {group} idx {idx}: analytic {analytic:.3e} vs numeric {numeric:.3e} (rel {rel:.3e})"
|
||||
);
|
||||
max_rel = max_rel.max(rel);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked > 150, "gradient check must cover a real sample, got {checked}");
|
||||
println!("gradient check: {checked} params, max relative error {max_rel:.3e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pretraining_reduces_masked_loss_and_beats_mean_baseline() {
|
||||
let windows = correlated_toy_windows(40, 8, 99);
|
||||
let mut enc = RfEncoder::new(EncoderConfig { d_in: 24, d_pos: 8, d_model: 32 }, 7);
|
||||
let report = pretrain(
|
||||
&mut enc,
|
||||
&windows,
|
||||
&PretrainConfig { mask_fraction: 0.25, lr: 0.05, epochs: 40, seed: 123 },
|
||||
);
|
||||
assert!(
|
||||
report.final_loss < 0.5 * report.initial_loss,
|
||||
"loss must at least halve: {report:?}"
|
||||
);
|
||||
|
||||
// Constant (global-mean) predictor baseline on the same corpus: the
|
||||
// per-dim variance of token features. The encoder must beat it —
|
||||
// otherwise it learned nothing about context.
|
||||
let mut all: Vec<[f64; 24]> = Vec::new();
|
||||
for w in &windows {
|
||||
for t in &w.tokens {
|
||||
all.push(t.features);
|
||||
}
|
||||
}
|
||||
let n = all.len() as f64;
|
||||
let mut mean = [0.0f64; 24];
|
||||
for f in &all {
|
||||
for (m, v) in mean.iter_mut().zip(f) {
|
||||
*m += v / n;
|
||||
}
|
||||
}
|
||||
let mut var = 0.0;
|
||||
for f in &all {
|
||||
for (m, v) in mean.iter().zip(f) {
|
||||
var += (v - m).powi(2);
|
||||
}
|
||||
}
|
||||
var /= n * 24.0;
|
||||
assert!(
|
||||
report.final_loss < 0.8 * var,
|
||||
"must beat constant predictor: final {} vs baseline variance {}",
|
||||
report.final_loss,
|
||||
var
|
||||
);
|
||||
println!(
|
||||
"pretrain: initial {:.4} → final {:.4} (baseline variance {:.4})",
|
||||
report.initial_loss, report.final_loss, var
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn training_is_deterministic() {
|
||||
let windows = correlated_toy_windows(10, 6, 5);
|
||||
let cfg = PretrainConfig { mask_fraction: 0.3, lr: 0.05, epochs: 5, seed: 77 };
|
||||
let mut a = RfEncoder::new(EncoderConfig { d_in: 24, d_pos: 8, d_model: 16 }, 2);
|
||||
let mut b = RfEncoder::new(EncoderConfig { d_in: 24, d_pos: 8, d_model: 16 }, 2);
|
||||
let ra = pretrain(&mut a, &windows, &cfg);
|
||||
let rb = pretrain(&mut b, &windows, &cfg);
|
||||
assert_eq!(a.w1.w, b.w1.w);
|
||||
assert!((ra.final_loss - rb.final_loss).abs() < 1e-15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Domain-randomized synthetic dataset generator (ADR-276 §4).
|
||||
//!
|
||||
//! Randomizes *physics parameters*, not textures: room geometry, wall
|
||||
//! permittivity/conductivity, antenna placement, person kinematics and RCS,
|
||||
//! plus the hardware nuisances that break naive models in the field —
|
||||
//! chipset gain and phase offsets, carrier-frequency-offset drift, phase
|
||||
//! noise, packet loss (snapshot duplication), and interference bursts.
|
||||
//! Every window carries a full [`PartitionKey`] so the ADR-273 strict
|
||||
//! anti-leakage splits (held-out rooms / days / people / chipsets /
|
||||
//! firmware / layouts) are possible by construction.
|
||||
|
||||
use ndarray::Array3;
|
||||
use num_complex::Complex64;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::eval::PartitionKey;
|
||||
use crate::math::seeded_rng;
|
||||
use crate::synth::raytrace::synthesize_csi;
|
||||
use crate::synth::room::{Material, PersonSpec, RoomSpec};
|
||||
use crate::tensor::{
|
||||
CalibrationMeta, LinkGeometry, RfModality, RfTensor, CANONICAL_BINS, CANONICAL_SNAPSHOTS,
|
||||
};
|
||||
|
||||
/// Generator configuration.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SynthConfig {
|
||||
/// Master seed (same seed ⇒ byte-identical corpus).
|
||||
pub seed: u64,
|
||||
/// Number of distinct rooms.
|
||||
pub n_rooms: usize,
|
||||
/// Windows per room (half with a person, half empty, interleaved).
|
||||
pub windows_per_room: usize,
|
||||
/// Links (TX→RX pairs) per room.
|
||||
pub links: usize,
|
||||
/// Snapshot spacing in seconds.
|
||||
pub snapshot_dt_s: f64,
|
||||
}
|
||||
|
||||
impl Default for SynthConfig {
|
||||
fn default() -> Self {
|
||||
Self { seed: 0xC0FFEE, n_rooms: 8, windows_per_room: 24, links: 3, snapshot_dt_s: 0.05 }
|
||||
}
|
||||
}
|
||||
|
||||
/// One labeled synthetic window.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LabeledWindow {
|
||||
/// Canonical tensor (modality [`RfModality::Synthetic`]).
|
||||
pub tensor: RfTensor,
|
||||
/// Whether a person is present in the room during this window.
|
||||
pub presence: bool,
|
||||
/// Person position at the window's mid-time, when present.
|
||||
pub person_pos: Option<[f64; 3]>,
|
||||
/// Full provenance key for strict splits.
|
||||
pub key: PartitionKey,
|
||||
}
|
||||
|
||||
/// Per-room randomized nuisance profile (the "chipset").
|
||||
#[derive(Debug, Clone)]
|
||||
struct HardwareProfile {
|
||||
chipset: String,
|
||||
firmware: String,
|
||||
layout: String,
|
||||
gain: f64,
|
||||
phase_offset: f64,
|
||||
cfo_rad_per_snap: f64,
|
||||
noise_sigma: f64,
|
||||
}
|
||||
|
||||
/// The generator.
|
||||
pub struct SynthGenerator {
|
||||
cfg: SynthConfig,
|
||||
}
|
||||
|
||||
impl SynthGenerator {
|
||||
/// New generator.
|
||||
#[must_use]
|
||||
pub fn new(cfg: SynthConfig) -> Self {
|
||||
Self { cfg }
|
||||
}
|
||||
|
||||
/// 56 subcarrier frequencies over 20 MHz around 2.437 GHz.
|
||||
#[must_use]
|
||||
pub fn subcarrier_freqs() -> Vec<f64> {
|
||||
(0..CANONICAL_BINS)
|
||||
.map(|k| 2.437e9 - 10e6 + 20e6 * k as f64 / (CANONICAL_BINS - 1) as f64)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generates the full labeled corpus, deterministically from the seed.
|
||||
///
|
||||
/// # Panics
|
||||
/// Only on internal invariant violation (tensor construction from
|
||||
/// generated finite values cannot fail).
|
||||
#[must_use]
|
||||
pub fn generate(&self) -> Vec<LabeledWindow> {
|
||||
let mut rng = seeded_rng(self.cfg.seed);
|
||||
let freqs = Self::subcarrier_freqs();
|
||||
let mut out = Vec::with_capacity(self.cfg.n_rooms * self.cfg.windows_per_room);
|
||||
|
||||
for room_idx in 0..self.cfg.n_rooms {
|
||||
// --- Randomized physics for this room ---
|
||||
let size = [
|
||||
rng.gen_range(4.0..10.0),
|
||||
rng.gen_range(3.0..8.0),
|
||||
rng.gen_range(2.4..3.2),
|
||||
];
|
||||
let material = Material {
|
||||
rel_permittivity: rng.gen_range(2.0..7.0),
|
||||
conductivity_s_m: rng.gen_range(0.002..0.1),
|
||||
};
|
||||
let links: Vec<LinkGeometry> = (0..self.cfg.links)
|
||||
.map(|_| LinkGeometry {
|
||||
tx_pos: [
|
||||
rng.gen_range(0.3..size[0] - 0.3),
|
||||
rng.gen_range(0.3..size[1] - 0.3),
|
||||
rng.gen_range(1.0..2.0),
|
||||
],
|
||||
rx_pos: [
|
||||
rng.gen_range(0.3..size[0] - 0.3),
|
||||
rng.gen_range(0.3..size[1] - 0.3),
|
||||
rng.gen_range(1.0..2.0),
|
||||
],
|
||||
})
|
||||
.collect();
|
||||
let hw = HardwareProfile {
|
||||
chipset: format!("chip-{}", room_idx % 3),
|
||||
firmware: format!("fw-{}", room_idx % 2),
|
||||
layout: format!("layout-{}", (room_idx / 2) % 2),
|
||||
gain: rng.gen_range(0.5..2.0),
|
||||
phase_offset: rng.gen_range(-std::f64::consts::PI..std::f64::consts::PI),
|
||||
cfo_rad_per_snap: rng.gen_range(-0.3..0.3),
|
||||
noise_sigma: rng.gen_range(0.01..0.05),
|
||||
};
|
||||
let person_id = format!("p{}", room_idx % 4);
|
||||
// Person kinematics randomized per room; the person walks a
|
||||
// straight segment that stays inside the room for the corpus
|
||||
// duration (velocity kept small relative to room size).
|
||||
let person = PersonSpec {
|
||||
start: [
|
||||
rng.gen_range(size[0] * 0.25..size[0] * 0.75),
|
||||
rng.gen_range(size[1] * 0.25..size[1] * 0.75),
|
||||
rng.gen_range(1.0..1.6),
|
||||
],
|
||||
velocity: {
|
||||
let speed = rng.gen_range(0.3..1.0);
|
||||
let ang: f64 = rng.gen_range(0.0..std::f64::consts::TAU);
|
||||
[speed * ang.cos() * 0.2, speed * ang.sin() * 0.2, 0.0]
|
||||
},
|
||||
rcs_m2: rng.gen_range(0.3..0.8),
|
||||
};
|
||||
|
||||
let occupied = RoomSpec::new(size, material, vec![person]).expect("generated in range");
|
||||
let empty = RoomSpec::new(size, material, vec![]).expect("generated in range");
|
||||
|
||||
for w in 0..self.cfg.windows_per_room {
|
||||
let presence = w % 2 == 0;
|
||||
let room = if presence { &occupied } else { &empty };
|
||||
// Window start times cycle so the person oscillates within
|
||||
// the room instead of walking out of it.
|
||||
let t0 = (w % 6) as f64 * CANONICAL_SNAPSHOTS as f64 * self.cfg.snapshot_dt_s;
|
||||
|
||||
let mut data =
|
||||
Array3::zeros((self.cfg.links, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
for (l, link) in links.iter().enumerate() {
|
||||
let mut prev: Option<Vec<Complex64>> = None;
|
||||
for s in 0..CANONICAL_SNAPSHOTS {
|
||||
let t = t0 + s as f64 * self.cfg.snapshot_dt_s;
|
||||
// Packet loss: 5 % of snapshots re-deliver the
|
||||
// previous frame instead of a fresh capture.
|
||||
let lost = prev.is_some() && rng.gen_bool(0.05);
|
||||
let h: Vec<Complex64> = if lost {
|
||||
prev.clone().expect("guarded by prev.is_some()")
|
||||
} else {
|
||||
synthesize_csi(room, link.tx_pos, link.rx_pos, &freqs, t)
|
||||
};
|
||||
// Chipset gain + static phase + CFO drift.
|
||||
let rot = Complex64::from_polar(
|
||||
hw.gain,
|
||||
hw.phase_offset + hw.cfo_rad_per_snap * s as f64,
|
||||
);
|
||||
// Interference burst: 3 % of snapshots take a strong
|
||||
// wideband hit; otherwise thermal noise only.
|
||||
let burst = if rng.gen_bool(0.03) { 10.0 } else { 1.0 };
|
||||
for (b, hv) in h.iter().enumerate() {
|
||||
let noise = Complex64::new(
|
||||
rng.gen_range(-1.0..1.0) * hw.noise_sigma * burst * 1e-4,
|
||||
rng.gen_range(-1.0..1.0) * hw.noise_sigma * burst * 1e-4,
|
||||
);
|
||||
data[[l, b, s]] = hv * rot + noise;
|
||||
}
|
||||
prev = Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
let mid_t = t0 + 0.5 * CANONICAL_SNAPSHOTS as f64 * self.cfg.snapshot_dt_s;
|
||||
let tensor = RfTensor::new(
|
||||
RfModality::Synthetic,
|
||||
2.437e9,
|
||||
20e6,
|
||||
data,
|
||||
links.clone(),
|
||||
rng.gen_range(0.0..0.2),
|
||||
(room_idx as u64) << 32 | w as u64,
|
||||
format!("synth-{}", hw.chipset),
|
||||
rng.gen_range(0.6..0.95),
|
||||
(hw.noise_sigma / 0.05).clamp(0.0, 1.0) * 0.5,
|
||||
CalibrationMeta::default(),
|
||||
)
|
||||
.expect("generated tensor is finite and in range");
|
||||
|
||||
out.push(LabeledWindow {
|
||||
tensor,
|
||||
presence,
|
||||
person_pos: presence.then(|| occupied.people[0].position_at(mid_t)),
|
||||
key: PartitionKey {
|
||||
room: format!("room-{room_idx}"),
|
||||
day: format!("day-{}", w / (self.cfg.windows_per_room / 2).max(1)),
|
||||
person: if presence { person_id.clone() } else { "none".into() },
|
||||
chipset: hw.chipset.clone(),
|
||||
firmware: hw.firmware.clone(),
|
||||
layout: hw.layout.clone(),
|
||||
// Windows sharing a start-time slot within a room
|
||||
// form one capture session.
|
||||
session: format!("room-{room_idx}-s{}", w % 6),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn small_cfg(seed: u64) -> SynthConfig {
|
||||
SynthConfig { seed, n_rooms: 3, windows_per_room: 6, links: 2, snapshot_dt_s: 0.05 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_is_byte_deterministic_per_seed() {
|
||||
let a = SynthGenerator::new(small_cfg(7)).generate();
|
||||
let b = SynthGenerator::new(small_cfg(7)).generate();
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (x, y) in a.iter().zip(&b) {
|
||||
assert_eq!(x.presence, y.presence);
|
||||
assert_eq!(x.key, y.key);
|
||||
for (u, v) in x.tensor.data.iter().zip(y.tensor.data.iter()) {
|
||||
assert!(u == v, "same seed must give identical complex samples");
|
||||
}
|
||||
}
|
||||
// And a different seed gives a different corpus.
|
||||
let c = SynthGenerator::new(small_cfg(8)).generate();
|
||||
assert!(a
|
||||
.iter()
|
||||
.zip(&c)
|
||||
.any(|(x, y)| x.tensor.data.iter().zip(y.tensor.data.iter()).any(|(u, v)| u != v)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_windows_carry_more_temporal_energy() {
|
||||
let corpus = SynthGenerator::new(small_cfg(42)).generate();
|
||||
let temporal_energy = |w: &LabeledWindow| {
|
||||
// Mean per-bin variance across snapshots.
|
||||
let (links, bins, snaps) = w.tensor.dims();
|
||||
let mut acc = 0.0;
|
||||
for l in 0..links {
|
||||
for b in 0..bins {
|
||||
let vals: Vec<f64> =
|
||||
(0..snaps).map(|s| w.tensor.data[[l, b, s]].norm()).collect();
|
||||
let m = vals.iter().sum::<f64>() / snaps as f64;
|
||||
acc += vals.iter().map(|v| (v - m).powi(2)).sum::<f64>() / snaps as f64;
|
||||
}
|
||||
}
|
||||
acc / (links * bins) as f64
|
||||
};
|
||||
let present: Vec<f64> =
|
||||
corpus.iter().filter(|w| w.presence).map(temporal_energy).collect();
|
||||
let absent: Vec<f64> =
|
||||
corpus.iter().filter(|w| !w.presence).map(temporal_energy).collect();
|
||||
let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
|
||||
assert!(
|
||||
mean(&present) > 5.0 * mean(&absent),
|
||||
"a moving person must dominate temporal variance: present {} vs absent {}",
|
||||
mean(&present),
|
||||
mean(&absent)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_and_partition_keys_are_complete() {
|
||||
let corpus = SynthGenerator::new(small_cfg(1)).generate();
|
||||
assert_eq!(corpus.len(), 18);
|
||||
for w in &corpus {
|
||||
assert_eq!(w.tensor.modality, RfModality::Synthetic, "honest labeling");
|
||||
assert_eq!(w.presence, w.person_pos.is_some());
|
||||
assert!(!w.key.room.is_empty());
|
||||
assert!(!w.key.chipset.is_empty());
|
||||
if let Some(p) = w.person_pos {
|
||||
assert!(p.iter().all(|v| v.is_finite()));
|
||||
}
|
||||
}
|
||||
// Multiple rooms exist so strict room-holdout splits are possible.
|
||||
let rooms: std::collections::BTreeSet<&str> =
|
||||
corpus.iter().map(|w| w.key.room.as_str()).collect();
|
||||
assert_eq!(rooms.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Physics-guided synthetic RF world generator (ADR-276).
|
||||
//!
|
||||
//! Generates labeled CSI windows from first-principles multipath physics —
|
||||
//! shoebox rooms via the Allen–Berkley image method (reflection order ≤ 2),
|
||||
//! Fresnel wall materials with complex permittivity, moving people as
|
||||
//! bistatic point scatterers (Doppler emerges from path-length change, it is
|
||||
//! never injected), plus domain randomization of the *physics* parameters
|
||||
//! (materials, geometry, chipset gain/phase/noise, CFO, packet loss,
|
||||
//! interference) rather than cosmetic noise.
|
||||
//!
|
||||
//! Everything is seeded ChaCha20-deterministic, and every tensor produced
|
||||
//! here is stamped [`crate::tensor::RfModality::Synthetic`] — the honest
|
||||
//! label ADR-276 requires until results are validated on measured data.
|
||||
//!
|
||||
//! Physics anchors proven in tests:
|
||||
//! - direct path amplitude ≡ Friis (`raytrace::tests::direct_path_is_exact_friis`)
|
||||
//! - reciprocity `H(a→b) = H(b→a)`
|
||||
//! - first-order reflection delay ≡ mirror-image geometry
|
||||
//! - a walking person produces the analytically expected Doppler phase rate
|
||||
|
||||
pub mod generator;
|
||||
pub mod raytrace;
|
||||
pub mod room;
|
||||
|
||||
pub use generator::{LabeledWindow, SynthConfig, SynthGenerator};
|
||||
pub use raytrace::{enumerate_paths, synthesize_csi, PathContribution};
|
||||
pub use room::{Material, PersonSpec, RoomSpec};
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Image-method multipath ray tracer for shoebox rooms (ADR-276 §3).
|
||||
//!
|
||||
//! Allen–Berkley mirror images up to reflection order 2 plus single-bounce
|
||||
//! bistatic scattering off each person. The channel at frequency `f` is
|
||||
//!
|
||||
//! ```text
|
||||
//! H(f) = Σ_paths Γ_p · (c/f)/(4π) · s_p · e^{-j·2πf·d_p/c}
|
||||
//! ```
|
||||
//!
|
||||
//! with `s_p = 1/d` for wall paths and `s_p = √(σ/4π)/(d₁·d₂)` for person
|
||||
//! scattering (bistatic radar equation, amplitude form). Doppler is never
|
||||
//! injected: it emerges from the person's path length changing between
|
||||
//! snapshots.
|
||||
|
||||
use num_complex::Complex64;
|
||||
|
||||
use super::room::RoomSpec;
|
||||
|
||||
const C: f64 = 299_792_458.0;
|
||||
|
||||
/// One propagation path.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PathContribution {
|
||||
/// Total geometric path length (metres) — sets delay and phase.
|
||||
pub distance_m: f64,
|
||||
/// Amplitude scale multiplying `λ/(4π)`: `1/d` for wall paths,
|
||||
/// `√(σ/4π)/(d₁·d₂)` for scatterers.
|
||||
pub amp_scale: f64,
|
||||
/// Product of complex reflection coefficients along the path
|
||||
/// (`Γ^order`, evaluated at the carrier).
|
||||
pub reflection: Complex64,
|
||||
/// Number of wall bounces (0 = direct or scatterer path).
|
||||
pub order: usize,
|
||||
}
|
||||
|
||||
/// Enumerates all wall-image paths of order ≤ `max_order` plus person
|
||||
/// scattering paths, for the room state at time `t` seconds.
|
||||
#[must_use]
|
||||
pub fn enumerate_paths(
|
||||
room: &RoomSpec,
|
||||
tx: [f64; 3],
|
||||
rx: [f64; 3],
|
||||
carrier_hz: f64,
|
||||
t: f64,
|
||||
max_order: usize,
|
||||
) -> Vec<PathContribution> {
|
||||
let gamma = room.wall_material.reflection_coefficient(carrier_hz);
|
||||
let mut paths = Vec::new();
|
||||
|
||||
// Allen–Berkley images: per axis, sign ∈ {+, −} and lattice shift
|
||||
// n ∈ {−1, 0, 1}; bounce count is |2n| for +, |2n−1| for −.
|
||||
for sx in [1i64, -1] {
|
||||
for nx in -1i64..=1 {
|
||||
let bx = if sx == 1 { (2 * nx).unsigned_abs() } else { (2 * nx - 1).unsigned_abs() };
|
||||
if bx as usize > max_order {
|
||||
continue;
|
||||
}
|
||||
for sy in [1i64, -1] {
|
||||
for ny in -1i64..=1 {
|
||||
let by =
|
||||
if sy == 1 { (2 * ny).unsigned_abs() } else { (2 * ny - 1).unsigned_abs() };
|
||||
if (bx + by) as usize > max_order {
|
||||
continue;
|
||||
}
|
||||
for sz in [1i64, -1] {
|
||||
for nz in -1i64..=1 {
|
||||
let bz = if sz == 1 {
|
||||
(2 * nz).unsigned_abs()
|
||||
} else {
|
||||
(2 * nz - 1).unsigned_abs()
|
||||
};
|
||||
let order = (bx + by + bz) as usize;
|
||||
if order > max_order {
|
||||
continue;
|
||||
}
|
||||
let img = [
|
||||
sx as f64 * tx[0] + 2.0 * nx as f64 * room.size[0],
|
||||
sy as f64 * tx[1] + 2.0 * ny as f64 * room.size[1],
|
||||
sz as f64 * tx[2] + 2.0 * nz as f64 * room.size[2],
|
||||
];
|
||||
let d = ((img[0] - rx[0]).powi(2)
|
||||
+ (img[1] - rx[1]).powi(2)
|
||||
+ (img[2] - rx[2]).powi(2))
|
||||
.sqrt();
|
||||
if d < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let reflection = if order == 0 {
|
||||
Complex64::new(1.0, 0.0)
|
||||
} else {
|
||||
gamma.powu(order as u32)
|
||||
};
|
||||
// Reflections with |Γ|=0 contribute nothing.
|
||||
if order > 0 && reflection.norm() < 1e-15 {
|
||||
continue;
|
||||
}
|
||||
paths.push(PathContribution {
|
||||
distance_m: d,
|
||||
amp_scale: 1.0 / d,
|
||||
reflection,
|
||||
order,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Person scatterers (single bounce TX → person → RX).
|
||||
for person in &room.people {
|
||||
let p = person.position_at(t);
|
||||
let d1 = ((p[0] - tx[0]).powi(2) + (p[1] - tx[1]).powi(2) + (p[2] - tx[2]).powi(2)).sqrt();
|
||||
let d2 = ((p[0] - rx[0]).powi(2) + (p[1] - rx[1]).powi(2) + (p[2] - rx[2]).powi(2)).sqrt();
|
||||
if d1 < 1e-9 || d2 < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
paths.push(PathContribution {
|
||||
distance_m: d1 + d2,
|
||||
amp_scale: (person.rcs_m2 / (4.0 * std::f64::consts::PI)).sqrt() / (d1 * d2),
|
||||
reflection: Complex64::new(1.0, 0.0),
|
||||
order: 0,
|
||||
});
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
/// Synthesizes the channel frequency response at each `freqs_hz` for the
|
||||
/// room state at time `t`.
|
||||
#[must_use]
|
||||
pub fn synthesize_csi(
|
||||
room: &RoomSpec,
|
||||
tx: [f64; 3],
|
||||
rx: [f64; 3],
|
||||
freqs_hz: &[f64],
|
||||
t: f64,
|
||||
) -> Vec<Complex64> {
|
||||
let carrier = freqs_hz[freqs_hz.len() / 2];
|
||||
let paths = enumerate_paths(room, tx, rx, carrier, t, 2);
|
||||
freqs_hz
|
||||
.iter()
|
||||
.map(|&f| {
|
||||
let mut h = Complex64::new(0.0, 0.0);
|
||||
for p in &paths {
|
||||
let amp = (C / f) / (4.0 * std::f64::consts::PI) * p.amp_scale;
|
||||
let phase = -2.0 * std::f64::consts::PI * f * p.distance_m / C;
|
||||
h += p.reflection * Complex64::from_polar(amp, phase);
|
||||
}
|
||||
h
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::synth::room::{Material, PersonSpec};
|
||||
|
||||
fn freqs() -> Vec<f64> {
|
||||
// 56 subcarriers over 20 MHz around 2.437 GHz.
|
||||
(0..56).map(|k| 2.437e9 - 10e6 + 20e6 * k as f64 / 55.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_path_is_exact_friis() {
|
||||
// Absorber walls ⇒ only the direct path survives.
|
||||
let room = RoomSpec::new([8.0, 6.0, 3.0], Material::absorber(), vec![]).unwrap();
|
||||
let tx = [1.0, 1.0, 1.5];
|
||||
let rx = [5.0, 4.0, 1.5]; // d = 5
|
||||
let freqs = freqs();
|
||||
let h = synthesize_csi(&room, tx, rx, &freqs, 0.0);
|
||||
for (f, hv) in freqs.iter().zip(&h) {
|
||||
let expected = (C / f) / (4.0 * std::f64::consts::PI * 5.0);
|
||||
assert!(
|
||||
(hv.norm() - expected).abs() < 1e-15,
|
||||
"at {f} Hz: |H| = {} vs Friis {expected}",
|
||||
hv.norm()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_is_reciprocal() {
|
||||
let people = vec![PersonSpec { start: [3.0, 2.0, 1.2], velocity: [0.4, 0.1, 0.0], rcs_m2: 0.5 }];
|
||||
let room = RoomSpec::new([8.0, 6.0, 3.0], Material::concrete(), people).unwrap();
|
||||
let a = [1.0, 1.0, 1.5];
|
||||
let b = [6.5, 4.2, 1.1];
|
||||
let freqs = freqs();
|
||||
let fwd = synthesize_csi(&room, a, b, &freqs, 0.35);
|
||||
let rev = synthesize_csi(&room, b, a, &freqs, 0.35);
|
||||
for (x, y) in fwd.iter().zip(&rev) {
|
||||
assert!((x - y).norm() < 1e-12, "H(a→b) must equal H(b→a): {x} vs {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_order_reflection_matches_mirror_geometry() {
|
||||
let room = RoomSpec::new([8.0, 6.0, 3.0], Material::concrete(), vec![]).unwrap();
|
||||
let tx = [2.0, 3.0, 1.5];
|
||||
let rx = [6.0, 3.0, 1.5];
|
||||
let paths = enumerate_paths(&room, tx, rx, 2.437e9, 0.0, 2);
|
||||
|
||||
// Floor bounce (z = 0): mirror TX to z = −1.5; d = √(4² + 3²) = 5.
|
||||
let floor = ((tx[0] - rx[0]).powi(2)
|
||||
+ (tx[1] - rx[1]).powi(2)
|
||||
+ (-tx[2] - rx[2]).powi(2))
|
||||
.sqrt();
|
||||
assert!((floor - 5.0).abs() < 1e-12, "test geometry sanity");
|
||||
assert!(
|
||||
paths.iter().any(|p| p.order == 1 && (p.distance_m - 5.0).abs() < 1e-12),
|
||||
"floor-bounce image path at exactly 5 m must exist"
|
||||
);
|
||||
|
||||
// Ceiling bounce (z = 3): mirror TX to z = 4.5; d = √(16 + 9) = 5.
|
||||
assert!(
|
||||
paths
|
||||
.iter()
|
||||
.any(|p| p.order == 1 && (p.distance_m - 5.0).abs() < 1e-12
|
||||
&& p.distance_m > 0.0),
|
||||
"ceiling-bounce path must exist"
|
||||
);
|
||||
|
||||
// Path count sanity: direct + 6 first-order + second-order set, all
|
||||
// with |Γ| > 0 for concrete.
|
||||
assert!(paths.iter().filter(|p| p.order == 1).count() == 6, "6 first-order walls");
|
||||
assert!(paths.iter().any(|p| p.order == 2));
|
||||
assert_eq!(paths.iter().filter(|p| p.order == 0).count(), 1, "one direct path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_person_produces_the_analytic_doppler_phase_rate() {
|
||||
// Person walking radially outward along the TX–RX bisector normal;
|
||||
// compare the residual (person-only) phase rotation between
|
||||
// snapshots against −2πf·Δd/c.
|
||||
let person = PersonSpec { start: [4.0, 2.0, 1.2], velocity: [0.0, 0.8, 0.0], rcs_m2: 0.6 };
|
||||
let with_person =
|
||||
RoomSpec::new([8.0, 6.0, 3.0], Material::drywall(), vec![person]).unwrap();
|
||||
let empty = RoomSpec::new([8.0, 6.0, 3.0], Material::drywall(), vec![]).unwrap();
|
||||
let tx = [1.0, 2.0, 1.5];
|
||||
let rx = [7.0, 2.0, 1.5];
|
||||
let f = [2.437e9];
|
||||
let dt = 0.05;
|
||||
|
||||
for step in 0..4 {
|
||||
let t0 = step as f64 * dt;
|
||||
let t1 = t0 + dt;
|
||||
let resid0 = synthesize_csi(&with_person, tx, rx, &f, t0)[0]
|
||||
- synthesize_csi(&empty, tx, rx, &f, t0)[0];
|
||||
let resid1 = synthesize_csi(&with_person, tx, rx, &f, t1)[0]
|
||||
- synthesize_csi(&empty, tx, rx, &f, t1)[0];
|
||||
let measured_dphi = (resid1 * resid0.conj()).arg();
|
||||
|
||||
let path_len = |t: f64| {
|
||||
let p = person.position_at(t);
|
||||
let d1 = ((p[0] - tx[0]).powi(2) + (p[1] - tx[1]).powi(2) + (p[2] - tx[2]).powi(2))
|
||||
.sqrt();
|
||||
let d2 = ((p[0] - rx[0]).powi(2) + (p[1] - rx[1]).powi(2) + (p[2] - rx[2]).powi(2))
|
||||
.sqrt();
|
||||
d1 + d2
|
||||
};
|
||||
let expected_dphi = -2.0 * std::f64::consts::PI * f[0] * (path_len(t1) - path_len(t0)) / C;
|
||||
// Compare on the unit circle (phases are mod 2π).
|
||||
let diff = (Complex64::from_polar(1.0, measured_dphi)
|
||||
* Complex64::from_polar(1.0, -expected_dphi))
|
||||
.arg();
|
||||
assert!(
|
||||
diff.abs() < 1e-6,
|
||||
"step {step}: measured Δφ {measured_dphi} vs analytic {expected_dphi}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Room, material, and person specifications for the synthetic world
|
||||
//! (ADR-276 §2).
|
||||
|
||||
use num_complex::Complex64;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Result, UnifiedError};
|
||||
|
||||
/// Vacuum permittivity (F/m).
|
||||
const EPS0: f64 = 8.854_187_812_8e-12;
|
||||
|
||||
/// Wall material with complex permittivity — the quantity domain
|
||||
/// randomization varies (randomize physics, not textures).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Material {
|
||||
/// Relative permittivity ε_r (> 1 for solids).
|
||||
pub rel_permittivity: f64,
|
||||
/// Conductivity σ in S/m (loss term).
|
||||
pub conductivity_s_m: f64,
|
||||
}
|
||||
|
||||
impl Material {
|
||||
/// Typical poured concrete (ITU-R P.2040 ballpark).
|
||||
#[must_use]
|
||||
pub fn concrete() -> Self {
|
||||
Self { rel_permittivity: 5.3, conductivity_s_m: 0.073 }
|
||||
}
|
||||
|
||||
/// Gypsum drywall.
|
||||
#[must_use]
|
||||
pub fn drywall() -> Self {
|
||||
Self { rel_permittivity: 2.9, conductivity_s_m: 0.016 }
|
||||
}
|
||||
|
||||
/// Window glass.
|
||||
#[must_use]
|
||||
pub fn glass() -> Self {
|
||||
Self { rel_permittivity: 6.3, conductivity_s_m: 0.004 }
|
||||
}
|
||||
|
||||
/// Perfect absorber (anechoic) — kills all reflections; used by tests to
|
||||
/// isolate the direct path.
|
||||
#[must_use]
|
||||
pub fn absorber() -> Self {
|
||||
Self { rel_permittivity: 1.0, conductivity_s_m: 0.0 }
|
||||
}
|
||||
|
||||
/// Complex relative permittivity at frequency `f`:
|
||||
/// `ε = ε_r − j·σ/(ω·ε₀)`.
|
||||
#[must_use]
|
||||
pub fn complex_permittivity(&self, freq_hz: f64) -> Complex64 {
|
||||
let omega = 2.0 * std::f64::consts::PI * freq_hz;
|
||||
Complex64::new(self.rel_permittivity, -self.conductivity_s_m / (omega * EPS0))
|
||||
}
|
||||
|
||||
/// Normal-incidence Fresnel amplitude reflection coefficient
|
||||
/// `Γ = (1 − √ε)/(1 + √ε)` against air. (Normal incidence is the
|
||||
/// standard image-method simplification; angle dependence is a
|
||||
/// randomizable refinement, not a correctness requirement.)
|
||||
#[must_use]
|
||||
pub fn reflection_coefficient(&self, freq_hz: f64) -> Complex64 {
|
||||
let sqrt_eps = self.complex_permittivity(freq_hz).sqrt();
|
||||
(Complex64::new(1.0, 0.0) - sqrt_eps) / (Complex64::new(1.0, 0.0) + sqrt_eps)
|
||||
}
|
||||
}
|
||||
|
||||
/// A person modeled as a moving bistatic point scatterer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PersonSpec {
|
||||
/// Position at t = 0, metres.
|
||||
pub start: [f64; 3],
|
||||
/// Constant velocity, m/s.
|
||||
pub velocity: [f64; 3],
|
||||
/// Radar cross-section, m² (torso ≈ 0.3–1.0 at WiFi bands).
|
||||
pub rcs_m2: f64,
|
||||
}
|
||||
|
||||
impl PersonSpec {
|
||||
/// Position at time `t` seconds.
|
||||
#[must_use]
|
||||
pub fn position_at(&self, t: f64) -> [f64; 3] {
|
||||
[
|
||||
self.start[0] + self.velocity[0] * t,
|
||||
self.start[1] + self.velocity[1] * t,
|
||||
self.start[2] + self.velocity[2] * t,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// A shoebox room: `[0, Lx] × [0, Ly] × [0, Lz]` with one wall material and
|
||||
/// zero or more people.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RoomSpec {
|
||||
/// Interior dimensions `[Lx, Ly, Lz]`, metres.
|
||||
pub size: [f64; 3],
|
||||
/// Wall/floor/ceiling material.
|
||||
pub wall_material: Material,
|
||||
/// Occupants.
|
||||
pub people: Vec<PersonSpec>,
|
||||
}
|
||||
|
||||
impl RoomSpec {
|
||||
/// Validated constructor: positive dimensions, finite fields, people
|
||||
/// starting inside the room.
|
||||
pub fn new(size: [f64; 3], wall_material: Material, people: Vec<PersonSpec>) -> Result<Self> {
|
||||
if size.iter().any(|s| !s.is_finite() || *s <= 0.0) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"room dimensions must be finite and positive, got {size:?}"
|
||||
)));
|
||||
}
|
||||
for p in &people {
|
||||
for (axis, v) in p.start.iter().enumerate() {
|
||||
if !v.is_finite() || *v < 0.0 || *v > size[axis] {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"person start {:?} outside room {size:?}",
|
||||
p.start
|
||||
)));
|
||||
}
|
||||
}
|
||||
if p.rcs_m2 <= 0.0 || !p.rcs_m2.is_finite() {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"rcs_m2 must be positive, got {}",
|
||||
p.rcs_m2
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(Self { size, wall_material, people })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fresnel_coefficient_sane_for_concrete() {
|
||||
let g = Material::concrete().reflection_coefficient(2.437e9);
|
||||
// Concrete at 2.4 GHz: |Γ| ≈ 0.39–0.45, negative real part
|
||||
// (denser medium ⇒ phase inversion).
|
||||
assert!(g.norm() > 0.3 && g.norm() < 0.5, "|Γ| = {}", g.norm());
|
||||
assert!(g.re < 0.0);
|
||||
// Physical bound: |Γ| < 1 for any passive material.
|
||||
for m in [Material::drywall(), Material::glass(), Material::concrete()] {
|
||||
assert!(m.reflection_coefficient(5.18e9).norm() < 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absorber_reflects_nothing() {
|
||||
let g = Material::absorber().reflection_coefficient(2.437e9);
|
||||
assert!(g.norm() < 1e-12, "ε_r = 1, σ = 0 must give Γ = 0, got {g}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_validation_rejects_bad_specs() {
|
||||
assert!(RoomSpec::new([4.0, -3.0, 2.5], Material::drywall(), vec![]).is_err());
|
||||
let outside = PersonSpec { start: [9.0, 1.0, 1.0], velocity: [0.0; 3], rcs_m2: 0.5 };
|
||||
assert!(RoomSpec::new([4.0, 3.0, 2.5], Material::drywall(), vec![outside]).is_err());
|
||||
let ok = PersonSpec { start: [2.0, 1.0, 1.0], velocity: [0.5, 0.0, 0.0], rcs_m2: 0.5 };
|
||||
let room = RoomSpec::new([4.0, 3.0, 2.5], Material::drywall(), vec![ok]).expect("valid");
|
||||
assert_eq!(room.people.len(), 1);
|
||||
assert_eq!(room.people[0].position_at(2.0), [3.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
//! Canonical RF tensor — the single normalization target of every hardware
|
||||
//! adapter (ADR-274 §2).
|
||||
//!
|
||||
//! Every modality (WiFi CSI, cellular SRS, FMCW radar range profiles, UWB
|
||||
//! CIR) is normalized into the same `(links × bins × snapshots)` complex
|
||||
//! tensor plus calibration metadata. Downstream code (tokenizer, encoder,
|
||||
//! Gaussian memory) never sees vendor formats.
|
||||
|
||||
use ndarray::Array3;
|
||||
use num_complex::Complex64;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Result, UnifiedError};
|
||||
|
||||
/// Canonical number of frequency/delay bins after adapter resampling.
|
||||
///
|
||||
/// 56 matches the usable-subcarrier count of 20 MHz 802.11n CSI after guard
|
||||
/// removal (and the 114→56 interpolation already used by
|
||||
/// `wifi-densepose-train::subcarrier`), so the most common source needs no
|
||||
/// resampling at all.
|
||||
pub const CANONICAL_BINS: usize = 56;
|
||||
|
||||
/// Canonical number of temporal snapshots per tensor window.
|
||||
pub const CANONICAL_SNAPSHOTS: usize = 8;
|
||||
|
||||
/// RF sensing modality of a capture or tensor.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum RfModality {
|
||||
/// 802.11 Channel State Information (per-subcarrier frequency response).
|
||||
WifiCsi,
|
||||
/// 802.11 channel impulse response (delay-domain taps).
|
||||
WifiCir,
|
||||
/// 802.11 beamforming feedback report (BFI/BFLD path).
|
||||
WifiBfReport,
|
||||
/// 5G NR uplink Sounding Reference Signal frequency response (O-RAN ISAC path).
|
||||
CellularSrs,
|
||||
/// FMCW radar range profile (post range-FFT complex bins).
|
||||
FmcwRadar,
|
||||
/// FMCW radar range–azimuth map.
|
||||
FmcwRangeAzimuth,
|
||||
/// FMCW radar Doppler–azimuth map.
|
||||
FmcwDopplerAzimuth,
|
||||
/// Ultra-wideband channel impulse response taps.
|
||||
UwbCir,
|
||||
/// Bluetooth Channel Sounding tones (phase-based ranging + RTT).
|
||||
BleCs,
|
||||
/// Output of the ADR-276 synthetic world generator (honest labeling:
|
||||
/// tensors of this modality must never be reported as measured).
|
||||
Synthetic,
|
||||
}
|
||||
|
||||
/// Transmitter/receiver placement for one link, metres, room frame.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LinkGeometry {
|
||||
/// Transmit antenna position `[x, y, z]` in metres.
|
||||
pub tx_pos: [f64; 3],
|
||||
/// Receive antenna position `[x, y, z]` in metres.
|
||||
pub rx_pos: [f64; 3],
|
||||
}
|
||||
|
||||
impl LinkGeometry {
|
||||
/// Euclidean TX→RX distance in metres.
|
||||
#[must_use]
|
||||
pub fn distance_m(&self) -> f64 {
|
||||
let d: f64 = (0..3).map(|i| (self.tx_pos[i] - self.rx_pos[i]).powi(2)).sum();
|
||||
d.sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
/// Calibration contract carried alongside every canonical tensor (ADR-274 §2.2).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CalibrationMeta {
|
||||
/// Oscillator quality in parts-per-million drift (lower is better).
|
||||
pub clock_ppm: f64,
|
||||
/// Whether per-link phase offsets have been calibrated out upstream.
|
||||
pub phase_calibrated: bool,
|
||||
/// Gain offset (dB) applied during normalization, for provenance.
|
||||
pub gain_offset_db: f64,
|
||||
/// Identifier of the empty-room baseline applied, if any (ADR-135).
|
||||
pub baseline_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CalibrationMeta {
|
||||
fn default() -> Self {
|
||||
Self { clock_ppm: 20.0, phase_calibrated: false, gain_offset_db: 0.0, baseline_id: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical complex RF tensor: `(links, bins, snapshots)` plus the
|
||||
/// metadata every downstream consumer needs (freshness, geometry, clock
|
||||
/// quality, uncertainty, provenance).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RfTensor {
|
||||
/// Source modality.
|
||||
pub modality: RfModality,
|
||||
/// Carrier centre frequency in Hz.
|
||||
pub center_freq_hz: f64,
|
||||
/// Occupied bandwidth in Hz (span of the bin axis).
|
||||
pub bandwidth_hz: f64,
|
||||
/// Complex samples, shape `(links, bins, snapshots)`.
|
||||
pub data: Array3<Complex64>,
|
||||
/// Per-link antenna geometry; `links.len() == data.dim().0`.
|
||||
pub links: Vec<LinkGeometry>,
|
||||
/// Age of the *oldest* snapshot in seconds at tensor construction time
|
||||
/// (the freshness signal the encoder fuses multiplicatively, ADR-274 §3).
|
||||
pub sample_age_s: f64,
|
||||
/// Capture timestamp, nanoseconds since epoch.
|
||||
pub timestamp_ns: u64,
|
||||
/// Source device identifier (chipset/firmware provenance key).
|
||||
pub device_id: String,
|
||||
/// Clock quality in `[0, 1]` (1 = disciplined reference, 0 = free-running).
|
||||
pub clock_quality: f64,
|
||||
/// Front-end uncertainty proxy in `[0, 1]` (0 = clean, 1 = at noise floor).
|
||||
pub uncertainty: f64,
|
||||
/// Calibration contract.
|
||||
pub calibration: CalibrationMeta,
|
||||
}
|
||||
|
||||
impl RfTensor {
|
||||
/// Validated constructor — the only way to build an `RfTensor`.
|
||||
///
|
||||
/// Boundary rules enforced here (so downstream modules may assume them):
|
||||
/// non-empty dims, geometry length matches the link axis, all samples
|
||||
/// finite, frequencies positive, `clock_quality`/`uncertainty` ∈ [0, 1],
|
||||
/// `sample_age_s` finite and non-negative.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
modality: RfModality,
|
||||
center_freq_hz: f64,
|
||||
bandwidth_hz: f64,
|
||||
data: Array3<Complex64>,
|
||||
links: Vec<LinkGeometry>,
|
||||
sample_age_s: f64,
|
||||
timestamp_ns: u64,
|
||||
device_id: String,
|
||||
clock_quality: f64,
|
||||
uncertainty: f64,
|
||||
calibration: CalibrationMeta,
|
||||
) -> Result<Self> {
|
||||
let (n_links, n_bins, n_snaps) = data.dim();
|
||||
if n_links == 0 || n_bins == 0 || n_snaps == 0 {
|
||||
return Err(UnifiedError::ShapeMismatch(format!(
|
||||
"empty tensor axis: ({n_links}, {n_bins}, {n_snaps})"
|
||||
)));
|
||||
}
|
||||
if links.len() != n_links {
|
||||
return Err(UnifiedError::ShapeMismatch(format!(
|
||||
"geometry describes {} links, data has {n_links}",
|
||||
links.len()
|
||||
)));
|
||||
}
|
||||
if !(center_freq_hz.is_finite() && center_freq_hz > 0.0) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"center_freq_hz must be finite and positive, got {center_freq_hz}"
|
||||
)));
|
||||
}
|
||||
if !(bandwidth_hz.is_finite() && bandwidth_hz > 0.0) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"bandwidth_hz must be finite and positive, got {bandwidth_hz}"
|
||||
)));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&clock_quality) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"clock_quality must be in [0,1], got {clock_quality}"
|
||||
)));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&uncertainty) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"uncertainty must be in [0,1], got {uncertainty}"
|
||||
)));
|
||||
}
|
||||
if !(sample_age_s.is_finite() && sample_age_s >= 0.0) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"sample_age_s must be finite and >= 0, got {sample_age_s}"
|
||||
)));
|
||||
}
|
||||
for link in &links {
|
||||
for c in link.tx_pos.iter().chain(link.rx_pos.iter()) {
|
||||
if !c.is_finite() {
|
||||
return Err(UnifiedError::InvalidInput(
|
||||
"non-finite antenna coordinate".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.iter().any(|z| !z.re.is_finite() || !z.im.is_finite()) {
|
||||
return Err(UnifiedError::InvalidInput("non-finite complex sample".into()));
|
||||
}
|
||||
Ok(Self {
|
||||
modality,
|
||||
center_freq_hz,
|
||||
bandwidth_hz,
|
||||
data,
|
||||
links,
|
||||
sample_age_s,
|
||||
timestamp_ns,
|
||||
device_id,
|
||||
clock_quality,
|
||||
uncertainty,
|
||||
calibration,
|
||||
})
|
||||
}
|
||||
|
||||
/// `(links, bins, snapshots)`.
|
||||
#[must_use]
|
||||
pub fn dims(&self) -> (usize, usize, usize) {
|
||||
self.data.dim()
|
||||
}
|
||||
|
||||
/// Carrier wavelength in metres.
|
||||
#[must_use]
|
||||
pub fn wavelength_m(&self) -> f64 {
|
||||
299_792_458.0 / self.center_freq_hz
|
||||
}
|
||||
|
||||
/// Delay–Doppler magnitude map for one link (ADR-281 §3): IDFT over the
|
||||
/// frequency axis (→ delay bins) crossed with a DFT over the snapshot
|
||||
/// axis (→ Doppler bins). Shape `(bins, snapshots)`.
|
||||
///
|
||||
/// Delay-Doppler-native modalities (OTFS ISAC, radar) must not be
|
||||
/// collapsed into scalar motion energy before storage — this transform
|
||||
/// keeps the native representation queryable locally.
|
||||
///
|
||||
/// Implemented **separably** — delay IDFT per snapshot, then Doppler
|
||||
/// DFT per delay row: `O(B²S + S²B)` instead of the direct form's
|
||||
/// `O(B²S²)` (equivalence proven in tests, speedup measured in
|
||||
/// `benches/unified_bench.rs`).
|
||||
pub fn delay_doppler_map(&self, link: usize) -> crate::Result<ndarray::Array2<f64>> {
|
||||
let (n_links, n_bins, n_snaps) = self.dims();
|
||||
if link >= n_links {
|
||||
return Err(crate::UnifiedError::DimensionMismatch(format!(
|
||||
"link {link} out of range ({n_links} links)"
|
||||
)));
|
||||
}
|
||||
// Stage 1: per snapshot, IDFT over bins → delay columns.
|
||||
let mut delay = ndarray::Array2::from_elem((n_bins, n_snaps), Complex64::new(0.0, 0.0));
|
||||
for s in 0..n_snaps {
|
||||
for d in 0..n_bins {
|
||||
let mut acc = Complex64::new(0.0, 0.0);
|
||||
for b in 0..n_bins {
|
||||
let ang = 2.0 * std::f64::consts::PI * (b * d) as f64 / n_bins as f64;
|
||||
acc += self.data[[link, b, s]] * Complex64::new(ang.cos(), ang.sin());
|
||||
}
|
||||
delay[[d, s]] = acc / n_bins as f64;
|
||||
}
|
||||
}
|
||||
// Stage 2: per delay row, DFT over snapshots → Doppler.
|
||||
let mut out = ndarray::Array2::zeros((n_bins, n_snaps));
|
||||
for d in 0..n_bins {
|
||||
for v in 0..n_snaps {
|
||||
let mut acc = Complex64::new(0.0, 0.0);
|
||||
for s in 0..n_snaps {
|
||||
let ang = -2.0 * std::f64::consts::PI * (s * v) as f64 / n_snaps as f64;
|
||||
acc += delay[[d, s]] * Complex64::new(ang.cos(), ang.sin());
|
||||
}
|
||||
out[[d, v]] = acc.norm() / n_snaps as f64;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Direct (non-separable) reference implementation of
|
||||
/// [`Self::delay_doppler_map`] — kept for the equivalence test and the
|
||||
/// benchmark baseline.
|
||||
pub fn delay_doppler_map_direct(&self, link: usize) -> crate::Result<ndarray::Array2<f64>> {
|
||||
let (n_links, n_bins, n_snaps) = self.dims();
|
||||
if link >= n_links {
|
||||
return Err(crate::UnifiedError::DimensionMismatch(format!(
|
||||
"link {link} out of range ({n_links} links)"
|
||||
)));
|
||||
}
|
||||
let mut out = ndarray::Array2::zeros((n_bins, n_snaps));
|
||||
for d in 0..n_bins {
|
||||
for v in 0..n_snaps {
|
||||
let mut acc = Complex64::new(0.0, 0.0);
|
||||
for b in 0..n_bins {
|
||||
for s in 0..n_snaps {
|
||||
let ang = 2.0 * std::f64::consts::PI
|
||||
* ((b * d) as f64 / n_bins as f64 - (s * v) as f64 / n_snaps as f64);
|
||||
acc += self.data[[link, b, s]] * Complex64::new(ang.cos(), ang.sin());
|
||||
}
|
||||
}
|
||||
out[[d, v]] = acc.norm() / (n_bins * n_snaps) as f64;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn link() -> LinkGeometry {
|
||||
LinkGeometry { tx_pos: [0.0, 0.0, 1.0], rx_pos: [3.0, 4.0, 1.0] }
|
||||
}
|
||||
|
||||
fn valid_data(links: usize) -> Array3<Complex64> {
|
||||
Array3::from_elem((links, CANONICAL_BINS, CANONICAL_SNAPSHOTS), Complex64::new(1.0, 0.0))
|
||||
}
|
||||
|
||||
fn build(data: Array3<Complex64>, links: Vec<LinkGeometry>) -> Result<RfTensor> {
|
||||
RfTensor::new(
|
||||
RfModality::WifiCsi,
|
||||
2.437e9,
|
||||
20e6,
|
||||
data,
|
||||
links,
|
||||
0.05,
|
||||
1_700_000_000_000_000_000,
|
||||
"test-device".into(),
|
||||
0.8,
|
||||
0.1,
|
||||
CalibrationMeta::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_tensor_and_reports_dims() {
|
||||
let t = build(valid_data(2), vec![link(), link()]).expect("valid tensor");
|
||||
assert_eq!(t.dims(), (2, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
// 2.437 GHz → λ ≈ 0.1230 m.
|
||||
assert!((t.wavelength_m() - 0.123_017).abs() < 1e-4);
|
||||
assert!((t.links[0].distance_m() - 5.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delay_doppler_map_localizes_a_synthetic_target() {
|
||||
// A scatterer at delay bin 7 with Doppler bin 3:
|
||||
// H[b,s] = exp(-j2πb·7/B) · exp(+j2πs·3/S) ⇒ single peak at (7, 3).
|
||||
let (d0, v0) = (7usize, 3usize);
|
||||
let data = Array3::from_shape_fn((1, CANONICAL_BINS, CANONICAL_SNAPSHOTS), |(_, b, s)| {
|
||||
let ang = -2.0 * std::f64::consts::PI * (b * d0) as f64 / CANONICAL_BINS as f64
|
||||
+ 2.0 * std::f64::consts::PI * (s * v0) as f64 / CANONICAL_SNAPSHOTS as f64;
|
||||
Complex64::new(ang.cos(), ang.sin())
|
||||
});
|
||||
let t = build(data, vec![link()]).expect("valid tensor");
|
||||
let map = t.delay_doppler_map(0).expect("map");
|
||||
assert!((map[[d0, v0]] - 1.0).abs() < 1e-9, "peak must be unit at ({d0},{v0})");
|
||||
for d in 0..CANONICAL_BINS {
|
||||
for v in 0..CANONICAL_SNAPSHOTS {
|
||||
if (d, v) != (d0, v0) {
|
||||
assert!(map[[d, v]] < 1e-9, "leakage at ({d},{v}): {}", map[[d, v]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(t.delay_doppler_map(5).is_err(), "out-of-range link is a typed error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separable_delay_doppler_matches_direct_form() {
|
||||
// Random-ish structured tensor: several scatterers + noise floor.
|
||||
let data = Array3::from_shape_fn((1, CANONICAL_BINS, CANONICAL_SNAPSHOTS), |(_, b, s)| {
|
||||
let a1 = -2.0 * std::f64::consts::PI * (b * 3) as f64 / CANONICAL_BINS as f64
|
||||
+ 2.0 * std::f64::consts::PI * (s * 2) as f64 / CANONICAL_SNAPSHOTS as f64;
|
||||
let a2 = -2.0 * std::f64::consts::PI * (b * 11) as f64 / CANONICAL_BINS as f64
|
||||
- 2.0 * std::f64::consts::PI * s as f64 / CANONICAL_SNAPSHOTS as f64;
|
||||
Complex64::new(a1.cos() + 0.4 * a2.cos() + 0.01 * ((b * 7 + s) % 5) as f64,
|
||||
a1.sin() + 0.4 * a2.sin())
|
||||
});
|
||||
let t = build(data, vec![link()]).expect("valid tensor");
|
||||
let fast = t.delay_doppler_map(0).expect("separable");
|
||||
let direct = t.delay_doppler_map_direct(0).expect("direct");
|
||||
for d in 0..CANONICAL_BINS {
|
||||
for v in 0..CANONICAL_SNAPSHOTS {
|
||||
assert!(
|
||||
(fast[[d, v]] - direct[[d, v]]).abs() < 1e-10,
|
||||
"mismatch at ({d},{v}): {} vs {}",
|
||||
fast[[d, v]],
|
||||
direct[[d, v]]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_geometry_link_mismatch() {
|
||||
assert!(matches!(
|
||||
build(valid_data(2), vec![link()]),
|
||||
Err(UnifiedError::ShapeMismatch(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_finite_sample() {
|
||||
let mut data = valid_data(1);
|
||||
data[[0, 3, 2]] = Complex64::new(f64::NAN, 0.0);
|
||||
assert!(matches!(build(data, vec![link()]), Err(UnifiedError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_range_scalars() {
|
||||
let t = RfTensor::new(
|
||||
RfModality::WifiCsi,
|
||||
2.437e9,
|
||||
20e6,
|
||||
valid_data(1),
|
||||
vec![link()],
|
||||
-1.0, // negative age
|
||||
0,
|
||||
"d".into(),
|
||||
0.8,
|
||||
0.1,
|
||||
CalibrationMeta::default(),
|
||||
);
|
||||
assert!(matches!(t, Err(UnifiedError::InvalidInput(_))));
|
||||
|
||||
let t = RfTensor::new(
|
||||
RfModality::WifiCsi,
|
||||
2.437e9,
|
||||
20e6,
|
||||
valid_data(1),
|
||||
vec![link()],
|
||||
0.0,
|
||||
0,
|
||||
"d".into(),
|
||||
1.5, // clock quality out of range
|
||||
0.1,
|
||||
CalibrationMeta::default(),
|
||||
);
|
||||
assert!(matches!(t, Err(UnifiedError::InvalidInput(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! RF tokenizer — canonical tensor in, encoder-ready tokens out
|
||||
//! (ADR-274 §3.1).
|
||||
//!
|
||||
//! One token per `(link, subcarrier-group)`; each token carries amplitude,
|
||||
//! delay-spectrum, Doppler-spectrum, phase-dynamics, freshness, geometry,
|
||||
//! clock-quality, and uncertainty features. The exact 24-dimensional layout
|
||||
//! is documented on [`RfToken`]; the encoder treats it as an opaque vector,
|
||||
//! so new feature dims only require bumping [`D_IN`].
|
||||
|
||||
use num_complex::Complex64;
|
||||
|
||||
use crate::math::DftPlan;
|
||||
use crate::tensor::{RfTensor, CANONICAL_SNAPSHOTS};
|
||||
|
||||
/// Subcarrier-group width: 56 bins / 8 = 7 tokens per link.
|
||||
pub const GROUP_BINS: usize = 8;
|
||||
|
||||
/// Token feature dimension.
|
||||
pub const D_IN: usize = 24;
|
||||
|
||||
/// Sinusoidal position-encoding dimension (used only by masked
|
||||
/// reconstruction so the decoder knows *which* token it is predicting).
|
||||
pub const D_POS: usize = 16;
|
||||
|
||||
/// One tokenized `(link, group)` cell.
|
||||
///
|
||||
/// All amplitude-derived features are computed on window-median-normalized,
|
||||
/// CFO-aligned samples (see [`RfTokenizer::tokenize`]), so they are
|
||||
/// invariant to front-end gain and common phase drift.
|
||||
///
|
||||
/// Feature layout (all values finite, roughly unit-scale):
|
||||
///
|
||||
/// | idx | feature |
|
||||
/// |-----|---------|
|
||||
/// | 0–7 | `ln(1+amp)` per bin, averaged over snapshots |
|
||||
/// | 8–11 | delay-domain DFT magnitudes (bins 0–3) of the snapshot-mean group |
|
||||
/// | 12–15 | `ln(1+100·mag)` Doppler DFT magnitudes (bins 1–4, DC skipped) across snapshots |
|
||||
/// | 16 | `ln(1+20·std)` temporal amplitude deviation (motion energy) |
|
||||
/// | 17 | mean inter-snapshot phase velocity (rad/snapshot) |
|
||||
/// | 18 | sample age (seconds, clipped to 10) |
|
||||
/// | 19 | link distance / 10 m |
|
||||
/// | 20 | link midpoint height / 3 m |
|
||||
/// | 21 | link azimuth / π |
|
||||
/// | 22 | clock quality |
|
||||
/// | 23 | uncertainty |
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RfToken {
|
||||
/// Feature vector, layout above.
|
||||
pub features: [f64; D_IN],
|
||||
/// Link index within the source tensor.
|
||||
pub link: usize,
|
||||
/// Subcarrier-group index within the link.
|
||||
pub group: usize,
|
||||
}
|
||||
|
||||
/// A tokenized tensor window plus the window-level context the encoder's
|
||||
/// age/geometry paths consume.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokenizedWindow {
|
||||
/// Tokens, link-major then group order.
|
||||
pub tokens: Vec<RfToken>,
|
||||
/// Window age in seconds (drives the multiplicative freshness gate).
|
||||
pub age_s: f64,
|
||||
/// Window-level geometry summary: mean TX xyz then mean RX xyz, in
|
||||
/// decametres (÷10) to keep unit scale.
|
||||
pub geometry: [f64; 6],
|
||||
}
|
||||
|
||||
/// Tokenizer with precomputed DFT plans (delay + Doppler transforms are the
|
||||
/// hot path; see `benches/unified_bench.rs` for the measured speedup over
|
||||
/// planless DFTs).
|
||||
pub struct RfTokenizer {
|
||||
delay_plan: DftPlan,
|
||||
doppler_plan: DftPlan,
|
||||
}
|
||||
|
||||
impl Default for RfTokenizer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RfTokenizer {
|
||||
/// Builds the tokenizer (allocates the two DFT twiddle tables once).
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
delay_plan: DftPlan::new(GROUP_BINS, 4),
|
||||
doppler_plan: DftPlan::new(CANONICAL_SNAPSHOTS, 5),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tokenizes a canonical tensor. Panics never: the tensor's validated
|
||||
/// invariants (canonical dims after adapter normalization) are assumed;
|
||||
/// non-canonical bin counts simply produce fewer/more groups.
|
||||
///
|
||||
/// Two hardware-invariance steps happen before feature extraction
|
||||
/// (ADR-274 §3.1 — without them, chipset gain and CFO drift dominate
|
||||
/// every downstream feature):
|
||||
///
|
||||
/// 1. **Scale**: all samples are divided by the window's median
|
||||
/// amplitude, so front-end gain and absolute path loss cancel.
|
||||
/// 2. **CFO alignment**: per link, each snapshot is de-rotated by the
|
||||
/// common phase between it and snapshot 0
|
||||
/// (`arg Σ_b H[b,s]·H̄[b,0]`) — carrier-frequency-offset drift is a
|
||||
/// *common* rotation and cancels, while a moving scatterer's
|
||||
/// frequency-selective perturbation survives.
|
||||
#[must_use]
|
||||
pub fn tokenize(&self, tensor: &RfTensor) -> TokenizedWindow {
|
||||
let (n_links, n_bins, n_snaps) = tensor.dims();
|
||||
let n_groups = n_bins / GROUP_BINS;
|
||||
let mut tokens = Vec::with_capacity(n_links * n_groups);
|
||||
|
||||
// Window-level robust amplitude scale.
|
||||
let amps: Vec<f64> = tensor.data.iter().map(|z| z.norm()).collect();
|
||||
let scale = crate::math::median(&s).max(1e-12);
|
||||
|
||||
// Per-(link, snapshot) CFO-alignment rotations against snapshot 0.
|
||||
let mut align = vec![vec![Complex64::new(1.0, 0.0); n_snaps]; n_links];
|
||||
for l in 0..n_links {
|
||||
for s in 1..n_snaps {
|
||||
let mut acc = Complex64::new(0.0, 0.0);
|
||||
for b in 0..n_bins {
|
||||
acc += tensor.data[[l, b, s]] * tensor.data[[l, b, 0]].conj();
|
||||
}
|
||||
if acc.norm() > 1e-18 {
|
||||
align[l][s] = (acc / acc.norm()).conj();
|
||||
}
|
||||
}
|
||||
}
|
||||
let sample = |l: usize, b: usize, s: usize| tensor.data[[l, b, s]] * align[l][s] / scale;
|
||||
|
||||
for l in 0..n_links {
|
||||
let geo = &tensor.links[l];
|
||||
let dx = geo.rx_pos[0] - geo.tx_pos[0];
|
||||
let dy = geo.rx_pos[1] - geo.tx_pos[1];
|
||||
let dist = geo.distance_m().max(1e-6);
|
||||
let mid_z = (geo.tx_pos[2] + geo.rx_pos[2]) / 2.0;
|
||||
let azimuth = dy.atan2(dx);
|
||||
|
||||
for g in 0..n_groups {
|
||||
let b0 = g * GROUP_BINS;
|
||||
let mut f = [0.0f64; D_IN];
|
||||
|
||||
// Snapshot-mean complex value per bin (delay features) and
|
||||
// group-mean complex value per snapshot (Doppler features).
|
||||
let mut bin_means = [Complex64::new(0.0, 0.0); GROUP_BINS];
|
||||
let mut snap_means = vec![Complex64::new(0.0, 0.0); n_snaps];
|
||||
let mut amp_sum = [0.0f64; GROUP_BINS];
|
||||
let mut amp_all = Vec::with_capacity(GROUP_BINS * n_snaps);
|
||||
for (bi, bin) in (b0..b0 + GROUP_BINS).enumerate() {
|
||||
for (s, sm) in snap_means.iter_mut().enumerate() {
|
||||
let z = sample(l, bin, s);
|
||||
bin_means[bi] += z;
|
||||
*sm += z;
|
||||
amp_sum[bi] += z.norm();
|
||||
amp_all.push(z.norm());
|
||||
}
|
||||
}
|
||||
for b in &mut bin_means {
|
||||
*b /= n_snaps as f64;
|
||||
}
|
||||
for s in &mut snap_means {
|
||||
*s /= GROUP_BINS as f64;
|
||||
}
|
||||
|
||||
// 0–7: log-amplitudes.
|
||||
for bi in 0..GROUP_BINS {
|
||||
f[bi] = (1.0 + amp_sum[bi] / n_snaps as f64).ln();
|
||||
}
|
||||
// 8–11: delay spectrum of the group.
|
||||
for (k, m) in self.delay_plan.magnitudes(&bin_means).iter().enumerate() {
|
||||
f[8 + k] = *m;
|
||||
}
|
||||
// 12–15: Doppler spectrum across snapshots (skip DC bin 0),
|
||||
// log-compressed to O(1): motion magnitudes live at 1e-2 of
|
||||
// the static field, and leaving them 20× smaller than the
|
||||
// amplitude dims stalls every downstream linear adapter
|
||||
// (feature design, not adapter parameters).
|
||||
let dop_scale = |m: f64| (1.0 + 100.0 * m).ln();
|
||||
if n_snaps == CANONICAL_SNAPSHOTS {
|
||||
let dop = self.doppler_plan.magnitudes(&snap_means);
|
||||
for k in 0..4 {
|
||||
f[12 + k] = dop_scale(dop[1 + k]);
|
||||
}
|
||||
} else {
|
||||
for (k, m) in
|
||||
crate::math::dft_magnitudes(&snap_means, 5).iter().skip(1).enumerate()
|
||||
{
|
||||
f[12 + k] = dop_scale(*m);
|
||||
}
|
||||
}
|
||||
// 16: temporal amplitude std (motion energy), same treatment.
|
||||
let mean_amp = amp_all.iter().sum::<f64>() / amp_all.len() as f64;
|
||||
let var = amp_all.iter().map(|a| (a - mean_amp).powi(2)).sum::<f64>()
|
||||
/ amp_all.len() as f64;
|
||||
f[16] = (1.0 + 20.0 * var.sqrt()).ln();
|
||||
// 17: mean inter-snapshot phase velocity of the group mean.
|
||||
let mut dphi = 0.0;
|
||||
for s in 1..n_snaps {
|
||||
dphi += (snap_means[s] * snap_means[s - 1].conj()).arg();
|
||||
}
|
||||
f[17] = dphi / (n_snaps.max(2) - 1) as f64;
|
||||
// 18–23: freshness, geometry, clock, uncertainty.
|
||||
f[18] = tensor.sample_age_s.min(10.0);
|
||||
f[19] = dist / 10.0;
|
||||
f[20] = mid_z / 3.0;
|
||||
f[21] = azimuth / std::f64::consts::PI;
|
||||
f[22] = tensor.clock_quality;
|
||||
f[23] = tensor.uncertainty;
|
||||
|
||||
tokens.push(RfToken { features: f, link: l, group: g });
|
||||
}
|
||||
}
|
||||
|
||||
let mut geometry = [0.0f64; 6];
|
||||
for geo in &tensor.links {
|
||||
for i in 0..3 {
|
||||
geometry[i] += geo.tx_pos[i];
|
||||
geometry[3 + i] += geo.rx_pos[i];
|
||||
}
|
||||
}
|
||||
for v in &mut geometry {
|
||||
*v /= 10.0 * n_links as f64;
|
||||
}
|
||||
|
||||
TokenizedWindow { tokens, age_s: tensor.sample_age_s, geometry }
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed sinusoidal position encoding for token index `idx` (masked
|
||||
/// reconstruction target addressing; not a learned parameter).
|
||||
#[must_use]
|
||||
pub fn position_encoding(idx: usize) -> [f64; D_POS] {
|
||||
let mut p = [0.0f64; D_POS];
|
||||
for k in 0..D_POS / 2 {
|
||||
let freq = 1.0 / 10_000f64.powf(2.0 * k as f64 / D_POS as f64);
|
||||
p[2 * k] = (idx as f64 * freq).sin();
|
||||
p[2 * k + 1] = (idx as f64 * freq).cos();
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tensor::{CalibrationMeta, LinkGeometry, RfModality, CANONICAL_BINS};
|
||||
use ndarray::Array3;
|
||||
|
||||
fn tensor_with(motion: bool) -> RfTensor {
|
||||
let data = Array3::from_shape_fn((2, CANONICAL_BINS, CANONICAL_SNAPSHOTS), |(l, b, s)| {
|
||||
let base = 1.0 + 0.1 * (b as f64 / 10.0).sin() + 0.05 * l as f64;
|
||||
let wobble = if motion {
|
||||
// Snapshot-varying, frequency-selective perturbation — a
|
||||
// moving scatterer (bin-dependent so CFO alignment, which
|
||||
// only removes *common* rotations, must not cancel it).
|
||||
0.3 * (2.0 * std::f64::consts::PI * 2.0 * s as f64 / 8.0).sin()
|
||||
* (1.0 + b as f64 / 56.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Complex64::new(0.0, wobble).exp() * (base + wobble.abs())
|
||||
});
|
||||
RfTensor::new(
|
||||
RfModality::WifiCsi,
|
||||
2.437e9,
|
||||
20e6,
|
||||
data,
|
||||
vec![
|
||||
LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.0, 2.0] },
|
||||
LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.3, 2.0] },
|
||||
],
|
||||
0.05,
|
||||
0,
|
||||
"tok-test".into(),
|
||||
0.8,
|
||||
0.1,
|
||||
CalibrationMeta::default(),
|
||||
)
|
||||
.expect("valid tensor")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn produces_expected_token_grid() {
|
||||
let w = RfTokenizer::new().tokenize(&tensor_with(false));
|
||||
assert_eq!(w.tokens.len(), 2 * (CANONICAL_BINS / GROUP_BINS));
|
||||
for t in &w.tokens {
|
||||
assert!(t.features.iter().all(|v| v.is_finite()), "non-finite feature");
|
||||
}
|
||||
// Context passthrough.
|
||||
assert!((w.age_s - 0.05).abs() < 1e-12);
|
||||
assert!(w.tokens[0].features[22] > 0.79 && w.tokens[0].features[22] < 0.81);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_raises_doppler_and_variance_features() {
|
||||
let tok = RfTokenizer::new();
|
||||
let still = tok.tokenize(&tensor_with(false));
|
||||
let moving = tok.tokenize(&tensor_with(true));
|
||||
let dop = |w: &TokenizedWindow| {
|
||||
w.tokens.iter().map(|t| t.features[12..16].iter().sum::<f64>()).sum::<f64>()
|
||||
};
|
||||
let var = |w: &TokenizedWindow| w.tokens.iter().map(|t| t.features[16]).sum::<f64>();
|
||||
assert!(
|
||||
dop(&moving) > 10.0 * dop(&still) + 1e-9,
|
||||
"Doppler features must respond to motion: moving={} still={}",
|
||||
dop(&moving),
|
||||
dop(&still)
|
||||
);
|
||||
assert!(var(&moving) > var(&still));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_encoding_is_unique_and_bounded() {
|
||||
let a = position_encoding(0);
|
||||
let b = position_encoding(7);
|
||||
assert_ne!(a, b);
|
||||
for v in a.iter().chain(b.iter()) {
|
||||
assert!(v.abs() <= 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! End-to-end acceptance pipeline for the unified RF spatial world model
|
||||
//! (ADR-273 §6), on SYNTHETIC data (ADR-276 generator — every number below
|
||||
//! is a synthetic-world result until validated on measured captures).
|
||||
//!
|
||||
//! The pipeline exercised is the deployment pipeline, not a shortcut:
|
||||
//! physics-simulated CSI → canonical tensor → tokenizer → self-supervised
|
||||
//! masked-reconstruction pretraining (labels never touch the encoder) →
|
||||
//! frozen encoder → ≤1 %-budget presence adapter → strict anti-leakage
|
||||
//! evaluation → policy-wrapped export.
|
||||
//!
|
||||
//! Acceptance gates checked here (synthetic analogues of ADR-273 §6):
|
||||
//! 1. presence F1 ≥ 0.90 on completely held-out rooms;
|
||||
//! 2. the same gate under a held-out *chipset* split;
|
||||
//! 3. known→unknown relative degradation < 20 %;
|
||||
//! 4. adapters < 1 % of backbone parameters;
|
||||
//! 5. p95 tokenize+encode latency < 50 ms;
|
||||
//! 6. every exported output carries uncertainty, provenance, model version,
|
||||
//! and purpose, and leaves only through the policy trust boundary.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use ruview_unified::encoder::{EncoderConfig, RfEncoder};
|
||||
use ruview_unified::eval::{
|
||||
expected_calibration_error, f1_score, relative_degradation, selective_metrics, PartitionDim,
|
||||
StrictSplit,
|
||||
};
|
||||
use ruview_unified::gaussian::primitive::Provenance;
|
||||
use ruview_unified::heads::{within_adapter_budget, PresenceHead};
|
||||
use ruview_unified::policy::{
|
||||
BoundedEvent, EventValue, PolicyEngine, PrivacyZone, SensingPurpose, TrustBoundary,
|
||||
};
|
||||
use ruview_unified::pretrain::{pretrain, PretrainConfig};
|
||||
use ruview_unified::synth::{LabeledWindow, SynthConfig, SynthGenerator};
|
||||
use ruview_unified::tokenizer::{RfTokenizer, TokenizedWindow};
|
||||
|
||||
/// Shared fixture: corpus, tokenized windows, encoder config.
|
||||
struct Fixture {
|
||||
corpus: Vec<LabeledWindow>,
|
||||
windows: Vec<TokenizedWindow>,
|
||||
cfg: EncoderConfig,
|
||||
}
|
||||
|
||||
fn build_fixture() -> Fixture {
|
||||
let corpus = SynthGenerator::new(SynthConfig {
|
||||
seed: 273_273,
|
||||
n_rooms: 8,
|
||||
windows_per_room: 20,
|
||||
links: 3,
|
||||
snapshot_dt_s: 0.05,
|
||||
})
|
||||
.generate();
|
||||
let tokenizer = RfTokenizer::new();
|
||||
let windows: Vec<TokenizedWindow> =
|
||||
corpus.iter().map(|w| tokenizer.tokenize(&w.tensor)).collect();
|
||||
// d_model = 64 keeps the debug-profile test fast; the ≤1 % adapter
|
||||
// budget is checked against THIS backbone, not a larger one.
|
||||
let cfg = EncoderConfig { d_model: 64, ..EncoderConfig::default() };
|
||||
Fixture { corpus, windows, cfg }
|
||||
}
|
||||
|
||||
/// Pretrains on the training side only, trains a presence head on frozen
|
||||
/// representations, and returns (known-condition F1, held-out F1, held-out
|
||||
/// probabilities and labels) for a given strict split.
|
||||
fn run_split(fx: &Fixture, split: &StrictSplit) -> (f64, f64, Vec<f64>, Vec<bool>) {
|
||||
// Self-supervised pretraining: training windows only, no labels.
|
||||
let train_windows: Vec<TokenizedWindow> =
|
||||
split.train.iter().map(|&i| fx.windows[i].clone()).collect();
|
||||
let mut encoder = RfEncoder::new(fx.cfg, 7);
|
||||
let report = pretrain(
|
||||
&mut encoder,
|
||||
&train_windows,
|
||||
&PretrainConfig { mask_fraction: 0.25, lr: 0.03, epochs: 8, seed: 0x5EED },
|
||||
);
|
||||
assert!(
|
||||
report.final_loss < report.initial_loss,
|
||||
"pretraining must reduce masked loss: {report:?}"
|
||||
);
|
||||
|
||||
// Frozen encoder → environment-invariant content representation for
|
||||
// every window (presence is a cross-room task; the geometry-conditioned
|
||||
// `encode()` view is for localization-style heads).
|
||||
let zs: Vec<Vec<f64>> = fx.windows.iter().map(|w| encoder.encode_content(w)).collect();
|
||||
|
||||
// ≤1 % adapter on the frozen backbone.
|
||||
let mut head = PresenceHead::new(encoder.content_dim());
|
||||
assert!(
|
||||
within_adapter_budget(encoder.param_count(), head.param_count()),
|
||||
"presence adapter {} params vs backbone {}",
|
||||
head.param_count(),
|
||||
encoder.param_count()
|
||||
);
|
||||
let train_z: Vec<Vec<f64>> = split.train.iter().map(|&i| zs[i].clone()).collect();
|
||||
let train_y: Vec<bool> = split.train.iter().map(|&i| fx.corpus[i].presence).collect();
|
||||
head.train(&train_z, &train_y, 1.0, 800);
|
||||
|
||||
let eval = |idx: &[usize]| -> (Vec<f64>, Vec<bool>) {
|
||||
let probs: Vec<f64> = idx.iter().map(|&i| head.predict_prob(&zs[i])).collect();
|
||||
let labels: Vec<bool> = idx.iter().map(|&i| fx.corpus[i].presence).collect();
|
||||
(probs, labels)
|
||||
};
|
||||
let (train_p, train_l) = eval(&split.train);
|
||||
let (test_p, test_l) = eval(&split.test);
|
||||
let known_f1 = f1_score(&train_p.iter().map(|p| *p > 0.5).collect::<Vec<_>>(), &train_l);
|
||||
let held_f1 = f1_score(&test_p.iter().map(|p| *p > 0.5).collect::<Vec<_>>(), &test_l);
|
||||
(known_f1, held_f1, test_p, test_l)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acceptance_pipeline_on_synthetic_worlds() {
|
||||
let fx = build_fixture();
|
||||
let keys: Vec<_> = fx.corpus.iter().map(|w| w.key.clone()).collect();
|
||||
|
||||
// ---- Gate 1: held-out ROOMS (rooms 6 and 7 never seen in any stage).
|
||||
let room_split = StrictSplit::holdout(&keys, PartitionDim::Room, &["room-6", "room-7"]);
|
||||
assert!(room_split.verify(&keys), "room split must be leak-free");
|
||||
assert!(room_split.test.len() >= 30, "held-out set must be substantial");
|
||||
let (known_f1, room_f1, test_p, test_l) = run_split(&fx, &room_split);
|
||||
println!("SYNTHETIC room-holdout: known F1 {known_f1:.4}, held-out F1 {room_f1:.4}");
|
||||
assert!(
|
||||
room_f1 >= 0.90,
|
||||
"ADR-273 gate: presence F1 on unseen rooms must be >= 0.90, got {room_f1:.4} (SYNTHETIC)"
|
||||
);
|
||||
|
||||
// ---- Gate 3: known → unknown degradation < 20 %.
|
||||
let degradation = relative_degradation(known_f1, room_f1);
|
||||
println!("SYNTHETIC degradation known→unknown: {degradation:.4}");
|
||||
assert!(
|
||||
degradation < 0.20,
|
||||
"cross-environment degradation must stay under 20 %, got {degradation:.4}"
|
||||
);
|
||||
|
||||
// ---- Calibration + abstention on the held-out side: raising the
|
||||
// confidence threshold must never raise selective risk, and full-
|
||||
// coverage risk is bounded by the F1 gate above.
|
||||
let ece = expected_calibration_error(&test_p, &test_l, 10);
|
||||
println!("SYNTHETIC held-out ECE: {ece:.4}");
|
||||
let full = selective_metrics(&test_p, &test_l, 0.5);
|
||||
let strict = selective_metrics(&test_p, &test_l, 0.9);
|
||||
println!(
|
||||
"SYNTHETIC abstention: coverage {:.2}→{:.2}, risk {:.4}→{:.4}",
|
||||
full.coverage, strict.coverage, full.selective_risk, strict.selective_risk
|
||||
);
|
||||
assert!(strict.selective_risk <= full.selective_risk + 1e-12);
|
||||
|
||||
// ---- Gate 2: held-out CHIPSET (every chip-2 room excluded from
|
||||
// training; per-room gain/phase/CFO/noise profiles are random, so the
|
||||
// held-out hardware profile is genuinely unseen).
|
||||
let chip_split = StrictSplit::holdout(&keys, PartitionDim::Chipset, &["chip-2"]);
|
||||
assert!(chip_split.verify(&keys), "chipset split must be leak-free");
|
||||
let (chip_known, chip_f1, _, _) = run_split(&fx, &chip_split);
|
||||
println!("SYNTHETIC chipset-holdout: known F1 {chip_known:.4}, held-out F1 {chip_f1:.4}");
|
||||
assert!(
|
||||
chip_f1 >= 0.90,
|
||||
"presence F1 on unseen chipset must be >= 0.90, got {chip_f1:.4} (SYNTHETIC)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_latency_budget_tokenize_plus_encode() {
|
||||
let fx = build_fixture();
|
||||
let tokenizer = RfTokenizer::new();
|
||||
let encoder = RfEncoder::new(fx.cfg, 7);
|
||||
|
||||
// Warm up, then measure 100 full tokenize+encode passes.
|
||||
let mut latencies_us: Vec<u128> = Vec::with_capacity(100);
|
||||
for i in 0..110 {
|
||||
let idx = i % fx.corpus.len();
|
||||
let start = Instant::now();
|
||||
let w = tokenizer.tokenize(&fx.corpus[idx].tensor);
|
||||
let z = encoder.encode(&w);
|
||||
std::hint::black_box(z);
|
||||
if i >= 10 {
|
||||
latencies_us.push(start.elapsed().as_micros());
|
||||
}
|
||||
}
|
||||
latencies_us.sort_unstable();
|
||||
let p50 = latencies_us[latencies_us.len() / 2];
|
||||
let p95 = latencies_us[latencies_us.len() * 95 / 100];
|
||||
println!("edge latency tokenize+encode: p50 {p50} µs, p95 {p95} µs (debug profile)");
|
||||
// ADR-273 gate is 50 ms at p95 on edge hardware; even the unoptimized
|
||||
// debug profile must clear it with margin on a dev machine.
|
||||
assert!(p95 < 50_000, "p95 latency {p95} µs exceeds the 50 ms budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_leave_only_through_the_policy_boundary_fully_attributed() {
|
||||
let fx = build_fixture();
|
||||
let tokenizer = RfTokenizer::new();
|
||||
let encoder = RfEncoder::new(fx.cfg, 7);
|
||||
let mut head = PresenceHead::new(encoder.content_dim());
|
||||
// Small supervised fit so probabilities are meaningful.
|
||||
let zs: Vec<Vec<f64>> = fx
|
||||
.corpus
|
||||
.iter()
|
||||
.take(60)
|
||||
.map(|w| encoder.encode_content(&tokenizer.tokenize(&w.tensor)))
|
||||
.collect();
|
||||
let ys: Vec<bool> = fx.corpus.iter().take(60).map(|w| w.presence).collect();
|
||||
head.train(&zs, &ys, 0.5, 100);
|
||||
|
||||
let mut engine = PolicyEngine::new();
|
||||
engine.upsert_zone(PrivacyZone {
|
||||
id: "room-0".into(),
|
||||
allowed_purposes: [SensingPurpose::Presence].into_iter().collect(),
|
||||
retention_s: 600,
|
||||
identity_explicitly_enabled: false,
|
||||
});
|
||||
let boundary = TrustBoundary::new(engine);
|
||||
|
||||
let p = head.predict_prob(&zs[0]);
|
||||
let event = BoundedEvent::new(
|
||||
SensingPurpose::Presence,
|
||||
EventValue::Presence(p > 0.5),
|
||||
1.0 - (2.0 * p - 1.0).abs(), // confidence → uncertainty
|
||||
Provenance {
|
||||
device_id: fx.corpus[0].tensor.device_id.clone(),
|
||||
model_version: 1,
|
||||
synthetic: true, // honest labeling: synthetic evidence
|
||||
},
|
||||
1,
|
||||
fx.corpus[0].tensor.timestamp_ns,
|
||||
"room-0",
|
||||
)
|
||||
.expect("attributed event");
|
||||
|
||||
// Compliant export passes and the payload is a typed verdict — the
|
||||
// BoundedEvent type has no variant that can carry RF samples, so raw
|
||||
// CSI export is unrepresentable, not merely forbidden.
|
||||
let exported =
|
||||
boundary.export(event.clone(), fx.corpus[0].tensor.timestamp_ns).expect("export ok");
|
||||
assert!(exported.uncertainty >= 0.0 && exported.uncertainty <= 1.0);
|
||||
assert!(exported.provenance.synthetic, "synthetic provenance must survive export");
|
||||
|
||||
// An ungranted purpose in the same zone is denied (fail-closed).
|
||||
let denied = BoundedEvent { purpose: SensingPurpose::IdentityRecognition, ..event };
|
||||
assert!(boundary.export(denied, fx.corpus[0].tensor.timestamp_ns).is_err());
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Security property tests (ADR-273 pre-merge item 12): the crate's
|
||||
//! system boundaries must never panic on hostile input and must stay
|
||||
//! fail-closed under arbitrary authorization states.
|
||||
//!
|
||||
//! Strategy: proptest drives the validated constructors and policy gates
|
||||
//! with arbitrary values (including NaN/±inf smuggled through
|
||||
//! `f64::from_bits`) and asserts the *contract*, not specific values:
|
||||
//! every input either yields a valid object or a typed error — never a
|
||||
//! panic, and never a permissive default.
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use ruview_unified::adapters::{ble_cs_range, BleCsFrame};
|
||||
use ruview_unified::control::{
|
||||
admit_task, validate_representation, CoherentSensorGroup, MemberSyncState, PrivacyClass,
|
||||
SensingTask, SpatialZone, TaskSufficientRepresentation,
|
||||
};
|
||||
use ruview_unified::gaussian::primitive::{Provenance, RfGaussian};
|
||||
use ruview_unified::policy::{
|
||||
BoundedEvent, EventValue, PolicyEngine, PrivacyZone, SensingPurpose,
|
||||
};
|
||||
use ruview_unified::tensor::{CalibrationMeta, LinkGeometry, RfModality, RfTensor};
|
||||
|
||||
/// Arbitrary f64 including NaN, ±inf, subnormals — the values an attacker
|
||||
/// or a broken driver would deliver.
|
||||
fn any_f64() -> impl Strategy<Value = f64> {
|
||||
any::<u64>().prop_map(f64::from_bits)
|
||||
}
|
||||
|
||||
fn any_purpose() -> impl Strategy<Value = SensingPurpose> {
|
||||
prop_oneof![
|
||||
Just(SensingPurpose::Presence),
|
||||
Just(SensingPurpose::Activity),
|
||||
Just(SensingPurpose::Vitals),
|
||||
Just(SensingPurpose::Localization),
|
||||
Just(SensingPurpose::PoseTracking),
|
||||
Just(SensingPurpose::IdentityRecognition),
|
||||
Just(SensingPurpose::ChannelDiagnostics),
|
||||
]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
|
||||
/// RfTensor::new never panics; invalid numeric fields are typed errors.
|
||||
#[test]
|
||||
fn rf_tensor_constructor_never_panics(
|
||||
re in any_f64(),
|
||||
im in any_f64(),
|
||||
freq in any_f64(),
|
||||
bw in any_f64(),
|
||||
age in any_f64(),
|
||||
clock in any_f64(),
|
||||
unc in any_f64(),
|
||||
tx in prop::array::uniform3(any_f64()),
|
||||
) {
|
||||
let data = ndarray::Array3::from_elem((1, 4, 2), num_complex::Complex64::new(re, im));
|
||||
let links = vec![LinkGeometry { tx_pos: tx, rx_pos: [1.0, 0.0, 1.0] }];
|
||||
let result = RfTensor::new(
|
||||
RfModality::WifiCsi, freq, bw, data, links, age, 0, "prop".into(),
|
||||
clock, unc, CalibrationMeta::default(),
|
||||
);
|
||||
// Contract: Ok only when every validated field is actually valid.
|
||||
if let Ok(t) = result {
|
||||
prop_assert!(t.center_freq_hz.is_finite() && t.center_freq_hz > 0.0);
|
||||
prop_assert!((0.0..=1.0).contains(&t.clock_quality));
|
||||
prop_assert!((0.0..=1.0).contains(&t.uncertainty));
|
||||
prop_assert!(t.sample_age_s.is_finite() && t.sample_age_s >= 0.0);
|
||||
prop_assert!(t.data.iter().all(|z| z.re.is_finite() && z.im.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
/// RfGaussian::new never panics; accepted Gaussians have a normalized
|
||||
/// quaternion and in-range trust fields.
|
||||
#[test]
|
||||
fn rf_gaussian_constructor_never_panics(
|
||||
pos in prop::array::uniform3(any_f64()),
|
||||
scale in prop::array::uniform3(any_f64()),
|
||||
quat in prop::array::uniform4(any_f64()),
|
||||
occ in any_f64(),
|
||||
conf in any_f64(),
|
||||
tau in any_f64(),
|
||||
) {
|
||||
let result = RfGaussian::new(
|
||||
pos, scale, quat, occ, conf, 0, tau,
|
||||
Provenance { device_id: "prop".into(), model_version: 1, synthetic: true },
|
||||
);
|
||||
if let Ok(g) = result {
|
||||
let qn: f64 = g.orientation.iter().map(|q| q * q).sum::<f64>().sqrt();
|
||||
prop_assert!((qn - 1.0).abs() < 1e-9, "quaternion must be normalized");
|
||||
prop_assert!(g.scale.iter().all(|s| *s > 0.0));
|
||||
prop_assert!(g.occupancy >= 0.0);
|
||||
prop_assert!((0.0..=1.0).contains(&g.confidence));
|
||||
// Density at the centre of a valid Gaussian is exactly 1.
|
||||
prop_assert!((g.density_at(g.position) - 1.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
/// BoundedEvent::new never panics; exported accountability fields are
|
||||
/// always in range.
|
||||
#[test]
|
||||
fn bounded_event_constructor_never_panics(
|
||||
uncertainty in any_f64(),
|
||||
model_version in any::<u32>(),
|
||||
value in any_f64(),
|
||||
) {
|
||||
let result = BoundedEvent::new(
|
||||
SensingPurpose::Presence,
|
||||
EventValue::RespirationBpm(value),
|
||||
uncertainty,
|
||||
Provenance { device_id: "prop".into(), model_version: 1, synthetic: false },
|
||||
model_version,
|
||||
0,
|
||||
"zone",
|
||||
);
|
||||
if let Ok(e) = result {
|
||||
prop_assert!((0.0..=1.0).contains(&e.uncertainty));
|
||||
prop_assert!(e.model_version != 0, "unassigned model version must never export");
|
||||
}
|
||||
}
|
||||
|
||||
/// ble_cs_range never panics on arbitrary phases/frequencies/RTT, and
|
||||
/// any Ok evidence has a finite, non-negative distance.
|
||||
#[test]
|
||||
fn ble_cs_range_never_panics(
|
||||
phases in prop::collection::vec(any_f64(), 0..24),
|
||||
f0 in any_f64(),
|
||||
df in any_f64(),
|
||||
rtt in prop::option::of(any_f64()),
|
||||
) {
|
||||
let n = phases.len();
|
||||
let frame = BleCsFrame {
|
||||
frequency_steps_hz: (0..n).map(|k| f0 + df * k as f64).collect(),
|
||||
phase_samples_rad: phases,
|
||||
round_trip_time_ns: rtt,
|
||||
};
|
||||
if let Ok(ev) = ble_cs_range(&frame) {
|
||||
// Non-finite inputs are rejected at the boundary (the unwrap
|
||||
// loop would otherwise spin forever on +inf — the DoS this
|
||||
// suite originally caught), so Ok evidence is fully finite.
|
||||
prop_assert!(ev.phase_distance_m.is_finite() && ev.phase_distance_m >= 0.0);
|
||||
prop_assert!((0.0..=1.0).contains(&ev.confidence));
|
||||
if let Some(d) = ev.rtt_distance_m {
|
||||
prop_assert!(d.is_finite());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The policy engine is fail-closed for every purpose against every
|
||||
/// zone configuration that does not explicitly grant it.
|
||||
#[test]
|
||||
fn policy_engine_is_fail_closed_under_arbitrary_grants(
|
||||
purpose in any_purpose(),
|
||||
granted in prop::collection::btree_set(any_purpose(), 0..7),
|
||||
identity_enabled in any::<bool>(),
|
||||
query_unknown_zone in any::<bool>(),
|
||||
) {
|
||||
let mut engine = PolicyEngine::new();
|
||||
engine.upsert_zone(PrivacyZone {
|
||||
id: "z".into(),
|
||||
allowed_purposes: granted.clone(),
|
||||
retention_s: 60,
|
||||
identity_explicitly_enabled: identity_enabled,
|
||||
});
|
||||
let zone_id = if query_unknown_zone { "nope" } else { "z" };
|
||||
let verdict = engine.authorize(zone_id, purpose);
|
||||
if query_unknown_zone {
|
||||
prop_assert!(verdict.is_err(), "unknown zone must always deny");
|
||||
} else if !granted.contains(&purpose) {
|
||||
prop_assert!(verdict.is_err(), "ungranted purpose must deny");
|
||||
} else if purpose == SensingPurpose::IdentityRecognition && !identity_enabled {
|
||||
prop_assert!(verdict.is_err(), "identity single-gate must deny");
|
||||
} else {
|
||||
prop_assert!(verdict.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
/// Task admission can never approve raw export, whatever else is true.
|
||||
#[test]
|
||||
fn raw_export_is_unreachable(
|
||||
purpose in any_purpose(),
|
||||
raw in any::<bool>(),
|
||||
confidence in any_f64(),
|
||||
) {
|
||||
let mut engine = PolicyEngine::new();
|
||||
engine.upsert_zone(PrivacyZone {
|
||||
id: "z".into(),
|
||||
allowed_purposes: [purpose].into_iter().collect(),
|
||||
retention_s: 60,
|
||||
identity_explicitly_enabled: true,
|
||||
});
|
||||
let task = SensingTask {
|
||||
task_id: 1,
|
||||
purpose,
|
||||
target_area: SpatialZone { id: "z".into(), min_m: [0.0; 3], max_m: [1.0; 3] },
|
||||
modalities: vec![RfModality::WifiCsi],
|
||||
requested_resolution_m: 0.5,
|
||||
maximum_latency_ms: 100,
|
||||
minimum_confidence: confidence,
|
||||
raw_retention_seconds: 60,
|
||||
result_retention_seconds: 60,
|
||||
authorized_consumers: vec![],
|
||||
consent_reference: Some("consent-1".into()),
|
||||
raw_export_allowed: raw,
|
||||
};
|
||||
let verdict = admit_task(&engine, &task);
|
||||
if raw {
|
||||
prop_assert!(verdict.is_err(), "raw export must be structurally unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
/// Coherent fusion denies whenever any reported error is non-finite or
|
||||
/// out of bounds — NaN cannot sneak past the gate.
|
||||
#[test]
|
||||
fn coherent_fusion_rejects_non_finite_sync_state(
|
||||
time_err in any_f64(),
|
||||
phase_err in any_f64(),
|
||||
hash in any::<u64>(),
|
||||
) {
|
||||
let group = CoherentSensorGroup {
|
||||
group_id: "g".into(),
|
||||
members: vec!["m".into()],
|
||||
maximum_time_error_ns: 50.0,
|
||||
maximum_phase_error_rad: 0.2,
|
||||
baseline_geometry_hash: 7,
|
||||
};
|
||||
let verdict = group.can_fuse(&[MemberSyncState {
|
||||
member_id: "m".into(),
|
||||
time_error_ns: time_err,
|
||||
phase_error_rad: phase_err,
|
||||
geometry_hash: hash,
|
||||
}]);
|
||||
let in_bounds = time_err.is_finite()
|
||||
&& time_err.abs() <= 50.0
|
||||
&& phase_err.is_finite()
|
||||
&& phase_err.abs() <= 0.2
|
||||
&& hash == 7;
|
||||
prop_assert_eq!(verdict.is_ok(), in_bounds, "NaN/inf must deny, bounds must bind");
|
||||
}
|
||||
|
||||
/// Representation validation never approves an identity-retaining
|
||||
/// occupancy representation, whatever the other fields say — checked
|
||||
/// across *every* `SensingPurpose` branch (each has a distinct ceiling
|
||||
/// and exclusion set in `validate_representation`; testing only
|
||||
/// `Presence`, as this property previously did, leaves the other three
|
||||
/// branches — covering P3–P5, the higher-risk representations —
|
||||
/// completely unverified).
|
||||
#[test]
|
||||
fn occupancy_representations_must_exclude_identity(
|
||||
purpose in any_purpose(),
|
||||
class in prop_oneof![
|
||||
Just(PrivacyClass::P0), Just(PrivacyClass::P1), Just(PrivacyClass::P2),
|
||||
Just(PrivacyClass::P3), Just(PrivacyClass::P4), Just(PrivacyClass::P5)
|
||||
],
|
||||
excluded in prop::collection::vec("[a-z]{1,10}", 0..4),
|
||||
bits in any_f64(),
|
||||
) {
|
||||
let rep = TaskSufficientRepresentation {
|
||||
task_id: 1,
|
||||
source_receipts: vec![1],
|
||||
semantic_state: vec![0.5],
|
||||
information_bound_bits: bits,
|
||||
excluded_information: excluded.clone(),
|
||||
privacy_class: class,
|
||||
};
|
||||
let verdict = validate_representation(&rep, purpose);
|
||||
// Oracle mirrors the (ceiling, must_exclude) table in
|
||||
// `control::validate_representation` so this property checks the
|
||||
// *contract* per purpose group, not just the Presence case.
|
||||
let (ceiling, must_exclude): (PrivacyClass, &[&str]) = match purpose {
|
||||
SensingPurpose::Presence | SensingPurpose::ChannelDiagnostics => {
|
||||
(PrivacyClass::P2, &["identity", "vitals"])
|
||||
}
|
||||
SensingPurpose::Activity | SensingPurpose::Localization => {
|
||||
(PrivacyClass::P3, &["identity"])
|
||||
}
|
||||
SensingPurpose::Vitals | SensingPurpose::PoseTracking => {
|
||||
(PrivacyClass::P4, &["identity"])
|
||||
}
|
||||
SensingPurpose::IdentityRecognition => (PrivacyClass::P5, &[]),
|
||||
};
|
||||
let excludes_required =
|
||||
must_exclude.iter().all(|c| excluded.iter().any(|e| e == c));
|
||||
if verdict.is_ok() {
|
||||
prop_assert!(
|
||||
excludes_required,
|
||||
"approved rep for {:?} must exclude {:?}", purpose, must_exclude
|
||||
);
|
||||
prop_assert!(
|
||||
class <= ceiling,
|
||||
"approved rep for {:?} must respect ceiling {:?}", purpose, ceiling
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ impl ComplexSample {
|
||||
/// This is a lossy *view*, never re-serialised as the witness form
|
||||
/// (ADR-136 §3.3 risk mitigation — one encoder only).
|
||||
#[must_use]
|
||||
#[allow(clippy::cast_possible_truncation)] // f64 -> f32 is the documented lossy narrowing above
|
||||
pub fn as_complex32(&self) -> num_complex::Complex32 {
|
||||
num_complex::Complex32::new(self.0.re as f32, self.0.im as f32)
|
||||
}
|
||||
@@ -581,7 +582,9 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
b.extend_from_slice(&m.timestamp.seconds.to_le_bytes());
|
||||
b.extend_from_slice(&m.timestamp.nanos.to_le_bytes());
|
||||
let dev = m.device_id.as_str().as_bytes();
|
||||
b.extend_from_slice(&(dev.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(
|
||||
&u32::try_from(dev.len()).expect("device_id length fits u32").to_le_bytes(),
|
||||
);
|
||||
b.extend_from_slice(dev);
|
||||
b.push(match m.frequency_band {
|
||||
FrequencyBand::Band2_4GHz => 0,
|
||||
@@ -592,15 +595,12 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
b.extend_from_slice(&m.bandwidth_mhz.to_le_bytes());
|
||||
b.push(m.antenna_config.tx_antennas);
|
||||
b.push(m.antenna_config.rx_antennas);
|
||||
match m.antenna_config.spacing_mm {
|
||||
Some(s) => {
|
||||
b.push(1);
|
||||
b.extend_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
None => {
|
||||
b.push(0);
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
}
|
||||
if let Some(s) = m.antenna_config.spacing_mm {
|
||||
b.push(1);
|
||||
b.extend_from_slice(&s.to_le_bytes());
|
||||
} else {
|
||||
b.push(0);
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
}
|
||||
b.extend_from_slice(&m.rssi_dbm.to_le_bytes());
|
||||
b.extend_from_slice(&m.noise_floor_dbm.to_le_bytes());
|
||||
@@ -623,8 +623,12 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
b.extend_from_slice(&m.model_version.to_le_bytes());
|
||||
|
||||
// Shape, then complex payload stream-major.
|
||||
b.extend_from_slice(&(self.data.nrows() as u32).to_le_bytes());
|
||||
b.extend_from_slice(&(self.data.ncols() as u32).to_le_bytes());
|
||||
b.extend_from_slice(
|
||||
&u32::try_from(self.data.nrows()).expect("stream count fits u32").to_le_bytes(),
|
||||
);
|
||||
b.extend_from_slice(
|
||||
&u32::try_from(self.data.ncols()).expect("subcarrier count fits u32").to_le_bytes(),
|
||||
);
|
||||
for sample in self.data_complex_samples() {
|
||||
b.extend_from_slice(&sample.to_le_bytes());
|
||||
}
|
||||
@@ -714,7 +718,7 @@ impl<'a> Cursor<'a> {
|
||||
Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap()))
|
||||
}
|
||||
fn i8(&mut self) -> Result<i8, CanonicalDecodeError> {
|
||||
Ok(self.take(1)?[0] as i8)
|
||||
Ok(i8::from_le_bytes(self.take(1)?.try_into().unwrap()))
|
||||
}
|
||||
fn uuid(&mut self) -> Result<Uuid, CanonicalDecodeError> {
|
||||
Ok(Uuid::from_bytes(self.take(16)?.try_into().unwrap()))
|
||||
@@ -1462,7 +1466,7 @@ mod tests {
|
||||
fn lcg(state: &mut u64) -> f64 {
|
||||
*state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
// Map high bits into [-1e6, 1e6) for a wide exponent spread.
|
||||
((*state >> 11) as f64 / (1u64 << 53) as f64) * 2.0e6 - 1.0e6
|
||||
((*state >> 11) as f64 / (1u64 << 53) as f64).mul_add(2.0e6, -1.0e6)
|
||||
}
|
||||
|
||||
/// AC1 — `ComplexSample` little-endian round-trip + endianness pin.
|
||||
@@ -1671,6 +1675,7 @@ mod tests {
|
||||
/// unwinding panic (panic-on-adversarial-input = 0). Sweep truncations and a
|
||||
/// deterministic fuzz spread.
|
||||
#[test]
|
||||
#[allow(clippy::cast_possible_truncation)] // fuzz byte extraction: truncation IS the point
|
||||
fn canonical_decode_never_panics_on_arbitrary_bytes() {
|
||||
use ndarray::Array2;
|
||||
let mut meta = CsiMetadata::new(DeviceId::new("node"), FrequencyBand::Band5GHz, 36);
|
||||
|
||||
@@ -146,7 +146,7 @@ impl PpduType {
|
||||
/// bit 3 : LDPC (reserved — not yet populated by firmware)
|
||||
/// bit 4 : 802.15.4 time-sync valid (C6 only)
|
||||
/// bit 5-7 : reserved
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Adr018Flags {
|
||||
pub bw40: bool,
|
||||
pub stbc: bool,
|
||||
@@ -173,12 +173,6 @@ impl Adr018Flags {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Adr018Flags {
|
||||
fn default() -> Self {
|
||||
Self { bw40: false, stbc: false, ldpc: false, ieee802154_sync_valid: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// WiFi channel bandwidth.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Bandwidth {
|
||||
|
||||
@@ -93,8 +93,10 @@ fn fsm_full_cycle_setup_measure_report_terminate() {
|
||||
|
||||
#[test]
|
||||
fn responder_rejects_unsupported_bandwidth_and_initiator_resets() {
|
||||
let mut cfg = SessionConfig::default();
|
||||
cfg.capabilities = SensingCapabilities::esp32_opportunistic(); // max 40 MHz
|
||||
let cfg = SessionConfig {
|
||||
capabilities: SensingCapabilities::esp32_opportunistic(), // max 40 MHz
|
||||
..SessionConfig::default()
|
||||
};
|
||||
let mut responder = SensingSession::new_responder(cfg);
|
||||
let mut initiator = SensingSession::new_initiator(SessionConfig::default());
|
||||
|
||||
@@ -204,8 +206,10 @@ fn capacity_and_policy_and_profile_rejections() {
|
||||
));
|
||||
|
||||
// Incompatible profile
|
||||
let mut cfg = SessionConfig::default();
|
||||
cfg.profile = SpecProfile::VendorExtension("acme".into());
|
||||
let cfg = SessionConfig {
|
||||
profile: SpecProfile::VendorExtension("acme".into()),
|
||||
..SessionConfig::default()
|
||||
};
|
||||
let mut responder = SensingSession::new_responder(cfg);
|
||||
let actions = responder
|
||||
.handle(SessionEvent::SetupRequestReceived(setup_request(6)))
|
||||
|
||||
@@ -268,8 +268,10 @@ fn sbp_validation_shares_setup_chain_with_one_to_one_status_mapping() {
|
||||
|
||||
// Incompatible profile now surfaces as its own status (the old
|
||||
// duplicated SBP chain folded it into RejectedUnsupportedParams).
|
||||
let mut cfg = SessionConfig::default();
|
||||
cfg.profile = SpecProfile::VendorExtension("acme".into());
|
||||
let cfg = SessionConfig {
|
||||
profile: SpecProfile::VendorExtension("acme".into()),
|
||||
..SessionConfig::default()
|
||||
};
|
||||
let mut proxy = SensingSession::new_responder(cfg);
|
||||
let actions = proxy
|
||||
.handle(SessionEvent::SbpRequestReceived(sbp_request(41)))
|
||||
|
||||
@@ -91,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
|
||||
@@ -128,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,6 +9,8 @@
|
||||
//! - 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;
|
||||
|
||||
@@ -7720,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
|
||||
@@ -7967,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",
|
||||
@@ -7978,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."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8016,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,
|
||||
@@ -8090,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))
|
||||
@@ -8145,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
|
||||
@@ -9113,6 +9173,251 @@ mod observatory_persons_field_position_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
//! Short-lived, single-use WebSocket tickets (ADR-272).
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! A browser's `WebSocket` constructor cannot set an `Authorization` header on
|
||||
//! the upgrade request. That limitation is why `/ws/sensing`,
|
||||
//! `/ws/introspection` and `/api/v1/stream/pose` have been exempt from
|
||||
//! [`crate::bearer_auth`] — which means that on a server with auth switched
|
||||
//! ON, an unauthenticated caller can still complete a WebSocket handshake to
|
||||
//! the **live sensing stream**. The REST control plane is locked; the data
|
||||
//! plane is open.
|
||||
//!
|
||||
//! A ticket closes that without pretending browsers can do something they
|
||||
//! cannot: the page makes an ordinary authenticated `POST /api/v1/ws-ticket`
|
||||
//! (a normal request, where it *can* set headers), gets an opaque string, and
|
||||
//! passes it as `?ticket=…` on the upgrade.
|
||||
//!
|
||||
//! # Why a query parameter is acceptable here, when it usually is not
|
||||
//!
|
||||
//! Putting a credential in a URL is normally a mistake: URLs land in access
|
||||
//! logs, `Referer` headers and browser history. Three properties keep this one
|
||||
//! bounded, and all three are load-bearing:
|
||||
//!
|
||||
//! 1. **Single use.** Consumed on the first upgrade attempt. A ticket in a log
|
||||
//! is already spent.
|
||||
//! 2. **Seconds, not hours.** [`TICKET_TTL`] is 30s — long enough for a page to
|
||||
//! open a socket, far too short to be worth harvesting.
|
||||
//! 3. **It is not the credential.** It authorizes one WebSocket connection.
|
||||
//! It cannot be replayed against `/api/v1/*`, cannot be refreshed, and
|
||||
//! carries no user identity a thief could reuse elsewhere.
|
||||
//!
|
||||
//! Native clients — the Python client, the Rust CLI, the TS MCP client — are
|
||||
//! **not** browsers and must send a normal `Authorization` header on the
|
||||
//! upgrade instead. Routing them through tickets would add a round-trip and a
|
||||
//! second credential path for no benefit.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rand::RngCore;
|
||||
|
||||
/// How long a ticket is valid. Deliberately tiny — a page opens its socket
|
||||
/// immediately after fetching one, so anything longer is only useful to
|
||||
/// someone who found the URL later.
|
||||
pub const TICKET_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Global cap on outstanding tickets.
|
||||
const MAX_OUTSTANDING: usize = 512;
|
||||
|
||||
/// Per-principal cap.
|
||||
///
|
||||
/// The global cap alone is not enough: one authenticated `sensing:read` caller
|
||||
/// looping on `POST /api/v1/ws-ticket` could occupy all 512 slots for 30
|
||||
/// seconds and 503 every other user — a denial of service by an ordinary,
|
||||
/// lowest-privilege account. A page needs a handful of concurrent sockets, so
|
||||
/// this is generous while making one caller unable to starve the rest.
|
||||
///
|
||||
/// Tickets issued to the legacy static token share the `None` bucket, since
|
||||
/// that credential carries no subject to attribute them to.
|
||||
const MAX_PER_PRINCIPAL: usize = 16;
|
||||
|
||||
/// What a redeemed ticket authorizes.
|
||||
///
|
||||
/// The scopes are captured at issue time from the authenticated request, so a
|
||||
/// WebSocket inherits exactly the authority of the credential that asked for
|
||||
/// it — a `sensing:read` session cannot obtain a ticket that outranks itself.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TicketGrant {
|
||||
/// Space-separated scopes held by the issuing principal, or `None` when the
|
||||
/// issuer was the legacy static token (which predates scopes and carries
|
||||
/// full authority).
|
||||
pub scopes: Option<String>,
|
||||
/// `sub` of the issuing principal, for logging. `None` for the static token.
|
||||
pub subject: Option<String>,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
grant: TicketGrant,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
/// In-memory ticket store.
|
||||
///
|
||||
/// `Debug` deliberately reports only a count, never ticket values — a ticket in
|
||||
/// a debug log is a live credential for as long as it is unspent.
|
||||
///
|
||||
/// In-memory is correct rather than merely convenient: tickets live for
|
||||
/// seconds, and a ticket surviving a restart would be a ticket outliving the
|
||||
/// server that vouched for it.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct TicketStore {
|
||||
inner: Arc<Mutex<HashMap<String, Entry>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TicketStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let n = self.inner.lock().map(|m| m.len()).unwrap_or(0);
|
||||
f.debug_struct("TicketStore").field("outstanding", &n).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TicketStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Mint a ticket for an authenticated caller.
|
||||
///
|
||||
/// Returns `None` if too many tickets are outstanding — refusing to issue
|
||||
/// is the correct failure here; the alternative is unbounded growth driven
|
||||
/// by a caller who is authenticated but misbehaving.
|
||||
pub fn issue(&self, grant: TicketGrant) -> Option<String> {
|
||||
let mut map = self.inner.lock().expect("ticket store poisoned");
|
||||
prune(&mut map);
|
||||
if map.len() >= MAX_OUTSTANDING {
|
||||
tracing::warn!(
|
||||
outstanding = map.len(),
|
||||
"refusing to issue a WebSocket ticket: global cap reached"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// Per-principal cap, so one caller cannot starve every other user.
|
||||
let held_by_this_principal = map
|
||||
.values()
|
||||
.filter(|e| e.grant.subject == grant.subject)
|
||||
.count();
|
||||
if held_by_this_principal >= MAX_PER_PRINCIPAL {
|
||||
tracing::warn!(
|
||||
subject = ?grant.subject,
|
||||
held = held_by_this_principal,
|
||||
"refusing to issue a WebSocket ticket: per-principal cap reached"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
let ticket = hex(&bytes);
|
||||
map.insert(
|
||||
ticket.clone(),
|
||||
Entry {
|
||||
grant,
|
||||
expires_at: Instant::now() + TICKET_TTL,
|
||||
},
|
||||
);
|
||||
Some(ticket)
|
||||
}
|
||||
|
||||
/// Redeem a ticket. **Removes it** — a ticket is valid exactly once, so a
|
||||
/// replay of the same URL fails even within the TTL.
|
||||
pub fn consume(&self, ticket: &str) -> Option<TicketGrant> {
|
||||
let mut map = self.inner.lock().expect("ticket store poisoned");
|
||||
prune(&mut map);
|
||||
let entry = map.remove(ticket)?;
|
||||
// Belt and braces: prune already dropped expired entries, but an entry
|
||||
// expiring between the two would otherwise slip through.
|
||||
if entry.expires_at <= Instant::now() {
|
||||
return None;
|
||||
}
|
||||
Some(entry.grant)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn outstanding(&self) -> usize {
|
||||
self.inner.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
fn prune(map: &mut HashMap<String, Entry>) {
|
||||
let now = Instant::now();
|
||||
map.retain(|_, e| e.expires_at > now);
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write;
|
||||
bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
|
||||
let _ = write!(s, "{b:02x}");
|
||||
s
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract `ticket` from a raw query string.
|
||||
fn ticket_from_query(query: &str) -> Option<String> {
|
||||
for pair in query.split('&') {
|
||||
if let Some(v) = pair.strip_prefix("ticket=") {
|
||||
if !v.is_empty() {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract `ticket` from a request URI's query, if present.
|
||||
pub fn ticket_from_uri(uri: &axum::http::Uri) -> Option<String> {
|
||||
ticket_from_query(uri.query()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn grant() -> TicketGrant {
|
||||
TicketGrant {
|
||||
scopes: Some("sensing:read".into()),
|
||||
subject: Some("user-1".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ticket_round_trips_once() {
|
||||
let store = TicketStore::new();
|
||||
let t = store.issue(grant()).expect("issued");
|
||||
assert_eq!(store.consume(&t), Some(grant()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ticket_cannot_be_used_twice() {
|
||||
// The property that makes a credential-in-a-URL tolerable: by the time
|
||||
// it reaches a log, it is spent.
|
||||
let store = TicketStore::new();
|
||||
let t = store.issue(grant()).unwrap();
|
||||
assert!(store.consume(&t).is_some(), "first use succeeds");
|
||||
assert!(store.consume(&t).is_none(), "replay must fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_ticket_is_refused() {
|
||||
let store = TicketStore::new();
|
||||
assert!(store.consume("deadbeef").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consuming_removes_the_entry_rather_than_marking_it() {
|
||||
let store = TicketStore::new();
|
||||
let t = store.issue(grant()).unwrap();
|
||||
assert_eq!(store.outstanding(), 1);
|
||||
store.consume(&t);
|
||||
assert_eq!(store.outstanding(), 0, "spent tickets must not accumulate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_ticket_is_refused_and_pruned() {
|
||||
let store = TicketStore::new();
|
||||
let t = store.issue(grant()).unwrap();
|
||||
// Force expiry without sleeping.
|
||||
{
|
||||
let mut map = store.inner.lock().unwrap();
|
||||
map.get_mut(&t).unwrap().expires_at = Instant::now() - Duration::from_secs(1);
|
||||
}
|
||||
assert!(store.consume(&t).is_none(), "expired ticket must be refused");
|
||||
assert_eq!(store.outstanding(), 0, "and must not linger");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tickets_are_unpredictable_and_distinct() {
|
||||
let store = TicketStore::new();
|
||||
let a = store.issue(grant()).unwrap();
|
||||
let b = store.issue(grant()).unwrap();
|
||||
assert_ne!(a, b);
|
||||
// 32 bytes hex — guessing is not a strategy.
|
||||
assert_eq!(a.len(), 64, "expected 256 bits of ticket");
|
||||
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grant_records_the_issuing_principals_scopes() {
|
||||
// A sensing:read session must not be able to mint a ticket that
|
||||
// outranks it — the WebSocket inherits the issuer's authority.
|
||||
let store = TicketStore::new();
|
||||
let g = TicketGrant {
|
||||
scopes: Some("sensing:read".into()),
|
||||
subject: Some("u".into()),
|
||||
};
|
||||
let t = store.issue(g.clone()).unwrap();
|
||||
assert_eq!(store.consume(&t).unwrap().scopes.as_deref(), Some("sensing:read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_principal_cannot_starve_the_global_pool() {
|
||||
// The reported DoS: an ordinary sensing:read caller looping on
|
||||
// /api/v1/ws-ticket used to be able to occupy every slot and 503
|
||||
// everyone else.
|
||||
let store = TicketStore::new();
|
||||
let noisy = TicketGrant {
|
||||
scopes: Some("sensing:read".into()),
|
||||
subject: Some("noisy-user".into()),
|
||||
};
|
||||
for _ in 0..MAX_PER_PRINCIPAL {
|
||||
assert!(store.issue(noisy.clone()).is_some());
|
||||
}
|
||||
assert!(
|
||||
store.issue(noisy).is_none(),
|
||||
"one principal must hit its own cap"
|
||||
);
|
||||
// ...and a different user is entirely unaffected.
|
||||
let other = TicketGrant {
|
||||
scopes: Some("sensing:read".into()),
|
||||
subject: Some("quiet-user".into()),
|
||||
};
|
||||
assert!(
|
||||
store.issue(other).is_some(),
|
||||
"another principal must still be served"
|
||||
);
|
||||
assert!(
|
||||
store.outstanding() < MAX_OUTSTANDING,
|
||||
"the global pool was never exhausted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issuing_is_refused_once_too_many_are_outstanding() {
|
||||
let store = TicketStore::new();
|
||||
for i in 0..MAX_OUTSTANDING {
|
||||
// Distinct subjects, so this exercises the GLOBAL cap and not the
|
||||
// per-principal one.
|
||||
let g = TicketGrant {
|
||||
scopes: Some("sensing:read".into()),
|
||||
subject: Some(format!("user-{i}")),
|
||||
};
|
||||
assert!(store.issue(g).is_some());
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.issue(TicketGrant {
|
||||
scopes: Some("sensing:read".into()),
|
||||
subject: Some("one-more".into())
|
||||
})
|
||||
.is_none(),
|
||||
"the global cap must still hold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_tickets_free_capacity_again() {
|
||||
let store = TicketStore::new();
|
||||
for i in 0..MAX_OUTSTANDING {
|
||||
store.issue(TicketGrant {
|
||||
scopes: Some("sensing:read".into()),
|
||||
subject: Some(format!("user-{i}")),
|
||||
});
|
||||
}
|
||||
assert!(store
|
||||
.issue(TicketGrant {
|
||||
scopes: Some("sensing:read".into()),
|
||||
subject: Some("blocked".into())
|
||||
})
|
||||
.is_none());
|
||||
{
|
||||
let mut map = store.inner.lock().unwrap();
|
||||
for e in map.values_mut() {
|
||||
e.expires_at = Instant::now() - Duration::from_secs(1);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
store.issue(grant()).is_some(),
|
||||
"the cap must be self-healing, not a permanent wedge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_ticket_from_a_query_string() {
|
||||
assert_eq!(ticket_from_query("ticket=abc123").as_deref(), Some("abc123"));
|
||||
assert_eq!(
|
||||
ticket_from_query("foo=1&ticket=abc123&bar=2").as_deref(),
|
||||
Some("abc123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_or_empty_ticket_parameter_yields_none() {
|
||||
assert!(ticket_from_query("foo=1").is_none());
|
||||
assert!(ticket_from_query("ticket=").is_none());
|
||||
assert!(ticket_from_query("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parameter_merely_ending_in_ticket_is_not_a_ticket() {
|
||||
// `?myticket=x` must not be read as `?ticket=x`.
|
||||
assert!(ticket_from_query("myticket=abc").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Boots the REAL `sensing-server` binary and probes BOTH listeners.
|
||||
//!
|
||||
//! # Why this test exists
|
||||
//!
|
||||
//! Two authentication bypasses shipped in the ADR-272 work, and 526 green unit
|
||||
//! tests could not see either, because every auth test in this crate builds its
|
||||
//! OWN `Router` with a hand-picked subset of routes. A synthetic router can
|
||||
//! never observe how the real one is assembled — and both defects were assembly:
|
||||
//!
|
||||
//! 1. The dedicated WebSocket listener (`--ws-port`) was constructed with only
|
||||
//! host validation. `require_bearer` was never applied to it at all. That is
|
||||
//! the port the shipped UI actually connects to
|
||||
//! (`ui/services/sensing.service.js` maps HTTP 8080 -> WS 8765), so the
|
||||
//! earlier fix protected a path the browser never takes.
|
||||
//! 2. `/ws/field` was `.merge()`d AFTER the auth layer on the HTTP router. In
|
||||
//! axum a layer wraps only what is already registered, so merging afterwards
|
||||
//! silently exempts those routes.
|
||||
//!
|
||||
//! Both were found by adversarial review, not by the suite. This test closes
|
||||
//! that gap: it runs the actual binary, so it sees the actual wiring.
|
||||
//!
|
||||
//! It deliberately asserts on **ports and transports**, not on handler logic —
|
||||
//! handler behaviour is covered by the unit suites. What is unique here is that
|
||||
//! nothing is synthetic: real process, real listeners, real TCP.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const TOKEN: &str = "integration-test-secret";
|
||||
|
||||
/// Reserve a port by binding and immediately releasing it.
|
||||
///
|
||||
/// Mildly racy, which is why each test reserves its own set and the server is
|
||||
/// given several seconds to come up: a collision surfaces as a boot failure,
|
||||
/// not as a false pass.
|
||||
fn free_port() -> u16 {
|
||||
let l = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral");
|
||||
let p = l.local_addr().unwrap().port();
|
||||
drop(l);
|
||||
p
|
||||
}
|
||||
|
||||
struct Server {
|
||||
child: Child,
|
||||
http: u16,
|
||||
ws: u16,
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Spawn the real binary. `env` lets a test choose the auth configuration.
|
||||
///
|
||||
/// PANICS rather than returning `None` on failure. This used to return an
|
||||
/// `Option` that every test turned into `return`, which meant all five
|
||||
/// assertions were skipped precisely when the server was broken — including
|
||||
/// broken BY an auth change. A boot failure printed one line that `cargo
|
||||
/// test` swallows without `--nocapture` and reported `5 passed`. The only
|
||||
/// test in the suite that observes real wiring disarmed itself exactly when
|
||||
/// it mattered; a boot-time panic in the auth path would have shipped green.
|
||||
fn start(env: &[(&str, &str)]) -> Self {
|
||||
// `free_port()` releases the port before the child binds it, so under
|
||||
// parallel `cargo test` execution another test's server can grab the
|
||||
// same ephemeral port first (observed as `Os { code: 10048/98, kind:
|
||||
// AddrInUse }` on the child's actual bind, distinct from a genuine
|
||||
// auth-wiring break). Retry a bounded number of times on THAT specific
|
||||
// signature only — any other boot failure (including a real wiring
|
||||
// regression) still panics on the first attempt, preserving the
|
||||
// fail-loud property described above.
|
||||
const MAX_ATTEMPTS: u32 = 3;
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
let (http, ws, udp) = (free_port(), free_port(), free_port());
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_sensing-server"));
|
||||
cmd.args([
|
||||
"--http-port", &http.to_string(),
|
||||
"--ws-port", &ws.to_string(),
|
||||
"--udp-port", &udp.to_string(),
|
||||
"--bind-addr", "127.0.0.1",
|
||||
"--no-edge-registry",
|
||||
"--source", "simulate",
|
||||
])
|
||||
// Inherit nothing auth-related from the developer's shell, or a local
|
||||
// RUVIEW_* export would silently change what this test proves.
|
||||
.env_remove("RUVIEW_API_TOKEN")
|
||||
.env_remove("RUVIEW_OAUTH_ISSUER")
|
||||
.env_remove("RUVIEW_WS_LEGACY_UNAUTHENTICATED")
|
||||
.stdout(Stdio::null())
|
||||
// Captured, not discarded: if the server dies at boot, its stderr is the
|
||||
// only thing that says why, and the panic below reproduces it.
|
||||
.stderr(Stdio::piped());
|
||||
for (k, v) in env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let mut child = cmd.spawn().expect("spawn sensing-server");
|
||||
if await_ready(http, ws) {
|
||||
return Server { child, http, ws };
|
||||
}
|
||||
let mut err = String::new();
|
||||
if let Some(mut s) = child.stderr.take() {
|
||||
let _ = s.read_to_string(&mut err);
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let looks_like_port_collision =
|
||||
err.contains("AddrInUse") || err.contains("Address already in use")
|
||||
|| err.contains("code: 10048") || err.contains("code: 98");
|
||||
if looks_like_port_collision && attempt < MAX_ATTEMPTS {
|
||||
continue;
|
||||
}
|
||||
panic!(
|
||||
"sensing-server did not become ready on :{http} (http) and :{ws} (ws) \
|
||||
within 30s (attempt {attempt}/{MAX_ATTEMPTS}). This is a FAILURE, not a skip — \
|
||||
the wiring assertions below cannot run, and a boot-time break in the auth path \
|
||||
is exactly what they exist to catch.\n\
|
||||
--- server stderr ---\n{err}"
|
||||
);
|
||||
}
|
||||
unreachable!("loop always returns or panics");
|
||||
}
|
||||
}
|
||||
|
||||
fn await_ready(http: u16, ws: u16) -> bool {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while Instant::now() < deadline {
|
||||
if TcpStream::connect(("127.0.0.1", http)).is_ok()
|
||||
&& TcpStream::connect(("127.0.0.1", ws)).is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// One raw HTTP/1.1 request; returns the status code.
|
||||
fn status(port: u16, method: &str, path: &str, headers: &[(&str, &str)]) -> u16 {
|
||||
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
||||
let mut s = TcpStream::connect(addr).expect("connect");
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let mut req = format!("{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n");
|
||||
for (k, v) in headers {
|
||||
req.push_str(&format!("{k}: {v}\r\n"));
|
||||
}
|
||||
req.push_str("Connection: close\r\n\r\n");
|
||||
s.write_all(req.as_bytes()).expect("write");
|
||||
|
||||
let mut line = String::new();
|
||||
BufReader::new(&mut s).read_line(&mut line).expect("status line");
|
||||
line.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|c| c.parse().ok())
|
||||
.unwrap_or_else(|| panic!("unparseable status line: {line:?}"))
|
||||
}
|
||||
|
||||
/// A genuine WebSocket upgrade. 101 means the connection was ACCEPTED.
|
||||
fn ws_upgrade(port: u16, path: &str, bearer: Option<&str>) -> u16 {
|
||||
let mut headers: Vec<(&str, &str)> = vec![
|
||||
("Upgrade", "websocket"),
|
||||
("Connection", "Upgrade"),
|
||||
("Sec-WebSocket-Version", "13"),
|
||||
("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="),
|
||||
];
|
||||
let auth;
|
||||
if let Some(b) = bearer {
|
||||
auth = format!("Bearer {b}");
|
||||
headers.push(("Authorization", &auth));
|
||||
}
|
||||
// Not `Connection: close` — that would contradict the upgrade.
|
||||
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
||||
let mut s = TcpStream::connect(addr).expect("connect");
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let mut req = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n");
|
||||
for (k, v) in &headers {
|
||||
req.push_str(&format!("{k}: {v}\r\n"));
|
||||
}
|
||||
req.push_str("\r\n");
|
||||
s.write_all(req.as_bytes()).expect("write");
|
||||
|
||||
let mut buf = [0u8; 256];
|
||||
let n = s.read(&mut buf).expect("read");
|
||||
let head = String::from_utf8_lossy(&buf[..n]);
|
||||
let line = head.lines().next().unwrap_or_default();
|
||||
line.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|c| c.parse().ok())
|
||||
.unwrap_or_else(|| panic!("unparseable status line: {line:?}"))
|
||||
}
|
||||
|
||||
/// Every WebSocket path, on every listener. This list is the point of the test.
|
||||
const WS_PATHS: &[&str] = &["/ws/sensing", "/ws/introspection", "/api/v1/stream/pose", "/ws/field"];
|
||||
|
||||
/// Non-WebSocket routes that carry sensing data and must be gated.
|
||||
///
|
||||
/// `/api/field` is here because it was NOT gated: it serves the same signed
|
||||
/// `FieldEvent` stream as `/ws/field`, but sits outside `/api/v1/`, and the gate
|
||||
/// protected `/api/v1/*` by prefix. `/ws/field` was gated in this PR and its
|
||||
/// REST twin, one path segment over, returned 200 to an anonymous caller on
|
||||
/// both listeners. That is why the gate is now deny-by-default.
|
||||
const PROTECTED_REST_PATHS: &[&str] = &["/api/field", "/api/v1/models"];
|
||||
|
||||
#[test]
|
||||
fn with_auth_on_no_listener_serves_sensing_data_anonymously() {
|
||||
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
|
||||
|
||||
for (label, port) in [("http", server.http), ("ws", server.ws)] {
|
||||
for path in PROTECTED_REST_PATHS {
|
||||
assert_eq!(
|
||||
status(port, "GET", path, &[]),
|
||||
401,
|
||||
"{label} port served {path} to an anonymous caller"
|
||||
);
|
||||
// And the credential must actually work, or the assertion above
|
||||
// could pass because the route simply does not exist.
|
||||
let ok = status(port, "GET", path, &[("Authorization", &format!("Bearer {TOKEN}"))]);
|
||||
assert_ne!(ok, 401, "{label} port {path} rejected a VALID bearer");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dashboard_shell_and_sign_in_stay_reachable_when_auth_is_on() {
|
||||
// Deny-by-default must not lock the user out of the page that renders the
|
||||
// sign-in button, or of sign-in itself. This is the other half of the
|
||||
// allowlist: too tight is as broken as too loose, just louder.
|
||||
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
|
||||
for path in ["/health", "/oauth/status"] {
|
||||
assert_ne!(
|
||||
status(server.http, "GET", path, &[]),
|
||||
401,
|
||||
"{path} must stay anonymous — sign-in depends on it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_auth_on_no_listener_accepts_an_unauthenticated_websocket() {
|
||||
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
|
||||
|
||||
// Control first: if REST is not gated, the server is misconfigured and the
|
||||
// WebSocket assertions below would pass for the wrong reason.
|
||||
assert_eq!(
|
||||
status(server.http, "GET", "/api/v1/models", &[]),
|
||||
401,
|
||||
"REST must be gated, or this test proves nothing"
|
||||
);
|
||||
|
||||
for &port_label in &["http", "ws"] {
|
||||
let port = if port_label == "http" { server.http } else { server.ws };
|
||||
for path in WS_PATHS {
|
||||
let code = ws_upgrade(port, path, None);
|
||||
assert_ne!(
|
||||
code, 101,
|
||||
"{port_label} port ACCEPTED an unauthenticated upgrade to {path} — \
|
||||
this is the bypass that shipped twice"
|
||||
);
|
||||
assert_eq!(
|
||||
code, 401,
|
||||
"{port_label} port {path} should refuse with 401, got {code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bearer_on_the_upgrade_is_accepted_on_both_listeners() {
|
||||
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
|
||||
// Native clients (Python, CLI, MCP) are not browser-constrained and must be
|
||||
// able to authenticate a WebSocket without the ticket round-trip.
|
||||
for (label, port) in [("http", server.http), ("ws", server.ws)] {
|
||||
assert_eq!(
|
||||
ws_upgrade(port, "/ws/sensing", Some(TOKEN)),
|
||||
101,
|
||||
"{label} port must accept a valid bearer on the upgrade"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_auth_off_both_listeners_stay_open() {
|
||||
// The compatibility promise: an unconfigured deployment sees no change.
|
||||
let server = Server::start(&[]);
|
||||
assert_eq!(status(server.http, "GET", "/api/v1/models", &[]), 200);
|
||||
for (label, port) in [("http", server.http), ("ws", server.ws)] {
|
||||
assert_eq!(
|
||||
ws_upgrade(port, "/ws/sensing", None),
|
||||
101,
|
||||
"{label} port must stay open when no credential is configured"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legacy_escape_hatch_opens_websockets_without_weakening_rest() {
|
||||
let server = Server::start(&[
|
||||
("RUVIEW_API_TOKEN", TOKEN),
|
||||
("RUVIEW_WS_LEGACY_UNAUTHENTICATED", "1"),
|
||||
]);
|
||||
// The hatch is scoped to WebSockets on purpose. If it ever widened to REST
|
||||
// it would be a bypass wearing a migration label.
|
||||
assert_eq!(
|
||||
status(server.http, "GET", "/api/v1/models", &[]),
|
||||
401,
|
||||
"the escape hatch must not weaken REST"
|
||||
);
|
||||
for (label, port) in [("http", server.http), ("ws", server.ws)] {
|
||||
assert_eq!(
|
||||
ws_upgrade(port, "/ws/sensing", None),
|
||||
101,
|
||||
"{label} port should be open while the hatch is set"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_stays_anonymous_on_both_listeners() {
|
||||
// Documented exemption (ADR-272): orchestrator probes are anonymous by
|
||||
// design. Pinned so it is a decision, not an accident nobody re-checks.
|
||||
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
|
||||
for (label, port) in [("http", server.http), ("ws", server.ws)] {
|
||||
assert_eq!(
|
||||
status(port, "GET", "/health", &[]),
|
||||
200,
|
||||
"{label} port /health must remain anonymous"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user