Compare commits

...

13 Commits

Author SHA1 Message Date
rUv 04028acd54 feat: add multi-actor PoseCode scene crate 2026-07-15 09:53:38 -04:00
ruv 82c1b8fdf8 chore: bump wifi-densepose-signal 0.3.5 for crates.io (#1334)
Published 0.3.4 predates HardwareNormalizer::resample_to_canonical and
MultistaticConfig::for_tdm_schedule, which the sensing-server binary
uses — its publish verify fails against the registry 0.3.4. The in-repo
version had not been bumped since those APIs landed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-14 13:31:42 -04:00
ruv 9dceb976c7 chore(publish): version rufield deps + bump worldgraph/rufield submodules (#1334)
- wifi-densepose-rufield: add version="0.1.0" to the four rufield path
  deps — rufield-core/-provenance/-privacy/-fusion are now published to
  crates.io, making this crate (and wifi-densepose-sensing-server 0.3.4)
  publishable
- v2/crates/worldgraph -> 4441bc0: wifi-densepose-worldgraph 0.3.2
  published (adds prune_semantic_states; unblocks wifi-densepose-engine
  0.3.1 publish)
- vendor/rufield -> f3c1492: breaks the fusion<->adapters circular
  dev-dependency (path-only dev-dep, stripped at publish)

Closes the crates.io publish blockers in #1334.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-14 13:18:43 -04:00
rUv 7b244bdc8c Merge pull request #1332 from ruvnet/integrate/pr-1311-1313
Integrate community fixes: DevKitC-1 overlay, EngineBridge guard config, pose-WS bearer auth (#1308 #1309 #1310)
2026-07-14 12:48:11 -04:00
github-actions[bot] 90667d0f1d chore: update vendor submodules to latest upstream (#1331)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-14 12:15:39 -04:00
ruv 8c1d3d772a chore: bump wifi-densepose-engine 0.3.1, wifi-densepose-sensing-server 0.3.4
engine 0.3.1: additive StreamingEngine::set_multistatic_config (#1312)
sensing-server 0.3.4: bearer-auth pose-WS exemption + EngineBridge guard
config threading (#1312, #1313); no public lib API change (engine_bridge
is binary-internal)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-14 12:12:48 -04:00
ruv 83f549d308 fix: post-review fixes for PRs #1311/#1312/#1313 + CHANGELOG
- sdkconfig.defaults.devkitc: header build command idf v5.2 -> v5.4
  (source needs esp_driver_uart, IDF >=5.3 — same reason the PR fixed
  the README refs)
- engine/lib.rs: separate doc comments — set_room_adapter's ADR-150
  adapter/witness doc had merged into set_multistatic_config's doc
- ui: apply stored bearer token at api.service.js module load instead
  of QuickSettings.init — services + dashboard tab dispatch their first
  /api/v1/* requests before initializeEnhancements() runs, so the first
  requests on every load went out without the Authorization header
- CHANGELOG: Unreleased entries for #1308/#1309/#1310

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-14 11:08:37 -04:00
ruv d59ca00baa Merge PR #1313: exempt /api/v1/stream/pose WS from bearer auth + UI token field 2026-07-13 13:50:36 -04:00
ruv da81eab714 Merge PR #1312: wire WDP_GUARD_INTERVAL_US/WDP_TDM_* into EngineBridge 2026-07-13 13:50:35 -04:00
ruv d840ced2db Merge PR #1311: display-less DevKitC-1 build overlay + IDF v5.4 doc refs 2026-07-13 13:50:35 -04:00
erichkusuki 2ddb6a7b02 fix(sensing-server): exempt /api/v1/stream/pose WS from bearer auth; add UI token field
Browsers cannot attach an Authorization header to a WebSocket upgrade,
so with RUVIEW_API_TOKEN set the Live Demo pose stream at
/api/v1/stream/pose always failed with 401 — the same reason
/ws/sensing is already exempted (see bearer_auth module docs). Adds a
narrow EXEMPT_PATHS list plus a regression test that the exemption
does not leak to other /api/v1/* paths. Query-string tokens remain
rejected (CWE-598 test untouched).

Also adds an 'API Access' bearer-token field to the QuickSettings
panel: ui/services/api.service.js had setAuthToken() but nothing ever
called it, so enabling RUVIEW_API_TOKEN broke every /api/v1/* call
from the bundled dashboard. The token is stored in localStorage and
applied before the first request.

Fixes #1310

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-11 13:32:02 +02:00
erichkusuki b5ce60081b fix(sensing-server): wire WDP_GUARD_INTERVAL_US/WDP_TDM_* into EngineBridge
EngineBridge::new built its own StreamingEngine, whose internal
MultistaticFuser was hardcoded to MultistaticConfig::default() (60 ms
guard) — the #1031/#1049 env overrides only reached the sibling
multistatic_fuser field on AppState, so governed trust cycles failed
against the default guard no matter what the deployment configured.

- wifi-densepose-engine: add StreamingEngine::set_multistatic_config()
- engine_bridge: EngineBridge::new takes Option<MultistaticConfig>
- main.rs: thread the same env-derived config into both fusion paths

Verified on a 2-node ESP32-S3 deployment: the failure message now
reflects the configured guard, and with healthy nodes governed cycles
ran 90 s with zero fusion errors.

Fixes #1309

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-11 13:32:01 +02:00
erichkusuki 0a282d0870 fix(firmware): add display-less DevKitC-1 build overlay; update stale IDF v5.2 refs to v5.4
The ADR-045 display probe false-positives on display-less
ESP32-S3-DevKitC-1 boards (SH8601 init 'succeeds' against floating
pins), so main.c skips the RuView#893 MGMT+DATA promiscuous upgrade
and CSI yield collapses to 0 pps. sdkconfig.defaults.devkitc compiles
display support out, making has_display constant-false so the #893
upgrade always applies. Hardware-verified on 2x DevKitC-1-N16R8:
0 pps -> steady 40-45 pps.

Also updates the Quick Start build commands and badge from
espressif/idf:v5.2 to v5.4 — current source uses esp_driver_uart
(IDF v5.3+) and no longer builds under v5.2; CI already uses v5.4.

Addresses #1308

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-11 13:32:01 +02:00
36 changed files with 2053 additions and 49 deletions
+6
View File
@@ -7,10 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Multi-actor PoseCode scene crate (`wifi-densepose-posecode`, ADR-266).** Adds a deterministic Rust scene model for multiple persistent RuView actors, synchronized poses, inter-person contacts, confidence and observation provenance. Includes a bounded line parser, canonical serializer, direct `PoseTrack` adapter, confidence-aware phase segmentation, provenance-sensitive range validation, 14 feature-enabled unit tests and Criterion parser/serializer benchmarks. Raw 20 Hz observations remain separate from semantic phases; observed range anomalies are warnings rather than medical safety claims. MEASURED on the implementation container: two-actor parse 3.40 µs, serialization 3.29 µs.
### Changed
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
### Fixed
- **EngineBridge now honors `WDP_GUARD_INTERVAL_US`/`WDP_SOFT_GUARD_US`/`WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US`** (#1309, PR #1312, @erichkusuki). The governed trust path previously built its multistatic fuser from a hardcoded `MultistaticConfig::default()` (60 ms guard), so multi-node deployments with WiFi/ESP-NOW time sync (10150 ms drift) failed every governed cycle regardless of configuration — while the startup log claimed the override took effect. New `StreamingEngine::set_multistatic_config()`; `EngineBridge::new()` takes an `Option<MultistaticConfig>` threaded from the same env-derived config as `AppState.multistatic_fuser`. Hardware-verified on a live 2-node ESP32-S3 setup (90 s window, 0 fusion errors; previously every cycle failed).
- **`/api/v1/stream/pose` WebSocket reachable with `RUVIEW_API_TOKEN` set + dashboard bearer-token field** (#1310, PR #1313, @erichkusuki). Browsers cannot attach an `Authorization` header to a WS upgrade, so the Live Demo pose stream always failed when auth was on; the path is now on a narrow exact-match exemption list (mirrors `/ws/sensing`), with a regression test pinning that the exemption doesn't leak to other `/api/v1/*` paths. The QuickSettings panel gains an "API Access" field storing the bearer token in `localStorage`; the token is applied at `api.service.js` module load so the very first request carries it.
- **Display-less DevKitC-1 boards: `sdkconfig.defaults.devkitc` build overlay** (#1308, PR #1311, @erichkusuki). The ADR-045 runtime display probe false-positives on stock ESP32-S3-DevKitC-1 (floating QSPI pins), which silently skipped the RuView#893 MGMT+DATA CSI upgrade and collapsed CSI yield to 0 pps. The overlay compiles display support out (`has_display` constant-false). Also fixes stale `espressif/idf:v5.2` README references to v5.4 (source requires `esp_driver_uart`, IDF ≥5.3). Hardware-verified on 2× DevKitC-1-N16R8 (0 → 4045 pps).
- **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn``ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `<jobsDir>/<id>.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20.
- **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed.
- **`csi_fps_ema` reported the CSI frame rate 40840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`.
+2
View File
@@ -14,6 +14,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware; `ieee80211bf/` 802.11bf forward-compat protocol model (ADR-153) |
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
| `wifi-densepose-posecode` | ADR-266 multi-actor semantic motion scenes, PoseTrack adapter, bounded parser, confidence-aware phase segmentation |
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) — `calibrate`/`calibrate-serve`/`enroll`/`train-room`/`room-watch` + MAT (MAT gated behind the `mat` feature; build `--no-default-features` for the aarch64/appliance calibration binary) |
| `wifi-densepose-calibration` | ADR-151 per-room calibration & specialist training — `baseline → enroll → extract → train` → bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly) + multistatic fusion; pure Rust, edge-deployable |
@@ -81,6 +82,7 @@ All 5 ruvector crates integrated in workspace:
- ADR-263: `@ruvnet/ruview` npm harness deep review + optimization strategy (Proposed)
- ADR-264: `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` deep review + optimization strategy (Proposed)
- ADR-265: RuView npm distribution strategy — CI gate, provenance, version single-sourcing (Proposed)
- ADR-266: Multi-actor PoseCode scenes from persistent RuView tracks (Accepted)
### Supported Hardware
+2 -1
View File
@@ -617,7 +617,8 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
| [Architecture Decisions](docs/adr/README.md) | 182 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Architecture Decisions](docs/adr/README.md) | 183 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Multi-actor PoseCode](v2/crates/wifi-densepose-posecode/) | Rust scene protocol converting persistent RuView pose tracks into confidence-scored actors, synchronized motion phases, contacts and deterministic text. [ADR-266](docs/adr/ADR-266-multi-actor-posecode-scenes.md). |
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
| [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
@@ -0,0 +1,141 @@
# ADR-266: Multi-Actor PoseCode Scenes
| Field | Value |
|-------|-------|
| Status | Accepted, crate implementation complete |
| Date | 2026-07-15 |
| Deciders | ruv |
| Crate | `wifi-densepose-posecode` |
| Related | ADR-029, ADR-037, ADR-082, ADR-101, ADR-170 |
## Context
RuView already maintains multiple persistent `PoseTrack` aggregates. Each track
contains 17 COCO keypoints, a lifecycle, an AETHER identity embedding and a
stable `TrackId`. The missing layer is a compact, agent-readable representation
of what those tracked people do together over time.
PoseCode 0.1 provides a useful vocabulary for one actor, sequential movement
phases, semantic joint actions, contacts and browser rendering. Its current
protocol does not represent multiple actors, shared world coordinates,
inter-person contact, observation confidence or track provenance.
Encoding every 20 Hz RuView frame as text would also be incorrect. It would
produce 1,200 steps per person per minute, discard observation uncertainty and
couple the sensing hot path to a presentation language.
## Decision
Create the leaf crate `wifi-densepose-posecode` and define a backwards-minded
0.2 scene extension with these invariants:
1. A `Scene` owns named actors and synchronized phases.
2. Each actor can carry the originating RuView `TrackId` and confidence.
3. Every joint target and inter-person contact carries confidence.
4. Actor positions and travel use one room-calibrated three-dimensional frame.
5. Raw `SceneFrame` observations remain separate from compact semantic phases.
6. `PhaseSegmenter` emits a phase on actor membership change, motion settling
or a bounded maximum duration.
7. Serialization is canonical and deterministic.
8. The parser has hard document, line, actor, phase and target limits.
9. General range-of-motion violations are errors for authored scenes but only
warnings for observed WiFi scenes. RF pose estimates are not medical joint
measurements and must not create false safety claims.
The text form is:
```posecode
posecode scene "Assisted squat"
source observed_wifi_csi
actor patient:
rig humanoid
pose start = standing
track 7
confidence 0.82
position 0 0 0
actor therapist:
rig humanoid
pose start = standing
track 12
confidence 0.76
position 1.2 0 0
step "Lower" 1.5s flow:
patient.knee_left: flex 95 0.8
therapist.shoulder_left: flex 30 0.7
contact therapist.hand_left patient.shoulder_right 0.7
repeat 1
```
## Architecture
`RuViewAdapter` consumes references to existing `PoseTrack` values. It does not
duplicate assignment or identity tracking. It calculates elbows and knees from
three-point interior angles, estimates hip and shoulder sagittal movement in a
calibrated coordinate frame and maps the hip midpoint to actor position.
The adapter is exposed by the crate's `ruview` feature so the protocol, parser
and segmenter remain usable without pulling the full signal and RuVector graph.
The adapter uses keypoint confidence when available. Current trackers that have
not populated that field receive an explicit configurable fallback confidence,
degraded by track staleness. Non-finite coordinates and terminated tracks fail
closed.
`PhaseSegmenter` consumes ordered frames. High-rate frames remain available for
live rendering and evidence storage while the resulting scene records only
meaningful synchronized targets. This preserves both fidelity and readability.
## Security and Resource Bounds
The public parser accepts at most 1 MiB per document and 4,096 bytes per line.
The scene validator defaults to 32 actors, 10,000 phases and 64 joint targets
per actor per phase. All coordinates, angles and confidence values must be
finite. References to undeclared actors are rejected. Durations, repeats and
timeline addition are bounded.
No network, filesystem, model or renderer capability is present in the crate.
It is a deterministic transformation leaf over owned data.
## Consequences
RuView can now produce privacy-preserving multi-person replays, structured fall
and interaction evidence, exercise comparison inputs and agent-readable motion
records without video.
The crate does not solve RF source separation. Its multi-person accuracy is
bounded by the upstream pose model and `PoseTracker`. Contact inference is also
not fabricated; contacts must be observed by a future calibrated classifier or
authored explicitly.
The current COCO skeleton has no toe keypoints, so ankle rotation and precise
foot contact cannot be inferred honestly. Those targets are omitted rather
than guessed.
## Acceptance Criteria
1. A scene containing two actors, simultaneous targets and a cross-actor
contact parses and serializes deterministically.
2. Unknown actors, non-finite values, reversed timestamps and unbounded inputs
fail closed.
3. One RuView track converts to finite semantic targets with the same TrackId.
4. Observed out-of-range angles produce warnings, not medical errors.
5. Phase count is smaller than input frame count for a normal motion sequence.
6. Focused crate tests and the complete workspace test suite pass before merge.
## Measured Results
On the implementation container with Rust 1.88, Criterion measured the canonical
two-actor scene parser at 3.40 microseconds and serializer at 3.29 microseconds
per operation. The protocol-only dependency surface is three runtime crates:
`serde`, `serde_json` and `thiserror`.
The protocol-only configuration passes 13 unit tests, documentation tests,
Rustfmt and Clippy with warnings denied. The `ruview` feature passes 14 unit
tests, including native `PoseTrack` conversion, under the repository's RuVector
AVX512-capable build configuration.
## References
1. PoseCode repository and protocol, MIT licensed:
<https://github.com/posecode-dev/posecode>
2. RuView multi-person pose decision: ADR-037.
3. RuView persistent pose tracking: ADR-029 and `pose_tracker.rs`.
+2 -1
View File
@@ -1,6 +1,6 @@
# Architecture Decision Records
This folder contains 182 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
This folder contains 183 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
## Why ADRs?
@@ -123,6 +123,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-263](ADR-263-ruview-npm-harness-deep-review.md) | `@ruvnet/ruview` npm harness — deep review + optimization strategy | Proposed |
| [ADR-264](ADR-264-rvagent-mcp-and-cli-npm-deep-review.md) | `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` — deep review + optimization strategy | Proposed |
| [ADR-265](ADR-265-ruview-npm-distribution-strategy.md) | RuView npm distribution strategy — CI gate, provenance, version single-sourcing, namespace | Proposed |
| [ADR-266](ADR-266-multi-actor-posecode-scenes.md) | Multi-actor PoseCode scenes from persistent RuView tracks | Accepted |
---
+12 -4
View File
@@ -4,7 +4,7 @@
This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (production) or ESP32-C6 (research target — Wi-Fi 6 / 802.15.4 / TWT / LP-core hibernation, see [ADR-110](../../docs/adr/ADR-110-esp32-c6-firmware-extension.md)) and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the [WiFi-DensePose](../../README.md) project.
[![ESP-IDF v5.2](https://img.shields.io/badge/ESP--IDF-v5.2-blue.svg)](https://docs.espressif.com/projects/esp-idf/en/v5.2/)
[![ESP-IDF v5.4](https://img.shields.io/badge/ESP--IDF-v5.4-blue.svg)](https://docs.espressif.com/projects/esp-idf/en/v5.4/)
[![Target: ESP32-S3 / ESP32-C6](https://img.shields.io/badge/target-ESP32--S3%20%7C%20ESP32--C6-purple.svg)](https://www.espressif.com/en/products/socs/esp32-s3)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-green.svg)](../../LICENSE)
[![Binary: ~943 KB](https://img.shields.io/badge/binary-~943%20KB-orange.svg)](#memory-budget)
@@ -48,10 +48,18 @@ with `--flash_size 4MB`.
# From the repository root:
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \
espressif/idf:v5.4 bash -c \
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
```
> **Display-less boards (ESP32-S3-DevKitC-1 and similar):** build with the
> `sdkconfig.defaults.devkitc` overlay instead — the default build compiles
> display support in, and the runtime panel probe false-positives on boards
> with no panel, which disables the RuView#893 MGMT+DATA CSI upgrade and
> collapses CSI yield to 0 pps. See the header of
> [`sdkconfig.defaults.devkitc`](sdkconfig.defaults.devkitc) for the exact
> build command.
### 2. Flash
Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB):
@@ -250,7 +258,7 @@ Offset Size Field
# From the repository root:
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \
espressif/idf:v5.4 bash -c \
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
```
@@ -268,7 +276,7 @@ To change Kconfig settings before building:
```bash
MSYS_NO_PATHCONV=1 docker run --rm -it \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \
espressif/idf:v5.4 bash -c \
"idf.py set-target esp32s3 && idf.py menuconfig"
```
@@ -0,0 +1,16 @@
# DevKitC-1 (display-less) production overlay.
#
# The stock ESP32-S3-DevKitC-1 has no AMOLED panel, but the ADR-045 runtime
# probe false-positives on it: with no TCA9554 and floating QSPI pins, the
# SH8601 init sequence reports success, display_is_active() returns true, and
# main.c skips the RuView#893 MGMT+DATA promiscuous upgrade — CSI yield
# collapses to 0 pps (the exact symptom #893 fixed). Compiling display support
# out makes has_display constant-false so the upgrade always applies.
#
# Build (from repo root, per README "Docker — the only reliable method"):
# MSYS_NO_PATHCONV=1 docker run --rm \
# -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
# espressif/idf:v5.4 bash -c \
# "rm -rf build sdkconfig && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' set-target esp32s3 && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' build"
# CONFIG_DISPLAY_ENABLE is not set
+14 -1
View File
@@ -147,4 +147,17 @@ export class ApiService {
}
// Create singleton instance
export const apiService = new ApiService();
export const apiService = new ApiService();
// Storage key shared with the QuickSettings "API Access" panel.
export const API_TOKEN_STORAGE_KEY = 'ruview-api-token';
// Apply a previously-saved bearer token at module load — before app init
// dispatches its first request — so a configured RUVIEW_API_TOKEN works from
// the very first /api/v1/* call. The server only ever checks the
// `Authorization: Bearer` header (see bearer_auth.rs) — this intentionally
// never puts the token in a URL query string.
try {
const storedToken = localStorage.getItem(API_TOKEN_STORAGE_KEY);
if (storedToken) apiService.setAuthToken(storedToken);
} catch { /* storage unavailable (private browsing etc.) */ }
+14
View File
@@ -4088,6 +4088,20 @@ a:focus-visible,
color: #fff;
}
.qs-text-input {
padding: var(--space-6) var(--space-8);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-secondary);
color: var(--color-text);
font-size: var(--font-size-sm);
}
.qs-text-input:focus {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
/* --- Screenshot Flash --- */
.screenshot-flash {
+44
View File
@@ -1,6 +1,8 @@
// Quick Settings Panel - Centralized configuration for all UI features
// Accessible via gear icon in header
import { apiService, API_TOKEN_STORAGE_KEY } from '../services/api.service.js';
export class QuickSettings {
constructor(app) {
this.app = app;
@@ -9,6 +11,8 @@ export class QuickSettings {
this.isOpen = false;
}
// A stored token is applied at api.service.js module load (before any
// request fires) — this panel only saves/clears it.
init() {
this.createButton();
this.createPanel();
@@ -70,6 +74,18 @@ export class QuickSettings {
<span class="qs-switch"></span>
</label>
</div>
<div class="qs-section">
<div class="qs-section-title">API Access</div>
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
<span>Bearer token (set only if the server enforces RUVIEW_API_TOKEN)</span>
<input type="password" id="qs-api-token" class="qs-text-input" placeholder="Paste token..." autocomplete="off" style="width: 100%; box-sizing: border-box;">
<div style="display: flex; gap: 8px;">
<button class="qs-btn" id="qs-api-token-save">Save & Apply</button>
<button class="qs-btn-danger" id="qs-api-token-clear">Clear</button>
</div>
<span id="qs-api-token-status" style="font-size: 0.85em; opacity: 0.75;"></span>
</div>
</div>
<div class="qs-section">
<div class="qs-section-title">Data</div>
<div class="qs-row">
@@ -112,6 +128,30 @@ export class QuickSettings {
}
});
this.panel.querySelector('#qs-api-token-save').addEventListener('click', () => {
const input = this.panel.querySelector('#qs-api-token');
const status = this.panel.querySelector('#qs-api-token-status');
const token = input.value.trim();
if (!token) {
status.textContent = 'Enter a token first, or use Clear to remove one.';
return;
}
try { localStorage.setItem(API_TOKEN_STORAGE_KEY, token); } catch { /* noop */ }
apiService.setAuthToken(token);
status.textContent = 'Token saved and applied. Reloading...';
setTimeout(() => window.location.reload(), 600);
});
this.panel.querySelector('#qs-api-token-clear').addEventListener('click', () => {
const input = this.panel.querySelector('#qs-api-token');
const status = this.panel.querySelector('#qs-api-token-status');
try { localStorage.removeItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
apiService.setAuthToken(null);
input.value = '';
status.textContent = 'Token cleared. Reloading...';
setTimeout(() => window.location.reload(), 600);
});
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
try {
localStorage.clear();
@@ -154,6 +194,10 @@ export class QuickSettings {
if (this.getSetting('compact')) {
document.body.classList.add('compact-mode');
}
const status = this.panel.querySelector('#qs-api-token-status');
let hasToken = false;
try { hasToken = !!localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
if (status) status.textContent = hasToken ? 'A token is currently set.' : 'No token set (auth is off or unnecessary).';
}
prefersReducedMotion() {
Generated
+15 -16
View File
@@ -7533,7 +7533,6 @@ dependencies = [
"tokio-test",
"toml 0.8.23",
"tracing",
"wifi-densepose-core",
]
[[package]]
@@ -10961,7 +10960,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-engine"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"blake3",
"criterion",
@@ -11092,6 +11091,17 @@ dependencies = [
"tower-http",
]
[[package]]
name = "wifi-densepose-posecode"
version = "0.3.0"
dependencies = [
"criterion",
"serde",
"serde_json",
"thiserror 2.0.18",
"wifi-densepose-signal",
]
[[package]]
name = "wifi-densepose-rufield"
version = "0.3.0"
@@ -11126,7 +11136,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-sensing-server"
version = "0.3.3"
version = "0.3.4"
dependencies = [
"axum",
"chrono",
@@ -11161,7 +11171,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-signal"
version = "0.3.4"
version = "0.3.5"
dependencies = [
"chrono",
"criterion",
@@ -11268,7 +11278,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-worldgraph"
version = "0.3.1"
version = "0.3.2"
dependencies = [
"petgraph",
"serde",
@@ -11277,17 +11287,6 @@ dependencies = [
"wifi-densepose-geo",
]
[[package]]
name = "wifi-densepose-worldmodel"
version = "0.3.1"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"wifi-densepose-worldgraph",
]
[[package]]
name = "winapi"
version = "0.3.9"
+4
View File
@@ -46,6 +46,9 @@ members = [
# PR #491 slot heuristic with a Candle network + Stoer-Wagner fusion.
# Motivated by #499 ghost-skeleton reports.
"crates/cog-person-count",
# ADR-266: Multi-actor PoseCode scene protocol. Converts persistent
# RuvSense PoseTracks into confidence-scored, deterministic motion scenes.
"crates/wifi-densepose-posecode",
# ADR-116: Home Assistant + Matter Cognitum Seed cog. Wraps the
# ADR-115 MQTT publisher as a Seed-installable artifact with
# mDNS, embedded broker, RuVector thresholds, Ed25519 witness.
@@ -215,6 +218,7 @@ wifi-densepose-hardware = { version = "0.3.0", path = "crates/wifi-densepose-har
wifi-densepose-wasm = { version = "0.3.0", path = "crates/wifi-densepose-wasm" }
wifi-densepose-mat = { version = "0.3.0", path = "crates/wifi-densepose-mat" }
wifi-densepose-ruvector = { version = "0.3.0", path = "crates/wifi-densepose-ruvector" }
wifi-densepose-posecode = { version = "0.3.0", path = "crates/wifi-densepose-posecode" }
wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/worldgraph/wifi-densepose-worldmodel" }
[profile.release]
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "wifi-densepose-engine"
description = "RuView streaming-engine integration layer — composes the ADR-135..146 building blocks into one trust-traceable pipeline cycle"
version = "0.3.0"
version = "0.3.1"
edition.workspace = true
authors.workspace = true
license.workspace = true
@@ -226,6 +226,19 @@ impl StreamingEngine {
}
}
/// Override the multistatic fuser's timestamp guard interval (#1049/#1057).
/// Without this, `StreamingEngine::new` always builds
/// `MultistaticFuser::with_config(MultistaticConfig::default())` — a
/// hardcoded 60 ms hard guard that ignores whatever schedule/override the
/// caller derived from `WDP_TDM_SLOTS`/`WDP_GUARD_INTERVAL_US`, so
/// WiFi/ESP-NOW-synced multi-node deployments spuriously fail governed
/// trust cycles even after widening the guard elsewhere.
///
/// Rebuilds the fuser, so call before any frames are processed.
pub fn set_multistatic_config(&mut self, cfg: MultistaticConfig) {
self.fuser = MultistaticFuser::with_config(cfg);
}
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
/// cycle on, the adapter id is part of provenance `model_version` — and
/// therefore of the witness — so the exact weights shaping inference are
@@ -0,0 +1,28 @@
[package]
name = "wifi-densepose-posecode"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Multi-actor semantic motion scenes for RuView PoseTrack streams"
documentation = "https://docs.rs/wifi-densepose-posecode"
keywords = ["wifi", "pose", "motion", "posecode", "multi-person"]
categories = ["science", "computer-vision", "parser-implementations"]
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
wifi-densepose-signal = { version = "0.3.5", path = "../wifi-densepose-signal", default-features = false, optional = true }
[dev-dependencies]
criterion.workspace = true
[features]
default = []
ruview = ["dep:wifi-densepose-signal"]
[[bench]]
name = "posecode_bench"
harness = false
@@ -0,0 +1,37 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use wifi_densepose_posecode::{parse_posecode, to_posecode};
const TWO_ACTORS: &str = r#"posecode scene "crossing"
source observed_wifi_csi
actor person_1:
rig humanoid
pose start = standing
track 1
confidence 0.8
position 0 0 0
actor person_2:
rig humanoid
pose start = standing
track 2
confidence 0.8
position 1 0 0
step "Move" 0.05s linear:
person_1.knee_left: flex 30 0.8
person_1.hip_left: flex 20 0.8
person_2.knee_right: flex 35 0.8
person_2.hip_right: flex 25 0.8
repeat 1
"#;
fn benchmarks(c: &mut Criterion) {
c.bench_function("parse_two_actor_scene", |b| {
b.iter(|| parse_posecode(black_box(TWO_ACTORS)).unwrap())
});
let scene = parse_posecode(TWO_ACTORS).unwrap();
c.bench_function("serialize_two_actor_scene", |b| {
b.iter(|| to_posecode(black_box(&scene)))
});
}
criterion_group!(benches, benchmarks);
criterion_main!(benches);
@@ -0,0 +1,443 @@
//! Conversion from tracked COCO-17 skeletons to semantic joint observations.
use crate::error::{Error, Result};
use crate::model::{ActorObservation, JointTarget, SceneFrame, Vec3};
const NUM_KEYPOINTS: usize = 17;
const LEFT_SHOULDER: usize = 5;
const RIGHT_SHOULDER: usize = 6;
const LEFT_ELBOW: usize = 7;
const RIGHT_ELBOW: usize = 8;
const LEFT_WRIST: usize = 9;
const RIGHT_WRIST: usize = 10;
const LEFT_HIP: usize = 11;
const RIGHT_HIP: usize = 12;
const LEFT_KNEE: usize = 13;
const RIGHT_KNEE: usize = 14;
const LEFT_ANKLE: usize = 15;
const RIGHT_ANKLE: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrackState {
Tentative,
Active,
Lost,
Terminated,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct KeypointObservation {
pub position: Vec3,
pub confidence: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TrackObservation {
pub id: u64,
pub state: TrackState,
pub keypoints: [KeypointObservation; NUM_KEYPOINTS],
pub time_since_update: u64,
}
#[cfg(feature = "ruview")]
impl From<&wifi_densepose_signal::ruvsense::PoseTrack> for TrackObservation {
fn from(track: &wifi_densepose_signal::ruvsense::PoseTrack) -> Self {
use wifi_densepose_signal::ruvsense::TrackLifecycleState;
Self {
id: track.id.0,
state: match track.lifecycle {
TrackLifecycleState::Tentative => TrackState::Tentative,
TrackLifecycleState::Active => TrackState::Active,
TrackLifecycleState::Lost => TrackState::Lost,
TrackLifecycleState::Terminated => TrackState::Terminated,
},
keypoints: std::array::from_fn(|i| {
let point = track.keypoints[i].position();
KeypointObservation {
position: Vec3::new(point[0], point[1], point[2]),
confidence: track.keypoints[i].confidence,
}
}),
time_since_update: track.time_since_update,
}
}
}
#[derive(Debug, Clone)]
pub struct AdapterConfig {
/// Coordinate system up vector after room calibration.
pub world_up: Vec3,
/// Coordinate system forward vector after room calibration.
pub world_forward: Vec3,
/// Ignore tracks below this aggregate confidence.
pub min_confidence: f32,
/// Confidence used until an inference backend populates keypoint confidence.
pub unscored_confidence: f32,
/// Emit lost tracks as predicted observations.
pub include_lost: bool,
}
impl Default for AdapterConfig {
fn default() -> Self {
Self {
world_up: Vec3::new(0.0, 1.0, 0.0),
world_forward: Vec3::new(0.0, 0.0, 1.0),
min_confidence: 0.2,
unscored_confidence: 0.5,
include_lost: false,
}
}
}
#[derive(Debug, Clone)]
pub struct RuViewAdapter {
config: AdapterConfig,
}
impl RuViewAdapter {
pub fn new(config: AdapterConfig) -> Result<Self> {
if !config.world_up.is_finite() || !config.world_forward.is_finite() {
return Err(Error::Observation("coordinate axes must be finite".into()));
}
if !(0.0..=1.0).contains(&config.min_confidence)
|| !(0.0..=1.0).contains(&config.unscored_confidence)
{
return Err(Error::Observation("confidence must be in [0, 1]".into()));
}
if norm(config.world_up) < 1e-6 || norm(config.world_forward) < 1e-6 {
return Err(Error::Observation(
"coordinate axes must be non-zero".into(),
));
}
let alignment = dot(normalize(config.world_up), normalize(config.world_forward)).abs();
if alignment > 0.98 {
return Err(Error::Observation(
"world up and forward axes must not be parallel".into(),
));
}
Ok(Self { config })
}
/// Convert all visible tracks into one synchronized observation frame.
pub fn frame(&self, timestamp_ms: u64, tracks: &[TrackObservation]) -> SceneFrame {
let actors = tracks
.iter()
.filter_map(|track| self.actor(track))
.collect();
SceneFrame {
timestamp_ms,
actors,
}
}
/// Direct bridge for the RuView tracker aggregate. The feature is optional
/// so protocol-only consumers do not pull the signal and RuVector graph.
#[cfg(feature = "ruview")]
pub fn frame_from_pose_tracks(
&self,
timestamp_ms: u64,
tracks: &[&wifi_densepose_signal::ruvsense::PoseTrack],
) -> SceneFrame {
let observations: Vec<TrackObservation> =
tracks.iter().map(|track| (*track).into()).collect();
self.frame(timestamp_ms, &observations)
}
pub fn actor(&self, track: &TrackObservation) -> Option<ActorObservation> {
if track.state == TrackState::Terminated
|| (!self.config.include_lost && track.state == TrackState::Lost)
{
return None;
}
let confidence = track_confidence(track, self.config.unscored_confidence);
if confidence < self.config.min_confidence {
return None;
}
let p = |index: usize| track.keypoints[index].position;
if track.keypoints.iter().any(|kp| !kp.position.is_finite()) {
return None;
}
let left_hip = p(LEFT_HIP);
let right_hip = p(RIGHT_HIP);
let position = midpoint(left_hip, right_hip);
let up = normalize(self.config.world_up);
let forward = normalize(sub(
self.config.world_forward,
scale(up, dot(self.config.world_forward, up)),
));
let mut joints = Vec::with_capacity(10);
add_flexion(
&mut joints,
"elbow_left",
p(LEFT_SHOULDER),
p(LEFT_ELBOW),
p(LEFT_WRIST),
joint_confidence(track, &[LEFT_SHOULDER, LEFT_ELBOW, LEFT_WRIST], confidence),
);
add_flexion(
&mut joints,
"elbow_right",
p(RIGHT_SHOULDER),
p(RIGHT_ELBOW),
p(RIGHT_WRIST),
joint_confidence(
track,
&[RIGHT_SHOULDER, RIGHT_ELBOW, RIGHT_WRIST],
confidence,
),
);
add_flexion(
&mut joints,
"knee_left",
left_hip,
p(LEFT_KNEE),
p(LEFT_ANKLE),
joint_confidence(track, &[LEFT_HIP, LEFT_KNEE, LEFT_ANKLE], confidence),
);
add_flexion(
&mut joints,
"knee_right",
right_hip,
p(RIGHT_KNEE),
p(RIGHT_ANKLE),
joint_confidence(track, &[RIGHT_HIP, RIGHT_KNEE, RIGHT_ANKLE], confidence),
);
add_signed_flex(
&mut joints,
"hip_left",
sub(p(LEFT_KNEE), left_hip),
up,
forward,
joint_confidence(track, &[LEFT_HIP, LEFT_KNEE], confidence),
);
add_signed_flex(
&mut joints,
"hip_right",
sub(p(RIGHT_KNEE), right_hip),
up,
forward,
joint_confidence(track, &[RIGHT_HIP, RIGHT_KNEE], confidence),
);
add_signed_flex(
&mut joints,
"shoulder_left",
sub(p(LEFT_ELBOW), p(LEFT_SHOULDER)),
up,
forward,
joint_confidence(track, &[LEFT_SHOULDER, LEFT_ELBOW], confidence),
);
add_signed_flex(
&mut joints,
"shoulder_right",
sub(p(RIGHT_ELBOW), p(RIGHT_SHOULDER)),
up,
forward,
joint_confidence(track, &[RIGHT_SHOULDER, RIGHT_ELBOW], confidence),
);
let shoulder_mid = midpoint(p(LEFT_SHOULDER), p(RIGHT_SHOULDER));
let torso = sub(shoulder_mid, position);
let hinge = angle_deg(torso, up).clamp(0.0, 180.0);
joints.push(JointTarget {
joint: "pelvis".into(),
action: "hinge".into(),
degrees: hinge,
confidence: joint_confidence(
track,
&[LEFT_SHOULDER, RIGHT_SHOULDER, LEFT_HIP, RIGHT_HIP],
confidence,
),
});
Some(ActorObservation {
actor_id: format!("person_{}", track.id),
track_id: track.id,
confidence,
position,
joints,
})
}
}
fn track_confidence(track: &TrackObservation, fallback: f32) -> f32 {
let scored: Vec<f32> = track
.keypoints
.iter()
.map(|kp| kp.confidence)
.filter(|v| v.is_finite() && *v > 0.0)
.collect();
let measured = if scored.is_empty() {
fallback
} else {
scored.iter().sum::<f32>() / scored.len() as f32
};
let freshness = 1.0 / (1.0 + track.time_since_update as f32 * 0.2);
(measured * freshness).clamp(0.0, 1.0)
}
fn joint_confidence(track: &TrackObservation, indices: &[usize], fallback: f32) -> f32 {
indices.iter().fold(1.0_f32, |confidence, &index| {
let measured = track.keypoints[index].confidence;
confidence.min(if measured.is_finite() && measured > 0.0 {
measured
} else {
fallback
})
})
}
fn add_flexion(out: &mut Vec<JointTarget>, name: &str, a: Vec3, b: Vec3, c: Vec3, confidence: f32) {
let inner = angle_deg(sub(a, b), sub(c, b));
if inner.is_finite() {
out.push(JointTarget {
joint: name.into(),
action: "flex".into(),
degrees: (180.0 - inner).clamp(0.0, 180.0),
confidence,
});
}
}
fn add_signed_flex(
out: &mut Vec<JointTarget>,
name: &str,
limb: Vec3,
up: Vec3,
forward: Vec3,
confidence: f32,
) {
let down = scale(up, -1.0);
let degrees = dot(limb, forward).atan2(dot(limb, down)).to_degrees();
if degrees.is_finite() {
out.push(JointTarget {
joint: name.into(),
action: if degrees >= 0.0 { "flex" } else { "extend" }.into(),
degrees: degrees.abs().clamp(0.0, 180.0),
confidence,
});
}
}
fn midpoint(a: Vec3, b: Vec3) -> Vec3 {
scale(add(a, b), 0.5)
}
fn add(a: Vec3, b: Vec3) -> Vec3 {
Vec3::new(a.x + b.x, a.y + b.y, a.z + b.z)
}
fn sub(a: Vec3, b: Vec3) -> Vec3 {
Vec3::new(a.x - b.x, a.y - b.y, a.z - b.z)
}
fn scale(v: Vec3, s: f32) -> Vec3 {
Vec3::new(v.x * s, v.y * s, v.z * s)
}
fn dot(a: Vec3, b: Vec3) -> f32 {
a.x * b.x + a.y * b.y + a.z * b.z
}
fn norm(v: Vec3) -> f32 {
dot(v, v).sqrt()
}
fn normalize(v: Vec3) -> Vec3 {
scale(v, 1.0 / norm(v).max(1e-9))
}
fn angle_deg(a: Vec3, b: Vec3) -> f32 {
let denom = norm(a) * norm(b);
if denom < 1e-9 {
return 0.0;
}
(dot(a, b) / denom).clamp(-1.0, 1.0).acos().to_degrees()
}
#[cfg(test)]
mod tests {
use super::*;
fn standing_track() -> TrackObservation {
let mut keypoints = [KeypointObservation::default(); NUM_KEYPOINTS];
let mut set = |index, x, y, z| {
keypoints[index] = KeypointObservation {
position: Vec3::new(x, y, z),
confidence: 0.9,
};
};
set(LEFT_SHOULDER, -0.2, 1.5, 0.0);
set(RIGHT_SHOULDER, 0.2, 1.5, 0.0);
set(LEFT_ELBOW, -0.25, 1.1, 0.0);
set(RIGHT_ELBOW, 0.25, 1.1, 0.0);
set(LEFT_WRIST, -0.25, 0.75, 0.0);
set(RIGHT_WRIST, 0.25, 0.75, 0.0);
set(LEFT_HIP, -0.12, 0.9, 0.0);
set(RIGHT_HIP, 0.12, 0.9, 0.0);
set(LEFT_KNEE, -0.12, 0.5, 0.0);
set(RIGHT_KNEE, 0.12, 0.5, 0.0);
set(LEFT_ANKLE, -0.12, 0.05, 0.0);
set(RIGHT_ANKLE, 0.12, 0.05, 0.0);
TrackObservation {
id: 7,
state: TrackState::Active,
keypoints,
time_since_update: 0,
}
}
#[test]
fn converts_track_to_named_actor() {
let adapter = RuViewAdapter::new(AdapterConfig::default()).unwrap();
let actor = adapter.actor(&standing_track()).unwrap();
assert_eq!(actor.actor_id, "person_7");
assert_eq!(actor.track_id, 7);
assert!(actor.joints.len() >= 9);
assert!(actor.joints.iter().all(|j| j.degrees.is_finite()));
}
#[test]
fn rejects_invalid_axes() {
let config = AdapterConfig {
world_up: Vec3::default(),
..Default::default()
};
assert!(RuViewAdapter::new(config).is_err());
}
#[test]
fn rejects_parallel_coordinate_axes() {
let config = AdapterConfig {
world_forward: Vec3::new(0.0, 2.0, 0.0),
..Default::default()
};
assert!(RuViewAdapter::new(config).is_err());
}
#[test]
fn filters_stale_low_confidence_tracks() {
let mut track = standing_track();
track.time_since_update = 100;
let adapter = RuViewAdapter::new(AdapterConfig::default()).unwrap();
assert!(adapter.actor(&track).is_none());
}
#[cfg(feature = "ruview")]
#[test]
fn converts_native_ruview_pose_track() {
use wifi_densepose_signal::ruvsense::{PoseTrack, TrackId, TrackLifecycleState};
let source = standing_track();
let positions = std::array::from_fn(|i| {
let p = source.keypoints[i].position;
[p.x, p.y, p.z]
});
let mut native = PoseTrack::new(TrackId(23), &positions, 0, 128);
native.lifecycle = TrackLifecycleState::Active;
let observed = TrackObservation::from(&native);
assert_eq!(observed.id, 23);
assert_eq!(observed.state, TrackState::Active);
assert_eq!(
observed.keypoints[LEFT_HIP].position,
source.keypoints[LEFT_HIP].position
);
}
}
@@ -0,0 +1,24 @@
//! Error types for parser and adapter boundaries.
/// Crate result alias.
pub type Result<T> = std::result::Result<T, Error>;
/// Errors are structured and line anchored where input text is involved.
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum Error {
#[error("line {line}: {message}")]
Parse { line: usize, message: String },
#[error("invalid scene: {0}")]
Validation(String),
#[error("invalid observation: {0}")]
Observation(String),
}
impl Error {
pub(crate) fn parse(line: usize, message: impl Into<String>) -> Self {
Self::Parse {
line,
message: message.into(),
}
}
}
@@ -0,0 +1,29 @@
//! Multi-actor semantic motion scenes for RuView.
//!
//! This crate uses the PoseCode movement vocabulary and implements the
//! RuView 0.2 multi-actor extension from ADR-266. It deliberately separates
//! the high-rate observed pose stream from the compact semantic scene:
//! [`adapter::RuViewAdapter`] converts persistent RuView tracks into observed
//! frames, [`segmenter::PhaseSegmenter`] reduces those frames to synchronized
//! phases, and [`serialize::to_posecode`] emits deterministic text.
pub mod adapter;
pub mod error;
pub mod model;
pub mod parser;
pub mod segmenter;
pub mod serialize;
pub mod validate;
pub use adapter::{
AdapterConfig, KeypointObservation, RuViewAdapter, TrackObservation, TrackState,
};
pub use error::{Error, Result};
pub use model::*;
pub use parser::parse_posecode;
pub use segmenter::{PhaseSegmenter, SegmenterConfig};
pub use serialize::to_posecode;
pub use validate::{validate_scene, ValidationConfig, ValidationIssue, ValidationSeverity};
/// Protocol version implemented by this crate.
pub const PROTOCOL_VERSION: &str = "0.2";
@@ -0,0 +1,203 @@
//! Renderer independent scene model.
use serde::{Deserialize, Serialize};
/// Hard limits keep untrusted scene documents bounded in memory and time.
pub const MAX_ACTORS: usize = 32;
pub const MAX_PHASES: usize = 10_000;
pub const MAX_TARGETS_PER_ACTOR: usize = 64;
pub const MAX_NAME_BYTES: usize = 128;
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct Vec3 {
pub x: f32,
pub y: f32,
pub z: f32,
}
impl Vec3 {
pub fn new(x: f32, y: f32, z: f32) -> Self {
Self { x, y, z }
}
pub fn is_finite(self) -> bool {
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
}
pub fn distance(self, other: Self) -> f32 {
let dx = self.x - other.x;
let dy = self.y - other.y;
let dz = self.z - other.z;
(dx * dx + dy * dy + dz * dz).sqrt()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct EulerDeg {
pub x: f32,
pub y: f32,
pub z: f32,
}
impl EulerDeg {
pub fn is_finite(self) -> bool {
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SceneSource {
Authored,
ObservedWifiCsi,
Imported,
}
impl Default for SceneSource {
fn default() -> Self {
Self::Authored
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Actor {
pub id: String,
pub rig: String,
pub start_pose: String,
pub track_id: Option<u64>,
pub confidence: f32,
pub position: Vec3,
}
impl Actor {
pub fn humanoid(id: impl Into<String>) -> Self {
Self {
id: id.into(),
rig: "humanoid".into(),
start_pose: "standing".into(),
track_id: None,
confidence: 1.0,
position: Vec3::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JointTarget {
pub joint: String,
pub action: String,
pub degrees: f32,
pub confidence: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ActorTargets {
pub actor_id: String,
pub joints: Vec<JointTarget>,
pub ground_lock: Vec<String>,
pub travel: Option<Vec3>,
pub confidence: f32,
}
impl ActorTargets {
pub fn new(actor_id: impl Into<String>) -> Self {
Self {
actor_id: actor_id.into(),
joints: Vec::new(),
ground_lock: Vec::new(),
travel: None,
confidence: 1.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EffectorRef {
pub actor_id: String,
pub effector: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Contact {
pub from: EffectorRef,
pub to: EffectorRef,
pub confidence: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Timing {
Flow,
Settle,
Drive,
Snap,
Linear,
EaseIn,
EaseOut,
EaseInOut,
}
impl Timing {
pub fn as_str(self) -> &'static str {
match self {
Self::Flow => "flow",
Self::Settle => "settle",
Self::Drive => "drive",
Self::Snap => "snap",
Self::Linear => "linear",
Self::EaseIn => "ease-in",
Self::EaseOut => "ease-out",
Self::EaseInOut => "ease-in-out",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Phase {
pub name: String,
pub start_ms: u64,
pub duration_ms: u64,
pub timing: Timing,
pub actors: Vec<ActorTargets>,
pub contacts: Vec<Contact>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Scene {
pub version: String,
pub name: String,
pub source: SceneSource,
pub actors: Vec<Actor>,
pub phases: Vec<Phase>,
pub repeat: u32,
}
impl Scene {
pub fn new(name: impl Into<String>) -> Self {
Self {
version: crate::PROTOCOL_VERSION.into(),
name: name.into(),
source: SceneSource::Authored,
actors: Vec::new(),
phases: Vec::new(),
repeat: 1,
}
}
}
/// One actor at one observed instant. Raw observations remain separate from
/// semantic phases so 20 Hz input does not become unreadable scene text.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ActorObservation {
pub actor_id: String,
pub track_id: u64,
pub confidence: f32,
pub position: Vec3,
pub joints: Vec<JointTarget>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SceneFrame {
pub timestamp_ms: u64,
pub actors: Vec<ActorObservation>,
}
@@ -0,0 +1,375 @@
//! Bounded line parser for the PoseCode 0.2 multi-actor extension.
use crate::error::{Error, Result};
use crate::model::*;
use crate::validate::{validate_scene, ValidationConfig, ValidationSeverity};
const MAX_INPUT_BYTES: usize = 1_048_576;
const MAX_LINE_BYTES: usize = 4096;
pub fn parse_posecode(source: &str) -> Result<Scene> {
if source.len() > MAX_INPUT_BYTES {
return Err(Error::parse(0, "document exceeds 1 MiB"));
}
let mut scene: Option<Scene> = None;
let mut current_actor: Option<usize> = None;
let mut current_phase: Option<usize> = None;
let mut elapsed_ms = 0_u64;
for (index, raw) in source.lines().enumerate() {
let line_no = index + 1;
if raw.len() > MAX_LINE_BYTES {
return Err(Error::parse(line_no, "line exceeds 4096 bytes"));
}
let line = strip_comment(raw).trim();
if line.is_empty() {
continue;
}
if scene.is_none() {
let rest = line
.strip_prefix("posecode scene ")
.ok_or_else(|| Error::parse(line_no, "expected posecode scene header"))?;
scene = Some(Scene::new(parse_quoted(rest, line_no)?));
continue;
}
let doc = scene.as_mut().expect("initialized above");
if let Some(value) = line.strip_prefix("source ") {
doc.source = match value {
"authored" => SceneSource::Authored,
"observed_wifi_csi" => SceneSource::ObservedWifiCsi,
"imported" => SceneSource::Imported,
_ => return Err(Error::parse(line_no, "unknown source")),
};
continue;
}
if let Some(value) = line.strip_prefix("actor ") {
let id = value
.strip_suffix(':')
.ok_or_else(|| Error::parse(line_no, "actor declaration must end with ':'"))?;
doc.actors.push(Actor::humanoid(id.trim()));
current_actor = Some(doc.actors.len() - 1);
current_phase = None;
continue;
}
if let Some(value) = line.strip_prefix("step ") {
let (name, tail) = take_quoted(value, line_no)?;
let fields: Vec<&str> = tail.trim_end_matches(':').split_whitespace().collect();
if fields.len() != 2 {
return Err(Error::parse(line_no, "step requires duration and timing"));
}
let duration_ms = parse_duration(fields[0], line_no)?;
let timing = parse_timing(fields[1], line_no)?;
doc.phases.push(Phase {
name,
start_ms: elapsed_ms,
duration_ms,
timing,
actors: Vec::new(),
contacts: Vec::new(),
});
elapsed_ms = elapsed_ms
.checked_add(duration_ms)
.ok_or_else(|| Error::parse(line_no, "timeline overflow"))?;
current_phase = Some(doc.phases.len() - 1);
current_actor = None;
continue;
}
if let Some(value) = line.strip_prefix("repeat ") {
doc.repeat = value
.parse()
.map_err(|_| Error::parse(line_no, "repeat must be an integer"))?;
if doc.repeat == 0 || doc.repeat > 10_000 {
return Err(Error::parse(line_no, "repeat must be 1 to 10000"));
}
continue;
}
if let Some(ai) = current_actor {
parse_actor_field(&mut doc.actors[ai], line, line_no)?;
continue;
}
if let Some(pi) = current_phase {
parse_phase_field(&mut doc.phases[pi], line, line_no)?;
continue;
}
return Err(Error::parse(line_no, "directive is outside actor or step"));
}
let scene = scene.ok_or_else(|| Error::parse(0, "empty document"))?;
if let Some(issue) = validate_scene(&scene, &ValidationConfig::default())
.into_iter()
.find(|i| i.severity == ValidationSeverity::Error)
{
return Err(Error::Validation(format!(
"{}: {}",
issue.path, issue.message
)));
}
Ok(scene)
}
fn parse_actor_field(actor: &mut Actor, line: &str, line_no: usize) -> Result<()> {
if let Some(value) = line.strip_prefix("rig ") {
actor.rig = value.trim().into();
return Ok(());
}
if let Some(value) = line.strip_prefix("pose start = ") {
actor.start_pose = value.trim().into();
return Ok(());
}
if let Some(value) = line.strip_prefix("track ") {
actor.track_id = Some(
value
.parse()
.map_err(|_| Error::parse(line_no, "track must be an integer"))?,
);
return Ok(());
}
if let Some(value) = line.strip_prefix("confidence ") {
actor.confidence = parse_confidence(value, line_no)?;
return Ok(());
}
if let Some(value) = line.strip_prefix("position ") {
actor.position = parse_vec3(value, line_no)?;
return Ok(());
}
Err(Error::parse(line_no, "unknown actor directive"))
}
fn parse_phase_field(phase: &mut Phase, line: &str, line_no: usize) -> Result<()> {
if let Some(value) = line.strip_prefix("contact ") {
let fields: Vec<&str> = value.split_whitespace().collect();
if !(fields.len() == 2 || fields.len() == 3) {
return Err(Error::parse(
line_no,
"contact requires two effectors and optional confidence",
));
}
phase.contacts.push(Contact {
from: parse_effector(fields[0], line_no)?,
to: parse_effector(fields[1], line_no)?,
confidence: if fields.len() == 3 {
parse_confidence(fields[2], line_no)?
} else {
1.0
},
});
return Ok(());
}
let (lhs, rhs) = line
.split_once(':')
.ok_or_else(|| Error::parse(line_no, "phase directive requires ':'"))?;
let (actor_id, target) = lhs
.trim()
.split_once('.')
.ok_or_else(|| Error::parse(line_no, "target must be actor qualified"))?;
let actor = phase_actor_mut(phase, actor_id);
match target {
"ground-lock" => {
actor.ground_lock = rhs
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
"travel" => {
actor.travel = Some(parse_vec3_or_xz(rhs, line_no)?);
}
joint => {
let fields: Vec<&str> = rhs.split_whitespace().collect();
if !(fields.len() == 2 || fields.len() == 3) {
return Err(Error::parse(
line_no,
"joint target requires action, degrees and optional confidence",
));
}
actor.joints.push(JointTarget {
joint: joint.into(),
action: fields[0].into(),
degrees: fields[1]
.parse()
.map_err(|_| Error::parse(line_no, "degrees must be numeric"))?,
confidence: if fields.len() == 3 {
parse_confidence(fields[2], line_no)?
} else {
1.0
},
});
}
}
Ok(())
}
fn phase_actor_mut<'a>(phase: &'a mut Phase, id: &str) -> &'a mut ActorTargets {
if let Some(index) = phase.actors.iter().position(|a| a.actor_id == id) {
return &mut phase.actors[index];
}
phase.actors.push(ActorTargets::new(id));
phase.actors.last_mut().expect("just pushed")
}
fn parse_effector(value: &str, line: usize) -> Result<EffectorRef> {
let (actor_id, effector) = value
.split_once('.')
.ok_or_else(|| Error::parse(line, "effector must be actor qualified"))?;
Ok(EffectorRef {
actor_id: actor_id.into(),
effector: effector.into(),
})
}
fn parse_vec3(value: &str, line: usize) -> Result<Vec3> {
let f: Vec<&str> = value.split_whitespace().collect();
if f.len() != 3 {
return Err(Error::parse(line, "position requires x y z"));
}
Ok(Vec3::new(
parse_f32(f[0], line)?,
parse_f32(f[1], line)?,
parse_f32(f[2], line)?,
))
}
fn parse_vec3_or_xz(value: &str, line: usize) -> Result<Vec3> {
let f: Vec<&str> = value.split_whitespace().collect();
match f.len() {
2 => Ok(Vec3::new(
parse_f32(f[0], line)?,
0.0,
parse_f32(f[1], line)?,
)),
3 => parse_vec3(value, line),
_ => Err(Error::parse(line, "travel requires x z or x y z")),
}
}
fn parse_f32(value: &str, line: usize) -> Result<f32> {
let v: f32 = value
.parse()
.map_err(|_| Error::parse(line, "expected finite number"))?;
if !v.is_finite() {
return Err(Error::parse(line, "expected finite number"));
}
Ok(v)
}
fn parse_confidence(value: &str, line: usize) -> Result<f32> {
let v = parse_f32(value, line)?;
if !(0.0..=1.0).contains(&v) {
return Err(Error::parse(line, "confidence must be in [0, 1]"));
}
Ok(v)
}
fn parse_duration(value: &str, line: usize) -> Result<u64> {
let seconds = value
.strip_suffix('s')
.ok_or_else(|| Error::parse(line, "duration must end in s"))?;
let seconds = parse_f32(seconds, line)?;
if seconds <= 0.0 || seconds > 300.0 {
return Err(Error::parse(line, "duration must be in (0, 300] seconds"));
}
Ok((seconds * 1000.0).round() as u64)
}
fn parse_timing(value: &str, line: usize) -> Result<Timing> {
match value {
"flow" => Ok(Timing::Flow),
"settle" => Ok(Timing::Settle),
"drive" => Ok(Timing::Drive),
"snap" => Ok(Timing::Snap),
"linear" => Ok(Timing::Linear),
"ease-in" => Ok(Timing::EaseIn),
"ease-out" => Ok(Timing::EaseOut),
"ease-in-out" => Ok(Timing::EaseInOut),
_ => Err(Error::parse(line, "unknown timing")),
}
}
fn parse_quoted(value: &str, line: usize) -> Result<String> {
let (quoted, tail) = take_quoted(value, line)?;
if !tail.trim().is_empty() {
return Err(Error::parse(line, "unexpected text after quoted name"));
}
Ok(quoted)
}
fn take_quoted(value: &str, line: usize) -> Result<(String, &str)> {
let rest = value
.strip_prefix('"')
.ok_or_else(|| Error::parse(line, "expected quoted name"))?;
let end = rest
.find('"')
.ok_or_else(|| Error::parse(line, "unterminated quoted name"))?;
Ok((rest[..end].to_string(), &rest[end + 1..]))
}
fn strip_comment(line: &str) -> &str {
let hash = line.find('#');
let slash = line.find("//");
match (hash, slash) {
(Some(a), Some(b)) => &line[..a.min(b)],
(Some(a), None) | (None, Some(a)) => &line[..a],
(None, None) => line,
}
}
#[cfg(test)]
mod tests {
use super::*;
const SCENE: &str = r#"posecode scene "Assisted squat"
source observed_wifi_csi
actor patient:
rig humanoid
pose start = standing
track 7
confidence 0.82
position 0 0 0
actor therapist:
rig humanoid
pose start = standing
position 1.2 0 0
step "Lower" 1.5s flow:
patient.knee_left: flex 95 0.8
patient.ground-lock: feet
therapist.shoulder_left: flex 30
contact therapist.hand_left patient.shoulder_right 0.7
repeat 2
"#;
#[test]
fn parses_multiple_actors_and_contacts() {
let scene = parse_posecode(SCENE).unwrap();
assert_eq!(scene.actors.len(), 2);
assert_eq!(scene.phases[0].actors.len(), 2);
assert_eq!(scene.phases[0].contacts.len(), 1);
assert_eq!(scene.repeat, 2);
}
#[test]
fn rejects_unknown_actor_reference() {
let bad = "posecode scene \"x\"\nactor p1:\n rig humanoid\nstep \"x\" 1s flow:\n ghost.knee_left: flex 20";
assert!(parse_posecode(bad).is_err());
}
#[test]
fn rejects_non_finite_number() {
let bad = "posecode scene \"x\"\nactor p1:\n position NaN 0 0";
assert!(parse_posecode(bad).is_err());
}
#[test]
fn rejects_duplicate_actors() {
let bad = "posecode scene \"x\"\nactor p1:\n rig humanoid\nactor p1:\n rig humanoid";
assert!(parse_posecode(bad).is_err());
}
#[test]
fn rejects_oversized_document_before_parsing() {
let bad = "x".repeat(MAX_INPUT_BYTES + 1);
assert!(parse_posecode(&bad).is_err());
}
}
@@ -0,0 +1,212 @@
//! Confidence preserving reduction of high-rate observations into phases.
use std::collections::{BTreeMap, BTreeSet};
use crate::error::{Error, Result};
use crate::model::*;
#[derive(Debug, Clone)]
pub struct SegmenterConfig {
pub minimum_phase_ms: u64,
pub maximum_phase_ms: u64,
pub stable_velocity_deg_s: f32,
}
impl Default for SegmenterConfig {
fn default() -> Self {
Self {
minimum_phase_ms: 200,
maximum_phase_ms: 3_000,
stable_velocity_deg_s: 8.0,
}
}
}
#[derive(Debug, Clone)]
pub struct PhaseSegmenter {
config: SegmenterConfig,
}
impl PhaseSegmenter {
pub fn new(config: SegmenterConfig) -> Result<Self> {
if config.minimum_phase_ms == 0
|| config.maximum_phase_ms < config.minimum_phase_ms
|| !config.stable_velocity_deg_s.is_finite()
|| config.stable_velocity_deg_s < 0.0
{
return Err(Error::Observation("invalid segmenter configuration".into()));
}
Ok(Self { config })
}
pub fn segment(&self, name: impl Into<String>, frames: &[SceneFrame]) -> Result<Scene> {
if frames.is_empty() {
return Err(Error::Observation("at least one frame is required".into()));
}
for pair in frames.windows(2) {
if pair[1].timestamp_ms <= pair[0].timestamp_ms {
return Err(Error::Observation(
"timestamps must be strictly increasing".into(),
));
}
}
let mut scene = Scene::new(name);
scene.source = SceneSource::ObservedWifiCsi;
scene.actors = actors_from_frames(frames);
if frames.len() == 1 {
return Ok(scene);
}
let mut start = 0_usize;
let mut was_stable = true;
for i in 1..frames.len() {
let dt = frames[i].timestamp_ms - frames[i - 1].timestamp_ms;
let velocity = angular_velocity(&frames[i - 1], &frames[i], dt);
let stable = velocity <= self.config.stable_velocity_deg_s;
let elapsed = frames[i].timestamp_ms - frames[start].timestamp_ms;
let membership_changed = actor_ids(&frames[i - 1]) != actor_ids(&frames[i]);
let boundary = membership_changed
|| elapsed >= self.config.maximum_phase_ms
|| (stable && !was_stable && elapsed >= self.config.minimum_phase_ms);
if boundary {
scene
.phases
.push(make_phase(scene.phases.len(), &frames[start], &frames[i]));
start = i;
}
was_stable = stable;
}
let last = frames.len() - 1;
if start < last {
scene.phases.push(make_phase(
scene.phases.len(),
&frames[start],
&frames[last],
));
}
Ok(scene)
}
}
fn actors_from_frames(frames: &[SceneFrame]) -> Vec<Actor> {
let mut found = BTreeMap::<u64, Actor>::new();
for frame in frames {
for observed in &frame.actors {
found
.entry(observed.track_id)
.and_modify(|actor| {
actor.confidence = actor.confidence.max(observed.confidence);
actor.position = observed.position;
})
.or_insert_with(|| {
let mut actor = Actor::humanoid(observed.actor_id.clone());
actor.track_id = Some(observed.track_id);
actor.confidence = observed.confidence;
actor.position = observed.position;
actor
});
}
}
found.into_values().collect()
}
fn make_phase(index: usize, start: &SceneFrame, end: &SceneFrame) -> Phase {
let actors = end
.actors
.iter()
.map(|observed| ActorTargets {
actor_id: observed.actor_id.clone(),
joints: observed.joints.clone(),
ground_lock: Vec::new(),
travel: Some(observed.position),
confidence: observed.confidence,
})
.collect();
Phase {
name: format!("Observed {}", index + 1),
start_ms: start.timestamp_ms,
duration_ms: (end.timestamp_ms - start.timestamp_ms).max(1),
timing: Timing::Linear,
actors,
contacts: Vec::new(),
}
}
fn actor_ids(frame: &SceneFrame) -> BTreeSet<u64> {
frame.actors.iter().map(|a| a.track_id).collect()
}
fn angular_velocity(previous: &SceneFrame, current: &SceneFrame, dt_ms: u64) -> f32 {
let mut sum = 0.0;
let mut count = 0_u32;
for a in &current.actors {
let Some(before) = previous.actors.iter().find(|p| p.track_id == a.track_id) else {
return f32::INFINITY;
};
for joint in &a.joints {
if let Some(old) = before
.joints
.iter()
.find(|j| j.joint == joint.joint && j.action == joint.action)
{
sum += (joint.degrees - old.degrees).abs() * joint.confidence.min(old.confidence);
count += 1;
}
}
}
if count == 0 {
return 0.0;
}
(sum / count as f32) / (dt_ms as f32 / 1000.0).max(0.001)
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(ms: u64, angle: f32) -> SceneFrame {
SceneFrame {
timestamp_ms: ms,
actors: vec![ActorObservation {
actor_id: "p1".into(),
track_id: 1,
confidence: 0.9,
position: Vec3::default(),
joints: vec![JointTarget {
joint: "knee_left".into(),
action: "flex".into(),
degrees: angle,
confidence: 0.9,
}],
}],
}
}
#[test]
fn creates_compact_observed_scene() {
let frames = vec![
frame(0, 0.0),
frame(100, 20.0),
frame(200, 40.0),
frame(300, 40.1),
frame(500, 40.1),
];
let scene = PhaseSegmenter::new(SegmenterConfig::default())
.unwrap()
.segment("squat", &frames)
.unwrap();
assert_eq!(scene.actors.len(), 1);
assert!(!scene.phases.is_empty());
assert!(scene.phases.len() < frames.len());
assert_eq!(scene.source, SceneSource::ObservedWifiCsi);
}
#[test]
fn rejects_reversed_time() {
let frames = vec![frame(100, 0.0), frame(50, 1.0)];
assert!(PhaseSegmenter::new(SegmenterConfig::default())
.unwrap()
.segment("bad", &frames)
.is_err());
}
}
@@ -0,0 +1,131 @@
//! Canonical deterministic PoseCode text serialization.
use std::fmt::Write;
use crate::model::*;
pub fn to_posecode(scene: &Scene) -> String {
let mut out = String::new();
writeln!(out, "posecode scene \"{}\"", safe_text(&scene.name)).unwrap();
writeln!(out, "source {}", source_name(scene.source)).unwrap();
for actor in &scene.actors {
writeln!(out, "actor {}:", actor.id).unwrap();
writeln!(out, " rig {}", actor.rig).unwrap();
writeln!(out, " pose start = {}", actor.start_pose).unwrap();
if let Some(track) = actor.track_id {
writeln!(out, " track {track}").unwrap();
}
writeln!(out, " confidence {}", number(actor.confidence)).unwrap();
writeln!(
out,
" position {} {} {}",
number(actor.position.x),
number(actor.position.y),
number(actor.position.z)
)
.unwrap();
}
for phase in &scene.phases {
writeln!(
out,
"step \"{}\" {}s {}:",
safe_text(&phase.name),
number(phase.duration_ms as f32 / 1000.0),
phase.timing.as_str()
)
.unwrap();
for actor in &phase.actors {
for joint in &actor.joints {
writeln!(
out,
" {}.{}: {} {} {}",
actor.actor_id,
joint.joint,
joint.action,
number(joint.degrees),
number(joint.confidence)
)
.unwrap();
}
if !actor.ground_lock.is_empty() {
writeln!(
out,
" {}.ground-lock: {}",
actor.actor_id,
actor.ground_lock.join(", ")
)
.unwrap();
}
if let Some(p) = actor.travel {
writeln!(
out,
" {}.travel: {} {} {}",
actor.actor_id,
number(p.x),
number(p.y),
number(p.z)
)
.unwrap();
}
}
for contact in &phase.contacts {
writeln!(
out,
" contact {}.{} {}.{} {}",
contact.from.actor_id,
contact.from.effector,
contact.to.actor_id,
contact.to.effector,
number(contact.confidence)
)
.unwrap();
}
}
writeln!(out, "repeat {}", scene.repeat).unwrap();
out
}
fn source_name(source: SceneSource) -> &'static str {
match source {
SceneSource::Authored => "authored",
SceneSource::ObservedWifiCsi => "observed_wifi_csi",
SceneSource::Imported => "imported",
}
}
fn safe_text(value: &str) -> String {
value
.chars()
.filter(|c| !c.is_control() && *c != '"')
.collect()
}
fn number(value: f32) -> String {
if value == 0.0 {
return "0".into();
}
let mut text = format!("{value:.4}");
while text.ends_with('0') {
text.pop();
}
if text.ends_with('.') {
text.pop();
}
text
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse_posecode;
#[test]
fn canonical_text_round_trips() {
let mut scene = Scene::new("two people");
scene.actors.push(Actor::humanoid("p1"));
scene.actors.push(Actor::humanoid("p2"));
let text = to_posecode(&scene);
assert_eq!(parse_posecode(&text).unwrap(), scene);
assert_eq!(to_posecode(&parse_posecode(&text).unwrap()), text);
}
}
@@ -0,0 +1,202 @@
//! Deterministic scene validation with provenance-aware range policy.
use std::collections::HashSet;
use crate::model::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationSeverity {
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationIssue {
pub severity: ValidationSeverity,
pub path: String,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct ValidationConfig {
pub max_actors: usize,
pub max_phases: usize,
/// Authored values outside general ROM are errors. Observed values are
/// warnings because RF estimates are not medical measurements.
pub authored_rom_is_error: bool,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
max_actors: MAX_ACTORS,
max_phases: MAX_PHASES,
authored_rom_is_error: true,
}
}
}
pub fn validate_scene(scene: &Scene, config: &ValidationConfig) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
if scene.name.is_empty() || scene.name.len() > MAX_NAME_BYTES {
error(
&mut issues,
"scene.name",
"name must contain 1 to 128 bytes",
);
}
if scene.actors.len() > config.max_actors {
error(&mut issues, "scene.actors", "actor limit exceeded");
}
if scene.phases.len() > config.max_phases {
error(&mut issues, "scene.phases", "phase limit exceeded");
}
let mut ids = HashSet::new();
for (i, actor) in scene.actors.iter().enumerate() {
let path = format!("actors[{i}]");
if !valid_id(&actor.id) {
error(&mut issues, &path, "invalid actor identifier");
}
if !ids.insert(actor.id.as_str()) {
error(&mut issues, &path, "duplicate actor identifier");
}
check_confidence(&mut issues, &format!("{path}.confidence"), actor.confidence);
if !actor.position.is_finite() {
error(
&mut issues,
&format!("{path}.position"),
"position must be finite",
);
}
}
let actor_ids: HashSet<&str> = scene.actors.iter().map(|a| a.id.as_str()).collect();
for (pi, phase) in scene.phases.iter().enumerate() {
let path = format!("phases[{pi}]");
if phase.duration_ms == 0 || phase.duration_ms > 300_000 {
error(
&mut issues,
&format!("{path}.duration_ms"),
"duration must be 1 to 300000 ms",
);
}
let mut phase_actors = HashSet::new();
for targets in &phase.actors {
if !actor_ids.contains(targets.actor_id.as_str()) {
error(&mut issues, &path, "phase references unknown actor");
}
if !phase_actors.insert(targets.actor_id.as_str()) {
error(&mut issues, &path, "actor appears more than once in phase");
}
if targets.joints.len() > MAX_TARGETS_PER_ACTOR {
error(&mut issues, &path, "joint target limit exceeded");
}
check_confidence(&mut issues, &path, targets.confidence);
for joint in &targets.joints {
check_confidence(&mut issues, &path, joint.confidence);
if !joint.degrees.is_finite() {
error(&mut issues, &path, "joint angle must be finite");
continue;
}
if let Some((min, max)) = range(&joint.joint, &joint.action) {
if joint.degrees < min || joint.degrees > max {
let severity = if scene.source == SceneSource::Authored
&& config.authored_rom_is_error
{
ValidationSeverity::Error
} else {
ValidationSeverity::Warning
};
issues.push(ValidationIssue {
severity,
path: path.clone(),
message: format!(
"{}.{} angle {} outside general range [{}, {}]",
joint.joint, joint.action, joint.degrees, min, max
),
});
}
}
}
}
for contact in &phase.contacts {
if !actor_ids.contains(contact.from.actor_id.as_str())
|| !actor_ids.contains(contact.to.actor_id.as_str())
{
error(&mut issues, &path, "contact references unknown actor");
}
check_confidence(&mut issues, &path, contact.confidence);
}
}
issues
}
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.enumerate()
.all(|(i, b)| b.is_ascii_alphanumeric() || b == b'_' || (i > 0 && b == b'-'))
}
fn check_confidence(issues: &mut Vec<ValidationIssue>, path: &str, value: f32) {
if !value.is_finite() || !(0.0..=1.0).contains(&value) {
error(issues, path, "confidence must be finite and in [0, 1]");
}
}
fn error(issues: &mut Vec<ValidationIssue>, path: &str, message: &str) {
issues.push(ValidationIssue {
severity: ValidationSeverity::Error,
path: path.into(),
message: message.into(),
});
}
fn range(joint: &str, action: &str) -> Option<(f32, f32)> {
match (
joint.trim_end_matches("_left").trim_end_matches("_right"),
action,
) {
("shoulder", "flex") => Some((0.0, 180.0)),
("shoulder", "extend") => Some((0.0, 60.0)),
("elbow", "flex") => Some((0.0, 154.0)),
("hip", "flex") => Some((0.0, 135.0)),
("hip", "extend") => Some((0.0, 20.0)),
("knee", "flex") => Some((0.0, 144.0)),
("pelvis", "hinge") => Some((0.0, 120.0)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn observed_rom_is_warning_not_medical_error() {
let mut scene = Scene::new("observed");
scene.source = SceneSource::ObservedWifiCsi;
scene.actors.push(Actor::humanoid("p1"));
let mut targets = ActorTargets::new("p1");
targets.joints.push(JointTarget {
joint: "knee_left".into(),
action: "flex".into(),
degrees: 170.0,
confidence: 0.5,
});
scene.phases.push(Phase {
name: "frame".into(),
start_ms: 0,
duration_ms: 50,
timing: Timing::Linear,
actors: vec![targets],
contacts: vec![],
});
let issues = validate_scene(&scene, &ValidationConfig::default());
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, ValidationSeverity::Warning);
}
}
+4 -4
View File
@@ -15,10 +15,10 @@ repository.workspace = true
# (serde / serde_json / toml / sha2 / ed25519-dalek only — no tch / openblas /
# ndarray / candle), so they build under `--no-default-features`.
[dependencies]
rufield-core = { path = "../../../vendor/rufield/crates/rufield-core" }
rufield-provenance = { path = "../../../vendor/rufield/crates/rufield-provenance" }
rufield-privacy = { path = "../../../vendor/rufield/crates/rufield-privacy" }
rufield-fusion = { path = "../../../vendor/rufield/crates/rufield-fusion" }
rufield-core = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-core" }
rufield-provenance = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-provenance" }
rufield-privacy = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-privacy" }
rufield-fusion = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-fusion" }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-sensing-server"
version = "0.3.3"
version = "0.3.4"
edition.workspace = true
description = "Lightweight Axum server for WiFi sensing UI with RuVector signal processing"
license.workspace = true
@@ -34,6 +34,15 @@ pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN";
/// Path prefix the middleware protects when auth is enabled.
pub const PROTECTED_PREFIX: &str = "/api/v1/";
/// `/api/v1/stream/pose` is a WebSocket upgrade endpoint reachable from
/// browser code. Unlike a plain fetch(), the browser `WebSocket` constructor
/// cannot attach an `Authorization` header to the handshake request, so this
/// path can never carry a bearer token from a stock browser client — the
/// same reasoning that already exempts `/ws/sensing` (see module docs).
/// Exempted here rather than moved out of `/api/v1/*` to avoid an API
/// surface change for existing clients.
const EXEMPT_PATHS: &[&str] = &["/api/v1/stream/pose"];
/// Cheap, cloneable handle to the configured token (or `None`).
#[derive(Debug, Clone, Default)]
pub struct AuthState {
@@ -93,7 +102,8 @@ pub async fn require_bearer(
let Some(expected) = auth.token.clone() else {
return next.run(request).await;
};
if !request.uri().path().starts_with(PROTECTED_PREFIX) {
let path = request.uri().path();
if !path.starts_with(PROTECTED_PREFIX) || EXEMPT_PATHS.contains(&path) {
return next.run(request).await;
}
let supplied = request
@@ -141,6 +151,7 @@ mod tests {
.route("/health", get(|| async { "ok" }))
.route("/api/v1/info", get(|| async { "ok" }))
.route("/api/v1/sensitive", axum::routing::post(|| async { "ok" }))
.route("/api/v1/stream/pose", get(|| async { "ok" }))
.route("/ui/index.html", get(|| async { "<html/>" }))
}
@@ -361,6 +372,26 @@ mod tests {
);
}
/// `/api/v1/stream/pose` is a WebSocket upgrade the browser `WebSocket`
/// constructor drives directly — it cannot attach an `Authorization`
/// header, so this path must stay reachable even with auth ON (mirrors
/// the existing `/ws/sensing` exemption, just inside the `/api/v1/*`
/// prefix this time).
#[tokio::test]
async fn enabled_exempts_pose_stream_websocket() {
let r = wrap(AuthState::from_token("s3cr3t"));
assert_eq!(
status(r.clone(), "GET", "/api/v1/stream/pose", None).await,
StatusCode::OK,
"pose stream WS must stay reachable without a bearer token"
);
// The exemption is narrow: it must not leak to other /api/v1/* paths.
assert_eq!(
status(r, "GET", "/api/v1/info", None).await,
StatusCode::UNAUTHORIZED
);
}
#[test]
fn ct_eq_basics() {
assert!(ct_eq(b"abc", b"abc"));
@@ -38,6 +38,7 @@ use wifi_densepose_bfld::{PrivacyClass, PrivacyMode};
use wifi_densepose_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput};
use wifi_densepose_geo::types::GeoRegistration;
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
use wifi_densepose_signal::ruvsense::multistatic::MultistaticConfig;
use wifi_densepose_worldgraph::WorldId;
use super::multistatic_bridge::node_frames_from_states;
@@ -79,8 +80,24 @@ pub struct EngineBridge {
impl EngineBridge {
/// Build a bridge for one installation. `room_area_id`/`room_name` name the
/// observation scope; `mode` is the starting privacy mode.
pub fn new(mode: PrivacyMode, model_version: u16, room_area_id: &str, room_name: &str) -> Self {
///
/// `multistatic_cfg` is `None` on the pure default (60 ms/20 ms guard);
/// callers that derive a config from `WDP_TDM_SLOTS`/`WDP_GUARD_INTERVAL_US`
/// (see `main.rs::multistatic_guard_config_from_env`) should pass it here
/// too — otherwise the governed trust cycle silently keeps using the
/// hardcoded default even though the sibling `multistatic_fuser` field on
/// `AppState` picked up the override (#1049/#1057).
pub fn new(
mode: PrivacyMode,
model_version: u16,
room_area_id: &str,
room_name: &str,
multistatic_cfg: Option<MultistaticConfig>,
) -> Self {
let mut engine = StreamingEngine::new(mode, model_version, GeoRegistration::default());
if let Some(cfg) = multistatic_cfg {
engine.set_multistatic_config(cfg);
}
let room = engine.add_room(room_area_id, room_name);
Self {
engine,
@@ -267,7 +284,7 @@ mod tests {
#[test]
fn empty_states_produce_no_belief() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room", None);
let out = bridge.process_cycle_from_states(&HashMap::new(), 1_000);
assert!(out.is_none());
// No belief published, no sensor wired.
@@ -276,7 +293,7 @@ mod tests {
#[test]
fn live_cycle_produces_witnessed_belief_with_provenance() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room", None);
let states = two_node_states();
let out = bridge
.process_cycle_from_states(&states, 10_000)
@@ -299,7 +316,7 @@ mod tests {
fn live_path_is_deterministic() {
let states = two_node_states_fixed();
let run = || {
let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
b.process_cycle_from_states(&states, 5_000).unwrap().unwrap()
};
let a = run();
@@ -325,7 +342,7 @@ mod tests {
#[test]
fn nodes_registered_once_across_cycles() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
let states = two_node_states();
bridge.process_cycle_from_states(&states, 1_000);
bridge.process_cycle_from_states(&states, 2_000);
@@ -336,7 +353,7 @@ mod tests {
#[test]
fn retention_bounds_world_graph_growth() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
bridge.set_semantic_retention(5);
let states = two_node_states();
for i in 0..20i64 {
@@ -349,7 +366,7 @@ mod tests {
#[test]
fn adapter_identity_flows_into_live_witness() {
let states = two_node_states_fixed();
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
let base = bridge
.process_cycle_from_states(&states, 1_000)
.unwrap()
@@ -381,7 +398,7 @@ mod tests {
/// the status endpoint, with a zero error count on the happy path.
#[test]
fn observe_cycle_records_trust_state() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
assert!(bridge.last_trust_witness().is_none());
assert_eq!(bridge.effective_class(), None);
@@ -430,7 +447,7 @@ mod tests {
m
}
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
let mismatched = mismatched_states();
assert!(bridge.observe_cycle(&mismatched, 1_000).is_none());
@@ -461,7 +478,7 @@ mod tests {
/// mapping bfld's privacy gate applies at `Restricted`.
#[test]
fn restricted_class_suppresses_raw_outputs() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity); // base = Restricted
bridge
.observe_cycle(&two_node_states(), 1_000)
@@ -472,7 +489,7 @@ mod tests {
#[test]
fn identity_strict_mode_is_carried_into_provenance() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity);
let out = bridge
.process_cycle_from_states(&two_node_states(), 7_000)
@@ -7514,6 +7514,11 @@ async fn main() {
let field_surface: rufield_surface::FieldState =
Arc::new(RwLock::new(rufield_surface::FieldSurface::from_env()));
// Populated inside the `multistatic_fuser` field initializer below, then
// threaded into `engine_bridge` so both fusion paths honor the same
// WDP_TDM_SLOTS/WDP_GUARD_INTERVAL_US-derived guard (#1049/#1057).
let mut engine_bridge_multistatic_cfg: Option<MultistaticConfig> = None;
let state: SharedState = Arc::new(RwLock::new(AppStateInner {
latest_update: None,
rssi_history: VecDeque::new(),
@@ -7588,7 +7593,7 @@ async fn main() {
);
let mut fuser = MultistaticFuser::with_config(MultistaticConfig {
min_nodes: 1, // single-node passthrough
..cfg
..cfg.clone()
});
if let Some(ref pos_str) = args.node_positions {
let positions = field_bridge::parse_node_positions(pos_str);
@@ -7600,6 +7605,10 @@ async fn main() {
fuser.set_node_positions(positions);
}
}
engine_bridge_multistatic_cfg = Some(MultistaticConfig {
min_nodes: 1,
..cfg
});
fuser
},
engine_bridge: engine_bridge::EngineBridge::new(
@@ -7607,6 +7616,7 @@ async fn main() {
1,
"default",
"Default Room",
engine_bridge_multistatic_cfg,
),
field_model: if args.calibrate {
info!("Field model calibration enabled — room should be empty during startup");
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-signal"
version = "0.3.4"
version = "0.3.5"
edition.workspace = true
description = "WiFi CSI signal processing for DensePose estimation"
license.workspace = true
+1 -1
+1 -1