mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-20 22:00:04 -05:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c249ec8cb | ||
|
|
7927839f4f | ||
|
|
a76adc3c2f | ||
|
|
aae2ed5345 | ||
|
|
3161af52da | ||
|
|
501f138360 | ||
|
|
4685618388 | ||
|
|
1d50518a70 | ||
|
|
0370d49e4a | ||
|
|
de27336fa1 | ||
|
|
73e82313ac |
No files matched your search
@@ -1 +1 @@
|
||||
{"sessionId":"d80c93c2-51b7-42e8-a0fc-dc47cff1200f","pid":45748,"acquiredAt":1779668018388}
|
||||
{"sessionId":"905385c4-b13f-5091-96df-5752fb109cf5","pid":509,"procStart":"527","acquiredAt":1786922977672}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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/
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
@@ -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 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{"ankle_penetration_m":0.04,"evidence":"SYNTHETIC/L0","expected_raw_hash":"98caf45c249d584d52ede84e2d76bd8c4bf225d426a4ef1651a5ddb178258b88","expected_result_hash":"8ef2ded432c938055a5ebc86eadff811216884093853f5c740c7aff7708b9bef","sequence":1}
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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 }));
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
node "$(dirname "$0")/pose-physics/verify-golden.mjs" "$@"
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
node "$(dirname "$0")/pose-physics/verify-splits.mjs" "$@"
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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(¤t, 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
Reference in new issue
Block a user