Compare commits

..
11 Commits
Author SHA1 Message Date
rUv 2c249ec8cb docs(adr): record Cognitum Spaces production evidence (#1639)
* docs(adr): record Cognitum Spaces production evidence

* docs(adr): link deferred Spaces milestones
2026-08-18 15:59:00 -04:00
rUv 7927839f4f feat: add governed Cognitum Spaces activation (#1631)
Add explicit spaces:read consent, a bounded read client and CLI surface, and ADR-325's privacy, memory, and action-governance contract.

Closes #1630.
2026-08-18 15:21:48 -04:00
rUv a76adc3c2f Merge pull request #1624 from ruvnet/claude/off-axis-sneaker-ruview-u398g0
ADR-324: off-axis-mode — clean-room Kooima projection in Rust/WASM + demo
2026-08-16 21:02:12 -04:00
Claudeandclaude-flow aae2ed5345 chore: refresh scheduled_tasks session lock
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BU3NcEgTpAVvu5QGtw4czT
2026-08-16 23:53:55 +00:00
Claudeandclaude-flow 3161af52da feat(offaxis): clean-room Kooima off-axis projection in Rust/WASM (ADR-324)
New leaf crate v2/crates/ruview-offaxis implementing ADR-324's projection
core from the published math only (Kooima 2008 generalized perspective
projection; Casiez 2012 one-euro filter) — no code from the unlicensed
prior-art repo. Dependency-free native core; wasm-bindgen surface gated to
wasm32 (cdylib+rlib, ~54 KB wasm after bindgen).

- projection: Screen (3 corners, any orientation) + off_axis() -> typed
  errors, never NaN matrices; column-major f64 (three.js Matrix4 layout).
  Tests pin the defining invariants: screen corners -> NDC corners for a
  grid of eye positions and tilted screens; screen-plane points are
  eye-invariant; centered eye reduces to the symmetric frustum; near/far
  map to NDC -1/+1.
- filter: one-euro with injected timestamps (no clock in crate);
  monotonicity/convergence/NaN-rejection tests.
- rf: field-peak extraction mirroring field_localize.rs constants
  (X_SCALE 0.6, Z_SCALE 0.5, PEAK_THRESHOLD 0.35) and the Tier B
  coarse-parallax stage (deadband, gain, hard clamp) so over-claiming is
  impossible at the API level. No accuracy numbers asserted.
- wasm: OffAxisCamera + RfParallax bindgen classes; per-frame updates hold
  last good state instead of throwing.
- benches (criterion): full Tier B frame ~598 ns; argmax scan optimized
  -18%/-33% (20x20/100x100). MEASURED table + reproducer in README.
- examples/three.js/demos/07-off-axis-window.html: demo with SYNTHETIC
  mouse simulator and labeled RF Tier B mode ('coarse body parallax - not
  head tracking'), physical calibration panel, /ws/sensing input, and a
  build-instructions overlay when the local pkg/ output is missing
  (generated artifacts stay uncommitted; .gitignore entry added).
- Validated 23 unit tests + doctest, clippy clean, wasm32 release build,
  Node smoke test of the bindgen output, and a headless-Chromium run of
  the demo (engine load, eye response, mode labels, ws failure path).
- ADR-324: header + section 2.5 amendment recording the Rust/WASM core.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BU3NcEgTpAVvu5QGtw4czT
2026-08-16 23:52:43 +00:00
Claudeandclaude-flow 501f138360 docs(adr): ADR-324 off-axis head-coupled perspective demo (RF-assisted)
Deep-research ADR answering whether icurtis1/off-axis-sneaker can be used
with RuView. Adopts the head-coupled perspective technique (Kooima
generalized off-axis projection) via a clean-room implementation — the
upstream repo is unlicensed, so no code or assets are reused. Defines a
tiered integration: webcam-fine tracking with RF presence gating and
multi-person arbitration (Tier A), an explicitly labeled RF-only coarse
body-parallax mode (Tier B), and evidence-gated future metric RF head
positioning (Tier C). No server changes; existing /ws/sensing and
/api/v1/stream/pose streams only. Indexed in the ADR README.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BU3NcEgTpAVvu5QGtw4czT
2026-08-16 23:20:11 +00:00
rUv 4685618388 fix: remediate vitals, desktop, and repository integrity issues (#1618)
Fix breathing confidence, Windows desktop launch and clean UI builds, contributor-harness line-ending integrity, runtime secret hygiene, and remove the six ADR-299 CSI/person-data files from the current tree.
2026-08-15 20:09:43 -04:00
rUv 1d50518a70 Merge pull request #1617 from ruvnet/feat/adr-323-pose-physics
feat: add physics-constrained pose refinement
2026-08-15 17:08:02 -04:00
ruv 0370d49e4a feat: add physics-constrained pose refinement 2026-08-15 14:29:07 -04:00
rUv de27336fa1 Merge pull request #1588 from ruvnet/fix/issue-triage-batch-1
fix: issue triage batch (#1521-1557) + wire the certificate spine together
2026-08-11 19:37:31 -04:00
ruv 73e82313ac fix: issue triage batch (#1521-1557) + wire the certificate spine together
Reviews and fixes RuView's 10 most recent substantive issues, plus closes
the wiring gap flagged in the perception-substrate review (docs/adr,
gist, release notes from the PR #1579 review).

## Fixed

- #1526: UI falsely claimed "LIVE — ESP32 Hardware Connected" whenever the
  unauthenticated /api/v1/status probe 401'd. Now sends the bearer token
  on the probe (the fallback direction was already fixed by ADR-295).
- #1554: top-level `classification` (GET /api/v1/sensing/latest) was the
  last UDP packet's single node, not the fused room aggregate, so it
  flapped at packet rate with 2+ disagreeing nodes. Now derived from
  `RoomInference` (ADR-297's `fuse_room`) at both call sites.
- #1541: per-node MQTT `presence`/`presence_score` read a `classification`
  JSON key that does not exist on `NodeInfo` (the real field is
  `node_inference`), silently falling back to the room aggregate for
  every node — reproducing the exact "lockstep publish" the field report
  measured. Also fixed the *existing* regression test for this bug,
  which used the same wrong key in its own fixture and so never caught it.
- #1557: pose-fusion's `wsPortMap` only knew port 3000, so a remapped host
  port fell through to `localhost:8765` (nothing there for a remote
  viewer). WS port is now derived from `location.port` instead of a
  2-entry lookup table (the "never render simulated as live" half was
  already fixed by ADR-295's `onVerifiedFrame` gate).
- #1525: the no-model pose path already clamps keypoint confidence to a
  0.1 floor, but the renderer's own threshold is also 0.1 compared with
  `<=`/`>` — a keypoint at exactly the floor was still invisible. Floor
  raised to 0.15 to clear the client's gate.
- #1556: `--mqtt-ca-file`/`--mqtt-client-cert`/`--mqtt-client-key` were
  parsed and stored but never applied — TLS always used the system trust
  store, so a self-signed broker always failed UnknownIssuer. Now builds
  `rumqttc::TlsConfiguration::Simple` from the real PEM files (no new TLS
  dependency needed). A file that can't be read logs why and falls back
  to system trust instead of failing opaquely later.
- #1555: the MQTT availability heartbeat asserted "online" for every known
  node on a fixed 30s timer regardless of whether that node's data was
  still arriving. Now tracks each node's last-seen broadcast snapshot and
  only reports "online" within a 10s freshness window, otherwise
  "offline" — a frozen sensor can no longer look available. (The other
  half — restarting a publisher that goes permanently silent — needs a
  reproduction the reporter themselves weren't certain of; left for a
  follow-up rather than guessing at the trigger.)
- #1540: already fixed on main (node-keyed RateLimiter, ADR-297).
- #1521/#1522: not fixable in this repo (published HF model artifact);
  replied with the ADR-298 gate status and the exact byte-level fix for
  the safetensors header, and corrected the README row that claimed the
  file loads with the reference loader.
- #1542, #1527: replied — #1542 is a real, larger firmware+server feature
  left open for follow-up; #1527's suggested fixes were already applied,
  the one residual sample is an inherent first-frame paint gap.

## Certificate spine wiring (closes the gap flagged in the PR #1579 review)

`ruview-certify` and `ruview-policy` now depend on `ruview-ood` and provide
real `From<ruview_ood::DomainState>` adapters plus a composed entry point,
`ruview_policy::authorize_from_certificate`, matching the adapter contract
`ruview-policy`'s own doc comment already described but that no code
actually implemented. A new cross-crate integration test
(`acceptance_test_b_real_integration`) mints a real signed
`CapabilityCertificate` and proves a real post-drift `ruview_ood::Unknown`
denies a `SafetyCritical` action through the composed pipeline — not two
disconnected unit tests hand-setting the same enum value.

Also:
- Wires `evaluate_linear_head` (ADR-298 model-release gate) into a new CI
  job so the checker itself can't silently regress; documents that gating
  an actual model publish is still a manual step (no HF automation here).
- Wires `SourceState::export_watermark()` into `start_recording`: a
  recording captured while the source is synthetic is now stamped in its
  metadata (not the filename or per-line JSON, to avoid breaking
  `delete_recording`'s path reconstruction or the training dataset
  loader's schema).
- Updates docs/user-guide.md's "Developer Preview" section to describe
  what's now genuinely wired vs. still not (no live continuous
  calibration/OOD loop in the running server yet).

## Validation

- cargo test --workspace --no-default-features: 4391 passed, 0 failed
- cargo build --release -p wifi-densepose-sensing-server --features mqtt:
  clean
- Server smoke-tested end-to-end against the simulator (real startup,
  UDP/WS/HTTP listeners, /api/v1/sensing/latest stable across calls)
- Real ESP32-S3 hardware was NOT reachable this session (no COM port
  present, zero UDP frames received after 40s bound to 0.0.0.0:5005) —
  the multi-node fixes are validated by the new unit/integration tests
  and full-workspace regression, not by live hardware.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-08-11 19:08:54 -04:00
125 changed files with 14059 additions and 151506 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"sessionId":"d80c93c2-51b7-42e8-a0fc-dc47cff1200f","pid":45748,"acquiredAt":1779668018388}
{"sessionId":"905385c4-b13f-5091-96df-5752fb109cf5","pid":509,"procStart":"527","acquiredAt":1786922977672}
+3
View File
@@ -0,0 +1,3 @@
# The contributor harness hashes provenance inputs byte-for-byte. Keep text
# files in this boundary on LF even when Windows enables core.autocrlf.
harness/ruview/** text=auto eol=lf
+1 -1
View File
@@ -204,7 +204,7 @@ jobs:
node-version: '22'
- name: Run UI unit tests
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
# Unit and Integration Tests
# Python pytest matrix — runs against the archived v1 Python tree.
+67
View File
@@ -0,0 +1,67 @@
name: Model release gate (ADR-298)
# ADR-298 model-release sanity gates (issue #1521): structural checks that
# block a degenerate/mislabeled classifier head (unreachable decision
# boundary, near-constant output, degenerate class balance, a metric
# surfaced under a task name it wasn't computed as) before it ships.
#
# Checker: v2/crates/wifi-densepose-train/src/model_gates.rs
#
# IMPORTANT — the honest scope of this job: it protects the *checker itself*
# from regressing (the gate logic + its issue-1521 regression fixture are
# exercised on every push/PR that touches this crate), and running it is
# required before ADR-298 can be called "wired in" at all. It does NOT gate
# an actual model publish — this repository does not automate uploading to
# the HuggingFace model repo (`ruvnet/wifi-densepose-pretrained`); that
# remains a manual, human-run step. Before publishing or replacing a model
# artifact there, run this gate against the real head weights locally:
#
# cargo test -p wifi-densepose-train model_gates
#
# and, until a CLI entry point exists to run `evaluate_linear_head` against an
# arbitrary `.safetensors`/`.rvf` file, load the head's `weight`/`bias` in a
# short script and call `wifi_densepose_train::evaluate_linear_head` directly.
on:
push:
branches:
- main
- master
paths:
- "v2/crates/wifi-densepose-train/**"
pull_request:
paths:
- "v2/crates/wifi-densepose-train/**"
workflow_dispatch:
permissions:
contents: read
jobs:
model-release-gate:
name: Model release gate check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
submodules: recursive
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- name: Run the model-release gate's own test suite
working-directory: v2
run: cargo test -p wifi-densepose-train --no-default-features model_gates -- --nocapture
- name: Summarize result
if: always()
run: |
{
echo '### Model release gate (ADR-298)'
echo ''
echo 'This job protects `model_gates.rs` from regressing. It does not itself'
echo 'gate a real HuggingFace model publish — that upload is a manual step'
echo 'outside this repository; run `cargo test -p wifi-densepose-train model_gates`'
echo 'against real head weights before publishing one.'
} >> "$GITHUB_STEP_SUMMARY"
+7
View File
@@ -303,4 +303,11 @@ 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/
# The server also writes this secret when launched from v2/. Keep the rule
# file-specific so tracked datasets below v2/data remain visible.
/v2/data/session-secret
*.proptest-regressions
# ADR-324: wasm-bindgen output for ruview-offaxis is generated locally
# (see the crate README); never commit generated artifacts.
v2/crates/ruview-offaxis/pkg/
+1
View File
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`archive/v1` (the original pure-Python implementation) formally deprecated (ADR-187)** — commits `1fb5397dd`, `b1417fb6e`; refs #509, #1125. Added `archive/v1/DEPRECATED.md` (a loud tombstone) and a `> ⚠️ DEPRECATED` notice atop `archive/v1/README.md`, both pointing at the maintained `v2/` workspace and the `wifi-densepose 2.x` / `ruview` pip wheel (ADR-117). Records the honest fact behind #509: `archive/v1`'s `DensePoseHead` is **architecture-only** — random `kaiming_normal_` init with **zero committed checkpoints** under `archive/v1/` (MEASURED by Glob over `**/*.{pth,onnx,safetensors,pt,ckpt,bin}`). The ADR-028 deterministic proof `archive/v1/data/proof/verify.py` stays live and is explicitly out of scope. The same effort added a **"Model weights: what's real, what's not" three-tier table** to `README.md` + `docs/user-guide.md`, separating real-and-validated checkpoints (presence 82.3% held-out temporal-triplet, MM-Fi pose 82.69% torso-PCK@20, `count_v1`) from the real-but-weak on-device `pose_v1` (PCK@20 = 3.0%, runtime `confidence=0` stub, below the ADR-079 ≥35% target) from the architecture-only `archive/v1` head — and caveated every live single-ESP32 17-keypoint advertisement accordingly. Docs/labeling only; no code or model behavior changed.
### Fixed
- **Pose-vitals, desktop, and repository-integrity issue remediation.** Breathing confidence now measures periodic autocorrelation at the estimated respiratory frequency instead of penalizing clean sinusoidal signals via crest factor (#1610). The desktop launcher resolves the Windows `.exe`, uses `where` for PATH lookup, and passes log filtering through `RUST_LOG`; its React versions, Vite type declarations, and Tauri UI hook working directories are aligned (#1516, #1517, #1518). Runtime session secrets written from `v2/` are ignored, and contributor-harness provenance inputs are pinned to LF across Windows checkouts (#1519, #1520). With explicit owner authorization, the six raw CSI/person-data capture and metadata files identified by ADR-299 were removed from the current tree; historical copies remain pending separately coordinated incident response.
- **`docs/huggingface/MODEL_CARD.md` had drifted from the model card actually published on the Hub (issue #1481).** Every filename in its "Files in this repo" table (`pretrained-encoder.onnx`, `pretrained-heads.onnx`, `pretrained.rvf`, `room-profiles.json`) pointed at files never uploaded to `ruvnet/wifi-densepose-pretrained` — only `config.json` existed. Replaced the in-repo card with the content actually live on the Hub (`model.safetensors`, `model-q{2,4,8}.bin`, `node-{1,2}.json`, `presence-head.json`, `csi-embed-v2.*`, honest v1→v2 retraction of the single-class "100%" presence claim) and added a "Using with the Rust sensing server (RVF conversion)" section documenting the `--convert-model`/`--convert-out` and `--model` auto-convert paths that neither card previously mentioned.
- **`--convert-model` failed on the published `model.safetensors`: NUL-padded safetensors header rejected by strict JSON parse (issue #1480, #894 follow-up).** The reference safetensors format pads its JSON header to an 8-byte boundary with trailing NUL bytes; `safetensors_to_rvf` (`wifi-densepose-sensing-server/src/model_format.rs`) fed the full declared-length header slice straight to `serde_json::from_slice`, which rejects the padding as "trailing characters." Since the only published full-precision weight file exercises this padding, `--convert-model` could not convert it at all. Fixed by trimming trailing NUL/whitespace bytes before parsing. Pinned by `safetensors_nul_padded_header_converts` (a header padded to the 8-byte boundary, matching the real HF file, converts and round-trips its weights through `ProgressiveLoader`).
- **In-server training reconnected — "Start Training" no longer silently no-ops; `/ws/train/progress` streams real progress (ADR-186, issue #1233).** The dashboard's Start Training button POSTed a config, got `success:true`, and nothing happened: `/api/v1/train/start` was a stub that flipped a status string and logged one line, and `/ws/train/progress` 404'd. The full pure-Rust trainer in `training_api.rs` (loads recorded CSI, gradient-descent, exports a `.rvf`) already existed but was **orphaned** — never declared as a module (no `mod training_api;`), so it wasn't compiled at all. Fix (`wifi-densepose-sensing-server`): declared the module, reconciled `AppStateInner` (replaced the `training_status`/`training_config` stub fields with a shared `TrainingState` status handle + cooperative cancel flag + a `training_progress_tx` broadcast), deleted the stub handlers, and merged the real `training_api::routes()` (so `/api/v1/train/{start,stop,status,pretrain,lora}` and `/ws/train/progress` resolve under the existing `/api/v1/*` bearer gate). The training core was decoupled from the ~60-field server state so it is unit-testable. **P5 honesty guarantee:** with `RUVIEW_DISABLE_SERVER_TRAINING` set, start returns a structured `{enabled:false, cli:"wifi-densepose train-room"}` HTTP 409 — never a silent success — and the dashboard disables the Start buttons with a CLI tooltip (enablement is surfaced on `/api/v1/train/status`). Pinned by 8 new tests incl. a **live-socket** test that completes a genuine 101 WebSocket handshake and receives a real progress frame after a POST start, a full POST→poll-status→`.rvf`-exists round-trip, a path-traversal rejection, cancellation, and the disabled-409 path. `cargo test -p wifi-densepose-sensing-server -p wifi-densepose-train --no-default-features` — 0 failed.
+1 -1
View File
@@ -223,7 +223,7 @@ huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/wif
| Consumer | Format used | Status |
|----------|-------------|--------|
| Python training / evaluation / embedding extraction | `model.safetensors` | ✅ Works — load with `safetensors.torch.load_file` |
| Python training / evaluation / embedding extraction | `model.safetensors` | ⚠️ The published file's header is NUL-padded, which the reference `safetensors.torch.load_file` rejects (issue [#1522](https://github.com/ruvnet/RuView/issues/1522)) — pending a corrected re-upload. `csi-embed-v2.safetensors` in the same repo is unaffected and loads normally. |
| Inspect / re-export the bundle | `model.rvf.jsonl` (line-by-line JSON) | ✅ Works — plain JSONL |
| Sensing-server `--model <PATH>` flag | native RVF, `model.safetensors`, or `model.rvf.jsonl` | ✅ Native RVF loads directly; safetensors and JSONL auto-convert in memory |
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,15 +0,0 @@
{
"id": "pretrain-1775182186",
"name": "pretrain-1775182186",
"label": "mixed-activity",
"started_at": "2026-04-03T02:09:46Z",
"ended_at": "2026-04-03T02:11:46Z",
"duration_secs": 120,
"frame_count": 5783,
"file_size_bytes": 2580539,
"file_path": "data/recordings\\pretrain-1775182186.csi.jsonl",
"nodes": {
"2": 2886,
"1": 2897
}
}
@@ -1,6 +1,6 @@
# ADR-299: Repository CSI data-incident controls — ignore rules and a pre-commit/CI policy check
- **Status**: Accepted — controls implemented; tree remediation gated on owner sign-off
- **Status**: Accepted — controls and current-tree remediation implemented; history coordination pending
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: privacy, data-governance, ci, security, incident
@@ -27,20 +27,20 @@ formatting nit.
or present as tracked files, with a message pointing here. Tests may use
only synthetic or expressly-consented minimal fixtures.
**Explicitly gated on data-owner sign-off (NOT done autonomously):**
**Owner-authorized current-tree remediation (2026-08-15):**
- Removing the existing recordings from the tree, and any history rewrite, are
outward-facing/destructive and require the data owner to first establish
provenance, consent, purpose, retention authority, and redistribution
rights. The review is correct that rewriting `origin` does not erase forks
and clones; coordination is required. This ADR records the controls and the
required follow-up; it does not delete the data.
- The data owner authorized removal of the six known CSI capture and metadata
files from the current tree. The removal is recoverable from Git history and
does not claim to erase existing clones, forks, caches, or release artifacts.
- Any history rewrite remains a separate coordinated incident-response action.
It requires an inventory of affected refs and releases, downstream notice,
credential and artifact review, and an explicit execution plan.
## Consequences
- No new CSI captures can be committed (ignore + policy check).
- The existing tracked recordings remain until the owner decides; the incident
is documented and the guard prevents worsening it.
- The six known tracked recordings are absent from the current tree. Historical
copies remain until a separately authorized and coordinated history rewrite.
- CI gains one fast policy job; contributors get a local pre-commit check.
## Validation
@@ -48,3 +48,5 @@ formatting nit.
- Policy-check unit tests: a staged `*.csi.jsonl` fails; a synthetic fixture
under an allowed test path passes; the check is deterministic and offline.
- Manual confirmation that the new ignore globs cover both active directories.
- `bash scripts/csi-data-policy-check.sh --tracked` passes after the authorized
current-tree removal.
@@ -0,0 +1,364 @@
# ADR-323: Native Rust physics-constrained pose refinement
- **Status**: Proposed
- **Date**: 2026-08-15
- **Deciders**: ruv
- **Owners**: RuView perception and edge runtime maintainers
- **Tags**: pose, physics, rust, uncertainty, provenance, abstention, edge
- **Numbering note**: ADR-323 is the next free number in the authoring checkout. Re-run the ADR index/collision check immediately before merge and rename if needed.
- **Extends**: ADR-020, ADR-027, ADR-079, ADR-101, ADR-135, ADR-145, ADR-150, ADR-273, ADR-279, ADR-282, ADR-295, ADR-296, ADR-297, ADR-298, ADR-302, ADR-303, ADR-304, ADR-305, ADR-306
- **Supersedes**: None
## Executive decision
RuView will add a clean-room native Rust boundary between RF pose inference and
semantic publication. It will preserve the immutable RF observation, publish a
physics assessment, optionally produce a bounded corrected candidate, and
abstain when required evidence is absent. It must never increase observational
confidence merely because a pose is physically plausible.
Three independently gated layers are adopted:
1. A deterministic kinematic auditor and bounded covariance-weighted projector
using Rust and `nalgebra`.
2. An optional articulated-body dynamics auditor using `rapier3d`.
3. A later optional supervised residual model using Burn.
The first production milestone is deterministic audit. It is not a GRIP port,
not PPO, and not evidence that the current pose observer is production-ready.
## Context
ADR-101's committed Cog emits 17 COCO keypoints as normalized 2D coordinates.
Its model has no per-joint uncertainty head and publishes a constant confidence.
The sensing server also contains renderer-oriented EMA and bone clamping. These
surfaces cannot establish metric 3D physics and can make weak evidence look
more convincing.
Pose output can violate bone length, floor, velocity, acceleration, and temporal
continuity constraints. Downstream consumers also cannot reliably distinguish
observed coordinates from derived correction. The rejected premise is:
"physically plausible means more likely correct." Plausibility is only a prior;
many incorrect poses are plausible.
GRIP is architectural inspiration for an observer/controller split, but it
observes four wearable IMUs and pressure insoles and drives a simulator. RuView
observes RF, so GRIP weights are not input-compatible. External code, weights,
simulators, and datasets require independent license review and never enter the
runtime dependency graph by implication.
## Outcome and actors
For every accepted person track/timestamp, the engine returns exactly one
`PoseRefinementV1`, including off, timeout, rejection, and abstention paths:
- immutable `PoseObservationV2` content hash;
- constraint residuals and quality disposition;
- an optional bounded candidate and an explicit `selected` bit;
- a typed reason when correction is unavailable;
- model, calibration, configuration, and optional learned-artifact provenance.
The RF observer owns observations and calibrated uncertainty; tracking owns
identity stability; physics owns assessment/correction only; the sensing server
owns deadlines, modes, publication, and rollback; the evidence engine owns
release evaluation; clients choose raw/both/refined without silent fallback.
## Input and coordinate contract
Metric correction requires a monotonic nanosecond timestamp, session-scoped
track ID, sequence and sensor epoch, 17 ordered COCO joints in metric X/Y/Z,
per-joint positive-semidefinite covariance calibrated on held-out data, a
versioned right-handed Z-up room frame, a normalized upward floor plane, model
and calibration hashes, ADR-302 trust state, and authenticated/replay-protected
source provenance.
`Image2d` observations may be audited for image-plane ratios and continuity but
must never enter 3D projection/dynamics or be called physically corrected.
Unknown trust, missing calibration, missing uncertainty, stale/non-monotonic
input, non-finite values, invalid covariance, excessive tracks, and room-bound
violations fail to raw output with a typed reason.
## Public contracts
`wifi-densepose-core` owns `PoseObservationV2` and `PoseRefinementV1`; no
duplicate server/Cog contract is permitted. Public output remains COCO17. The
engine derives pelvis and thorax virtually and never labels them observed.
The raw content hash is deterministic and excludes its own hash field. The
idempotency key is `(sensor_epoch, sequence, track_id, raw_hash, config_hash)`.
An exact duplicate returns the cached result; same sequence with different
content is a replay rejection.
Contact is `hypothesis` unless a measured sensor and its provenance say
otherwise. Raw, derived, hypothesis, and unknown labels must survive every
projection.
## Confidence invariant
For upstream calibrated confidence `c_obs`, normalized residual `r`, and
normalized intervention `i`:
```text
c_physics = exp(-(beta_r * r + beta_i * i))
c_effective = min(c_obs, c_obs * c_physics)
0 <= c_effective <= c_obs <= 1
```
Only a separately witnessed multimodal fusion contract may increase fused
confidence.
## Deterministic projector
The default `kinematic` feature has no Rapier, Burn, ONNX, libtorch, Python,
CUDA, or network dependency. Per bounded iteration it:
1. projects observed parent/child distances toward anonymous track-scoped
bone-length posteriors;
2. applies broad joint/trunk validity checks without an upright prior;
3. bounds temporal motion and resets derivatives after gaps;
4. resolves floor penetration only, allowing seated, kneeling, prone, child-
scale, mobility-aid, and genuine-fall poses;
5. recomputes residuals and stops below epsilon.
Initial operator-owned caps are four iterations (hard maximum eight), 0.20 m
single-joint correction, 0.10 m root correction, 250 ms derivative gap, 500 ms
track reset, ten known joints, a 100 m metric room bound, a separate 16,384
image-coordinate audit bound, and a 5 ms one-track Pi 5 p95 gate. Keeping image
and metric bounds separate prevents legitimate pixel observations from
weakening the physical room bound. A candidate over either correction cap is
discarded in full.
Bone posteriors are initialized only from high-confidence frames, anonymous,
memory-only, track-scoped, and deleted on expiry. Persistent personalization is
outside this ADR and requires consent/retention/deletion governance.
## Optional dynamics and learned layers
`dynamics` adds a process-owned Rapier humanoid and begins audit-only. Network
input may never provide Rapier snapshots, bodies, constraints, solver limits,
or arbitrary geometry. Dynamics approval is independent of kinematic approval.
`learned` uses first-party Burn 0.21 core/NN components without `burn-tch`
because this workspace already has a different native libtorch link.
`learned-cpu` adds the ndarray backend. The implemented two-layer GRU uses a
20-frame history and width 128 to predict bounded residuals, uncertainty,
foot-contact hypotheses, and abstention. Verified model records can be loaded
from bytes and executed natively; no trained artifact is shipped or approved.
The resolved Burn/CubeCL graph declares Rust 1.92, while the workspace file
pins Rust 1.89 and the authoring host provides Rust 1.91.1.
`--ignore-rust-version` is diagnostic evidence only: learned activation remains
blocked until an approved Rust 1.92 release-toolchain change builds it without
that override. Residuals are hard-clipped to deterministic caps and cannot
bypass validation or confidence monotonicity. PPO is deferred until measured
evidence identifies a failure supervised residual learning cannot address.
## Feature boundary
```text
default = kinematic
dynamics = rapier3d
learned = burn-core + burn-nn
learned-cpu = learned + burn-ndarray
learned-train = learned + burn-train
learned-wgpu = learned-train + burn-wgpu
learned-cuda = learned-train + burn-cuda
deterministic = rapier3d?/enhanced-determinism
```
The lockfile is release authority. The learned feature currently requires the
toolchain supported by Burn/CubeCL's resolved graph; this does not change the
default edge build.
## Runtime modes and API
Rollout is `OFF -> AUDIT -> SHADOW_CORRECT -> OPT_IN_CORRECT -> DEFAULT_CORRECT`.
Evidence permits forward transitions; any regression returns immediately to
audit/off. Correct selection additionally requires authenticated sensor
identity and replay protection from ADR-305. High model confidence cannot
override missing source authentication.
Existing pose fields stay unchanged and raw remains the migration default:
```text
GET /api/v1/pose/current?view=raw
GET /api/v1/pose/current?view=both
GET /api/v1/pose/current?view=refined
```
Refined-only returns HTTP 409 with `pose_refined_unavailable` when no selected
candidate exists. It never silently returns raw labeled refined.
## Security, privacy, and availability
All frames, model output, geometry, and pre-verification artifacts are
untrusted. Calibration/config/model artifacts become trusted only after signed,
hash-addressed verification and atomic activation. Runtime inference performs
no model retrieval or other network access.
Fixed arrays/caps, bounded iterations, a maximum track count, room geometry
limits, deadlines, and track expiry constrain denial of service. Timeout drops
partial refinement, never raw publication. Backpressure retains the newest raw
frame per track, drops intermediate refinement work, resets derivatives after
250 ms, and never extrapolates beyond 500 ms.
Metrics contain only allowlisted aggregate scalars: mode/disposition/reason,
stage latency, iterations, maximum correction, residuals, confidence delta,
track resets, invalid input, timeout, and raw/refined divergence. They exclude
joint arrays, body dimensions, room coordinates, CSI, and persistent person
identifiers. Bone/gait state is memory-only and excluded from logs.
Refined output is not a sole medical, emergency, industrial-safety, or
autonomous-control source. A real fall is valid state and must never be made
upright to stabilize a simulator.
## Threat model summary
| Threat | Primary control | Residual risk |
|---|---|---|
| Spoofed/replayed sensor | ADR-305 identity, MAC, sequence and replay window; correction gate | Compromised legitimate sensor |
| Altered model/floor/config | Signed hashes, authenticated configuration, atomic activation | Authorized unsafe configuration |
| Poisoned data/splits | Immutable manifests, strict split validator, witnessed benchmarks | Subtle label poisoning |
| Operator repudiation | Append-only witnessed transition with actor/old/new hash/reason | Compromised signer |
| Biometric/log leakage | Track-local retention and fixed metric allowlist | Aggregate inference |
| Track/geometry CPU flood | Authentication, cardinality/geometry/allocation/deadline caps | Valid dense-scene overload |
| Remote mode escalation | Capability-scoped local control plane, deny by default | Compromised operator capability |
| Derived output relabeled observed | Required schema/provenance and signed event envelope | Malicious downstream stripping |
The implementation review records commit, lockfile hash, Rust toolchain,
scanner versions, and advisory-feed timestamp.
## Evidence protocol
Evidence levels are L0 deterministic synthetic, L1 public measured replay, L2
controlled RuView RF plus optical truth, L3 subject/room/hardware/session-
disjoint RuView, L4 privacy-safe shadow fleet aggregates, and L5 independent
vertical validation outside this ADR.
No sequence, contiguous take, subject, room, or calibration session may cross
train/test for the generalization gate. Preprocessing, body priors, and
uncertainty calibration fit training data only. Reports include raw observer,
renderer smoothing, audit, deterministic correction, dynamics audit, and
learned residual on identical observations, plus empty-room, prone/fall,
missing-joint, and OOD subsets.
Primary metrics are 3D MPJPE, declared-threshold PCK, per-joint error, foot
slide, floor penetration, jerk, uncertainty calibration, abstention coverage,
and selective risk. Learned runs use at least five fixed seeds and report mean,
median, standard deviation, and 95% bootstrap intervals. All frames count;
selective metrics report risk and coverage.
## Acceptance gates
- **G0 contract**: real metric 3D/covariance output, round-trip raw hash,
versioned frame/floor, 2D compatibility, non-stub observer, ADR-298 artifact
sanity, and the ADR-079 PCK@20 >=35% gate or adopted successor. The current
committed Cog does not pass G0, so correction remains unavailable.
- **G1 deterministic audit**: property/fuzz tests, deterministic hashes per
platform class, 24-hour accelerated replay without panic/growth, Pi 5 p95
<=5 ms, and universal confidence monotonicity.
- **G2 shadow correction**: strict-disjoint measured median MPJPE improvement
>=10% with positive 95% CI lower bound; foot slide >=30% and jerk >=25%
better; no joint median >5 mm worse; fall/prone sensitivity change <=2 pp;
>=95% corrections below 0.10 m; every correction above 0.20 m abstains.
- **G3 opt-in**: >=30 subjects, 10 rooms, 3 hardware configurations, and 3
independent sessions/room; UNKNOWN never selected; confidence monotonic;
live disable; REST/WebSocket/MQTT/Home Assistant/replay compatibility.
- **G4 default visualization only**: 30 shadow days under 0.1% timeout/internal
error, no open severity 1/2 incidents, and gates still valid for current
model/calibration.
Dynamics and learned engines each repeat G2-G4; approval is not inherited.
## Testing and completion evidence
Unit/property/fuzz/integration/security coverage maps to requirements R1-R13:
raw hash, confidence, modes, malformed/stale/frame/covariance input, caps and
deadlines, provenance, dependency graph, pose diversity/fall preservation,
strict splits, fail-to-raw faults, no network capability, and authenticated
source/replay selection.
Release commands include focused core/physics tests, default/dynamics/learned
feature checks, format/clippy, benches, `cargo deny`, `cargo audit`, strict split
verification, and golden replay verification. Completion also requires JSON
schemas, measured Pi 5/x86 rows, strict manifest hashes, raw/refined metrics,
SBOM/license report, rollback drill, and residual-risk owners. Missing measured
or operational evidence leaves status Proposed and runtime in audit.
## Rollback
Rollback is an authenticated mode transition to audit/off, not a binary
downgrade. Stop selection immediately, keep raw publication and disposition
records, discard track state, and retain only aggregate incident metrics plus
signed configuration history. Failed artifact activation leaves the previous
engine atomically active. Additive schemas remain; refined-only callers receive
the typed unavailable response.
## Consequences
### Positive
- Explicit anti-hallucination and provenance boundary after RF inference.
- Reusable native Rust consistency primitive with measurable abstention.
- Python/CUDA remain absent from the production default.
- Cross-modal teacher data remains possible without wearable runtime inputs.
### Negative
- Full value requires a real metric 3D observer and calibrated uncertainty.
- Stateful tracks add latency/memory; optional backends add supply-chain surface.
- A constrained but wrong pose can look more credible.
- Strict data collection costs more than the software implementation.
### Neutral
- This ADR does not improve RF observability or current weight evidence.
- Existing 2D consumers continue to function.
## Implementation phases
P0 contracts/schemas; P1 deterministic audit; P2 bounded shadow correction; P3
server/Cog publication and evidence ledger; P4 Rapier audit; P5 Burn residual
training/inference. Code may land ahead of evidence, but runtime authority
advances only through the gates above.
## Implementation status at proposal
- P0-P3 are implemented on this branch: canonical contracts, strict schemas,
deterministic audit/projection, authenticated correction receipts,
idempotency, bounded track state, latest-frame backpressure, additive HTTP
and WebSocket publication, live legacy-2D audit, privacy-safe metrics, golden
replay, and strict-split checks.
- P4 is implemented as an optional persistent per-track Rapier dynamics auditor
and remains audit-only pending independent G2-G4 evidence.
- P5 inference architecture, artifact verification, serialization, and native
CPU execution are implemented. Training data, a signed trained artifact, and
G2-G4 accuracy/calibration evidence do not exist, so the layer has no runtime
selection authority. Its resolved Rust 1.92 requirement is also an explicit
activation blocker on the current Rust 1.91.1 release host.
- The live Cog honestly emits `Image2d`, degraded trust, and uncalibrated
uncertainty. It can be audited but cannot be selected for 3D correction.
G0 therefore remains open until an independently released metric-3D observer
with calibrated covariance is integrated.
- Local x86 latency and synthetic contract checks are recorded in the append-
only evidence ledger. Pi 5 measurements, 24-hour replay, 100-million-case
fuzzing, held-out RF/optical accuracy, fleet shadowing, and vertical safety
validation remain release evidence gates rather than software claims.
## References
- [GRIP project](https://ryosukehori.github.io/grip-project/)
- [GRIP paper (arXiv:2603.16233)](https://arxiv.org/abs/2603.16233)
- [Rapier documentation](https://docs.rs/rapier3d/)
- [Burn documentation](https://docs.rs/burn/0.21.0/burn/)
- [ADR-020](./ADR-020-rust-ruvector-ai-model-migration.md)
- [ADR-079](./ADR-079-camera-ground-truth-training.md)
- [ADR-101](./ADR-101-pose-estimation-cog.md)
- [ADR-150](./ADR-150-rf-foundation-encoder.md)
- [ADR-273](./ADR-273-unified-rf-spatial-world-model.md)
- [ADR-279](./ADR-279-native-rf-frame-contract.md)
- [ADR-298](./ADR-298-model-release-sanity-gates.md)
- [ADR-302](./ADR-302-out-of-distribution-detection.md)
- [ADR-303](./ADR-303-ground-truth-synchronization.md)
- [ADR-304](./ADR-304-evidence-engine.md)
- [ADR-305](./ADR-305-authenticated-sensor-identity.md)
- [ADR-306](./ADR-306-canonical-spatial-ontology.md)
@@ -0,0 +1,276 @@
# ADR-324: off-axis-mode — RF-assisted head-coupled perspective for the three.js realtime demo
| Field | Value |
|-------|-------|
| **Status** | Proposed (core implemented — see §2.5) |
| **Date** | 2026-08-16 |
| **Deciders** | ruv |
| **Codename** | **off-axis-mode** |
| **Scope** | New `examples/three.js/demos/07-off-axis-window.html` (client-side only); no server changes |
| **Relates to** | ADR-019 (sensing-only UI), ADR-035 (live sensing UI accuracy), ADR-169 (adam-mode), ADR-170 (yoga-mode), ADR-282 (L0–L5 evidence ladder), ADR-295 (source provenance), ADR-306 (spatial ontology), ADR-307 (persistent tracking), ADR-323 (pose refinement) |
| **Prior art** | [`icurtis1/off-axis-sneaker`](https://github.com/icurtis1/off-axis-sneaker) (reference only — see §2.1 licensing) |
| **Numbering note** | ADR-324 is the next free number in the authoring checkout (322 is unused, 323 is the latest on disk). Re-run the ADR index/collision check immediately before merge and rename if needed. |
| **Tracking issue** | none yet |
---
## 1. Context
### 1.1 The question this ADR answers
"Can we use [`icurtis1/off-axis-sneaker`](https://github.com/icurtis1/off-axis-sneaker)
with RuView?" The answer is: **yes for the technique, no for the code, and
only honestly for the RF part.** This ADR records the research behind each of
those three clauses and defines the integration that is actually defensible.
### 1.2 What off-axis-sneaker is
`off-axis-sneaker` is a React + TypeScript + Vite web app that renders a GLB
model (a sneaker) in three.js and creates a *head-coupled perspective*
("fish-tank VR" / "window into the screen") illusion:
- **Tracking input**: MediaPipe Face Mesh (468 facial landmarks) from a
webcam. Head (x, y) comes from the eye midpoint; depth (z) is proxied by
inter-ocular distance. An exponential moving average (default factor 0.3)
smooths jitter; sensitivity multipliers are `strengthX: 4`, `strengthY: 3`,
`strengthZ: 2`.
- **Projection**: `src/utils/offAxisCamera.ts` builds a **true asymmetric
(off-axis) frustum** — `makePerspective(left, right, top, bottom, near, far)`
with `left/right/top/bottom = (screenBound − eyePosition) · (near /
viewerToScreenDistance)` — i.e. Kooima's generalized perspective projection,
plus a matching camera translation. Constants: `nearPlane 0.05`,
`farPlane 1000`, `worldScale 0.01` (cm → world units), `movementScale 1.5`.
- **Calibration**: a wizard captures physical screen width/height (cm),
typical viewing distance, and pixel density, stored locally, so eye position
is computed relative to the *physical* display.
The technique descends from Johnny Chung Lee's 2007 Wii-remote desktop VR
demo and the fish-tank VR literature (Ware, Arthur & Booth, CHI '93). The
projection math is Robert Kooima's "Generalized Perspective Projection"
(2008). Both are public, well-documented techniques independent of any one
implementation.
### 1.3 What the illusion physically requires
The head-coupled illusion is only convincing when the tracked eye position is
**accurate to roughly centimeters** and **low-latency**. The VR literature
puts comfortable motion-to-photon latency below ~20 ms for head-mounted
displays; desktop fish-tank VR tolerates more, but visible lag between head
motion and parallax response is exactly what breaks the "window" illusion.
`CLAIMED` (literature values; no RuView measurement exists for this demo yet).
### 1.4 What RuView RF sensing can actually supply today
This is where honesty is mandatory (repo rule: never present WiFi sensing as
camera-grade).
- **Field-peak position, not metric localization.**
`wifi-densepose-sensing-server/src/field_localize.rs` derives a position
from the strongest peak of the 20×20 `signal_field` carried on
`/ws/sensing` `sensing_update` frames. Its own module doc states the
caveat: the subcarrier→angle mapping is a *representation*; "a single ESP32
link cannot resolve a true (x, z) room position." The emitted position is
"strongest field peak in the room model," mapped with `X_SCALE 0.6`,
`Z_SCALE 0.5`, gated by `PEAK_THRESHOLD 0.35` — real, live, motion-tracking,
but **not a calibrated person fix** and nowhere near eye-position precision.
- **RF pose is 2-D, normalized, constant-confidence.** The committed Cog
(ADR-101, restated by ADR-323) emits 17 COCO keypoints as normalized 2-D
coordinates with a constant confidence and no per-joint uncertainty. A
"nose" keypoint exists (COCO index 0), but it is not a metric 3-D head fix.
- **Tracks are coarse and pseudonymous by design.** `ruview-track` (ADR-307)
maintains `person_N` tracks with container-level ("kitchen → hallway")
continuity, coarse non-reversible features, and asserts **no accuracy
number** — outputs default to evidence level `L1`.
- **Update cadence and latency are unmeasured for this purpose.** The demo
pipeline runs at ~30 Hz on the MediaPipe side (ADR-170), but no end-to-end
RF motion-to-photon latency has been measured. Any figure quoted for the RF
path must be tagged `MEASURED` with a reproducer before it appears in docs
or UI.
Conclusion of the capability match: **RF cannot drive a convincing fish-tank
illusion by itself today**, and this ADR does not claim it can. RF *can*
supply things a webcam cannot: camera-free presence, zone-level position,
person count, approach direction, and pseudonymous continuity — including
when the camera is off.
### 1.5 What this ADR is *not*
- Not a vendoring of `off-axis-sneaker` (see §2.1 — the repo has no license).
- Not a claim of camera-grade RF head tracking, at any tier.
- Not a backend change: no new server endpoints, no new auth surface, no
schema changes. Purely additive client-side HTML/JS, like ADR-169/170.
- Not a React/Vite/Tailwind adoption. The `examples/three.js/demos/*` are
dependency-light single-file HTML demos and stay that way.
## 2. Decision
### 2.1 Licensing: adopt the technique, not the code
`off-axis-sneaker` publishes **no license**. Under default copyright, its
source cannot be copied, vendored, or translated into this repository.
Decision:
1. **No code, assets, or models from `off-axis-sneaker` enter this repo.**
The GLB sneaker model is likewise unlicensed for reuse; demos use assets
already present in `examples/`.
2. The off-axis projection is implemented **clean-room from the public
sources**: Kooima's "Generalized Perspective Projection" (2008) — the
`pa/pb/pc` screen-corner formulation — and three.js's documented
`PerspectiveCamera.projectionMatrix` override path. The repository is cited
as prior art in this ADR only.
3. If upstream later adds a permissive license, revisiting reuse requires a
new ADR note, not silent copying.
### 2.2 Tiered integration — each tier labeled by what it really is
**Tier A (ships first): webcam-fine + RF-context hybrid.**
`07-off-axis-window.html` uses MediaPipe Face Landmarker (already the pattern
in demo 05) for fine head tracking and the Kooima frustum for rendering —
functionally what off-axis-sneaker does, reimplemented. RuView RF adds the
camera-free layer around it:
- **Presence-gated camera**: the webcam pipeline starts only when the RF
presence signal (`/ws/sensing` `sensing_update`) says someone is in the
zone, and stops after a configurable RF-vacancy timeout. The privacy
posture improves: the camera is *off* until physics says there is someone
to track.
- **Multi-person arbitration**: when RF reports more than one person, the HUD
says so and the demo holds the last stable perspective instead of jumping
between faces.
- **Pre-warm**: RF approach direction (field-peak trajectory) warms up
MediaPipe and the scene before the person sits down.
**Tier B (demo mode, prominently labeled): RF-only coarse parallax.**
A toggle drives the off-axis eye position from RF alone — field peak (x, z)
plus the pose nose keypoint when present — through a one-euro filter, a
deadband, and a hard gain clamp. The HUD labels it **"RF coarse body
parallax — not head tracking"** and shows the live evidence level (`L1`
heuristic unless a certificate says otherwise, per ADR-282/ADR-318). The
expected experience is a slow, body-scale parallax sway — a demonstrative
"the room model moves because *you* moved, with no camera" — not a stable
fish-tank illusion. The demo must never present Tier B as equivalent to
Tier A.
**Tier C (future, explicitly gated, not promised): metric RF head position.**
Only a calibrated multistatic deployment (ADR-297 multi-node semantics,
ADR-311 fusion, ADR-303 ground-truth sync) with an evidence-engine ledger
entry (ADR-304) and a capability certificate (ADR-318) could justify feeding
RF positions into the fine path. No current data supports this; Tier C exists
in this ADR solely so nobody ships it informally without those gates.
### 2.3 Implementation surface
- New file `examples/three.js/demos/07-off-axis-window.html` (07, not 06 —
ADR-170 reserves `06-yoga-mode.html`). Single-file demo following the 01–05
conventions: same CSS custom properties, same HUD/helper-panel pattern,
served from the existing static demo server
(`http://127.0.0.1:8765/examples/three.js/demos/…`).
- A small clean-room module (inline `<script type="module">` or
`examples/three.js/lib/off-axis-camera.js` if shared later) that, given
screen corners `pa, pb, pc` (from calibration) and eye point `pe`, sets
`camera.projectionMatrix` via the Kooima formulation each frame.
- Data inputs are the **existing** streams only: `/ws/sensing`
(`sensing_update` → `signal_field` → field peak, using the same
`X_SCALE`/`Z_SCALE`/`PEAK_THRESHOLD` mapping as `field_localize.rs`) and,
when available, `/api/v1/stream/pose` for the nose keypoint. WebSocket
access uses the existing ticket flow (`ws_ticket.rs` / `bearer_auth.rs`);
no endpoint is exempted or added.
- Calibration mirrors the sneaker app's concept without its code: screen
width/height in cm, viewing distance, persisted in `localStorage` under a
demo-scoped key. No calibration data leaves the browser.
- Provenance discipline: if the demo is pointed at a synthetic or replayed
source, the ADR-295 provenance state must surface in the HUD exactly as the
Observatory does — synthetic can never present as live.
### 2.4 Honesty and evidence rules binding this feature
1. Every user-visible latency, accuracy, or precision statement in the demo,
README, or docs carries a `MEASURED` (with reproducer), `CLAIMED`, or
`SYNTHETIC` tag. This ADR itself contains no `MEASURED` claims.
2. Tier B is labeled coarse body parallax in the HUD at all times; there is
no configuration that hides the label while RF drives the camera.
3. No PCK or pose-accuracy number may be quoted for the RF path without the
mean-pose baseline and a leakage-free held-out split (repo rule).
4. The webcam feed never leaves the browser; no frames, landmarks, or
embeddings are sent to the server. RF data continues to obey ADR-307's
privacy invariants (pseudonymous, coarse, rotatable).
### 2.5 Implementation status (2026-08-16 amendment)
The projection core shipped as a **Rust crate compiled to WASM** rather than
the inline JS module §2.3 anticipated — a strict upgrade with the same
surface: `v2/crates/ruview-offaxis` (dependency-free native core; wasm-bindgen
only on wasm32) implements the Kooima projection, the one-euro filter, the
field-peak mapping (constants mirroring `field_localize.rs`), and the Tier B
coarse-parallax stage with its deadband/gain/clamp bounds enforced in Rust.
`examples/three.js/demos/07-off-axis-window.html` consumes the wasm-bindgen
output (built locally per the crate README; generated artifacts are not
committed). Validation and `MEASURED` benchmarks live in the crate README.
The demo ships with a `SYNTHETIC`-labeled mouse simulator and the labeled
Tier B RF mode; a Tier A fine tracker connects through
`OffAxisCamera.update_normalized` and remains host-provided.
## 3. Options considered
| Option | Verdict | Why |
|---|---|---|
| Vendor `off-axis-sneaker` (or fork + point at RuView) | **Rejected** | No license ⇒ no redistribution rights. Also React/Vite stack conflicts with the repo's single-file demo convention. |
| Clean-room Kooima off-axis demo, webcam-fine + RF-context (Tier A/B) | **Chosen** | Legally clean, matches demo conventions, uses RF for what it is actually good at, and demonstrates camera-free presence value honestly. |
| RF-only head-coupled perspective as the headline | **Rejected** | Over-claim. Single-link field peaks are a representation, not metric localization (`field_localize.rs` caveat); shipping this as "head tracking" violates the camera-grade rule. Survives only as the labeled Tier B toggle. |
| Wait for multistatic metric localization (Tier C) before any demo | **Rejected** | Blocks a useful, honest demo on a phase-2/3 program (ADR-303/311/318) with no delivery date. The gates are recorded instead. |
| Add a dedicated server endpoint for head position | **Rejected** | Unnecessary — existing `/ws/sensing` + `/api/v1/stream/pose` suffice; a new endpoint would expand the auth surface for no capability gain. |
## 4. Consequences
**Improves**
- A publicly legible demo of RF sensing's actual differentiator: the scene
knows you are there, where you roughly are, and how many of you there are —
before and without any camera.
- Privacy posture of the head-tracking demo class: camera duty-cycle is
bounded by RF presence instead of always-on.
- Canonical, licensed off-axis projection code the Observatory or future UI
can reuse.
**Costs / risks**
- Tier B can underwhelm viewers primed by webcam demos; the mitigation is the
labeling and the side-by-side toggle, not inflated gain.
- MediaPipe CDN dependency (same as demo 05) remains a network-availability
risk for Tier A; the demo must degrade to Tier B with a visible notice.
- Screen-calibration friction (cm measurements) may deter casual users; a
"skip calibration (approximate)" path with degraded-accuracy labeling is
acceptable.
- Upstream `off-axis-sneaker` may change or add a license; tracking that is
manual.
**Follow-ups (not in this ADR's scope)**
- Measure end-to-end RF motion-to-parallax latency with a reproducer and
publish it `MEASURED`.
- If/when ADR-303/311 land, evaluate Tier C against the ADR-318 certificate
gate.
- Consider promoting the off-axis camera module into the Observatory 3D view.
## 5. Validation
- Demo checklist (manual, per ADR-169/170 practice): loads from the static
server; Tier A activates only on RF presence; Tier B label visible whenever
RF drives the camera; provenance badge correct against a synthetic source;
no network requests carrying webcam-derived data (verified in devtools).
- `rg` gate before merge: no file under `examples/` contains code originating
from `icurtis1/off-axis-sneaker`.
- No workspace, harness, or firmware validation rows are triggered — the
change is a static HTML demo plus this document.
## 6. References
- [`icurtis1/off-axis-sneaker`](https://github.com/icurtis1/off-axis-sneaker) — prior-art reference (unlicensed; technique only)
- Robert Kooima, *Generalized Perspective Projection*, 2008 — off-axis frustum math
- Johnny Chung Lee, *Head Tracking for Desktop VR Displays using the Wii Remote*, 2007
- Ware, Arthur & Booth, *Fish Tank Virtual Reality*, CHI '93 — head coupling vs. stereo
- `v2/crates/wifi-densepose-sensing-server/src/field_localize.rs` — field-peak honesty caveat and coordinate mapping
- `v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs`, `bearer_auth.rs` — WebSocket auth pattern
- `v2/crates/ruview-track/src/lib.rs` — ADR-307 privacy invariants and evidence discipline
- ADR-169, ADR-170 — demo-scoped ADR pattern for `examples/three.js/demos/`
- ADR-282 — L0–L5 evidence ladder; ADR-295 — provenance state machine
@@ -0,0 +1,476 @@
# ADR-325: Cognitum Spaces activation and governed spatial exchange
- **Status**: Accepted — read path implemented; write path remains policy-gated
- **Date**: 2026-08-17
- **Deciders**: ruv
- **Tags**: cognitum-spaces, oauth, spatial-state, privacy, ruvector, policy, autogenous
- **Relates to**: ADR-271, ADR-277, ADR-304, ADR-306, ADR-312, ADR-318, ADR-319, ADR-321; Cognitum API ADR-094; Autogenous ADR-402
## Context
RuView produces camera-free RF perception locally. Cognitum Spaces provides a
tenant-scoped cloud projection of physical places. Autogenous ADR-402 proposes
using that projection as a spatial-intelligence input for agent coordination.
The useful product is not another sensor dashboard: it is a governed chain from
local perception to spatial state, persistent memory, explanation, and action.
Four product pillars define the requested integration:
1. **Spatial state** — sites, buildings, floors, rooms/spaces, zones, entities,
semantic events, and alerts.
2. **RuView perception** — camera-free sensing is normalized locally before any
permitted P2/P3 semantic event synchronizes.
3. **Persistent memory** — RuVector grounds anomaly explanations in
tenant-scoped spatial history.
4. **Governed action** — agents observe or recommend by default; consequential
execution requires explicit policy authorization.
The live API audit on 2026-08-17 established the current production boundary:
- `GET https://api.cognitum.one/v1/spaces` exists and returns a bounded list;
- an unauthenticated request is rejected;
- the current account has no paired sites, so the authenticated result is an
empty list rather than fabricated sample state;
- the projection declares HomeCore Edge authoritative and excludes raw CSI,
CIR, RF tensors, recordings, pose frames, vital waveforms, and identity
observations;
- the first deployed Function revision accepted only legacy `cog_` API keys;
- the gateway was configured to authenticate private Function hops, but the
direct Function endpoint was still publicly invokable; that bypass has now
been closed and the exact gateway runtime service account is the only
invoker;
- OAuth protected-resource metadata and a RuView-scoped OAuth accept path were
absent.
The Autogenous review at commit
`f7fa308b261bac89a8909edae8a3fdbbfb8ce66c` found additional integration risks:
- its Spaces client only listed spaces; no governed ingest contract existed;
- it trusted a loose TypeScript cast, with no response-size, timeout, redirect,
or strict semantic-boundary validation;
- its observation conversion dropped tenant/message/sequence identity;
- missing confidence became zero but could still enter fusion;
- provenance could be substituted for calibration identity;
- a Spaces-derived belief could be converted back into an observation and
counted as independent corroboration, laundering one source into two;
- its API-key exchange returns a `cognitum-cli` OAuth token, but the live Spaces
endpoint accepted only a `cog_` key. Calling this “OAuth Spaces access” was a
contract mismatch.
## Decision
Adopt a one-way-by-default, typed spatial exchange with separate activation,
data, memory, and action authorities.
```text
RuView RF capture (P0/P1, local)
-> calibrated/OOD-gated semantic observation
-> ontology + evidence + witness envelope (P2/P3)
-> HomeCore authoritative edge state
-> Cognitum Spaces tenant/workspace projection
-> RuView bounded read client / Autogenous spatial context
-> RuVector tenant-scoped memory and explanation
-> recommendation
-> ruvview-policy authorization + approval + receipt
-> optional consequential action
```
Cloud state is a projection of edge state, not a second sensor and not an
independent corroborating modality.
### 1. Activation and data-plane credentials are distinct
RuView uses Cognitum's existing Authorization Code + PKCE flow with the public
`ruview` client. A user explicitly requests `spaces:read` with
`wifi-densepose login --spaces`. The authorization-server registration is a
ceiling; ordinary sensing login does not silently gain cloud access.
The Spaces resource server accepts either:
- a legacy API key carrying `spaces:read` (or the migration-compatible
predecessor `devices:manage`); or
- a Cognitum OAuth access token that passes every condition below.
OAuth acceptance is conjunctive:
| Check | Required value |
|---|---|
| Signature | ES256 against `https://auth.cognitum.one/.well-known/jwks.json` |
| Issuer | exact `https://auth.cognitum.one` |
| Audience | exact `ruview` |
| Client claim | exact `ruview` |
| Token type | ordinary `access`; setup/workload tokens denied |
| Lifetime | current `exp`/`nbf`, five-second clock tolerance only |
| Scope | exact token `spaces:read` member |
| Tenant binding | valid non-empty UUID `org_id` and `workspace_id` |
An API key is not called OAuth. An OAuth token is not stored in
`COGNITUM_SPACES_API`. The compatibility environment variable contains an API
key only and is never printed, logged, or committed.
OAuth consent grants identity-bound read access. It does **not** grant device
pairing, data publication, deployment, billing, spending, leases, learning
promotion, automation installation, commands, or actuator authority.
### 2. The gateway owns the private credential relay
The public gateway strips inbound `X-Cognitum-User-Authorization` and
`X-Serverless-Authorization`. For a locked Function upstream it then:
1. retains a legacy `cog_` credential in `X-API-Key`, or, for the exact Spaces
route only, retains a non-key bearer in a gateway-owned internal header;
2. replaces `Authorization` with the gateway's Google invoker ID token;
3. fails closed with `503` if it cannot mint that hop identity;
4. forwards only to the configured Function origin.
The Function's Cloud Run invoker check is enabled. `allUsers` has no invoker
binding; only the exact `apigateway-sa` service account may invoke it. This is
required because otherwise a caller could bypass Cloud Armor and spoof an
internal relay header.
The API publishes RFC 9728 protected-resource metadata naming the authorization
server and `spaces:read` scope. Discovery describes capability; it does not
grant it.
### 3. Tenant isolation is part of authentication
Legacy API-key documents are queried by their existing owner-bound `tenantId`.
OAuth requests are conjunctively queried by both signed `org_id` and
`workspace_id` using stored `tenantId` and `workspaceId` fields. The public
tenant identifier is projected from signed `org_id`. A request cannot supply
either selector in a query string.
No cross-tenant aggregation exists on this path. Pagination, search, memory,
and event endpoints added later must carry the same authoritative principal;
client-provided tenant filters may only narrow within it, never replace it.
### 4. Spatial model and ownership
The canonical RuView vocabulary remains ADR-306:
```text
Site -> Building -> Floor -> Space -> Zone
-> Sensor / Person / Object / Track
-> Observation -> Event -> Alert
```
Cognitum may call a bounded room a “space”; RuView does not create a second
room type. Stable external IDs are namespaced and validated before entering the
ontology. HomeCore remains authoritative for local registry state and local
automation. Cognitum owns tenant/workspace projection and activation. RuVector
owns indexed spatial history, not tenancy or authorization.
The current live endpoint exposes the first `Space` slice only. Sites, floors,
zones, entities, events, and alerts are contract milestones, not inferred from
missing fields. A client must represent absence as unknown/unavailable and must
not fabricate parents, coordinates, people, alerts, or provenance.
### 5. Privacy boundary and synchronization eligibility
Only allow-listed P2/P3 semantic projections may cross the cloud boundary.
| Class | Examples | Cloud default |
|---|---|---|
| P0 | raw CSI, CIR, RF tensors, packet captures | prohibited |
| P1 | pose frames, vital waveforms, identity observations, recordings | prohibited |
| P2 | occupancy count, bounded activity/fall possibility, anomaly score | permitted when policy allows |
| P3 | versions, connection health, signed capability metadata | permitted |
The client independently rejects forbidden raw-field names anywhere in the
response. This is defense in depth, not a substitute for server-side
projection. It also enforces HTTPS except for loopback tests, refuses redirects,
uses bounded connect/total timeouts, caps responses at 1 MiB, caps the list at
100 spaces, bounds nesting/arrays/strings, validates confidence, and rejects
non-P2/P3 space records.
Cloud-bound envelopes must preserve, when available:
- tenant/workspace/site/space/device identity;
- `messageId` and monotonic `eventSequence`;
- `observedAt`, `expiresAt`, freshness, and connection state;
- privacy class and semantic schema version;
- calibrated confidence and explicit uncertainty/abstention;
- model, HomeCore, hardware-manifest, calibration, evidence, and witness
provenance.
Provenance is never used as a calibration identifier. Missing confidence,
calibration, timestamp, or tenant identity stays missing and cannot satisfy an
admission rule.
### 6. No feedback laundering or false corroboration
A Spaces record derived from RuView evidence carries derivation lineage. If it
returns to RuView or Autogenous, it is a **projection/recollection** of that
lineage, not a new observation. It cannot:
- increment corroborating-sensor count;
- raise evidence level;
- be fused as an independent modality;
- reset freshness to retrieval time;
- erase abstention, contradiction, or uncertainty;
- generate a second belief that cites the first as support.
Deduplication keys include tenant, source/witness identity, message ID, and
sequence. Cycles are detected and rejected. Independent corroboration requires
a distinct authenticated source and evidence chain.
### 7. Persistent memory is tenant-scoped and explanation-oriented
RuVector indexes accepted semantic state under at least:
```text
(tenant_id, workspace_id, site_id, space_id, schema_version, time_bucket)
```
It stores bounded semantic features, uncertainty, evidence references, and
witness digests. It does not store OAuth/API credentials or prohibited raw
payloads. Retrieval always applies the authenticated tenant/workspace filter
before similarity ranking.
An anomaly explanation names:
- the current semantic state and its uncertainty;
- the relevant learned baseline/window from ADR-312;
- comparable tenant-local history;
- the measured deviation and contradictory evidence;
- the provenance/witness chain;
- the evidence label (`MEASURED`, `SYNTHETIC`, or `CLAIMED`).
Memory supplies context, not permission. A historically common action is not
automatically authorized.
### 8. Agents observe and recommend; policy authorizes action
Autogenous and other agents receive read-only spatial context by default. Their
normal outputs are observations, explanations, proposals, and recommendations.
Any consequential action must cross the ADR-321 `ruview-policy` gate with:
- an exact action class and target;
- a fresh capability certificate;
- KNOWN/DEGRADED/UNKNOWN domain state;
- bounded uncertainty and sufficient evidence;
- tenant/workspace authorization;
- expiry, nonce, idempotency key, and replay protection;
- required human/policy approval;
- a terminal witness receipt for allow or deny.
Missing policy, unknown action class, stale state, incomplete provenance, or an
unavailable approval service denies. OAuth `spaces:read` can never authorize an
action. This ADR adds no actuator method to the Spaces client.
## Implementation
### RuView
- `ruview-cognitum-spaces` is a reusable, read-only client with typed/redacted
credentials and a bounded response decoder.
- `wifi-densepose login --spaces` explicitly requests `spaces:read` through the
existing PKCE flow and credential store.
- `wifi-densepose spaces` refreshes OAuth through the existing single-flight,
persist-before-return mechanism, verifies that the stored grant contains
`spaces:read`, and lists validated state. `COGNITUM_SPACES_API` remains an
explicit compatibility path.
### Cognitum Identity
- the `ruview` public client allow-list includes `spaces:read`;
- RFC 8414 metadata advertises it;
- refresh preserves the originally granted scope;
- no new client secret or password grant is introduced.
### Cognitum API
- the gateway preserves caller OAuth through an internal, spoof-resistant
relay while authenticating the private Function hop;
- Spaces verifies the signed OAuth principal and queries by tenant + workspace;
- legacy API-key behavior remains available;
- bounded semantic-state `PUT` is available only to an explicitly scoped API-key
publisher and is not exposed by the RuView OAuth client;
- OpenAPI documents both alternatives and RFC 9728 metadata supports discovery;
- the Function remains gateway-only at Cloud Run IAM.
### Autogenous
Autogenous must consume an explicitly typed credential. It must not imply that
`/v1/cli/session/exchange` produces a RuView-audience token: that exchange
currently produces `client_id=cognitum-cli` and cannot pass the Spaces policy.
An external RuView PKCE token may be supplied after activation, or a scoped API
key may be used as the compatibility path. Response validation and lineage
rules in this ADR apply before agent belief formation.
## Threat model
| Threat | Required control |
|---|---|
| Direct Function bypass | invoker IAM check; gateway SA only; no `allUsers` |
| Forged internal OAuth header | strip inbound relay headers; gateway writes after route classification |
| Token substitution | ES256/JWKS plus exact issuer, audience, client, type, scope, and tenant claims |
| Cross-tenant enumeration | principal-derived Firestore selector; bounded non-enumerating errors |
| Redirect/token exfiltration | redirects disabled; HTTPS required; fixed path |
| Oversized/malformed response | byte/depth/count/string bounds before use |
| Raw-data regression | server allow-list plus client forbidden-field rejection |
| Secret disclosure | redacting types; no token logs/URLs; `.env` untracked |
| Feedback amplification | lineage preservation, dedupe, cycle rejection, no independent corroboration |
| Memory leakage | tenant filter before vector search; no global nearest-neighbor pass |
| Agent overreach | observe/recommend default; ADR-321 fail-closed action gate |
| Stale/replayed state | expiry, sequence, message ID, freshness, witness receipt |
| JWKS outage/rotation | bounded cache; fail closed; refresh after unknown `kid`; no algorithm fallback |
## Deployment and rollback
Rollout order is dependency-safe:
1. merge and deploy Identity scope/metadata;
2. deploy the Spaces Function with OAuth verification while API-key behavior
remains unchanged;
3. deploy the gateway relay and protected-resource metadata;
4. verify gateway API-key access, OAuth denial matrices, direct-origin platform
denial (`401` or `403` before application code), and tenant isolation;
5. merge/release the RuView client and CLI activation;
6. enable Autogenous consumption only after its strict validation/lineage gates
pass.
Rollback disables OAuth advertisement/relay and returns clients to scoped API
keys. It must not restore public Function invocation. Revoking an OAuth session
or API key must not alter paired-site state.
## Validation and acceptance
Required automated gates:
- Identity: metadata test, migration application, PKCE authorize/token/refresh
scope preservation, cross-client scope denial;
- API Function: valid claim matrix and rejection for wrong issuer/audience/
client/type/scope/tenant, API-key regression, tenant query assertion, bounded
projection tests, build and dependency audit;
- gateway: spoofed relay stripped, caller OAuth preserved, Google hop identity
substituted, OpenAPI security alternatives, RFC 9728 metadata, build and
dependency audit;
- RuView: semantic decoder bounds/privacy tests, redaction tests, login scope
tests, CLI compile, and live empty/non-empty response tests without fixtures
masquerading as production;
- policy: no Spaces read can invoke an actuator; denial receipts are witnessed.
Production readback must prove:
- unauthenticated gateway request returns `401`;
- legacy scoped API key returns the authenticated tenant list;
- valid RuView OAuth returns only its workspace;
- wrong client, missing `spaces:read`, setup/workload token, and second-tenant
token are denied;
- the direct Function origin is rejected by the Google platform with `401` or
`403` before application code, even with a valid application credential;
- response remains `no-store` and excludes P0/P1;
- no secret appears in logs, diffs, artifacts, or issue/PR text.
Performance, detection quality, and action-safety numbers are not claimed by
this decision. Any such number requires a named reproducer and the repository's
evidence labels. An empty production tenant is a successful isolation/read-path
test, not sensing-quality evidence.
## Production evidence (2026-08-18)
The bounded Spaces read slice and RuView activation path are deployed. The exact
production release chain is:
- Spaces run `32148530629`, revision `spacesapi-00003-xij`, source
`fc333e634cd918b9d6fdde4eecbe7beac1043ab8`, Node 22, runtime service account
`spacesapi-runtime@cognitum-20260110.iam.gserviceaccount.com`, with
`apigateway-sa@cognitum-20260110.iam.gserviceaccount.com` as sole invoker;
- gateway run `32151485401`, revision `apigateway-00180-peh`, source
`c4e99ebb4ce0d4e1407f435f905621476c1f0166`, image digest
`sha256:bacb81281a54256ff6fdaac253175e76ce6fc225f399163ca0a807a2839bd6a3`;
- Identity run `32163542502`, revision `identity-00052-fid`, source
`fb6320827b879e481cad6caf184d3cbccd8279c4`, image digest
`sha256:0cd5896518bd8ecf042d2f3e9aea58a32e65a68dbddaab1e54f8ae6da2bfab06`,
and runtime service account
`identity-runtime-prod@cognitum-20260110.iam.gserviceaccount.com`.
The live API-key matrix returned `200` with an empty bounded list,
`Cache-Control: private, no-store`, and no prohibited P0/P1 projection fields.
No credential returned `401`. A direct-origin request received a Google
Frontend Bearer challenge (`401`) before application code.
Two independent RuView Authorization Code + PKCE principals also passed the
live matrix. Each token used ES256, exact issuer/audience/client checks,
`sensing:read spaces:read`, signed UUID organization/workspace claims, refresh
rotation, and revocation. Each gateway read returned `200`, an empty bounded
list, and `private, no-store`; a corrupted signature returned `401`; and the
principals had distinct pseudonymous tenant/workspace fingerprints. This proves
the production empty-tenant behavior and independent claim binding. Non-empty
cross-tenant isolation remains emulator/staging evidence because production was
not mutated to manufacture a fixture.
Identity metadata deliberately advertises `spaces:read` for RuView but not
`spaces:write`. The deployed semantic-state `PUT` remains an API-key-only
publisher surface. RuView therefore has no OAuth write, command, policy-approval,
or actuator capability.
This evidence does not claim implementation of sites/buildings/floors/zones,
entities, semantic event or alert resources, tenant-scoped RuVector spatial
history, MQTT reconciliation, governed actions, commands, or actuators. Those
remain separately reviewed milestones.
## Consequences
### Positive
- One Cognitum identity can explicitly activate RuView's cloud spatial read
capability without sharing a long-lived static bearer.
- Tenant and workspace become cryptographically bound inputs to the data query.
- RuView and Autogenous gain useful spatial context without importing raw RF or
inventing independent evidence.
- The design keeps a path for RuVector-grounded explanations and separately
governed action without treating either as part of the deployed read slice.
- The direct-origin bypass is closed permanently, independent of OAuth rollout.
### Costs and limitations
- Two credential types coexist during migration and must stay visibly distinct.
- OAuth depends on Identity JWKS availability and correct key rotation.
- The current API exposes spaces only; the full hierarchy/events/alerts model
remains staged work.
- OAuth workspace IDs will return only documents populated with `workspaceId`;
legacy owner-only documents require an explicit migration, never a broad query.
- The RuView client exposes no write, command, or agent execution surface. The
separate API-key semantic-state ingress is neither OAuth activation nor
actuator authority.
## Alternatives considered
**Keep API keys only.** Rejected as the target: keys are useful for service
compatibility but do not provide user activation, consent, short lifetime, or
refresh/revocation semantics.
**Treat the CLI API-key exchange token as a Spaces OAuth token.** Rejected: it
is minted for `cognitum-cli`, not `ruview`, and accepting it would remove the
audience/client boundary.
**Trust the gateway without verifying OAuth in Spaces.** Rejected: hop identity
and user authorization are distinct, and authorization must remain valid if the
route topology changes.
**Make Spaces state independent corroboration.** Rejected: it is derived from
the same RuView/HomeCore lineage and would double-count evidence.
**Allow agents to execute from `spaces:read`.** Rejected: read consent is not
action authority, and perception confidence alone cannot authorize consequence.
**Synchronize raw RF for better cloud models.** Rejected by default: it violates
the edge privacy boundary and is unnecessary for the semantic product.
## References
- Autogenous ADR-402, `docs/adr/ADR-402-ruview-cognitum-spaces-spatial-intelligence.md`
- Cognitum API ADR-094, `docs/adr/ADR-094-cognitum-spaces-homecore-edge-boundary.md`
- Cognitum API hierarchy/events/alerts follow-up,
`https://github.com/cognitum-one/api/issues/206`
- RuVector spatial-history follow-up,
`https://github.com/ruvnet/RuView/issues/1640`
- governed-action and witness-receipt follow-up,
`https://github.com/ruvnet/RuView/issues/1641`
- RFC 7636, Proof Key for Code Exchange
- RFC 8414, OAuth 2.0 Authorization Server Metadata
- RFC 9700, OAuth 2.0 Security Best Current Practice
- RFC 9728, OAuth 2.0 Protected Resource Metadata
+2
View File
@@ -108,6 +108,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) |
| [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
| [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
| [ADR-324](ADR-324-off-axis-head-coupled-perspective-demo.md) | off-axis-mode — RF-assisted head-coupled perspective demo (clean-room Kooima projection; RF presence gating) | Proposed |
### Architecture and infrastructure
@@ -179,6 +180,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-319](ADR-319-witness-chain.md) | Witness chain — staged, signed epistemic envelope | Accepted (phase 1) |
| [ADR-320](ADR-320-sensor-hal.md) | RuView sensor HAL — abstract all sensing hardware to one Observation type | Proposed (phase 2) |
| [ADR-321](ADR-321-decision-policy-action-authorization.md) | Decision policy — action authorization conditioned on certificate class, freshness, uncertainty, evidence | Accepted (phase 1) |
| [ADR-323](ADR-323-native-rust-physics-constrained-pose-refinement.md) | Native Rust physics-constrained pose refinement | Proposed |
---
@@ -0,0 +1,71 @@
# Physics pose refinement evidence ledger
ADR-323 performance and accuracy targets are gates, not measured claims. Append
rows; never replace prior measurements. Every row must identify the repository
commit, lockfile hash, Rust toolchain, target, engine/features, configuration
hash, corpus/split hash, command, sample count, and evidence label.
## Runtime measurements
| Date | Commit | Lock SHA-256 | Target/toolchain | Engine/config | Tracks | Samples | p50 | p95 | p99/max | RSS delta | Evidence | Reproducer |
|---|---|---|---|---|---:|---:|---:|---:|---:|---:|---|---|
| 2026-08-15 | `de27336` + uncommitted ADR-323 changes | `552737eab9092b59ea9dd2b2caf68389f0b0966679f0fbb33ff2b1b3d42e2668` | Windows x86_64, Intel Core Ultra 9 285H, rustc 1.91.1 | deterministic kinematic shadow, config `ef3cf581f75124c1d45a8d6bedcef32e4d1bacb39ee0dfcd4e520171fda2d8cf` | 1 | 20,000 | 0.0080 ms | 0.0097 ms | 0.0195/0.5465 ms | not measured | **MEASURED**, local host only; not Pi 5 evidence | `cargo run --release -p wifi-densepose-physics --example latency_probe -- 20000` |
| 2026-08-15 | `de27336` + uncommitted ADR-323 changes | `552737eab9092b59ea9dd2b2caf68389f0b0966679f0fbb33ff2b1b3d42e2668` | Windows x86_64, Intel Core Ultra 9 285H, rustc 1.91.1 | deterministic kinematic shadow after final local optimization, same config | 1 | 20,000 | 0.0075 ms | 0.0084 ms | 0.0117/0.1579 ms | not measured | **MEASURED**, local host only; not Pi 5 evidence | same release probe command |
| 2026-08-15 | `de27336` + uncommitted ADR-323 changes | `552737eab9092b59ea9dd2b2caf68389f0b0966679f0fbb33ff2b1b3d42e2668` | Windows x86_64, Intel Core Ultra 9 285H, rustc 1.91.1 | final deterministic kinematic shadow, config `a44dc696234f31eda54cd4b436bc2d2c69b9638565b729ac9f07435cedfd0dcc` | 1 | 20,000 | 0.0071 ms | 0.0084 ms | 0.0147/1.5994 ms | not measured | **MEASURED**, local host only; not Pi 5 evidence | same release probe command |
The probe measures a warm, one-track `PhysicsEngine::process` call. It excludes
transport, publication, resident-memory delta, dynamics, and learned inference.
It is not evidence for the Pi 5 gate.
Criterion separately measured `kinematic_one_track` at
`[11.911, 12.757, 14.069] us` across 100 samples (approximately 369,000 timed
iterations). That benchmark includes observation construction and canonical
hashing in the timed routine and uses fresh engine state; it is **MEASURED** on
the same local host and is not a percentile or Pi 5 claim.
## Accuracy measurements
| Date | Commit | Corpus/split | Variant | Coverage | MPJPE | PCK threshold/result | Foot slide | Jerk | Fall/prone delta | Evidence |
|---|---|---|---|---:|---:|---|---:|---:|---:|---|
No measured accuracy evidence has been recorded. The deterministic tests are
L0/SYNTHETIC contract evidence only and cannot satisfy G2.
## Validation and supply-chain record
- The default dependency graph is checked to exclude Burn, Rapier, Tch, and
ONNX Runtime. Dynamics and learned backends remain opt-in.
- Burn CPU serialization/inference tests pass on the authoring host only with
Cargo's `--ignore-rust-version`; the resolved CubeCL graph requires Rust 1.92.
The workspace file pins Rust 1.89 and the host provides Rust 1.91.1. This is
diagnostic, not release approval.
- `cargo audit 0.22.1` used RustSec database commit
`69f93cf294852cfa9b53751f4ca86de3283dd290` (feed timestamp 2026-08-12).
ADR-323 updates remove resolved advisories in `event-listener`, `rkyv`, and
`wasmtime`. The workspace still has five advisories in pre-existing
`quick-xml` and `rsa` dependency paths; the default physics graph contains
none of them. The optional Burn training graph includes yanked `spin 0.9.8`.
- `cargo-deny` is not installed on the authoring host, so the required license
and policy gate is not claimed complete.
- Strict Clippy passes with warnings denied for core/physics default and
dynamics builds, the diagnostic learned-CPU build, and the Cog itself with
dependency linting excluded. Focused core, physics, dynamics, learned, Cog,
sensing-server adapter/live-audit/HTTP, schema, golden, strict-split,
feature-boundary, and fuzz-build checks pass.
- The repository-wide rustfmt gate is already red across unrelated crates. The
sensing-server library has existing warning debt, and unscoped Cog Clippy is
blocked by existing `wifi-densepose-ruvector` warnings. The prescribed
`cargo test --workspace --no-default-features` did not reach a terminal result
in either a 904-second cold or 604-second warm serial run on this Windows
host. None of these broader gates is represented as green.
- The standalone fuzz lock SHA-256 is
`d386c4edb130bb6b2d1a4ef77334c78e25e0695e90a9d97c01284876acb8c2c6`.
## Required commands
```text
cargo bench -p wifi-densepose-physics
node scripts/pose-physics/verify-feature-boundary.mjs
bash scripts/verify-pose-physics-splits.sh <manifest.json>
bash scripts/replay-pose-physics-golden.sh <golden-results.jsonl>
```
@@ -0,0 +1,38 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ruview.net/schemas/pose-observation-v2.schema.json",
"title": "PoseObservationV2",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "timestamp_ns", "sensor_epoch", "sequence", "track_id", "frame", "calibration_id", "floor_plane", "model", "source", "trust_state", "dimensionality", "uncertainty_calibrated", "joints", "observer_confidence", "canonical_hash"],
"properties": {
"schema_version": { "const": 2 },
"timestamp_ns": { "type": "integer", "minimum": 0 },
"sensor_epoch": { "type": "integer", "minimum": 0 },
"sequence": { "type": "integer", "minimum": 0 },
"track_id": { "$ref": "#/$defs/string_id" },
"frame": { "$ref": "#/$defs/frame" },
"calibration_id": { "$ref": "#/$defs/string_id" },
"floor_plane": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/floor" }] },
"model": { "$ref": "#/$defs/model" },
"source": { "$ref": "#/$defs/source" },
"trust_state": { "enum": ["KNOWN", "DEGRADED", "UNKNOWN"] },
"dimensionality": { "enum": ["image2d", "metric3d"] },
"uncertainty_calibrated": { "type": "boolean" },
"joints": { "type": "array", "minItems": 17, "maxItems": 17, "items": { "$ref": "#/$defs/joint" } },
"observer_confidence": { "$ref": "#/$defs/probability" },
"canonical_hash": { "$ref": "#/$defs/hash" }
},
"$defs": {
"probability": { "type": "number", "minimum": 0, "maximum": 1 },
"hash": { "type": "array", "minItems": 32, "maxItems": 32, "items": { "type": "integer", "minimum": 0, "maximum": 255 } },
"string_id": { "type": "string", "minLength": 1, "maxLength": 128 },
"vec3": { "type": "array", "minItems": 3, "maxItems": 3, "items": { "type": "number" } },
"frame": { "type": "object", "additionalProperties": false, "required": ["name", "version", "metric", "right_handed", "z_up"], "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "version": { "type": "integer", "minimum": 1 }, "metric": { "type": "boolean" }, "right_handed": { "type": "boolean" }, "z_up": { "type": "boolean" } } },
"floor": { "type": "object", "additionalProperties": false, "required": ["normal", "offset_m"], "properties": { "normal": { "$ref": "#/$defs/vec3" }, "offset_m": { "type": "number" } } },
"model": { "type": "object", "additionalProperties": false, "required": ["id", "artifact_hash"], "properties": { "id": { "type": "string", "minLength": 1, "maxLength": 128 }, "artifact_hash": { "$ref": "#/$defs/hash" } } },
"source": { "type": "object", "additionalProperties": false, "required": ["sensor_id", "authenticated", "replay_protected"], "properties": { "sensor_id": { "type": "string", "minLength": 1, "maxLength": 128 }, "authenticated": { "type": "boolean" }, "replay_protected": { "type": "boolean" } } },
"covariance": { "type": "object", "additionalProperties": false, "required": ["xx", "xy", "xz", "yy", "yz", "zz"], "properties": { "xx": { "type": "number", "minimum": 0 }, "xy": { "type": "number" }, "xz": { "type": "number" }, "yy": { "type": "number", "minimum": 0 }, "yz": { "type": "number" }, "zz": { "type": "number", "minimum": 0 } } },
"joint": { "type": "object", "additionalProperties": false, "required": ["kind", "position_m", "covariance_m2", "confidence", "visibility"], "properties": { "kind": { "enum": ["nose", "left_eye", "right_eye", "left_ear", "right_ear", "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", "left_wrist", "right_wrist", "left_hip", "right_hip", "left_knee", "right_knee", "left_ankle", "right_ankle"] }, "position_m": { "$ref": "#/$defs/vec3" }, "covariance_m2": { "$ref": "#/$defs/covariance" }, "confidence": { "$ref": "#/$defs/probability" }, "visibility": { "enum": ["visible", "occluded", "unknown"] } } }
}
}
@@ -0,0 +1,36 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ruview.net/schemas/pose-refinement-v1.schema.json",
"title": "PoseRefinementV1",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "raw_observation_hash", "mode", "disposition", "selected", "refined_joints_m", "physics_confidence", "effective_confidence", "intervention", "residuals", "refined_residuals", "contact_hypotheses", "dynamics", "provenance", "reason", "canonical_hash"],
"properties": {
"schema_version": { "const": 1 },
"raw_observation_hash": { "$ref": "#/$defs/hash" },
"mode": { "enum": ["off", "audit", "shadow_correct", "opt_in_correct", "default_correct"] },
"disposition": { "enum": ["bypassed", "audited2d", "audited", "shadowed", "corrected", "abstained", "rejected"] },
"selected": { "type": "boolean" },
"refined_joints_m": { "oneOf": [{ "type": "null" }, { "type": "array", "minItems": 17, "maxItems": 17, "items": { "$ref": "#/$defs/vec3" } }] },
"physics_confidence": { "$ref": "#/$defs/probability" },
"effective_confidence": { "$ref": "#/$defs/probability" },
"intervention": { "$ref": "#/$defs/intervention" },
"residuals": { "$ref": "#/$defs/residuals" },
"refined_residuals": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/residuals" }] },
"contact_hypotheses": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "$ref": "#/$defs/contact" } },
"dynamics": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/dynamics" }] },
"provenance": { "$ref": "#/$defs/provenance" },
"reason": { "enum": [null, "mode_off", "unsupported_schema", "hash_mismatch", "invalid_number", "invalid_covariance", "stale_input", "coordinate_frame_mismatch", "calibration_unavailable", "uncertainty_uncalibrated", "ood_unknown", "source_unauthenticated", "replay_protection_unavailable", "correction_not_authorized", "replay_rejected", "non_monotonic_input", "too_few_known_joints", "correction_too_large", "deadline_exceeded", "track_capacity", "internal_error"] },
"canonical_hash": { "$ref": "#/$defs/hash" }
},
"$defs": {
"probability": { "type": "number", "minimum": 0, "maximum": 1 },
"hash": { "type": "array", "minItems": 32, "maxItems": 32, "items": { "type": "integer", "minimum": 0, "maximum": 255 } },
"vec3": { "type": "array", "minItems": 3, "maxItems": 3, "items": { "type": "number" } },
"intervention": { "type": "object", "additionalProperties": false, "required": ["max_joint_correction_m", "root_correction_m", "corrected_joint_count", "solver_iterations", "elapsed_us"], "properties": { "max_joint_correction_m": { "type": "number", "minimum": 0 }, "root_correction_m": { "type": "number", "minimum": 0 }, "corrected_joint_count": { "type": "integer", "minimum": 0, "maximum": 17 }, "solver_iterations": { "type": "integer", "minimum": 0, "maximum": 8 }, "elapsed_us": { "type": "integer", "minimum": 0 } } },
"residuals": { "type": "object", "additionalProperties": false, "required": ["bone_m", "joint_limit_rad", "velocity_mps", "acceleration_mps2", "temporal_jerk", "floor_penetration_m", "contact_m", "collision_m", "normalized_total"], "properties": { "bone_m": { "type": "number", "minimum": 0 }, "joint_limit_rad": { "type": "number", "minimum": 0 }, "velocity_mps": { "type": "number", "minimum": 0 }, "acceleration_mps2": { "type": "number", "minimum": 0 }, "temporal_jerk": { "type": "number", "minimum": 0 }, "floor_penetration_m": { "type": "number", "minimum": 0 }, "contact_m": { "type": "number", "minimum": 0 }, "collision_m": { "type": "number", "minimum": 0 }, "normalized_total": { "type": "number", "minimum": 0 } } },
"contact": { "type": "object", "additionalProperties": false, "required": ["state", "probability"], "properties": { "state": { "enum": ["hypothesis", "measured", "unknown"] }, "probability": { "$ref": "#/$defs/probability" } } },
"dynamics": { "type": "object", "additionalProperties": false, "required": ["stable", "segment_count", "joint_count", "contact_count", "substeps", "tracking_error_m", "joint_anchor_error_m", "floor_penetration_m", "control_effort"], "properties": { "stable": { "type": "boolean" }, "segment_count": { "type": "integer", "minimum": 0, "maximum": 255 }, "joint_count": { "type": "integer", "minimum": 0, "maximum": 255 }, "contact_count": { "type": "integer", "minimum": 0, "maximum": 65535 }, "substeps": { "type": "integer", "minimum": 1, "maximum": 8 }, "tracking_error_m": { "type": "number", "minimum": 0 }, "joint_anchor_error_m": { "type": "number", "minimum": 0 }, "floor_penetration_m": { "type": "number", "minimum": 0 }, "control_effort": { "type": "number", "minimum": 0 } } },
"provenance": { "type": "object", "additionalProperties": false, "required": ["engine", "engine_version", "config_hash", "rf_model_hash", "calibration_id", "learned_artifact_hash"], "properties": { "engine": { "type": "string", "minLength": 1, "maxLength": 128 }, "engine_version": { "type": "string", "minLength": 1, "maxLength": 64 }, "config_hash": { "$ref": "#/$defs/hash" }, "rf_model_hash": { "$ref": "#/$defs/hash" }, "calibration_id": { "type": "string", "minLength": 1, "maxLength": 128 }, "learned_artifact_hash": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/hash" }] } } }
}
}
+40 -17
View File
@@ -1502,12 +1502,24 @@ action` pipeline, where a downstream consumer either gets a calibrated, provenan
answer or an explicit `UNKNOWN` — never a confident-looking guess outside the sensor's
proven operating envelope.
**Status: developer preview.** Phase 1 shipped nine new crates with their own test
suites, and each one works correctly in isolation. **They are not yet wired together or
into the live `sensing-server` request path** — there is currently no code path where a
real drift signal from a running sensor flows through calibration → certificate
invalidation → policy denial. Treat everything below as a library you can compose
yourself today, not a safety guarantee the server enforces for you yet.
**Status: developer preview, now wired at the crate level.** Phase 1 shipped nine new
crates. `ruview-certify` and `ruview-policy` now depend on `ruview-ood` and provide a
real adapter (`impl From<ruview_ood::DomainState> for _`) plus a composed entry point,
`ruview_policy::authorize_from_certificate`, that takes a real signed
`CapabilityCertificate` and a real `ruview_ood::DomainState` and drives them through
`authorize()` — not a hand-built `AssuranceInputs`. A cross-crate integration test
(`ruview-policy`'s `acceptance_test_b_real_integration` module) mints an actual signed
certificate and proves a real post-drift `Unknown` denies a `SafetyCritical` action
through that one composed pipeline.
**What's still not done:** none of this runs automatically inside the live
`sensing-server` request path yet — there is no continuous calibration/OOD-monitoring
loop wired into the running server that calls this pipeline on live sensor data. Treat
`authorize_from_certificate` as a real, tested library entry point you can call from your
own integration today, not something the server invokes for you on every request yet.
That remaining step is a genuinely separate, larger effort (deciding polling cadence,
where calibration state lives, what triggers re-certification) — see ADR-300 for the
phased plan.
### The crates
@@ -1539,30 +1551,41 @@ assert!(!cert.is_valid(now_ms, DomainState::Degraded));
assert!(!cert.is_valid(now_ms, DomainState::Unknown));
```
### Gating an action
### Gating an action from a real certificate + a real OOD reading
```rust
use ruview_policy::{authorize, ActionClass, DomainState};
use ruview_policy::authorize_from_certificate;
let decision = authorize(ActionClass::SafetyCritical, &inputs);
// Deny with a named FailedCondition (e.g. `domain_not_known`) rather than a
// silent false-positive, whenever the domain isn't KNOWN.
// `cert` (ruview_certify::CapabilityCertificate) and `domain`
// (ruview_ood::DomainState) come from your own certify/OOD calls.
let decision = authorize_from_certificate(
ActionClass::SafetyCritical,
&cert, &verifier, now_unix_s, domain,
certificate_class, uncertainty, evidence_level,
);
// Deny with a named FailedCondition (e.g. DomainNotKnown) — not a silent
// false-positive — the moment `domain` degrades, even though `cert` itself
// is still validly signed and unexpired.
```
**Important:** `ruview_certify::DomainState` and `ruview_policy::DomainState` (and
`ruview_ood`'s) are currently three separate enum types — `ruview-ood`'s `Degraded`
variant even carries different data. There is no automatic conversion between them.
If you compose these crates yourself today, you own writing that bridge; don't assume
one crate's domain read automatically reaches another's gate.
`ruview_certify::DomainState` and `ruview_policy::DomainState` are still each their own
type (`ruview-ood`'s `Degraded`/`Unknown` additionally carry a `DomainCause`), but the
conversion between them is no longer something you have to write yourself —
`authorize_from_certificate` does it via the crates' own `From<ruview_ood::DomainState>`
impls.
### What's genuinely enforced today, for comparison
Not every ADR-295–296 remediation item is preview-only. Two are live now:
Not every ADR-295–296 remediation item is preview-only. Three are live now:
- **UDP data-plane bind hardening (ADR-296)** — `sensing-server`'s `UdpSourceAllowlist`
is checked on every incoming packet (`main.rs`), not just defined.
- **CSI data-incident repo controls (ADR-299)** — `scripts/csi-data-policy-check.sh`
runs in CI on every push/PR and fails the build on a policy violation.
- **Synthetic-export watermarking (ADR-295)** — `start_recording` stamps a `SYNTHETIC`
watermark on a recording's metadata (`GET /api/v1/recordings`, the start-recording
response) whenever it captures while the live source is synthetic — an operator
browsing or scripting against recordings can't mistake generated data for a capture.
---
@@ -0,0 +1,378 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RuView · ADR-324 · off-axis window (ruview-offaxis WASM)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='7' y='9' width='18' height='14' rx='2' fill='none' stroke='%23e8a634' stroke-width='2'/><circle cx='16' cy='16' r='3' fill='%23e8a634'/></svg>">
<style>
:root {
--bg: #0a0a0a;
--bg-panel: rgba(0, 0, 0, 0.88);
--amber: #e8a634;
--amber-dim: #4a3a1a;
--amber-hot: #ffc04d;
--grid-major: #444444;
--grid-minor: #222222;
--green: #4f4;
--blue: #4cf;
--red: #f66;
--text-mute: #888;
--border: #2a2a2a;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--amber);
font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
canvas { display: block; }
#info {
position: absolute;
top: 16px;
left: 16px;
padding: 14px 16px;
background: var(--bg-panel);
border: 1px solid var(--amber);
border-radius: 8px;
min-width: 280px;
max-width: 360px;
font-size: 12px;
line-height: 1.55;
z-index: 10;
backdrop-filter: blur(6px);
box-shadow: 0 4px 24px rgba(232, 166, 52, 0.08);
}
#info h1 { margin: 0 0 2px 0; font-size: 14px; letter-spacing: 0.5px; }
#info .sub { font-size: 11px; color: var(--text-mute); margin-bottom: 10px; }
#info .row { display: flex; justify-content: space-between; gap: 12px; margin: 2px 0; }
#info .row .k { color: var(--text-mute); }
#info .row .v { color: var(--amber); font-variant-numeric: tabular-nums; }
#info .row .v.live { color: var(--green); }
#info .row .v.warn { color: var(--red); }
/* The mandatory ADR-324 §2.4 mode label: always visible while RF
drives the camera; there is no configuration that hides it. */
#mode-label {
position: absolute;
top: 16px;
right: 16px;
padding: 8px 14px;
background: var(--bg-panel);
border: 1px solid var(--blue);
border-radius: 8px;
color: var(--blue);
font-size: 12px;
z-index: 11;
}
#mode-label.rf { border-color: var(--red); color: var(--red); }
#controls {
position: absolute;
bottom: 16px;
left: 16px;
padding: 12px 16px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px;
z-index: 10;
max-width: 340px;
}
#controls h2 { margin: 0 0 8px 0; font-size: 12px; color: var(--text-mute); }
#controls label { display: flex; justify-content: space-between; gap: 8px; margin: 4px 0; align-items: center; }
#controls input[type="number"] {
width: 70px; background: #111; border: 1px solid var(--border);
color: var(--amber); font-family: inherit; font-size: 12px; padding: 2px 6px; border-radius: 4px;
}
#controls input[type="text"] {
width: 190px; background: #111; border: 1px solid var(--border);
color: var(--amber); font-family: inherit; font-size: 11px; padding: 2px 6px; border-radius: 4px;
}
#controls button {
background: var(--amber-dim); border: 1px solid var(--amber); color: var(--amber-hot);
font-family: inherit; font-size: 12px; padding: 4px 10px; border-radius: 5px; cursor: pointer; margin-top: 6px;
}
#controls button:hover { background: var(--amber); color: #000; }
#wasm-missing {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.92);
z-index: 50;
}
#wasm-missing .box {
max-width: 560px; border: 1px solid var(--amber); border-radius: 10px;
background: var(--bg-panel); padding: 22px 26px; font-size: 13px; line-height: 1.7;
}
#wasm-missing code { color: var(--amber-hot); background: #151005; padding: 1px 5px; border-radius: 4px; display: block; margin: 4px 0; }
</style>
</head>
<body>
<div id="info">
<h1>OFF-AXIS WINDOW</h1>
<div class="sub">ADR-324 · clean-room Kooima projection · ruview-offaxis (Rust→WASM)</div>
<div class="row"><span class="k">engine</span><span class="v" id="hud-engine">loading…</span></div>
<div class="row"><span class="k">input</span><span class="v" id="hud-input">mouse (SYNTHETIC)</span></div>
<div class="row"><span class="k">eye x/y/z (m)</span><span class="v" id="hud-eye">—</span></div>
<div class="row"><span class="k">render fps</span><span class="v live" id="hud-fps">—</span></div>
<div class="row"><span class="k">rf socket</span><span class="v" id="hud-ws">not connected</span></div>
<div class="row"><span class="k">rf peak</span><span class="v" id="hud-peak">—</span></div>
<div class="row" style="margin-top:8px"><span class="k" style="font-size:10px">
keys: <b>M</b> mouse · <b>R</b> RF Tier B · wheel = distance</span></div>
</div>
<div id="mode-label">MOUSE SIM — SYNTHETIC INPUT</div>
<div id="controls">
<h2>PHYSICAL CALIBRATION (stored locally)</h2>
<label>screen width (cm) <input id="cal-w" type="number" step="0.5" value="60"></label>
<label>screen height (cm) <input id="cal-h" type="number" step="0.5" value="34"></label>
<label>viewing distance (cm) <input id="cal-d" type="number" step="1" value="65"></label>
<h2 style="margin-top:10px">RF SOURCE (Tier B)</h2>
<label>ws url <input id="ws-url" type="text" value="ws://127.0.0.1:8080/ws/sensing"></label>
<button id="apply">apply calibration</button>
</div>
<div id="wasm-missing">
<div class="box">
<b>ruview-offaxis WASM module not found.</b><br><br>
This demo loads the crate's wasm-bindgen output from
<code>v2/crates/ruview-offaxis/pkg/</code>. Generated artifacts are not
committed (repo rule); build them once:
<code>cd v2 && cargo build -p ruview-offaxis --target wasm32-unknown-unknown --release</code>
<code>wasm-bindgen --target web --out-dir crates/ruview-offaxis/pkg \
target/wasm32-unknown-unknown/release/ruview_offaxis.wasm</code>
(install the CLI with <code>cargo install wasm-bindgen-cli --version 0.2.114</code>)<br>
then reload. Full steps: <code>v2/crates/ruview-offaxis/README.md</code>.
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script type="module">
// ADR-324 demo: the projection math lives in Rust/WASM (ruview-offaxis).
// This file only wires inputs (mouse SYNTHETIC sim, or /ws/sensing
// signal_field for the labeled Tier B coarse-parallax mode) into the
// WASM camera and copies its matrices onto a three.js camera.
const $ = (id) => document.getElementById(id);
// ---- Load the WASM module (not committed; user builds it once). ----
let wasm;
try {
wasm = await import('../../../v2/crates/ruview-offaxis/pkg/ruview_offaxis.js');
await wasm.default();
$('hud-engine').textContent = 'ruview-offaxis wasm';
} catch (e) {
console.error('ruview-offaxis pkg not found', e);
$('wasm-missing').style.display = 'flex';
throw e;
}
const { OffAxisCamera, RfParallax } = wasm;
// ---- Calibration (persisted locally; never leaves the browser). ----
const CAL_KEY = 'ruview-offaxis-demo-cal';
const saved = JSON.parse(localStorage.getItem(CAL_KEY) || 'null');
if (saved) { $('cal-w').value = saved.w; $('cal-h').value = saved.h; $('cal-d').value = saved.d; }
const cal = () => ({ w: +$('cal-w').value || 60, h: +$('cal-h').value || 34, d: +$('cal-d').value || 65 });
let cam = new OffAxisCamera(cal().w, cal().h, cal().d, 0.05, 100.0);
cam.set_filter(1.2, 0.4); // interactive: light smoothing, quick catch-up
let rf = new RfParallax(cal().d / 100);
$('apply').onclick = () => {
const c = cal();
localStorage.setItem(CAL_KEY, JSON.stringify(c));
cam = new OffAxisCamera(c.w, c.h, c.d, 0.05, 100.0);
cam.set_filter(1.2, 0.4);
rf = new RfParallax(c.d / 100);
buildRoom(); // room proportions follow the physical screen
};
// ---- three.js scene: a room extending behind the screen plane. ----
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0a);
// Camera is fully driven by the WASM matrices.
const camera = new THREE.PerspectiveCamera();
camera.matrixAutoUpdate = false;
let room = new THREE.Group();
function buildRoom() {
scene.remove(room);
room = new THREE.Group();
const c = cal();
const W = c.w / 100, H = c.h / 100, DEPTH = Math.max(W, 0.8) * 2.0;
// Wireframe box behind the screen: the classic "window" cue.
const boxGeo = new THREE.BoxGeometry(W, H, DEPTH);
const edges = new THREE.EdgesGeometry(boxGeo);
const box = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xe8a634 }));
box.position.z = -DEPTH / 2; // screen plane is z = 0
room.add(box);
// Depth rails: rows of columns receding into the box.
const colMat = new THREE.MeshStandardMaterial({ color: 0x4a3a1a, emissive: 0x2a1f08 });
for (let i = 1; i <= 6; i++) {
for (const sx of [-1, 1]) {
const col = new THREE.Mesh(new THREE.CylinderGeometry(0.008, 0.008, H * 0.9, 12), colMat);
col.position.set(sx * W * 0.42, 0, -DEPTH * i / 7);
room.add(col);
}
}
// Floating objects at staggered depths (parallax targets).
const knotMat = new THREE.MeshStandardMaterial({ color: 0xe8a634, metalness: 0.4, roughness: 0.35 });
const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(H * 0.18, H * 0.05, 120, 16), knotMat);
knot.position.set(0, 0, -DEPTH * 0.45);
knot.name = 'knot';
room.add(knot);
const orb = new THREE.Mesh(
new THREE.IcosahedronGeometry(H * 0.08, 1),
new THREE.MeshStandardMaterial({ color: 0x4cf0ff, emissive: 0x0a3540 })
);
orb.position.set(-W * 0.22, H * 0.18, -DEPTH * 0.18);
room.add(orb);
// One object slightly IN FRONT of the screen plane — pops "out".
const pop = new THREE.Mesh(
new THREE.OctahedronGeometry(H * 0.05),
new THREE.MeshStandardMaterial({ color: 0xffc04d, emissive: 0x604010 })
);
pop.position.set(W * 0.28, -H * 0.2, 0.06);
room.add(pop);
room.add(new THREE.AmbientLight(0xffffff, 0.35));
const key = new THREE.PointLight(0xffe0a0, 1.0);
key.position.set(0.3, 0.4, 0.5);
room.add(key);
scene.add(room);
}
buildRoom();
// ---- Input modes. ----
// 'mouse' — SYNTHETIC eye simulator (always available, no hardware).
// 'rf' — Tier B: /ws/sensing signal_field → RfParallax.
// Labeled coarse body parallax, NOT head tracking (ADR-324 §2.4).
let mode = 'mouse';
const modeLabel = $('mode-label');
function setMode(m) {
mode = m;
if (m === 'rf') {
modeLabel.textContent = 'RF COARSE BODY PARALLAX — NOT HEAD TRACKING';
modeLabel.classList.add('rf');
$('hud-input').textContent = 'rf field peak (Tier B)';
connectWs();
} else {
modeLabel.textContent = 'MOUSE SIM — SYNTHETIC INPUT';
modeLabel.classList.remove('rf');
$('hud-input').textContent = 'mouse (SYNTHETIC)';
}
}
window.addEventListener('keydown', (e) => {
if (e.key === 'm' || e.key === 'M') setMode('mouse');
if (e.key === 'r' || e.key === 'R') setMode('rf');
});
// Mouse sim: pointer position maps to a ±0.3 m eye excursion;
// wheel adjusts distance.
let mouseEye = { x: 0, y: 0, d: cal().d / 100 };
window.addEventListener('pointermove', (e) => {
mouseEye.x = (e.clientX / window.innerWidth - 0.5) * 0.6;
mouseEye.y = (0.5 - e.clientY / window.innerHeight) * 0.4;
});
window.addEventListener('wheel', (e) => {
mouseEye.d = Math.min(2.5, Math.max(0.2, mouseEye.d + e.deltaY * 0.0005));
}, { passive: true });
// ---- RF Tier B input: /ws/sensing sensing_update.signal_field. ----
let ws = null;
function connectWs() {
if (ws) { try { ws.close(); } catch (_) {} }
const url = $('ws-url').value;
$('hud-ws').textContent = 'connecting…';
try { ws = new WebSocket(url); } catch (e) {
$('hud-ws').textContent = 'invalid url'; return;
}
ws.onopen = () => { $('hud-ws').textContent = 'connected'; };
ws.onclose = () => { $('hud-ws').textContent = 'closed'; };
ws.onerror = () => { $('hud-ws').textContent = 'error (server up? ticket needed?)'; };
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
const field = msg.signal_field || (msg.data && msg.data.signal_field);
if (!field || !field.values) return;
const nx = field.grid_size || field.nx || 20;
const nz = field.grid_size || field.nz || 20;
const values = Float32Array.from(field.values);
const found = rf.update(values, nx, nz, performance.now() / 1000);
$('hud-peak').textContent = found
? `value ${rf.peak_value().toFixed(2)} (≥ 0.35 gate)`
: 'below 0.35 gate — holding';
// Provenance surfaced verbatim (ADR-295: synthetic never
// presents as live).
if (msg.provenance || msg.source) {
$('hud-ws').textContent = `connected · src: ${msg.provenance || msg.source}`;
}
} catch (_) { /* non-JSON frame */ }
};
}
// ---- Render loop: one WASM call, three matrix copies, render. ----
const tmp = new THREE.Matrix4();
let frames = 0, lastFps = performance.now();
function animate() {
requestAnimationFrame(animate);
const t = performance.now() / 1000;
if (mode === 'mouse') {
cam.update_eye(mouseEye.x, mouseEye.y, mouseEye.d, t);
} else {
const e = rf.eye(); // bounded coarse-parallax eye (metres)
cam.update_eye(e[0], e[1], e[2], t);
}
// Copy the Kooima matrices onto the three.js camera.
camera.projectionMatrix.fromArray(cam.projection());
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
tmp.fromArray(cam.view());
camera.matrixWorld.copy(tmp).invert(); // world = inverse(view)
camera.matrixWorldInverse.copy(tmp);
const knot = room.getObjectByName('knot');
if (knot) { knot.rotation.y += 0.003; knot.rotation.x += 0.001; }
renderer.render(scene, camera);
const eye = cam.eye();
$('hud-eye').textContent = `${eye[0].toFixed(3)} / ${eye[1].toFixed(3)} / ${eye[2].toFixed(3)}`;
frames++;
const now = performance.now();
if (now - lastFps > 1000) {
$('hud-fps').textContent = String(frames);
frames = 0; lastFps = now;
}
}
animate();
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight);
// NOTE: no camera.aspect update — the frustum is fully determined
// by the physical screen calibration, not the browser viewport.
});
</script>
</body>
</html>
+1 -1
View File
@@ -54,7 +54,7 @@
},
"filesDigest": "278e166323774f53215cb493818bdedff39ea0aab94cfaf6eeea216c90929e41",
"brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
"gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd",
"gateFingerprint": "06c79d85776260f1d36d1387760357c12410180faacb25f0d4850f2039ab2ea9",
"developmentPins": {
"@metaharness/darwin": "0.8.0",
"@metaharness/flywheel": "0.1.7",
+1 -1
View File
@@ -1 +1 @@
81db8a57fc4ae77b4a70078d454638c73a501bb7c46193bb99823a817d3cee9e manifest.json
026cb69f165dab97e299a96ee67169c7602cd26d5dec392284df619ed85f47c6 manifest.json
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import {
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const provenanceFiles = [
"harness/ruview/package.json",
"harness/ruview/.harness/manifest.json",
"harness/ruview/.harness/manifest.sha256",
];
function git(args, cwd = repoRoot) {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
test("sensing-server session secret is ignored at the v2 runtime path", () => {
assert.doesNotThrow(() =>
git(["check-ignore", "--no-index", "--quiet", "--", "v2/data/session-secret"]),
);
});
test("harness provenance files are checked out with LF line endings", () => {
const attributes = git(["check-attr", "text", "eol", "--", ...provenanceFiles]);
for (const file of provenanceFiles) {
assert.match(attributes, new RegExp(`${file}: text: auto`));
assert.match(attributes, new RegExp(`${file}: eol: lf`));
}
});
test("core.autocrlf checkout preserves LF bytes for harness provenance", () => {
const scratch = mkdtempSync(join(tmpdir(), "ruview-lf-checkout-"));
const source = join(scratch, "source");
const checkout = join(scratch, "checkout");
try {
mkdirSync(join(source, "harness/ruview/.harness"), { recursive: true });
copyFileSync(join(repoRoot, ".gitattributes"), join(source, ".gitattributes"));
writeFileSync(join(source, "harness/ruview/.harness/manifest.json"), '{\n "ok": true\n}\n');
writeFileSync(join(source, "harness/ruview/.harness/manifest.sha256"), "digest manifest.json\n");
git(["init", "--quiet"], source);
git(["config", "user.email", "test@example.invalid"], source);
git(["config", "user.name", "RuView test"], source);
git(["add", "."], source);
git(["commit", "--quiet", "-m", "test fixture"], source);
execFileSync("git", ["-c", "core.autocrlf=true", "clone", "--quiet", source, checkout]);
for (const relativePath of provenanceFiles.slice(1)) {
const bytes = readFileSync(join(checkout, relativePath));
assert.equal(bytes.includes(Buffer.from("\r\n")), false, `${relativePath} must remain LF`);
}
} finally {
rmSync(scratch, { recursive: true, force: true });
}
});
+1
View File
@@ -0,0 +1 @@
{"ankle_penetration_m":0.04,"evidence":"SYNTHETIC/L0","expected_raw_hash":"98caf45c249d584d52ede84e2d76bd8c4bf225d426a4ef1651a5ddb178258b88","expected_result_hash":"8ef2ded432c938055a5ebc86eadff811216884093853f5c740c7aff7708b9bef","sequence":1}
+21
View File
@@ -0,0 +1,21 @@
{
"schema_version": 1,
"records": [
{
"split": "train",
"sequence": "sequence-1",
"take": "take-1",
"subject": "subject-1",
"room": "room-1",
"calibration_session": "calibration-1"
},
{
"split": "test",
"sequence": "sequence-2",
"take": "take-2",
"subject": "subject-1",
"room": "room-2",
"calibration_session": "calibration-2"
}
]
}
@@ -0,0 +1,8 @@
{
"evidence": "SYNTHETIC/L0",
"records": [
{ "split": "train", "sequence": "syn-train-seq", "take": "syn-train-take", "subject": "syn-train-subject", "room": "syn-train-room", "calibration_session": "syn-train-cal" },
{ "split": "validation", "sequence": "syn-validation-seq", "take": "syn-validation-take", "subject": "syn-validation-subject", "room": "syn-validation-room", "calibration_session": "syn-validation-cal" },
{ "split": "test", "sequence": "syn-test-seq", "take": "syn-test-take", "subject": "syn-test-subject", "room": "syn-test-room", "calibration_session": "syn-test-cal" }
]
}
@@ -0,0 +1,25 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const result = spawnSync(
"cargo",
["tree", "-p", "wifi-densepose-physics", "-e", "normal", "--prefix", "none"],
{ cwd: path.join(root, "v2"), encoding: "utf8" },
);
if (result.error) throw result.error;
if (result.status !== 0) {
if (result.stdout) process.stderr.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
process.exit(result.status ?? 1);
}
const forbidden = /^(?:burn(?:-|\s|$)|rapier3d(?:\s|$)|tch(?:\s|$)|ort(?:\s|$))/m;
if (forbidden.test(result.stdout)) {
process.stderr.write(result.stdout);
throw new Error("default physics dependency graph contains an optional heavy backend");
}
process.stdout.write(JSON.stringify({ verdict: "PASS", profile: "default-kinematic" }) + "\n");
+16
View File
@@ -0,0 +1,16 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const input = process.argv[2];
if (!input) throw new Error("usage: replay-pose-physics-golden.sh <golden-results.jsonl>");
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const result = spawnSync(
"cargo",
["run", "--quiet", "-p", "wifi-densepose-physics", "--features", "deterministic", "--example", "verify_golden", "--", path.resolve(input)],
{ cwd: path.join(root, "v2"), encoding: "utf8" },
);
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
@@ -0,0 +1,27 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const directory = path.dirname(fileURLToPath(import.meta.url));
const run = (script, fixture) =>
spawnSync(process.execPath, [path.join(directory, script), path.join(directory, "testdata", fixture)], {
cwd: path.resolve(directory, "../.."),
encoding: "utf8",
});
const strict = run("verify-splits.mjs", "strict-split-manifest.json");
if (strict.status !== 0 || !strict.stdout.includes('"verdict":"PASS"')) {
throw new Error(`strict split fixture failed: ${strict.stderr || strict.stdout}`);
}
const leaky = run("verify-splits.mjs", "leaky-split-manifest.json");
if (leaky.status === 0 || !leaky.stderr.includes("leakage:")) {
throw new Error("leaky split fixture was not rejected with a typed leakage error");
}
const golden = run("verify-golden.mjs", "golden-results.jsonl");
if (golden.status !== 0 || !golden.stdout.includes('"verdict":"PASS"')) {
throw new Error(`golden replay fixture failed: ${golden.stderr || golden.stdout}`);
}
process.stdout.write(JSON.stringify({ verdict: "PASS", checks: 3 }) + "\n");
+23
View File
@@ -0,0 +1,23 @@
import fs from "node:fs";
import crypto from "node:crypto";
const path = process.argv[2];
if (!path) throw new Error("usage: verify-pose-physics-splits.sh <manifest.json>");
const bytes = fs.readFileSync(path);
const manifest = JSON.parse(bytes);
const dimensions = ["sequence", "take", "subject", "room", "calibration_session"];
if (!Array.isArray(manifest.records) || manifest.records.length === 0) throw new Error("manifest.records must be non-empty");
const ownership = new Map();
for (const record of manifest.records) {
if (!record.split || !["train", "validation", "test"].includes(record.split)) throw new Error("invalid split");
for (const dimension of dimensions) {
const value = record[dimension];
if (typeof value !== "string" || value.length === 0) throw new Error(`missing ${dimension}`);
const key = `${dimension}:${value}`;
const prior = ownership.get(key);
if (prior && prior !== record.split) throw new Error(`leakage: ${key} crosses ${prior}/${record.split}`);
ownership.set(key, record.split);
}
}
const digest = crypto.createHash("sha256").update(bytes).digest("hex");
console.log(JSON.stringify({ verdict: "PASS", records: manifest.records.length, sha256: digest }));
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
node "$(dirname "$0")/pose-physics/verify-golden.mjs" "$@"
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
node "$(dirname "$0")/pose-physics/verify-splits.mjs" "$@"
+17 -7
View File
@@ -140,13 +140,23 @@ function init() {
csiCnn.tryLoadWasm(wasmBase);
// Auto-connect to local sensing server WebSocket if available.
// Served from the Docker image the sensing stream lives on :3001 (same
// port mapping sensing.service.js uses); the standalone dev server stays
// on :8765.
const wsPortMap = { '3000': '3001' };
const mappedPort = wsPortMap[window.location.port];
const defaultWsUrl = mappedPort
? `ws://${window.location.hostname}:${mappedPort}/ws/sensing`
// Served from the Docker image, the sensing stream lives one port above the
// HTTP port (3000 -> 3001 is the documented default mapping); the
// standalone dev server stays on :8765.
//
// Issue #1557: a hardcoded `{'3000': '3001'}` map only worked when the UI
// was served on exactly port 3000 — remapping the host port (e.g.
// `-p 3010:3000 -p 3011:3001`, needed whenever 3000 is already taken) fell
// through to `localhost:8765`, which for a remote viewer is nothing on
// *their* machine. `connectLive()` then failed silently. Derive the WS port
// from the actual HTTP port instead of a fixed lookup table, so it follows
// any host-port remapping automatically. This is a best-effort default, not
// a safety mechanism — `onVerifiedFrame` below (issue #1557 fix) is what
// actually prevents an unconfirmed/failed connection from ever being shown
// as LIVE, regardless of whether this guess is right.
const httpPort = Number(window.location.port);
const defaultWsUrl = Number.isFinite(httpPort) && httpPort > 0
? `ws://${window.location.hostname}:${httpPort + 1}/ws/sensing`
: 'ws://localhost:8765/ws/sensing';
if (wsUrlInput) wsUrlInput.value = defaultWsUrl;
// ADR-272: exchange the stored bearer for a single-use ?ticket= before the
+7 -1
View File
@@ -1,4 +1,5 @@
import { withWsTicket } from './ws-ticket.js';
import { apiService } from './api.service.js';
/**
* Sensing WebSocket Service
*
@@ -308,8 +309,13 @@ class SensingService {
// an *unknown* state — it must NOT collapse to "live". Prefer the canonical
// `source_state` the server now returns; on any error stay conservative
// (server-simulated) until a real frame's `source` field promotes us.
//
// Send the bearer token via `apiService.getHeaders()` so the probe can
// actually succeed under the documented secure posture (API auth
// enabled) instead of always 401ing and relying solely on the
// conservative fallback below (issue #1526, suggested fix #1).
try {
const resp = await fetch('/api/v1/status');
const resp = await fetch('/api/v1/status', { headers: apiService.getHeaders() });
if (resp.ok) {
const json = await resp.json();
this._applyServerSource(json.source, json.source_state);
Generated
+2376 -127
View File
File diff suppressed because it is too large. Load diff
+16
View File
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"crates/wifi-densepose-core",
"crates/wifi-densepose-physics", # ADR-323 bounded pose assessment/correction
"crates/wifi-densepose-signal",
"crates/wifi-densepose-nn",
# wifi-densepose-api / -db / -config: removed in #578.
@@ -36,6 +37,7 @@ members = [
# 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/ruview-cognitum-spaces", # ADR-325 Cognitum Spaces read client
"crates/nvsim",
"crates/nvsim-server",
"crates/homecore", # ADR-127 — HOMECORE state machine
@@ -117,6 +119,9 @@ members = [
"crates/ruview-counterfactual",# ADR-313 counterfactual spatial inference
"crates/ruview-infogain", # ADR-314 information-gain scheduler
"crates/ruview-active", # ADR-309 active sensing control
# ADR-324 — clean-room Kooima off-axis (head-coupled perspective) projection.
# Dependency-free native core; wasm-bindgen surface only on wasm32.
"crates/ruview-offaxis",
]
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
# excluded from workspace to avoid breaking `cargo test --workspace`.
@@ -155,6 +160,16 @@ serde_yaml = "0.9"
tokio = { version = "1.35", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
nalgebra = "0.34"
smallvec = "1.15"
bitflags = "2.9"
rapier3d = { version = "0.33", default-features = false, features = ["std", "dim3", "f32"] }
burn-core = { version = "0.21", default-features = false, features = ["std"] }
burn-nn = { version = "0.21", default-features = false }
burn-ndarray = { version = "0.21", default-features = false, features = ["std"] }
burn-train = { version = "0.21", default-features = false }
burn-wgpu = { version = "0.21", default-features = false, features = ["std"] }
burn-cuda = { version = "0.21", default-features = false, features = ["std"] }
# Signal processing
ndarray = { version = "0.17", features = ["serde"] }
@@ -251,6 +266,7 @@ ruvector-gnn = { version = "2.2.0", default-features = false }
# Internal crates
wifi-densepose-core = { version = "0.3.0", path = "crates/wifi-densepose-core" }
wifi-densepose-physics = { version = "0.1.0", path = "crates/wifi-densepose-physics" }
wifi-densepose-signal = { version = "0.3.0", path = "crates/wifi-densepose-signal" }
wifi-densepose-nn = { version = "0.3.0", path = "crates/wifi-densepose-nn" }
wifi-densepose-api = { version = "0.3.0", path = "crates/wifi-densepose-api" }
+1
View File
@@ -36,6 +36,7 @@ safetensors = "0.4"
# wifi-densepose-train re-exports the model types we need; depend by path
# inside the workspace.
wifi-densepose-train = { version = "0.3.1", path = "../wifi-densepose-train", default-features = false }
wifi-densepose-core = { workspace = true, features = ["serde"] }
[dev-dependencies]
tempfile = "3"
+114 -1
View File
@@ -20,6 +20,11 @@ use candle_core::{DType, Device, Tensor};
use candle_nn::{Conv1d, Conv1dConfig, Linear, Module, VarBuilder};
use std::path::Path;
use std::sync::Arc;
use wifi_densepose_core::{
CalibrationId, Coco17Joint, JointObservation, JointVisibility, ModelRef, PoseDimensionality,
PoseObservationV2, PoseTrustState, Probability, SourceProvenance, SpatialFrameRef,
SymmetricCovariance3, TrackId,
};
/// 56 subcarriers × 20 frames per CSI window — matches the format
/// produced by `scripts/align-ground-truth.js` after #641.
@@ -50,10 +55,87 @@ pub struct PoseOutput {
pub confidence: f32,
}
/// Process-owned provenance required to wrap the current two-dimensional
/// observer output without claiming metric depth or calibrated uncertainty.
#[derive(Debug, Clone)]
pub struct ImageObservationContext {
pub timestamp_ns: u64,
pub sensor_epoch: u64,
pub sequence: u64,
pub model_id: String,
pub model_artifact_hash: [u8; 32],
}
impl PoseOutput {
pub fn is_finite(&self) -> bool {
self.keypoints.iter().all(|v| v.is_finite()) && self.confidence.is_finite()
}
/// Add the canonical ADR-323 contract while honestly retaining the
/// current observer's image-space and uncertainty limitations.
#[must_use]
pub fn to_image_observation(
&self,
context: ImageObservationContext,
) -> Option<PoseObservationV2> {
if self.keypoints.len() != OUTPUT_KEYPOINTS * 2 || !self.is_finite() {
return None;
}
let confidence = Probability::new(self.confidence.clamp(0.0, 1.0)).ok()?;
let joints = core::array::from_fn(|index| JointObservation {
kind: Coco17Joint::ALL[index],
position_m: [self.keypoints[index * 2], self.keypoints[index * 2 + 1], 0.0],
// Zero only means no covariance estimate is supplied. The explicit
// `uncertainty_calibrated=false` gate prevents correction.
covariance_m2: SymmetricCovariance3 {
xx: 0.0,
xy: 0.0,
xz: 0.0,
yy: 0.0,
yz: 0.0,
zz: 0.0,
},
confidence,
visibility: if confidence == Probability::ZERO {
JointVisibility::Unknown
} else {
JointVisibility::Visible
},
});
let mut observation = PoseObservationV2 {
schema_version: wifi_densepose_core::POSE_OBSERVATION_SCHEMA_VERSION,
timestamp_ns: context.timestamp_ns,
sensor_epoch: context.sensor_epoch,
sequence: context.sequence,
track_id: TrackId("local:1".into()),
frame: SpatialFrameRef {
name: "normalized-image".into(),
version: 1,
metric: false,
right_handed: false,
z_up: false,
},
calibration_id: CalibrationId("image:uncalibrated".into()),
floor_plane: None,
model: ModelRef {
id: context.model_id,
artifact_hash: context.model_artifact_hash,
},
source: SourceProvenance {
sensor_id: "sensing-server:loopback".into(),
authenticated: false,
replay_protected: false,
},
trust_state: PoseTrustState::Degraded,
dimensionality: PoseDimensionality::Image2d,
uncertainty_calibrated: false,
joints,
observer_confidence: confidence,
canonical_hash: [0; 32],
};
observation.seal();
Some(observation)
}
}
/// Per-room LoRA calibration adapter (ADR-150 §3.5–3.6). Low-rank deltas on the pose
@@ -185,6 +267,7 @@ impl PoseNet {
pub struct InferenceEngine {
inner: Option<Arc<LoadedModel>>,
device: Device,
artifact_hash: [u8; 32],
}
struct LoadedModel {
@@ -227,6 +310,7 @@ impl InferenceEngine {
adapter_path: Option<&Path>,
) -> Result<Self, Box<dyn std::error::Error>> {
let device = pick_device();
let artifact_hash = combined_artifact_hash(weights_path, adapter_path)?;
let inner = match weights_path {
Some(p) if p.exists() => {
// SAFETY: `from_mmaped_safetensors` mmaps the file for the
@@ -246,7 +330,11 @@ impl InferenceEngine {
}
_ => None,
};
Ok(Self { inner, device })
Ok(Self {
inner,
device,
artifact_hash,
})
}
/// Whether a per-room calibration adapter is currently attached.
@@ -266,6 +354,13 @@ impl InferenceEngine {
}
}
/// SHA-256 identity of the base weights and optional adapter, or zero for
/// the explicit no-model stub.
#[must_use]
pub const fn artifact_hash(&self) -> [u8; 32] {
self.artifact_hash
}
pub fn infer(&self, window: &CsiWindow) -> Result<PoseOutput, Box<dyn std::error::Error>> {
if window.data.len() != INPUT_SUBCARRIERS * INPUT_TIMESTEPS {
return Err(format!(
@@ -336,6 +431,24 @@ fn pick_device() -> Device {
Device::Cpu
}
fn combined_artifact_hash(
weights_path: Option<&Path>,
adapter_path: Option<&Path>,
) -> Result<[u8; 32], Box<dyn std::error::Error>> {
use sha2::{Digest, Sha256};
let Some(weights_path) = weights_path.filter(|path| path.exists()) else {
return Ok([0; 32]);
};
let mut hasher = Sha256::new();
hasher.update(b"ruview.pose-observer-artifacts-v1\0");
hasher.update(std::fs::read(weights_path)?);
if let Some(adapter_path) = adapter_path.filter(|path| path.exists()) {
hasher.update(b"adapter\0");
hasher.update(std::fs::read(adapter_path)?);
}
Ok(hasher.finalize().into())
}
fn default_weights_path() -> Option<std::path::PathBuf> {
// Search in the order an installed Cog would see it.
let candidates = [
+4 -1
View File
@@ -125,7 +125,10 @@ fn cmd_run(
);
}
let engine = InferenceEngine::with_adapter(adapter.as_deref())?;
let engine = InferenceEngine::with_weights_and_adapter(
Some(cfg.model_path.as_path()),
adapter.as_deref(),
)?;
if engine.is_calibrated() {
tracing::info!("per-room calibration adapter loaded");
}
@@ -42,7 +42,12 @@ impl<'a> Event<'a> {
}
}
pub fn pose_frame(tick: u64, n_persons: usize, persons: Value) -> Self {
pub fn pose_frame(
tick: u64,
n_persons: usize,
persons: Value,
observation: &wifi_densepose_core::PoseObservationV2,
) -> Self {
Self {
ts: now_secs(),
level: "info",
@@ -51,6 +56,7 @@ impl<'a> Event<'a> {
"tick": tick,
"n_persons": n_persons,
"persons": persons,
"observation": observation,
}),
}
}
+29 -2
View File
@@ -2,7 +2,9 @@
//! runs a CSI window through the engine, emits `pose.frame` events.
use crate::config::CogConfig;
use crate::inference::{CsiWindow, InferenceEngine, INPUT_SUBCARRIERS, INPUT_TIMESTEPS};
use crate::inference::{
CsiWindow, ImageObservationContext, InferenceEngine, INPUT_SUBCARRIERS, INPUT_TIMESTEPS,
};
use crate::publisher::{emit_event, Event};
use std::time::Duration;
use tokio::time::sleep;
@@ -13,6 +15,14 @@ pub async fn run_loop(
) -> Result<(), Box<dyn std::error::Error>> {
let mut buffer: Vec<f32> = Vec::with_capacity(INPUT_SUBCARRIERS * INPUT_TIMESTEPS);
let mut tick: u64 = 0;
let process_started = std::time::Instant::now();
let sensor_epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_nanos()
.try_into()
.unwrap_or(u64::MAX);
let model_id = format!("pose-estimation:{}", engine.backend());
let model_artifact_hash = engine.artifact_hash();
loop {
// Poll one frame from the sensing-server. On error, sleep and retry —
@@ -34,7 +44,24 @@ pub async fn run_loop(
"keypoints": chunk_pairs(&out.keypoints),
"confidence": out.confidence,
}]);
emit_event(&Event::pose_frame(tick, 1, persons));
let timestamp_ns = sensor_epoch.saturating_add(
process_started
.elapsed()
.as_nanos()
.try_into()
.unwrap_or(u64::MAX),
);
if let Some(observation) = out.to_image_observation(
ImageObservationContext {
timestamp_ns,
sensor_epoch,
sequence: tick,
model_id: model_id.clone(),
model_artifact_hash,
},
) {
emit_event(&Event::pose_frame(tick, 1, persons, &observation));
}
}
}
}
+47 -1
View File
@@ -5,7 +5,8 @@
use cog_pose_estimation::{
inference::{
InferenceEngine, SyntheticInput, INPUT_SUBCARRIERS, INPUT_TIMESTEPS, OUTPUT_KEYPOINTS,
ImageObservationContext, InferenceEngine, PoseOutput, SyntheticInput, INPUT_SUBCARRIERS,
INPUT_TIMESTEPS, OUTPUT_KEYPOINTS,
},
manifest::ManifestSpec,
};
@@ -108,6 +109,8 @@ fn per_room_adapter_changes_inference_output() {
assert!(!base.is_calibrated(), "base must report uncalibrated");
assert!(cal.is_calibrated(), "adapter engine must report calibrated");
assert_ne!(base.artifact_hash(), [0; 32]);
assert_ne!(base.artifact_hash(), cal.artifact_hash());
// Non-zero input — a zero window would zero the LoRA delta (x·A·B = 0).
let win = cog_pose_estimation::inference::CsiWindow {
@@ -173,6 +176,49 @@ fn manifest_roundtrips() {
assert_eq!(back.version, "0.0.1");
}
#[test]
fn current_observer_is_canonical_image_2d_and_never_claims_calibration() {
let output = PoseOutput {
keypoints: vec![0.5; OUTPUT_KEYPOINTS * 2],
confidence: 0.185,
};
let observation = output
.to_image_observation(ImageObservationContext {
timestamp_ns: 10,
sensor_epoch: 9,
sequence: 1,
model_id: "pose-estimation:test".into(),
model_artifact_hash: [4; 32],
})
.unwrap();
assert_eq!(
observation.dimensionality,
wifi_densepose_core::PoseDimensionality::Image2d
);
assert!(!observation.frame.metric);
assert!(!observation.uncertainty_calibrated);
assert!(!observation.source.authenticated);
assert!(!observation.source.replay_protected);
assert_eq!(observation.canonical_hash, observation.compute_canonical_hash());
}
#[test]
fn malformed_image_pose_cannot_enter_canonical_contract() {
let output = PoseOutput {
keypoints: vec![0.5; 10],
confidence: 0.185,
};
assert!(output
.to_image_observation(ImageObservationContext {
timestamp_ns: 10,
sensor_epoch: 9,
sequence: 1,
model_id: "pose-estimation:test".into(),
model_artifact_hash: [4; 32],
})
.is_none());
}
/// ADR-159 §A1 — the default-config min_confidence threshold must not silently
/// suppress every `pose.frame`. With the old `default_min_confidence()=0.3` and
/// the model's per-frame confidence pinned at 0.185, the runtime gate
+4
View File
@@ -19,6 +19,10 @@ pub mod scope {
/// 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";
/// Read tenant-scoped P2/P3 semantic state from Cognitum Spaces.
/// This grants no raw sensing access and no action authority.
pub const SPACES_READ: &str = "spaces:read";
}
/// A verified caller. Constructed only by
+1
View File
@@ -13,6 +13,7 @@ blake3 = { version = "1.5", default-features = false }
ruview-ontology = { path = "../ruview-ontology" }
ruview-attest = { path = "../ruview-attest" }
ruview-evidence = { path = "../ruview-evidence" }
ruview-ood = { path = "../ruview-ood" }
wifi-densepose-calibration = { path = "../wifi-densepose-calibration", default-features = false }
[dev-dependencies]
+53
View File
@@ -106,6 +106,59 @@ impl DomainState {
}
}
/// The adapter boundary from the real `ruview-ood` gate result to this
/// crate's three-way domain signature (ADR-297/302). `ruview_ood::DomainState`
/// carries a `DomainCause` on `Degraded`/`Unknown`; `is_valid` only needs the
/// three-way outcome, so the cause is dropped here — this is the conversion
/// this crate's own `DomainState` doc comment already described but that
/// previously did not exist anywhere, leaving `ruview-ood`'s live drift result
/// with no path into a certificate check.
impl From<ruview_ood::DomainState> for DomainState {
fn from(state: ruview_ood::DomainState) -> Self {
match state {
ruview_ood::DomainState::Known => DomainState::Known,
ruview_ood::DomainState::Degraded(_) => DomainState::Degraded,
ruview_ood::DomainState::Unknown(_) => DomainState::Unknown,
}
}
}
#[cfg(test)]
mod domain_state_adapter_tests {
//! Pins the `ruview-ood` -> `ruview-certify` adapter boundary: a real OOD
//! gate result must map to the matching certify-side outcome, and — the
//! load-bearing case — a post-drift `Unknown` from `ood` must actually
//! invalidate an otherwise-valid certificate through this conversion,
//! not just when a test hand-constructs `certify::DomainState::Unknown`
//! directly.
use super::DomainState;
#[test]
fn known_maps_to_known() {
assert_eq!(DomainState::from(ruview_ood::DomainState::Known), DomainState::Known);
}
#[test]
fn degraded_drops_the_cause_and_maps_to_degraded() {
assert_eq!(
DomainState::from(ruview_ood::DomainState::Degraded(
ruview_ood::DomainCause::ModerateDrift
)),
DomainState::Degraded
);
}
#[test]
fn unknown_drops_the_cause_and_maps_to_unknown() {
assert_eq!(
DomainState::from(ruview_ood::DomainState::Unknown(
ruview_ood::DomainCause::DriftBeyondEnvelope
)),
DomainState::Unknown
);
}
}
/// The operating metrics frozen onto a certificate, sliced from the ADR-304
/// ledger for one exact context (never a global average).
///
@@ -0,0 +1,19 @@
[package]
name = "ruview-cognitum-spaces"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Bounded, privacy-preserving Cognitum Spaces client for RuView"
publish = false
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
url = "2"
[dev-dependencies]
tokio.workspace = true
+387
View File
@@ -0,0 +1,387 @@
//! Cognitum Spaces read client (ADR-325).
//!
//! This crate consumes tenant-scoped semantic P2/P3 state only. It never
//! uploads raw CSI/CIR, RF tensors, pose frames, vital waveforms, recordings,
//! or identity observations, and it exposes no action method.
use std::time::Duration;
use reqwest::redirect::Policy;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use url::Url;
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
const MAX_SPACES: usize = 100;
const MAX_JSON_DEPTH: usize = 16;
const MAX_STRING_BYTES: usize = 4096;
const REQUIRED_EXCLUSIONS: [&str; 7] = [
"raw_csi",
"cir",
"rf_tensors",
"recordings",
"pose_frames",
"vital_waveforms",
"identity_observations",
];
#[derive(Clone)]
pub enum Credential {
OAuth(String),
ApiKey(String),
}
impl std::fmt::Debug for Credential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OAuth(_) => f.write_str("OAuth(<redacted>)"),
Self::ApiKey(_) => f.write_str("ApiKey(<redacted>)"),
}
}
}
impl Credential {
pub fn oauth(token: impl Into<String>) -> Result<Self, Error> {
secret(Self::OAuth, token.into())
}
pub fn api_key(key: impl Into<String>) -> Result<Self, Error> {
let key = key.into();
if !key.starts_with("cog_") || key.len() == 4 {
return Err(Error::InvalidCredential);
}
secret(Self::ApiKey, key)
}
}
fn secret(make: impl FnOnce(String) -> Credential, value: String) -> Result<Credential, Error> {
if value.is_empty()
|| value.len() > 16_384
|| value.chars().any(char::is_whitespace)
|| value.chars().any(char::is_control)
{
return Err(Error::InvalidCredential);
}
Ok(make(value))
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Spaces base URL must be HTTPS (HTTP is allowed only on loopback)")]
InsecureUrl,
#[error("invalid Spaces base URL")]
InvalidUrl,
#[error("invalid or empty credential")]
InvalidCredential,
#[error("Spaces request failed: {0}")]
Transport(#[from] reqwest::Error),
#[error("Spaces rejected the credential ({0})")]
Authentication(u16),
#[error("Spaces returned HTTP {0}")]
Http(u16),
#[error("Spaces response is too large")]
ResponseTooLarge,
#[error("Spaces response is not JSON")]
ContentType,
#[error("Spaces response violates the semantic boundary: {0}")]
InvalidResponse(String),
}
#[derive(Clone, Debug)]
pub struct Client {
endpoint: Url,
credential: Credential,
http: reqwest::Client,
}
impl Client {
pub fn new(base_url: &str, credential: Credential) -> Result<Self, Error> {
let base = Url::parse(base_url).map_err(|_| Error::InvalidUrl)?;
let loopback = base
.host_str()
.is_some_and(|host| host == "localhost" || host == "127.0.0.1" || host == "::1");
if base.scheme() != "https" && !(base.scheme() == "http" && loopback) {
return Err(Error::InsecureUrl);
}
if !base.username().is_empty()
|| base.password().is_some()
|| base.query().is_some()
|| base.fragment().is_some()
{
return Err(Error::InvalidUrl);
}
let endpoint = base.join("/v1/spaces").map_err(|_| Error::InvalidUrl)?;
let http = reqwest::Client::builder()
.redirect(Policy::none())
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(10))
.user_agent(concat!(
"ruview-cognitum-spaces/",
env!("CARGO_PKG_VERSION")
))
.build()?;
Ok(Self {
endpoint,
credential,
http,
})
}
pub async fn list(&self) -> Result<SpacesResponse, Error> {
let mut request = self
.http
.get(self.endpoint.clone())
.header("Accept", "application/json");
request = match &self.credential {
Credential::OAuth(token) => request.bearer_auth(token),
Credential::ApiKey(key) => request.header("X-API-Key", key),
};
let mut response = request.send().await?;
let status = response.status();
if status.as_u16() == 401 || status.as_u16() == 403 {
return Err(Error::Authentication(status.as_u16()));
}
if !status.is_success() {
return Err(Error::Http(status.as_u16()));
}
let is_json = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| {
v.split(';')
.next()
.is_some_and(|m| m.trim().eq_ignore_ascii_case("application/json"))
});
if !is_json {
return Err(Error::ContentType);
}
if response
.content_length()
.is_some_and(|n| n > MAX_RESPONSE_BYTES as u64)
{
return Err(Error::ResponseTooLarge);
}
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await? {
if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES {
return Err(Error::ResponseTooLarge);
}
body.extend_from_slice(&chunk);
}
decode(&body)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpacesResponse {
pub object: String,
pub data: Vec<Space>,
pub boundary: DataBoundary,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Space {
pub id: String,
pub tenant_id: String,
pub workspace_id: Option<String>,
pub site_id: String,
pub name: String,
pub version: u64,
pub privacy: String,
pub status: String,
pub connection: String,
pub state: SemanticState,
pub provenance: Value,
pub hardware: Value,
pub data_boundary: Value,
#[serde(default)]
pub observed_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SemanticState {
pub occupancy: Option<u64>,
pub confidence: Option<f64>,
pub observed_at: Option<String>,
pub freshness_ms: Option<u64>,
pub classification: String,
pub uncertainty: Value,
pub evidence: Vec<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataBoundary {
pub authoritative_state: String,
pub cloud_role: String,
pub excluded: Vec<String>,
}
pub fn decode(bytes: &[u8]) -> Result<SpacesResponse, Error> {
if bytes.len() > MAX_RESPONSE_BYTES {
return Err(Error::ResponseTooLarge);
}
let value: Value = serde_json::from_slice(bytes)
.map_err(|_| Error::InvalidResponse("malformed JSON".into()))?;
validate_value(&value, 0)?;
let response: SpacesResponse = serde_json::from_value(value)
.map_err(|e| Error::InvalidResponse(format!("schema mismatch: {e}")))?;
if response.object != "list" || response.data.len() > MAX_SPACES {
return Err(Error::InvalidResponse("invalid list envelope".into()));
}
if response.boundary.authoritative_state != "HomeCore Edge"
|| REQUIRED_EXCLUSIONS.iter().any(|required| {
!response
.boundary
.excluded
.iter()
.any(|excluded| excluded == required)
})
{
return Err(Error::InvalidResponse(
"incomplete edge privacy boundary".into(),
));
}
for space in &response.data {
if space.id.is_empty()
|| space.tenant_id.is_empty()
|| space.site_id.is_empty()
|| space.name.is_empty()
{
return Err(Error::InvalidResponse(
"space identity is incomplete".into(),
));
}
if !matches!(space.privacy.as_str(), "P2" | "P3") || space.state.classification != "P2" {
return Err(Error::InvalidResponse("non-semantic privacy class".into()));
}
if space
.state
.confidence
.is_some_and(|v| !v.is_finite() || !(0.0..=1.0).contains(&v))
{
return Err(Error::InvalidResponse("invalid confidence".into()));
}
}
Ok(response)
}
fn validate_value(value: &Value, depth: usize) -> Result<(), Error> {
if depth > MAX_JSON_DEPTH {
return Err(Error::InvalidResponse("JSON nesting is too deep".into()));
}
match value {
Value::String(s) if s.len() > MAX_STRING_BYTES => {
return Err(Error::InvalidResponse("string exceeds bound".into()));
}
Value::Array(items) if items.len() > 1000 => {
return Err(Error::InvalidResponse("array exceeds bound".into()));
}
Value::Array(items) => {
for item in items {
validate_value(item, depth + 1)?;
}
}
Value::Object(map) => {
if map.len() > 128 {
return Err(Error::InvalidResponse("object exceeds bound".into()));
}
for (key, item) in map {
if key.len() > MAX_STRING_BYTES {
return Err(Error::InvalidResponse("object key exceeds bound".into()));
}
let normalized: String = key
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect();
if matches!(
normalized.as_str(),
"rawcsi"
| "cir"
| "rftensors"
| "recordings"
| "poseframes"
| "vitalwaveforms"
| "identityobservations"
) {
return Err(Error::InvalidResponse(format!(
"forbidden raw field: {key}"
)));
}
validate_value(item, depth + 1)?;
}
}
_ => {}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn valid() -> Vec<u8> {
br#"{"object":"list","data":[{"id":"room-1","tenantId":"tenant-1","workspaceId":"workspace-1","siteId":"site-1","name":"Room","version":1,"privacy":"P2","status":"live","connection":"connected","state":{"occupancy":1,"confidence":0.9,"observedAt":"2026-08-17T00:00:00Z","freshnessMs":5,"classification":"P2","uncertainty":null,"evidence":[]},"provenance":{},"hardware":{},"dataBoundary":{},"observedAt":"2026-08-17T00:00:00Z","expiresAt":null}],"boundary":{"authoritativeState":"HomeCore Edge","cloudRole":"tenant-scoped semantic synchronization","excluded":["raw_csi","cir","rf_tensors","recordings","pose_frames","vital_waveforms","identity_observations"]}}"#.to_vec()
}
#[test]
fn accepts_bounded_semantic_state() {
assert_eq!(decode(&valid()).unwrap().data.len(), 1);
}
#[test]
fn rejects_raw_fields_anywhere() {
let mut value: Value = serde_json::from_slice(&valid()).unwrap();
value["data"][0]["state"]["raw_csi"] = Value::String("secret".into());
assert!(matches!(
decode(&serde_json::to_vec(&value).unwrap()),
Err(Error::InvalidResponse(_))
));
}
#[test]
fn rejects_missing_confidence_as_a_number_outside_bounds() {
let mut value: Value = serde_json::from_slice(&valid()).unwrap();
value["data"][0]["state"]["confidence"] = Value::from(2.0);
assert!(matches!(
decode(&serde_json::to_vec(&value).unwrap()),
Err(Error::InvalidResponse(_))
));
}
#[test]
fn rejects_incomplete_privacy_boundary() {
let mut value: Value = serde_json::from_slice(&valid()).unwrap();
value["boundary"]["excluded"] = serde_json::json!(["raw_csi"]);
assert!(matches!(
decode(&serde_json::to_vec(&value).unwrap()),
Err(Error::InvalidResponse(_))
));
}
#[test]
fn rejects_credential_bearing_urls_and_whitespace_secrets() {
let credential = Credential::oauth("token").unwrap();
assert!(matches!(
Client::new("https://user:pass@api.cognitum.one", credential),
Err(Error::InvalidUrl)
));
assert!(matches!(
Credential::oauth("token with spaces"),
Err(Error::InvalidCredential)
));
}
#[test]
fn credentials_are_redacted() {
let c = Credential::oauth("secret-token").unwrap();
assert!(!format!("{c:?}").contains("secret-token"));
}
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "ruview-offaxis"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Clean-room generalized off-axis (head-coupled) perspective projection for RuView demos (ADR-324)"
readme = "README.md"
[lib]
# cdylib for the wasm32 build (ADR-324 demo surface); rlib for native reuse.
crate-type = ["cdylib", "rlib"]
# The native core is dependency-free by design (ADR-324 §2.3): the wasm-bindgen
# surface exists only when compiling for wasm32, so the workspace test gate
# (`cargo test --workspace --no-default-features`) never pulls JS-interop deps.
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
[dev-dependencies]
criterion = { workspace = true }
[[bench]]
name = "offaxis"
harness = false
+164
View File
@@ -0,0 +1,164 @@
# ruview-offaxis
Clean-room **off-axis (head-coupled) perspective projection** in Rust, with a
wasm-bindgen surface for browser demos. Implements ADR-324.
The screen becomes a window: given the physical screen's corners and the
viewer's eye position, the crate produces the asymmetric frustum and
screen-aligned view matrix that keep the screen plane fixed while everything
behind and in front of it moves with true parallax.
## Clean-room statement
ADR-324 records that the prior-art repository (`icurtis1/off-axis-sneaker`)
publishes **no license**. No code, assets, or derived text from it appear in
this crate. The implementation follows the published math only:
- Robert Kooima, *Generalized Perspective Projection* (2008) — the
`pa`/`pb`/`pc` screen-corner frustum formulation.
- Casiez, Roussel & Vogel, *1€ Filter* (CHI 2012) — adaptive tracking-noise
smoothing.
## What's in the crate
| Module | Contents |
|---|---|
| `projection` | `Screen` (3 corners, any orientation), `off_axis()` → asymmetric `projection` + screen-aligned `view` matrix (column-major `f64`, the three.js `Matrix4.elements` layout). All failure modes are typed errors — never NaN matrices. |
| `filter` | `OneEuro` / `OneEuro3` one-euro filter. Timestamps are injected; the crate never reads a clock. |
| `rf` | `field_peak()` — strongest-cell extraction for `/ws/sensing` `signal_field` grids, mirroring the sensing server's `field_localize.rs` constants (`X_SCALE 0.6`, `Z_SCALE 0.5`, `PEAK_THRESHOLD 0.35`). `CoarseParallax` — the **Tier B** stage: deadband + gain + hard clamp + one-euro. `ScreenCalibration` — physical screen (cm) + normalized-head → metric eye mapping (**Tier A** input hook). |
| `wasm` (wasm32 only) | `OffAxisCamera` and `RfParallax` wasm-bindgen classes. |
The native core is **dependency-free**; wasm-bindgen is pulled only when
compiling for `wasm32`.
## Honesty contract (repo rule — read before demoing)
- A single-link CSI field peak is a *representation of field energy*, *not*
metric localization and *never* a head position (see the caveat in
`wifi-densepose-sensing-server/src/field_localize.rs`). The Tier B path is
therefore **coarse body parallax by construction**: deadbanded,
gain-limited, hard-clamped. Do not present it as head tracking; the demo
labels it on screen at all times.
- Numeric defaults (gains, deadbands, filter cutoffs) are interaction-design
choices — `CLAIMED`, not measured performance.
- The benchmark numbers below are `MEASURED` with the stated reproducer on
the stated machine; re-run locally before relying on them.
## Native quick start
```rust
use ruview_offaxis::{off_axis, Screen, Vec3};
// 60 cm × 34 cm screen centered at the origin; eye 65 cm out, 10 cm right.
let screen = Screen::centered(0.60, 0.34)?;
let oa = off_axis(&screen, Vec3::new(0.10, 0.0, 0.65), 0.05, 100.0)?;
let mvp: [f64; 16] = oa.view_projection(); // column-major, GL/three.js layout
# Ok::<(), ruview_offaxis::OffAxisError>(())
```
Key invariant (unit-tested for a grid of eye positions and for tilted
screens): the physical screen corners always project exactly to the NDC
corners — `pa→(−1,−1)`, `pb→(1,−1)`, `pc→(−1,1)`, `pd→(1,1)` — and points on
the screen plane are eye-invariant. That is the mathematical definition of
"the screen is a window".
## Building the WASM package
Generated artifacts are not committed (repo rule). Build once:
```bash
cd v2
rustup target add wasm32-unknown-unknown
cargo build -p ruview-offaxis --target wasm32-unknown-unknown --release
# Install the matching CLI once: cargo install wasm-bindgen-cli --version 0.2.114
wasm-bindgen --target web --out-dir crates/ruview-offaxis/pkg \
target/wasm32-unknown-unknown/release/ruview_offaxis.wasm
```
Output: `pkg/ruview_offaxis.js` + `pkg/ruview_offaxis_bg.wasm`
(≈54 KB wasm, `MEASURED` for this crate at wasm-bindgen 0.2.114; `wasm-opt -Oz`
can shrink it further if you have binaryen). The demo at
`examples/three.js/demos/07-off-axis-window.html` loads this path directly —
build, then open the demo. A Node smoke test of the same flow lives in the
PR's validation notes.
## three.js integration (the whole wiring)
```js
import init, { OffAxisCamera, RfParallax } from './pkg/ruview_offaxis.js';
await init();
// Physical calibration in cm — measure your actual screen.
const cam = new OffAxisCamera(60, 34, 65, 0.05, 100.0);
cam.set_filter(1.2, 0.4); // one-euro: min_cutoff Hz, beta
const camera = new THREE.PerspectiveCamera();
camera.matrixAutoUpdate = false; // WASM owns every matrix
const view = new THREE.Matrix4();
function onFrame(eyeX, eyeY, eyeZ) { // metres, screen space
cam.update_eye(eyeX, eyeY, eyeZ, performance.now() / 1000);
camera.projectionMatrix.fromArray(cam.projection());
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
view.fromArray(cam.view());
camera.matrixWorld.copy(view).invert();
camera.matrixWorldInverse.copy(view);
}
```
Scene convention: the screen plane is `z = 0`; content behind the screen has
`z < 0`; content with `z > 0` "pops out". Do **not** update `camera.aspect`
on resize — the frustum is determined by the physical screen, not the
viewport.
### Input tiers (ADR-324)
- **Tier A (fine tracker)** — feed any head tracker through
`cam.update_normalized(nx, ny, depthScale, lateralRangeM, t)`; `nx`/`ny`
are normalized image coordinates from whatever fine tracker the host runs
(which stays entirely in the browser). RF adds presence gating around it.
- **Tier B (RF only, labeled)** — `RfParallax.update(Float32Array, nx, nz, t)`
with `/ws/sensing` `signal_field` values, then `rf.eye()` →
`cam.update_eye(...)`. Keep the on-screen "coarse body parallax — not head
tracking" label; the clamps in the Rust core bound the excursion but the
label is what keeps the demo honest.
## Benchmarks
`MEASURED` — reproducer: `cd v2 && cargo bench -p ruview-offaxis`.
Environment for the numbers below: Linux x86_64 container (shared/virtualized
CPU), rustc 1.89.0, criterion 0.5, 2026-08-16. Treat them as order-of-
magnitude; re-run on your hardware.
| Benchmark | Time (median) |
|---|---|
| `off_axis_projection` (frustum + view build) | ~76 ns |
| `view_projection` combined (4×4 multiply) | ~27 ns |
| `one_euro3_step` (3-axis filter step) | ~60 ns |
| `field_peak_20x20` (live grid size) | ~488 ns |
| `field_peak_100x100` | ~12.3 µs |
| `tier_b_full_frame_20x20` (scan → parallax → projection) | ~598 ns |
The full Tier B per-frame path costs well under a microsecond — under 0.01%
of a 60 Hz frame budget. The argmax scan was the only hot spot found; it was
rewritten branch-light for a measured −18% (20×20) / −33% (100×100) before
these numbers were taken. End-to-end *motion-to-photon* latency (RF capture →
render) has **not** been measured and is dominated by the sensing pipeline,
not this crate; no figure is claimed.
## Validation
```bash
cd v2
cargo test -p ruview-offaxis # 23 unit tests + doctest
cargo clippy -p ruview-offaxis --all-targets # zero warnings
cargo build -p ruview-offaxis --target wasm32-unknown-unknown --release
```
## References
- ADR-324 — decision record, tier definitions, licensing analysis
- `docs/adr/ADR-282-*` — L0–L5 evidence ladder (labels used above)
- `v2/crates/wifi-densepose-sensing-server/src/field_localize.rs` — the
field-peak honesty caveat this crate inherits
@@ -0,0 +1,84 @@
//! Criterion benchmarks for the per-frame hot path (ADR-324).
//!
//! Reproducer: `cd v2 && cargo bench -p ruview-offaxis`
//! Any numbers quoted from this bench are MEASURED on the machine that ran
//! that command; re-run locally before relying on them.
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use ruview_offaxis::{
field_peak, off_axis, CoarseParallax, CoarseParallaxConfig, OneEuro3, OneEuroConfig, Screen,
Vec3,
};
fn bench_projection(c: &mut Criterion) {
let screen = Screen::centered(0.6, 0.34).unwrap();
c.bench_function("off_axis_projection", |b| {
let mut t = 0.0_f64;
b.iter(|| {
t += 0.016;
let eye = Vec3::new(0.1 * t.sin(), 0.05 * t.cos(), 0.65);
black_box(off_axis(black_box(&screen), black_box(eye), 0.05, 100.0).unwrap())
})
});
c.bench_function("off_axis_view_projection_combined", |b| {
let eye = Vec3::new(0.1, -0.03, 0.65);
let oa = off_axis(&screen, eye, 0.05, 100.0).unwrap();
b.iter(|| black_box(black_box(&oa).view_projection()))
});
}
fn bench_filter(c: &mut Criterion) {
c.bench_function("one_euro3_step", |b| {
let mut f = OneEuro3::new(OneEuroConfig::default());
let mut t = 0.0_f64;
b.iter(|| {
t += 0.016;
black_box(f.filter(Vec3::new(t.sin(), t.cos(), 0.65), t))
})
});
}
fn make_grid(nx: usize, nz: usize) -> Vec<f32> {
// Deterministic pseudo-field with one hot cell.
let mut v: Vec<f32> = (0..nx * nz).map(|i| 0.05 + (i % 7) as f32 * 0.01).collect();
v[(nz / 3) * nx + nx / 4] = 0.9;
v
}
fn bench_field_peak(c: &mut Criterion) {
let g20 = make_grid(20, 20);
c.bench_function("field_peak_20x20", |b| {
b.iter(|| black_box(field_peak(black_box(&g20), 20, 20)))
});
let g100 = make_grid(100, 100);
c.bench_function("field_peak_100x100", |b| {
b.iter(|| black_box(field_peak(black_box(&g100), 100, 100)))
});
}
fn bench_full_tier_b_frame(c: &mut Criterion) {
// The whole Tier B per-frame path: grid scan → parallax → projection.
let screen = Screen::centered(0.6, 0.34).unwrap();
let g20 = make_grid(20, 20);
c.bench_function("tier_b_full_frame_20x20", |b| {
let mut cp = CoarseParallax::new(CoarseParallaxConfig::default());
let mut t = 0.0_f64;
b.iter(|| {
t += 0.016;
let peak = field_peak(black_box(&g20), 20, 20);
let eye = cp.update(peak, t);
black_box(off_axis(&screen, eye, 0.05, 100.0).unwrap())
})
});
}
criterion_group!(
benches,
bench_projection,
bench_filter,
bench_field_peak,
bench_full_tier_b_frame
);
criterion_main!(benches);
+240
View File
@@ -0,0 +1,240 @@
//! One-euro filter (Casiez, Roussel & Vogel, CHI 2012) for eye-position
//! smoothing.
//!
//! Implemented from the published algorithm: an exponential low-pass whose
//! cutoff adapts to the signal's speed — low cutoff (heavy smoothing) when
//! nearly still, higher cutoff (low lag) when moving fast. This is the
//! standard jitter/lag trade-off filter for interactive tracking.
//!
//! Timestamps are injected by the caller in seconds (monotonic). The crate
//! never reads a clock — repo discipline, and it keeps the wasm build free
//! of `performance.now()` assumptions.
use crate::math::Vec3;
use core::f64::consts::PI;
/// One-euro filter parameters.
///
/// - `min_cutoff` (Hz): smoothing floor. Lower = smoother but laggier at rest.
/// - `beta`: speed coefficient. Higher = less lag during fast motion.
/// - `d_cutoff` (Hz): cutoff for the internal derivative estimate.
///
/// Defaults are the paper's recommended starting point (1.0, 0.0, 1.0);
/// interactive head tracking typically tunes `min_cutoff` down and `beta` up.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OneEuroConfig {
/// Smoothing floor in Hz (lower = smoother but laggier at rest).
pub min_cutoff: f64,
/// Speed coefficient (higher = less lag during fast motion).
pub beta: f64,
/// Cutoff in Hz for the internal derivative estimate.
pub d_cutoff: f64,
}
impl Default for OneEuroConfig {
fn default() -> Self {
Self {
min_cutoff: 1.0,
beta: 0.0,
d_cutoff: 1.0,
}
}
}
/// Smoothing factor for an exponential low-pass at `cutoff` Hz sampled
/// `dt` seconds apart.
#[inline]
fn alpha(cutoff: f64, dt: f64) -> f64 {
let tau = 1.0 / (2.0 * PI * cutoff);
1.0 / (1.0 + tau / dt)
}
/// Scalar one-euro filter.
#[derive(Clone, Copy, Debug, Default)]
pub struct OneEuro {
cfg: OneEuroConfig,
/// `(t, x_hat, dx_hat)` from the previous accepted sample.
state: Option<(f64, f64, f64)>,
}
impl OneEuro {
/// Create with the given parameters.
pub fn new(cfg: OneEuroConfig) -> Self {
Self { cfg, state: None }
}
/// Replace the parameters, keeping filter state.
pub fn set_config(&mut self, cfg: OneEuroConfig) {
self.cfg = cfg;
}
/// Clear state; the next sample passes through unfiltered.
pub fn reset(&mut self) {
self.state = None;
}
/// Filter sample `x` taken at time `t_s` (seconds). Non-monotonic or
/// non-finite input returns the previous estimate unchanged (never NaN).
pub fn filter(&mut self, x: f64, t_s: f64) -> f64 {
if !x.is_finite() || !t_s.is_finite() {
return self.state.map_or(0.0, |(_, xh, _)| xh);
}
match self.state {
None => {
self.state = Some((t_s, x, 0.0));
x
}
Some((t0, x0, dx0)) => {
let dt = t_s - t0;
if dt <= 0.0 {
return x0;
}
let dx = (x - x0) / dt;
let a_d = alpha(self.cfg.d_cutoff, dt);
let dx_hat = a_d * dx + (1.0 - a_d) * dx0;
let cutoff = self.cfg.min_cutoff + self.cfg.beta * dx_hat.abs();
let a = alpha(cutoff, dt);
let x_hat = a * x + (1.0 - a) * x0;
self.state = Some((t_s, x_hat, dx_hat));
x_hat
}
}
}
}
/// Component-wise one-euro filter over a [`Vec3`].
#[derive(Clone, Copy, Debug, Default)]
pub struct OneEuro3 {
x: OneEuro,
y: OneEuro,
z: OneEuro,
}
impl OneEuro3 {
/// Create with the same parameters on all three axes.
pub fn new(cfg: OneEuroConfig) -> Self {
Self {
x: OneEuro::new(cfg),
y: OneEuro::new(cfg),
z: OneEuro::new(cfg),
}
}
/// Replace parameters on all axes, keeping state.
pub fn set_config(&mut self, cfg: OneEuroConfig) {
self.x.set_config(cfg);
self.y.set_config(cfg);
self.z.set_config(cfg);
}
/// Clear state on all axes.
pub fn reset(&mut self) {
self.x.reset();
self.y.reset();
self.z.reset();
}
/// Filter a 3-D sample taken at time `t_s` (seconds).
pub fn filter(&mut self, v: Vec3, t_s: f64) -> Vec3 {
Vec3::new(
self.x.filter(v.x, t_s),
self.y.filter(v.y, t_s),
self.z.filter(v.z, t_s),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_sample_passes_through() {
let mut f = OneEuro::new(OneEuroConfig::default());
assert_eq!(f.filter(3.25, 0.0), 3.25);
}
#[test]
fn constant_input_stays_constant() {
let mut f = OneEuro::new(OneEuroConfig::default());
for i in 0..100 {
let y = f.filter(1.5, i as f64 * 0.016);
assert!((y - 1.5).abs() < 1e-12);
}
}
#[test]
fn step_response_converges_monotonically() {
let mut f = OneEuro::new(OneEuroConfig {
min_cutoff: 1.0,
beta: 0.0,
d_cutoff: 1.0,
});
f.filter(0.0, 0.0);
let mut prev = 0.0;
for i in 1..200 {
let y = f.filter(1.0, i as f64 * 0.016);
assert!(y > prev, "monotone rise");
assert!(y <= 1.0 + 1e-12, "no overshoot");
prev = y;
}
assert!(prev > 0.99, "converged near the step, got {prev}");
}
#[test]
fn higher_beta_tracks_fast_motion_closer() {
// A fast ramp: the adaptive filter (beta > 0) must lag less than the
// pure low-pass (beta = 0).
let slow_cfg = OneEuroConfig {
min_cutoff: 0.5,
beta: 0.0,
d_cutoff: 1.0,
};
let fast_cfg = OneEuroConfig {
min_cutoff: 0.5,
beta: 1.0,
d_cutoff: 1.0,
};
let mut slow = OneEuro::new(slow_cfg);
let mut fast = OneEuro::new(fast_cfg);
let mut last = (0.0, 0.0, 0.0);
for i in 0..120 {
let t = i as f64 * 0.016;
let x = t * 2.0; // 2 m/s ramp
last = (x, slow.filter(x, t), fast.filter(x, t));
}
let (x, s, f) = last;
assert!(
(x - f).abs() < (x - s).abs(),
"beta reduces lag: |{x}-{f}| < |{x}-{s}|"
);
}
#[test]
fn rejects_non_monotonic_and_non_finite_samples() {
let mut f = OneEuro::new(OneEuroConfig::default());
f.filter(1.0, 1.0);
let settled = f.filter(1.0, 2.0);
// Time going backwards: hold the estimate.
assert_eq!(f.filter(99.0, 0.5), settled);
// NaN input: hold the estimate.
assert_eq!(f.filter(f64::NAN, 3.0), settled);
// NaN never propagates.
let y = f.filter(1.0, 4.0);
assert!(y.is_finite());
}
#[test]
fn vec3_filter_is_componentwise() {
let mut f3 = OneEuro3::new(OneEuroConfig::default());
let mut fx = OneEuro::new(OneEuroConfig::default());
for i in 0..10 {
let t = i as f64 * 0.02;
let v = Vec3::new(i as f64 * 0.1, -1.0, 2.0);
let out = f3.filter(v, t);
assert_eq!(out.x, fx.filter(v.x, t));
assert_eq!(out.y, -1.0);
assert_eq!(out.z, 2.0);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
//! # `ruview-offaxis` — clean-room off-axis (head-coupled) perspective (ADR-324)
//!
//! Generalized perspective projection for "window into the screen"
//! (fish-tank VR / head-coupled perspective) demos, implemented from the
//! published math — Robert Kooima, *Generalized Perspective Projection*
//! (2008) — plus the supporting input stages the ADR-324 demo needs:
//!
//! - [`Screen`] / [`off_axis`]: three physical screen corners + a tracked
//! eye → asymmetric frustum and screen-aligned view matrix (column-major
//! `f64`, the three.js `Matrix4.elements` layout).
//! - [`OneEuro`] / [`OneEuro3`]: the one-euro filter (Casiez et al., CHI
//! 2012) for tracking-noise smoothing with injected timestamps.
//! - [`rf`]: RuView-specific input mapping — `/ws/sensing` signal-field
//! peak extraction (constants mirroring the sensing server's
//! `field_localize.rs`) and the bounded **Tier B** coarse-parallax stage.
//! - A wasm-bindgen surface (wasm32 only) exposing [`wasm::OffAxisCamera`]
//! and [`wasm::RfParallax`] to the browser demos.
//!
//! ## Clean-room statement
//!
//! ADR-324 records that the prior-art repository (`icurtis1/off-axis-sneaker`)
//! is unlicensed: no code, assets, or derived text from it appear here. This
//! crate is written solely from the published Kooima and Casiez papers and
//! RuView's own source.
//!
//! ## Honesty contract (repo rule)
//!
//! Nothing in this crate asserts sensing accuracy. The Tier B RF path is
//! **coarse body parallax by construction** — deadbanded, gain-limited,
//! hard-clamped — because a single-link CSI field peak is a representation
//! of field energy, not metric localization (see
//! `wifi-densepose-sensing-server/src/field_localize.rs`). Numeric defaults
//! are interaction-design choices (`CLAIMED`); benchmark numbers live in the
//! README tagged `MEASURED` with their reproducer.
//!
//! ## Native quick start
//!
//! ```
//! use ruview_offaxis::{off_axis, Screen, Vec3};
//!
//! // A 60 cm × 34 cm screen centered at the origin, eye 65 cm away and
//! // 10 cm to the right.
//! let screen = Screen::centered(0.60, 0.34)?;
//! let oa = off_axis(&screen, Vec3::new(0.10, 0.0, 0.65), 0.05, 100.0)?;
//! // Column-major, ready for three.js Matrix4.fromArray / any GL pipeline.
//! let _m: [f64; 16] = oa.view_projection();
//! # Ok::<(), ruview_offaxis::OffAxisError>(())
//! ```
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod filter;
pub mod math;
pub mod projection;
pub mod rf;
#[cfg(target_arch = "wasm32")]
pub mod wasm;
pub use filter::{OneEuro, OneEuro3, OneEuroConfig};
pub use math::{Mat4, Vec3};
pub use projection::{off_axis, OffAxis, OffAxisError, Screen, MIN_EYE_DISTANCE};
pub use rf::{
field_peak, CoarseParallax, CoarseParallaxConfig, FieldPeak, ScreenCalibration,
FIELD_PEAK_THRESHOLD, FIELD_X_SCALE, FIELD_Z_SCALE,
};
+177
View File
@@ -0,0 +1,177 @@
//! Minimal 3-vector and column-major 4×4 matrix helpers.
//!
//! Deliberately dependency-free: this crate targets wasm32 for the ADR-324
//! demo surface, and a `nalgebra` pull would dominate the module size for
//! what is a handful of fixed-size operations. Matrices use the OpenGL /
//! three.js `Matrix4.elements` layout: column-major, `m[col * 4 + row]`.
/// A 3-component `f64` vector.
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct Vec3 {
/// X component.
pub x: f64,
/// Y component.
pub y: f64,
/// Z component.
pub z: f64,
}
impl Vec3 {
/// Construct from components.
#[inline]
pub const fn new(x: f64, y: f64, z: f64) -> Self {
Self { x, y, z }
}
/// Scale by `s`.
#[inline]
pub fn scale(self, s: f64) -> Vec3 {
Vec3::new(self.x * s, self.y * s, self.z * s)
}
/// Dot product.
#[inline]
pub fn dot(self, rhs: Vec3) -> f64 {
self.x * rhs.x + self.y * rhs.y + self.z * rhs.z
}
/// Cross product (right-handed).
#[inline]
pub fn cross(self, rhs: Vec3) -> Vec3 {
Vec3::new(
self.y * rhs.z - self.z * rhs.y,
self.z * rhs.x - self.x * rhs.z,
self.x * rhs.y - self.y * rhs.x,
)
}
/// Euclidean length.
#[inline]
pub fn length(self) -> f64 {
self.dot(self).sqrt()
}
/// Unit vector, or `None` when the length is (near) zero.
#[inline]
pub fn normalize(self) -> Option<Vec3> {
let len = self.length();
if len <= f64::EPSILON {
None
} else {
Some(self.scale(1.0 / len))
}
}
}
impl core::ops::Sub for Vec3 {
type Output = Vec3;
#[inline]
fn sub(self, rhs: Vec3) -> Vec3 {
Vec3::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z)
}
}
impl core::ops::Add for Vec3 {
type Output = Vec3;
#[inline]
fn add(self, rhs: Vec3) -> Vec3 {
Vec3::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z)
}
}
/// Column-major 4×4 matrix, `m[col * 4 + row]` — the exact layout of
/// three.js `Matrix4.elements` / OpenGL, so the array can be passed to
/// `Matrix4.fromArray` untouched.
pub type Mat4 = [f64; 16];
/// The identity matrix.
pub const IDENTITY: Mat4 = [
1.0, 0.0, 0.0, 0.0, //
0.0, 1.0, 0.0, 0.0, //
0.0, 0.0, 1.0, 0.0, //
0.0, 0.0, 0.0, 1.0,
];
/// `a * b` (column-major).
#[inline]
pub fn mul(a: &Mat4, b: &Mat4) -> Mat4 {
let mut out = [0.0; 16];
for col in 0..4 {
for row in 0..4 {
let mut acc = 0.0;
for k in 0..4 {
acc += a[k * 4 + row] * b[col * 4 + k];
}
out[col * 4 + row] = acc;
}
}
out
}
/// Transform a point (`w = 1`); returns the transformed `(x, y, z)` and `w`
/// *before* the perspective divide, so callers can check clip-space signs.
#[inline]
pub fn transform_point(m: &Mat4, p: Vec3) -> (Vec3, f64) {
let x = m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12];
let y = m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13];
let z = m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14];
let w = m[3] * p.x + m[7] * p.y + m[11] * p.z + m[15];
(Vec3::new(x, y, z), w)
}
/// Transform a point and apply the perspective divide, yielding normalized
/// device coordinates. Returns `None` when `w` is (near) zero.
#[inline]
pub fn project_point(m: &Mat4, p: Vec3) -> Option<Vec3> {
let (v, w) = transform_point(m, p);
if w.abs() <= f64::EPSILON {
None
} else {
Some(v.scale(1.0 / w))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_transforms_are_noops() {
let p = Vec3::new(1.5, -2.0, 3.25);
let (q, w) = transform_point(&IDENTITY, p);
assert_eq!(q, p);
assert_eq!(w, 1.0);
assert_eq!(mul(&IDENTITY, &IDENTITY), IDENTITY);
}
#[test]
fn cross_is_right_handed() {
let x = Vec3::new(1.0, 0.0, 0.0);
let y = Vec3::new(0.0, 1.0, 0.0);
assert_eq!(x.cross(y), Vec3::new(0.0, 0.0, 1.0));
}
#[test]
fn normalize_rejects_zero() {
assert!(Vec3::new(0.0, 0.0, 0.0).normalize().is_none());
let n = Vec3::new(0.0, 3.0, 4.0).normalize().unwrap();
assert!((n.length() - 1.0).abs() < 1e-12);
}
#[test]
fn mul_matches_manual_translation_composition() {
// T(a) * T(b) == T(a + b) for translations.
let mut ta = IDENTITY;
ta[12] = 1.0;
ta[13] = 2.0;
ta[14] = 3.0;
let mut tb = IDENTITY;
tb[12] = -4.0;
tb[13] = 0.5;
tb[14] = 7.0;
let tab = mul(&ta, &tb);
assert_eq!(tab[12], -3.0);
assert_eq!(tab[13], 2.5);
assert_eq!(tab[14], 10.0);
}
}
+449
View File
@@ -0,0 +1,449 @@
//! Clean-room generalized (off-axis) perspective projection.
//!
//! Implements the screen-corner formulation published by Robert Kooima,
//! "Generalized Perspective Projection" (2008): given a physical screen
//! described by three of its corners and a tracked eye position in the same
//! tracker space, produce the asymmetric frustum and view transform that make
//! the screen behave as a window into the virtual scene.
//!
//! No code from any existing implementation (including the unlicensed
//! `icurtis1/off-axis-sneaker` prior art referenced by ADR-324) was copied or
//! consulted while writing this module; the derivation follows the published
//! math only.
//!
//! ## Conventions
//!
//! - Right-handed tracker space, metres.
//! - `pa` = screen lower-left, `pb` = lower-right, `pc` = upper-left corner.
//! - The screen normal `vn = vr × vu` points toward the viewer's side; the
//! eye must be on that side (`EyeBehindScreen` otherwise).
//! - Output matrices are column-major `f64` in the three.js / OpenGL layout
//! (see [`crate::math::Mat4`]). NDC follows OpenGL: visible x/y/z in
//! `[-1, 1]`, camera looking down `-z` in eye space.
use crate::math::{mul, Mat4, Vec3, IDENTITY};
use core::fmt;
/// Minimum eye-to-screen-plane distance (metres). Below this the frustum
/// degenerates (division by ~0); callers get a typed error instead of NaNs.
pub const MIN_EYE_DISTANCE: f64 = 1e-6;
/// Errors from screen construction or projection evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OffAxisError {
/// The three corners do not span a plane (coincident or collinear).
DegenerateScreen,
/// The eye is on or behind the screen plane (`distance <= MIN_EYE_DISTANCE`).
EyeBehindScreen,
/// `near`/`far` are not `0 < near < far`, or not finite.
InvalidClipPlanes,
/// A non-finite input coordinate was supplied.
NonFiniteInput,
}
impl fmt::Display for OffAxisError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OffAxisError::DegenerateScreen => {
write!(f, "screen corners are coincident or collinear")
}
OffAxisError::EyeBehindScreen => {
write!(f, "eye is on or behind the screen plane")
}
OffAxisError::InvalidClipPlanes => {
write!(f, "clip planes must satisfy 0 < near < far and be finite")
}
OffAxisError::NonFiniteInput => write!(f, "input coordinate is not finite"),
}
}
}
impl std::error::Error for OffAxisError {}
/// A physical screen described by three corners, with its orthonormal basis
/// precomputed at construction (the basis is eye-independent, so caching it
/// keeps the per-frame [`off_axis`] call to the eye-dependent work only).
#[derive(Clone, Copy, Debug)]
pub struct Screen {
pa: Vec3,
pb: Vec3,
pc: Vec3,
vr: Vec3,
vu: Vec3,
vn: Vec3,
}
impl Screen {
/// Build a screen from its lower-left (`pa`), lower-right (`pb`) and
/// upper-left (`pc`) corners, in metres, in any orientation.
pub fn new(pa: Vec3, pb: Vec3, pc: Vec3) -> Result<Self, OffAxisError> {
for v in [pa, pb, pc] {
if !(v.x.is_finite() && v.y.is_finite() && v.z.is_finite()) {
return Err(OffAxisError::NonFiniteInput);
}
}
let vr = (pb - pa)
.normalize()
.ok_or(OffAxisError::DegenerateScreen)?;
let vu = (pc - pa)
.normalize()
.ok_or(OffAxisError::DegenerateScreen)?;
let vn = vr
.cross(vu)
.normalize()
.ok_or(OffAxisError::DegenerateScreen)?;
Ok(Self {
pa,
pb,
pc,
vr,
vu,
vn,
})
}
/// Convenience: an axis-aligned screen of `width_m × height_m` metres,
/// centered at the origin in the `z = 0` plane, normal facing `+z`
/// (the viewer side). This matches the usual desktop-demo setup where
/// the tracker origin is the screen center.
pub fn centered(width_m: f64, height_m: f64) -> Result<Self, OffAxisError> {
if !(width_m.is_finite() && height_m.is_finite()) {
return Err(OffAxisError::NonFiniteInput);
}
if width_m <= 0.0 || height_m <= 0.0 {
return Err(OffAxisError::DegenerateScreen);
}
let hw = width_m / 2.0;
let hh = height_m / 2.0;
Screen::new(
Vec3::new(-hw, -hh, 0.0),
Vec3::new(hw, -hh, 0.0),
Vec3::new(-hw, hh, 0.0),
)
}
/// Lower-left corner.
pub fn pa(&self) -> Vec3 {
self.pa
}
/// Lower-right corner.
pub fn pb(&self) -> Vec3 {
self.pb
}
/// Upper-left corner.
pub fn pc(&self) -> Vec3 {
self.pc
}
/// The implied upper-right corner `pb + (pc - pa)`.
pub fn pd(&self) -> Vec3 {
self.pb + self.pc - self.pa
}
/// Unit right vector along the screen's bottom edge.
pub fn vr(&self) -> Vec3 {
self.vr
}
/// Unit up vector along the screen's left edge.
pub fn vu(&self) -> Vec3 {
self.vu
}
/// Unit normal, pointing toward the viewer side.
pub fn vn(&self) -> Vec3 {
self.vn
}
/// Signed distance from `eye` to the screen plane along the normal
/// (positive when the eye is on the viewer side).
pub fn eye_distance(&self, eye: Vec3) -> f64 {
// va = pa - pe; d = -(va · vn)
-(self.pa - eye).dot(self.vn)
}
}
/// The result of a generalized projection evaluation.
///
/// `projection` is the asymmetric frustum; `view` is the rigid transform
/// (screen-basis rotation + eye translation) taking tracker space into eye
/// space. For three.js, either:
///
/// - set `camera.projectionMatrix` from `projection` and position/orient the
/// camera from the eye and screen basis yourself, or
/// - use [`OffAxis::view_projection`] as a single combined matrix when you
/// manage matrices manually.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OffAxis {
/// Asymmetric perspective frustum (column-major).
pub projection: Mat4,
/// Screen-aligned view matrix: rotation into the screen basis composed
/// with translation by the negated eye position (column-major).
pub view: Mat4,
}
impl OffAxis {
/// `projection * view` — the full tracker-space-to-clip-space matrix.
pub fn view_projection(&self) -> Mat4 {
mul(&self.projection, &self.view)
}
}
/// OpenGL-convention asymmetric frustum matrix (column-major).
#[inline]
fn frustum(l: f64, r: f64, b: f64, t: f64, n: f64, f: f64) -> Mat4 {
let mut m = [0.0; 16];
m[0] = 2.0 * n / (r - l);
m[5] = 2.0 * n / (t - b);
m[8] = (r + l) / (r - l);
m[9] = (t + b) / (t - b);
m[10] = -(f + n) / (f - n);
m[11] = -1.0;
m[14] = -2.0 * f * n / (f - n);
m
}
/// Evaluate the generalized off-axis projection for `screen` as seen from
/// `eye`, with the given clip planes.
///
/// Errors when the eye is on/behind the screen plane, when clip planes are
/// invalid, or when inputs are non-finite. Never returns NaN-bearing
/// matrices: every failure mode is a typed error (renderer-facing code must
/// hold the last good matrix on error, not draw garbage).
pub fn off_axis(screen: &Screen, eye: Vec3, near: f64, far: f64) -> Result<OffAxis, OffAxisError> {
if !(eye.x.is_finite() && eye.y.is_finite() && eye.z.is_finite()) {
return Err(OffAxisError::NonFiniteInput);
}
if !(near.is_finite() && far.is_finite()) || near <= 0.0 || far <= near {
return Err(OffAxisError::InvalidClipPlanes);
}
let (vr, vu, vn) = (screen.vr, screen.vu, screen.vn);
// Vectors from the eye to each screen corner.
let va = screen.pa - eye;
let vb = screen.pb - eye;
let vc = screen.pc - eye;
// Distance from the eye to the screen plane.
let d = -va.dot(vn);
if d <= MIN_EYE_DISTANCE {
return Err(OffAxisError::EyeBehindScreen);
}
// Frustum extents on the near plane.
let nd = near / d;
let l = vr.dot(va) * nd;
let r = vr.dot(vb) * nd;
let b = vu.dot(va) * nd;
let t = vu.dot(vc) * nd;
let projection = frustum(l, r, b, t, near, far);
// View = screen-basis rotation (rows vr/vu/vn) * translation by -eye.
// Composed directly: the translation column is -R^T-rotated eye.
let mut view = IDENTITY;
view[0] = vr.x;
view[4] = vr.y;
view[8] = vr.z;
view[1] = vu.x;
view[5] = vu.y;
view[9] = vu.z;
view[2] = vn.x;
view[6] = vn.y;
view[10] = vn.z;
view[12] = -vr.dot(eye);
view[13] = -vu.dot(eye);
view[14] = -vn.dot(eye);
Ok(OffAxis { projection, view })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::project_point;
const EPS: f64 = 1e-9;
fn assert_close(a: f64, b: f64, eps: f64, what: &str) {
assert!((a - b).abs() < eps, "{what}: {a} vs {b}");
}
/// The defining invariant of off-axis projection: for ANY valid eye
/// position, the physical screen corners land exactly on the NDC corners
/// — pa→(-1,-1), pb→(1,-1), pc→(-1,1), pd→(1,1).
#[test]
fn screen_corners_map_to_ndc_corners_for_all_eyes() {
let screen = Screen::centered(0.6, 0.34).unwrap();
let mut checked = 0;
for xi in -4..=4 {
for yi in -3..=3 {
for zi in 1..=6 {
let eye = Vec3::new(xi as f64 * 0.25, yi as f64 * 0.2, zi as f64 * 0.35);
let oa = off_axis(&screen, eye, 0.05, 100.0).unwrap();
let m = oa.view_projection();
for (corner, ex, ey) in [
(screen.pa(), -1.0, -1.0),
(screen.pb(), 1.0, -1.0),
(screen.pc(), -1.0, 1.0),
(screen.pd(), 1.0, 1.0),
] {
let ndc = project_point(&m, corner).unwrap();
assert_close(ndc.x, ex, EPS, "ndc.x");
assert_close(ndc.y, ey, EPS, "ndc.y");
}
checked += 1;
}
}
}
assert_eq!(checked, 9 * 7 * 6);
}
/// The invariant survives an arbitrarily rotated + translated screen:
/// the math is coordinate-frame independent.
#[test]
fn corner_invariant_holds_for_tilted_screen() {
// A screen rotated ~30° about y and ~15° about x, shifted off origin.
let (cy, sy) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
let (cx, sx) = (15f64.to_radians().cos(), 15f64.to_radians().sin());
let rot = |v: Vec3| {
// Ry then Rx.
let v = Vec3::new(cy * v.x + sy * v.z, v.y, -sy * v.x + cy * v.z);
Vec3::new(v.x, cx * v.y - sx * v.z, sx * v.y + cx * v.z)
};
let shift = Vec3::new(0.4, -0.2, 1.1);
let pa = rot(Vec3::new(-0.3, -0.17, 0.0)) + shift;
let pb = rot(Vec3::new(0.3, -0.17, 0.0)) + shift;
let pc = rot(Vec3::new(-0.3, 0.17, 0.0)) + shift;
let screen = Screen::new(pa, pb, pc).unwrap();
// An eye on the viewer side of the tilted plane.
let eye = shift + rot(Vec3::new(0.1, 0.05, 0.8));
let m = off_axis(&screen, eye, 0.01, 50.0)
.unwrap()
.view_projection();
for (corner, ex, ey) in [
(screen.pa(), -1.0, -1.0),
(screen.pb(), 1.0, -1.0),
(screen.pc(), -1.0, 1.0),
(screen.pd(), 1.0, 1.0),
] {
let ndc = project_point(&m, corner).unwrap();
assert_close(ndc.x, ex, EPS, "tilted ndc.x");
assert_close(ndc.y, ey, EPS, "tilted ndc.y");
}
}
/// A centered eye must reduce to the ordinary symmetric perspective
/// frustum: l == -r, b == -t, and the projection matrix has no skew
/// terms (m[8] == m[9] == 0).
#[test]
fn centered_eye_is_symmetric_perspective() {
let screen = Screen::centered(0.6, 0.34).unwrap();
let d = 0.7;
let near = 0.05;
let oa = off_axis(&screen, Vec3::new(0.0, 0.0, d), near, 100.0).unwrap();
assert_close(oa.projection[8], 0.0, EPS, "skew x");
assert_close(oa.projection[9], 0.0, EPS, "skew y");
// fovy check: m[5] == 1/tan(fovy/2), tan(fovy/2) = (h/2)/d.
let expected_m5 = d / (0.34 / 2.0);
assert_close(oa.projection[5], expected_m5, 1e-9, "m[5] focal");
// aspect: m[0] == m[5]/aspect.
let expected_m0 = d / (0.6 / 2.0);
assert_close(oa.projection[0], expected_m0, 1e-9, "m[0] focal");
}
/// Depth convention: a point at `near` in front of the eye maps to NDC
/// z = -1; a point at `far` maps to +1 (OpenGL convention).
#[test]
fn near_and_far_map_to_ndc_depth_bounds() {
let screen = Screen::centered(0.6, 0.34).unwrap();
let eye = Vec3::new(0.12, -0.05, 0.65);
let (near, far) = (0.05, 40.0);
let m = off_axis(&screen, eye, near, far).unwrap().view_projection();
// Looking direction in tracker space is -vn.
let toward = screen.vn().scale(-1.0);
let p_near = eye + toward.scale(near);
let p_far = eye + toward.scale(far);
assert_close(project_point(&m, p_near).unwrap().z, -1.0, 1e-7, "near z");
assert_close(project_point(&m, p_far).unwrap().z, 1.0, 1e-7, "far z");
}
/// A point exactly on the screen plane keeps the same NDC (x, y) for
/// every eye position — this is the "window" property: the screen
/// surface itself is the fixed point of the illusion.
#[test]
fn screen_plane_points_are_eye_invariant() {
let screen = Screen::centered(0.5, 0.3).unwrap();
// A point 30% right / 20% up from screen center, on the plane.
let p = Vec3::new(0.5 * 0.3, 0.3 * 0.2, 0.0);
let mut first: Option<(f64, f64)> = None;
for eye in [
Vec3::new(0.0, 0.0, 0.6),
Vec3::new(0.3, 0.1, 0.4),
Vec3::new(-0.5, -0.2, 1.2),
] {
let m = off_axis(&screen, eye, 0.05, 100.0)
.unwrap()
.view_projection();
let ndc = project_point(&m, p).unwrap();
match first {
None => first = Some((ndc.x, ndc.y)),
Some((fx, fy)) => {
assert_close(ndc.x, fx, EPS, "plane-point ndc.x eye-invariance");
assert_close(ndc.y, fy, EPS, "plane-point ndc.y eye-invariance");
}
}
}
}
#[test]
fn error_paths_are_typed() {
let screen = Screen::centered(0.6, 0.34).unwrap();
let ok_eye = Vec3::new(0.0, 0.0, 0.5);
// Degenerate screens.
let p = Vec3::new(0.0, 0.0, 0.0);
assert_eq!(
Screen::new(p, p, Vec3::new(0.0, 1.0, 0.0)).unwrap_err(),
OffAxisError::DegenerateScreen
);
assert_eq!(
Screen::new(p, Vec3::new(1.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0)).unwrap_err(),
OffAxisError::DegenerateScreen
);
// Eye behind / on the plane.
assert_eq!(
off_axis(&screen, Vec3::new(0.0, 0.0, -0.5), 0.05, 10.0).unwrap_err(),
OffAxisError::EyeBehindScreen
);
assert_eq!(
off_axis(&screen, Vec3::new(0.2, 0.1, 0.0), 0.05, 10.0).unwrap_err(),
OffAxisError::EyeBehindScreen
);
// Bad clip planes.
assert_eq!(
off_axis(&screen, ok_eye, 0.0, 10.0).unwrap_err(),
OffAxisError::InvalidClipPlanes
);
assert_eq!(
off_axis(&screen, ok_eye, 1.0, 1.0).unwrap_err(),
OffAxisError::InvalidClipPlanes
);
assert_eq!(
off_axis(&screen, ok_eye, -1.0, 10.0).unwrap_err(),
OffAxisError::InvalidClipPlanes
);
// Non-finite input.
assert_eq!(
off_axis(&screen, Vec3::new(f64::NAN, 0.0, 0.5), 0.05, 10.0).unwrap_err(),
OffAxisError::NonFiniteInput
);
assert_eq!(
off_axis(&screen, ok_eye, f64::INFINITY, f64::INFINITY).unwrap_err(),
OffAxisError::InvalidClipPlanes
);
}
}
+442
View File
@@ -0,0 +1,442 @@
//! RF-input mapping for the ADR-324 demo tiers.
//!
//! This module turns RuView's *existing* RF surfaces into a bounded,
//! honestly-labeled eye position for the off-axis camera:
//!
//! - [`field_peak`] extracts the strongest cell of a `/ws/sensing`
//! `signal_field` grid using the **same** grid→world mapping as the
//! sensing server's `field_localize.rs` (constants mirrored below, with
//! the same honesty caveat: a single-link field peak is a representation
//! of where field energy concentrates, **not** calibrated metric
//! localization, and never a head position).
//! - [`CoarseParallax`] converts field-peak motion into a **Tier B** eye
//! offset: deadbanded, gain-limited, hard-clamped, one-euro filtered.
//! Its output is coarse body parallax by construction — the clamps make
//! over-claiming impossible at the API level.
//! - [`ScreenCalibration`] holds the physical screen measurements and maps
//! a normalized head position (Tier A, from any fine tracker the host
//! provides) into metric eye coordinates in screen space.
//!
//! Evidence discipline: nothing in this module asserts an accuracy number.
//! All numeric defaults are interaction-design choices (`CLAIMED`), not
//! measured performance.
use crate::filter::{OneEuro, OneEuroConfig};
use crate::math::Vec3;
/// Grid-cell → world X scale (metres per cell), mirroring
/// `wifi-densepose-sensing-server/src/field_localize.rs::X_SCALE`.
pub const FIELD_X_SCALE: f64 = 0.6;
/// Grid-cell → world Z scale (metres per cell), mirroring
/// `field_localize.rs::Z_SCALE`.
pub const FIELD_Z_SCALE: f64 = 0.5;
/// Minimum normalized field value for a cell to count as a real peak,
/// mirroring `field_localize.rs::PEAK_THRESHOLD`.
pub const FIELD_PEAK_THRESHOLD: f64 = 0.35;
/// The strongest cell of a signal-field grid, in the demo's world mapping.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FieldPeak {
/// World X (metres): `(ix - nx/2) * FIELD_X_SCALE`.
pub x: f64,
/// World Z (metres): `(iz - nz/2) * FIELD_Z_SCALE`.
pub z: f64,
/// The peak's normalized field value in `[0, 1]`.
pub value: f64,
/// Grid column of the peak.
pub ix: usize,
/// Grid row of the peak.
pub iz: usize,
}
/// Find the strongest field cell at or above [`FIELD_PEAK_THRESHOLD`].
///
/// `values` is row-major with the sensing server's layout
/// (`idx = iz * nx + ix`). Returns `None` when the slice length doesn't
/// match `nx * nz`, when the grid is empty, or when no cell reaches the
/// threshold (an honest "no localizable hotspot" outcome, mirroring the
/// server's gating).
pub fn field_peak(values: &[f32], nx: usize, nz: usize) -> Option<FieldPeak> {
field_peak_with_threshold(values, nx, nz, FIELD_PEAK_THRESHOLD)
}
/// [`field_peak`] with a caller-supplied threshold (used by tests and by
/// demos that want to visualize sub-threshold energy without moving the
/// camera).
pub fn field_peak_with_threshold(
values: &[f32],
nx: usize,
nz: usize,
threshold: f64,
) -> Option<FieldPeak> {
if nx == 0 || nz == 0 || values.len() != nx * nz {
return None;
}
// Branch-light argmax: a NaN never satisfies `v > best_v`, so non-finite
// cells are skipped without an explicit is_finite() in the hot loop
// (+inf is excluded by the finite check on the winner below).
let mut best_idx = usize::MAX;
let mut best_v = f32::NEG_INFINITY;
for (idx, &v) in values.iter().enumerate() {
if v > best_v {
best_v = v;
best_idx = idx;
}
}
if best_idx == usize::MAX || !best_v.is_finite() {
return None;
}
let (idx, v) = (best_idx, best_v);
// Compare in f32: grid values arrive as f32 (Float32Array), and casting
// 0.35_f32 up to f64 lands a hair below a 0.35_f64 threshold.
if v < threshold as f32 {
return None;
}
let ix = idx % nx;
let iz = idx / nx;
Some(FieldPeak {
x: (ix as f64 - nx as f64 / 2.0) * FIELD_X_SCALE,
z: (iz as f64 - nz as f64 / 2.0) * FIELD_Z_SCALE,
value: v as f64,
ix,
iz,
})
}
/// Tier B parameters. Every default is an interaction-design choice
/// (`CLAIMED`), deliberately conservative so the mode reads as what it is:
/// slow body-scale parallax, not head tracking.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CoarseParallaxConfig {
/// Eye metres produced per body metre of peak movement (≤ 1 keeps the
/// effect visibly sub-physical).
pub gain: f64,
/// Peak movement below this (metres, from the session origin) is
/// ignored entirely — RF field peaks jitter, and the deadband keeps a
/// still room visually still.
pub deadband_m: f64,
/// Hard clamp on the |x| eye offset (metres). The API cannot emit a
/// larger excursion regardless of input.
pub max_offset_m: f64,
/// Nominal viewing distance (metres) used as the eye's Z when no depth
/// modulation applies.
pub base_distance_m: f64,
/// One-euro parameters for the offset filter. Tier B wants heavy
/// smoothing: default `min_cutoff` is well below the Tier A default.
pub filter: OneEuroConfig,
}
impl Default for CoarseParallaxConfig {
fn default() -> Self {
Self {
gain: 0.5,
deadband_m: 0.15,
max_offset_m: 0.35,
base_distance_m: 0.65,
filter: OneEuroConfig {
min_cutoff: 0.4,
beta: 0.2,
d_cutoff: 1.0,
},
}
}
}
/// Tier B: field-peak motion → bounded, smoothed eye position.
///
/// The first accepted peak establishes a session origin; subsequent peaks
/// move the eye relative to it. Peaks below threshold (i.e. `None` from
/// [`field_peak`]) hold the last eye position — the camera never snaps.
#[derive(Clone, Copy, Debug)]
pub struct CoarseParallax {
cfg: CoarseParallaxConfig,
fx: OneEuro,
fz: OneEuro,
origin: Option<(f64, f64)>,
eye: Vec3,
}
impl CoarseParallax {
/// Create with the given configuration.
pub fn new(cfg: CoarseParallaxConfig) -> Self {
Self {
cfg,
fx: OneEuro::new(cfg.filter),
fz: OneEuro::new(cfg.filter),
origin: None,
eye: Vec3::new(0.0, 0.0, cfg.base_distance_m),
}
}
/// The current (last computed) eye position in screen space (metres).
pub fn eye(&self) -> Vec3 {
self.eye
}
/// Forget the session origin and filter state; the eye returns to the
/// centered rest position.
pub fn reset(&mut self) {
self.origin = None;
self.fx.reset();
self.fz.reset();
self.eye = Vec3::new(0.0, 0.0, self.cfg.base_distance_m);
}
/// Ingest a field peak observed at `t_s` seconds; returns the updated
/// eye position. Call with the output of [`field_peak`]; pass `None`
/// (below-threshold field) to hold the current position.
pub fn update(&mut self, peak: Option<FieldPeak>, t_s: f64) -> Vec3 {
let Some(p) = peak else { return self.eye };
let (ox, oz) = *self.origin.get_or_insert((p.x, p.z));
let dx = deadband(p.x - ox, self.cfg.deadband_m);
let dz = deadband(p.z - oz, self.cfg.deadband_m);
let raw_x = (dx * self.cfg.gain).clamp(-self.cfg.max_offset_m, self.cfg.max_offset_m);
// Peak Z (room depth) modulates viewing distance, same bound.
let raw_z = (dz * self.cfg.gain).clamp(-self.cfg.max_offset_m, self.cfg.max_offset_m);
let x = self.fx.filter(raw_x, t_s);
let z_off = self.fz.filter(raw_z, t_s);
// Keep the eye strictly in front of the screen: distance floor at
// half the nominal distance.
let z = (self.cfg.base_distance_m + z_off).max(self.cfg.base_distance_m * 0.5);
self.eye = Vec3::new(x, 0.0, z);
self.eye
}
}
/// Zero inside `±band`, shifted toward zero outside it (continuous at the
/// band edge, so motion doesn't jump when leaving the deadband).
#[inline]
fn deadband(v: f64, band: f64) -> f64 {
if v > band {
v - band
} else if v < -band {
v + band
} else {
0.0
}
}
/// Physical screen calibration (Tier A): metric screen size plus the
/// viewer's nominal distance. Mirrors the concept of a measured-screen
/// calibration wizard; values persist wherever the host keeps them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScreenCalibration {
/// Physical screen width in metres.
pub width_m: f64,
/// Physical screen height in metres.
pub height_m: f64,
/// Nominal eye-to-screen distance in metres.
pub base_distance_m: f64,
}
impl ScreenCalibration {
/// Construct from centimetre measurements (how humans measure screens).
pub fn from_cm(width_cm: f64, height_cm: f64, distance_cm: f64) -> Self {
Self {
width_m: width_cm / 100.0,
height_m: height_cm / 100.0,
base_distance_m: distance_cm / 100.0,
}
}
/// The screen this calibration describes, centered at the origin
/// (see [`crate::Screen::centered`]).
pub fn screen(&self) -> Result<crate::Screen, crate::OffAxisError> {
crate::Screen::centered(self.width_m, self.height_m)
}
/// Map a normalized head position from a fine tracker into metric eye
/// coordinates in screen space.
///
/// - `nx`, `ny`: head position in normalized image coordinates
/// (`[0, 1]`, origin top-left, x rightward — the usual camera-image
/// convention). The image is assumed mirrored (selfie view), so a
/// viewer moving to *their* left moves the eye left in screen space.
/// - `depth_scale`: multiplier on the nominal distance (1.0 = at the
/// calibrated distance; a fine tracker derives it from e.g. apparent
/// inter-feature distance). Clamped to `[0.25, 4.0]`.
/// - `lateral_range_m`: metres of eye travel represented by the full
/// image width/height. Deployment-specific; the screen width is a
/// reasonable default.
pub fn eye_from_normalized(
&self,
nx: f64,
ny: f64,
depth_scale: f64,
lateral_range_m: f64,
) -> Vec3 {
let cx = (nx.clamp(0.0, 1.0) - 0.5) * lateral_range_m;
let cy = (0.5 - ny.clamp(0.0, 1.0)) * lateral_range_m * (self.height_m / self.width_m);
let z = self.base_distance_m * depth_scale.clamp(0.25, 4.0);
Vec3::new(cx, cy, z)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn grid(nx: usize, nz: usize, hot: &[(usize, usize, f32)]) -> Vec<f32> {
let mut v = vec![0.05_f32; nx * nz];
for &(ix, iz, val) in hot {
v[iz * nx + ix] = val;
}
v
}
#[test]
fn peak_mapping_matches_field_localize_layout() {
// 20×20 grid, hot cell at (ix=15, iz=4).
let g = grid(20, 20, &[(15, 4, 0.9)]);
let p = field_peak(&g, 20, 20).unwrap();
assert_eq!(p.ix, 15);
assert_eq!(p.iz, 4);
// world_x = (15 - 10) * 0.6, world_z = (4 - 10) * 0.5 — the exact
// transform documented in field_localize.rs / the Observatory.
assert!((p.x - 3.0).abs() < 1e-12);
assert!((p.z - -3.0).abs() < 1e-12);
assert!((p.value - 0.9).abs() < 1e-6);
}
#[test]
fn below_threshold_and_malformed_grids_yield_none() {
let g = grid(20, 20, &[(3, 3, 0.34)]);
assert_eq!(field_peak(&g, 20, 20), None, "0.34 < threshold 0.35");
assert!(field_peak(&g, 19, 20).is_none(), "length mismatch");
assert!(field_peak(&[], 0, 0).is_none(), "empty grid");
// NaN cells are skipped, not propagated.
let mut g = grid(4, 4, &[(1, 1, 0.8)]);
g[0] = f32::NAN;
let p = field_peak(&g, 4, 4).unwrap();
assert_eq!((p.ix, p.iz), (1, 1));
}
#[test]
fn exact_threshold_is_accepted() {
let g = grid(20, 20, &[(2, 2, 0.35)]);
assert!(field_peak(&g, 20, 20).is_some(), "threshold is inclusive");
}
#[test]
fn coarse_parallax_is_deadbanded_gained_and_clamped() {
let cfg = CoarseParallaxConfig::default();
let mut cp = CoarseParallax::new(cfg);
// First peak sets the origin: eye stays at rest.
let origin = FieldPeak {
x: 1.2,
z: -0.5,
value: 0.8,
ix: 12,
iz: 9,
};
let eye0 = cp.update(Some(origin), 0.0);
assert_eq!(eye0, Vec3::new(0.0, 0.0, cfg.base_distance_m));
// Movement inside the deadband: still at rest.
let small = FieldPeak {
x: 1.2 + 0.1,
..origin
};
let eye1 = cp.update(Some(small), 0.5);
assert_eq!(eye1.x, 0.0, "0.1 m < 0.15 m deadband");
// A huge excursion is clamped to max_offset regardless of gain.
let huge = FieldPeak {
x: 1.2 + 50.0,
..origin
};
let mut eye = Vec3::default();
for i in 0..600 {
eye = cp.update(Some(huge), 1.0 + i as f64 * 0.05);
}
assert!(eye.x <= cfg.max_offset_m + 1e-9, "clamped: {}", eye.x);
assert!(
eye.x > cfg.max_offset_m * 0.9,
"filter converged near clamp"
);
// Below-threshold (None) holds position; never snaps back.
let held = cp.update(None, 100.0);
assert_eq!(held, eye);
}
#[test]
fn coarse_parallax_depth_never_crosses_the_screen() {
let cfg = CoarseParallaxConfig::default();
let mut cp = CoarseParallax::new(cfg);
let origin = FieldPeak {
x: 0.0,
z: 0.0,
value: 0.9,
ix: 10,
iz: 10,
};
cp.update(Some(origin), 0.0);
// Walk far toward the screen (negative z offset).
let close = FieldPeak { z: -50.0, ..origin };
let mut eye = Vec3::default();
for i in 0..600 {
eye = cp.update(Some(close), 0.1 + i as f64 * 0.05);
}
assert!(
eye.z >= cfg.base_distance_m * 0.5 - 1e-9,
"distance floor: {}",
eye.z
);
}
#[test]
fn reset_returns_to_rest() {
let mut cp = CoarseParallax::new(CoarseParallaxConfig::default());
cp.update(
Some(FieldPeak {
x: 0.0,
z: 0.0,
value: 0.9,
ix: 0,
iz: 0,
}),
0.0,
);
cp.update(
Some(FieldPeak {
x: 9.0,
z: 0.0,
value: 0.9,
ix: 0,
iz: 0,
}),
1.0,
);
cp.reset();
assert_eq!(
cp.eye(),
Vec3::new(0.0, 0.0, CoarseParallaxConfig::default().base_distance_m)
);
}
#[test]
fn calibration_maps_normalized_head_to_metric_eye() {
let cal = ScreenCalibration::from_cm(60.0, 34.0, 65.0);
assert!((cal.width_m - 0.6).abs() < 1e-12);
// Centered head at nominal depth = centered eye at base distance.
let c = cal.eye_from_normalized(0.5, 0.5, 1.0, cal.width_m);
assert_eq!(c, Vec3::new(0.0, 0.0, 0.65));
// Head at image right edge → eye half the range to the right;
// image y grows downward → ny=0 (top) is +y in screen space.
let e = cal.eye_from_normalized(1.0, 0.0, 1.0, 0.6);
assert!((e.x - 0.3).abs() < 1e-12);
assert!(e.y > 0.0);
// Depth scale is clamped to a sane band.
assert_eq!(cal.eye_from_normalized(0.5, 0.5, 100.0, 0.6).z, 0.65 * 4.0);
assert_eq!(cal.eye_from_normalized(0.5, 0.5, 0.0, 0.6).z, 0.65 * 0.25);
// Out-of-range normalized coords are clamped, not extrapolated.
let clamped = cal.eye_from_normalized(7.0, -3.0, 1.0, 0.6);
assert!((clamped.x - 0.3).abs() < 1e-12);
}
}
+202
View File
@@ -0,0 +1,202 @@
//! wasm-bindgen surface (compiled only for `wasm32`).
//!
//! Exposes two small classes to JavaScript:
//!
//! - [`OffAxisCamera`] — Tier A/B shared core: screen calibration + one-euro
//! filtering + Kooima projection. Feed it an eye position each frame, read
//! back column-major matrices ready for `THREE.Matrix4.fromArray`.
//! - [`RfParallax`] — Tier B input stage: `/ws/sensing` `signal_field`
//! grids in, bounded coarse-parallax eye position out. Its output is
//! coarse body parallax by construction (deadband + gain + clamp), so a
//! demo cannot accidentally present it as head tracking.
//!
//! All methods that can fail return `Result<_, JsError>` (thrown as JS
//! exceptions); per-frame update methods instead hold the last good state so
//! a render loop never has to try/catch.
use crate::filter::{OneEuro3, OneEuroConfig};
use crate::math::Vec3;
use crate::projection::{off_axis, OffAxis, Screen};
use crate::rf::{field_peak, CoarseParallax, CoarseParallaxConfig, ScreenCalibration};
use wasm_bindgen::prelude::*;
/// Head-coupled off-axis camera for a physically calibrated screen.
#[wasm_bindgen]
pub struct OffAxisCamera {
screen: Screen,
cal: ScreenCalibration,
near: f64,
far: f64,
filter: OneEuro3,
current: OffAxis,
eye: Vec3,
}
#[wasm_bindgen]
impl OffAxisCamera {
/// Create from physical screen measurements in **centimetres** (the
/// units a person measures with) plus clip planes in metres.
#[wasm_bindgen(constructor)]
pub fn new(
screen_width_cm: f64,
screen_height_cm: f64,
viewing_distance_cm: f64,
near_m: f64,
far_m: f64,
) -> Result<OffAxisCamera, JsError> {
let cal =
ScreenCalibration::from_cm(screen_width_cm, screen_height_cm, viewing_distance_cm);
let screen = cal.screen().map_err(|e| JsError::new(&e.to_string()))?;
let eye = Vec3::new(0.0, 0.0, cal.base_distance_m);
let current =
off_axis(&screen, eye, near_m, far_m).map_err(|e| JsError::new(&e.to_string()))?;
Ok(Self {
screen,
cal,
near: near_m,
far: far_m,
filter: OneEuro3::new(OneEuroConfig {
min_cutoff: 1.0,
beta: 0.3,
d_cutoff: 1.0,
}),
current,
eye,
})
}
/// Tune the one-euro filter (`min_cutoff` Hz, `beta`). Lower
/// `min_cutoff` = smoother at rest; higher `beta` = less lag in motion.
pub fn set_filter(&mut self, min_cutoff: f64, beta: f64) {
self.filter.set_config(OneEuroConfig {
min_cutoff,
beta,
d_cutoff: 1.0,
});
}
/// Reset filter state (e.g. after tracking was lost).
pub fn reset_filter(&mut self) {
self.filter.reset();
}
/// Update from a **metric eye position** in screen space (metres,
/// origin = screen center, +x right, +y up, +z toward the viewer) at
/// time `t_s` seconds. Returns `true` when the matrices were updated;
/// `false` when the sample was rejected (eye behind screen / non-finite)
/// and the previous matrices were held.
pub fn update_eye(&mut self, x_m: f64, y_m: f64, z_m: f64, t_s: f64) -> bool {
let eye = self.filter.filter(Vec3::new(x_m, y_m, z_m), t_s);
match off_axis(&self.screen, eye, self.near, self.far) {
Ok(oa) => {
self.current = oa;
self.eye = eye;
true
}
Err(_) => false,
}
}
/// Update from a **normalized head position** (`nx`, `ny` in `[0, 1]`,
/// camera-image convention, mirrored/selfie view) with a depth scale
/// (1.0 = at the calibrated distance) — the Tier A path fed by any fine
/// tracker the host runs. `lateral_range_m` is how many metres of eye
/// travel the full image spans (screen width is a reasonable start).
pub fn update_normalized(
&mut self,
nx: f64,
ny: f64,
depth_scale: f64,
lateral_range_m: f64,
t_s: f64,
) -> bool {
let eye = self
.cal
.eye_from_normalized(nx, ny, depth_scale, lateral_range_m);
self.update_eye(eye.x, eye.y, eye.z, t_s)
}
/// The current asymmetric projection matrix, column-major, 16 elements
/// (pass straight to `THREE.Matrix4.fromArray`).
pub fn projection(&self) -> Vec<f64> {
self.current.projection.to_vec()
}
/// The current screen-aligned view matrix, column-major, 16 elements.
pub fn view(&self) -> Vec<f64> {
self.current.view.to_vec()
}
/// `projection * view` as one matrix, column-major, 16 elements.
pub fn view_projection(&self) -> Vec<f64> {
self.current.view_projection().to_vec()
}
/// The current (filtered) eye position `[x, y, z]` in metres.
pub fn eye(&self) -> Vec<f64> {
vec![self.eye.x, self.eye.y, self.eye.z]
}
}
/// Tier B input stage: signal-field grids → bounded coarse-parallax eye.
#[wasm_bindgen]
pub struct RfParallax {
parallax: CoarseParallax,
last_value: f64,
has_peak: bool,
}
#[wasm_bindgen]
impl RfParallax {
/// Create with the default conservative Tier B tuning and the given
/// nominal viewing distance (metres).
#[wasm_bindgen(constructor)]
pub fn new(base_distance_m: f64) -> RfParallax {
let cfg = CoarseParallaxConfig {
base_distance_m,
..CoarseParallaxConfig::default()
};
RfParallax {
parallax: CoarseParallax::new(cfg),
last_value: 0.0,
has_peak: false,
}
}
/// Ingest one `signal_field` grid (`values.length == nx * nz`,
/// `idx = iz * nx + ix` — the `/ws/sensing` layout) observed at `t_s`
/// seconds. Returns `true` when a peak at/above the server's threshold
/// was found (eye moved), `false` when the field had no localizable
/// hotspot (eye held).
pub fn update(&mut self, values: &[f32], nx: usize, nz: usize, t_s: f64) -> bool {
let peak = field_peak(values, nx, nz);
self.has_peak = peak.is_some();
self.last_value = peak.map_or(self.last_value, |p| p.value);
self.parallax.update(peak, t_s);
self.has_peak
}
/// The current eye position `[x, y, z]` in metres (screen space) —
/// feed to `OffAxisCamera.update_eye`.
pub fn eye(&self) -> Vec<f64> {
let e = self.parallax.eye();
vec![e.x, e.y, e.z]
}
/// Whether the last grid had an at/above-threshold peak.
pub fn has_peak(&self) -> bool {
self.has_peak
}
/// The last accepted peak's normalized field value (HUD display).
pub fn peak_value(&self) -> f64 {
self.last_value
}
/// Forget the session origin (person left / demo reset).
pub fn reset(&mut self) {
self.parallax.reset();
self.has_peak = false;
self.last_value = 0.0;
}
}
+4
View File
@@ -10,6 +10,10 @@ repository.workspace = true
thiserror.workspace = true
serde = { workspace = true, features = ["derive"] }
ruview-evidence = { path = "../ruview-evidence" }
ruview-ood = { path = "../ruview-ood" }
ruview-certify = { path = "../ruview-certify" }
ruview-attest = { path = "../ruview-attest" }
[dev-dependencies]
serde_json.workspace = true
ruview-ontology = { path = "../ruview-ontology" }
+207
View File
@@ -433,6 +433,70 @@ pub fn authorize_with(
}
}
// ---------------------------------------------------------------------------
// The real adapter (ADR-297/318/321) — ruview-ood + ruview-certify -> here.
// ---------------------------------------------------------------------------
/// The adapter boundary from a real `ruview-ood` gate result to this crate's
/// three-way domain signature, exactly as the crate-level doc comment
/// prescribes: the cause carried by `Degraded`/`Unknown` is dropped, since
/// [`authorize`] only needs the three-way outcome.
impl From<ruview_ood::DomainState> for DomainState {
fn from(state: ruview_ood::DomainState) -> Self {
match state {
ruview_ood::DomainState::Known => DomainState::Known,
ruview_ood::DomainState::Degraded(_) => DomainState::Degraded,
ruview_ood::DomainState::Unknown(_) => DomainState::Unknown,
}
}
}
/// Authorize an action from a **real** [`ruview_certify::CapabilityCertificate`]
/// and a **real** [`ruview_ood::DomainState`], instead of a hand-built
/// [`AssuranceInputs`].
///
/// This is the composition the crate-level "Adapter note" describes but that,
/// before this function existed, no code in the workspace actually performed:
/// `ruview-policy` never depended on `ruview-certify`/`ruview-ood`, so a real
/// OOD drift result had no path into a real `authorize()` call — only two
/// disconnected unit tests, each hand-setting the same enum value on its own
/// crate's local type, exercised the *shape* of the story.
///
/// Deliberately **not** `cert.is_valid(now, domain)`: that folds the domain
/// check into `certificate_valid`, which would attribute a domain-caused deny
/// to a generic "certificate invalid" instead of
/// [`FailedCondition::DomainNotKnown`]/[`FailedCondition::DomainDegraded`].
/// Time+signature and domain are validated separately here, per the crate's
/// own documented contract, and `authorize` (pure, clock-free) does the rest.
#[must_use]
pub fn authorize_from_certificate<V: ruview_attest::Verifier + ?Sized>(
class: ActionClass,
cert: &ruview_certify::CapabilityCertificate,
verifier: &V,
now_unix_s: i64,
domain: ruview_ood::DomainState,
certificate_class: CertificateClass,
uncertainty: f64,
evidence_level: EvidenceLevel,
) -> Authorization {
let certificate_valid =
cert.verify(verifier) && now_unix_s < cert.content.valid_until_unix_s;
let certificate_age_secs =
(now_unix_s - cert.content.calibrated_date_unix_s).max(0) as u64;
authorize(
class,
&AssuranceInputs {
certificate_class,
certificate_valid,
certificate_age_secs,
domain_state: DomainState::from(domain),
uncertainty,
evidence_level,
},
)
}
// ---------------------------------------------------------------------------
// Tests — all fixtures are SYNTHETIC / L0 (CLAUDE.md honesty rule).
// ---------------------------------------------------------------------------
@@ -751,3 +815,146 @@ mod tests {
assert_eq!(decision, back);
}
}
// ---------------------------------------------------------------------------
// Real cross-crate integration test (ADR-297/300/318/321 acceptance test B).
//
// The PR that introduced ruview-ood/-certify/-policy claimed this exact
// scenario — "a post-drift UNKNOWN domain invalidates the capability
// certificate and denies a SafetyCritical action" — as a load-bearing
// acceptance test. What existed were two disconnected unit tests in two
// crates that had no dependency on each other, each hand-setting the same
// enum value on its own crate's *local* type. Neither exercised
// `authorize_from_certificate`, because it didn't exist, and `ruview-policy`
// didn't depend on `ruview-certify`/`ruview-ood` at all.
//
// This test mints a REAL signed `CapabilityCertificate` (ruview-certify) and
// drives `authorize_from_certificate` (this crate) with a REAL
// `ruview_ood::DomainState`, so the drift-invalidates-capability claim is
// backed by one composed pipeline, not two same-shaped unit tests.
// ---------------------------------------------------------------------------
#[cfg(test)]
mod acceptance_test_b_real_integration {
use super::*;
use ruview_attest::{Blake3MacSigner, DeviceId};
use ruview_certify::{Capability, CertificateContent, OperatingMetrics};
use ruview_evidence::EvidenceLevel;
use ruview_ontology::SpaceId;
const CALIBRATED_AT: i64 = 1_000;
const VALID_UNTIL: i64 = 5_000;
/// A real, signed `CapabilityCertificate` — hand-assembled from public
/// types rather than going through `ruview_certify::mint`'s full
/// calibration/evidence-ledger pipeline (already covered by that crate's
/// own test suite). What this test is pinning is the *adapter*, not
/// certify's minting validation.
fn real_signed_certificate() -> (ruview_certify::CapabilityCertificate, Blake3MacSigner) {
let signer = Blake3MacSigner::new([9u8; 32]);
let content = CertificateContent {
capability: Capability::Presence,
room: SpaceId::new("kitchen").unwrap(),
calibration_version: 1,
calibration_expires_at_unix_s: VALID_UNTIL + 1,
hardware: DeviceId::new("dev-1").unwrap(),
model_version: "m-1".to_string(),
calibrated_date_unix_s: CALIBRATED_AT,
metrics: OperatingMetrics {
moving_recall: 0.9,
stationary_recall: 0.85,
false_presence_per_24h: 0.1,
},
valid_until_unix_s: VALID_UNTIL,
evidence_level: EvidenceLevel::L0,
};
let signature = ruview_attest::Signer::sign(&signer, &content.canonical_bytes());
let cert = ruview_certify::CapabilityCertificate {
content,
signature: Some(signature),
};
(cert, signer)
}
#[test]
fn known_domain_authorizes_safety_critical() {
let (cert, signer) = real_signed_certificate();
let decision = authorize_from_certificate(
ActionClass::SafetyCritical,
&cert,
&signer,
CALIBRATED_AT + 10, // well inside validity
ruview_ood::DomainState::Known,
CertificateClass::High,
0.05,
EvidenceLevel::L3,
);
assert!(decision.is_allowed(), "{decision:?}");
}
/// The acceptance-test-B claim, for real: the SAME certificate that just
/// authorized a SafetyCritical action, re-evaluated with nothing changed
/// except a real post-drift `ruview_ood::DomainState::Unknown`, denies —
/// through the actual ood -> certify-adapter -> policy composition, not a
/// hand-set field on a local type.
#[test]
fn post_drift_unknown_domain_denies_safety_critical() {
let (cert, signer) = real_signed_certificate();
let now = CALIBRATED_AT + 10;
let pre_drift = authorize_from_certificate(
ActionClass::SafetyCritical,
&cert,
&signer,
now,
ruview_ood::DomainState::Known,
CertificateClass::High,
0.05,
EvidenceLevel::L3,
);
assert!(pre_drift.is_allowed(), "pre-drift: {pre_drift:?}");
let post_drift = authorize_from_certificate(
ActionClass::SafetyCritical,
&cert,
&signer,
now,
ruview_ood::DomainState::Unknown(ruview_ood::DomainCause::DriftBeyondEnvelope),
CertificateClass::High,
0.05,
EvidenceLevel::L3,
);
assert_eq!(
post_drift,
Authorization::Deny {
failed_condition: FailedCondition::DomainNotKnown,
},
"a real post-drift UNKNOWN domain must deny the same certificate \
that was just valid, before a false-confident inference reaches \
an actuator",
);
}
#[test]
fn tampered_certificate_is_rejected_regardless_of_domain() {
let (cert, _signer) = real_signed_certificate();
// A different signer's key cannot verify this certificate's tag —
// exercises the "real verify(), not is_valid()" half of the adapter.
let wrong_signer = Blake3MacSigner::new([0xAAu8; 32]);
let decision = authorize_from_certificate(
ActionClass::Convenience,
&cert,
&wrong_signer,
CALIBRATED_AT + 10,
ruview_ood::DomainState::Known,
CertificateClass::Basic,
0.05,
EvidenceLevel::L0,
);
assert_eq!(
decision,
Authorization::Deny {
failed_condition: FailedCondition::CertificateInvalid,
},
);
}
}
+2
View File
@@ -62,9 +62,11 @@ anyhow = "1.0"
# the sensing server depends on this same crate with default features and
# gets only the verifier.
ruview-auth = { path = "../ruview-auth", features = ["login"] }
ruview-cognitum-spaces = { path = "../ruview-cognitum-spaces" }
# Only for constructing the HTTP client hands to Session.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
thiserror = "2.0"
url = "2"
# Time
chrono = { version = "0.4", features = ["serde"] }
+36 -11
View File
@@ -24,6 +24,10 @@ pub struct LoginArgs {
#[arg(long)]
pub admin: bool,
/// Also activate read-only Cognitum Spaces access (`spaces:read`).
#[arg(long)]
pub spaces: bool,
/// Skip the browser and use the paste-a-code flow.
///
/// Detected automatically over SSH and inside containers; this forces it.
@@ -66,18 +70,21 @@ fn path_or_default(p: Option<PathBuf>) -> PathBuf {
/// 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 {
fn requested_scope(admin: bool, spaces: bool) -> String {
let mut scopes = vec![scope::SENSING_READ];
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()
scopes.push(scope::SENSING_ADMIN);
}
if spaces {
scopes.push(scope::SPACES_READ);
}
scopes.join(" ")
}
pub async fn login_cmd(args: LoginArgs) -> anyhow::Result<()> {
let scope = requested_scope(args.admin);
let scope = requested_scope(args.admin, args.spaces);
let opts = LoginOptions {
credentials_path: path_or_default(args.credentials_path),
@@ -102,7 +109,9 @@ pub async fn logout_cmd(args: LogoutArgs) -> anyhow::Result<()> {
}
// 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.");
println!(
"Note: this forgets the local credential only. It does not revoke the session server-side."
);
Ok(())
}
@@ -157,9 +166,12 @@ mod tests {
// 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);
let s = requested_scope(false, false);
assert_eq!(s, scope::SENSING_READ);
assert!(!s.contains(scope::SENSING_ADMIN), "read-only login leaked admin: {s}");
assert!(
!s.contains(scope::SENSING_ADMIN),
"read-only login leaked admin: {s}"
);
}
#[test]
@@ -167,9 +179,22 @@ mod tests {
// 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}");
let s = requested_scope(true, false);
assert!(
s.split_whitespace().any(|x| x == scope::SENSING_READ),
"{s}"
);
assert!(
s.split_whitespace().any(|x| x == scope::SENSING_ADMIN),
"{s}"
);
}
#[test]
fn spaces_activation_is_explicit_and_read_only() {
let s = requested_scope(false, true);
assert!(s.split_whitespace().any(|x| x == scope::SPACES_READ));
assert!(!s.split_whitespace().any(|x| x == scope::SENSING_ADMIN));
}
#[test]
+5 -1
View File
@@ -29,9 +29,10 @@ use clap::{Parser, Subcommand};
pub mod auth;
pub mod calibrate;
pub mod calibrate_api;
pub mod room;
#[cfg(feature = "mat")]
pub mod mat;
pub mod room;
pub mod spaces;
/// WiFi-DensePose Command Line Interface
#[derive(Parser, Debug)]
@@ -61,6 +62,9 @@ pub enum Commands {
/// Show the stored Cognitum session: account, scope, and whether it is live.
Whoami(auth::WhoamiArgs),
/// Read tenant-scoped semantic state from Cognitum Spaces (ADR-325).
Spaces(spaces::SpacesArgs),
/// 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.
+3
View File
@@ -27,6 +27,9 @@ async fn main() -> anyhow::Result<()> {
Commands::Whoami(args) => {
wifi_densepose_cli::auth::whoami_cmd(args).await?;
}
Commands::Spaces(args) => {
wifi_densepose_cli::spaces::spaces_cmd(args).await?;
}
Commands::Calibrate(args) => {
wifi_densepose_cli::calibrate::execute(args).await?;
}
@@ -0,0 +1,74 @@
//! `wifi-densepose spaces` — Cognitum Spaces activation and read access.
use std::path::PathBuf;
use clap::Args;
use ruview_auth::{login, scope};
use ruview_cognitum_spaces::{Client, Credential};
#[derive(Debug, Args)]
pub struct SpacesArgs {
/// Cognitum Spaces API origin.
#[arg(long, default_value = "https://api.cognitum.one")]
pub base_url: String,
/// Compatibility API key. If omitted, use the stored OAuth session.
#[arg(long, env = "COGNITUM_SPACES_API", hide_env_values = true)]
pub api_key: Option<String>,
/// OAuth credential file used when no API key is supplied.
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
pub credentials_path: Option<PathBuf>,
/// Emit the validated response as JSON.
#[arg(long)]
pub json: bool,
}
pub async fn spaces_cmd(args: SpacesArgs) -> anyhow::Result<()> {
let credential = match args.api_key {
Some(key) => Credential::api_key(key)?,
None => {
let path = args
.credentials_path
.unwrap_or_else(login::default_credentials_path);
let session = login::Session::load_from(path, reqwest::Client::new())?;
let snapshot = session.snapshot().await;
let granted = snapshot.effective_scope().unwrap_or_default();
if !granted
.split_whitespace()
.any(|item| item == scope::SPACES_READ)
{
anyhow::bail!(
"stored OAuth session lacks spaces:read; run `wifi-densepose login --spaces`"
);
}
Credential::oauth(session.ensure_fresh().await?)?
}
};
let response = Client::new(&args.base_url, credential)?.list().await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&response)?);
return Ok(());
}
println!("Cognitum Spaces: {}", response.data.len());
println!(
"Boundary: {} / {}",
response.boundary.authoritative_state, response.boundary.cloud_role
);
for space in response.data {
let occupancy = space
.state
.occupancy
.map_or_else(|| "unknown".into(), |v| v.to_string());
let confidence = space
.state
.confidence
.map_or_else(|| "unknown".into(), |v| format!("{v:.3}"));
println!(
"{}\t{}\toccupancy={}\tconfidence={}\t{}",
space.id, space.name, occupancy, confidence, space.status
);
}
Ok(())
}
+11
View File
@@ -47,12 +47,23 @@
extern crate alloc;
pub mod error;
pub mod pose_observation_v2;
pub mod pose_refinement_v1;
pub mod traits;
pub mod types;
pub mod utils;
// Re-export commonly used types at the crate root
pub use error::{CoreError, CoreResult, InferenceError, SignalError, StorageError};
pub use pose_observation_v2::{
CalibrationId, Coco17Joint, FloorPlane, JointObservation, JointVisibility, ModelRef,
PoseDimensionality, PoseObservationV2, PoseTrustState, Probability, SourceProvenance,
SpatialFrameRef, SymmetricCovariance3, TrackId, POSE_OBSERVATION_SCHEMA_VERSION,
};
pub use pose_refinement_v1::{
AbstentionReason, ConstraintResiduals, ContactHypothesis, ContactState, DynamicsResiduals,
InterventionSummary, PhysicsMode, PhysicsProvenance, PoseRefinementV1, RefinementDisposition,
};
pub use traits::{CanonicalFrame, DataStore, NeuralInference, SignalProcessor};
pub use types::{
AntennaConfig,
@@ -0,0 +1,401 @@
//! Canonical metric pose observation contract for physics assessment (ADR-323).
#![allow(missing_docs)]
#[cfg(not(feature = "std"))]
use alloc::string::String;
#[cfg(feature = "std")]
use std::string::String;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
/// Schema version accepted by [`PoseObservationV2`].
pub const POSE_OBSERVATION_SCHEMA_VERSION: u16 = 2;
/// A validated probability in the inclusive range `[0, 1]`.
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "f32", into = "f32"))]
pub struct Probability(f32);
impl Probability {
/// Construct a finite probability.
///
/// # Errors
///
/// Returns an error for non-finite values or values outside `[0, 1]`.
pub fn new(value: f32) -> Result<Self, &'static str> {
if value.is_finite() && (0.0..=1.0).contains(&value) {
Ok(Self(value))
} else {
Err("probability must be finite and in [0, 1]")
}
}
/// Return the underlying value.
#[must_use]
pub const fn get(self) -> f32 {
self.0
}
/// Zero probability.
pub const ZERO: Self = Self(0.0);
/// Unit probability.
pub const ONE: Self = Self(1.0);
}
impl TryFrom<f32> for Probability {
type Error = &'static str;
fn try_from(value: f32) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<Probability> for f32 {
fn from(value: Probability) -> Self {
value.get()
}
}
macro_rules! string_id {
($name:ident) => {
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct $name(pub String);
};
}
string_id!(TrackId);
string_id!(CalibrationId);
/// Versioned coordinate frame. Metric correction requires every boolean here.
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SpatialFrameRef {
/// Stable frame name.
pub name: String,
/// Frame definition version.
pub version: u32,
/// Coordinates are metres rather than image-normalized units.
pub metric: bool,
/// The axes form a right-handed system.
pub right_handed: bool,
/// Positive Z is vertical/up.
pub z_up: bool,
}
/// Signed model identity.
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ModelRef {
/// Human-readable model/version identifier.
pub id: String,
/// Verified model artifact digest.
pub artifact_hash: [u8; 32],
}
/// Sensor provenance relevant to correction selection.
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SourceProvenance {
/// Stable sensor identity.
pub sensor_id: String,
/// True only after message/source authentication succeeds.
pub authenticated: bool,
/// True only when sequence binding and replay-window checks are active.
pub replay_protected: bool,
}
/// RF distribution trust state from ADR-302.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum PoseTrustState {
/// Calibration/evidence says the sample is in distribution.
Known,
/// Input is usable for audit but confidence should be reduced.
Degraded,
/// Input is out-of-distribution or cannot be classified.
Unknown,
}
/// Whether the observation is image-plane or physical 3D.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum PoseDimensionality {
/// Normalized or pixel image coordinates; audit-only.
Image2d,
/// Metric room-frame X/Y/Z coordinates.
Metric3d,
}
/// Normalized plane `normal dot point + offset_m = 0`.
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct FloorPlane {
/// Upward-pointing unit normal.
pub normal: [f32; 3],
/// Plane offset in metres.
pub offset_m: f32,
}
impl FloorPlane {
/// Whether all values are finite and the normal is approximately unit length.
#[must_use]
pub fn is_valid(self) -> bool {
if !self.normal.iter().all(|v| v.is_finite()) || !self.offset_m.is_finite() {
return false;
}
let n2 = self.normal.iter().map(|v| v * v).sum::<f32>();
(n2 - 1.0).abs() <= 1.0e-3 && self.normal[2] > 0.0
}
/// Signed distance to the plane in metres.
#[must_use]
pub fn signed_distance(self, point: [f32; 3]) -> f32 {
self.normal[0].mul_add(
point[0],
self.normal[1].mul_add(point[1], self.normal[2].mul_add(point[2], self.offset_m)),
)
}
}
/// The six unique entries of a symmetric 3x3 covariance matrix.
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SymmetricCovariance3 {
pub xx: f32,
pub xy: f32,
pub xz: f32,
pub yy: f32,
pub yz: f32,
pub zz: f32,
}
impl SymmetricCovariance3 {
/// Conservative positive-semidefinite validation using principal minors.
#[must_use]
#[allow(
clippy::similar_names,
clippy::suboptimal_flops,
clippy::suspicious_operation_groupings
)]
pub fn is_positive_semidefinite(self) -> bool {
const EPS: f32 = 1.0e-8;
let values = [self.xx, self.xy, self.xz, self.yy, self.yz, self.zz];
if !values.iter().all(|v| v.is_finite()) {
return false;
}
if self.xx < -EPS || self.yy < -EPS || self.zz < -EPS {
return false;
}
let xy_minor = self.xx * self.yy - self.xy * self.xy;
let xz_minor = self.xx * self.zz - self.xz * self.xz;
let yz_minor = self.yy * self.zz - self.yz * self.yz;
let det = self.xx * (self.yy * self.zz - self.yz * self.yz)
- self.xy * (self.xy * self.zz - self.yz * self.xz)
+ self.xz * (self.xy * self.yz - self.yy * self.xz);
xy_minor >= -EPS && xz_minor >= -EPS && yz_minor >= -EPS && det >= -EPS
}
/// Trace, used as a bounded uncertainty weight.
#[must_use]
pub fn trace(self) -> f32 {
self.xx + self.yy + self.zz
}
}
/// COCO-17 joint identity in canonical order.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[repr(u8)]
pub enum Coco17Joint {
Nose,
LeftEye,
RightEye,
LeftEar,
RightEar,
LeftShoulder,
RightShoulder,
LeftElbow,
RightElbow,
LeftWrist,
RightWrist,
LeftHip,
RightHip,
LeftKnee,
RightKnee,
LeftAnkle,
RightAnkle,
}
impl Coco17Joint {
/// Canonical COCO-17 order.
pub const ALL: [Self; 17] = [
Self::Nose,
Self::LeftEye,
Self::RightEye,
Self::LeftEar,
Self::RightEar,
Self::LeftShoulder,
Self::RightShoulder,
Self::LeftElbow,
Self::RightElbow,
Self::LeftWrist,
Self::RightWrist,
Self::LeftHip,
Self::RightHip,
Self::LeftKnee,
Self::RightKnee,
Self::LeftAnkle,
Self::RightAnkle,
];
}
/// Whether a joint is usable by the observer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum JointVisibility {
Visible,
Occluded,
Unknown,
}
/// One observed COCO joint and calibrated uncertainty.
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct JointObservation {
pub kind: Coco17Joint,
pub position_m: [f32; 3],
pub covariance_m2: SymmetricCovariance3,
pub confidence: Probability,
pub visibility: JointVisibility,
}
/// Immutable pose observation presented to the physics boundary.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PoseObservationV2 {
pub schema_version: u16,
pub timestamp_ns: u64,
pub sensor_epoch: u64,
pub sequence: u64,
pub track_id: TrackId,
pub frame: SpatialFrameRef,
pub calibration_id: CalibrationId,
pub floor_plane: Option<FloorPlane>,
pub model: ModelRef,
pub source: SourceProvenance,
pub trust_state: PoseTrustState,
pub dimensionality: PoseDimensionality,
pub uncertainty_calibrated: bool,
pub joints: [JointObservation; 17],
pub observer_confidence: Probability,
pub canonical_hash: [u8; 32],
}
impl PoseObservationV2 {
/// Compute a deterministic content hash excluding `canonical_hash` itself.
#[must_use]
pub fn compute_canonical_hash(&self) -> [u8; 32] {
let mut h = blake3::Hasher::new();
h.update(b"ruview.pose-observation-v2\0");
h.update(&self.schema_version.to_le_bytes());
h.update(&self.timestamp_ns.to_le_bytes());
h.update(&self.sensor_epoch.to_le_bytes());
h.update(&self.sequence.to_le_bytes());
hash_str(&mut h, &self.track_id.0);
hash_str(&mut h, &self.frame.name);
h.update(&self.frame.version.to_le_bytes());
h.update(&[
self.frame.metric.into(),
self.frame.right_handed.into(),
self.frame.z_up.into(),
]);
hash_str(&mut h, &self.calibration_id.0);
match self.floor_plane {
Some(plane) => {
h.update(&[1]);
for value in plane.normal {
h.update(&value.to_bits().to_le_bytes());
}
h.update(&plane.offset_m.to_bits().to_le_bytes());
}
None => {
h.update(&[0]);
}
}
hash_str(&mut h, &self.model.id);
h.update(&self.model.artifact_hash);
hash_str(&mut h, &self.source.sensor_id);
h.update(&[
self.source.authenticated.into(),
self.source.replay_protected.into(),
]);
h.update(&[
self.trust_state as u8,
self.dimensionality as u8,
self.uncertainty_calibrated.into(),
]);
for joint in &self.joints {
h.update(&[joint.kind as u8, joint.visibility as u8]);
for value in joint.position_m {
h.update(&value.to_bits().to_le_bytes());
}
for value in [
joint.covariance_m2.xx,
joint.covariance_m2.xy,
joint.covariance_m2.xz,
joint.covariance_m2.yy,
joint.covariance_m2.yz,
joint.covariance_m2.zz,
] {
h.update(&value.to_bits().to_le_bytes());
}
h.update(&joint.confidence.get().to_bits().to_le_bytes());
}
h.update(&self.observer_confidence.get().to_bits().to_le_bytes());
*h.finalize().as_bytes()
}
/// Set `canonical_hash` to the deterministic content hash.
pub fn seal(&mut self) {
self.canonical_hash = self.compute_canonical_hash();
}
}
fn hash_str(hasher: &mut blake3::Hasher, value: &str) {
let bytes = value.as_bytes();
hasher.update(&(bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probability_rejects_non_finite_and_out_of_range() {
assert!(Probability::new(f32::NAN).is_err());
assert!(Probability::new(-0.1).is_err());
assert!(Probability::new(1.1).is_err());
assert!((Probability::new(0.5).unwrap().get() - 0.5).abs() < f32::EPSILON);
}
#[test]
fn covariance_rejects_negative_principal_minor() {
let covariance = SymmetricCovariance3 {
xx: 0.1,
xy: 1.0,
xz: 0.0,
yy: 0.1,
yz: 0.0,
zz: 0.1,
};
assert!(!covariance.is_positive_semidefinite());
}
}
@@ -0,0 +1,317 @@
//! Additive physics assessment and correction contract (ADR-323).
#![allow(missing_docs)]
#[cfg(not(feature = "std"))]
use alloc::string::String;
#[cfg(feature = "std")]
use std::string::String;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::Probability;
/// Runtime rollout mode.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum PhysicsMode {
Off,
Audit,
ShadowCorrect,
OptInCorrect,
DefaultCorrect,
}
impl PhysicsMode {
/// Whether a validated candidate may be selected for downstream use.
#[must_use]
pub const fn selects_correction(self) -> bool {
matches!(self, Self::OptInCorrect | Self::DefaultCorrect)
}
}
/// Outcome of processing exactly one accepted frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum RefinementDisposition {
Bypassed,
Audited2d,
Audited,
Shadowed,
Corrected,
Abstained,
Rejected,
}
/// Typed fail-to-raw reason.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum AbstentionReason {
ModeOff,
UnsupportedSchema,
HashMismatch,
InvalidNumber,
InvalidCovariance,
StaleInput,
CoordinateFrameMismatch,
CalibrationUnavailable,
UncertaintyUncalibrated,
OodUnknown,
SourceUnauthenticated,
ReplayProtectionUnavailable,
CorrectionNotAuthorized,
ReplayRejected,
NonMonotonicInput,
TooFewKnownJoints,
CorrectionTooLarge,
DeadlineExceeded,
TrackCapacity,
InternalError,
}
/// Constraint residuals. Values are metric or normalized as named.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ConstraintResiduals {
pub bone_m: f32,
pub joint_limit_rad: f32,
pub velocity_mps: f32,
pub acceleration_mps2: f32,
pub temporal_jerk: f32,
pub floor_penetration_m: f32,
pub contact_m: f32,
pub collision_m: f32,
pub normalized_total: f32,
}
/// Summary of how much the candidate differs from the observation.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct InterventionSummary {
pub max_joint_correction_m: f32,
pub root_correction_m: f32,
pub corrected_joint_count: u8,
pub solver_iterations: u8,
pub elapsed_us: u64,
}
/// Optional articulated-body audit scalars.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct DynamicsResiduals {
pub stable: bool,
pub segment_count: u8,
pub joint_count: u8,
pub contact_count: u16,
pub substeps: u8,
pub tracking_error_m: f32,
pub joint_anchor_error_m: f32,
pub floor_penetration_m: f32,
pub control_effort: f32,
}
/// Contact state is never promoted to measured without sensor provenance.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum ContactState {
Hypothesis,
Measured,
Unknown,
}
/// One foot-contact assessment.
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ContactHypothesis {
pub state: ContactState,
pub probability: Probability,
}
impl Default for ContactHypothesis {
fn default() -> Self {
Self {
state: ContactState::Unknown,
probability: Probability::ZERO,
}
}
}
/// Version and artifact lineage for an assessment.
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PhysicsProvenance {
pub engine: String,
pub engine_version: String,
pub config_hash: [u8; 32],
pub rf_model_hash: [u8; 32],
pub calibration_id: String,
pub learned_artifact_hash: Option<[u8; 32]>,
}
/// Physics result returned for every accepted input frame.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PoseRefinementV1 {
pub schema_version: u16,
pub raw_observation_hash: [u8; 32],
pub mode: PhysicsMode,
pub disposition: RefinementDisposition,
pub selected: bool,
pub refined_joints_m: Option<[[f32; 3]; 17]>,
pub physics_confidence: Probability,
pub effective_confidence: Probability,
pub intervention: InterventionSummary,
/// Residuals of the immutable observer output.
pub residuals: ConstraintResiduals,
/// Residuals of the bounded candidate, when a candidate was computed.
pub refined_residuals: Option<ConstraintResiduals>,
/// Present only when the separately gated Rapier auditor ran.
pub dynamics: Option<DynamicsResiduals>,
pub contact_hypotheses: [ContactHypothesis; 2],
pub provenance: PhysicsProvenance,
pub reason: Option<AbstentionReason>,
/// Canonical BLAKE3 hash of every preceding result field.
pub canonical_hash: [u8; 32],
}
impl PoseRefinementV1 {
/// Compute a deterministic content hash excluding `canonical_hash` itself.
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn compute_canonical_hash(&self) -> [u8; 32] {
let mut h = blake3::Hasher::new();
h.update(b"ruview.pose-refinement-v1\0");
h.update(&self.schema_version.to_le_bytes());
h.update(&self.raw_observation_hash);
h.update(&[
self.mode as u8,
self.disposition as u8,
self.selected.into(),
]);
match self.refined_joints_m {
Some(joints) => {
h.update(&[1]);
for joint in joints {
hash_f32s(&mut h, &joint);
}
}
None => {
h.update(&[0]);
}
}
hash_f32s(
&mut h,
&[
self.physics_confidence.get(),
self.effective_confidence.get(),
],
);
hash_f32s(
&mut h,
&[
self.intervention.max_joint_correction_m,
self.intervention.root_correction_m,
],
);
h.update(&[
self.intervention.corrected_joint_count,
self.intervention.solver_iterations,
]);
h.update(&self.intervention.elapsed_us.to_le_bytes());
hash_residuals(&mut h, self.residuals);
match self.refined_residuals {
Some(residuals) => {
h.update(&[1]);
hash_residuals(&mut h, residuals);
}
None => {
h.update(&[0]);
}
}
match self.dynamics {
Some(dynamics) => {
h.update(&[1]);
h.update(&[
dynamics.stable.into(),
dynamics.segment_count,
dynamics.joint_count,
dynamics.substeps,
]);
h.update(&dynamics.contact_count.to_le_bytes());
hash_f32s(
&mut h,
&[
dynamics.tracking_error_m,
dynamics.joint_anchor_error_m,
dynamics.floor_penetration_m,
dynamics.control_effort,
],
);
}
None => {
h.update(&[0]);
}
}
for contact in self.contact_hypotheses {
h.update(&[contact.state as u8]);
hash_f32s(&mut h, &[contact.probability.get()]);
}
hash_str(&mut h, &self.provenance.engine);
hash_str(&mut h, &self.provenance.engine_version);
h.update(&self.provenance.config_hash);
h.update(&self.provenance.rf_model_hash);
hash_str(&mut h, &self.provenance.calibration_id);
match self.provenance.learned_artifact_hash {
Some(hash) => {
h.update(&[1]);
h.update(&hash);
}
None => {
h.update(&[0]);
}
}
match self.reason {
Some(reason) => h.update(&[1, reason as u8]),
None => h.update(&[0]),
};
*h.finalize().as_bytes()
}
/// Seal the result after all fields have been populated.
pub fn seal(&mut self) {
self.canonical_hash = self.compute_canonical_hash();
}
}
fn hash_residuals(hasher: &mut blake3::Hasher, residuals: ConstraintResiduals) {
hash_f32s(
hasher,
&[
residuals.bone_m,
residuals.joint_limit_rad,
residuals.velocity_mps,
residuals.acceleration_mps2,
residuals.temporal_jerk,
residuals.floor_penetration_m,
residuals.contact_m,
residuals.collision_m,
residuals.normalized_total,
],
);
}
fn hash_f32s(hasher: &mut blake3::Hasher, values: &[f32]) {
for value in values {
hasher.update(&value.to_bits().to_le_bytes());
}
}
fn hash_str(hasher: &mut blake3::Hasher, value: &str) {
hasher.update(&(value.len() as u64).to_le_bytes());
hasher.update(value.as_bytes());
}
@@ -2355,22 +2355,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
@@ -2391,16 +2391,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
@@ -355,6 +355,9 @@ pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
fn list_serial_ports_fallback() -> Result<Vec<SerialPortInfo>, String> {
tracing::info!("Using fallback serial port listing");
// The platform-specific blocks below mutate this collection on macOS and
// Linux; on Windows the empty result is the intended fallback.
#[allow(unused_mut)]
let mut result = Vec::new();
// List /dev/cu.usb* devices on macOS
@@ -6,8 +6,39 @@ use tauri::{AppHandle, Manager, State};
use crate::state::AppState;
const fn server_binary_name(is_windows: bool) -> &'static str {
if is_windows {
"sensing-server.exe"
} else {
"sensing-server"
}
}
const fn path_locator_command(is_windows: bool) -> &'static str {
if is_windows {
"where"
} else {
"which"
}
}
/// Default binary name for the sensing server.
const DEFAULT_SERVER_BIN: &str = "sensing-server";
const DEFAULT_SERVER_BIN: &str = server_binary_name(cfg!(windows));
const PATH_LOCATOR_COMMAND: &str = path_locator_command(cfg!(windows));
fn first_path_match(stdout: &[u8]) -> Option<String> {
String::from_utf8_lossy(stdout)
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(str::to_owned)
}
fn configure_log_level(cmd: &mut Command, log_level: Option<&str>) {
if let Some(log_level) = log_level {
cmd.env("RUST_LOG", log_level);
}
}
/// Find the sensing server binary path.
///
@@ -48,10 +79,12 @@ fn find_server_binary(app: &AppHandle, custom_path: Option<&str>) -> Result<Stri
}
// 4. Check if it's in PATH
if let Ok(output) = Command::new("which").arg(DEFAULT_SERVER_BIN).output() {
if let Ok(output) = Command::new(PATH_LOCATOR_COMMAND)
.arg(DEFAULT_SERVER_BIN)
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
if let Some(path) = first_path_match(&output.stdout) {
return Ok(path);
}
}
@@ -104,9 +137,7 @@ pub async fn start_server(
if let Some(ref bind_addr) = config.bind_address {
cmd.args(["--bind", bind_addr]);
}
if let Some(ref log_level) = config.log_level {
cmd.args(["--log-level", log_level]);
}
configure_log_level(&mut cmd, config.log_level.as_deref());
// Default to explicit "simulated" demo mode when the desktop user hasn't
// chosen a source — this is the *Tauri demo* app, not a production
@@ -397,6 +428,44 @@ pub struct ServerLogsResponse {
mod tests {
use super::*;
#[test]
fn should_pass_log_level_through_rust_log() {
let mut command = Command::new("sensing-server");
configure_log_level(&mut command, Some("debug"));
let args: Vec<_> = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
let rust_log = command
.get_envs()
.find(|(key, _)| key.to_string_lossy() == "RUST_LOG")
.and_then(|(_, value)| value)
.map(|value| value.to_string_lossy().into_owned());
assert!(!args.iter().any(|arg| arg == "--log-level"));
assert_eq!(rust_log.as_deref(), Some("debug"));
}
#[test]
fn should_use_platform_binary_names() {
assert_eq!(server_binary_name(true), "sensing-server.exe");
assert_eq!(path_locator_command(true), "where");
assert_eq!(server_binary_name(false), "sensing-server");
assert_eq!(path_locator_command(false), "which");
}
#[test]
fn should_use_first_path_match_when_locator_returns_multiple_results() {
let matches = b"C:\\first\\sensing-server.exe\r\nC:\\second\\sensing-server.exe\r\n";
assert_eq!(
first_path_match(matches).as_deref(),
Some("C:\\first\\sensing-server.exe")
);
assert_eq!(first_path_match(b"\r\n\r\n"), None);
}
#[test]
fn test_server_config_default() {
let config = ServerConfig {
@@ -6,8 +6,14 @@
"build": {
"frontendDist": "ui/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "cd ../ui && npm run dev",
"beforeBuildCommand": "cd ../ui && npm run build"
"beforeDevCommand": {
"cwd": "ui",
"script": "npm run dev"
},
"beforeBuildCommand": {
"cwd": "ui",
"script": "npm run build"
}
},
"app": {
"windows": [
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const uiDir = dirname(fileURLToPath(import.meta.url));
const packageJson = JSON.parse(readFileSync(join(uiDir, "package.json"), "utf8"));
const tauriConfig = JSON.parse(
readFileSync(join(uiDir, "..", "tauri.conf.json"), "utf8"),
);
function major(versionRange) {
const match = versionRange.match(/\d+/);
assert.ok(match, `Expected a semver range, got ${versionRange}`);
return Number(match[0]);
}
test("React runtime and type packages use the same major version", () => {
assert.equal(
major(packageJson.dependencies.react),
major(packageJson.dependencies["react-dom"]),
);
assert.equal(
major(packageJson.devDependencies["@types/react"]),
major(packageJson.devDependencies["@types/react-dom"]),
);
assert.equal(
major(packageJson.dependencies.react),
major(packageJson.devDependencies["@types/react"]),
);
});
test("Tauri frontend hooks run from the UI directory", () => {
assert.deepEqual(tauriConfig.build.beforeDevCommand, {
cwd: "ui",
script: "npm run dev",
});
assert.deepEqual(tauriConfig.build.beforeBuildCommand, {
cwd: "ui",
script: "npm run build",
});
});
test("Vite client types cover CSS side-effect imports", () => {
const declaration = readFileSync(join(uiDir, "src", "vite-env.d.ts"), "utf8");
assert.match(declaration, /reference types=["']vite\/client["']/);
});
+139 -158
View File
@@ -11,25 +11,25 @@
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-shell": "^2.3.5",
"react": "^18.3.1",
"react-dom": "^19.2.5"
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^19.2.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^6.0.3",
"vite": "^6.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -38,9 +38,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -48,21 +48,21 @@
}
},
"node_modules/@babel/core": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -79,14 +79,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
"integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.8",
"@babel/types": "^7.29.8",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -96,14 +96,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -113,9 +113,9 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -123,29 +123,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.28.6"
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -165,9 +165,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -175,9 +175,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -185,9 +185,9 @@
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -195,27 +195,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -257,33 +257,33 @@
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
"integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.8",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.8",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.8",
"debug": "^4.3.1"
},
"engines": {
@@ -291,14 +291,14 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1233,28 +1233,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -1283,9 +1275,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"version": "2.11.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1296,9 +1288,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"dev": true,
"funding": [
{
@@ -1316,11 +1308,11 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
"electron-to-chromium": "^1.5.402",
"node-releases": "^2.0.53",
"update-browserslist-db": "^1.3.0"
},
"bin": {
"browserslist": "cli.js"
@@ -1330,9 +1322,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001777",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
"integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
"version": "1.0.30001809",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
"integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
"dev": true,
"funding": [
{
@@ -1383,9 +1375,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.307",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
"integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
"version": "1.5.407",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
"integrity": "sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==",
"dev": true,
"license": "ISC"
},
@@ -1488,6 +1480,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsesc": {
@@ -1516,18 +1509,6 @@
"node": ">=6"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -1546,9 +1527,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@@ -1565,11 +1546,14 @@
}
},
"node_modules/node-releases": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
"version": "2.0.53",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
"integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
@@ -1579,9 +1563,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1592,9 +1576,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@@ -1612,7 +1596,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1621,27 +1605,24 @@
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.5"
"react": "^19.2.8"
}
},
"node_modules/react-refresh": {
@@ -1757,9 +1738,9 @@
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
"integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
"dev": true,
"funding": [
{
@@ -1788,9 +1769,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6,18 +6,19 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "node --test build-config.test.mjs",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-shell": "^2.3.5",
"react": "^18.3.1",
"react-dom": "^19.2.5"
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^19.2.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^6.0.3",
"vite": "^6.0.0"
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,58 @@
[package]
name = "wifi-densepose-physics"
version = "0.1.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Bounded, provenance-preserving pose physics assessment for RuView"
[features]
default = ["kinematic"]
kinematic = []
dynamics = ["dep:rapier3d"]
learned = ["dep:burn-core", "dep:burn-nn"]
learned-cpu = ["learned", "dep:burn-ndarray"]
learned-train = ["learned", "dep:burn-train"]
learned-wgpu = ["learned-train", "dep:burn-wgpu"]
learned-cuda = ["learned-train", "dep:burn-cuda"]
deterministic = ["rapier3d?/enhanced-determinism"]
[dependencies]
wifi-densepose-core = { workspace = true, features = ["serde"] }
nalgebra.workspace = true
serde.workspace = true
thiserror.workspace = true
smallvec.workspace = true
bitflags.workspace = true
tracing.workspace = true
blake3 = "1.5"
rapier3d = { workspace = true, optional = true }
burn-core = { workspace = true, optional = true }
burn-nn = { workspace = true, optional = true }
burn-ndarray = { workspace = true, optional = true }
burn-train = { workspace = true, optional = true }
burn-wgpu = { workspace = true, optional = true }
burn-cuda = { workspace = true, optional = true }
[dev-dependencies]
serde_json.workspace = true
proptest.workspace = true
criterion.workspace = true
[[bench]]
name = "frame_latency"
harness = false
[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
[lints.clippy]
all = "warn"
pedantic = "warn"
missing_errors_doc = "allow"
module_name_repetitions = "allow"
cast_precision_loss = "allow"
cast_possible_truncation = "allow"
cast_sign_loss = "allow"
@@ -0,0 +1,84 @@
#![allow(missing_docs)]
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use wifi_densepose_core::{
CalibrationId, Coco17Joint, FloorPlane, JointObservation, JointVisibility, ModelRef,
PhysicsMode, PoseDimensionality, PoseObservationV2, PoseTrustState, Probability,
SourceProvenance, SpatialFrameRef, SymmetricCovariance3, TrackId,
};
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
fn observation(sequence: u64) -> PoseObservationV2 {
let joints = Coco17Joint::ALL.map(|kind| JointObservation {
kind,
position_m: [f32::from(kind as u8) * 0.01, 0.0, 1.0],
covariance_m2: SymmetricCovariance3 {
xx: 0.001,
xy: 0.0,
xz: 0.0,
yy: 0.001,
yz: 0.0,
zz: 0.001,
},
confidence: Probability::new(0.8).unwrap(),
visibility: JointVisibility::Visible,
});
let mut raw = PoseObservationV2 {
schema_version: 2,
timestamp_ns: 1_000_000_000 + sequence * 33_000_000,
sensor_epoch: 1,
sequence,
track_id: TrackId("bench".into()),
frame: SpatialFrameRef {
name: "room".into(),
version: 1,
metric: true,
right_handed: true,
z_up: true,
},
calibration_id: CalibrationId("cal".into()),
floor_plane: Some(FloorPlane {
normal: [0.0, 0.0, 1.0],
offset_m: 0.0,
}),
model: ModelRef {
id: "bench".into(),
artifact_hash: [1; 32],
},
source: SourceProvenance {
sensor_id: "bench".into(),
authenticated: true,
replay_protected: true,
},
trust_state: PoseTrustState::Known,
dimensionality: PoseDimensionality::Metric3d,
uncertainty_calibrated: true,
joints,
observer_confidence: Probability::new(0.8).unwrap(),
canonical_hash: [0; 32],
};
raw.seal();
raw
}
fn bench_frame(c: &mut Criterion) {
c.bench_function("kinematic_one_track", |b| {
b.iter_batched(
|| {
PhysicsEngine::new(PhysicsConfig {
mode: PhysicsMode::ShadowCorrect,
..PhysicsConfig::default()
})
.unwrap()
},
|mut engine| {
let raw = observation(1);
black_box(engine.process(&raw, raw.timestamp_ns));
},
criterion::BatchSize::SmallInput,
);
});
}
criterion_group!(benches, bench_frame);
criterion_main!(benches);
@@ -0,0 +1,130 @@
//! Reproducible release-mode latency probe. Results apply only to this host.
use std::{fmt::Write as _, hint::black_box, time::Instant};
use wifi_densepose_core::{
CalibrationId, Coco17Joint, FloorPlane, JointObservation, JointVisibility, ModelRef,
PhysicsMode, PoseDimensionality, PoseObservationV2, PoseTrustState, Probability,
SourceProvenance, SpatialFrameRef, SymmetricCovariance3, TrackId,
};
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let samples = std::env::args()
.nth(1)
.map_or(Ok(20_000usize), |value| value.parse())?;
if samples < 100 {
return Err("sample count must be at least 100".into());
}
let mut engine = PhysicsEngine::new(PhysicsConfig {
mode: PhysicsMode::ShadowCorrect,
..PhysicsConfig::default()
})?;
let config_hash = encode_hex(engine.config_hash());
for sequence in 1..=1_000 {
let raw = observation(sequence);
black_box(engine.process(&raw, raw.timestamp_ns));
}
let mut nanoseconds = Vec::with_capacity(samples);
for offset in 0..samples {
let sequence = 1_001 + offset as u64;
let raw = observation(sequence);
let started = Instant::now();
let result = engine.process(&raw, raw.timestamp_ns);
nanoseconds.push(started.elapsed().as_nanos() as u64);
assert!(result.effective_confidence.get() <= raw.observer_confidence.get());
black_box(result);
}
nanoseconds.sort_unstable();
let percentile =
|numerator: usize| nanoseconds[((samples - 1) * numerator) / 100] as f64 / 1_000_000.0;
println!(
"{{\"evidence\":\"MEASURED\",\"scope\":\"local-host-only\",\"engine\":\"kinematic-pbd\",\"mode\":\"shadow_correct\",\"config_hash\":\"{config_hash}\",\"samples\":{samples},\"p50_ms\":{:.6},\"p95_ms\":{:.6},\"p99_ms\":{:.6},\"max_ms\":{:.6}}}",
percentile(50),
percentile(95),
percentile(99),
nanoseconds[samples - 1] as f64 / 1_000_000.0,
);
Ok(())
}
fn encode_hex(bytes: [u8; 32]) -> String {
let mut encoded = String::with_capacity(64);
for byte in bytes {
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
}
encoded
}
fn observation(sequence: u64) -> PoseObservationV2 {
let positions = [
[0.0, 0.0, 1.70],
[-0.03, 0.0, 1.73],
[0.03, 0.0, 1.73],
[-0.08, 0.0, 1.71],
[0.08, 0.0, 1.71],
[-0.20, 0.0, 1.45],
[0.20, 0.0, 1.45],
[-0.35, 0.0, 1.15],
[0.35, 0.0, 1.15],
[-0.45, 0.0, 0.90],
[0.45, 0.0, 0.90],
[-0.14, 0.0, 0.90],
[0.14, 0.0, 0.90],
[-0.14, 0.0, 0.48],
[0.14, 0.0, 0.48],
[-0.14, 0.0, 0.04],
[0.14, 0.0, 0.04],
];
let mut joints = core::array::from_fn(|index| JointObservation {
kind: Coco17Joint::ALL[index],
position_m: positions[index],
covariance_m2: SymmetricCovariance3 {
xx: 0.001,
xy: 0.0,
xz: 0.0,
yy: 0.001,
yz: 0.0,
zz: 0.001,
},
confidence: Probability::new(0.8).expect("fixture confidence is valid"),
visibility: JointVisibility::Visible,
});
joints[15].position_m[2] -= (sequence % 7) as f32 * 0.001;
let mut raw = PoseObservationV2 {
schema_version: 2,
timestamp_ns: 1_000_000_000 + sequence * 33_000_000,
sensor_epoch: 1,
sequence,
track_id: TrackId("latency:1".into()),
frame: SpatialFrameRef {
name: "room:latency".into(),
version: 1,
metric: true,
right_handed: true,
z_up: true,
},
calibration_id: CalibrationId("cal:latency".into()),
floor_plane: Some(FloorPlane {
normal: [0.0, 0.0, 1.0],
offset_m: 0.0,
}),
model: ModelRef {
id: "pose:latency".into(),
artifact_hash: [1; 32],
},
source: SourceProvenance {
sensor_id: "sensor:latency".into(),
authenticated: false,
replay_protected: false,
},
trust_state: PoseTrustState::Known,
dimensionality: PoseDimensionality::Metric3d,
uncertainty_calibrated: true,
joints,
observer_confidence: Probability::new(0.78).expect("fixture confidence is valid"),
canonical_hash: [0; 32],
};
raw.seal();
raw
}
@@ -0,0 +1,154 @@
//! Deterministic L0 replay verifier for ADR-323 golden hashes.
use std::{fmt::Write as _, path::Path};
use wifi_densepose_core::{
CalibrationId, Coco17Joint, FloorPlane, JointObservation, JointVisibility, ModelRef,
PoseDimensionality, PoseObservationV2, PoseTrustState, Probability, SourceProvenance,
SpatialFrameRef, SymmetricCovariance3, TrackId,
};
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut arguments = std::env::args().skip(1);
let path = arguments
.next()
.ok_or("usage: verify_golden <golden.jsonl> [--emit|--contracts]")?;
let mode = arguments.next();
let emit = mode.as_deref() == Some("--emit");
let contracts = mode.as_deref() == Some("--contracts");
if mode.is_some() && !emit && !contracts {
return Err("mode must be --emit or --contracts".into());
}
let contents = std::fs::read_to_string(Path::new(&path))?;
let mut count = 0usize;
for (line_index, line) in contents
.lines()
.filter(|line| !line.trim().is_empty())
.enumerate()
{
let mut value: serde_json::Value = serde_json::from_str(line)?;
let sequence = value["sequence"].as_u64().ok_or("sequence must be u64")?;
let penetration = value["ankle_penetration_m"]
.as_f64()
.ok_or("ankle_penetration_m must be numeric")? as f32;
if value["evidence"] != "SYNTHETIC/L0" {
return Err(format!("line {}: evidence must be SYNTHETIC/L0", line_index + 1).into());
}
let mut raw = observation(sequence);
raw.joints[15].position_m[2] -= penetration;
raw.seal();
let result = PhysicsEngine::new(PhysicsConfig::default())?.process(&raw, raw.timestamp_ns);
let raw_hash = encode_hex(raw.canonical_hash);
let result_hash = encode_hex(result.canonical_hash);
if emit {
value["expected_raw_hash"] = raw_hash.clone().into();
value["expected_result_hash"] = result_hash.clone().into();
println!("{}", serde_json::to_string(&value)?);
} else {
if value["expected_raw_hash"] != raw_hash
|| value["expected_result_hash"] != result_hash
{
return Err(format!(
"line {}: hash mismatch (raw={raw_hash}, result={result_hash})",
line_index + 1
)
.into());
}
if contracts {
println!(
"{}",
serde_json::to_string(&serde_json::json!({ "raw": raw, "result": result }))?
);
}
}
count += 1;
}
if count == 0 {
return Err("golden replay is empty".into());
}
if !emit && !contracts {
println!("{{\"verdict\":\"PASS\",\"records\":{count},\"engine\":\"kinematic-pbd\",\"evidence\":\"SYNTHETIC/L0\"}}");
}
Ok(())
}
fn observation(sequence: u64) -> PoseObservationV2 {
let positions = [
[0.0, 0.0, 1.70],
[-0.03, 0.0, 1.73],
[0.03, 0.0, 1.73],
[-0.08, 0.0, 1.71],
[0.08, 0.0, 1.71],
[-0.20, 0.0, 1.45],
[0.20, 0.0, 1.45],
[-0.35, 0.0, 1.15],
[0.35, 0.0, 1.15],
[-0.45, 0.0, 0.90],
[0.45, 0.0, 0.90],
[-0.14, 0.0, 0.90],
[0.14, 0.0, 0.90],
[-0.14, 0.0, 0.48],
[0.14, 0.0, 0.48],
[-0.14, 0.0, 0.04],
[0.14, 0.0, 0.04],
];
let joints = core::array::from_fn(|index| JointObservation {
kind: Coco17Joint::ALL[index],
position_m: positions[index],
covariance_m2: SymmetricCovariance3 {
xx: 0.001,
xy: 0.0,
xz: 0.0,
yy: 0.001,
yz: 0.0,
zz: 0.001,
},
confidence: Probability::new(0.8).expect("fixture confidence is valid"),
visibility: JointVisibility::Visible,
});
let mut raw = PoseObservationV2 {
schema_version: 2,
timestamp_ns: 1_000_000_000 + sequence * 33_000_000,
sensor_epoch: 7,
sequence,
track_id: TrackId("golden:1".into()),
frame: SpatialFrameRef {
name: "room:golden".into(),
version: 1,
metric: true,
right_handed: true,
z_up: true,
},
calibration_id: CalibrationId("cal:golden".into()),
floor_plane: Some(FloorPlane {
normal: [0.0, 0.0, 1.0],
offset_m: 0.0,
}),
model: ModelRef {
id: "pose:golden".into(),
artifact_hash: [3; 32],
},
source: SourceProvenance {
sensor_id: "sensor:golden".into(),
authenticated: false,
replay_protected: false,
},
trust_state: PoseTrustState::Known,
dimensionality: PoseDimensionality::Metric3d,
uncertainty_calibrated: true,
joints,
observer_confidence: Probability::new(0.78).expect("fixture confidence is valid"),
canonical_hash: [0; 32],
};
raw.seal();
raw
}
fn encode_hex(bytes: [u8; 32]) -> String {
let mut encoded = String::with_capacity(64);
for byte in bytes {
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
}
encoded
}
@@ -0,0 +1 @@
/target/
+884
View File
@@ -0,0 +1,884 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "blake3"
version = "1.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
"cpufeatures",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "cc"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "glam"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da"
[[package]]
name = "glam"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b"
[[package]]
name = "glam"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16"
[[package]]
name = "glam"
version = "0.17.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776"
[[package]]
name = "glam"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34"
[[package]]
name = "glam"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca"
[[package]]
name = "glam"
version = "0.20.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f"
[[package]]
name = "glam"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815"
[[package]]
name = "glam"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774"
[[package]]
name = "glam"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c"
[[package]]
name = "glam"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945"
[[package]]
name = "glam"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
[[package]]
name = "glam"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9"
[[package]]
name = "glam"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94"
[[package]]
name = "glam"
version = "0.29.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee"
[[package]]
name = "glam"
version = "0.30.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
[[package]]
name = "glam"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7"
[[package]]
name = "glam"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfuzzer-sys"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [
"arbitrary",
"cc",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "matrixmultiply"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nalgebra"
version = "0.34.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df76ea0ff5c7e6b88689085804d6132ded0ddb9de5ca5b8aeb9eeadc0508a70a"
dependencies = [
"approx",
"glam 0.14.0",
"glam 0.15.2",
"glam 0.16.0",
"glam 0.17.3",
"glam 0.18.0",
"glam 0.19.0",
"glam 0.20.5",
"glam 0.21.3",
"glam 0.22.0",
"glam 0.23.0",
"glam 0.24.2",
"glam 0.25.0",
"glam 0.27.0",
"glam 0.28.0",
"glam 0.29.3",
"glam 0.30.10",
"glam 0.31.1",
"glam 0.32.1",
"matrixmultiply",
"nalgebra-macros",
"num-complex",
"num-rational",
"num-traits",
"simba",
"typenum",
]
[[package]]
name = "nalgebra-macros"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "ndarray"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
"serde",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "safe_arch"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
dependencies = [
"bytemuck",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simba"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95"
dependencies = [
"approx",
"num-complex",
"num-traits",
"paste",
"wide",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "uuid"
version = "1.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
dependencies = [
"getrandom",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wide"
version = "0.7.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
dependencies = [
"bytemuck",
"safe_arch",
]
[[package]]
name = "wifi-densepose-core"
version = "0.3.2"
dependencies = [
"blake3",
"chrono",
"ndarray",
"num-complex",
"num-traits",
"serde",
"thiserror",
"uuid",
]
[[package]]
name = "wifi-densepose-physics"
version = "0.1.0"
dependencies = [
"bitflags",
"blake3",
"nalgebra",
"serde",
"smallvec",
"thiserror",
"tracing",
"wifi-densepose-core",
]
[[package]]
name = "wifi-densepose-physics-fuzz"
version = "0.0.0"
dependencies = [
"libfuzzer-sys",
"serde_json",
"wifi-densepose-core",
"wifi-densepose-physics",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,24 @@
[package]
name = "wifi-densepose-physics-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
serde_json = "1"
wifi-densepose-core = { path = "../../wifi-densepose-core", features = ["serde"] }
wifi-densepose-physics = { path = ".." }
[workspace]
members = ["."]
[[bin]]
name = "pose_frame"
path = "fuzz_targets/pose_frame.rs"
test = false
doc = false
bench = false
@@ -0,0 +1,15 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
fuzz_target!(|bytes: &[u8]| {
if bytes.len() > 64 * 1024 {
return;
}
if let Ok(raw) = serde_json::from_slice::<wifi_densepose_core::PoseObservationV2>(bytes) {
if let Ok(mut engine) = PhysicsEngine::new(PhysicsConfig::default()) {
let _ = engine.process(&raw, raw.timestamp_ns);
}
}
});
@@ -0,0 +1,164 @@
//! Bounded runtime configuration.
use wifi_densepose_core::PhysicsMode;
/// Runtime limits. Untrusted frames cannot modify these values.
#[derive(Clone, Debug)]
pub struct PhysicsConfig {
pub mode: PhysicsMode,
pub max_tracks: usize,
pub deadline_ms: u64,
pub max_iterations: u8,
pub max_joint_correction_m: f32,
pub max_root_correction_m: f32,
pub max_frame_gap_ms: u64,
pub track_reset_gap_ms: u64,
pub stale_after_ms: u64,
pub minimum_joint_confidence: f32,
pub minimum_known_joints: usize,
pub calibration_joint_confidence: f32,
pub solver_epsilon_m: f32,
pub max_velocity_mps: f32,
pub max_acceleration_mps2: f32,
pub room_bound_m: f32,
pub image_coordinate_bound: f32,
pub beta_residual: f32,
pub beta_intervention: f32,
pub dynamics_audit: bool,
pub dynamics_substeps: u8,
pub dynamics_dt_seconds: f32,
}
impl Default for PhysicsConfig {
fn default() -> Self {
Self {
mode: PhysicsMode::Audit,
max_tracks: 4,
deadline_ms: 5,
max_iterations: 4,
max_joint_correction_m: 0.20,
max_root_correction_m: 0.10,
max_frame_gap_ms: 250,
track_reset_gap_ms: 500,
stale_after_ms: 500,
minimum_joint_confidence: 0.10,
minimum_known_joints: 10,
calibration_joint_confidence: 0.70,
solver_epsilon_m: 0.001,
max_velocity_mps: 8.0,
max_acceleration_mps2: 40.0,
room_bound_m: 100.0,
image_coordinate_bound: 16_384.0,
beta_residual: 1.0,
beta_intervention: 2.0,
dynamics_audit: false,
dynamics_substeps: 2,
dynamics_dt_seconds: 1.0 / 30.0,
}
}
}
impl PhysicsConfig {
/// Validate operator-controlled limits before activation.
pub fn validate(&self) -> Result<(), &'static str> {
if self.max_tracks == 0 || self.max_tracks > 256 {
return Err("max_tracks must be in 1..=256");
}
if self.max_iterations == 0 || self.max_iterations > 8 {
return Err("max_iterations must be in 1..=8");
}
if self.deadline_ms == 0 || self.deadline_ms > 1000 {
return Err("deadline_ms must be in 1..=1000");
}
let finite_positive = [
self.max_joint_correction_m,
self.max_root_correction_m,
self.solver_epsilon_m,
self.max_velocity_mps,
self.max_acceleration_mps2,
self.room_bound_m,
self.image_coordinate_bound,
self.beta_residual,
self.beta_intervention,
self.dynamics_dt_seconds,
];
if finite_positive.iter().any(|v| !v.is_finite() || *v <= 0.0) {
return Err("numeric limits must be finite and positive");
}
if self.image_coordinate_bound > 1_000_000.0 {
return Err("image coordinate bound is excessive");
}
if !(0.0..=1.0).contains(&self.minimum_joint_confidence)
|| !(0.0..=1.0).contains(&self.calibration_joint_confidence)
{
return Err("confidence limits must be in [0, 1]");
}
if !(1..=17).contains(&self.minimum_known_joints) {
return Err("minimum_known_joints must be in 1..=17");
}
if self.max_frame_gap_ms == 0
|| self.track_reset_gap_ms < self.max_frame_gap_ms
|| self.stale_after_ms < self.max_frame_gap_ms
{
return Err("frame/reset/stale gaps must be ordered and non-zero");
}
if self.max_root_correction_m > self.max_joint_correction_m
|| self.solver_epsilon_m >= self.max_joint_correction_m
{
return Err("correction limits are inconsistent");
}
if self.calibration_joint_confidence < self.minimum_joint_confidence {
return Err("calibration confidence must meet the joint-use threshold");
}
if self.dynamics_audit && !cfg!(feature = "dynamics") {
return Err("dynamics_audit requires the dynamics Cargo feature");
}
if !(1..=8).contains(&self.dynamics_substeps)
|| !self.dynamics_dt_seconds.is_finite()
|| !(1.0 / 240.0..=0.05).contains(&self.dynamics_dt_seconds)
{
return Err("dynamics limits are invalid");
}
Ok(())
}
/// Stable hash used in idempotency and provenance.
#[must_use]
pub fn canonical_hash(&self) -> [u8; 32] {
let mut h = blake3::Hasher::new();
h.update(b"ruview.pose-physics-config-v1\0");
h.update(&[
self.mode as u8,
self.max_iterations,
self.dynamics_audit.into(),
self.dynamics_substeps,
]);
for value in [
self.max_tracks as u64,
self.deadline_ms,
self.max_frame_gap_ms,
self.track_reset_gap_ms,
self.stale_after_ms,
self.minimum_known_joints as u64,
] {
h.update(&value.to_le_bytes());
}
for value in [
self.max_joint_correction_m,
self.max_root_correction_m,
self.minimum_joint_confidence,
self.calibration_joint_confidence,
self.solver_epsilon_m,
self.max_velocity_mps,
self.max_acceleration_mps2,
self.room_bound_m,
self.image_coordinate_bound,
self.beta_residual,
self.beta_intervention,
self.dynamics_dt_seconds,
] {
h.update(&value.to_bits().to_le_bytes());
}
*h.finalize().as_bytes()
}
}
@@ -0,0 +1,26 @@
//! Bone-length consistency.
use crate::skeleton::{
distance, virtual_joints, SkeletonPosterior, CONSTRAINED_EDGE_COUNT, OBSERVED_EDGES,
VIRTUAL_NECK_EDGE, VIRTUAL_TRUNK_EDGE,
};
/// Mean absolute edge-length residual.
#[must_use]
pub fn bone_residual(joints: &[[f32; 3]; 17], posterior: &SkeletonPosterior) -> f32 {
let observed_total = OBSERVED_EDGES
.iter()
.enumerate()
.map(|(index, (a, b))| {
let current = distance(joints[*a], joints[*b]);
(current - posterior.target(index, current)).abs()
})
.sum::<f32>();
let (pelvis, thorax) = virtual_joints(joints);
let trunk = distance(pelvis, thorax);
let neck = distance(thorax, joints[0]);
let total = observed_total
+ (trunk - posterior.target(VIRTUAL_TRUNK_EDGE, trunk)).abs()
+ (neck - posterior.target(VIRTUAL_NECK_EDGE, neck)).abs();
total / CONSTRAINED_EDGE_COUNT as f32
}
@@ -0,0 +1,32 @@
//! Foot-contact hypotheses derived from floor proximity and tangential motion.
use nalgebra::Vector3;
use wifi_densepose_core::FloorPlane;
use super::TemporalHistory;
/// Maximum tangential foot displacement while an ankle is close enough to the floor
/// to be a contact hypothesis. This is never labeled as measured contact.
#[must_use]
pub fn contact_slide_residual(
joints: &[[f32; 3]; 17],
floor: FloorPlane,
history: &TemporalHistory,
timestamp_ns: u64,
) -> f32 {
let Some((previous, previous_ns)) = &history.previous else {
return 0.0;
};
if timestamp_ns <= *previous_ns {
return 0.0;
}
let normal = Vector3::from(floor.normal);
[15usize, 16usize]
.into_iter()
.filter(|&index| floor.signed_distance(joints[index]).abs() <= 0.05)
.map(|index| {
let displacement = Vector3::from(joints[index]) - Vector3::from(previous[index]);
(displacement - normal * displacement.dot(&normal)).norm()
})
.fold(0.0, f32::max)
}
@@ -0,0 +1,25 @@
//! Calibrated floor-plane constraint. It prevents penetration only.
use wifi_densepose_core::FloorPlane;
/// Maximum penetration among observed joints.
#[must_use]
pub fn floor_penetration(joints: &[[f32; 3]; 17], floor: FloorPlane) -> f32 {
joints
.iter()
.map(|point| (-floor.signed_distance(*point)).max(0.0))
.fold(0.0, f32::max)
}
/// Move penetrating joints to the plane. No upright or contact prior is used.
pub fn project_floor(joints: &mut [[f32; 3]; 17], floor: FloorPlane, _weights: &[f32; 17]) {
for joint in joints.iter_mut() {
let penetration = (-floor.signed_distance(*joint)).max(0.0);
if penetration <= 0.0 {
continue;
}
for (coordinate, normal) in joint.iter_mut().zip(floor.normal) {
*coordinate += normal * penetration;
}
}
}
@@ -0,0 +1,76 @@
//! Broad hinge-angle constraints that reject only anatomically impossible poses.
use nalgebra::Vector3;
/// `(parent, hinge, distal, minimum angle, maximum angle)`.
const HINGES: [(usize, usize, usize, f32, f32); 4] = [
(5, 7, 9, 0.087_266_46, 3.124_139_3),
(6, 8, 10, 0.087_266_46, 3.124_139_3),
(11, 13, 15, 0.087_266_46, 3.124_139_3),
(12, 14, 16, 0.087_266_46, 3.124_139_3),
];
/// Maximum angular violation in radians.
#[must_use]
pub fn joint_limit_residual(joints: &[[f32; 3]; 17]) -> f32 {
HINGES
.iter()
.map(|&(parent, hinge, distal, minimum, maximum)| {
let angle = angle_at(joints[parent], joints[hinge], joints[distal]);
if angle < minimum {
minimum - angle
} else if angle > maximum {
angle - maximum
} else {
0.0
}
})
.fold(0.0, f32::max)
}
/// Move only the distal point toward a legal hinge angle. The movement weight
/// makes uncertain joints converge faster while the outer correction cap
/// remains authoritative.
pub fn project_joint_limits(joints: &mut [[f32; 3]; 17], weights: &[f32; 17]) {
for &(parent, hinge, distal, minimum, maximum) in &HINGES {
let parent_direction = Vector3::from(joints[parent]) - Vector3::from(joints[hinge]);
let distal_vector = Vector3::from(joints[distal]) - Vector3::from(joints[hinge]);
let parent_length = parent_direction.norm();
let distal_length = distal_vector.norm();
if parent_length <= 1.0e-6 || distal_length <= 1.0e-6 {
continue;
}
let parent_unit = parent_direction / parent_length;
let distal_unit = distal_vector / distal_length;
let angle = parent_unit.dot(&distal_unit).clamp(-1.0, 1.0).acos();
let target = angle.clamp(minimum, maximum);
if (target - angle).abs() <= f32::EPSILON {
continue;
}
let mut perpendicular = distal_unit - parent_unit * parent_unit.dot(&distal_unit);
if perpendicular.norm_squared() <= 1.0e-8 {
let basis = if parent_unit.x.abs() < 0.8 {
Vector3::x()
} else {
Vector3::y()
};
perpendicular = parent_unit.cross(&basis);
}
let perpendicular = perpendicular.normalize();
let target_direction = parent_unit * target.cos() + perpendicular * target.sin();
let target_point = Vector3::from(joints[hinge]) + target_direction * distal_length;
let alpha = weights[distal].clamp(0.05, 1.0);
joints[distal] =
(Vector3::from(joints[distal]) * (1.0 - alpha) + target_point * alpha).into();
}
}
fn angle_at(parent: [f32; 3], hinge: [f32; 3], distal: [f32; 3]) -> f32 {
let a = Vector3::from(parent) - Vector3::from(hinge);
let b = Vector3::from(distal) - Vector3::from(hinge);
let denominator = a.norm() * b.norm();
if denominator <= 1.0e-8 {
return 0.0;
}
(a.dot(&b) / denominator).clamp(-1.0, 1.0).acos()
}
@@ -0,0 +1,58 @@
//! Deterministic kinematic residuals.
mod bone;
mod contact;
mod floor;
mod joint_limit;
mod temporal;
pub use bone::bone_residual;
pub use contact::contact_slide_residual;
pub use floor::{floor_penetration, project_floor};
pub use joint_limit::{joint_limit_residual, project_joint_limits};
pub use temporal::{temporal_residuals, TemporalHistory};
use crate::skeleton::{distance, virtual_joints};
use wifi_densepose_core::ConstraintResiduals;
/// Evaluate residuals without mutating the candidate.
#[must_use]
pub fn audit(
joints: &[[f32; 3]; 17],
posterior: &crate::skeleton::SkeletonPosterior,
floor: wifi_densepose_core::FloorPlane,
history: &TemporalHistory,
timestamp_ns: u64,
) -> ConstraintResiduals {
let bone_m = bone_residual(joints, posterior);
let floor_penetration_m = floor_penetration(joints, floor);
let (velocity_mps, acceleration_mps2, temporal_jerk) =
temporal_residuals(joints, history, timestamp_ns);
let contact_m = contact_slide_residual(joints, floor, history, timestamp_ns);
let (pelvis, thorax) = virtual_joints(joints);
let trunk = distance(pelvis, thorax);
let trunk_violation = if (0.05..=1.2).contains(&trunk) {
0.0
} else {
1.0
};
let joint_limit_rad = joint_limit_residual(joints).max(trunk_violation);
let normalized_total = (bone_m / 0.05
+ floor_penetration_m / 0.05
+ velocity_mps / 8.0
+ acceleration_mps2 / 40.0
+ joint_limit_rad
+ contact_m / 0.25)
/ 6.0;
ConstraintResiduals {
bone_m,
joint_limit_rad,
velocity_mps,
acceleration_mps2,
temporal_jerk,
floor_penetration_m,
contact_m,
collision_m: 0.0,
normalized_total,
}
}
@@ -0,0 +1,65 @@
//! Bounded temporal state and residuals.
use nalgebra::Vector3;
use crate::skeleton::distance;
/// At most two previous frames, held only for a local track.
#[derive(Clone, Debug, Default)]
pub struct TemporalHistory {
pub previous: Option<([[f32; 3]; 17], u64)>,
pub previous_previous: Option<([[f32; 3]; 17], u64)>,
}
impl TemporalHistory {
/// Commit a validated observation/candidate.
pub fn push(&mut self, joints: [[f32; 3]; 17], timestamp_ns: u64) {
self.previous_previous = self.previous.take();
self.previous = Some((joints, timestamp_ns));
}
/// Drop derivatives after a discontinuity.
pub fn reset(&mut self) {
*self = Self::default();
}
}
/// Maximum velocity, acceleration, and normalized jerk.
#[must_use]
pub fn temporal_residuals(
joints: &[[f32; 3]; 17],
history: &TemporalHistory,
timestamp_ns: u64,
) -> (f32, f32, f32) {
let Some((previous, previous_ns)) = &history.previous else {
return (0.0, 0.0, 0.0);
};
let dt = seconds(timestamp_ns.saturating_sub(*previous_ns));
if dt <= 0.0 {
return (f32::INFINITY, f32::INFINITY, f32::INFINITY);
}
let velocity = joints
.iter()
.zip(previous)
.map(|(a, b)| distance(*a, *b) / dt)
.fold(0.0, f32::max);
let Some((older, older_ns)) = &history.previous_previous else {
return (velocity, 0.0, 0.0);
};
let previous_dt = seconds(previous_ns.saturating_sub(*older_ns));
if previous_dt <= 0.0 {
return (velocity, f32::INFINITY, f32::INFINITY);
}
let mut acceleration = 0.0f32;
for index in 0..17 {
let v_now = (Vector3::from(joints[index]) - Vector3::from(previous[index])) / dt;
let v_previous =
(Vector3::from(previous[index]) - Vector3::from(older[index])) / previous_dt;
acceleration = acceleration.max((v_now - v_previous).norm() / dt);
}
(velocity, acceleration, acceleration * dt)
}
fn seconds(nanoseconds: u64) -> f32 {
nanoseconds as f32 / 1_000_000_000.0
}
@@ -0,0 +1,236 @@
//! Optional bounded Rapier articulated-dynamics audit.
//!
//! All bodies, constraints, and snapshots are process-owned. The auditor
//! scores a candidate but never mutates or selects the public pose.
use rapier3d::prelude::{
ColliderBuilder, PhysicsWorld, Pose, RigidBodyBuilder, RigidBodyHandle, Rotation,
SphericalJointBuilder, Vector,
};
use wifi_densepose_core::FloorPlane;
use crate::skeleton::OBSERVED_EDGES;
const SEGMENT_RADIUS_M: f32 = 0.035;
const MAX_SUBSTEPS: u8 = 8;
#[derive(Clone, Debug)]
struct Segment {
handle: RigidBodyHandle,
target_position: Pose,
endpoints: (usize, usize),
midpoint: Vector,
rotation: Rotation,
}
#[derive(Clone, Debug)]
struct JointAnchor {
first: usize,
second: usize,
local_first: Vector,
local_second: Vector,
}
/// Scalar dynamics assessment safe for metrics and provenance.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct DynamicsAssessment {
pub stable: bool,
pub segment_count: u8,
pub joint_count: u8,
pub contact_count: u16,
pub substeps: u8,
pub max_tracking_error_m: f32,
pub max_joint_anchor_error_m: f32,
pub max_floor_penetration_m: f32,
pub max_control_effort: f32,
}
/// Process-owned humanoid assembled from capsule segments and spherical joints.
pub struct DynamicsAuditor {
world: PhysicsWorld,
segments: Vec<Segment>,
anchors: Vec<JointAnchor>,
floor: FloorPlane,
}
impl DynamicsAuditor {
/// Build a bounded articulated model from one candidate pose.
#[must_use]
pub fn new(joints: &[[f32; 3]; 17], floor: FloorPlane) -> Self {
let mut world = PhysicsWorld::new();
world.gravity = Vector::new(0.0, 0.0, -9.81);
let floor_normal = Vector::from_array(floor.normal);
let floor_point = floor_normal * -floor.offset_m;
let floor_rotation = Rotation::from_rotation_arc(Vector::Z, floor_normal);
world.insert(
RigidBodyBuilder::fixed().pose(Pose::from_parts(floor_point, floor_rotation)),
ColliderBuilder::cuboid(10.0, 10.0, 0.02).friction(0.8),
);
let mut segments = Vec::with_capacity(OBSERVED_EDGES.len());
for endpoints in OBSERVED_EDGES {
let a = Vector::from_array(joints[endpoints.0]);
let b = Vector::from_array(joints[endpoints.1]);
let delta = b - a;
let length = delta.length().max(SEGMENT_RADIUS_M * 2.0);
let midpoint = (a + b) * 0.5;
let direction = delta.try_normalize().unwrap_or(Vector::Y);
let rotation = Rotation::from_rotation_arc(Vector::Y, direction);
let position = Pose::from_parts(midpoint, rotation);
let (handle, _) = world.insert(
RigidBodyBuilder::dynamic()
.pose(position)
.linear_damping(0.8)
.angular_damping(0.8)
.can_sleep(false),
ColliderBuilder::capsule_y(
(length * 0.5 - SEGMENT_RADIUS_M).max(0.001),
SEGMENT_RADIUS_M,
)
.density(450.0)
.friction(0.7),
);
segments.push(Segment {
handle,
target_position: position,
endpoints,
midpoint,
rotation,
});
}
let mut anchors = Vec::new();
for first in 0..segments.len() {
for second in (first + 1)..segments.len() {
let Some(shared) =
shared_endpoint(segments[first].endpoints, segments[second].endpoints)
else {
continue;
};
let point = Vector::from_array(joints[shared]);
let local_first =
segments[first].rotation.inverse() * (point - segments[first].midpoint);
let local_second =
segments[second].rotation.inverse() * (point - segments[second].midpoint);
world.insert_impulse_joint(
segments[first].handle,
segments[second].handle,
SphericalJointBuilder::new()
.local_anchor1(local_first)
.local_anchor2(local_second)
.contacts_enabled(false),
);
anchors.push(JointAnchor {
first,
second,
local_first,
local_second,
});
}
}
Self {
world,
segments,
anchors,
floor,
}
}
/// Run bounded PD-controlled audit steps and return scalar residuals.
#[must_use]
pub fn audit(
&mut self,
joints: &[[f32; 3]; 17],
dt_seconds: f32,
requested_substeps: u8,
) -> DynamicsAssessment {
let substeps = requested_substeps.clamp(1, MAX_SUBSTEPS);
self.world.integration_parameters.dt =
dt_seconds.clamp(1.0 / 240.0, 0.05) / f32::from(substeps);
let mut max_control_effort = 0.0f32;
for segment in &mut self.segments {
let a = Vector::from_array(joints[segment.endpoints.0]);
let b = Vector::from_array(joints[segment.endpoints.1]);
let midpoint = (a + b) * 0.5;
let direction = (b - a).try_normalize().unwrap_or(Vector::Y);
let rotation = Rotation::from_rotation_arc(Vector::Y, direction);
segment.target_position = Pose::from_parts(midpoint, rotation);
}
for _ in 0..substeps {
for segment in &self.segments {
let Some(body) = self.world.bodies.get_mut(segment.handle) else {
continue;
};
let position_error = segment.target_position.translation - body.translation();
let rotation_error =
(segment.target_position.rotation * body.rotation().inverse()).to_scaled_axis();
let force = position_error * 120.0 - body.linvel() * 18.0;
let torque = rotation_error * 35.0 - body.angvel() * 8.0;
max_control_effort = max_control_effort.max(force.length()).max(torque.length());
body.add_force(force, true);
body.add_torque(torque, true);
}
self.world.step();
}
let max_tracking_error_m = self
.segments
.iter()
.filter_map(|segment| {
self.world
.bodies
.get(segment.handle)
.map(|body| (segment, body))
})
.map(|(segment, body)| {
(segment.target_position.translation - body.translation()).length()
})
.fold(0.0, f32::max);
let max_joint_anchor_error_m = self
.anchors
.iter()
.filter_map(|anchor| {
let first = self.world.bodies.get(self.segments[anchor.first].handle)?;
let second = self.world.bodies.get(self.segments[anchor.second].handle)?;
let point_first = first.position() * anchor.local_first;
let point_second = second.position() * anchor.local_second;
Some((point_first - point_second).length())
})
.fold(0.0, f32::max);
let max_floor_penetration_m = self
.segments
.iter()
.filter_map(|segment| self.world.bodies.get(segment.handle))
.map(|body| (-self.floor.signed_distance(body.translation().to_array())).max(0.0))
.fold(0.0, f32::max);
let contact_count = self
.world
.narrow_phase
.contact_pairs()
.filter(|pair| pair.has_any_active_contact())
.count()
.try_into()
.unwrap_or(u16::MAX);
let stable = max_tracking_error_m.is_finite()
&& max_joint_anchor_error_m.is_finite()
&& max_floor_penetration_m.is_finite()
&& max_control_effort.is_finite();
DynamicsAssessment {
stable,
segment_count: self.segments.len().try_into().unwrap_or(u8::MAX),
joint_count: self.anchors.len().try_into().unwrap_or(u8::MAX),
contact_count,
substeps,
max_tracking_error_m,
max_joint_anchor_error_m,
max_floor_penetration_m,
max_control_effort,
}
}
}
fn shared_endpoint(first: (usize, usize), second: (usize, usize)) -> Option<usize> {
[first.0, first.1]
.into_iter()
.find(|endpoint| *endpoint == second.0 || *endpoint == second.1)
}
@@ -0,0 +1,779 @@
//! Stateful, bounded top-level control flow.
use std::{collections::HashMap, time::Instant};
use wifi_densepose_core::{
AbstentionReason, ConstraintResiduals, ContactHypothesis, ContactState, PhysicsMode,
PhysicsProvenance, PoseDimensionality, PoseObservationV2, PoseRefinementV1, PoseTrustState,
Probability, RefinementDisposition, POSE_OBSERVATION_SCHEMA_VERSION,
};
use crate::{
config::PhysicsConfig,
constraints::{self, TemporalHistory},
error::PhysicsError,
projector,
skeleton::SkeletonPosterior,
uncertainty::movement_weight,
};
#[derive(Default)]
struct TrackState {
last_sequence: Option<u64>,
last_timestamp_ns: Option<u64>,
last_hash: Option<[u8; 32]>,
last_config_hash: Option<[u8; 32]>,
cached: Option<PoseRefinementV1>,
skeleton: SkeletonPosterior,
temporal: TemporalHistory,
last_calibration_id: Option<String>,
#[cfg(feature = "dynamics")]
dynamics: Option<crate::dynamics::DynamicsAuditor>,
}
/// Evidence receipt produced only after the serving boundary verifies the
/// signed configuration, release gate, sensor authentication, and replay
/// policy. The physics crate binds it to one exact configuration hash.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CorrectionAuthorization {
config_hash: [u8; 32],
evidence_hash: [u8; 32],
}
impl CorrectionAuthorization {
/// Construct a receipt from already verified, hash-addressed evidence.
pub fn from_verified_evidence(
config_hash: [u8; 32],
evidence_hash: [u8; 32],
) -> Result<Self, PhysicsError> {
if config_hash == [0; 32] || evidence_hash == [0; 32] {
return Err(PhysicsError::InvalidCorrectionAuthorization(
"configuration and evidence hashes must be non-zero",
));
}
Ok(Self {
config_hash,
evidence_hash,
})
}
}
/// Per-frame receipt returned by the ADR-305 verifier after signature,
/// freshness, sequence, replay-window, calibration, and trust-state checks.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VerifiedFrameContext {
raw_hash: [u8; 32],
sensor_epoch: u64,
sequence: u64,
sensor_id: String,
calibration_id: String,
receipt_hash: [u8; 32],
}
impl VerifiedFrameContext {
/// Bind process-owned verification evidence to one canonical frame.
pub fn from_verified_envelope(
raw_hash: [u8; 32],
sensor_epoch: u64,
sequence: u64,
sensor_id: String,
calibration_id: String,
receipt_hash: [u8; 32],
) -> Result<Self, PhysicsError> {
if raw_hash == [0; 32]
|| receipt_hash == [0; 32]
|| sensor_id.is_empty()
|| calibration_id.is_empty()
{
return Err(PhysicsError::InvalidCorrectionAuthorization(
"verified frame receipt is incomplete",
));
}
Ok(Self {
raw_hash,
sensor_epoch,
sequence,
sensor_id,
calibration_id,
receipt_hash,
})
}
fn matches(&self, raw: &PoseObservationV2) -> bool {
self.raw_hash == raw.canonical_hash
&& self.sensor_epoch == raw.sensor_epoch
&& self.sequence == raw.sequence
&& self.sensor_id == raw.source.sensor_id
&& self.calibration_id == raw.calibration_id.0
&& self.receipt_hash != [0; 32]
}
}
/// In-memory physics engine. Track state is anonymous and discarded on expiry.
pub struct PhysicsEngine {
config: PhysicsConfig,
config_hash: [u8; 32],
tracks: HashMap<(wifi_densepose_core::TrackId, u64), TrackState>,
correction_authorization: Option<CorrectionAuthorization>,
}
impl PhysicsEngine {
/// Activate a validated trusted configuration.
pub fn new(config: PhysicsConfig) -> Result<Self, PhysicsError> {
config
.validate()
.map_err(PhysicsError::InvalidConfiguration)?;
let config_hash = config.canonical_hash();
Ok(Self {
config,
config_hash,
tracks: HashMap::new(),
correction_authorization: None,
})
}
/// Current immutable configuration.
#[must_use]
pub const fn config(&self) -> &PhysicsConfig {
&self.config
}
/// Hash of the exact active configuration.
#[must_use]
pub const fn config_hash(&self) -> [u8; 32] {
self.config_hash
}
/// Hash a prospective mode without activating it, so a signed evidence
/// receipt can be bound before the atomic transition.
#[must_use]
pub fn config_hash_for_mode(&self, mode: PhysicsMode) -> [u8; 32] {
let mut prospective = self.config.clone();
prospective.mode = mode;
prospective.canonical_hash()
}
/// Activate a correction receipt after the serving boundary has verified it.
pub fn authorize_correction(
&mut self,
authorization: CorrectionAuthorization,
) -> Result<(), PhysicsError> {
self.correction_authorization = Some(authorization);
Ok(())
}
/// Authenticated control planes may change rollout mode without restart.
pub fn set_mode(&mut self, mode: PhysicsMode) -> Result<(), PhysicsError> {
let mut prospective = self.config.clone();
prospective.mode = mode;
let prospective_hash = prospective.canonical_hash();
if mode.selects_correction()
&& self
.correction_authorization
.is_none_or(|receipt| receipt.config_hash != prospective_hash)
{
return Err(PhysicsError::InvalidCorrectionAuthorization(
"correction mode requires evidence bound to the target configuration",
));
}
self.config.mode = mode;
self.config_hash = prospective_hash;
if matches!(mode, PhysicsMode::Off) {
self.tracks.clear();
}
Ok(())
}
/// Process one frame and always return a typed assessment.
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn process(&mut self, raw: &PoseObservationV2, now_ns: u64) -> PoseRefinementV1 {
self.process_with_context(raw, now_ns, None)
}
/// Process a frame with process-owned source verification evidence.
#[must_use]
pub fn process_verified(
&mut self,
raw: &PoseObservationV2,
now_ns: u64,
verification: &VerifiedFrameContext,
) -> PoseRefinementV1 {
self.process_with_context(raw, now_ns, Some(verification))
}
#[allow(clippy::too_many_lines)]
fn process_with_context(
&mut self,
raw: &PoseObservationV2,
now_ns: u64,
verification: Option<&VerifiedFrameContext>,
) -> PoseRefinementV1 {
let started = Instant::now();
if let Some(reason) = self.validate_contract(raw, now_ns) {
return self.raw_result(raw, RefinementDisposition::Rejected, Some(reason));
}
if self.config.mode == PhysicsMode::Off {
return self.raw_result(
raw,
RefinementDisposition::Bypassed,
Some(AbstentionReason::ModeOff),
);
}
if raw.trust_state == PoseTrustState::Unknown {
return self.raw_result(
raw,
RefinementDisposition::Abstained,
Some(AbstentionReason::OodUnknown),
);
}
if raw.dimensionality == PoseDimensionality::Image2d {
return self.audit_2d(raw, started);
}
if self.config.mode.selects_correction()
&& self
.correction_authorization
.is_none_or(|receipt| receipt.config_hash != self.config_hash)
{
return self.raw_result(
raw,
RefinementDisposition::Abstained,
Some(AbstentionReason::CorrectionNotAuthorized),
);
}
if self.config.mode.selects_correction()
&& verification.is_none_or(|receipt| !receipt.matches(raw))
{
return self.raw_result(
raw,
RefinementDisposition::Abstained,
Some(AbstentionReason::SourceUnauthenticated),
);
}
if !raw.uncertainty_calibrated {
return self.raw_result(
raw,
RefinementDisposition::Abstained,
Some(AbstentionReason::UncertaintyUncalibrated),
);
}
let Some(floor) = raw.floor_plane else {
return self.raw_result(
raw,
RefinementDisposition::Abstained,
Some(AbstentionReason::CalibrationUnavailable),
);
};
if !floor.is_valid() {
return self.raw_result(
raw,
RefinementDisposition::Rejected,
Some(AbstentionReason::CoordinateFrameMismatch),
);
}
self.expire_tracks(now_ns);
let key = (raw.track_id.clone(), raw.sensor_epoch);
if !self.tracks.contains_key(&key) && self.tracks.len() >= self.config.max_tracks {
return self.raw_result(
raw,
RefinementDisposition::Abstained,
Some(AbstentionReason::TrackCapacity),
);
}
let mut state = self.tracks.remove(&key).unwrap_or_default();
if state.last_sequence == Some(raw.sequence) {
if state.last_hash == Some(raw.canonical_hash)
&& state.last_config_hash == Some(self.config_hash)
{
if let Some(cached) = state.cached.clone() {
self.tracks.insert(key, state);
return cached;
}
}
self.tracks.insert(key, state);
return self.raw_result(
raw,
RefinementDisposition::Abstained,
Some(AbstentionReason::ReplayRejected),
);
}
if state
.last_sequence
.is_some_and(|sequence| raw.sequence < sequence)
|| state
.last_timestamp_ns
.is_some_and(|timestamp| raw.timestamp_ns <= timestamp)
{
state.temporal.reset();
self.tracks.insert(key, state);
return self.raw_result(
raw,
RefinementDisposition::Abstained,
Some(AbstentionReason::NonMonotonicInput),
);
}
if state.last_timestamp_ns.is_some_and(|timestamp| {
raw.timestamp_ns.saturating_sub(timestamp) > self.config.track_reset_gap_ms * 1_000_000
}) {
state = TrackState::default();
} else if state.last_timestamp_ns.is_some_and(|timestamp| {
raw.timestamp_ns.saturating_sub(timestamp) > self.config.max_frame_gap_ms * 1_000_000
}) {
state.temporal.reset();
}
if state
.last_calibration_id
.as_ref()
.is_some_and(|calibration| calibration != &raw.calibration_id.0)
{
state = TrackState::default();
}
let known = raw
.joints
.iter()
.filter(|joint| {
joint.visibility != wifi_densepose_core::JointVisibility::Unknown
&& joint.confidence.get() >= self.config.minimum_joint_confidence
})
.count();
if known < self.config.minimum_known_joints {
self.tracks.insert(key, state);
return self.raw_result(
raw,
RefinementDisposition::Abstained,
Some(AbstentionReason::TooFewKnownJoints),
);
}
let observed = raw.joints.map(|joint| joint.position_m);
let confidence = raw.joints.map(|joint| joint.confidence.get());
let weights = raw.joints.map(|joint| movement_weight(joint.covariance_m2));
let audit = constraints::audit(
&observed,
&state.skeleton,
floor,
&state.temporal,
raw.timestamp_ns,
);
state.skeleton.observe(
&observed,
&confidence,
self.config.calibration_joint_confidence,
);
if self.config.mode == PhysicsMode::Audit {
let result = self.build_result(
raw,
RefinementDisposition::Audited,
false,
None,
wifi_densepose_core::InterventionSummary {
elapsed_us: elapsed_us(started),
..wifi_densepose_core::InterventionSummary::default()
},
audit,
None,
None,
);
state.temporal.push(observed, raw.timestamp_ns);
state.last_sequence = Some(raw.sequence);
state.last_timestamp_ns = Some(raw.timestamp_ns);
state.last_hash = Some(raw.canonical_hash);
state.last_config_hash = Some(self.config_hash);
state.last_calibration_id = Some(raw.calibration_id.0.clone());
state.cached = Some(result.clone());
self.tracks.insert(key, state);
return result;
}
let projection = projector::project(
&observed,
&weights,
&state.skeleton,
&state.temporal,
raw.timestamp_ns,
floor,
&self.config,
started,
);
let result = match projection {
Err(reason) => self.raw_result(raw, RefinementDisposition::Abstained, Some(reason)),
Ok(candidate) => {
#[allow(unused_mut)]
let mut refined_residuals = constraints::audit(
&candidate.joints,
&state.skeleton,
floor,
&state.temporal,
raw.timestamp_ns,
);
#[cfg(feature = "dynamics")]
let dynamics = if self.config.dynamics_audit {
let auditor = state.dynamics.get_or_insert_with(|| {
crate::dynamics::DynamicsAuditor::new(&candidate.joints, floor)
});
let assessment = auditor.audit(
&candidate.joints,
self.config.dynamics_dt_seconds,
self.config.dynamics_substeps,
);
refined_residuals.collision_m = assessment.max_floor_penetration_m;
refined_residuals.normalized_total += (assessment.max_tracking_error_m / 0.05
+ assessment.max_joint_anchor_error_m / 0.02
+ assessment.max_floor_penetration_m / 0.05)
/ 3.0;
Some(wifi_densepose_core::DynamicsResiduals {
stable: assessment.stable,
segment_count: assessment.segment_count,
joint_count: assessment.joint_count,
contact_count: assessment.contact_count,
substeps: assessment.substeps,
tracking_error_m: assessment.max_tracking_error_m,
joint_anchor_error_m: assessment.max_joint_anchor_error_m,
floor_penetration_m: assessment.max_floor_penetration_m,
control_effort: assessment.max_control_effort,
})
} else {
None
};
#[cfg(not(feature = "dynamics"))]
let dynamics: Option<wifi_densepose_core::DynamicsResiduals> = None;
if started.elapsed() >= std::time::Duration::from_millis(self.config.deadline_ms) {
self.raw_result(
raw,
RefinementDisposition::Abstained,
Some(AbstentionReason::DeadlineExceeded),
)
} else {
let (disposition, selected, refined) = match self.config.mode {
PhysicsMode::Audit => unreachable!("audit returned before projection"),
PhysicsMode::ShadowCorrect => (
RefinementDisposition::Shadowed,
false,
Some(candidate.joints),
),
PhysicsMode::OptInCorrect | PhysicsMode::DefaultCorrect => (
RefinementDisposition::Corrected,
true,
Some(candidate.joints),
),
PhysicsMode::Off => unreachable!("off returned before projection"),
};
let mut result = self.build_result(
raw,
disposition,
selected,
refined,
candidate.intervention,
audit,
Some(refined_residuals),
None,
);
result.dynamics = dynamics;
if result.dynamics.is_some() {
result.provenance.engine = "kinematic-pbd+rapier-audit".into();
result.seal();
}
result
}
}
};
let committed_joints = if result.selected {
result.refined_joints_m.unwrap_or(observed)
} else {
observed
};
state.temporal.push(committed_joints, raw.timestamp_ns);
state.last_sequence = Some(raw.sequence);
state.last_timestamp_ns = Some(raw.timestamp_ns);
state.last_hash = Some(raw.canonical_hash);
state.last_config_hash = Some(self.config_hash);
state.last_calibration_id = Some(raw.calibration_id.0.clone());
state.cached = Some(result.clone());
self.tracks.insert(key, state);
debug_assert!(result.effective_confidence.get() <= raw.observer_confidence.get());
debug_assert_eq!(result.raw_observation_hash, raw.canonical_hash);
result
}
fn validate_contract(&self, raw: &PoseObservationV2, now_ns: u64) -> Option<AbstentionReason> {
if raw.schema_version != POSE_OBSERVATION_SCHEMA_VERSION {
return Some(AbstentionReason::UnsupportedSchema);
}
if raw.compute_canonical_hash() != raw.canonical_hash {
return Some(AbstentionReason::HashMismatch);
}
if raw.track_id.0.is_empty()
|| raw.track_id.0.len() > 128
|| raw.frame.name.is_empty()
|| raw.frame.name.len() > 128
|| raw.calibration_id.0.is_empty()
|| raw.calibration_id.0.len() > 128
|| raw.model.id.is_empty()
|| raw.model.id.len() > 128
|| raw.source.sensor_id.is_empty()
|| raw.source.sensor_id.len() > 128
{
return Some(AbstentionReason::InvalidNumber);
}
if now_ns.saturating_sub(raw.timestamp_ns) > self.config.stale_after_ms * 1_000_000
|| raw.timestamp_ns > now_ns.saturating_add(50_000_000)
{
return Some(AbstentionReason::StaleInput);
}
let coordinate_bound = match raw.dimensionality {
PoseDimensionality::Metric3d => self.config.room_bound_m,
PoseDimensionality::Image2d => self.config.image_coordinate_bound,
};
for (index, joint) in raw.joints.iter().enumerate() {
if joint.kind != wifi_densepose_core::Coco17Joint::ALL[index]
|| !joint.position_m.iter().all(|v| v.is_finite())
|| joint.position_m.iter().any(|v| v.abs() > coordinate_bound)
{
return Some(AbstentionReason::InvalidNumber);
}
if !joint.covariance_m2.is_positive_semidefinite() {
return Some(AbstentionReason::InvalidCovariance);
}
}
match raw.dimensionality {
PoseDimensionality::Metric3d
if !(raw.frame.metric && raw.frame.right_handed && raw.frame.z_up) =>
{
Some(AbstentionReason::CoordinateFrameMismatch)
}
PoseDimensionality::Image2d if raw.frame.metric => {
Some(AbstentionReason::CoordinateFrameMismatch)
}
_ => None,
}
}
fn audit_2d(&mut self, raw: &PoseObservationV2, started: Instant) -> PoseRefinementV1 {
self.expire_tracks(raw.timestamp_ns);
let key = (raw.track_id.clone(), raw.sensor_epoch);
if !self.tracks.contains_key(&key) && self.tracks.len() >= self.config.max_tracks {
return self.raw_result(
raw,
RefinementDisposition::Abstained,
Some(AbstentionReason::TrackCapacity),
);
}
let mut state = self.tracks.remove(&key).unwrap_or_default();
if state.last_sequence == Some(raw.sequence) {
if state.last_hash == Some(raw.canonical_hash)
&& state.last_config_hash == Some(self.config_hash)
{
if let Some(cached) = state.cached.clone() {
self.tracks.insert(key, state);
return cached;
}
}
self.tracks.insert(key, state);
return self.raw_result(
raw,
RefinementDisposition::Abstained,
Some(AbstentionReason::ReplayRejected),
);
}
if state
.last_sequence
.is_some_and(|sequence| raw.sequence < sequence)
|| state
.last_timestamp_ns
.is_some_and(|timestamp| raw.timestamp_ns <= timestamp)
{
state.temporal.reset();
self.tracks.insert(key, state);
return self.raw_result(
raw,
RefinementDisposition::Abstained,
Some(AbstentionReason::NonMonotonicInput),
);
}
if state.last_timestamp_ns.is_some_and(|timestamp| {
raw.timestamp_ns.saturating_sub(timestamp) > self.config.track_reset_gap_ms * 1_000_000
}) {
state = TrackState::default();
} else if state.last_timestamp_ns.is_some_and(|timestamp| {
raw.timestamp_ns.saturating_sub(timestamp) > self.config.max_frame_gap_ms * 1_000_000
}) {
state.temporal.reset();
}
if state
.last_calibration_id
.as_ref()
.is_some_and(|calibration| calibration != &raw.calibration_id.0)
{
state = TrackState::default();
}
let joints = raw.joints.map(|joint| joint.position_m);
let residuals = image_residuals(&joints, &state.temporal, raw.timestamp_ns);
let intervention = wifi_densepose_core::InterventionSummary {
elapsed_us: elapsed_us(started),
..wifi_densepose_core::InterventionSummary::default()
};
let result = self.build_result(
raw,
RefinementDisposition::Audited2d,
false,
None,
intervention,
residuals,
None,
None,
);
state.temporal.push(joints, raw.timestamp_ns);
state.last_sequence = Some(raw.sequence);
state.last_timestamp_ns = Some(raw.timestamp_ns);
state.last_hash = Some(raw.canonical_hash);
state.last_config_hash = Some(self.config_hash);
state.last_calibration_id = Some(raw.calibration_id.0.clone());
state.cached = Some(result.clone());
self.tracks.insert(key, state);
result
}
fn raw_result(
&self,
raw: &PoseObservationV2,
disposition: RefinementDisposition,
reason: Option<AbstentionReason>,
) -> PoseRefinementV1 {
self.build_result(
raw,
disposition,
false,
None,
wifi_densepose_core::InterventionSummary::default(),
ConstraintResiduals::default(),
None,
reason,
)
}
#[allow(clippy::too_many_arguments)]
fn build_result(
&self,
raw: &PoseObservationV2,
disposition: RefinementDisposition,
selected: bool,
refined: Option<[[f32; 3]; 17]>,
intervention: wifi_densepose_core::InterventionSummary,
residuals: ConstraintResiduals,
refined_residuals: Option<ConstraintResiduals>,
reason: Option<AbstentionReason>,
) -> PoseRefinementV1 {
let penalty = self.config.beta_residual.mul_add(
residuals.normalized_total,
self.config.beta_intervention * intervention.max_joint_correction_m
/ self.config.max_joint_correction_m,
);
let physics_value = if reason == Some(AbstentionReason::ModeOff) {
1.0
} else if reason.is_some() {
0.0
} else {
(-penalty.max(0.0)).exp().clamp(0.0, 1.0)
};
let physics_confidence = Probability::new(physics_value).unwrap_or(Probability::ZERO);
let effective_value = raw
.observer_confidence
.get()
.min(raw.observer_confidence.get() * physics_value);
let effective_confidence = Probability::new(effective_value).unwrap_or(Probability::ZERO);
let contact_hypotheses = if raw.dimensionality == PoseDimensionality::Metric3d {
[15usize, 16usize].map(|index| {
let probability = raw.floor_plane.map_or(0.0, |floor| {
(1.0 - floor.signed_distance(raw.joints[index].position_m).abs() / 0.05)
.clamp(0.0, 1.0)
});
ContactHypothesis {
state: ContactState::Hypothesis,
probability: Probability::new(probability).unwrap_or(Probability::ZERO),
}
})
} else {
[ContactHypothesis::default(); 2]
};
let mut result = PoseRefinementV1 {
schema_version: 1,
raw_observation_hash: raw.canonical_hash,
mode: self.config.mode,
disposition,
selected,
refined_joints_m: refined,
physics_confidence,
effective_confidence,
intervention,
residuals,
refined_residuals,
dynamics: None,
contact_hypotheses,
provenance: PhysicsProvenance {
engine: "kinematic-pbd".into(),
engine_version: env!("CARGO_PKG_VERSION").into(),
config_hash: self.config_hash,
rf_model_hash: raw.model.artifact_hash,
calibration_id: raw.calibration_id.0.clone(),
learned_artifact_hash: None,
},
reason,
canonical_hash: [0; 32],
};
result.seal();
result
}
fn expire_tracks(&mut self, now_ns: u64) {
let expiry = self.config.track_reset_gap_ms * 1_000_000;
self.tracks.retain(|_, state| {
state
.last_timestamp_ns
.is_none_or(|timestamp| now_ns.saturating_sub(timestamp) <= expiry)
});
}
}
fn image_residuals(
joints: &[[f32; 3]; 17],
temporal: &crate::constraints::TemporalHistory,
timestamp_ns: u64,
) -> ConstraintResiduals {
let lengths = crate::skeleton::OBSERVED_EDGES
.map(|(a, b)| crate::skeleton::distance(joints[a], joints[b]));
let mean = lengths.iter().sum::<f32>() / lengths.len() as f32;
let ratio_variance = if mean > 1.0e-6 {
lengths
.iter()
.map(|length| ((length - mean) / mean).powi(2))
.sum::<f32>()
/ lengths.len() as f32
} else {
0.0
};
let (velocity, acceleration, jerk) =
crate::constraints::temporal_residuals(joints, temporal, timestamp_ns);
ConstraintResiduals {
normalized_total: ratio_variance.sqrt().min(1.0),
velocity_mps: velocity,
acceleration_mps2: acceleration,
temporal_jerk: jerk,
..ConstraintResiduals::default()
}
}
fn elapsed_us(started: Instant) -> u64 {
#[cfg(feature = "deterministic")]
{
let _ = started;
0
}
#[cfg(not(feature = "deterministic"))]
{
started.elapsed().as_micros().try_into().unwrap_or(u64::MAX)
}
}
@@ -0,0 +1,12 @@
//! Typed internal errors. Public frame failures are mapped to abstention reasons.
/// Construction/configuration errors that cannot be represented as a frame result.
#[derive(Debug, thiserror::Error)]
pub enum PhysicsError {
/// Invalid trusted configuration.
#[error("invalid physics configuration: {0}")]
InvalidConfiguration(&'static str),
/// A correction authorization is empty or bound to another configuration.
#[error("invalid correction authorization: {0}")]
InvalidCorrectionAuthorization(&'static str),
}
@@ -0,0 +1,163 @@
//! Signed learned-artifact identity, compatibility, and bounded output.
use super::{
features::FEATURE_WIDTH,
model::{HIDDEN_WIDTH, HISTORY_FRAMES},
};
pub const LEARNED_ARTIFACT_SCHEMA_VERSION: u16 = 1;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LearnedArtifactManifest {
pub schema_version: u16,
pub history_frames: usize,
pub feature_width: usize,
pub hidden_width: usize,
pub model_id: String,
}
impl LearnedArtifactManifest {
#[must_use]
pub fn adr323(model_id: String) -> Self {
Self {
schema_version: LEARNED_ARTIFACT_SCHEMA_VERSION,
history_frames: HISTORY_FRAMES,
feature_width: FEATURE_WIDTH,
hidden_width: HIDDEN_WIDTH,
model_id,
}
}
fn is_compatible(&self) -> bool {
self.schema_version == LEARNED_ARTIFACT_SCHEMA_VERSION
&& self.history_frames == HISTORY_FRAMES
&& self.feature_width == FEATURE_WIDTH
&& self.hidden_width == HIDDEN_WIDTH
&& !self.model_id.is_empty()
&& self.model_id.len() <= 128
}
}
/// Receipt from the existing signed RVF/Cog activation boundary.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignatureVerification {
pub key_id: String,
pub envelope_hash: [u8; 32],
}
impl SignatureVerification {
#[must_use]
pub fn accepted(key_id: String, envelope_hash: [u8; 32]) -> Option<Self> {
(!key_id.is_empty() && key_id.len() <= 128 && envelope_hash != [0; 32]).then_some(Self {
key_id,
envelope_hash,
})
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum LearnedArtifactError {
#[error("artifact content hash mismatch")]
HashMismatch,
#[error("artifact manifest is incompatible")]
IncompatibleManifest,
#[error("artifact signature receipt is invalid")]
InvalidSignatureReceipt,
#[error("artifact exceeds the configured byte limit")]
ArtifactTooLarge,
}
/// Verified immutable model bytes and activation provenance.
#[derive(Clone, Debug)]
pub struct LearnedArtifact {
pub content_hash: [u8; 32],
pub manifest: LearnedArtifactManifest,
pub signature: SignatureVerification,
bytes: Vec<u8>,
}
impl LearnedArtifact {
pub fn verified(
bytes: Vec<u8>,
expected: [u8; 32],
manifest: LearnedArtifactManifest,
signature: SignatureVerification,
maximum_bytes: usize,
) -> Result<Self, LearnedArtifactError> {
if bytes.len() > maximum_bytes {
return Err(LearnedArtifactError::ArtifactTooLarge);
}
if !manifest.is_compatible() {
return Err(LearnedArtifactError::IncompatibleManifest);
}
if signature.key_id.is_empty() || signature.envelope_hash == [0; 32] {
return Err(LearnedArtifactError::InvalidSignatureReceipt);
}
let actual = *blake3::hash(&bytes).as_bytes();
if actual != expected {
return Err(LearnedArtifactError::HashMismatch);
}
Ok(Self {
content_hash: actual,
manifest,
signature,
bytes,
})
}
#[must_use]
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
}
/// Learned heads after hard output validation.
#[derive(Clone, Copy, Debug)]
pub struct LearnedResidual {
pub joints_m: [[f32; 3]; 17],
pub log_variance: [[f32; 3]; 17],
pub contact_probability: [f32; 2],
pub abstention_probability: f32,
}
impl LearnedResidual {
/// Reject non-finite output, then clamp residuals and probabilistic heads.
#[must_use]
pub fn bounded(mut self, cap_m: f32) -> Option<Self> {
if !cap_m.is_finite()
|| cap_m <= 0.0
|| !self
.joints_m
.iter()
.flatten()
.all(|value| value.is_finite())
|| !self
.log_variance
.iter()
.flatten()
.all(|value| value.is_finite())
|| !self
.contact_probability
.iter()
.all(|value| value.is_finite())
|| !self.abstention_probability.is_finite()
{
return None;
}
for joint in &mut self.joints_m {
for coordinate in joint {
*coordinate = coordinate.clamp(-cap_m, cap_m);
}
}
for joint in &mut self.log_variance {
for coordinate in joint {
*coordinate = coordinate.clamp(-20.0, 10.0);
}
}
for probability in &mut self.contact_probability {
*probability = probability.clamp(0.0, 1.0);
}
self.abstention_probability = self.abstention_probability.clamp(0.0, 1.0);
Some(self)
}
}
@@ -0,0 +1,145 @@
//! Fixed-width, bounded learned-residual feature encoding.
use std::collections::VecDeque;
use wifi_densepose_core::{ConstraintResiduals, PoseObservationV2};
use crate::constraints::TemporalHistory;
pub const RF_EMBEDDING_WIDTH: usize = 128;
pub const BASE_FEATURE_WIDTH: usize = 319;
pub const FEATURE_WIDTH: usize = BASE_FEATURE_WIDTH + RF_EMBEDDING_WIDTH;
/// Exactly one encoded model timestep.
#[derive(Clone, Debug, PartialEq)]
pub struct PoseFeatureFrame {
values: Vec<f32>,
}
impl PoseFeatureFrame {
/// Encode observer, uncertainty, deterministic residual, temporal, floor,
/// OOD, and optional RF embedding features without allocating unbounded data.
#[must_use]
pub fn encode(
raw: &PoseObservationV2,
residuals: ConstraintResiduals,
history: &TemporalHistory,
rf_embedding: Option<&[f32; RF_EMBEDDING_WIDTH]>,
) -> Self {
let mut values = Vec::with_capacity(FEATURE_WIDTH);
for joint in &raw.joints {
values.extend(joint.position_m);
}
for joint in &raw.joints {
values.extend([
joint.covariance_m2.xx,
joint.covariance_m2.xy,
joint.covariance_m2.xz,
joint.covariance_m2.yy,
joint.covariance_m2.yz,
joint.covariance_m2.zz,
]);
}
values.extend(raw.joints.iter().map(|joint| joint.confidence.get()));
values.extend(
raw.joints
.iter()
.map(|joint| f32::from(joint.visibility as u8)),
);
values.extend([
residuals.bone_m,
residuals.joint_limit_rad,
residuals.velocity_mps,
residuals.acceleration_mps2,
residuals.temporal_jerk,
residuals.floor_penetration_m,
residuals.contact_m,
residuals.collision_m,
residuals.normalized_total,
]);
let current = raw.joints.map(|joint| joint.position_m);
let velocity = history
.previous
.as_ref()
.map_or([[0.0; 3]; 17], |(previous, ns)| {
derivative(&current, previous, raw.timestamp_ns.saturating_sub(*ns))
});
for joint in velocity {
values.extend(joint);
}
let acceleration = match (&history.previous, &history.previous_previous) {
(Some((previous, previous_ns)), Some((older, older_ns))) => {
let previous_velocity =
derivative(previous, older, previous_ns.saturating_sub(*older_ns));
derivative(
&velocity,
&previous_velocity,
raw.timestamp_ns.saturating_sub(*previous_ns),
)
}
_ => [[0.0; 3]; 17],
};
for joint in acceleration {
values.extend(joint);
}
values.extend(raw.joints.iter().map(|joint| {
raw.floor_plane
.map_or(0.0, |floor| floor.signed_distance(joint.position_m))
}));
values.extend(match raw.trust_state {
wifi_densepose_core::PoseTrustState::Known => [1.0, 0.0, 0.0],
wifi_densepose_core::PoseTrustState::Degraded => [0.0, 1.0, 0.0],
wifi_densepose_core::PoseTrustState::Unknown => [0.0, 0.0, 1.0],
});
values.push(raw.observer_confidence.get());
values.extend(rf_embedding.copied().unwrap_or([0.0; RF_EMBEDDING_WIDTH]));
debug_assert_eq!(values.len(), FEATURE_WIDTH);
Self { values }
}
#[must_use]
pub fn values(&self) -> &[f32] {
&self.values
}
}
/// Bounded twenty-frame history used by both trainer and inference.
#[derive(Clone, Debug, Default)]
pub struct FeatureHistory {
frames: VecDeque<PoseFeatureFrame>,
}
impl FeatureHistory {
pub fn push(&mut self, frame: PoseFeatureFrame) {
if self.frames.len() == super::model::HISTORY_FRAMES {
self.frames.pop_front();
}
self.frames.push_back(frame);
}
#[must_use]
pub fn is_ready(&self) -> bool {
self.frames.len() == super::model::HISTORY_FRAMES
}
/// Flatten only a complete history; partial windows must abstain.
#[must_use]
pub fn flattened(&self) -> Option<Vec<f32>> {
self.is_ready().then(|| {
self.frames
.iter()
.flat_map(|frame| frame.values.iter().copied())
.collect()
})
}
}
fn derivative(current: &[[f32; 3]; 17], previous: &[[f32; 3]; 17], dt_ns: u64) -> [[f32; 3]; 17] {
let dt = dt_ns as f32 / 1_000_000_000.0;
if dt <= 0.0 {
return [[0.0; 3]; 17];
}
core::array::from_fn(|joint| {
core::array::from_fn(|axis| (current[joint][axis] - previous[joint][axis]) / dt)
})
}
@@ -0,0 +1,23 @@
//! Optional Burn residual artifact boundary.
//!
//! Model training and activation remain evidence-gated. This module enforces
//! signed/hash-addressed artifact identity and the deterministic correction cap.
mod artifact;
pub mod features;
pub mod model;
#[cfg(feature = "learned-cpu")]
pub mod runtime;
pub mod training;
pub use artifact::{
LearnedArtifact, LearnedArtifactError, LearnedArtifactManifest, LearnedResidual,
SignatureVerification,
};
/// Re-export the selected framework so downstream trainer crates use the exact
/// workspace-pinned Burn version rather than introducing a second runtime.
pub use burn_core as framework;
#[cfg(feature = "learned-cpu")]
pub use burn_ndarray as cpu_backend;
pub use burn_nn as neural_network;
@@ -0,0 +1,93 @@
//! Burn GRU residual architecture. Backend selection remains a Cargo feature.
use burn::{
module::Module,
record::{FullPrecisionSettings, NamedMpkBytesRecorder, Recorder, RecorderError},
tensor::{backend::Backend, Tensor},
};
use burn_core as burn;
use burn_nn::{Gru, GruConfig, Linear, LinearConfig, Sigmoid, Tanh};
use super::features::FEATURE_WIDTH;
pub const HISTORY_FRAMES: usize = 20;
pub const HIDDEN_WIDTH: usize = 128;
pub const JOINT_RESIDUAL_WIDTH: usize = 17 * 3;
/// Multi-head outputs for bounded residuals, log variance, contact, and abstention.
pub struct ResidualModelOutput<B: Backend> {
pub joint_residual: Tensor<B, 2>,
pub residual_log_variance: Tensor<B, 2>,
pub contact_probability: Tensor<B, 2>,
pub abstention_probability: Tensor<B, 2>,
}
/// Two-layer unidirectional GRU followed by independent auditable heads.
#[derive(Module, Debug)]
pub struct ResidualGru<B: Backend> {
gru1: Gru<B>,
gru2: Gru<B>,
residual_head: Linear<B>,
variance_head: Linear<B>,
contact_head: Linear<B>,
abstention_head: Linear<B>,
tanh: Tanh,
sigmoid: Sigmoid,
}
impl<B: Backend> ResidualGru<B> {
/// Initialize the ADR-323 architecture on an explicit backend device.
#[must_use]
pub fn init(device: &B::Device) -> Self {
Self {
gru1: GruConfig::new(FEATURE_WIDTH, HIDDEN_WIDTH, true)
.with_clip(Some(10.0))
.init(device),
gru2: GruConfig::new(HIDDEN_WIDTH, HIDDEN_WIDTH, true)
.with_clip(Some(10.0))
.init(device),
residual_head: LinearConfig::new(HIDDEN_WIDTH, JOINT_RESIDUAL_WIDTH).init(device),
variance_head: LinearConfig::new(HIDDEN_WIDTH, JOINT_RESIDUAL_WIDTH).init(device),
contact_head: LinearConfig::new(HIDDEN_WIDTH, 2).init(device),
abstention_head: LinearConfig::new(HIDDEN_WIDTH, 1).init(device),
tanh: Tanh::new(),
sigmoid: Sigmoid::new(),
}
}
/// Deserialize only bytes that already passed manifest, hash, and
/// signature-receipt verification at the artifact boundary.
pub fn from_verified_artifact(
artifact: &super::LearnedArtifact,
device: &B::Device,
) -> Result<Self, RecorderError> {
let recorder = NamedMpkBytesRecorder::<FullPrecisionSettings>::default();
let record = recorder.load(artifact.bytes().to_vec(), device)?;
Ok(Self::init(device).load_record(record))
}
/// Serialize a trainer-produced record in the exact activation format.
pub fn into_artifact_bytes(self) -> Result<Vec<u8>, RecorderError> {
NamedMpkBytesRecorder::<FullPrecisionSettings>::default().record(self.into_record(), ())
}
/// Forward `[batch, 20, FEATURE_WIDTH]` to four bounded output heads.
#[must_use]
pub fn forward(&self, input: Tensor<B, 3>) -> ResidualModelOutput<B> {
let hidden = self.gru2.forward(self.gru1.forward(input, None), None);
let [batch, sequence, width] = hidden.shape().dims();
debug_assert_eq!(sequence, HISTORY_FRAMES);
debug_assert_eq!(width, HIDDEN_WIDTH);
let last = hidden
.slice([0..batch, (sequence - 1)..sequence, 0..width])
.squeeze_dim::<2>(1);
ResidualModelOutput {
joint_residual: self.tanh.forward(self.residual_head.forward(last.clone())),
residual_log_variance: self.variance_head.forward(last.clone()),
contact_probability: self
.sigmoid
.forward(self.contact_head.forward(last.clone())),
abstention_probability: self.sigmoid.forward(self.abstention_head.forward(last)),
}
}
}
@@ -0,0 +1,88 @@
//! CPU inference activation for verified Burn artifacts.
use burn_core::tensor::{Tensor, TensorData};
use burn_ndarray::{NdArray, NdArrayDevice};
use super::{
features::FEATURE_WIDTH,
model::{ResidualGru, HISTORY_FRAMES},
LearnedArtifact, LearnedResidual,
};
type CpuBackend = NdArray<f32>;
/// Native CPU residual inference. Construction requires a verified artifact.
pub struct CpuResidualRuntime {
model: ResidualGru<CpuBackend>,
device: NdArrayDevice,
artifact_hash: [u8; 32],
}
impl CpuResidualRuntime {
/// Atomically deserialize a verified Burn record before activation.
pub fn activate(artifact: &LearnedArtifact) -> Result<Self, burn_core::record::RecorderError> {
let device = NdArrayDevice::default();
let model = ResidualGru::from_verified_artifact(artifact, &device)?;
Ok(Self {
model,
device,
artifact_hash: artifact.content_hash,
})
}
/// Active immutable artifact identity.
#[must_use]
pub const fn artifact_hash(&self) -> [u8; 32] {
self.artifact_hash
}
/// Infer one complete history. Partial, non-finite, or malformed windows
/// abstain before entering Burn.
#[must_use]
pub fn predict(
&self,
flattened_history: &[f32],
correction_cap_m: f32,
) -> Option<LearnedResidual> {
if flattened_history.len() != HISTORY_FRAMES * FEATURE_WIDTH
|| !flattened_history.iter().all(|value| value.is_finite())
{
return None;
}
let input = Tensor::<CpuBackend, 3>::from_data(
TensorData::new(
flattened_history.to_vec(),
[1, HISTORY_FRAMES, FEATURE_WIDTH],
),
&self.device,
);
let output = self.model.forward(input);
let residual = output.joint_residual.into_data().to_vec::<f32>().ok()?;
let log_variance = output
.residual_log_variance
.into_data()
.to_vec::<f32>()
.ok()?;
let contact = output
.contact_probability
.into_data()
.to_vec::<f32>()
.ok()?;
let abstention = output
.abstention_probability
.into_data()
.to_vec::<f32>()
.ok()?;
let candidate = LearnedResidual {
joints_m: core::array::from_fn(|joint| {
core::array::from_fn(|axis| residual[joint * 3 + axis] * correction_cap_m)
}),
log_variance: core::array::from_fn(|joint| {
core::array::from_fn(|axis| log_variance[joint * 3 + axis])
}),
contact_probability: [contact[0], contact[1]],
abstention_probability: abstention[0],
};
candidate.bounded(correction_cap_m)
}
}
@@ -0,0 +1,38 @@
//! Auditable supervised residual objective shared by trainers and reports.
/// Unweighted components reported for every training/evaluation batch.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct LossComponents {
pub uncertainty_weighted_mpjpe: f32,
pub bone: f32,
pub temporal_jerk: f32,
pub contact: f32,
pub uncertainty_calibration: f32,
pub intervention: f32,
}
impl LossComponents {
/// ADR-323 supervised objective. Non-finite components are rejected.
#[must_use]
pub fn total(self) -> Option<f32> {
let values = [
self.uncertainty_weighted_mpjpe,
self.bone,
self.temporal_jerk,
self.contact,
self.uncertainty_calibration,
self.intervention,
];
values
.iter()
.all(|value| value.is_finite() && *value >= 0.0)
.then_some({
self.uncertainty_weighted_mpjpe
+ 0.20 * self.bone
+ 0.10 * self.temporal_jerk
+ 0.10 * self.contact
+ 0.10 * self.uncertainty_calibration
+ 0.05 * self.intervention
})
}
}
@@ -0,0 +1,25 @@
//! Native Rust physics-constrained pose assessment (ADR-323).
//!
//! The default feature contains only deterministic kinematic code. Optional
//! dynamics and learned backends cannot override hard validation, correction,
//! provenance, or confidence invariants.
#![forbid(unsafe_code)]
#![allow(missing_docs)]
pub mod config;
pub mod constraints;
#[cfg(feature = "dynamics")]
pub mod dynamics;
pub mod engine;
pub mod error;
#[cfg(feature = "learned")]
pub mod learned;
pub mod metrics;
pub mod projector;
pub mod skeleton;
pub mod uncertainty;
pub use config::PhysicsConfig;
pub use engine::{CorrectionAuthorization, PhysicsEngine, VerifiedFrameContext};
pub use error::PhysicsError;
@@ -0,0 +1,154 @@
//! Privacy-safe metric labels and bounded in-process aggregation.
use std::{collections::BTreeMap, fmt::Write as _, sync::Mutex};
use wifi_densepose_core::{PoseRefinementV1, RefinementDisposition};
/// Fixed allowlisted scalars. It deliberately contains no joint, room, or ID data.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MetricSnapshot {
pub latency_seconds: f64,
pub solver_iterations: u8,
pub max_correction_meters: f32,
pub bone_residual: f32,
pub floor_penetration_meters: f32,
pub temporal_jerk: f32,
pub confidence_delta: f32,
}
#[derive(Default)]
struct Aggregate {
frames: BTreeMap<(String, String, String), u64>,
total_latency_seconds: f64,
observations: u64,
deadline_exceeded: u64,
invalid_inputs: u64,
track_resets: BTreeMap<String, u64>,
dropped: u64,
latest: Option<MetricSnapshot>,
}
/// Thread-safe, bounded metrics registry with no person, joint, or room labels.
#[derive(Default)]
pub struct PhysicsMetrics {
aggregate: Mutex<Aggregate>,
}
impl PhysicsMetrics {
/// Record one returned result. The label cardinality is bounded by enums.
pub fn observe(&self, result: &PoseRefinementV1, observer_confidence: f32) {
let snapshot = MetricSnapshot::from_result(result, observer_confidence);
let mut aggregate = self
.aggregate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let key = (
enum_label(result.mode),
enum_label(result.disposition),
result.reason.map_or_else(|| "none".into(), enum_label),
);
*aggregate.frames.entry(key).or_default() += 1;
aggregate.total_latency_seconds += snapshot.latency_seconds;
aggregate.observations += 1;
if result.reason == Some(wifi_densepose_core::AbstentionReason::DeadlineExceeded) {
aggregate.deadline_exceeded += 1;
}
if result.disposition == RefinementDisposition::Rejected {
aggregate.invalid_inputs += 1;
}
aggregate.latest = Some(snapshot);
}
/// Record a bounded internal track reset reason.
pub fn track_reset(&self, reason: &'static str) {
let mut aggregate = self
.aggregate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*aggregate.track_resets.entry(reason.into()).or_default() += 1;
}
/// Record discarded intermediate refinement work under backpressure.
pub fn dropped_intermediate(&self) {
let mut aggregate = self
.aggregate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
aggregate.dropped += 1;
}
/// Encode the fixed metric allowlist in Prometheus text format.
#[must_use]
pub fn encode_prometheus(&self) -> String {
let aggregate = self
.aggregate
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut output = String::with_capacity(2048);
for ((mode, disposition, reason), count) in &aggregate.frames {
writeln!(
output,
"ruview_pose_physics_frames_total{{mode=\"{mode}\",disposition=\"{disposition}\",reason=\"{reason}\"}} {count}"
)
.expect("writing to String cannot fail");
}
let average_latency = if aggregate.observations == 0 {
0.0
} else {
aggregate.total_latency_seconds / aggregate.observations as f64
};
writeln!(
output,
"ruview_pose_physics_latency_seconds{{stage=\"total\",target=\"local\"}} {average_latency}"
)
.expect("writing to String cannot fail");
if let Some(latest) = aggregate.latest {
write!(
output,
"ruview_pose_physics_solver_iterations{{engine=\"kinematic-pbd\"}} {}\nruview_pose_physics_max_correction_meters {}\nruview_pose_physics_bone_residual {}\nruview_pose_physics_floor_penetration_meters {}\nruview_pose_physics_temporal_jerk {}\nruview_pose_physics_confidence_delta {}\nruview_pose_physics_raw_refined_divergence_meters {}\n",
latest.solver_iterations,
latest.max_correction_meters,
latest.bone_residual,
latest.floor_penetration_meters,
latest.temporal_jerk,
latest.confidence_delta,
latest.max_correction_meters,
)
.expect("writing to String cannot fail");
}
for (reason, count) in &aggregate.track_resets {
writeln!(
output,
"ruview_pose_physics_track_resets_total{{reason=\"{reason}\"}} {count}"
)
.expect("writing to String cannot fail");
}
write!(
output,
"ruview_pose_physics_invalid_input_total{{reason=\"contract\"}} {}\nruview_pose_physics_deadline_exceeded_total{{stage=\"total\"}} {}\nruview_pose_physics_dropped_intermediate_total {}\n",
aggregate.invalid_inputs, aggregate.deadline_exceeded, aggregate.dropped,
)
.expect("writing to String cannot fail");
output
}
}
fn enum_label(value: impl core::fmt::Debug) -> String {
format!("{value:?}").to_ascii_lowercase()
}
impl MetricSnapshot {
/// Extract only approved aggregate scalars.
#[must_use]
pub fn from_result(result: &PoseRefinementV1, observer_confidence: f32) -> Self {
Self {
latency_seconds: result.intervention.elapsed_us as f64 / 1_000_000.0,
solver_iterations: result.intervention.solver_iterations,
max_correction_meters: result.intervention.max_joint_correction_m,
bone_residual: result.residuals.bone_m,
floor_penetration_meters: result.residuals.floor_penetration_m,
temporal_jerk: result.residuals.temporal_jerk,
confidence_delta: result.effective_confidence.get() - observer_confidence,
}
}
}
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more