Compare commits

..

3 Commits

Author SHA1 Message Date
rUv 07b6bf8084 chore: extract ruv-neural to ruvnet/ruv-neural, wire as submodule (#1019)
The 12-crate brain-topology analysis ecosystem (v2/crates/ruv-neural) was a
self-contained nested workspace with no inbound deps from the v2 workspace
(verified: zero path references outside its own tree). Published standalone
at github.com/ruvnet/ruv-neural and re-attached here as a submodule at the
same path, so the build layout is unchanged while the project gets its own
repo/CI/release cadence.
2026-06-11 18:12:51 -04:00
ruv d22616c488 docs(research): WiFlow-STD audit writeup (published as public gist + upstream issue)
Gist: https://gist.github.com/ruvnet/47d4369c0bd251ed233bbc450d50f6e6
Upstream report: DY2434/WiFlow...issues/3

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 17:13:10 -04:00
rUv 17471e93ff ADR-152: WiFi-Pose SOTA 2026 intake — WiFlow-STD benchmark, Rust integrations, ADR-153 802.11bf layer, efficiency frontier (#1008)
* feat(calibration): NodeGeometry transceiver-geometry recording (ADR-152 §2.1.1)

PerceptAlign-motivated geometry capture at enrollment: per-node optional
records (position, antenna orientation, inter-node distances, acquisition
method) — recorded when known, never required. Event-sourced via
EnrollmentEvent::GeometryRecorded (latest recording wins); persisted on
SpecialistBank with serde defaults so pre-ADR-152 bank JSON loads cleanly
(fixture-proven, and geometry-free banks serialize byte-shape-identical
to the old schema); threaded through MultiNodeMixture as data only — the
learned geometry embeddings and algorithmic fusion use are §2.1.2,
deliberately deferred until the ADR-151 P6 LoRA heads exist.

Geometry recorded from now on means banks captured today remain usable
for layout-conditioned training later — you can't retroactively add
geometry to data you didn't record.

8 new tests (3 geometry, 2 anchor, 2 bank, 1 multistatic) + full-loop
extension (2-node geometry, one tape-measured + one unknown, surviving
the bank JSON round-trip the runtime loads from). 50/50 calibration
(both feature configs) + 23 CLI tests green.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(training): two-checkerboard camera↔room calibration for ADR-079 labels (ADR-152 §2.1.3)

Defends the camera-supervised pipeline against PerceptAlign's
"coordinate overfitting": MediaPipe keypoints were emitted in raw camera
coordinates with no shared frame and no transceiver-geometry metadata —
the exact label shape that memorizes deployment layout and collapses
cross-layout.

- scripts/calibrate-camera-room.py + calibration_lib.py: OpenCV
  two-checkerboard calibration → versioned bundle JSON (intrinsics,
  camera→room extrinsics, checkerboard spec, transceiver geometry,
  sha256 calibration_id). Intrinsics resolve from file > cache >
  multi-view computation > loud-warning 2-view fallback.
- collect-ground-truth.py --calibration <bundle>: every sample gains
  keypoints_room (unit bearing rays from the camera center in the room
  frame — documented projective alignment; raw image coords preserved
  so training chooses), camera_origin_room, calibration_id, and the
  transceiver geometry stamp. Without the flag, output is byte-identical
  to before (tested) + a one-line ADR-152 warning.

Design finding (recorded for ADR-152): a single planar checkerboard's
corner grid is centrosymmetric — the reversed corner ordering fits a
ghost camera pose with IDENTICAL reprojection error, so per-board flip
disambiguation is mathematically ill-posed. solve_two_board_extrinsics
solves the joint wall+floor set over all 4 flip combinations, where the
minimum is unique — an independent reason the TWO-checkerboard method is
required, beyond what PerceptAlign states.

15 headless pytest tests green (synthetic corners: extrinsics recovery
incl. ghost resolution, bundle round-trip + hash stability, ray
transforms w/ distortion + cross-resolution, no-calibration byte
identity).

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(benchmarks): WiFlow-STD reproduction harness + measurement (a) results (ADR-152 §2.2)

Shipped checkpoint REFUTED (0.08% PCK@20, wrong keypoint normalization);
6 reproducibility defects documented (broken imports, corrupted dataset
tail with float32-max garbage that NaN-poisons fp16 BatchNorm, unreachable
test phase). After repairs, retraining with upstream defaults reproduces
96.09% PCK@20 full-test / 96.61% corruption-free (published 97.25%) on
RTX 5080. Claims graded MEASURED-EQUIVALENT; 2.23M params + ~0.055 GFLOPs
verified. Third-party code/weights/data stay out of tree (gitignored).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat: ADR-152 Rust integrations + ADR-153 802.11bf protocol model

- calibration: GeometryEmbedding — 32-slot permutation-invariant NodeGeometry
  featurization for future LoRA-head conditioning (ADR-152 §2.1.2); derived
  SpecialistBank::geometry_embedding() accessor; 59 tests
- train: MaePretrainConfig + patchify/random-mask with UNSW measured recipe
  (80% masking, (30,3) patches; ADR-152 §2.3, arXiv 2511.18792); strict
  no-truncate/no-NaN policy; proptest properties
- train: WiFlowStdModel — tch-gated port of the verified ~96%-PCK@20
  WiFlow-STD architecture (ADR-152 §2.2 beyond-SOTA); ungated param formula
  pinned to 2,225,042; 15/17-keypoint support; 239 crate tests
- hardware: ieee80211bf forward-compatibility protocol model (ADR-153):
  SpecProfile gates, SensingCapabilities negotiation, required ConsentMode,
  session FSM, SensingTransport + SimTransport + OpportunisticCsiBridge;
  full acceptance checklist covered; 156+4 tests
- deps: ruvector bumps per ADR-152 §2.6 survey (mincut/solver 2.0.6,
  attention 2.1.0, gnn 2.2.0); vendor/ruvector synced to a083bd77f
- docs: ADR-153 accepted; ADR-152 §2.2 status, §2.4 amendment, §2.6 added

Workspace: 162 test suites green (--no-default-features); Python proof PASS.
Known pre-existing flake: homecore-api env_empty_falls_back_to_defaults
(unserialized env-var mutation) — untouched, follow-up.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs: CHANGELOG + CLAUDE.md entries for ADR-152 integrations and ADR-153

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(train): repair tch-backend bit-rot — gated path compiles and tests run again

Mechanical API refresh against current tch: Vec::from(Tensor) -> try_from
(+ explicit flatten), numel() usize cast, Rem/div ops -> remainder() /
divide_scalar_mode(floor) — the latter fixed a silent true-division bug in
heatmap argmax decoding; clamp(1.0, f64::MAX) -> clamp_min (torch 2.x scalar
overflow panic); petgraph EdgeRef import; missing EvalMetrics and
verify_checkpoint_dir APIs that tests documented. wiflow_std roundtrip test
uses safetensors (.pt _save_parameters roundtrip broken in torch 2.11
Windows). Gated: 349 passed (incl. all 20 wiflow_std); ungated: unchanged.
Known pre-existing: gaussian-heatmap convention mismatch (2 tests), proof
seed race under parallel threads — documented, deliberate follow-ups.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(train): WiFlow-STD PyTorch->tch weight import + numerical parity proof

export_to_safetensors.py maps the retrained checkpoint (295 tensors -> 248
mapped, param sum exactly 2,225,042; num_batches_tracked dropped) into a
tch-loadable safetensors plus a deterministic parity fixture. Gated #[ignore]
integration test loads it strictly and asserts forward-pass agreement:
max abs diff 1.192e-7 on the seed-42 fixture. dump_variable_names test makes
the tch name layout authoritative. Zero architecture discrepancies found.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix: workflow-review findings — BN gamma init, ThresholdParams serde, init docs

Concurrent validation workflow (2 review lanes + adversarial verification,
13 agents): 5 confirmed findings, 3 refuted. Fixes:
- wiflow_std: pin BatchNorm gamma to 1.0 (tch default draws Uniform(0,1) —
  silently halves activations in from-scratch training; loaded checkpoints
  unaffected, parity re-verified after the change)
- wiflow_std: document the conv-init divergences vs the reference's
  effective kaiming_normal(fan_out) re-init (from-scratch dynamics only)
- ieee80211bf: ThresholdParams deserialization validates via try_from so
  the <=100 invariant holds for untrusted payloads (+ rejection test)

Benchmarks (release, ruvzen): GeometryEmbedding 1.84us/call (542k/s),
MAE tokenization 7.38us/window (135k/s), 802.11bf FSM 8.9M events/s —
nothing suspicious.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr): ADR-152 §2.1.4 gate resolved — PerceptAlign repo MIT, dataset on HF

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(benchmarks): edge optimization measured + measurement (b) blocked + 92.9% retraction

Edge optimization (ADR-152 optimize track): ONNX Runtime fp32 is the CPU
latency win (3.2 ms/window, ~3.4x faster than torch, parity 2.4e-7); ORT
dynamic int8 reaches 2.44 MB (paper's ~2.2 MB claim plausible only via
conv-capable toolchains; -0.16pt PCK@20, +18% MPJPE, 2x slower); torch
dynamic quant converts 0% of this conv-only model; fp16 halves storage free
but is slower on CPU.

Measurement (b) BLOCKED-ON-DATA: only 1,077 paired ESP32 windows exist
(stop rule <2k). Forensic recheck of the surviving April holdout RETRACTS
the ADR-079 '92.9% PCK@20' figure: constant-output model, absolute (not
torso) threshold, 69 near-static frames — mean predictor scores 100% under
that protocol; torso-PCK@20 is 19.1%. Corroborates PR #535. Stale citations
removed from user-guide, readme-details, ADR-152 §2.1.3; no-citation rule
extended to ADR-079 accuracy claims. Unblock: >=2k-window multi-pose paired
session + torso-PCK re-baseline.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(user-guide): corrected camera-supervised collection tutorial

Step 0 CSI-rate check + session-length math (window yield = frames/20 —
the May session's 8x under-delivery was a ~12 Hz CSI rate, not an aligner
bug); two-checkerboard calibration step (ADR-152 §2.1.3); pose-variety and
confidence guidance; torso-normalized PCK + temporal-split + pred-variance
eval protocol (lessons from the 92.9% retraction); scale presets re-keyed
to realistic window counts.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(benchmarks): static PTQ int8 (calibrated) results + overnight capture script

Conv-only static QDQ beats dynamic int8 on accuracy (PCK@20 96.61-96.63%
vs 96.52%, MPJPE +10% vs +18% over fp32) at ~equal size/latency; all-ops
QDQ strictly worse (int8 activations through attention glue). Entropy
calibration verified bit-identical to MinMax on this data. Deployment:
ONNX fp32 for speed (3.2ms), static conv-only QDQ for smallest (2.53MB).

Also: scripts/overnight-empty-capture.py — segmented UDP CSI recorder for
empty-room baselines (no glob collisions, detach-safe).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(benchmarks): measurement (b) MEASURED — optimization transfer only, mean-pose baseline wins

WiFlow-STD fine-tuned on 2,046 fresh single-room ESP32 paired windows
(temporal 70/15/15, 70->540 adapter, K=17): pretrained-init 65% PCK@20 vs
scratch 0% (optimization transfer) but frozen-trunk ~0% (no feature
transfer), and NOTHING beats the mean-pose baseline (95.9% PCK@20 —
single subject, near-static normalized coords). Honesty gates held: pred
std 0.0113 (non-constant model) but mean-baseline dominance means no
citable CSI->pose capability from this data. ADR-152 open question 1
answered partially; definitive answer needs multi-subject/position data.

Two new aligner findings: heterogeneous csi_shape with silent zero-padding
(~20%), and extractCsiMatrix's transposed shape label (frame-major data,
[nSc, nFrames] label) — fixes pending.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(benchmarks): efficiency sweep MEASURED — half model dominates full reference

Compact WiFlow-STD variants on the same data/split/protocol: half (843,834
params, 0.38x) strictly dominates the 2.23M reference (PCK@20 96.62 vs
96.61, PCK@50 99.47 vs 99.11, MPJPE 0.00898 vs 0.0094) — the published
architecture is over-parameterized for its own benchmark. quarter (338k)
96.05%; tiny (56,290 params, 1/39.5) holds 94.11% — a ~220KB fp32 edge
candidate. In-domain caveats recorded; cross-domain untested.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(train): compact WiFlow-STD presets in Rust + tiny edge artifact (ADR-152)

WiFlowStdConfig gains half()/quarter()/tiny() mirroring the overnight sweep
exactly: TcnGroupsMode (Fixed/Gcd/Depthwise), input_pw_groups, derived
stride schedule and decoder-mid (all default to upstream behavior; legacy
serde JSON unaffected). Param formulas pin to trained ground truth first
try: 843,834 / 338,600 / 56,290; default 2,225,042 pin and 1.192e-7 parity
unchanged. 248 tests green.

Tiny edge artifact (tiny_edge_bench.py): ONNX fp32 = 295 KB, 0.66 ms/win
(~1,500/s CPU), 94.11% PCK@20 (matches sweep clean-test exactly; parity
1.49e-7). Static int8 is a bad trade at this scale (-1.43pt, +19% MPJPE,
-16% size, slower) — recorded as negative result. Export note: width-16
breaks AdaptiveAvgPool((15,1)) TorchScript export; replaced by exact
mean+matmul equivalent, proven by parity.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix: resolve all 10 confirmed code-review findings (7-angle review, 20/20 verified)

wiflow_std: min_feature_width (default 15) replaces the keypoints->stride
coupling — for_keypoints(17) now provably builds the trained [2,2,2,2]
graph and pools 15->17, matching the validated Python protocol (pinned by
tests); param_count() total on invalid configs; random_mask returns Result
and rejects non-finite/out-of-range ratios; trainer checkpoints switched
to safetensors (.pt VarStore roundtrip broken on Windows torch 2.11).

ieee80211bf: SBP proxy now re-triggers instances and relays reports via
Action::RelaySbpReport -> SensingFrame::SbpReport (clients consume via
their existing path); missed_instances reset on success = consecutive
semantics; SessionTable gains a guarded SBP entry point + unknown-id drop
counter; initiator-role sessions reject inbound setup/SBP requests
(RejectedNotSupported) closing the idle hijack; StartSetup/StartSbp
outside Idle return InvalidStateForCommand; SBP validation unified
through evaluate_setup with a 1:1 SetupStatus->SbpStatus mapping.
events.rs split out to honor the 500-line cap.

calibration/cli: enrollment geometry now actually reaches trained banks —
both production call sites attach .with_geometry; --geometry flag on
train-room and POST /enroll/geometry + train-body geometry on
calibrate-serve give production a recording surface; geometry-free banks
log the ADR-152 §2.1.2 note.

benchmarks: corruption masks committed as ground truth (unregenerable
after in-place cleaning; verified bit-identical regeneration from the
pristine copy) + generate_corruption_masks.py producer; _bench_common.py
dedups the 5x-copied shim/evaluate/seed/remap (post-refactor PCK@20
re-verified equal to the last digit); remote scripts get the mmap patch;
tiny_edge --calib validated multiple-of-64; onnx_bench --help no longer
executes (and overwrote) the export — artifact restored byte-exact.

Workspace: 2,963 tests passed, 0 failed; Python proof PASS.

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci: build workspace tests without debuginfo — runner disk exhaustion

The combined 38-crate debug target exceeds the GitHub runner's disk
('final link failed: No space left on device'); the same tree measured
151GB locally with full debuginfo. CARGO_PROFILE_{DEV,TEST}_DEBUG=0
shrinks the target ~5-10x; debuginfo serves no purpose in CI test runs.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 17:02:23 -04:00
200 changed files with 14236 additions and 25396 deletions
+11
View File
@@ -121,12 +121,23 @@ jobs:
with:
workspaces: v2
# The 38-crate workspace debug build exhausts the runner's disk when built
# with full debuginfo (observed: "final link failed: No space left on
# device" once the engine/benchmark crates landed; the same tree's local
# debug target measured 151 GB). Debuginfo is useless in CI — tests either
# pass or print their failure — so build without it; target shrinks ~5-10x.
- name: Run Rust tests
working-directory: v2
env:
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
run: cargo test --workspace --no-default-features
- name: Run ADR-147 worldmodel tests
working-directory: v2
env:
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
run: cargo test -p wifi-densepose-worldmodel --no-default-features
# ADR-134 CIR tests are behind the `cir` feature so the bench dependency
+4
View File
@@ -14,3 +14,7 @@
path = vendor/rvcsi
url = https://github.com/ruvnet/rvcsi
branch = main
[submodule "v2/crates/ruv-neural"]
path = v2/crates/ruv-neural
url = https://github.com/ruvnet/ruv-neural.git
branch = main
+7
View File
@@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Mesh partition risk now demotes the privacy class and is witnessed (ADR-032).** The dynamic min-cut guard's `at_risk` signal was advisory-only (it fed the recalibration advisor). It now also contributes to the ADR-141 privacy demotion alongside fusion- and array-level contradictions: a mesh close to partitioning makes the fused belief less trustworthy, so the cycle emits at a more restricted class (monotonic — information only removed). Because `effective_class` feeds the BLAKE3 witness, a fragmenting array now shifts the witness — partition risk is auditable, not just logged. The mesh computation moved ahead of the demotion step in `process_cycle`; new `mesh_guard_mut()` exposes risk-threshold tuning. Test proves a forced-risk 3-node cycle demotes PrivateHome Anonymous→Restricted and shifts the witness vs a clean *same-topology* baseline (the only delta between the two cycles is the forced risk).
### Added
- **ADR-152 WiFi-Pose SOTA 2026 intake — verified external benchmark + four Rust integrations.** A 22-source adversarially-verified survey of the 20252026 WiFi-sensing SOTA, with every adopted number reproduced or graded before integration:
- **WiFlow-STD (DY2434) reproduction (`benchmarks/wiflow-std/`)** — the external "97.25% PCK@20, 2.23M params" claim audited end-to-end: the **shipped checkpoint is REFUTED** (0.08% PCK@20 — wrong keypoint normalization, predates the published code), the released code does not run as published (6 documented defects, incl. an import that fails and an unreachable test phase), and the released dataset's final 13 files are corrupted (9,072 windows of NaN + float32-max garbage that NaN-poisons fp16 BatchNorm training). After repairing both, retraining with upstream defaults on an RTX 5080 reproduced **96.09% PCK@20 (full test) / 96.61% (corruption-free)** — claims graded MEASURED-EQUIVALENT; params (2,225,042) and FLOPs (~0.055 G) verified exactly. Full forensics in `benchmarks/wiflow-std/RESULTS.md`.
- **`GeometryEmbedding` (ADR-152 §2.1.2, `wifi-densepose-calibration`)** — 32-slot permutation-invariant, NaN-proof featurization of the §2.1.1 `NodeGeometry` records (centroid/spread, measured-first pairwise distances, circular azimuth stats, covariance-eigenvalue geometric diversity, per-node flags), schema-versioned for the ADR-151 P6 LoRA heads; derived `SpecialistBank::geometry_embedding()` accessor. The PerceptAlign "coordinate overfitting" defense, transplanted to per-room banks.
- **MAE pretraining recipe (ADR-152 §2.3, `wifi-densepose-train/src/mae.rs`)** — `MaePretrainConfig` pinning the UNSW-measured recipe (80% masking, (30,3) patches) with pure-Rust patchify/random-mask (exact counts, seed-deterministic, error-not-truncate divisibility, NaN rejection), property-tested; the consumption seam for the future ADR-150 ViT-Small encoder.
- **`WiFlowStdModel` Rust port (`wifi-densepose-train/src/wiflow_std/`)** — tch-gated idiomatic port of the verified spatio-temporal-decoupled architecture (grouped causal TCN → asymmetric conv stack → dual axial attention); ungated param formula asserted equal to the reference 2,225,042; 15/17-keypoint variants share weights (enables the ADR-152 §2.2(b) ESP32 fine-tune).
- **RuVector vendor sync + §2.6 opportunity survey** — vendor at `a083bd77f`; graded ADOPT/EVALUATE/WATCH table; crates.io bumps applied (mincut/solver 2.0.6, attention 2.1.0, gnn 2.2.0; RUSTSEC #504 audit: no pinned crate affected); top WATCH: unpublished `ruvector-graph-condense` differentiable min-cut for trainable subcarrier grouping.
- **ADR-153 IEEE 802.11bf-2025 forward-compatibility protocol model (`wifi-densepose-hardware/src/ieee80211bf/`)** — typed WLAN-sensing procedures (measurement setup/instance/report, SBP, termination) with `SpecProfile` version gates, `SensingCapabilities` negotiation, and **required** `ConsentMode` governance metadata on every setup; deterministic session FSM with rejection/timeout paths; `SensingTransport` seam with `SimTransport` and an `OpportunisticCsiBridge` mapping live ESP32 CSI batches into standardized report shape (a future chipset adapter replaces the bridge without touching RuvSense consumers). Not a certified implementation — simulation-tested protocol surface; OTA binding lands when silicon does. 19 acceptance tests.
- **Dynamic min-cut mesh partition guard in the streaming engine (`mesh_guard`).** Maintains a `ruvector-mincut` exact min-cut over the live mesh coupling graph (nodes = sensing nodes, coupling = product of fusion attention weights), surfacing per cycle: the global **cut value** (how close the array is to splitting — a structural measure per-node heuristics miss), the **weak side** (which specific nodes would partition: failure/jamming triage feeding ADR-032 posture), and an **at-risk flag** that counts as a structural event for the drift→recalibration advisor. Surfaced as `TrustedOutput::mesh`. **Measured cost policy** (criterion, 12-node mesh): weights are quantized (1/64; a *nonzero* coupling below one quantum saturates to quantum 1 so quantization never erases a live coupling — without the floor, balanced meshes of ≥ 65 nodes had every ~1/n coupling erased and sat permanently "at risk") and updates change-gated, so the steady-state cycle does zero graph work (~7.3 µs, ~23× cheaper than building); on any real change a full exact rebuild (~171 µs) is used because one `DynamicMinCut` delete+insert measured ~240 µs — the incremental machinery's overhead targets much larger graphs, so rebuild-on-change is the measured optimum at mesh scale (one-edge case 28% after the policy switch). Degenerate cases fail toward risk: a node with zero coupling is reported as already partitioned (cut 0). 9 mesh-guard tests + an engine-level wiring test; full `process_cycle` with the guard: ~33 µs for 4 nodes (50 ms budget).
- **Opt-in FFT operator for the CIR ISTA solver (814× measured).** Φ is a sub-DFT, so each ISTA mat-vec can run as one length-G FFT (O(G log G)) instead of a dense O(K·G) product. New `CirConfig::fft_operator` (default **false** — the dense path stays the bit-exact witness default; the FFT evaluates the same sums in a different order, so enabling it shifts float results and requires regenerating any pinned witness). `FftOperator` (rustfft, planned once at construction, scratch reused across the ISTA loop) dispatches inside `ista_solve`; warm-start/Lipschitz stay dense at construction. Measured (criterion, same run): ht20 2.22 ms → 265 µs (**8.4×**), ht40 10.26 ms → 717 µs (**14.3×**); the real HE40 grid (K=484, G=1452) scales further. 3 new tests: FFT↔dense matvec equivalence to float tolerance (ht20 + he40 grids), end-to-end dominant-tap agreement on a single-path frame, and all default configs keep FFT off. New `cir_estimate_fft` bench group.
- **Per-room adapter provenance + drift→recalibration advisor in the streaming engine.** Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150 §3.4) could silently change inference without the witness noticing. `StreamingEngine::set_room_adapter(AdapterInfo)` pins the adapter's content-derived id into provenance `model_version` (`rfenc-v1+adapter:<id>`) — and therefore into the BLAKE3 witness — so swapping or clearing adapter weights always shifts the witness (engine test proves base → adapter → other-adapter → cleared all witness differently, and cleared == base). New `RecalibrationAdvisor` recommends re-running the ADR-135 baseline / refitting the adapter on sustained low fusion coherence (streak threshold, default 60 cycles ≈ 3 s at 20 Hz) or an ADR-142 change-point; surfaced as `TrustedOutput::recalibration_recommended` and recorded on the sensing-server's `EngineBridge` alongside the witness. Bridge plumbing: `EngineBridge::{set_room_adapter, clear_room_adapter}` + live-path test that the adapter id flows into the live witness. *Scope note: this is the deployable provenance/trigger half of the "retrained model" roadmap item — fitting the adapter itself runs in the existing external calibration service (`aether-arena/calibration/`), and a trained RF-encoder checkpoint still does not exist in-tree.*
+4 -2
View File
@@ -10,9 +10,9 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
| `wifi-densepose-core` | Core types, traits, error types, CSI frame primitives |
| `wifi-densepose-signal` | SOTA signal processing + RuvSense multistatic sensing (16 modules) |
| `wifi-densepose-nn` | Neural network inference (ONNX, PyTorch, Candle backends) |
| `wifi-densepose-train` | Training pipeline with ruvector integration + ruview_metrics |
| `wifi-densepose-train` | Training pipeline with ruvector integration + ruview_metrics; MAE pretraining recipe (`mae.rs`, ADR-152 §2.3) + WiFlow-STD port (`wiflow_std/`, tch-gated) |
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware |
| `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-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) |
@@ -73,6 +73,8 @@ All 5 ruvector crates integrated in workspace:
- ADR-031: RuView sensing-first RF mode (Proposed)
- ADR-032: Multistatic mesh security hardening (Proposed)
- ADR-148: Drone swarm control system / `ruview-swarm` (In Progress)
- ADR-152: WiFi-Pose SOTA 2026 intake — geometry conditioning, WiFlow-STD benchmark (measurement (a) complete: claims MEASURED-EQUIVALENT at ~96% PCK@20), MAE recipe (Proposed; §2.12.3, 2.6 implemented)
- ADR-153: IEEE 802.11bf-2025 forward-compatibility protocol model (Accepted — amends ADR-152 §2.4)
### Supported Hardware
+26
View File
@@ -0,0 +1,26 @@
# Upstream clone (WiFlow-STD, DY2434) -- never commit third-party code/weights
upstream/
# Local python env
.venv/
# Downloaded data / artifacts
data/
downloads/
*.pth
*.pt
*.npy
*.npz
*.zip
*.mat
*.safetensors
results/parity_fixture.json
__pycache__/
*.onnx
# Committed ground truth: corruption masks for the pristine Kaggle download.
# remote/clean_v2.py zeroes the corrupted source windows IN PLACE, so these
# masks CANNOT be regenerated from a cleaned copy (generate_corruption_masks.py
# documents the criteria and reproduces them only from a fresh download).
!results/nan_windows_mask.npy
!results/big_windows_mask.npy
+486
View File
@@ -0,0 +1,486 @@
# WiFlow-STD (DY2434) Benchmark Results — ADR-152 §2.2
Upstream: <https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling>
pinned at `06899d29` (2026-04-05), Apache-2.0. Dataset: Kaggle `kaka2434/wiflow-dataset`
(12.8 GB archive → 15.5 GB extracted; 360,000 windows of 540×20 CSI + 15-keypoint 2D labels).
Published claims (README "Setting 1"): PCK@20 97.25%, PCK@30 98.63%, PCK@40 99.16%,
PCK@50 99.48%, MPJPE 0.007 m, 2.23M params, 0.07 GFLOPs.
## Measurement (a): their model on their data
### Artifact verification (MEASURED, 2026-06-10, this repo `eval_repro.py`)
| Check | Result |
|---|---|
| Parameter count | **2,225,042 (2.23M) — matches claim** |
| FLOPs (torch profiler, batch 1) | ~0.055 GFLOPs — consistent with 0.07B claim |
| CPU latency (Windows box, torch 2.12 CPU) | 13.2 ms/window @ batch 1 (76/s); 2.48 ms/sample @ batch 64 (403/s) |
| Checkpoint load | `weights_only=True` (no pickle code execution) |
### Released checkpoint does NOT reproduce the claims — REFUTED as shipped
Running the released `best_pose_model.pth` through the released code on the released
dataset with the released split procedure (seed-42 file-level 70/15/15; 54,000 test
samples) yields:
| Metric | Published | Measured (shipped checkpoint) |
|---|---|---|
| PCK@20 | 97.25% | **0.08%** |
| PCK@30 | 98.63% | 0.78% |
| PCK@40 | 99.16% | 5.53% |
| PCK@50 | 99.48% | 15.42% |
| MPJPE | 0.007 | **NaN** (dataset contains NaN CSI windows) |
Raw output: `results/repro_a.json`.
Diagnostics (on 2,000 NaN-free windows from the first files of the dataset, i.e.
mostly would-be *training* data — so this is not a split mismatch):
- Predictions correlate with targets (Pearson r ≈ 0.76) — the checkpoint is a trained
model, but in a **different keypoint normalization/order** than the released data.
- Best-case post-hoc global per-axis affine correction: PCK@20 ≈ 20%.
- Best-case per-keypoint affine correction (15×2 fitted transforms — generous
cheating): PCK@20 ≈ 72%, still far below 97.25%.
- Pred↔target keypoint correspondence matrix is degenerate (multiple predicted
keypoints best-match the same target joint) — keypoint convention mismatch.
### Reproducibility defects in the released artifacts
1. `models/__init__.py` imports `TemporalConvNet`, which `models/tcn.py` does not
define — **the published code does not import/run as-is**.
2. The released root checkpoint uses pre-rename module names (`att.*`, `final_conv.*`)
vs the published code (`attention.*`, `decoder.*`) — same shapes/param count, but
confirms the checkpoint predates the published code.
3. The second shipped checkpoint (`cross_dataset_test/WiFlow/best_pose_model.pth`) is
a **different architecture** (342-channel input = MM-Fi layout, 3 TCN layers,
3-channel/3D decoder) — not usable on their own dataset.
4. `run.py` ignores `--data_dir` and hardcodes `../preprocessed_csi_data`.
5. The released dataset's final 13 files (indices 487499; 9,072 windows, 2.52%)
are corrupted: NaN values plus garbage amplitudes up to 3.4e38 (float32 max) in
data that is otherwise [0,1]-normalized. Upstream code has no NaN/inf handling;
training as published on this download diverges — the first corrupted batch
overflows fp16 autocast and permanently poisons BatchNorm running statistics
(GradScaler step-skipping does not protect BN). The authors' training curves
show normal convergence, so their local data evidently differed from the
Kaggle upload. Window masks: `results/nan_windows_mask.npy`,
`results/big_windows_mask.npy`.
### Reproducing the corruption masks
The two mask files (9,070 NaN/Inf windows, 9,072 with |amplitude| > 1.5;
union 9,072, all in dataset files 487499) are **committed ground truth**
(gitignore-negated, ~352 KB each). They can only be regenerated from a
**pristine** Kaggle download: `remote/clean_v2.py` repairs the dataset by
zeroing the corrupted windows in place, after which the corruption evidence
is gone and a rescan returns all-False. `generate_corruption_masks.py`
re-derives them (chunked scan, criteria: any non-finite value OR
max |finite| > 1.5 per 540×20 window) and refuses to write all-False masks,
which indicate a cleaned copy. Verified 2026-06-11: a regeneration from the
local pristine download is bit-identical to the committed masks.
### Retraining result (MEASURED, 2026-06-10): claims APPROXIMATELY REPRODUCED
Since the shipped checkpoint is unusable, measurement (a) fell back to retraining
with upstream code + defaults (seed 42, batch 64, early-stopped at epoch 41 of 50,
best epoch 36, ~75 s/epoch) on ruvultra (RTX 5080). Deviations, all forced and
documented: one-line fix for defect (1); torch 2.x+cu128 instead of pinned 2.3.1
(Blackwell sm_120 unsupported); the 9,072 corrupted windows (defect 5) zeroed
entirely — without this the published pipeline produces NaN from epoch 1 (observed).
Scripts mirrored in `remote/`; raw metrics in `results/eval_retrained.json`.
| Metric | Published | Retrained (full test, 54,000) | Retrained (corruption-free, 52,560) |
|---|---|---|---|
| PCK@20 | 97.25% | **96.09%** | **96.61%** |
| PCK@30 | 98.63% | 97.89% | 98.23% |
| PCK@40 | 99.16% | 98.58% | 98.79% |
| PCK@50 | 99.48% | 98.99% | 99.11% |
| MPJPE | 0.007 | 0.0098 | 0.0094 |
Within ~0.61.2 PCK points of every published figure (single run, corrupted train
windows zeroed, different torch/GPU). **Verdict: the accuracy claims are credible
and approximately reproducible — but only after repairing the released dataset and
code.** Val best: PCK@20 96.99%, MPJPE 0.0086 (epoch 36).
One more defect found during the run:
6. `train.py` calls `plot_training_history`, which is not defined anywhere — the
built-in post-training test evaluation is unreachable as published (crashes
with NameError after training completes).
## ADR-152 §2.2 citation rule
Evidence grade for the WiFlow-STD accuracy claims after measurement (a):
**MEASURED-EQUIVALENT (96.196.6% PCK@20 reproduced by retraining; shipped
checkpoint REFUTED; dataset/code require repairs)**. RuView docs may cite
"~96% PCK@20 (our reproduction)" — still **not comparable** to our 17-keypoint
ESP32 numbers (different hardware, 5 subjects, in-domain random split,
15 keypoints).
## Edge optimization (measured)
ADR-152 "optimize beyond SOTA" track, 2026-06-10, this Windows box (Windows 11,
16 torch threads, torch 2.12.0+cpu, onnxruntime 1.26.0). Subject: the retrained
checkpoint `results/retrained_best_pose_model.pth` (2,225,042 fp32 params).
Scripts: `quantize_bench.py`, `onnx_bench.py`, `eval_ort_accuracy.py`.
Raw numbers: `results/edge_optimization.json`.
Accuracy is on a **10,000-window seed-42 random subset** of the corruption-free
test split (same seed-42 file-level 70/15/15 split as `eval_repro.py`; 54,000
test windows, 1,440 corrupted excluded via `results/nan_windows_mask.npy` |
`results/big_windows_mask.npy`, leaving 52,560; subset drawn with
`np.random.default_rng(42)`). The fp32 subset PCK@20 (96.68%) matches the full
clean-test figure (96.61%), so the subset is representative.
Latency is CPU ms/window, median of repeated runs, 3 interleaved repetitions
per variant (medians below; run-to-run spread on this box is large, roughly
±20-40% at batch 1 — reps are in the JSON).
| Variant | Disk size | Batch 1 (ms/win) | Batch 64 (ms/win) | PCK@20 | PCK@50 | MPJPE |
|---|---|---|---|---|---|---|
| torch fp32 (baseline) | 9.07 MB | 11.0 | 2.27 | 96.68% | 99.15% | 0.00936 |
| torch fp16 (`.half()`) | **4.58 MB** | 24.3 | 2.42 | 96.68% | 99.15% | 0.00946 |
| torch int8 dynamic | 9.07 MB (unchanged) | 15.6 | 2.06 | 96.68% (identical) | 99.15% | 0.00936 |
| ONNX fp32 (onnxruntime) | 8.97 MB | **3.2** | **2.0** | 96.68% | 99.15% | 0.00936 |
| ONNX int8 (ORT dynamic, supplementary) | **2.44 MB** | 6.5 | 5.8 | 96.52% | 99.15% | 0.01108 |
Findings:
- **torch dynamic INT8 quantizes nothing on this model.** The architecture has
**zero `nn.Linear` layers** — it is entirely Conv1d (21) + Conv2d (22) +
BatchNorm. `torch.ao.quantization.quantize_dynamic` (requested over
`{Linear, Conv1d, Conv2d}`) converted **0 modules / 0.0% of params**: dynamic
quantization only has kernels for Linear/RNN-family modules and silently
skips convolutions. The "int8" model is bit-identical to fp32 (same outputs,
same 9.07 MB). Conv quantization would require static (PTQ) quantization
with calibration — out of scope here; the ORT dynamic path below is the
honest int8 datapoint.
- **fp16 halves size for free accuracy-wise** (PCK@20 0.005 pt, MPJPE
+0.0001) but is *slower* on CPU at batch 1 (~2.2×) — torch CPU fp16 conv
kernels are emulated. fp16 is a storage/transport format here, not a CPU
runtime win.
- **ONNX Runtime is the real batch-1 latency win: ~3.4× faster than torch**
(3.2 vs 11.0 ms/window) at identical accuracy (parity 2.4e-7).
### Verdict on the paper's "~2.2 MB int8" claim
**Plausible but not free, and unreachable by the obvious PyTorch route.**
2,225,042 params × 1 byte ≈ 2.2 MB assumes *every* parameter quantizes.
PyTorch dynamic quantization — the one-liner most readers would reach for —
yields **9.07 MB (0% quantized)** because the model has no Linear layers.
ONNX Runtime dynamic quantization, which does have int8 conv weight support,
gets **2.44 MB** (close to the claim; the overhead is BatchNorm params/buffers
and quantization scales kept in fp32) at a measurable accuracy cost:
PCK@20 96.68 → 96.52% (0.16 pt) and MPJPE 0.00936 → 0.01108 (+18%), and
~2× slower inference than ONNX fp32 (ConvInteger kernels). The paper does not
state a method or an int8 accuracy; treat "2.2 MB" as a weight-arithmetic
estimate, achievable in practice only via conv-capable quantization toolchains
and with a small accuracy penalty.
### ONNX export status
**Works.** Exported via the TorchScript exporter (`dynamo=False`), opset 17,
with a dynamic batch axis — `results/retrained_fp32_dynamic.onnx` (8.97 MB),
verified to run at batch 1/2/64. The axial attention's
`view(N*W, C, H)` reshape traced correctly (sizes recorded as graph ops, not
baked constants). The dynamo exporter also captures the graph but crashed on
this box writing a ✅ to a cp1252 console (cosmetic Windows encoding issue, not
a model blocker). Parity vs torch on the stored fixture
(`results/parity_fixture.npz`, batch 2, seed 42): **max abs diff 2.4e-7 —
PASS** (< 1e-4). ORT-quantized int8 model: `results/retrained_int8_ort_dynamic.onnx`.
### Static PTQ (calibrated) — follow-up
Follow-up to the dynamic-int8 row above (2026-06-10, same box, onnxruntime
1.26.0): ONNX Runtime **static** post-training quantization
(`quantize_static`, QDQ format, per-channel int8 weights + int8 activations)
of the same fp32 export, calibrated on **corruption-free TRAINING-split
windows only** (seed-42 file-level split, same masks; 1,000 windows for
MinMax, 512 for the histogram calibrators; never test windows). Scopes:
"conv-only" (`op_types_to_quantize=["Conv"]` — the attention path exports as
Einsum/Softmax, which ORT never quantizes anyway, so "all-ops" additionally
quantizes the elementwise Mul/Sigmoid/Add/AveragePool glue). Accuracy on the
identical 10k-window seed-42 corruption-free test subset; latency median of
3 interleaved reps (fp32/dynamic re-benched in-session as references).
Script: `static_ptq_bench.py`; raw: `results/edge_optimization.json`
(`onnx_static_ptq`).
| Variant | Disk size | Batch 1 (ms/win) | Batch 64 (ms/win) | PCK@20 | PCK@50 | MPJPE |
|---|---|---|---|---|---|---|
| ONNX fp32 (reference) | 8.97 MB | 2.5 | 1.9 | 96.68% | 99.15% | 0.00936 |
| ORT dynamic int8 (baseline) | **2.44 MB** | 5.7 | 4.6 | 96.52% | 99.15% | 0.01108 |
| static QDQ **Percentile(99.99) conv-only** | 2.53 MB | 5.3 | 4.7 | 96.61% | 99.16% | **0.01031** |
| static QDQ MinMax conv-only | 2.53 MB | 5.2 | 3.3 | **96.63%** | 99.19% | 0.01084 |
| static QDQ Entropy conv-only | 2.53 MB | 5.2 | 3.1 | 96.60% | 99.19% | 0.01078 |
| static QDQ MinMax all-ops | 2.60 MB | 6.5 | 3.9 | 95.45% | 99.14% | 0.01486 |
| static QDQ Entropy all-ops | 2.60 MB | 5.7 | 4.1 | 95.30% | 99.13% | 0.01510 |
| static QDQ Percentile all-ops | 2.60 MB | 5.3 | 4.3 | 96.39% | 99.17% | 0.01218 |
**Verdict: static PTQ (conv-only) is the new best int8 point on accuracy —
but only modestly, and it does not fix int8's latency penalty.**
- **Accuracy: beats dynamic.** All three conv-only calibrations land at
PCK@20 96.6096.63% (vs dynamic 96.52%, fp32 96.68% — recovers ~⅔ of the
dynamic gap) and MPJPE 0.01030.0108 (vs dynamic 0.01108). Best MPJPE:
Percentile conv-only, +10% over fp32 instead of dynamic's +18%.
- **Size: slightly worse.** 2.53 MB vs 2.44 MB (+3.6%) — QDQ nodes and
per-channel scales cost a little; BatchNorm stays fp32 in both (the 12 BNs
follow Slice/Einsum/Reshape, never Conv, so they cannot be folded).
- **Latency: a wash vs dynamic, still ~2× slower than ONNX fp32 at batch 1.**
Batch-1 medians 5.25.3 vs dynamic 5.7 ms/win in-session — within this
box's ±2040% noise. Batch 64 leans static (3.13.3 for MinMax/Entropy
conv-only vs 4.6), same caveat.
- **All-ops QDQ is strictly worse**: up to 1.4 pt PCK@20 and +60% MPJPE for
zero size/latency benefit — int8 activations through the elementwise glue
around the attention blocks is where the damage is. Conv-only is the right
scope.
- Negative result worth recording: **Entropy calibration is a no-op here**
on an identical calibration set it selects full-range thresholds
bit-identical to MinMax (all 247 scales equal; verified on a 64-window
smoke set). Also, ORT 1.26's `CalibMaxIntermediateOutputs` raises a
spurious "No data is collected" when the batch count divides the chunk
size (worked around in the script).
Deployment guidance: need speed → ONNX fp32 (3.2 ms b1). Need int8 weights
for size → static QDQ conv-only (Percentile or MinMax,
`results/retrained_int8_static_percentile_conv.onnx`), which strictly
dominates dynamic int8 on accuracy at ~equal latency and +0.09 MB.
## Efficiency sweep (MEASURED, overnight 2026-06-10/11)
ADR-152 beyond-SOTA track: compact purpose-built variants of the WiFlow-STD
architecture, trained from scratch on the same cleaned dataset, identical
seed-42 file-level split, loss and protocol as the measurement-(a) reference
(fp32, batch 64, ≤50 epochs, patience 5; RTX 5080, ~2229 min/variant).
Variant transforms are pure channel/group/stride scalings of an
architecture-exact parameterized model (validated: reproduces 2,225,042 params
at the reference config). Scripts: `remote/sweep/`; raw:
`results/efficiency_sweep.jsonl`; checkpoints `results/{half,quarter,tiny}_best.pth`
(gitignored).
| Variant | Params | vs 2.23M | Clean-test PCK@20 | PCK@50 | MPJPE | Best epoch |
|---|---|---|---|---|---|---|
| full (reference, meas. a) | 2,225,042 | 1× | 96.61% | 99.11% | 0.0094 | 36 |
| **half** | **843,834** | **0.38×** | **96.62%** | **99.47%** | **0.00898** | 23 |
| quarter | 338,600 | 0.15× | 96.05% | 99.43% | 0.00928 | 50 |
| tiny | 56,290 | 0.025× | 94.11% | 99.36% | 0.0125 | 47 |
Findings:
- **The half model (843k params) strictly dominates the full reference** on
this dataset — equal PCK@20, better PCK@50 and MPJPE, converges in fewer
epochs. The published 2.23M architecture is over-parameterized for its own
benchmark.
- **tiny (56k params, 1/39.5) holds 94.11% PCK@20** — a ~220 KB fp32 /
~60 KB int8-class model in reach of severely constrained edge targets,
at 2.5 pt from the full reference.
- Caveats: in-domain (5-subject random-file split) like every number on this
dataset; single run per variant; corruption-free test subset (52,560).
Cross-domain behavior of compact variants is untested — ADR-150's evidence
says capacity *hurts* cross-subject, so the compact end may generalize no
worse, but that is a hypothesis, not a measurement.
### Compact-variant edge artifacts (MEASURED, 2026-06-11)
Edge pipeline for the **tiny** checkpoint (56,290 params), same machinery and
protocol as the full-model edge rows above (this Windows box, torch
2.12.0+cpu, onnxruntime 1.26.0; dynamic-batch opset-17 TorchScript export;
static QDQ **Percentile(99.99) conv-only** int8 calibrated on **512**
corruption-free TRAIN-split windows; accuracy on the identical 10k-window
seed-42 clean test subset; latency = median ms/window over 3 interleaved
reps, with the full-model fp32/int8 sessions interleaved as same-session
references). Script: `tiny_edge_bench.py`; raw:
`results/edge_optimization.json` (`tiny_variant`). Torch-vs-ORT parity on the
stored fixture input: **max abs diff 1.5e-7 — PASS** (< 1e-4). The tiny fp32
subset PCK@20 (94.11%) matches the full clean-test sweep figure (94.11%)
exactly, so the subset remains representative.
Two forced deviations, both recorded in the JSON:
1. **Adaptive-pool export rewrite.** tiny's derived stride schedule
`[2,1,1,1]` leaves feature width 16, and the TorchScript exporter rejects
`AdaptiveAvgPool2d((15,1))` when 15 is not a factor of the input height
(the full model never hit this — its width was exactly 15). Since the
pool over a fixed-size map is a fixed linear operator, the export wrapper
replaces it with `mean(-1)` (W axis, a factor) + a constant averaging
matmul using PyTorch's exact bin rule; the parity check (vs the original
torch model with the real pool) proves exactness.
2. **Calibration count 512, not "~500"**: ORT 1.26's histogram collector
`np.asarray()`'s the per-batch maxima, so the calibration count must be a
multiple of the 64-window calibration batch or the ragged last batch
crashes it (the earlier static-PTQ run dodged this by using exactly 512).
| Variant | Disk size | Batch 1 (ms/win) | Batch 64 (ms/win) | PCK@20 | PCK@50 | MPJPE |
|---|---|---|---|---|---|---|
| full ONNX fp32 (same-session ref) | 8.97 MB | 2.27 | 1.42 | 96.68% | 99.15% | 0.00936 |
| full static QDQ Percentile conv-only (same-session ref) | 2.53 MB | 5.53 | 3.82 | 96.61% | 99.16% | 0.01031 |
| **tiny ONNX fp32** | **0.295 MB** | **0.66** | **0.24** | **94.11%** | 99.37% | 0.01253 |
| tiny static QDQ Percentile conv-only | 0.248 MB | 0.85 | 1.03 | 92.68% | 99.33% | 0.01491 |
(tiny torch `.pth` checkpoint for reference: 0.34 MB on disk; 56,290 fp32
params ≈ 225 KB of weights.)
Findings:
- **The smallest deployable WiFlow-class model is the tiny ONNX fp32
artifact: ~295 KB on disk, 0.66 ms/window batch-1 CPU (~1,500 windows/s),
94.1% PCK@20** — 30× smaller and ~3.4× faster (in-session) than the full
ONNX fp32 model for 2.6 pt PCK@20.
- **int8 is a bad trade at this scale.** Static QDQ conv-only — the recipe
that cost the full model only 0.07 pt — costs tiny **1.43 pt** PCK@20
(94.11 → 92.68%) and +19% MPJPE, saves only 47 KB (16%; QDQ scales and
the fp32 BN/attention glue are proportionally larger in a small graph),
and is *slower* than tiny fp32 (0.85 vs 0.66 ms b1; 1.03 vs 0.24 ms b64 —
QDQ kernel overhead dominates when the convs are this small). A 56k-param
model has little redundancy left to absorb weight+activation rounding.
- Deployment guidance, compact edition: ship tiny as **ONNX fp32** — at
295 KB the int8 size saving solves no real constraint and costs accuracy
and speed. If ~250 KB vs ~295 KB ever matters, weight-only quantization
would be the thing to try next, not QDQ.
## Measurement (b): BLOCKED-ON-DATA (attempted 2026-06-10)
The fine-tune-on-ESP32 measurement stopped at dataset characterization, per the
pre-registered stop rule (<2,000 paired windows). Findings (MEASURED):
- **Only one trainable paired dataset exists**: `ruvultra:~/work/cog-pose-train/paired.jsonl`
— 1,077 windows (one subject, one room, one 29.9-min session, single node;
CSI [56, 20]; 17 COCO keypoints, MediaPipe confidence mean 0.44 — only 264
windows pass ADR-079's own conf>0.5 training filter). Prior measured attempts
on this exact set: 03% torso-PCK@20 (temporal splits, three independent
pipelines). Fine-tuning a 2.23M-param model on ~860 train windows would
measure memorization, not transfer.
- **The April session behind the old "92.9% PCK@20" claim is lost** (345
samples, 35 subcarriers; raw CSI gone from ruvzen/ruvultra/cognitum-v0; only
a 69-sample predictions+GT holdout survives at `models/wiflow-real/eval-holdout.jsonl`).
- **Forensic recheck of that holdout RETRACTS the 92.9% figure**: the trainer's
`pck()` used an absolute 0.2 image-unit threshold (not torso-normalized) and
the model output a **constant pose** (pred std 0.0000 across 69 near-static
frames; a mean predictor scores 100% under the same protocol). The
torso-normalized PCK@20 on the same holdout is 19.1%. This corroborates the
2026-05-11 audit retraction (CHANGELOG, PR #535); stale doc citations were
removed 2026-06-10 (user-guide, readme-details, ADR-152 §2.1.3). The §2.2
no-citation rule now applies to ADR-079 accuracy claims.
Unblock criteria: a paired collection session of ≥2k windows (≈35+ min at the
observed stride; multi-pose, conf>0.5, ideally with the §2.1.3 two-checkerboard
calibration), plus a re-baselined our-pipeline number under torso-PCK@20 on the
same split. WiFlow-STD assets stand ready on ruvultra (`~/wiflow-std-bench/`).
Also worth investigating: ADR-079's protocol predicts ~9k windows per 30 min;
the May session under-delivered ~8× (aligner drop rate?).
## Measurement (b) (MEASURED 2026-06-10/11)
The data baseline unblocked: the 2026-06-10 22:1022:40 collection session produced
**2,046 paired windows** (`ruvultra:~/wiflow-std-bench/paired-20260610.jsonl`; ONE
subject, ONE room, ONE ESP32 node, varied poses: walk/raise/squat/kick/wave/turn/
jump/sit; aligner `scripts/align-ground-truth.js`, non-overlapping 20-frame windows
~0.42 s; 17 COCO keypoints in normalized [0,1] camera coords; MediaPipe confidence
mean 0.802, min 0.692 — all windows pass the conf>0.5 filter). The 4 h timestamp
bug and the empty-frame confidence-dilution aligner findings are recorded
separately; results only here. Trained on ruvultra (RTX 5080, torch 2.11+cu128,
fp32, batch 32, GPU shared with the efficiency sweep). Scripts mirrored in
`remote/measb/`; raw metrics + full training curves in `results/measurement_b.json`.
### Two new aligner/dataset findings (forced deviations, MEASURED)
1. **`csi_shape` is heterogeneous, not [70, 20]**: 1,347× [70,20], 284× [134,20],
243× [26,20], 130× [12,20], 42× [20,20]. The ESP32 stream emits mixed frame
types and `extractCsiMatrix` stamps each window's subcarrier count from
`window[0].subcarriers`, zero-padding/truncating the other frames — even
native-70 windows contain ~20.4% internally zero-padded short frames
(subcarriers 4069 all-zero). Handling: the primary suite ("all 2,046")
linearly resamples every frame's subcarrier axis to 70 bins (identity for
native-70 frames) so the pre-registered n and split sizes hold; a secondary
suite restricts to the 1,347 native [70,20] windows as a homogeneity check.
2. **Aligner layout bug**: `extractCsiMatrix` fills `matrix[f * nSc + s]`
(frame-major) but declares `shape: [nSc, nFrames]` — the stored shape label is
transposed relative to the data. Confirmed by coherent per-frame zero-tails;
corrected on load (`reshape(nFrames, nSc).T`).
### Protocol (pre-registered, followed)
Temporal split, no shuffling across time: first 70% train (1,432), next 15% val
(307), last 15% test (307); seed 42 elsewhere. Model: learned 1×1 Conv1d 70→540
adapter prepended to the upstream WiFlow-STD trunk; K=17 via the parameter-free
adaptive pool (`AdaptiveAvgPool2d((17,1))` — pretrained weights load strict for
any K). CSI normalized by the TRAIN-split p99 amplitude (129.7 all / 130.9
native-70), clipped to [0,1]. Three runs, ≤60 epochs, early-stop patience 8 on
val MPJPE, AdamW (adapter lr 1e-4; pretrained trunk lr 1e-5, 10× lower; scratch
all 1e-4), fp32. Pretrained init = the measurement-(a) **retrained** checkpoint
(`upstream/test/best_pose_model.pth`, ~96% PCK@20 on WiFlow data; the
`att.`/`final_conv.` key remap from `eval_repro.py` applied defensively — a no-op,
that checkpoint already uses post-rename keys). Frozen-trunk run: trunk
`requires_grad=False` **and** held in `.eval()` so BatchNorm running stats cannot
drift — a pure transfer probe; only the 70→540 adapter (38,340 params) trains.
PCK is torso-normalized with **torso = ‖l_shoulder(5) l_hip(11)‖** (upstream
`calculate_pck` math — per-frame norm clamped at 0.01, mean over keypoints ×
frames — but upstream's `NECK_IDX/PELVIS_IDX = 2, 12` is a 15-keypoint
convention; on 17-kp COCO those indices are right_eye/right_hip, so the indices
were replaced, not the math). MPJPE is in normalized image units (not meters).
### Results — primary suite, all 2,046 windows (test = last 307)
| Run | PCK@10 | PCK@20 | PCK@30 | PCK@40 | PCK@50 | MPJPE | pred std | best ep |
|---|---|---|---|---|---|---|---|---|
| **mean-pose baseline** (honesty bar) | **73.1%** | **95.9%** | **98.7%** | 99.3% | 99.3% | **0.0148** | 0 (by constr.) | — |
| (i) pretrained-init, full fine-tune | 26.0% | 65.0% | 88.0% | 96.4% | 98.9% | 0.0313 | 0.0113 | 58/60 |
| (ii) scratch | 0.0% | 0.0% | 0.0% | 0.0% | 0.0% | 0.2554 | 0.0002 | 4 (stop @13) |
| (iii) frozen-trunk (adapter only) | 0.0% | 0.0% | 0.2% | 3.2% | 14.4% | 0.1260 | 0.0073 | 59/60 |
Secondary suite (native [70,20] windows only, n=1,347, test=202) reproduces the
same ordering: mean-baseline 96.0% / pretrained 67.1% / scratch 0.0% /
frozen-trunk 0.0% PCK@20 (MPJPE 0.0153 / 0.0318 / 0.2236 / 0.1343) — the
subcarrier-resampling choice does not change any conclusion.
### Interpretation
- **Did pretraining-transfer happen? Partially — as optimization transfer, not
feature transfer, and not past the honesty bar.**
- *Pretrained vs scratch*: dramatic (65.0% vs 0.0% PCK@20). The pretrained init
is the only configuration that trains at all under the pre-registered budget.
- *Frozen-trunk*: near-zero (0.0% PCK@20, 14.4% @50). WiFlow-STD's frozen
features do **not** transfer to our ESP32 domain through a linear subcarrier
adapter — the pretrained benefit is a well-conditioned initialization (incl.
calibrated BN/output scales), not reusable CSI→pose features.
- *Everything vs mean-pose baseline*: **no run beats it.** A constant
train-mean pose scores 95.9% torso-PCK@20 / 0.0148 MPJPE on this test split,
because a single subject in one camera frame barely moves in normalized
coordinates. The fine-tuned model is a real, non-constant model
(pred std 0.0113 > 0 — passes the constant-pose detector that retracted the
old 92.9% figure) but its deviations from the mean hurt: it fits train-period
temporal dynamics that do not generalize across the temporal split.
- **Verdict for ADR-152 §2.2(b): fine-tuning WiFlow-STD on this dataset does not
demonstrate CSI→pose signal beyond the mean pose.** Until a model beats the
mean-pose baseline on a temporal split, no PCK number from this line may be
cited as pose-estimation capability.
### Caveats (honest, pre-registered)
- Single subject, single room, single session (30 min), single ESP32 node —
in-domain temporal split only; nothing here speaks to cross-room or
cross-subject generalization.
- 2k windows vs the 360k-window WiFlow-STD corpus — **NOT comparable** to the
~96% in-domain measurement-(a) number, and the published 97.25% even less so.
- The scratch run's total collapse (it cannot even reach the mean pose; its
output BatchNorm/SiLU head must learn output scale from random init at lr 1e-4)
is an optimization outcome under the fixed budget, not proof the architecture
cannot learn from scratch — the pretrained-vs-scratch gap partially reflects
this conditioning advantage.
- Mixed-subcarrier frames (finding 1) mean even the "clean" windows carry ~20%
zero-padded frames; collection-side frame-type filtering should precede the
next session.
- Mean-baseline PCK is inflated by low pose variance relative to torso size
(~0.20.3 image units); PCK@10 (73.1%) shows the same ceiling effect at a
stricter threshold — the bar is the bar, but a livelier dataset would lower it.
## Pending
- (b) fine-tune on our ESP32 17-keypoint eval set — **MEASURED 2026-06-10/11**,
see above: no run beats the mean-pose baseline; pretraining transfers as
optimization aid only.
- (c) our internal WiFlow on their dataset (15-keypoint subset mapping) — also
affected: there is currently no validated internal pose model to compare
(the 92.9% artifact is retracted; the MM-Fi SOTA models in ADR-150 §3 are a
different input domain).
+200
View File
@@ -0,0 +1,200 @@
"""Shared infrastructure for the LOCAL wiflow-std benchmark scripts (ADR-152).
This module is the single canonical implementation of the helpers that were
previously copy-pasted across eval_repro.py / quantize_bench.py /
onnx_bench.py / eval_ort_accuracy.py / export_to_safetensors.py:
- ``import_upstream()`` -- sys.path setup + the models-package stub that
works around the upstream import bug, plus the >1GB np.load mmap patch
- ``install_np_load_mmap_patch()`` -- the mmap patch on its own
- ``remap_legacy_keys()`` / ``load_remapped_state()`` -- checkpoint
key remap for the pre-rename released checkpoint
- ``load_wiflow_model()`` -- WiFlowPoseModel from a checkpoint, eval mode
- ``set_seed()`` -- mirrors upstream run.py seeding exactly
- ``evaluate()`` -- THE canonical batch-weighted PCK/MPJPE evaluation loop
(thresholds 0.1-0.5, upstream utils/metrics.py math); accepts either a
torch nn.Module or an onnxruntime InferenceSession
The scripts under remote/ deploy to ruvultra as standalone single files and
therefore intentionally inline private copies of these helpers; when editing
them, treat this module as the reference implementation and keep the copies
in sync.
"""
import os
import random
import sys
import time
import types
import numpy as np
import torch
HERE = os.path.dirname(os.path.abspath(__file__))
UPSTREAM = os.path.join(HERE, "upstream")
RESULTS = os.path.join(HERE, "results")
DEFAULT_THRESHOLDS = (0.1, 0.2, 0.3, 0.4, 0.5)
# ---------------------------------------------------------------------------
# >1GB np.load mmap patch
# ---------------------------------------------------------------------------
# csi_windows.npy is ~13 GB; mmap large arrays instead of loading into RAM
# (loading it eagerly needs ~15 GB).
_np_load = np.load
def _np_load_mmap(path, *a, **kw):
if (isinstance(path, str) and path.endswith(".npy")
and os.path.getsize(path) > 1 << 30 and "mmap_mode" not in kw):
kw["mmap_mode"] = "r"
return _np_load(path, *a, **kw)
def install_np_load_mmap_patch():
"""Globally patch np.load so .npy files >1GB are mmap'd read-only.
Idempotent. Patching the numpy module attribute is equivalent to the
historical ``upstream_dataset.np.load = _np_load_mmap`` (dataset.np IS
the numpy module), but works regardless of import order.
"""
np.load = _np_load_mmap
# ---------------------------------------------------------------------------
# upstream import shim
# ---------------------------------------------------------------------------
def import_upstream(mmap_patch=True):
"""Make the upstream WiFlow-STD clone importable; returns its path.
Upstream bug: models/__init__.py imports TemporalConvNet, which
models/tcn.py does not define -- the package fails to import as
published. Register a stub package so the broken __init__ never
executes; submodules (models.pose_model etc.) still resolve via
__path__. Idempotent.
"""
if UPSTREAM not in sys.path:
sys.path.insert(0, UPSTREAM)
if "models" not in sys.modules:
_models_pkg = types.ModuleType("models")
_models_pkg.__path__ = [os.path.join(UPSTREAM, "models")]
sys.modules["models"] = _models_pkg
if mmap_patch:
install_np_load_mmap_patch()
return UPSTREAM
# ---------------------------------------------------------------------------
# checkpoint loading
# ---------------------------------------------------------------------------
# The released checkpoint predates the published code: modules were renamed
# att -> attention, final_conv -> decoder (param count identical, 2.23M).
LEGACY_RENAMES = {"att.": "attention.", "final_conv.": "decoder."}
def remap_legacy_keys(state):
"""Remap pre-rename state_dict keys; no-op for already-new-style keys."""
return {next((new + k[len(old):] for old, new in LEGACY_RENAMES.items()
if k.startswith(old)), k): v
for k, v in state.items()}
def load_remapped_state(path, map_location="cpu"):
"""torch.load (weights_only) + legacy key remap."""
state = torch.load(path, map_location=map_location, weights_only=True)
return remap_legacy_keys(state)
def load_wiflow_model(checkpoint, map_location="cpu", dropout=0.5):
"""Full-size WiFlowPoseModel from a checkpoint, strict load, eval mode."""
import_upstream()
from models.pose_model import WiFlowPoseModel
model = WiFlowPoseModel(dropout=dropout)
model.load_state_dict(load_remapped_state(checkpoint, map_location),
strict=True)
model.eval()
return model
# ---------------------------------------------------------------------------
# seeding
# ---------------------------------------------------------------------------
def set_seed(seed=42):
# mirror upstream run.py exactly
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
# ---------------------------------------------------------------------------
# THE canonical evaluation loop
# ---------------------------------------------------------------------------
def evaluate(model, loader, device=None, dtype=None, label="",
thresholds=DEFAULT_THRESHOLDS, progress_every=50):
"""Batch-weighted PCK/MPJPE over a DataLoader (upstream metrics math).
``model`` may be a torch nn.Module (optionally evaluated on ``device``
with inputs cast to ``dtype``) or an onnxruntime InferenceSession.
Per-threshold PCK values are independent in upstream calculate_pck, so
evaluating a superset of thresholds never changes any individual value.
Returns {"samples", "mpjpe", "pck@10".."pck@50", "wall_seconds"}.
"""
import_upstream()
from utils.metrics import calculate_mpjpe, calculate_pck
is_ort = hasattr(model, "get_inputs") # onnxruntime InferenceSession
if is_ort:
inp = model.get_inputs()[0].name
def forward(bx):
return torch.from_numpy(model.run(None, {inp: bx.numpy()})[0])
else:
model.eval()
def forward(bx):
if device is not None:
bx = bx.to(device)
if dtype is not None:
bx = bx.to(dtype)
return model(bx).float()
thresholds = list(thresholds)
totals = {t: 0.0 for t in thresholds}
total_mpe, n = 0.0, 0
t0 = time.time()
with torch.no_grad():
for batch_idx, (bx, by) in enumerate(loader):
out = forward(bx)
if device is not None and not is_ort:
by = by.to(device)
mpe = calculate_mpjpe(out, by)
pck = calculate_pck(out, by, thresholds=thresholds)
bs = by.size(0)
total_mpe += mpe * bs
for t in totals:
totals[t] += pck[t] * bs
n += bs
if batch_idx % progress_every == 0:
tag = f"[{label}] " if label else ""
pck20 = totals.get(0.2)
pck20_str = f"pck20={pck20 / n:.4f} " if pck20 is not None else ""
print(f" {tag}batch {batch_idx}: n={n} {pck20_str}"
f"mpjpe={total_mpe / n:.4f} ({time.time() - t0:.0f}s)",
flush=True)
return {
"samples": n,
"mpjpe": total_mpe / n,
**{f"pck@{int(t * 100)}": totals[t] / n for t in thresholds},
"wall_seconds": time.time() - t0,
}
@@ -0,0 +1,67 @@
"""ADR-152 edge optimization: accuracy of the ONNX fp32 and ORT-dynamic-int8
models on the same corruption-free 10k test subset used by quantize_bench.py.
The torch dynamic-int8 path quantizes nothing (no nn.Linear in the model), so
the only real int8 datapoint for the paper's "~2.2 MB int8" claim is the
onnxruntime dynamically quantized model -- this script measures what that
quantization costs in PCK/MPJPE.
Usage:
.venv/Scripts/python.exe eval_ort_accuracy.py \
--data-dir <preprocessed_csi_data> [--subset 10000]
Writes/merges into results/edge_optimization.json under key "onnx_accuracy".
"""
import argparse
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from _bench_common import RESULTS, evaluate # noqa: E402
from quantize_bench import build_test_subset # noqa: E402 (sets up upstream imports)
def evaluate_ort(sess, loader, label):
"""ORT-session evaluation via the canonical _bench_common.evaluate loop."""
return evaluate(sess, loader, label=label)
def main():
import onnxruntime as ort
parser = argparse.ArgumentParser()
parser.add_argument("--data-dir", default=os.path.join(
os.path.expanduser("~"), ".cache", "kagglehub", "datasets", "kaka2434",
"wiflow-dataset", "versions", "1", "preprocessed_csi_data"))
parser.add_argument("--subset", type=int, default=10000)
parser.add_argument("--out", default=os.path.join(RESULTS, "edge_optimization.json"))
args = parser.parse_args()
loader, _n_clean = build_test_subset(args.data_dir, args.subset)
results = {}
for label, fname in (("onnx_fp32", "retrained_fp32_dynamic.onnx"),
("onnx_int8_ort_dynamic", "retrained_int8_ort_dynamic.onnx")):
path = os.path.join(RESULTS, fname)
if not os.path.exists(path):
results[label] = {"error": f"{fname} not found; run onnx_bench.py first"}
continue
sess = ort.InferenceSession(path, providers=["CPUExecutionProvider"])
print(f"=== accuracy: {label} ({fname}) ===")
results[label] = evaluate_ort(sess, loader, label)
print(json.dumps(results[label], indent=2))
merged = {}
if os.path.exists(args.out):
with open(args.out) as f:
merged = json.load(f)
merged["onnx_accuracy"] = results
with open(args.out, "w") as f:
json.dump(merged, f, indent=2)
print(f"wrote {args.out}")
if __name__ == "__main__":
main()
+102
View File
@@ -0,0 +1,102 @@
"""ADR-152 §2.2 measurement (a): reproduce WiFlow-STD (DY2434) published test metrics.
Runs the released pretrained checkpoint (upstream/best_pose_model.pth) against the
released Kaggle dataset (kaka2434/wiflow-dataset) using the upstream code path:
identical dataset class, identical file-level 70/15/15 split at seed 42, identical
PCK/MPJPE implementations (utils/metrics.py).
Published claims (README, "Setting 1 random split"):
PCK@20 97.25% | PCK@30 98.63% | PCK@40 99.16% | PCK@50 99.48% | MPJPE 0.007 m
Usage:
.venv/Scripts/python.exe eval_repro.py --data-dir <dir containing csi_windows.npy>
"""
import argparse
import json
import os
import sys
import torch
from torch.utils.data import DataLoader
from _bench_common import (UPSTREAM, evaluate, import_upstream,
load_remapped_state, set_seed)
import_upstream() # sys.path + models stub + >1GB np.load mmap patch
from dataset import PreprocessedCSIKeypointsDataset, create_preprocessed_train_val_test_loaders # noqa: E402
from models.pose_model import WiFlowPoseModel # noqa: E402
def find_data_dir(root):
for dirpath, _dirnames, filenames in os.walk(root):
if "csi_windows.npy" in filenames:
return dirpath
return None
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--data-dir", required=True,
help="Directory containing csi_windows.npy (searched recursively)")
parser.add_argument("--checkpoint", default=os.path.join(UPSTREAM, "best_pose_model.pth"))
parser.add_argument("--batch-size", type=int, default=64)
parser.add_argument("--out", default=os.path.join(os.path.dirname(os.path.abspath(__file__)),
"results", "repro_a.json"))
args = parser.parse_args()
data_dir = args.data_dir
if not os.path.exists(os.path.join(data_dir, "csi_windows.npy")):
located = find_data_dir(data_dir)
if located is None:
sys.exit(f"csi_windows.npy not found under {data_dir}")
data_dir = located
print(f"data dir: {data_dir}")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"device: {device}, torch {torch.__version__}")
set_seed(42)
dataset = PreprocessedCSIKeypointsDataset(
data_dir=data_dir, keypoint_scale=1000.0, enable_temporal_clean=True)
# split must match upstream: file-level shuffle at random_seed=42, 70/15/15
_train_loader, _val_loader, test_loader = create_preprocessed_train_val_test_loaders(
dataset=dataset, batch_size=args.batch_size, num_workers=0, random_seed=42)
model = WiFlowPoseModel(dropout=0.5).to(device)
# released checkpoint predates the published code: modules were renamed
# att -> attention, final_conv -> decoder (param count identical, 2.23M)
state = load_remapped_state(args.checkpoint, map_location=device)
model.load_state_dict(state, strict=True)
n_params = sum(p.numel() for p in model.parameters())
print(f"checkpoint: {args.checkpoint} ({n_params/1e6:.2f}M params)")
# upstream also evaluates with drop_last=True; we report the full test set
# (drop_last=False) and the drop_last variant for exact comparability
results = {"published": {"pck@20": 0.9725, "pck@30": 0.9863, "pck@40": 0.9916,
"pck@50": 0.9948, "mpjpe": 0.007},
"params_millions": n_params / 1e6,
"data_dir": data_dir,
"device": str(device)}
print("=== test set (full, drop_last=False) ===")
results["test_full"] = evaluate(model, test_loader, device=device)
print(json.dumps(results["test_full"], indent=2))
test_loader_dl = DataLoader(test_loader.dataset, batch_size=args.batch_size,
shuffle=False, drop_last=True)
print("=== test set (drop_last=True, as upstream train.py) ===")
results["test_drop_last"] = evaluate(model, test_loader_dl, device=device)
print(json.dumps(results["test_drop_last"], indent=2))
os.makedirs(os.path.dirname(args.out), exist_ok=True)
with open(args.out, "w") as f:
json.dump(results, f, indent=2)
print(f"wrote {args.out}")
if __name__ == "__main__":
main()
@@ -0,0 +1,174 @@
"""ADR-152 §2.2: export the retrained WiFlow-STD PyTorch checkpoint to
safetensors with tch-rs (VarStore) variable names, plus a numerical-parity
fixture for the Rust port.
Outputs (all under results/, gitignored):
retrained_wiflow_std.safetensors -- 248 f32 tensors named exactly as the
Rust WiFlowStdModel VarStore expects
(see wiflow_std/model.rs
`dump_variable_names` for the
authoritative name dump)
parity_fixture.npz -- deterministic input (seed 42,
shape (2, 540, 20), uniform [0,1]) and
the Python model's eval-mode output
parity_fixture.json -- same data as flattened f32 lists, for
the dependency-free Rust test
(tests/test_wiflow_std_parity.rs)
PyTorch -> tch key mapping (derived from the VarStore dump, not guessed):
tcn.network.{i}.conv1_group.weight -> tcn{i}.conv1_group.weight
tcn.network.{i}.bn*_{group,pw}.<leaf> -> tcn{i}.bn*_{group,pw}.<leaf>
tcn.network.{i}.downsample.0.weight -> tcn{i}.ds_conv.weight
tcn.network.{i}.downsample.1.<leaf> -> tcn{i}.ds_bn.<leaf>
up.block.{0,1,4,5,8,9}.<leaf> -> conv_in.{conv1,bn1,conv2,bn2,conv3,bn3}.<leaf>
up.downsample.{0,1}.<leaf> -> conv_in.{ds_conv,ds_bn}.<leaf>
residual_blocks.{i}.block.{...}.<leaf> -> conv{i}.{conv1..bn3}.<leaf>
residual_blocks.{i}.downsample.{0,1} -> conv{i}.{ds_conv,ds_bn}
attention.{width,height}_axis.qkv_transform.weight
-> attention.{width,height}.qkv.weight
attention.{width,height}_axis.bn_* -> attention.{width,height}.bn_*
decoder.{0,1,3,4}.<leaf> -> {dec_conv1,dec_bn1,dec_conv2,dec_bn2}.<leaf>
*.num_batches_tracked -> dropped (tch BatchNorm has no such buffer)
Legacy upstream names (att. -> attention., final_conv. -> decoder.) are
remapped first, exactly as eval_repro.py does for the released checkpoint.
Usage:
.venv/Scripts/python.exe export_to_safetensors.py
"""
import json
import os
import re
import numpy as np
import torch
from safetensors.torch import save_file
from _bench_common import RESULTS, import_upstream, remap_legacy_keys
import_upstream() # sys.path + models stub
from models.pose_model import WiFlowPoseModel # noqa: E402
CHECKPOINT = os.path.join(RESULTS, "retrained_best_pose_model.pth")
# Sequential index -> tch sub-name inside one ConvBlock1/AsymmetricConvBlock:
# [Conv2d(0), BN(1), SiLU(2), Dropout2d(3), Conv2d(4), BN(5), SiLU(6),
# Dropout2d(7), Conv2d(8), BN(9)]
_BLOCK_IDX = {"0": "conv1", "1": "bn1", "4": "conv2", "5": "bn2",
"8": "conv3", "9": "bn3"}
_DS_IDX = {"0": "ds_conv", "1": "ds_bn"}
_DECODER_IDX = {"0": "dec_conv1", "1": "dec_bn1", "3": "dec_conv2",
"4": "dec_bn2"}
def _conv_block(new_prefix: str, rest: str) -> str:
m = re.fullmatch(r"block\.(\d+)\.(.+)", rest)
if m:
return f"{new_prefix}.{_BLOCK_IDX[m.group(1)]}.{m.group(2)}"
m = re.fullmatch(r"downsample\.(\d+)\.(.+)", rest)
if m:
return f"{new_prefix}.{_DS_IDX[m.group(1)]}.{m.group(2)}"
raise KeyError(f"unmapped conv-block key: {new_prefix} / {rest}")
def map_key(key: str) -> str:
"""Map one PyTorch state_dict key to the tch VarStore name."""
m = re.fullmatch(r"tcn\.network\.(\d+)\.(.+)", key)
if m:
i, rest = m.groups()
rest = (rest.replace("downsample.0.", "ds_conv.")
.replace("downsample.1.", "ds_bn."))
return f"tcn{i}.{rest}"
m = re.fullmatch(r"up\.(.+)", key)
if m:
return _conv_block("conv_in", m.group(1))
m = re.fullmatch(r"residual_blocks\.(\d+)\.(.+)", key)
if m:
return _conv_block(f"conv{m.group(1)}", m.group(2))
m = re.fullmatch(r"attention\.(width|height)_axis\.(.+)", key)
if m:
axis, rest = m.groups()
rest = rest.replace("qkv_transform.", "qkv.")
return f"attention.{axis}.{rest}"
m = re.fullmatch(r"decoder\.(\d+)\.(.+)", key)
if m:
return f"{_DECODER_IDX[m.group(1)]}.{m.group(2)}"
raise KeyError(f"unmapped checkpoint key: {key}")
def main():
state = torch.load(CHECKPOINT, map_location="cpu", weights_only=True)
if not isinstance(state, dict) or "tcn.network.0.conv1_group.weight" not in {
k for k in state
} | {k.replace("att.", "attention.") for k in state}:
# tolerate trainer wrappers like {"model_state_dict": ...}
for wrapper in ("model_state_dict", "state_dict", "model"):
if isinstance(state, dict) and wrapper in state:
state = state[wrapper]
break
# Legacy upstream names predate the published code (_bench_common).
state = remap_legacy_keys(state)
mapped = {}
dropped = 0
for k, v in state.items():
if k.endswith("num_batches_tracked"):
dropped += 1
continue
tch_key = map_key(k)
if tch_key in mapped:
raise KeyError(f"duplicate mapped key: {k} -> {tch_key}")
mapped[tch_key] = v.detach().to(torch.float32).contiguous()
n_params = sum(v.numel() for k, v in mapped.items()
if "running_" not in k)
print(f"checkpoint tensors: {len(state)} "
f"(dropped {dropped} num_batches_tracked)")
print(f"mapped tensors: {len(mapped)}, "
f"non-buffer params: {n_params/1e6:.6f}M")
assert len(mapped) == 248, f"expected 248 tch variables, got {len(mapped)}"
assert n_params == 2_225_042, f"param count mismatch: {n_params}"
st_path = os.path.join(RESULTS, "retrained_wiflow_std.safetensors")
save_file(mapped, st_path)
print(f"wrote {st_path}")
# ---- parity fixture --------------------------------------------------
model = WiFlowPoseModel(dropout=0.5)
model.load_state_dict(state, strict=True)
model.eval()
gen = torch.Generator().manual_seed(42)
x = torch.rand(2, 540, 20, generator=gen, dtype=torch.float32)
with torch.no_grad():
y = model(x)
print(f"fixture input {tuple(x.shape)} -> output {tuple(y.shape)}, "
f"output range [{y.min().item():.6f}, {y.max().item():.6f}]")
np.savez(os.path.join(RESULTS, "parity_fixture.npz"),
input=x.numpy(), output=y.numpy())
fixture = {
"seed": 42,
"input_shape": list(x.shape),
"input": x.flatten().tolist(),
"output_shape": list(y.shape),
"output": y.flatten().tolist(),
}
json_path = os.path.join(RESULTS, "parity_fixture.json")
with open(json_path, "w") as f:
json.dump(fixture, f)
print(f"wrote {os.path.join(RESULTS, 'parity_fixture.npz')}")
print(f"wrote {json_path}")
if __name__ == "__main__":
main()
@@ -0,0 +1,148 @@
"""Regenerate results/nan_windows_mask.npy + results/big_windows_mask.npy by
scanning a PRISTINE kagglehub download of the WiFlow-STD dataset
(kaka2434/wiflow-dataset v1, csi_windows.npy, 360,000 windows of 540x20).
============================ READ THIS FIRST ===============================
This script MUST be run against an UNCLEANED copy of the dataset.
remote/clean_v2.py (and its predecessor clean_nan.py) repair the dataset by
zeroing the corrupted windows IN PLACE, with no backup. A cleaned copy
contains no non-finite values and no out-of-range amplitudes, so on a cleaned
copy this scan produces ALL-FALSE masks -- silently wrong ground truth. The
script errors out loudly in that case (see the sanity check in main()).
That irreversibility is exactly why the two committed mask files under
results/ (gitignore-negated) are the canonical ground truth: once a download
has been cleaned, the masks can NEVER be regenerated from it. Only run this
on a fresh `kagglehub.dataset_download("kaka2434/wiflow-dataset")`.
============================================================================
Criteria (per window; mirrors the original 2026-06-10 scan and the
remote/clean_v2.py repair criteria):
nan mask: any non-finite value (NaN/Inf) anywhere in the 540x20 window
big mask: max |finite value| > 1.5 (the data is otherwise [0,1]-normalized;
the corrupted files contain garbage up to 3.4e38, float32 max)
Expected result on the pristine Kaggle download (RESULTS.md defect 5):
nan: 9,070 True | big: 9,072 True | union: 9,072 -- all windows in dataset
files 487-499 (the final 13 files), window indices 350,922-359,999.
Usage:
PYTHONUTF8=1 .venv/Scripts/python.exe generate_corruption_masks.py \
[--data-dir <dir containing csi_windows.npy>] [--out-dir results]
"""
import argparse
import os
import sys
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
RESULTS = os.path.join(HERE, "results")
EXPECTED = {"nan": 9070, "big": 9072, "union": 9072,
"files": (487, 499), "windows": (350922, 359999)}
def scan(csi_path, chunk=4000):
"""Chunked scan of the (mmap'd) windows array; returns (nan_mask, big_mask)."""
csi = np.load(csi_path, mmap_mode="r")
n = len(csi)
nan_mask = np.zeros(n, dtype=bool)
big_mask = np.zeros(n, dtype=bool)
for i in range(0, n, chunk):
block = np.asarray(csi[i:i + chunk])
finite = np.isfinite(block)
nan_mask[i:i + chunk] = (~finite).any(axis=(1, 2))
big_mask[i:i + chunk] = (
np.abs(np.where(finite, block, 0)).max(axis=(1, 2)) > 1.5)
if (i // chunk) % 10 == 0:
print(f" scanned {min(i + chunk, n):,}/{n:,} windows "
f"(nan={int(nan_mask.sum()):,} big={int(big_mask.sum()):,})",
flush=True)
return nan_mask, big_mask
def describe_files(data_dir, mask):
"""Map marked windows to dataset file indices via window_info.npz."""
info = os.path.join(data_dir, "window_info.npz")
if not os.path.exists(info):
return None
w2f = np.load(info)["window_to_file"]
return np.unique(w2f[mask])
def main():
parser = argparse.ArgumentParser(
description="Regenerate the corruption masks from a PRISTINE "
"(uncleaned) kagglehub download. See module docstring.")
parser.add_argument("--data-dir", default=os.path.join(
os.path.expanduser("~"), ".cache", "kagglehub", "datasets", "kaka2434",
"wiflow-dataset", "versions", "1", "preprocessed_csi_data"),
help="Directory containing csi_windows.npy (PRISTINE copy)")
parser.add_argument("--out-dir", default=RESULTS,
help="Where to write the two .npy masks")
parser.add_argument("--chunk", type=int, default=4000,
help="Windows per scan chunk (memory/speed tradeoff)")
args = parser.parse_args()
csi_path = os.path.join(args.data_dir, "csi_windows.npy")
if not os.path.exists(csi_path):
sys.exit(f"csi_windows.npy not found in {args.data_dir}")
print(f"scanning {csi_path} (chunk={args.chunk}) ...")
nan_mask, big_mask = scan(csi_path, args.chunk)
union = nan_mask | big_mask
print(f"nan: {int(nan_mask.sum()):,} | big: {int(big_mask.sum()):,} | "
f"union: {int(union.sum()):,} of {len(union):,} windows")
# ---- sanity check: an all-False result means a CLEANED copy ------------
if not union.any():
sys.exit(
"ERROR: scan found ZERO corrupted windows.\n"
"\n"
"The pristine Kaggle download (kaka2434/wiflow-dataset v1) is "
"known to contain\n"
"9,072 corrupted windows (NaN/Inf + amplitudes up to 3.4e38) in "
"dataset files\n"
"487-499 (RESULTS.md, reproducibility defect 5). Finding none "
"means this copy\n"
"has almost certainly already been repaired by remote/clean_v2.py "
"(or clean_nan.py),\n"
"which zeroes the corrupted windows IN PLACE -- after that the "
"corruption evidence\n"
"is gone and the masks CANNOT be regenerated from this copy.\n"
"\n"
"Refusing to overwrite the committed ground-truth masks with "
"all-False ones.\n"
"Re-download the dataset (kagglehub.dataset_download("
"'kaka2434/wiflow-dataset'))\n"
"and point --data-dir at the fresh, uncleaned copy.")
files = describe_files(args.data_dir, union)
if files is not None:
print(f"marked windows span dataset files {files.min()}-{files.max()}: "
f"{files.tolist()}")
lo, hi = EXPECTED["files"]
if files.min() != lo or files.max() != hi:
print(f"WARNING: expected marked files exactly {lo}-{hi} "
f"(the pristine v1 download); got {files.min()}-{files.max()}. "
f"Different dataset version, or a partially cleaned copy?")
for name, mask, exp in (("nan", nan_mask, EXPECTED["nan"]),
("big", big_mask, EXPECTED["big"])):
if int(mask.sum()) != exp:
print(f"WARNING: {name} mask has {int(mask.sum()):,} True windows; "
f"the pristine v1 download yields {exp:,}.")
os.makedirs(args.out_dir, exist_ok=True)
for name, mask in (("nan_windows_mask.npy", nan_mask),
("big_windows_mask.npy", big_mask)):
out = os.path.join(args.out_dir, name)
np.save(out, mask)
print(f"wrote {out} ({int(mask.sum()):,} True)")
if __name__ == "__main__":
main()
+220
View File
@@ -0,0 +1,220 @@
"""ADR-152 edge optimization: ONNX export + onnxruntime CPU benchmark for the
retrained WiFlow-STD checkpoint.
- Exports fp32 to ONNX. The axial attention reshapes with python ints taken
from tensor.size() (view(N*W, C, H)), so a traced graph bakes the batch
size; we first try a dynamic-batch export and verify it actually works at
batch sizes 1/2/64 -- if not, we fall back to fixed-batch exports.
- Verifies output parity vs torch on the stored fixture
(results/parity_fixture.npz, batch 2, seed 42): max abs diff < 1e-4.
- Measures onnxruntime CPU latency at batch 1 and 64 (median of N runs).
- Supplementary: onnxruntime dynamic int8 quantization of the exported model
(weight size datapoint for the paper's "~2.2 MB int8" claim).
Usage:
.venv/Scripts/python.exe onnx_bench.py
Writes/merges into results/edge_optimization.json under key "onnx".
"""
import json
import os
import platform
import statistics
import time
import traceback
import numpy as np
import torch
from _bench_common import RESULTS, import_upstream, load_wiflow_model
import_upstream() # sys.path + models stub + >1GB np.load mmap patch
CHECKPOINT = os.path.join(RESULTS, "retrained_best_pose_model.pth")
OUT_JSON = os.path.join(RESULTS, "edge_optimization.json")
def load_fp32_model():
return load_wiflow_model(CHECKPOINT)
def try_export(model, path, batch, dynamic, opset=17):
"""Returns (ok, exporter_used, error)."""
x = torch.rand(batch, 540, 20)
attempts = []
if dynamic:
attempts.append(("dynamo", dict(dynamo=True,
dynamic_shapes={"x": {0: "batch"}})))
attempts.append(("torchscript", dict(dynamo=False,
dynamic_axes={"input": {0: "batch"},
"output": {0: "batch"}})))
else:
attempts.append(("torchscript", dict(dynamo=False)))
attempts.append(("dynamo", dict(dynamo=True)))
last_err = None
for name, kw in attempts:
try:
with torch.no_grad():
torch.onnx.export(model, (x,), path, opset_version=opset,
input_names=["input"], output_names=["output"],
**kw)
return True, name, None
except Exception as e: # noqa: BLE001
last_err = f"{name}: {type(e).__name__}: {e}"
traceback.print_exc()
return False, None, last_err
def ort_session(path):
import onnxruntime as ort
return ort.InferenceSession(path, providers=["CPUExecutionProvider"])
def ort_run(sess, x):
inp = sess.get_inputs()[0].name
return sess.run(None, {inp: x})[0]
def bench_ort(sess, batch, n_runs):
rng = np.random.default_rng(123)
x = rng.random((batch, 540, 20), dtype=np.float32)
for _ in range(max(5, n_runs // 10)):
ort_run(sess, x)
times = []
for _ in range(n_runs):
t0 = time.perf_counter()
ort_run(sess, x)
times.append(time.perf_counter() - t0)
med = statistics.median(times)
return {
"batch_size": batch,
"runs": n_runs,
"median_ms_per_batch": med * 1e3,
"median_ms_per_window": med * 1e3 / batch,
"windows_per_second": batch / med,
}
def main():
import argparse
parser = argparse.ArgumentParser(
description="ONNX export + onnxruntime CPU benchmark for the "
"retrained WiFlow-STD checkpoint (no options; see "
"module docstring). NB: the published "
"retrained_fp32_dynamic.onnx came from the TorchScript "
"exporter; on newer torch the dynamo attempt may succeed "
"first and produce a different (external-data) artifact.")
parser.parse_args()
import onnxruntime
model = load_fp32_model()
results = {
"env": {
"torch": torch.__version__,
"onnxruntime": onnxruntime.__version__,
"platform": platform.platform(),
},
}
fixture = np.load(os.path.join(RESULTS, "parity_fixture.npz"))
fx, fy = fixture["input"], fixture["output"] # (2,540,20) -> (2,15,2)
# ---- export: dynamic batch first, fall back to fixed --------------------
dyn_path = os.path.join(RESULTS, "retrained_fp32_dynamic.onnx")
ok, exporter, err = try_export(model, dyn_path, batch=2, dynamic=True)
dynamic_works = False
if ok:
# verify the dynamic graph really runs at other batch sizes
try:
sess = ort_session(dyn_path)
for b in (1, 2, 64):
y = ort_run(sess, np.zeros((b, 540, 20), dtype=np.float32))
assert y.shape == (b, 15, 2), y.shape
dynamic_works = True
except Exception as e: # noqa: BLE001
print(f"dynamic-batch model does not generalize: {e}")
sessions = {}
if dynamic_works:
results["export"] = {"mode": "dynamic-batch", "exporter": exporter,
"file": os.path.basename(dyn_path),
"size_mb": os.path.getsize(dyn_path) / 1e6}
sess = ort_session(dyn_path)
sessions = {1: sess, 2: sess, 64: sess}
print(f"dynamic-batch export OK via {exporter}")
else:
results["export"] = {"mode": "fixed-batch", "fallback_reason": err,
"files": {}}
for b in (1, 2, 64):
p = os.path.join(RESULTS, f"retrained_fp32_b{b}.onnx")
ok, exporter, err = try_export(model, p, batch=b, dynamic=False)
if not ok:
results["export"]["files"][str(b)] = {"error": err}
print(f"EXPORT FAILED at batch {b}: {err}")
continue
results["export"]["files"][str(b)] = {
"exporter": exporter, "file": os.path.basename(p),
"size_mb": os.path.getsize(p) / 1e6}
sessions[b] = ort_session(p)
print(f"fixed-batch {b} export OK via {exporter}")
# ---- parity vs torch on the fixture -------------------------------------
if 2 in sessions:
y_ort = ort_run(sessions[2], fx)
with torch.no_grad():
y_torch = model(torch.from_numpy(fx)).numpy()
results["parity"] = {
"fixture": "results/parity_fixture.npz (batch 2, seed 42)",
"max_abs_diff_vs_stored_fixture": float(np.abs(y_ort - fy).max()),
"max_abs_diff_vs_torch_now": float(np.abs(y_ort - y_torch).max()),
"pass_lt_1e-4": bool(np.abs(y_ort - y_torch).max() < 1e-4),
}
print("parity:", json.dumps(results["parity"], indent=2))
# ---- latency -------------------------------------------------------------
results["latency"] = {}
if 1 in sessions:
results["latency"]["batch1"] = bench_ort(sessions[1], 1, 100)
print(f"ORT batch 1: {results['latency']['batch1']['median_ms_per_window']:.2f} ms/window")
if 64 in sessions:
results["latency"]["batch64"] = bench_ort(sessions[64], 64, 30)
print(f"ORT batch 64: {results['latency']['batch64']['median_ms_per_window']:.3f} ms/window")
# ---- supplementary: ORT dynamic int8 (size datapoint for the 2.2MB claim)
src = (dyn_path if dynamic_works
else os.path.join(RESULTS, "retrained_fp32_b1.onnx"))
if os.path.exists(src):
try:
from onnxruntime.quantization import QuantType, quantize_dynamic
q_path = os.path.join(RESULTS, "retrained_int8_ort_dynamic.onnx")
quantize_dynamic(src, q_path, weight_type=QuantType.QInt8)
entry = {"file": os.path.basename(q_path),
"size_mb": os.path.getsize(q_path) / 1e6}
try:
qs = ort_session(q_path)
yq = ort_run(qs, fx[:1] if not dynamic_works else fx)
ref = fy[:1] if not dynamic_works else fy
entry["runs"] = True
entry["max_abs_diff_vs_fp32_fixture"] = float(np.abs(yq - ref).max())
except Exception as e: # noqa: BLE001
entry["runs"] = False
entry["run_error"] = f"{type(e).__name__}: {e}"
results["ort_int8_dynamic_supplementary"] = entry
print("ORT int8:", json.dumps(entry, indent=2))
except Exception as e: # noqa: BLE001
results["ort_int8_dynamic_supplementary"] = {
"error": f"{type(e).__name__}: {e}"}
merged = {}
if os.path.exists(OUT_JSON):
with open(OUT_JSON) as f:
merged = json.load(f)
merged["onnx"] = results
with open(OUT_JSON, "w") as f:
json.dump(merged, f, indent=2)
print(f"wrote {OUT_JSON}")
if __name__ == "__main__":
main()
+228
View File
@@ -0,0 +1,228 @@
"""ADR-152 "optimize beyond SOTA": edge-optimization benchmark for the
retrained WiFlow-STD checkpoint (results/retrained_best_pose_model.pth,
~96% PCK@20, fp32 params 2,225,042).
Measures, for fp32 / fp16 / dynamic-int8 torch variants:
(a) serialized state_dict size on disk,
(b) CPU inference latency per window at batch 1 and batch 64
(median of repeated runs, this Windows box),
(c) accuracy (PCK@20/50 + MPJPE, upstream metrics) on a corruption-free
random subset of the seed-42 file-level 70/15/15 test split
(same split as eval_repro.py; corrupted windows 487-499 excluded via
results/nan_windows_mask.npy | results/big_windows_mask.npy).
Also verifies the paper's "~2.2 MB int8" size claim: reports which layer
types torch dynamic quantization actually converts (the model contains NO
nn.Linear -- it is Conv1d/Conv2d/BatchNorm only) and the real on-disk size.
Usage:
.venv/Scripts/python.exe quantize_bench.py \
--data-dir C:/Users/ruv/.cache/kagglehub/datasets/kaka2434/wiflow-dataset/versions/1/preprocessed_csi_data \
[--subset 10000] [--skip-accuracy]
Writes/merges into results/edge_optimization.json under key "torch".
"""
import argparse
import json
import os
import platform
import statistics
import time
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from _bench_common import HERE, RESULTS, evaluate, import_upstream, load_wiflow_model
import_upstream() # sys.path + models stub + >1GB np.load mmap patch
from dataset import ( # noqa: E402
PreprocessedCSIKeypointsDataset,
create_preprocessed_train_val_test_loaders,
)
CHECKPOINT = os.path.join(RESULTS, "retrained_best_pose_model.pth")
def load_fp32_model():
# legacy upstream key remap inside is a harmless no-op on this checkpoint
return load_wiflow_model(CHECKPOINT)
def state_dict_size_bytes(model, path):
torch.save(model.state_dict(), path)
return os.path.getsize(path)
def bench_latency(model, batch_size, n_runs, dtype=torch.float32):
gen = torch.Generator().manual_seed(123)
x = torch.rand(batch_size, 540, 20, generator=gen).to(dtype)
with torch.no_grad():
for _ in range(max(5, n_runs // 10)): # warmup
model(x)
times = []
for _ in range(n_runs):
t0 = time.perf_counter()
model(x)
times.append(time.perf_counter() - t0)
med = statistics.median(times)
return {
"batch_size": batch_size,
"runs": n_runs,
"median_ms_per_batch": med * 1e3,
"median_ms_per_window": med * 1e3 / batch_size,
"windows_per_second": batch_size / med,
}
def build_test_subset(data_dir, subset_size, batch_size=64):
"""Seed-42 file-level 70/15/15 test split (exactly as eval_repro.py),
minus corrupted windows, then a seed-42 random subset."""
dataset = PreprocessedCSIKeypointsDataset(
data_dir=data_dir, keypoint_scale=1000.0, enable_temporal_clean=True)
_tr, _va, test_loader = create_preprocessed_train_val_test_loaders(
dataset=dataset, batch_size=batch_size, num_workers=0, random_seed=42)
test_indices = np.asarray(test_loader.dataset.indices)
corrupted = (np.load(os.path.join(RESULTS, "nan_windows_mask.npy"))
| np.load(os.path.join(RESULTS, "big_windows_mask.npy")))
clean = test_indices[~corrupted[test_indices]]
print(f"test split: {len(test_indices)} windows, "
f"{len(test_indices) - len(clean)} corrupted excluded, "
f"{len(clean)} clean")
if subset_size and subset_size < len(clean):
rng = np.random.default_rng(42)
clean = np.sort(rng.choice(clean, size=subset_size, replace=False))
subset = torch.utils.data.Subset(dataset, clean.tolist())
loader = DataLoader(subset, batch_size=batch_size, shuffle=False,
num_workers=0)
return loader, len(clean)
def quantize_int8_dynamic(fp32_model):
"""torch.ao.quantization.quantize_dynamic on Linear/Conv where supported.
Returns (model, report) where report documents what actually quantized."""
qmodel = torch.ao.quantization.quantize_dynamic(
fp32_model, {nn.Linear, nn.Conv1d, nn.Conv2d}, dtype=torch.qint8)
quantized, total_params, quant_params = [], 0, 0
for name, mod in qmodel.named_modules():
cls = type(mod).__module__ + "." + type(mod).__name__
if "quantized" in cls:
w = mod.weight() if callable(getattr(mod, "weight", None)) else None
numel = w.numel() if w is not None else 0
quant_params += numel
quantized.append({"module": name, "class": cls, "params": numel})
for p in fp32_model.parameters():
total_params += p.numel()
n_linear = sum(isinstance(m, nn.Linear) for m in fp32_model.modules())
n_conv1d = sum(isinstance(m, nn.Conv1d) for m in fp32_model.modules())
n_conv2d = sum(isinstance(m, nn.Conv2d) for m in fp32_model.modules())
report = {
"eligible_module_counts": {
"nn.Linear": n_linear, "nn.Conv1d": n_conv1d, "nn.Conv2d": n_conv2d},
"modules_actually_quantized": quantized,
"n_modules_quantized": len(quantized),
"params_total": total_params,
"params_quantized": quant_params,
"params_quantized_fraction": quant_params / total_params,
}
return qmodel, report
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--data-dir", default=os.path.join(
os.path.expanduser("~"), ".cache", "kagglehub", "datasets", "kaka2434",
"wiflow-dataset", "versions", "1", "preprocessed_csi_data"))
parser.add_argument("--subset", type=int, default=10000)
parser.add_argument("--runs-b1", type=int, default=100)
parser.add_argument("--runs-b64", type=int, default=30)
parser.add_argument("--skip-accuracy", action="store_true")
parser.add_argument("--out", default=os.path.join(RESULTS, "edge_optimization.json"))
args = parser.parse_args()
torch.manual_seed(42)
results = {
"env": {
"torch": torch.__version__,
"platform": platform.platform(),
"processor": platform.processor(),
"num_threads": torch.get_num_threads(),
"checkpoint": os.path.relpath(CHECKPOINT, HERE),
},
"variants": {},
}
# ---- build variants ---------------------------------------------------
fp32 = load_fp32_model()
n_params = sum(p.numel() for p in fp32.parameters())
results["env"]["params"] = n_params
print(f"fp32 model: {n_params:,} params")
fp16 = load_fp32_model().half()
int8, q_report = quantize_int8_dynamic(load_fp32_model())
results["int8_dynamic_quant_report"] = q_report
print(f"int8 dynamic: {q_report['n_modules_quantized']} modules quantized, "
f"{q_report['params_quantized_fraction']*100:.1f}% of params")
variants = {
"fp32": (fp32, torch.float32, "retrained_fp32_resaved.pth"),
"fp16": (fp16, torch.float16, "retrained_fp16.pth"),
"int8_dynamic": (int8, torch.float32, "retrained_int8_dynamic.pth"),
}
# ---- (a) size + (b) latency -------------------------------------------
for name, (model, dtype, fname) in variants.items():
path = os.path.join(RESULTS, fname)
size = state_dict_size_bytes(model, path)
print(f"\n=== {name}: {size/1e6:.3f} MB on disk ({fname}) ===")
lat1 = bench_latency(model, 1, args.runs_b1, dtype)
lat64 = bench_latency(model, 64, args.runs_b64, dtype)
print(f" batch 1: {lat1['median_ms_per_window']:.2f} ms/window "
f"({lat1['windows_per_second']:.0f}/s)")
print(f" batch 64: {lat64['median_ms_per_window']:.3f} ms/window "
f"({lat64['windows_per_second']:.0f}/s)")
results["variants"][name] = {
"file": fname,
"size_bytes": size,
"size_mb": size / 1e6,
"latency_batch1": lat1,
"latency_batch64": lat64,
}
# ---- (c) accuracy ------------------------------------------------------
if not args.skip_accuracy:
loader, n_clean = build_test_subset(args.data_dir, args.subset)
results["accuracy_subset"] = {
"description": "seed-42 file-level 70/15/15 test split, corrupted "
"windows (files 487-499) excluded, seed-42 random "
"subset",
"subset_size": min(args.subset, n_clean) if args.subset else n_clean,
"clean_test_total": n_clean,
}
for name, (model, dtype, _f) in variants.items():
print(f"\n=== accuracy: {name} ===")
results["variants"][name]["accuracy"] = evaluate(
model, loader, dtype=dtype, label=name)
print(json.dumps(results["variants"][name]["accuracy"], indent=2))
# ---- merge into edge_optimization.json ---------------------------------
merged = {}
if os.path.exists(args.out):
with open(args.out) as f:
merged = json.load(f)
merged["torch"] = results
with open(args.out, "w") as f:
json.dump(merged, f, indent=2)
print(f"\nwrote {args.out}")
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
import numpy as np, os
d = os.path.expanduser('~/wiflow-std-bench/preprocessed_csi_data')
csi = np.load(os.path.join(d, 'csi_windows.npy'), mmap_mode='r+')
zeroed = 0
chunk = 4000
for i in range(0, len(csi), chunk):
block = csi[i:i+chunk]
finite = np.isfinite(block)
bad = (~finite).any(axis=(1, 2)) | (np.abs(np.where(finite, block, 0)).max(axis=(1, 2)) > 1.5)
if bad.any():
block[bad] = 0.0
zeroed += int(bad.sum())
csi.flush()
print(f'zeroed {zeroed} corrupted windows entirely')
@@ -0,0 +1,112 @@
"""Evaluate the retrained WiFlow-STD checkpoint (ADR-152 §2.2a fallback).
Scores the model produced by run.py (train_output/best_pose_model.pth or similar)
on the seed-42 test split: full test set AND NaN-free subset (excluding windows
that were zero-filled by clean_nan.py — file indices 487-499).
NOTE: deployed to ruvultra (~/wiflow-std-bench) as a standalone single file,
so it deliberately inlines its helpers. The reference implementations (upstream
import shim, >1GB np.load mmap patch, key-remap loader, canonical evaluate
loop) live in benchmarks/wiflow-std/_bench_common.py — keep copies in sync.
"""
import json, os, random, sys
import numpy as np
import torch
from torch.utils.data import DataLoader, Subset
# csi_windows.npy is ~13 GB; mmap large arrays instead of eagerly loading
# ~15 GB into RAM (same patch as _bench_common._np_load_mmap).
_np_load = np.load
def _np_load_mmap(path, *a, **kw):
if (isinstance(path, str) and path.endswith('.npy')
and os.path.getsize(path) > 1 << 30 and 'mmap_mode' not in kw):
kw['mmap_mode'] = 'r'
return _np_load(path, *a, **kw)
np.load = _np_load_mmap
sys.path.insert(0, os.path.expanduser('~/wiflow-std-bench/upstream'))
from dataset import PreprocessedCSIKeypointsDataset, create_preprocessed_train_val_test_loaders
from models.pose_model import WiFlowPoseModel
from utils.metrics import calculate_pck, calculate_mpjpe
def find_checkpoint():
cands = []
for root, _, files in os.walk(os.path.expanduser('~/wiflow-std-bench/train_output')):
for f in files:
if f.endswith('.pth'):
cands.append(os.path.join(root, f))
# also upstream/test default output dir
for root, _, files in os.walk(os.path.expanduser('~/wiflow-std-bench/upstream')):
for f in files:
if f.endswith('.pth') and 'best' in f and 'cross_dataset' not in root:
p = os.path.join(root, f)
if os.path.getmtime(p) > os.path.getmtime(os.path.expanduser('~/wiflow-std-bench/train.log')) - 86400 * 2:
cands.append(p)
cands = [c for c in cands if not c.endswith('upstream/best_pose_model.pth')]
if not cands:
sys.exit('no retrained checkpoint found')
return max(cands, key=os.path.getmtime)
def evaluate(model, loader, device):
model.eval()
totals = {t: 0.0 for t in (0.1, 0.2, 0.3, 0.4, 0.5)}
total_mpe, n = 0.0, 0
with torch.no_grad():
for bx, by in loader:
bx, by = bx.to(device), by.to(device)
out = model(bx)
bs = by.size(0)
total_mpe += calculate_mpjpe(out, by) * bs
pck = calculate_pck(out, by, thresholds=list(totals))
for t in totals:
totals[t] += pck[t] * bs
n += bs
return {'samples': n, 'mpjpe': total_mpe / n,
**{f'pck@{int(t*100)}': totals[t] / n for t in totals}}
random.seed(42); np.random.seed(42); torch.manual_seed(42)
torch.cuda.manual_seed_all(42)
torch.backends.cudnn.deterministic = True
d = os.path.expanduser('~/wiflow-std-bench/preprocessed_csi_data')
dataset = PreprocessedCSIKeypointsDataset(data_dir=d, keypoint_scale=1000.0,
enable_temporal_clean=True)
_, _, test_loader = create_preprocessed_train_val_test_loaders(
dataset=dataset, batch_size=256, num_workers=2, random_seed=42)
device = torch.device('cuda')
ckpt = find_checkpoint()
print('checkpoint:', ckpt)
model = WiFlowPoseModel(dropout=0.5).to(device)
state = torch.load(ckpt, map_location=device, weights_only=True)
renames = {'att.': 'attention.', 'final_conv.': 'decoder.'}
state = {next((new + k[len(old):] for old, new in renames.items()
if k.startswith(old)), k): v for k, v in state.items()}
model.load_state_dict(state, strict=True)
results = {'checkpoint': ckpt}
print('=== full test set ===')
results['test_full'] = evaluate(model, test_loader, device)
print(json.dumps(results['test_full'], indent=2))
# NaN-free subset: exclude windows from corrupted files 487-499
test_subset = test_loader.dataset # Subset(dataset, test_indices)
w2f = dataset.window_to_file
clean_idx = [i for i in test_subset.indices if w2f[i] < 487]
print(f'=== NaN-free test subset ({len(clean_idx)} of {len(test_subset.indices)}) ===')
clean_loader = DataLoader(Subset(dataset, clean_idx), batch_size=256, shuffle=False)
results['test_clean'] = evaluate(model, clean_loader, device)
print(json.dumps(results['test_clean'], indent=2))
out = os.path.expanduser('~/wiflow-std-bench/eval_retrained.json')
with open(out, 'w') as f:
json.dump(results, f, indent=2)
print('wrote', out)
@@ -0,0 +1,374 @@
"""ADR-152 SS2.2 measurement (b): WiFlow-STD fine-tuned on our fresh ESP32 paired dataset.
Dataset: ~/wiflow-std-bench/paired-20260610.jsonl -- 2,046 paired windows collected
2026-06-10 22:10-22:40 (ONE subject, ONE room, ONE ESP32 node, varied poses).
Per record: csi = flat float32 list, csi_shape, kp = 17 COCO [x, y] normalized [0,1]
camera coords, conf (MediaPipe mean confidence, all > 0.5 in this set), ts_start/ts_end.
Aligner: scripts/align-ground-truth.js, non-overlapping 20-frame windows (~0.42 s each).
Dataset findings (MEASURED on this file, 2026-06-10):
- csi_shape is HETEROGENEOUS, not uniformly [70, 20]: 1,347x [70,20], 284x [134,20],
243x [26,20], 130x [12,20], 42x [20,20]. The ESP32 stream emits mixed frame types
and the aligner stamps each window's subcarrier count from frame[0]
(extractCsiMatrix: nSc = window[0].subcarriers), zero-padding/truncating the rest.
Even native-70 windows contain ~20.4% internally zero-padded short frames
(subcarriers 40..69 all-zero for those frames).
- LAYOUT BUG: the aligner fills matrix[f * nSc + s] (frame-major) but declares
shape [nSc, nFrames]. The true layout is (frame, subcarrier); we reshape
(nFrames, nSc) and transpose. Confirmed by coherent per-frame zero-tails.
- Handling here (primary suite, "all2046"): every frame's subcarrier axis is
linearly resampled to 70 bins (np.interp over a normalized index domain;
identity for native-70 frames) so the pre-registered n=2,046 and split sizes
hold. Secondary suite ("native70") restricts to the 1,347 native [70,20]
windows (temporal 70/15/15 of those) as a homogeneity robustness check.
Pre-registered protocol (followed exactly):
1. TEMPORAL split (records are time-sorted; asserted): first 70% train (1,432),
next 15% val (307), last 15% test (307). No shuffling across time. Seed 42
for everything else.
2. Model: upstream WiFlow-STD trunk (WiFlowPoseModel) with a learned 1x1 Conv1d
projection 70->540 prepended, and K=17 via the parameter-free adaptive pool
(AdaptiveAvgPool2d((17, 1)) instead of (15, 1)) -- pretrained weights load
for any K. CSI normalization: divide by the TRAIN-split 99th-percentile
amplitude, clip to [0, 1] (documented in output JSON).
3. Three runs, <=60 epochs, early-stop patience 8 on val MPJPE, batch 32,
AdamW, fp32 (no autocast):
(i) pretrained-init: trunk init from upstream/test/best_pose_model.pth
(the measurement-(a) retrained checkpoint, ~96% PCK@20 on WiFlow data;
key remap att.->attention. / final_conv.->decoder. applied defensively
as in eval_repro.py -- a no-op for this checkpoint, which already uses
the new names). Discriminative lr: adapter 1e-4, trunk 1e-5.
(ii) scratch: same architecture, random init, all params lr 1e-4.
(iii) frozen-trunk: pretrained trunk frozen (requires_grad=False AND held in
.eval() so BatchNorm running stats cannot drift -- pure transfer probe);
only the 70->540 adapter trains, lr 1e-4.
4. Metrics on the temporal TEST split: torso-normalized PCK@10/20/30/40/50 and
MPJPE. Upstream utils/metrics.py calculate_pck(use_torso_norm=True) hardcodes
NECK_IDX/PELVIS_IDX = 2, 12 -- a 15-keypoint convention that is WRONG for our
17 COCO keypoints (2 = right_eye, 12 = right_hip). We therefore reimplement the
identical math (per-frame norm distance, clamp min 0.01, mean over all
keypoints x frames) with torso = ||l_shoulder(5) - l_hip(11)||.
Also reported: prediction std across test frames (constant-pose detector;
must be > 0) and the mean-pose-predictor baseline (train-split mean pose
evaluated on test -- the honesty bar).
Usage (on ruvultra):
nice -n 10 nohup ~/wiflow-std-bench/venv/bin/python train_measb.py > train_measb.log 2>&1 &
NOTE: deployed to ruvultra as a standalone single file, so it deliberately
inlines its helpers. The reference implementations (upstream import shim,
np.load mmap patch, key-remap loader, canonical evaluate loop) live in
benchmarks/wiflow-std/_bench_common.py — keep copies in sync.
"""
import json
import os
import random
import sys
import time
import numpy as np
import torch
import torch.nn as nn
BENCH = os.path.expanduser("~/wiflow-std-bench")
UPSTREAM = os.path.join(BENCH, "upstream")
MEASB = os.path.join(BENCH, "measb")
DATA = os.path.join(BENCH, "paired-20260610.jsonl")
CHECKPOINT = os.path.join(UPSTREAM, "test", "best_pose_model.pth")
sys.path.insert(0, UPSTREAM)
# Upstream defect (1): models/__init__.py imports a name tcn.py does not define.
# Register a stub package so the broken __init__ never executes (as eval_repro.py).
import types # noqa: E402
_models_pkg = types.ModuleType("models")
_models_pkg.__path__ = [os.path.join(UPSTREAM, "models")]
sys.modules["models"] = _models_pkg
from models.pose_model import WiFlowPoseModel # noqa: E402
SEED = 42
K = 17
N_SUBC = 70
TRUNK_IN = 540
BATCH = 32 # <= 64 per protocol (GPU shared with the efficiency sweep)
MAX_EPOCHS = 60
PATIENCE = 8
LR_ADAPTER = 1e-4
LR_TRUNK_FT = 1e-5 # 10x lower for the pretrained trunk vs the fresh adapter
L_SHOULDER, L_HIP = 5, 11
THRESHOLDS = (0.1, 0.2, 0.3, 0.4, 0.5)
def set_seed(seed=SEED):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
def resample_subcarriers(frame_major, n_out=N_SUBC):
"""(nFrames, nSc) -> (nFrames, n_out) by per-frame linear interpolation.
Identity for nSc == n_out. Normalized index domain [0, 1] on both sides.
"""
nf, nsc = frame_major.shape
if nsc == n_out:
return frame_major
xi = np.linspace(0.0, 1.0, nsc)
xo = np.linspace(0.0, 1.0, n_out)
return np.stack([np.interp(xo, xi, frame_major[f]) for f in range(nf)]).astype(np.float32)
def load_dataset():
csi, kps, confs, ts, native70 = [], [], [], [], []
shape_counts = {}
with open(DATA) as f:
for line in f:
r = json.loads(line)
nsc, nf = r["csi_shape"]
shape_counts[f"{nsc}x{nf}"] = shape_counts.get(f"{nsc}x{nf}", 0) + 1
assert nf == 20, r["csi_shape"]
# Aligner layout bug: data is frame-major despite the declared
# [nSc, nFrames] shape -- reshape (nFrames, nSc), then resample the
# subcarrier axis to 70 and transpose to (70 subcarriers, 20 frames).
fm = np.asarray(r["csi"], dtype=np.float32).reshape(nf, nsc)
csi.append(resample_subcarriers(fm).T)
kp = np.asarray(r["kp"], dtype=np.float32)
assert kp.shape == (K, 2), kp.shape
kps.append(kp)
confs.append(r["conf"])
ts.append(r["ts_start"])
native70.append(nsc == N_SUBC)
assert all(ts[i] <= ts[i + 1] for i in range(len(ts) - 1)), "records not time-sorted"
return (np.stack(csi), np.stack(kps), np.asarray(confs, dtype=np.float32),
np.asarray(native70), shape_counts, ts[0], ts[-1])
def temporal_split(n):
n_train = int(round(n * 0.70))
n_val = int(round(n * 0.15))
return slice(0, n_train), slice(n_train, n_train + n_val), slice(n_train + n_val, n)
class AdaptedWiFlow(nn.Module):
"""1x1 Conv1d adapter 70->540 + upstream WiFlow-STD trunk with K=17 pool head."""
def __init__(self, k=K, dropout=0.5):
super().__init__()
self.adapter = nn.Conv1d(N_SUBC, TRUNK_IN, kernel_size=1)
nn.init.kaiming_normal_(self.adapter.weight, mode="fan_out", nonlinearity="relu")
nn.init.constant_(self.adapter.bias, 0)
self.trunk = WiFlowPoseModel(dropout=dropout)
# K=17 via the parameter-free adaptive pool: decoder emits [B, 2, 15, 20]
# spatial maps; pooling H->17 instead of 15 yields [B, 17, 2] with no new
# parameters, so the pretrained state_dict loads strict=True for any K.
self.trunk.avg_pool = nn.AdaptiveAvgPool2d((k, 1))
def forward(self, x):
return self.trunk(self.adapter(x))
def load_pretrained_trunk(trunk, path):
state = torch.load(path, map_location="cpu", weights_only=True)
# Defensive remap as in eval_repro.py (no-op for the retrained checkpoint).
renames = {"att.": "attention.", "final_conv.": "decoder."}
state = {next((new + k[len(old):] for old, new in renames.items()
if k.startswith(old)), k): v
for k, v in state.items()}
trunk.load_state_dict(state, strict=True)
def pck_torso(pred, target, thresholds=THRESHOLDS):
"""Upstream calculate_pck math, torso = l_shoulder(5)<->l_hip(11) for 17-kp COCO."""
norm = torch.sqrt(((target[:, L_SHOULDER] - target[:, L_HIP]) ** 2).sum(dim=1))
norm = torch.clamp(norm, min=0.01)
dist = torch.sqrt(((pred - target) ** 2).sum(dim=2)) / norm.unsqueeze(1)
return {f"pck@{int(t * 100)}": (dist <= t).float().mean().item() for t in thresholds}
def mpjpe(pred, target):
return torch.sqrt(((pred - target) ** 2).sum(dim=2)).mean().item()
@torch.no_grad()
def predict(model, x, batch=256):
model.eval()
return torch.cat([model(x[i:i + batch]) for i in range(0, len(x), batch)])
def eval_preds(pred, target):
out = pck_torso(pred, target)
out["mpjpe"] = mpjpe(pred, target)
# Constant-pose detector: std across test frames per coordinate, mean over
# the 17x2 coordinates. 0.0 == degenerate constant predictor.
out["pred_std"] = pred.std(dim=0).mean().item()
return out
def train_run(name, x_tr, y_tr, x_va, y_va, device, pretrained, freeze_trunk,
lr_trunk):
set_seed(SEED)
model = AdaptedWiFlow().to(device)
if pretrained:
load_pretrained_trunk(model.trunk, CHECKPOINT)
if freeze_trunk:
for p in model.trunk.parameters():
p.requires_grad = False
groups = [{"params": model.adapter.parameters(), "lr": LR_ADAPTER}]
else:
groups = [{"params": model.adapter.parameters(), "lr": LR_ADAPTER},
{"params": model.trunk.parameters(), "lr": lr_trunk}]
opt = torch.optim.AdamW(groups)
loss_fn = nn.MSELoss()
n = len(x_tr)
best_val, best_state, best_epoch, bad = float("inf"), None, -1, 0
history = []
t0 = time.time()
for epoch in range(MAX_EPOCHS):
model.train()
if freeze_trunk:
model.trunk.eval() # keep BatchNorm running stats fixed: pure transfer
perm = torch.randperm(n, device=device)
ep_loss = 0.0
for i in range(0, n, BATCH):
idx = perm[i:i + BATCH]
opt.zero_grad()
loss = loss_fn(model(x_tr[idx]), y_tr[idx])
loss.backward()
opt.step()
ep_loss += loss.item() * len(idx)
val_mpjpe = mpjpe(predict(model, x_va), y_va)
history.append({"epoch": epoch, "train_mse": ep_loss / n, "val_mpjpe": val_mpjpe})
marker = ""
if val_mpjpe < best_val:
best_val, best_epoch, bad = val_mpjpe, epoch, 0
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
marker = " *"
else:
bad += 1
print(f"[{name}] epoch {epoch:02d} train_mse {ep_loss / n:.6f} "
f"val_mpjpe {val_mpjpe:.5f}{marker}", flush=True)
if bad >= PATIENCE:
print(f"[{name}] early stop at epoch {epoch} (best {best_epoch})", flush=True)
break
model.load_state_dict(best_state)
torch.save(best_state, os.path.join(MEASB, f"{name}_best.pth"))
return model, {"best_epoch": best_epoch, "best_val_mpjpe": best_val,
"epochs_run": len(history), "wall_seconds": round(time.time() - t0, 1),
"history": history}
def run_suite(tag, csi, kps, device):
"""Temporal 70/15/15 split, mean-pose baseline, three training runs."""
n = len(csi)
tr, va, te = temporal_split(n)
print(f"=== suite {tag}: n={n} train={tr.stop} val={va.stop - va.start} "
f"test={te.stop - te.start} ===", flush=True)
# CSI normalization constant from TRAIN split only.
train_p99 = float(np.percentile(csi[tr], 99))
train_max = float(csi[tr].max())
print(f"[{tag}] train p99={train_p99:.3f} max={train_max:.3f} -> /p99, clip [0,1]",
flush=True)
csi_n = np.clip(csi / train_p99, 0.0, 1.0).astype(np.float32)
x = torch.from_numpy(csi_n).to(device)
y = torch.from_numpy(kps).to(device)
x_tr, y_tr = x[tr], y[tr]
x_va, y_va = x[va], y[va]
x_te, y_te = x[te], y[te]
suite = {
"n_windows": n,
"split": {"n_train": int(tr.stop), "n_val": int(va.stop - va.start),
"n_test": int(te.stop - te.start)},
"csi_norm": {"method": "divide by train-split p99 amplitude, clip [0,1]",
"train_p99": train_p99, "train_max": train_max},
"runs": {},
}
# Honesty bar: mean-pose predictor fit on TRAIN, evaluated on TEST.
mean_pose = y_tr.mean(dim=0, keepdim=True).expand(len(y_te), -1, -1)
suite["mean_pose_baseline"] = eval_preds(mean_pose, y_te)
suite["mean_pose_baseline"]["note"] = "train-split mean pose; pred_std 0 by construction"
print(f"[{tag}] mean-pose baseline:", json.dumps(suite["mean_pose_baseline"]),
flush=True)
configs = [
("pretrained", dict(pretrained=True, freeze_trunk=False, lr_trunk=LR_TRUNK_FT)),
("scratch", dict(pretrained=False, freeze_trunk=False, lr_trunk=LR_ADAPTER)),
("frozen_trunk", dict(pretrained=True, freeze_trunk=True, lr_trunk=0.0)),
]
for name, cfg in configs:
print(f"=== run: {tag}/{name} {cfg} ===", flush=True)
model, train_info = train_run(f"{tag}_{name}", x_tr, y_tr, x_va, y_va,
device, **cfg)
test_metrics = eval_preds(predict(model, x_te), y_te)
n_trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
suite["runs"][name] = {"config": cfg, "trainable_params": n_trainable,
"train": {k: v for k, v in train_info.items()
if k != "history"},
"history": train_info["history"],
"test": test_metrics}
print(f"[{tag}/{name}] TEST:", json.dumps(test_metrics), flush=True)
return suite
def main():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"device {device}, torch {torch.__version__}", flush=True)
set_seed(SEED)
csi, kps, confs, native70, shape_counts, ts_first, ts_last = load_dataset()
print(f"shape distribution: {shape_counts}", flush=True)
results = {
"protocol": {
"dataset": DATA, "n_windows": len(csi),
"ts_first": ts_first, "ts_last": ts_last,
"conf_mean": float(confs.mean()), "conf_min": float(confs.min()),
"csi_shape_distribution": shape_counts,
"csi_layout_note": "aligner stores frame-major data under a transposed "
"[nSc, nFrames] shape label; corrected on load",
"csi_resample": "per-frame linear interp of subcarrier axis to 70 bins "
"(identity for native-70 frames); native-70 windows still "
"contain ~20.4% internally zero-padded short frames",
"split": "temporal 70/15/15 (no shuffle across time)",
"model": "1x1 Conv1d 70->540 adapter + WiFlowPoseModel trunk, "
"AdaptiveAvgPool2d((17,1)) head (parameter-free K=17)",
"checkpoint": CHECKPOINT,
"checkpoint_note": "measurement-(a) retrained checkpoint (~96% PCK@20 on "
"WiFlow data); att./final_conv. remap applied "
"defensively (no-op, already new-style keys)",
"optimizer": f"AdamW, adapter lr {LR_ADAPTER}, fine-tuned trunk lr "
f"{LR_TRUNK_FT} (10x lower), scratch all {LR_ADAPTER}",
"batch": BATCH, "max_epochs": MAX_EPOCHS, "patience": PATIENCE,
"precision": "fp32", "seed": SEED,
"pck": "torso-normalized, torso = ||l_shoulder(5) - l_hip(11)||, "
"clamp min 0.01, mean over keypoints x frames "
"(upstream math; upstream 2/12 indices are a 15-kp convention)",
},
# Primary: all 2,046 windows (pre-registered n), subcarrier axis resampled.
"all2046": None,
# Secondary robustness check: the 1,347 native [70,20] windows only.
"native70": None,
}
results["all2046"] = run_suite("all2046", csi, kps, device)
results["native70"] = run_suite("native70", csi[native70], kps[native70], device)
out = os.path.join(MEASB, "measurement_b.json")
with open(out, "w") as f:
json.dump(results, f, indent=2)
print(f"wrote {out}", flush=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,33 @@
#!/bin/bash
set -ex
cd ~/wiflow-std-bench
# 1. clone upstream at the pinned commit
if [ ! -d upstream ]; then
git clone https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling upstream
fi
cd upstream && git checkout 06899d294a0f44709d601a53e91dbf24759daefb && cd ..
# 2. documented deviation: fix upstream import bug (TemporalConvNet does not exist)
sed -i 's/from .tcn import TemporalConvNet/from .tcn import TemporalBlock/; s/'"'"'TemporalConvNet'"'"'/'"'"'TemporalBlock'"'"'/' upstream/models/__init__.py
# 3. venv: torch cu128 (RTX 5080 = sm_120 needs >=2.7; their pin 2.3.1 predates Blackwell)
if [ ! -d venv ]; then
python3 -m venv venv
./venv/bin/pip install -q --upgrade pip
./venv/bin/pip install -q torch --index-url https://download.pytorch.org/whl/cu128
./venv/bin/pip install -q numpy pandas matplotlib seaborn scikit-learn opencv-python-headless scipy tqdm psutil kagglehub
fi
./venv/bin/python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name(0))"
# 4. dataset via kagglehub (anonymous, public dataset)
DS=$(./venv/bin/python -c "import kagglehub; print(kagglehub.dataset_download('kaka2434/wiflow-dataset'))")
echo "dataset at: $DS"
# 5. run.py hardcodes ../preprocessed_csi_data relative to upstream/
ln -sfn "$DS/preprocessed_csi_data" ~/wiflow-std-bench/preprocessed_csi_data
# 6. train with upstream defaults (seed 42 set inside run.py)
../venv/bin/python ../clean_nan.py 2>/dev/null || venv/bin/python clean_nan.py
cd upstream
../venv/bin/python run.py --gpu 0 --batch_size 64 --epochs 50 --output_dir ../train_output
@@ -0,0 +1,332 @@
"""Configurable compact variants of the WiFlow-STD pose model (ADR-152 efficiency sweep).
This is a parameterized copy of upstream models/{pose_model,tcn,convnet,attention}.py
(DY2434/WiFlow @ 06899d29, Apache-2.0). upstream/ is NOT modified. Deviations from
upstream, all forced by shrinking channels and documented per variant in run_sweep.py:
1. TCN grouped-conv groups: upstream hardcodes groups=20, which does not divide
the compact channel counts (e.g. 270, 135, 85). Rule here:
- groups_mode='gcd20': per-conv groups = gcd(channels, 20) (== 20 wherever
upstream's choice is valid, incl. the 540-ch input conv; falls back to the
largest common divisor with 20 otherwise).
- groups_mode='depthwise': groups = channels (tiny variant only).
2. Conv2d downsampling strides: upstream uses 4 stride-(1,2) blocks because
240/2^4 = 15 == n_keypoints. With smaller TCN output widths that would leave
<15 rows and AdaptiveAvgPool2d((15,1)) would duplicate rows across keypoints.
Rule: halve the width only while the result stays >= 15 (stride-2 blocks
first, stride-1 after). Full model: 240 -> 4 halvings = upstream exactly.
3. input_pw_groups (tiny only): the dense 540->c pointwise + residual downsample
in TCN block 1 cost 2*540*c params (a ~117k floor that alone exceeds the
tiny <100k budget). tiny groups these two convs (groups=4; 4 | gcd(540, 68)).
4. Decoder mid-channels: upstream 64->32; here c_last -> max(c_last // 2, 4).
"""
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
def tcn_groups(channels: int, mode: str) -> int:
if mode == 'depthwise':
return channels
if mode == 'gcd20':
return math.gcd(channels, 20)
raise ValueError(mode)
# ---------------------------------------------------------------- TCN (copy of tcn.py)
class Chomp1d(nn.Module):
def __init__(self, chomp_size):
super().__init__()
self.chomp_size = chomp_size
def forward(self, x):
return x[:, :, :-self.chomp_size].contiguous()
class CompactGroupedTemporalBlock(nn.Module):
"""Upstream InnerGroupedTemporalBlock with parameterized groups."""
def __init__(self, n_inputs, n_outputs, kernel_size, stride, dilation, padding,
dropout=0.2, groups_mode='gcd20', pw_groups=1):
super().__init__()
g_in = tcn_groups(n_inputs, groups_mode)
g_out = tcn_groups(n_outputs, groups_mode)
self.groups = (g_in, g_out)
self.pw_groups = pw_groups
self.conv1_group = nn.Conv1d(n_inputs, n_inputs, kernel_size, stride=stride,
padding=padding, dilation=dilation,
groups=g_in, bias=False)
self.chomp1 = Chomp1d(padding) if padding > 0 else nn.Identity()
self.bn1_group = nn.BatchNorm1d(n_inputs)
self.relu1_group = nn.SiLU(inplace=True)
self.conv1_pw = nn.Conv1d(n_inputs, n_outputs, 1, groups=pw_groups, bias=False)
self.bn1_pw = nn.BatchNorm1d(n_outputs)
self.relu1_pw = nn.SiLU(inplace=True)
self.dropout1 = nn.Dropout(dropout)
self.conv2_group = nn.Conv1d(n_outputs, n_outputs, kernel_size, stride=1,
padding=padding, dilation=dilation,
groups=g_out, bias=False)
self.chomp2 = Chomp1d(padding) if padding > 0 else nn.Identity()
self.bn2_group = nn.BatchNorm1d(n_outputs)
self.relu2_group = nn.SiLU(inplace=True)
self.conv2_pw = nn.Conv1d(n_outputs, n_outputs, 1, bias=False)
self.bn2_pw = nn.BatchNorm1d(n_outputs)
self.relu2_pw = nn.SiLU(inplace=True)
self.dropout2 = nn.Dropout(dropout)
self.downsample = nn.Sequential(
nn.Conv1d(n_inputs, n_outputs, 1, groups=pw_groups, bias=False),
nn.BatchNorm1d(n_outputs)
) if n_inputs != n_outputs else nn.Identity()
def forward(self, x):
res = self.downsample(x)
out = self.conv1_group(x)
out = self.chomp1(out)
out = self.bn1_group(out)
out = self.relu1_group(out)
out = self.conv1_pw(out)
out = self.bn1_pw(out)
out = self.relu1_pw(out)
out = self.dropout1(out)
out = self.conv2_group(out)
out = self.chomp2(out)
out = self.bn2_group(out)
out = self.relu2_group(out)
out = self.conv2_pw(out)
out = self.bn2_pw(out)
out = self.relu2_pw(out)
out = self.dropout2(out)
return F.silu(out + res)
class CompactTemporalBlock(nn.Module):
def __init__(self, num_inputs, num_channels, kernel_size=3, dropout=0.2,
groups_mode='gcd20', input_pw_groups=1):
super().__init__()
layers = []
for i, out_channels in enumerate(num_channels):
dilation_size = 2 ** i
in_channels = num_inputs if i == 0 else num_channels[i - 1]
layers.append(CompactGroupedTemporalBlock(
in_channels, out_channels, kernel_size, stride=1,
dilation=dilation_size, padding=(kernel_size - 1) * dilation_size,
dropout=dropout, groups_mode=groups_mode,
pw_groups=input_pw_groups if i == 0 else 1))
self.network = nn.Sequential(*layers)
def forward(self, x):
return self.network(x)
# ------------------------------------------------------- Conv2d path (copy of convnet.py)
class AsymmetricConvBlock(nn.Module):
"""Upstream block with parameterized width stride (upstream: always (1,2))."""
def __init__(self, in_channels, out_channels, dropout=0.3, stride_w=2):
super().__init__()
self.block = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=(1, 3),
stride=(1, stride_w), padding=(0, 1)),
nn.BatchNorm2d(out_channels),
nn.SiLU(inplace=True),
nn.Dropout2d(dropout),
nn.Conv2d(out_channels, out_channels, kernel_size=(1, 3), padding=(0, 1)),
nn.BatchNorm2d(out_channels),
nn.SiLU(inplace=True),
nn.Dropout2d(dropout),
nn.Conv2d(out_channels, out_channels, kernel_size=(1, 3), padding=(0, 1)),
nn.BatchNorm2d(out_channels)
)
self.downsample = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=1,
stride=(1, stride_w), bias=False),
nn.BatchNorm2d(out_channels)
)
self.activation = nn.SiLU(inplace=True)
def forward(self, x):
return self.activation(self.block(x) + self.downsample(x))
class ConvBlock1(nn.Module):
def __init__(self, in_channels, out_channels, dropout=0.3):
super().__init__()
self.block = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=(1, 3), padding=(0, 1)),
nn.BatchNorm2d(out_channels),
nn.SiLU(inplace=True),
nn.Dropout2d(dropout),
nn.Conv2d(out_channels, out_channels, kernel_size=(1, 3), padding=(0, 1)),
nn.BatchNorm2d(out_channels),
nn.SiLU(inplace=True),
nn.Dropout2d(dropout),
nn.Conv2d(out_channels, out_channels, kernel_size=(1, 3), padding=(0, 1)),
nn.BatchNorm2d(out_channels)
)
self.downsample = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, bias=False),
nn.BatchNorm2d(out_channels)
)
self.activation = nn.SiLU(inplace=True)
def forward(self, x):
return self.activation(self.block(x) + self.downsample(x))
# ----------------------------------------------------- attention (verbatim attention.py)
class AxialAttention(nn.Module):
def __init__(self, in_planes, out_planes, groups=8, stride=1, bias=False, width=False):
assert (in_planes % groups == 0) and (out_planes % groups == 0)
super().__init__()
self.in_planes = in_planes
self.out_planes = out_planes
self.groups = groups
self.group_planes = out_planes // groups
self.stride = stride
self.bias = bias
self.width = width
self.qkv_transform = nn.Conv1d(in_planes, out_planes * 3, kernel_size=1,
stride=1, padding=0, bias=False)
self.bn_qkv = nn.BatchNorm1d(out_planes * 3)
self.bn_similarity = nn.BatchNorm2d(groups)
self.bn_output = nn.BatchNorm1d(out_planes)
if stride > 1:
self.pooling = nn.AvgPool2d(stride, stride=stride)
nn.init.normal_(self.qkv_transform.weight.data, 0, math.sqrt(1. / self.in_planes))
def forward(self, x):
if self.width:
x = x.permute(0, 2, 1, 3)
else:
x = x.permute(0, 3, 1, 2)
N, W, C, H = x.shape
x = x.contiguous().view(N * W, C, H)
qkv = self.bn_qkv(self.qkv_transform(x))
qkv = qkv.reshape(N * W, 3, self.out_planes, H).permute(1, 0, 2, 3)
q, k, v = qkv[0], qkv[1], qkv[2]
q = q.reshape(N * W, self.groups, self.group_planes, H)
k = k.reshape(N * W, self.groups, self.group_planes, H)
v = v.reshape(N * W, self.groups, self.group_planes, H)
qk = torch.einsum('bgci, bgcj->bgij', q, k)
qk = self.bn_similarity(qk)
similarity = F.softmax(qk, dim=-1)
sv = torch.einsum('bgij,bgcj->bgci', similarity, v)
sv = sv.reshape(N * W, self.out_planes, H)
out = self.bn_output(sv)
out = out.view(N, W, self.out_planes, H)
if self.width:
out = out.permute(0, 2, 1, 3)
else:
out = out.permute(0, 2, 3, 1)
if self.stride > 1:
out = self.pooling(out)
return out
class DualAxialAttention(nn.Module):
def __init__(self, in_planes, out_planes, groups=8, stride=1, bias=False):
super().__init__()
self.width_axis = AxialAttention(in_planes, out_planes, groups, stride, bias, width=True)
self.height_axis = AxialAttention(out_planes, out_planes, groups, stride, bias, width=False)
def forward(self, x):
return self.height_axis(self.width_axis(x))
# --------------------------------------------------------------- full model
def compute_strides(width: int, n_blocks: int, target: int = 15):
"""Halve width while result stays >= target (upstream: 240 -> 4 halvings -> 15)."""
strides = []
for _ in range(n_blocks):
nxt = (width + 1) // 2 # conv k=3 s=2 p=1: out = ceil(in/2)
if nxt >= target:
strides.append(2)
width = nxt
else:
strides.append(1)
return strides, width
class CompactWiFlowPoseModel(nn.Module):
"""Parameterized upstream WiFlowPoseModel.
Upstream config == tcn_channels=[540,440,340,240], conv_channels=[8,16,32,64],
attn_groups=8, groups_mode='gcd20' (gcd(c,20)==20 for all upstream channels),
input_pw_groups=1 -> identical architecture, 2,225,042 params.
"""
def __init__(self, tcn_channels, conv_channels, attn_groups,
groups_mode='gcd20', input_pw_groups=1, dropout=0.3,
num_subcarriers=540, num_keypoints=15):
super().__init__()
self.tcn = CompactTemporalBlock(
num_inputs=num_subcarriers, num_channels=tcn_channels, kernel_size=3,
dropout=dropout, groups_mode=groups_mode, input_pw_groups=input_pw_groups)
self.up = ConvBlock1(1, conv_channels[0])
strides, self.final_width = compute_strides(
tcn_channels[-1], len(conv_channels), target=num_keypoints)
self.conv_strides = strides
self.residual_blocks = nn.ModuleList()
in_channels = conv_channels[0]
for out_channels, s in zip(conv_channels, strides):
self.residual_blocks.append(
AsymmetricConvBlock(in_channels, out_channels, stride_w=s))
in_channels = out_channels
c_last = conv_channels[-1]
self.attention = DualAxialAttention(c_last, c_last, groups=attn_groups)
c_mid = max(c_last // 2, 4)
self.decoder = nn.Sequential(
nn.Conv2d(c_last, c_mid, kernel_size=3, padding=1),
nn.BatchNorm2d(c_mid),
nn.SiLU(inplace=True),
nn.Conv2d(c_mid, 2, kernel_size=1),
nn.BatchNorm2d(2),
nn.SiLU(inplace=True)
)
self.avg_pool = nn.AdaptiveAvgPool2d((num_keypoints, 1))
self._initialize_weights()
def _initialize_weights(self):
for m in self.modules():
if isinstance(m, nn.Conv1d):
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
if m.bias is not None:
nn.init.constant_(m.bias, 0)
elif isinstance(m, (nn.BatchNorm1d, nn.LayerNorm)):
nn.init.constant_(m.weight, 1)
nn.init.constant_(m.bias, 0)
elif isinstance(m, nn.Linear):
nn.init.xavier_normal_(m.weight)
if m.bias is not None:
nn.init.constant_(m.bias, 0)
def forward(self, x):
# [B, 540, 20]
x = self.tcn(x) # [B, C_tcn, 20]
x = x.transpose(1, 2).unsqueeze(1) # [B, 1, 20, C_tcn]
x = self.up(x)
for block in self.residual_blocks:
x = block(x) # [B, C_conv, 20, W']
x = x.permute(0, 1, 3, 2) # [B, C_conv, W', 20]
x = self.attention(x)
x = self.decoder(x) # [B, 2, W', 20]
x = self.avg_pool(x).squeeze(-1) # [B, 2, 15]
return x.transpose(1, 2) # [B, 15, 2]
def describe(model: 'CompactWiFlowPoseModel'):
params = sum(p.numel() for p in model.parameters())
tcn_g = [blk.groups for blk in model.tcn.network]
return {'params': params, 'tcn_groups_per_block': tcn_g,
'conv_strides': model.conv_strides, 'final_width': model.final_width}
@@ -0,0 +1,278 @@
"""WiFlow-STD compact-variant efficiency sweep (ADR-152) — sequential overnight runner.
Trains compact variants of the upstream WiFlow-STD architecture on the same
data/split as the full-size reference retraining (seed 42, file-level 70/15/15,
upstream dataset.py) and evaluates PCK@10..50 + MPJPE on the full test split and
the corruption-free test subset (file indices < 487).
Training mirrors upstream run.py/train.py defaults except:
- fp32 only (no fp16 autocast / GradScaler — avoids the BN-poisoning trap
documented in RESULTS.md defect 5; data on disk is already cleaned).
- batch 64 (kept modest: another GPU job may share the 16 GB card tonight).
- scheduler + early stopping keyed on val MPJPE (upstream early-stops on val MPE
with patience 5; same here).
Usage:
venv/bin/python sweep/run_sweep.py --dry-run # param counts only
nohup venv/bin/python sweep/run_sweep.py > sweep/sweep.log 2>&1 &
Idempotent: variants already present in sweep/results.jsonl are skipped.
NOTE: deployed to ruvultra (~/wiflow-std-bench/sweep) as a standalone file, so
it deliberately inlines its helpers. The reference implementations (upstream
import shim, >1GB np.load mmap patch, key-remap loader, canonical evaluate
loop) live in benchmarks/wiflow-std/_bench_common.py — keep copies in sync.
"""
import argparse
import copy
import json
import os
import random
import sys
import time
import numpy as np
import torch
from torch.utils.data import DataLoader, Subset
# csi_windows.npy is ~13 GB; mmap large arrays instead of eagerly loading
# ~15 GB into RAM (same patch as _bench_common._np_load_mmap).
_np_load = np.load
def _np_load_mmap(path, *a, **kw):
if (isinstance(path, str) and path.endswith('.npy')
and os.path.getsize(path) > 1 << 30 and 'mmap_mode' not in kw):
kw['mmap_mode'] = 'r'
return _np_load(path, *a, **kw)
np.load = _np_load_mmap
BENCH = os.path.expanduser('~/wiflow-std-bench')
SWEEP = os.path.join(BENCH, 'sweep')
sys.path.insert(0, os.path.join(BENCH, 'upstream'))
sys.path.insert(0, SWEEP)
from dataset import PreprocessedCSIKeypointsDataset, create_preprocessed_train_val_test_loaders # noqa: E402
from losses.pose_loss import PoseLoss # noqa: E402
from utils.metrics import calculate_pck, calculate_mpjpe # noqa: E402
from model_compact import CompactWiFlowPoseModel, describe # noqa: E402
VARIANTS = [
# name, tcn_channels, conv_channels, attn_groups, groups_mode, input_pw_groups
dict(name='half', tcn=[270, 220, 170, 120], conv=[4, 8, 16, 32], attn_groups=4,
groups_mode='gcd20', input_pw_groups=1),
dict(name='quarter', tcn=[135, 110, 85, 60], conv=[2, 4, 8, 16], attn_groups=2,
groups_mode='gcd20', input_pw_groups=1),
dict(name='tiny', tcn=[68, 56, 44, 32], conv=[2, 4, 8, 16], attn_groups=2,
groups_mode='depthwise', input_pw_groups=4),
]
BATCH = 64
EPOCHS = 50
PATIENCE = 5
LR = 1e-4
WEIGHT_DECAY = 5e-5
SEED = 42
CORRUPT_FILE_START = 487 # files 487-499 were zero-filled by clean_nan.py
def set_seed(seed=SEED):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
def build_model(v, dropout=0.5):
return CompactWiFlowPoseModel(
tcn_channels=v['tcn'], conv_channels=v['conv'], attn_groups=v['attn_groups'],
groups_mode=v['groups_mode'], input_pw_groups=v['input_pw_groups'],
dropout=dropout)
@torch.no_grad()
def evaluate(model, loader, device):
model.eval()
totals = {t: 0.0 for t in (0.1, 0.2, 0.3, 0.4, 0.5)}
total_mpe, n = 0.0, 0
for bx, by in loader:
bx, by = bx.to(device), by.to(device)
out = model(bx)
bs = by.size(0)
total_mpe += calculate_mpjpe(out, by) * bs
pck = calculate_pck(out, by, thresholds=list(totals))
for t in totals:
totals[t] += pck[t] * bs
n += bs
return {'samples': n, 'mpjpe': total_mpe / n,
**{f'pck@{int(t * 100)}': totals[t] / n for t in totals}}
def train_variant(v, dataset, device):
set_seed(SEED)
train_loader, val_loader, test_loader = create_preprocessed_train_val_test_loaders(
dataset=dataset, batch_size=BATCH, num_workers=2, random_seed=SEED)
set_seed(SEED) # re-seed after split so init is split-independent
model = build_model(v).to(device)
info = describe(model)
print(f"[{v['name']}] params={info['params']:,} tcn_groups={info['tcn_groups_per_block']} "
f"conv_strides={info['conv_strides']} final_width={info['final_width']}", flush=True)
criterion = PoseLoss(position_weight=1.0, bone_weight=0.2, loss_type='smooth_l1')
optimizer = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY,
betas=(0.9, 0.999))
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode='min', factor=0.5, patience=3, min_lr=LR / 1000,
cooldown=1, threshold=1e-4)
best_val_mpe = float('inf')
best_val_pck20 = 0.0
best_epoch = 0
best_state = None
patience_counter = 0
t0 = time.time()
error = None
epochs_run = 0
for epoch in range(1, EPOCHS + 1):
model.train()
ep_loss, nb = 0.0, 0
te = time.time()
for i, (bx, by) in enumerate(train_loader):
bx = bx.to(device, non_blocking=True)
by = by.to(device, non_blocking=True)
optimizer.zero_grad(set_to_none=True)
out = model(bx)
loss, _parts = criterion(out, by)
if not torch.isfinite(loss):
error = f'non-finite loss at epoch {epoch} step {i}'
break
loss.backward()
optimizer.step()
ep_loss += loss.item()
nb += 1
if epoch == 1 and i % 500 == 0:
print(f"[{v['name']}] e1 step {i}/{len(train_loader)} loss={loss.item():.5f}",
flush=True)
if error:
break
epochs_run = epoch
val = evaluate(model, val_loader, device)
scheduler.step(val['mpjpe'])
lr_now = optimizer.param_groups[0]['lr']
print(f"[{v['name']}] epoch {epoch}/{EPOCHS} train_loss={ep_loss / max(nb, 1):.5f} "
f"val_mpjpe={val['mpjpe']:.5f} val_pck20={val['pck@20'] * 100:.2f}% "
f"lr={lr_now:.2e} ({time.time() - te:.0f}s)", flush=True)
if val['mpjpe'] < best_val_mpe:
best_val_mpe = val['mpjpe']
best_val_pck20 = val['pck@20']
best_epoch = epoch
best_state = copy.deepcopy(model.state_dict())
patience_counter = 0
else:
patience_counter += 1
if patience_counter >= PATIENCE:
print(f"[{v['name']}] early stop at epoch {epoch} (best {best_epoch})", flush=True)
break
train_seconds = time.time() - t0
result = {
'variant': v['name'], 'params': info['params'],
'tcn_channels': v['tcn'], 'conv_channels': v['conv'],
'attn_groups': v['attn_groups'], 'groups_mode': v['groups_mode'],
'input_pw_groups': v['input_pw_groups'],
'tcn_groups_per_block': info['tcn_groups_per_block'],
'conv_strides': info['conv_strides'], 'final_width': info['final_width'],
'batch_size': BATCH, 'max_epochs': EPOCHS, 'patience': PATIENCE,
'lr': LR, 'weight_decay': WEIGHT_DECAY, 'seed': SEED, 'precision': 'fp32',
'epochs_run': epochs_run, 'best_epoch': best_epoch,
'best_val_mpjpe': best_val_mpe if best_state else None,
'best_val_pck20': best_val_pck20 if best_state else None,
'train_seconds': round(train_seconds, 1),
'torch': torch.__version__, 'error': error,
'finished_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
}
if best_state is not None:
ckpt = os.path.join(SWEEP, f"{v['name']}_best.pth")
torch.save(best_state, ckpt)
result['checkpoint'] = ckpt
model.load_state_dict(best_state)
eval_loader = DataLoader(test_loader.dataset, batch_size=256, shuffle=False,
num_workers=2)
result['test_full'] = evaluate(model, eval_loader, device)
w2f = dataset.window_to_file
clean_idx = [i for i in test_loader.dataset.indices if w2f[i] < CORRUPT_FILE_START]
clean_loader = DataLoader(Subset(dataset, clean_idx), batch_size=256,
shuffle=False, num_workers=2)
result['test_clean'] = evaluate(model, clean_loader, device)
print(f"[{v['name']}] TEST clean: pck20={result['test_clean']['pck@20'] * 100:.2f}% "
f"mpjpe={result['test_clean']['mpjpe']:.5f} | full: "
f"pck20={result['test_full']['pck@20'] * 100:.2f}%", flush=True)
return result
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--dry-run', action='store_true', help='print param counts and exit')
args = ap.parse_args()
if args.dry_run:
for v in VARIANTS:
m = build_model(v)
info = describe(m)
x = torch.randn(2, 540, 20)
m.eval()
y = m(x)
print(f"{v['name']:8s} params={info['params']:>9,} "
f"tcn={v['tcn']} conv={v['conv']} attn_g={v['attn_groups']} "
f"mode={v['groups_mode']} pw_g={v['input_pw_groups']} "
f"tcn_groups={info['tcn_groups_per_block']} strides={info['conv_strides']} "
f"W'={info['final_width']} out={tuple(y.shape)}")
return
results_path = os.path.join(SWEEP, 'results.jsonl')
done = set()
if os.path.exists(results_path):
with open(results_path) as f:
for line in f:
try:
done.add(json.loads(line)['variant'])
except Exception:
pass
device = torch.device('cuda')
print(f"torch {torch.__version__} on {torch.cuda.get_device_name(0)}", flush=True)
data_dir = os.path.join(BENCH, 'preprocessed_csi_data')
dataset = PreprocessedCSIKeypointsDataset(data_dir=data_dir, keypoint_scale=1000.0,
enable_temporal_clean=True)
for v in VARIANTS:
if v['name'] in done:
print(f"[{v['name']}] already in results.jsonl — skipping", flush=True)
continue
print(f"\n===== variant: {v['name']} =====", flush=True)
try:
result = train_variant(v, dataset, device)
except Exception as e: # record and move on to next variant
import traceback
traceback.print_exc()
result = {'variant': v['name'], 'error': repr(e),
'finished_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}
with open(results_path, 'a') as f:
f.write(json.dumps(result) + '\n')
f.flush()
print('\nSWEEP COMPLETE', flush=True)
if __name__ == '__main__':
main()
Binary file not shown.
@@ -0,0 +1,772 @@
{
"torch": {
"env": {
"torch": "2.12.0+cpu",
"platform": "Windows-11-10.0.26200-SP0",
"processor": "Intel64 Family 6 Model 197 Stepping 2, GenuineIntel",
"num_threads": 16,
"checkpoint": "results\\retrained_best_pose_model.pth",
"params": 2225042
},
"variants": {
"fp32": {
"file": "retrained_fp32_resaved.pth",
"size_bytes": 9068948,
"size_mb": 9.068948,
"latency_batch1": {
"batch_size": 1,
"runs": 100,
"median_ms_per_batch": 24.903650000851485,
"median_ms_per_window": 24.903650000851485,
"windows_per_second": 40.15475642991324
},
"latency_batch64": {
"batch_size": 64,
"runs": 30,
"median_ms_per_batch": 184.02919999789447,
"median_ms_per_window": 2.875456249967101,
"windows_per_second": 347.77089723115813
},
"accuracy": {
"samples": 10000,
"pck@20": 0.9668200004577636,
"pck@50": 0.9915333324432373,
"mpjpe": 0.00936222033649683,
"wall_seconds": 37.85407733917236
}
},
"fp16": {
"file": "retrained_fp16.pth",
"size_bytes": 4580332,
"size_mb": 4.580332,
"latency_batch1": {
"batch_size": 1,
"runs": 100,
"median_ms_per_batch": 23.936699999467237,
"median_ms_per_window": 23.936699999467237,
"windows_per_second": 41.776853117691964
},
"latency_batch64": {
"batch_size": 64,
"runs": 30,
"median_ms_per_batch": 102.32584999903338,
"median_ms_per_window": 1.5988414062348966,
"windows_per_second": 625.4529036465817
},
"accuracy": {
"samples": 10000,
"pck@20": 0.966773332977295,
"pck@50": 0.9915066654205322,
"mpjpe": 0.009460017587244511,
"wall_seconds": 21.632277250289917
}
},
"int8_dynamic": {
"file": "retrained_int8_dynamic.pth",
"size_bytes": 9068948,
"size_mb": 9.068948,
"latency_batch1": {
"batch_size": 1,
"runs": 100,
"median_ms_per_batch": 18.105350000041653,
"median_ms_per_window": 18.105350000041653,
"windows_per_second": 55.23229321707117
},
"latency_batch64": {
"batch_size": 64,
"runs": 30,
"median_ms_per_batch": 168.77549999844632,
"median_ms_per_window": 2.6371171874757238,
"windows_per_second": 379.20195763359703
},
"accuracy": {
"samples": 10000,
"pck@20": 0.9668200004577636,
"pck@50": 0.9915333324432373,
"mpjpe": 0.00936222033649683,
"wall_seconds": 45.35376596450806
}
}
},
"int8_dynamic_quant_report": {
"eligible_module_counts": {
"nn.Linear": 0,
"nn.Conv1d": 21,
"nn.Conv2d": 22
},
"modules_actually_quantized": [],
"n_modules_quantized": 0,
"params_total": 2225042,
"params_quantized": 0,
"params_quantized_fraction": 0.0
},
"accuracy_subset": {
"description": "seed-42 file-level 70/15/15 test split, corrupted windows (files 487-499) excluded, seed-42 random subset",
"subset_size": 10000,
"clean_test_total": 10000
}
},
"onnx": {
"env": {
"torch": "2.12.0+cpu",
"onnxruntime": "1.26.0",
"platform": "Windows-11-10.0.26200-SP0"
},
"export": {
"mode": "dynamic-batch",
"exporter": "torchscript",
"file": "retrained_fp32_dynamic.onnx",
"size_mb": 8.971781
},
"parity": {
"fixture": "results/parity_fixture.npz (batch 2, seed 42)",
"max_abs_diff_vs_stored_fixture": 2.384185791015625e-07,
"max_abs_diff_vs_torch_now": 2.384185791015625e-07,
"pass_lt_1e-4": true
},
"latency": {
"batch1": {
"batch_size": 1,
"runs": 100,
"median_ms_per_batch": 2.5410999987798277,
"median_ms_per_window": 2.5410999987798277,
"windows_per_second": 393.5303610563043
},
"batch64": {
"batch_size": 64,
"runs": 30,
"median_ms_per_batch": 181.95204999938142,
"median_ms_per_window": 2.8430007812403346,
"windows_per_second": 351.7410218803118
}
},
"ort_int8_dynamic_supplementary": {
"file": "retrained_int8_ort_dynamic.onnx",
"size_mb": 2.438794,
"runs": true,
"max_abs_diff_vs_fp32_fixture": 0.00827130675315857
}
},
"onnx_accuracy": {
"onnx_fp32": {
"samples": 10000,
"pck@20": 0.9668200004577636,
"pck@50": 0.9915333324432373,
"mpjpe": 0.00936222568154335,
"wall_seconds": 22.34790802001953
},
"onnx_int8_ort_dynamic": {
"samples": 10000,
"pck@20": 0.965240001964569,
"pck@50": 0.9915466655731201,
"mpjpe": 0.01108054072111845,
"wall_seconds": 55.742953062057495
}
},
"latency_controlled_rerun": {
"note": "3 interleaved repetitions per variant, median ms/window; quiet box",
"fp32": {
"batch1_ms_per_window_median": 10.969150001983508,
"batch1_reps": [
10.969150001983508,
12.646450000829645,
10.49820000116597
],
"batch64_ms_per_window_median": 2.2734187500077496,
"batch64_reps": [
2.377234374989712,
2.124126562478068,
2.2734187500077496
]
},
"fp16": {
"batch1_ms_per_window_median": 24.313550000442774,
"batch1_reps": [
25.1078499986761,
21.856999999727122,
24.313550000442774
],
"batch64_ms_per_window_median": 2.414695312495496,
"batch64_reps": [
2.5705156249955508,
1.7137437499741281,
2.414695312495496
]
},
"int8_dynamic": {
"batch1_ms_per_window_median": 15.627150000000256,
"batch1_reps": [
17.67525000104797,
14.627999998992891,
15.627150000000256
],
"batch64_ms_per_window_median": 2.0546906250160646,
"batch64_reps": [
2.0546906250160646,
2.03407343752815,
2.9325796875241394
]
},
"onnx_fp32": {
"batch1_ms_per_window_median": 3.186650001225644,
"batch1_reps": [
2.7332500012562377,
3.1995500012271805,
3.186650001225644
],
"batch64_ms_per_window_median": 1.9893374999924163,
"batch64_reps": [
1.5590843750032946,
1.9893374999924163,
2.2144343749914697
]
},
"onnx_int8_ort_dynamic": {
"batch1_ms_per_window_median": 6.50984999811044,
"batch1_reps": [
6.50984999811044,
6.455249998907675,
6.789299999581999
],
"batch64_ms_per_window_median": 5.770093750015803,
"batch64_reps": [
5.770093750015803,
3.912374999970325,
7.8067296875019565
]
}
},
"onnx_static_ptq": {
"env": {
"onnxruntime": "1.26.0",
"torch": "2.12.0+cpu",
"platform": "Windows-11-10.0.26200-SP0",
"source_model": "retrained_fp32_dynamic.onnx",
"preprocessed_model": {
"file": "retrained_fp32_preproc.onnx",
"size_mb": 8.981529
}
},
"variants": {
"minmax_all": {
"file": "retrained_int8_static_minmax_all.onnx",
"size_bytes": 2604286,
"size_mb": 2.604286,
"calibration": {
"method": "minmax",
"windows": 1000,
"percentile": null,
"seconds": 5.052440166473389
},
"scope": "all",
"per_channel": true,
"activation_type": "QInt8",
"weight_type": "QInt8",
"node_counts": {
"Add": 9,
"AveragePool": 1,
"BatchNormalization": 12,
"Concat": 10,
"Conv": 43,
"DequantizeLinear": 283,
"Einsum": 4,
"Gather": 16,
"Mul": 39,
"QuantizeLinear": 181,
"Reshape": 14,
"Shape": 2,
"Sigmoid": 37,
"Slice": 8,
"Softmax": 2,
"Squeeze": 1,
"Transpose": 7,
"Unsqueeze": 11
},
"max_abs_diff_vs_fp32_fixture": 0.015945255756378174,
"accuracy": {
"samples": 10000,
"pck@20": 0.9545266661643982,
"pck@50": 0.9913666645050049,
"mpjpe": 0.014860070134699345,
"wall_seconds": 43.455235958099365
}
},
"minmax_conv": {
"file": "retrained_int8_static_minmax_conv.onnx",
"size_bytes": 2527421,
"size_mb": 2.527421,
"calibration": {
"method": "minmax",
"windows": 1000,
"percentile": null,
"seconds": 4.380746126174927
},
"scope": "conv",
"per_channel": true,
"activation_type": "QInt8",
"weight_type": "QInt8",
"node_counts": {
"Add": 9,
"AveragePool": 1,
"BatchNormalization": 12,
"Concat": 10,
"Conv": 43,
"DequantizeLinear": 156,
"Einsum": 4,
"Gather": 16,
"Mul": 39,
"QuantizeLinear": 78,
"Reshape": 14,
"Shape": 2,
"Sigmoid": 37,
"Slice": 8,
"Softmax": 2,
"Squeeze": 1,
"Transpose": 7,
"Unsqueeze": 11
},
"max_abs_diff_vs_fp32_fixture": 0.010693132877349854,
"accuracy": {
"samples": 10000,
"pck@20": 0.9663399996757507,
"pck@50": 0.9918666641235352,
"mpjpe": 0.01084446222037077,
"wall_seconds": 35.937947034835815
}
},
"entropy_all": {
"file": "retrained_int8_static_entropy_all.onnx",
"size_bytes": 2604268,
"size_mb": 2.604268,
"calibration": {
"method": "entropy",
"windows": 512,
"percentile": null,
"seconds": 23.835066318511963
},
"scope": "all",
"per_channel": true,
"activation_type": "QInt8",
"weight_type": "QInt8",
"node_counts": {
"Add": 9,
"AveragePool": 1,
"BatchNormalization": 12,
"Concat": 10,
"Conv": 43,
"DequantizeLinear": 283,
"Einsum": 4,
"Gather": 16,
"Mul": 39,
"QuantizeLinear": 181,
"Reshape": 14,
"Shape": 2,
"Sigmoid": 37,
"Slice": 8,
"Softmax": 2,
"Squeeze": 1,
"Transpose": 7,
"Unsqueeze": 11
},
"max_abs_diff_vs_fp32_fixture": 0.015280365943908691,
"accuracy": {
"samples": 10000,
"pck@20": 0.9530466662406921,
"pck@50": 0.9912600006103516,
"mpjpe": 0.015098519864678382,
"wall_seconds": 51.514281034469604
}
},
"entropy_conv": {
"file": "retrained_int8_static_entropy_conv.onnx",
"size_bytes": 2527403,
"size_mb": 2.527403,
"calibration": {
"method": "entropy",
"windows": 512,
"percentile": null,
"seconds": 9.634419918060303
},
"scope": "conv",
"per_channel": true,
"activation_type": "QInt8",
"weight_type": "QInt8",
"node_counts": {
"Add": 9,
"AveragePool": 1,
"BatchNormalization": 12,
"Concat": 10,
"Conv": 43,
"DequantizeLinear": 156,
"Einsum": 4,
"Gather": 16,
"Mul": 39,
"QuantizeLinear": 78,
"Reshape": 14,
"Shape": 2,
"Sigmoid": 37,
"Slice": 8,
"Softmax": 2,
"Squeeze": 1,
"Transpose": 7,
"Unsqueeze": 11
},
"max_abs_diff_vs_fp32_fixture": 0.012535125017166138,
"accuracy": {
"samples": 10000,
"pck@20": 0.9659599989891052,
"pck@50": 0.9918666648864746,
"mpjpe": 0.010778637571632861,
"wall_seconds": 41.01180171966553
}
},
"percentile_all": {
"file": "retrained_int8_static_percentile_all.onnx",
"size_bytes": 2604052,
"size_mb": 2.604052,
"calibration": {
"method": "percentile",
"windows": 512,
"percentile": 99.99,
"seconds": 20.221954584121704
},
"scope": "all",
"per_channel": true,
"activation_type": "QInt8",
"weight_type": "QInt8",
"node_counts": {
"Add": 9,
"AveragePool": 1,
"BatchNormalization": 12,
"Concat": 10,
"Conv": 43,
"DequantizeLinear": 283,
"Einsum": 4,
"Gather": 16,
"Mul": 39,
"QuantizeLinear": 181,
"Reshape": 14,
"Shape": 2,
"Sigmoid": 37,
"Slice": 8,
"Softmax": 2,
"Squeeze": 1,
"Transpose": 7,
"Unsqueeze": 11
},
"max_abs_diff_vs_fp32_fixture": 0.017689883708953857,
"accuracy": {
"samples": 10000,
"pck@20": 0.9639333323478698,
"pck@50": 0.9916799991607667,
"mpjpe": 0.012176512064039708,
"wall_seconds": 49.365190744400024
}
},
"percentile_conv": {
"file": "retrained_int8_static_percentile_conv.onnx",
"size_bytes": 2527241,
"size_mb": 2.527241,
"calibration": {
"method": "percentile",
"windows": 512,
"percentile": 99.99,
"seconds": 8.223475694656372
},
"scope": "conv",
"per_channel": true,
"activation_type": "QInt8",
"weight_type": "QInt8",
"node_counts": {
"Add": 9,
"AveragePool": 1,
"BatchNormalization": 12,
"Concat": 10,
"Conv": 43,
"DequantizeLinear": 156,
"Einsum": 4,
"Gather": 16,
"Mul": 39,
"QuantizeLinear": 78,
"Reshape": 14,
"Shape": 2,
"Sigmoid": 37,
"Slice": 8,
"Softmax": 2,
"Squeeze": 1,
"Transpose": 7,
"Unsqueeze": 11
},
"max_abs_diff_vs_fp32_fixture": 0.014725983142852783,
"accuracy": {
"samples": 10000,
"pck@20": 0.9660599988937378,
"pck@50": 0.9916066654205322,
"mpjpe": 0.010310938355326652,
"wall_seconds": 36.89548587799072
}
}
},
"latency": {
"note": "3 interleaved repetitions per variant, median ms/window; onnx_fp32 / onnx_int8_ort_dynamic are same-session references",
"onnx_fp32": {
"batch1_reps": [
4.5327999996516155,
2.535649999117595,
2.167549997466267
],
"batch64_reps": [
1.9354515624740998,
2.4948054687854437,
1.9334703125082342
],
"batch1_ms_per_window_median": 2.535649999117595,
"batch64_ms_per_window_median": 1.9354515624740998
},
"onnx_int8_ort_dynamic": {
"batch1_reps": [
5.698599999959697,
5.721350000385428,
4.805099997611251
],
"batch64_reps": [
4.096601562508795,
4.857628124995017,
4.583800000006022
],
"batch1_ms_per_window_median": 5.698599999959697,
"batch64_ms_per_window_median": 4.583800000006022
},
"entropy_all": {
"batch1_reps": [
6.444149999879301,
5.038299999796436,
5.713200000172947
],
"batch64_reps": [
4.149468750028973,
3.437125000004926,
4.410960937491382
],
"batch1_ms_per_window_median": 5.713200000172947,
"batch64_ms_per_window_median": 4.149468750028973
},
"entropy_conv": {
"batch1_reps": [
4.874750000453787,
5.169099998965976,
5.236699998931726
],
"batch64_reps": [
3.010160156236452,
3.1175546875203963,
3.516850781238645
],
"batch1_ms_per_window_median": 5.169099998965976,
"batch64_ms_per_window_median": 3.1175546875203963
},
"percentile_all": {
"batch1_reps": [
5.184749999898486,
5.2898499998264015,
5.916899999647285
],
"batch64_reps": [
4.305105468745296,
4.460741406262514,
4.184502343747454
],
"batch1_ms_per_window_median": 5.2898499998264015,
"batch64_ms_per_window_median": 4.305105468745296
},
"percentile_conv": {
"batch1_reps": [
4.916449999655015,
7.150899999032845,
5.284949998895172
],
"batch64_reps": [
3.855813281262499,
4.688969531230214,
5.220103124997877
],
"batch1_ms_per_window_median": 5.284949998895172,
"batch64_ms_per_window_median": 4.688969531230214
},
"minmax_all": {
"batch1_reps": [
6.463300000177696,
7.149449998905766,
5.3209000016067876
],
"batch64_reps": [
3.9251343750095202,
4.033442187505898,
3.428199218745931
],
"batch1_ms_per_window_median": 6.463300000177696,
"batch64_ms_per_window_median": 3.9251343750095202
},
"minmax_conv": {
"batch1_reps": [
5.9961499991914025,
5.236549999608542,
4.854399998293957
],
"batch64_reps": [
4.368359375007458,
3.249617187492504,
3.0238906249735464
],
"batch1_ms_per_window_median": 5.236549999608542,
"batch64_ms_per_window_median": 3.249617187492504
}
},
"accuracy_subset": {
"description": "seed-42 file-level 70/15/15 test split, corrupted windows excluded, seed-42 random subset (same as quantize_bench/eval_ort_accuracy)",
"subset_size": 10000
}
},
"tiny_variant": {
"env": {
"torch": "2.12.0+cpu",
"onnxruntime": "1.26.0",
"platform": "Windows-11-10.0.26200-SP0",
"num_threads": 16,
"checkpoint": "results\\tiny_best.pth",
"checkpoint_size_bytes": 340555,
"params": 56290,
"variant_config": {
"tcn": [
68,
56,
44,
32
],
"conv": [
2,
4,
8,
16
],
"attn_groups": 2,
"groups_mode": "depthwise",
"input_pw_groups": 4
}
},
"export": {
"mode": "dynamic-batch",
"exporter": "torchscript",
"opset": 17,
"file": "tiny_fp32_dynamic.onnx",
"size_bytes": 295279,
"size_mb": 0.295279,
"verified_batches": [
1,
2,
64
],
"note": "AdaptiveAvgPool2d((15,1)) replaced at export by an exact mean(-1) + constant averaging matmul (final_width 16 is not a multiple of 15, which the TorchScript exporter rejects); exactness proven by the parity check vs the original torch model"
},
"parity": {
"fixture": "results/parity_fixture.npz input (batch 2, seed 42); reference output recomputed with the tiny torch model",
"max_abs_diff_vs_torch": 1.4901161193847656e-07,
"pass_lt_1e-4": true
},
"int8_static_percentile_conv": {
"file": "tiny_int8_static_percentile_conv.onnx",
"size_bytes": 248278,
"size_mb": 0.248278,
"calibration": {
"method": "percentile",
"percentile": 99.99,
"windows": 512,
"scope": "conv-only TRAIN-split corruption-free",
"seconds": 1.5347836017608643
},
"per_channel": true,
"activation_type": "QInt8",
"weight_type": "QInt8",
"max_abs_diff_vs_fp32_fixture": 0.018491357564926147
},
"latency": {
"note": "3 interleaved repetitions per variant, median ms/window; full-model sessions are same-session references",
"tiny_onnx_fp32": {
"batch1_reps": [
0.6312500008789357,
0.6834500018157996,
0.6595999984710943
],
"batch64_reps": [
0.37747578119251557,
0.24196640623586063,
0.2314671875183194
],
"batch1_ms_per_window_median": 0.6595999984710943,
"batch64_ms_per_window_median": 0.24196640623586063
},
"tiny_onnx_int8_static_percentile_conv": {
"batch1_reps": [
0.7988500001374632,
0.9382499993080273,
0.8451000030618161
],
"batch64_reps": [
0.9211476562995813,
1.3045390625165965,
1.026230468767153
],
"batch1_ms_per_window_median": 0.8451000030618161,
"batch64_ms_per_window_median": 1.026230468767153
},
"full_onnx_fp32_reference": {
"batch1_reps": [
2.267249998112675,
2.80170000041835,
2.132149998942623
],
"batch64_reps": [
1.3050578124875756,
1.4244992187855132,
1.8014164062947202
],
"batch1_ms_per_window_median": 2.267249998112675,
"batch64_ms_per_window_median": 1.4244992187855132
},
"full_onnx_int8_static_percentile_conv_reference": {
"batch1_reps": [
5.529599999135826,
4.768399998283712,
6.215800000063609
],
"batch64_reps": [
3.815724218725336,
3.1025562500417436,
4.333318749957016
],
"batch1_ms_per_window_median": 5.529599999135826,
"batch64_ms_per_window_median": 3.815724218725336
}
},
"accuracy_subset": {
"description": "seed-42 file-level 70/15/15 test split, corrupted windows excluded, seed-42 random subset (same as quantize_bench/eval_ort_accuracy/static_ptq_bench)",
"subset_size": 10000
},
"accuracy": {
"tiny_onnx_fp32": {
"samples": 10000,
"pck@20": 0.941106667804718,
"pck@50": 0.99369333152771,
"mpjpe": 0.012527281279861927,
"wall_seconds": 10.927234888076782
},
"tiny_onnx_int8_static_percentile_conv": {
"samples": 10000,
"pck@20": 0.9268133331298828,
"pck@50": 0.9932933319091797,
"mpjpe": 0.014906252065300942,
"wall_seconds": 12.320892333984375
}
}
}
}
@@ -0,0 +1,3 @@
{"variant": "half", "params": 843834, "tcn_channels": [270, 220, 170, 120], "conv_channels": [4, 8, 16, 32], "attn_groups": 4, "groups_mode": "gcd20", "input_pw_groups": 1, "tcn_groups_per_block": [[20, 10], [10, 20], [20, 10], [10, 20]], "conv_strides": [2, 2, 2, 1], "final_width": 15, "batch_size": 64, "max_epochs": 50, "patience": 5, "lr": 0.0001, "weight_decay": 5e-05, "seed": 42, "precision": "fp32", "epochs_run": 28, "best_epoch": 23, "best_val_mpjpe": 0.008576328293592842, "best_val_pck20": 0.9690593021534107, "train_seconds": 1346.4, "torch": "2.11.0+cu128", "error": null, "finished_utc": "2026-06-11T03:09:47Z", "checkpoint": "/home/ruvultra/wiflow-std-bench/sweep/half_best.pth", "test_full": {"samples": 54000, "mpjpe": 0.009419974447676428, "pck@10": 0.8740543655289544, "pck@20": 0.9610469643628156, "pck@30": 0.9813556064146537, "pck@40": 0.9896086878246731, "pck@50": 0.9934827546013726}, "test_clean": {"samples": 52560, "mpjpe": 0.008980081718602137, "pck@10": 0.8840944136840205, "pck@20": 0.9662253179869514, "pck@30": 0.9847971080282144, "pck@40": 0.9917795997050618, "pck@50": 0.9946956242600532}}
{"variant": "quarter", "params": 338600, "tcn_channels": [135, 110, 85, 60], "conv_channels": [2, 4, 8, 16], "attn_groups": 2, "groups_mode": "gcd20", "input_pw_groups": 1, "tcn_groups_per_block": [[20, 5], [5, 10], [10, 5], [5, 20]], "conv_strides": [2, 2, 1, 1], "final_width": 15, "batch_size": 64, "max_epochs": 50, "patience": 5, "lr": 0.0001, "weight_decay": 5e-05, "seed": 42, "precision": "fp32", "epochs_run": 50, "best_epoch": 50, "best_val_mpjpe": 0.008780752391864856, "best_val_pck20": 0.9672531302240159, "train_seconds": 1754.4, "torch": "2.11.0+cu128", "error": null, "finished_utc": "2026-06-11T03:39:06Z", "checkpoint": "/home/ruvultra/wiflow-std-bench/sweep/quarter_best.pth", "test_full": {"samples": 54000, "mpjpe": 0.009705399298005634, "pck@10": 0.8646123917014511, "pck@20": 0.9553815319449813, "pck@30": 0.979827209190086, "pck@40": 0.9887037501511751, "pck@50": 0.9931309027671814}, "test_clean": {"samples": 52560, "mpjpe": 0.009279253277105465, "pck@10": 0.8742288637923323, "pck@20": 0.9605315079427745, "pck@30": 0.9833016723076865, "pck@40": 0.9908206971631566, "pck@50": 0.9942719799017071}}
{"variant": "tiny", "params": 56290, "tcn_channels": [68, 56, 44, 32], "conv_channels": [2, 4, 8, 16], "attn_groups": 2, "groups_mode": "depthwise", "input_pw_groups": 4, "tcn_groups_per_block": [[540, 68], [68, 56], [56, 44], [44, 32]], "conv_strides": [2, 1, 1, 1], "final_width": 16, "batch_size": 64, "max_epochs": 50, "patience": 5, "lr": 0.0001, "weight_decay": 5e-05, "seed": 42, "precision": "fp32", "epochs_run": 50, "best_epoch": 47, "best_val_mpjpe": 0.012602971208592256, "best_val_pck20": 0.9397210340146666, "train_seconds": 1540.1, "torch": "2.11.0+cu128", "error": null, "finished_utc": "2026-06-11T04:04:50Z", "checkpoint": "/home/ruvultra/wiflow-std-bench/sweep/tiny_best.pth", "test_full": {"samples": 54000, "mpjpe": 0.012859782406853305, "pck@10": 0.7640358444319831, "pck@20": 0.9364815320968628, "pck@30": 0.9731568422317505, "pck@40": 0.9866444962642811, "pck@50": 0.992488939108672}, "test_clean": {"samples": 52560, "mpjpe": 0.012502924276904246, "pck@10": 0.770895526488985, "pck@20": 0.9411073559313967, "pck@30": 0.9764840687790962, "pck@40": 0.9886695077067278, "pck@50": 0.9936238432039409}}
@@ -0,0 +1,21 @@
{
"checkpoint": "/home/ruvultra/wiflow-std-bench/upstream/test/best_pose_model.pth",
"test_full": {
"samples": 54000,
"mpjpe": 0.009834060806367133,
"pck@10": 0.8686346120127925,
"pck@20": 0.9608815324571398,
"pck@30": 0.9789111610695168,
"pck@40": 0.9857975759682832,
"pck@50": 0.9898827553325229
},
"test_clean": {
"samples": 52560,
"mpjpe": 0.009432755044379373,
"pck@10": 0.876996495807189,
"pck@20": 0.9661454100405608,
"pck@30": 0.9823453060205306,
"pck@40": 0.987909734176537,
"pck@50": 0.9911238361167036
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.
@@ -0,0 +1,32 @@
{
"published": {
"pck@20": 0.9725,
"pck@30": 0.9863,
"pck@40": 0.9916,
"pck@50": 0.9948,
"mpjpe": 0.007
},
"params_millions": 2.225042,
"data_dir": "C:\\Users\\ruv\\.cache\\kagglehub\\datasets\\kaka2434\\wiflow-dataset\\versions\\1\\preprocessed_csi_data",
"device": "cpu",
"test_full": {
"samples": 54000,
"mpjpe": NaN,
"pck@10": 5.6790124349020145e-05,
"pck@20": 0.0007876543271596785,
"pck@30": 0.007780246982971827,
"pck@40": 0.05529259262923841,
"pck@50": 0.1542370371548114,
"wall_seconds": 118.03756999969482
},
"test_drop_last": {
"samples": 53952,
"mpjpe": NaN,
"pck@10": 5.6840649370682976e-05,
"pck@20": 0.0007883550872372227,
"pck@30": 0.007787168910892621,
"pck@40": 0.055318307667895535,
"pck@50": 0.15425316342412276,
"wall_seconds": 120.87458372116089
}
}
Binary file not shown.
+333
View File
@@ -0,0 +1,333 @@
"""ADR-152 edge optimization follow-up: ONNX Runtime STATIC post-training
quantization (calibration-based QDQ) of the retrained WiFlow-STD model, to
improve on the dynamic-int8 result (2.44 MB, PCK@20 96.52%, 6.5 ms/win b1).
Static PTQ pre-computes activation ranges from calibration data, so inference
uses QLinearConv/QDQ kernels instead of dynamic ConvInteger -- typically both
faster and (with good calibration) closer to fp32 accuracy.
Method:
- Calibration set: corruption-free windows drawn ONLY from the seed-42
file-level TRAINING split (same split as eval_repro.py; corrupted windows
excluded via results/nan_windows_mask.npy | big_windows_mask.npy), chosen
with np.random.default_rng(42). Never test windows.
- quantize_static, QuantFormat.QDQ, per-channel int8 weights, int8
activations; calibration methods MinMax / Entropy / Percentile(99.99);
scopes "all" (ORT default op set) vs "conv" (op_types_to_quantize=
["Conv"] -- leaves the attention path, which exports as Einsum/Softmax
and elementwise ops, in fp32).
- Model is pre-processed first (quant_pre_process: symbolic shape
inference + ORT graph optimization, folds BatchNormalization into Conv).
- Accuracy: identical protocol to eval_ort_accuracy.py -- the 10,000-window
seed-42 subset of the corruption-free test split (PCK@20/50, MPJPE).
- Latency: median ms/window at batch 1 (100 runs) and batch 64 (30 runs),
3 interleaved repetitions across all variants (fp32 and dynamic-int8
sessions included as same-session reference points).
Usage:
PYTHONUTF8=1 .venv/Scripts/python.exe static_ptq_bench.py \
[--data-dir <preprocessed_csi_data>] [--subset 10000]
[--calib-minmax 1000] [--calib-hist 512] [--skip-accuracy]
Writes/merges into results/edge_optimization.json under key "onnx_static_ptq".
"""
import argparse
import collections
import json
import os
import platform
import statistics
import sys
import time
import numpy as np
import torch
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from _bench_common import RESULTS # noqa: E402
# quantize_bench sets up upstream imports + the np.load mmap patch
# (both via _bench_common.import_upstream)
from quantize_bench import build_test_subset # noqa: E402
import quantize_bench as qb # noqa: E402
from eval_ort_accuracy import evaluate_ort # noqa: E402
FP32_ONNX = os.path.join(RESULTS, "retrained_fp32_dynamic.onnx")
DYN_INT8_ONNX = os.path.join(RESULTS, "retrained_int8_ort_dynamic.onnx")
PREPROC_ONNX = os.path.join(RESULTS, "retrained_fp32_preproc.onnx")
# ---------------------------------------------------------------------------
# calibration data: corruption-free TRAINING-split windows only
# ---------------------------------------------------------------------------
def build_calibration_windows(data_dir, n_windows):
"""Seed-42 file-level 70/15/15 TRAIN split (exactly as eval_repro.py),
minus corrupted windows, then a seed-42 random draw of n_windows."""
dataset = qb.PreprocessedCSIKeypointsDataset(
data_dir=data_dir, keypoint_scale=1000.0, enable_temporal_clean=True)
train_loader, _va, _te = qb.create_preprocessed_train_val_test_loaders(
dataset=dataset, batch_size=64, num_workers=0, random_seed=42)
train_indices = np.asarray(train_loader.dataset.indices)
corrupted = (np.load(os.path.join(RESULTS, "nan_windows_mask.npy"))
| np.load(os.path.join(RESULTS, "big_windows_mask.npy")))
clean = train_indices[~corrupted[train_indices]]
print(f"train split: {len(train_indices)} windows, "
f"{len(train_indices) - len(clean)} corrupted excluded, "
f"{len(clean)} clean")
rng = np.random.default_rng(42)
sel = np.sort(rng.choice(clean, size=n_windows, replace=False))
xs = np.stack([dataset[int(i)][0].numpy() for i in sel]).astype(np.float32)
print(f"calibration tensor: {xs.shape} from {n_windows} clean TRAIN windows")
return xs
def make_reader(windows, batch_size=64):
from onnxruntime.quantization import CalibrationDataReader
class WindowReader(CalibrationDataReader):
def __init__(self):
self._batches = [windows[i:i + batch_size]
for i in range(0, len(windows), batch_size)]
self._it = iter(self._batches)
def get_next(self):
b = next(self._it, None)
return None if b is None else {"input": b}
def rewind(self):
self._it = iter(self._batches)
def __len__(self):
return len(self._batches)
return WindowReader()
# ---------------------------------------------------------------------------
# quantization variants
# ---------------------------------------------------------------------------
def preprocess_model():
from onnxruntime.quantization.shape_inference import quant_pre_process
quant_pre_process(FP32_ONNX, PREPROC_ONNX)
return PREPROC_ONNX
def quantize_variant(src, dst, method, scope, calib_windows):
from onnxruntime.quantization import (CalibrationMethod, QuantFormat,
QuantType, quantize_static)
methods = {
"minmax": CalibrationMethod.MinMax,
"entropy": CalibrationMethod.Entropy,
"percentile": CalibrationMethod.Percentile,
}
# NB: do NOT pass CalibMaxIntermediateOutputs -- in ORT 1.26 the MinMax
# calibrater clears its buffer every N batches and then raises
# "No data is collected" if the batch count is divisible by N.
extra = {}
if method == "percentile":
extra["CalibPercentile"] = 99.99
op_types = ["Conv"] if scope == "conv" else None
t0 = time.time()
quantize_static(
src, dst, make_reader(calib_windows),
quant_format=QuantFormat.QDQ,
op_types_to_quantize=op_types,
per_channel=True,
activation_type=QuantType.QInt8,
weight_type=QuantType.QInt8,
calibrate_method=methods[method],
extra_options=extra,
)
secs = time.time() - t0
import onnx
ops = collections.Counter(n.op_type for n in onnx.load(dst).graph.node)
return {
"file": os.path.basename(dst),
"size_bytes": os.path.getsize(dst),
"size_mb": os.path.getsize(dst) / 1e6,
"calibration": {"method": method,
"windows": int(len(calib_windows)),
"percentile": extra.get("CalibPercentile"),
"seconds": secs},
"scope": scope,
"per_channel": True,
"activation_type": "QInt8",
"weight_type": "QInt8",
"node_counts": {k: v for k, v in sorted(ops.items())},
}
# ---------------------------------------------------------------------------
# latency (3 interleaved reps, like the latency_controlled_rerun)
# ---------------------------------------------------------------------------
def ort_session(path):
import onnxruntime as ort
return ort.InferenceSession(path, providers=["CPUExecutionProvider"])
def bench_ort(sess, batch, n_runs):
rng = np.random.default_rng(123)
x = rng.random((batch, 540, 20), dtype=np.float32)
inp = sess.get_inputs()[0].name
for _ in range(max(5, n_runs // 10)):
sess.run(None, {inp: x})
times = []
for _ in range(n_runs):
t0 = time.perf_counter()
sess.run(None, {inp: x})
times.append(time.perf_counter() - t0)
return statistics.median(times) * 1e3 / batch # ms/window
def interleaved_latency(sessions, reps=3, runs_b1=100, runs_b64=30):
lat = {name: {"batch1_reps": [], "batch64_reps": []} for name in sessions}
for rep in range(reps):
for name, sess in sessions.items():
lat[name]["batch1_reps"].append(bench_ort(sess, 1, runs_b1))
lat[name]["batch64_reps"].append(bench_ort(sess, 64, runs_b64))
print(f" rep {rep + 1}/{reps} {name}: "
f"b1={lat[name]['batch1_reps'][-1]:.2f} "
f"b64={lat[name]['batch64_reps'][-1]:.3f} ms/win", flush=True)
for name in lat:
lat[name]["batch1_ms_per_window_median"] = statistics.median(
lat[name]["batch1_reps"])
lat[name]["batch64_ms_per_window_median"] = statistics.median(
lat[name]["batch64_reps"])
return lat
# ---------------------------------------------------------------------------
def main():
import onnxruntime
parser = argparse.ArgumentParser()
parser.add_argument("--data-dir", default=os.path.join(
os.path.expanduser("~"), ".cache", "kagglehub", "datasets", "kaka2434",
"wiflow-dataset", "versions", "1", "preprocessed_csi_data"))
parser.add_argument("--subset", type=int, default=10000)
parser.add_argument("--calib-minmax", type=int, default=1000)
parser.add_argument("--calib-hist", type=int, default=512,
help="calibration windows for Entropy/Percentile "
"(histogram calibraters hold all intermediate "
"activations in RAM)")
parser.add_argument("--skip-accuracy", action="store_true")
parser.add_argument("--methods", default="minmax,entropy,percentile",
help="comma list of calibration methods to (re)run; "
"results merge into existing onnx_static_ptq")
parser.add_argument("--out", default=os.path.join(RESULTS, "edge_optimization.json"))
args = parser.parse_args()
results = {
"env": {
"onnxruntime": onnxruntime.__version__,
"torch": torch.__version__,
"platform": platform.platform(),
"source_model": os.path.basename(FP32_ONNX),
},
"variants": {},
}
# ---- calibration data (TRAIN split only) -------------------------------
calib_mm = build_calibration_windows(args.data_dir, args.calib_minmax)
calib_hist = calib_mm[:args.calib_hist]
# ---- preprocess + quantize ---------------------------------------------
print("\n=== quant_pre_process (shape inference + graph optimization) ===")
src = preprocess_model()
results["env"]["preprocessed_model"] = {
"file": os.path.basename(src),
"size_mb": os.path.getsize(src) / 1e6,
}
matrix = [(m, s) for m in args.methods.split(",")
for s in ("all", "conv")]
for method, scope in matrix:
name = f"{method}_{scope}"
dst = os.path.join(RESULTS, f"retrained_int8_static_{name}.onnx")
calib = calib_mm if method == "minmax" else calib_hist
print(f"\n=== quantize_static: {name} "
f"({len(calib)} calib windows) ===", flush=True)
try:
results["variants"][name] = quantize_variant(
src, dst, method, scope, calib)
print(f" {results['variants'][name]['size_mb']:.3f} MB")
except Exception as e: # noqa: BLE001
results["variants"][name] = {"error": f"{type(e).__name__}: {e}"}
print(f" FAILED: {e}")
# ---- fixture parity (sanity, batch 2) ----------------------------------
fixture = np.load(os.path.join(RESULTS, "parity_fixture.npz"))
fx, fy = fixture["input"], fixture["output"]
sessions = {}
for name, info in results["variants"].items():
if "error" in info:
continue
path = os.path.join(RESULTS, info["file"])
try:
sess = ort_session(path)
yq = sess.run(None, {sess.get_inputs()[0].name: fx})[0]
info["max_abs_diff_vs_fp32_fixture"] = float(np.abs(yq - fy).max())
sessions[name] = sess
except Exception as e: # noqa: BLE001
info["run_error"] = f"{type(e).__name__}: {e}"
print("\nfixture max-abs-diff vs fp32:",
{n: round(results["variants"][n].get("max_abs_diff_vs_fp32_fixture",
float("nan")), 5)
for n in results["variants"]})
# ---- latency: 3 interleaved reps incl. fp32 + dynamic-int8 reference ----
print("\n=== latency (3 interleaved reps) ===")
lat_sessions = {"onnx_fp32": ort_session(FP32_ONNX),
"onnx_int8_ort_dynamic": ort_session(DYN_INT8_ONNX)}
lat_sessions.update(sessions)
results["latency"] = {
"note": "3 interleaved repetitions per variant, median ms/window; "
"onnx_fp32 / onnx_int8_ort_dynamic are same-session references",
**interleaved_latency(lat_sessions),
}
# ---- accuracy on the standard 10k corruption-free test subset ----------
if not args.skip_accuracy:
loader, n_clean = build_test_subset(args.data_dir, args.subset)
results["accuracy_subset"] = {
"description": "seed-42 file-level 70/15/15 test split, corrupted "
"windows excluded, seed-42 random subset (same as "
"quantize_bench/eval_ort_accuracy)",
"subset_size": min(args.subset, n_clean) if args.subset else n_clean,
}
for name, sess in sessions.items():
print(f"\n=== accuracy: {name} ===")
results["variants"][name]["accuracy"] = evaluate_ort(
sess, loader, name)
print(json.dumps(results["variants"][name]["accuracy"], indent=2))
# ---- merge into edge_optimization.json ----------------------------------
merged = {}
if os.path.exists(args.out):
with open(args.out) as f:
merged = json.load(f)
prev = merged.get("onnx_static_ptq")
if prev: # nested merge so partial --methods reruns don't clobber
prev["env"] = results["env"]
prev["variants"].update(results["variants"])
prev.setdefault("latency", {}).update(results["latency"])
if "accuracy_subset" in results:
prev["accuracy_subset"] = results["accuracy_subset"]
else:
merged["onnx_static_ptq"] = results
with open(args.out, "w") as f:
json.dump(merged, f, indent=2)
print(f"\nwrote {args.out}")
if __name__ == "__main__":
main()
+313
View File
@@ -0,0 +1,313 @@
"""ADR-152 efficiency-sweep follow-up: edge pipeline for the TINY compact
WiFlow-STD variant (56,290 params, results/tiny_best.pth, trained overnight
2026-06-10/11 -- see RESULTS.md "Efficiency sweep").
Headline question: what does the smallest deployable WiFlow-class model look
like (KB + ms + PCK)? Reuses the onnx_bench.py / static_ptq_bench.py
machinery on the tiny checkpoint:
1. Load tiny_best.pth with remote/sweep/model_compact.py
(depthwise TCN groups, input_pw_groups=4, conv [2,4,8,16], attn groups 2).
2. Export ONNX: dynamic batch, opset 17, TorchScript exporter (dynamo=False)
-- same recipe that worked for the full model; verified at batch 1/2/64.
One forced deviation: tiny's stride schedule [2,1,1,1] leaves final_width
16, and the TorchScript exporter cannot export AdaptiveAvgPool2d((15,1))
when 15 is not a factor of the input height (the full model never hit
this -- its width was exactly 15). The adaptive pool over a fixed-size
feature map is a fixed linear map, so the export wrapper replaces it with
an exact matmul equivalent (PyTorch adaptive-pool bin semantics:
bin i averages rows floor(i*H/K)..ceil((i+1)*H/K)); the W axis (20->1,
a factor) becomes mean(-1). Exactness is proven by the parity check
below, which compares against the ORIGINAL torch model with the real
AdaptiveAvgPool2d.
3. Torch-vs-ORT parity on the stored fixture input
(results/parity_fixture.npz, batch 2, seed 42 -- same 540x20 input layout;
reference output recomputed with the tiny torch model). PASS < 1e-4.
4. Static QDQ conv-only int8 (quant_pre_process + quantize_static,
per-channel QInt8 weights+activations, Percentile(99.99) calibration on
512 corruption-free TRAIN-split windows -- the winning recipe and
calibration count from static_ptq_bench.py. 512, not "about 500":
ORT 1.26's histogram collector np.asarray()'s the per-batch maxima, so
the calibration count must be a multiple of the batch size 64 or the
ragged last batch crashes it).
5. Disk size + CPU latency b1/b64 (3 interleaved reps, median ms/window)
for tiny fp32 + tiny int8, with the full-model ONNX fp32 + static-int8
sessions interleaved as same-session references.
6. Accuracy (PCK@20/50 + MPJPE) on the identical 10k-window seed-42
corruption-free test subset for tiny fp32 + tiny int8.
Usage:
PYTHONUTF8=1 .venv/Scripts/python.exe tiny_edge_bench.py \
[--data-dir <preprocessed_csi_data>] [--subset 10000] [--calib 512]
(--calib must be a multiple of 64; see step 4 above)
Writes/merges into results/edge_optimization.json under key "tiny_variant".
"""
import argparse
import json
import os
import platform
import sys
import time
import numpy as np
import torch
HERE = os.path.dirname(os.path.abspath(__file__))
RESULTS = os.path.join(HERE, "results")
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "remote", "sweep"))
# quantize_bench sets up upstream imports + the np.load mmap patch
from quantize_bench import build_test_subset # noqa: E402
from eval_ort_accuracy import evaluate_ort # noqa: E402
from static_ptq_bench import ( # noqa: E402
build_calibration_windows,
interleaved_latency,
make_reader,
ort_session,
)
from model_compact import CompactWiFlowPoseModel, describe # noqa: E402
TINY_CKPT = os.path.join(RESULTS, "tiny_best.pth")
TINY_FP32_ONNX = os.path.join(RESULTS, "tiny_fp32_dynamic.onnx")
TINY_PREPROC_ONNX = os.path.join(RESULTS, "tiny_fp32_preproc.onnx")
TINY_INT8_ONNX = os.path.join(RESULTS, "tiny_int8_static_percentile_conv.onnx")
FULL_FP32_ONNX = os.path.join(RESULTS, "retrained_fp32_dynamic.onnx")
FULL_INT8_ONNX = os.path.join(RESULTS, "retrained_int8_static_percentile_conv.onnx")
# Exact tiny config from remote/sweep/run_sweep.py VARIANTS (measured 56,290
# params, clean-test PCK@20 94.11% -- results/efficiency_sweep.jsonl).
TINY = dict(tcn=[68, 56, 44, 32], conv=[2, 4, 8, 16], attn_groups=2,
groups_mode="depthwise", input_pw_groups=4)
def load_tiny_model():
model = CompactWiFlowPoseModel(
tcn_channels=TINY["tcn"], conv_channels=TINY["conv"],
attn_groups=TINY["attn_groups"], groups_mode=TINY["groups_mode"],
input_pw_groups=TINY["input_pw_groups"], dropout=0.5)
state = torch.load(TINY_CKPT, map_location="cpu", weights_only=True)
model.load_state_dict(state, strict=True)
model.eval()
return model
def adaptive_pool_matrix(h_in, h_out):
"""Exact AdaptiveAvgPool1d as a (h_out, h_in) averaging matrix, using
PyTorch's bin rule: bin i covers rows floor(i*h_in/h_out) ..
ceil((i+1)*h_in/h_out)."""
w = torch.zeros(h_out, h_in)
for i in range(h_out):
s = (i * h_in) // h_out
e = -((-(i + 1) * h_in) // h_out) # ceil division
w[i, s:e] = 1.0 / (e - s)
return w
class ExportWrapper(torch.nn.Module):
"""CompactWiFlowPoseModel forward with the AdaptiveAvgPool2d((K,1))
replaced by an exact fixed linear map (mean over the factor W axis, then
a constant averaging matmul over the non-factor H axis) so the
TorchScript ONNX exporter accepts it. Bit-equivalent up to float
round-off; proven by the parity check against the original model."""
def __init__(self, m, num_keypoints=15):
super().__init__()
self.m = m
self.register_buffer(
"pool_w_t", adaptive_pool_matrix(m.final_width, num_keypoints).t())
def forward(self, x):
m = self.m
x = m.tcn(x)
x = x.transpose(1, 2).unsqueeze(1)
x = m.up(x)
for block in m.residual_blocks:
x = block(x)
x = x.permute(0, 1, 3, 2)
x = m.attention(x)
x = m.decoder(x) # [B, 2, H=final_width, T=20]
x = x.mean(-1) # W-axis pool (20 -> 1, a factor)
x = x.matmul(self.pool_w_t) # exact adaptive H pool: [B, 2, K]
return x.transpose(1, 2) # [B, K, 2]
def export_onnx(model):
"""Dynamic-batch TorchScript export (the recipe that worked for the full
model in onnx_bench.py), verified at batch 1/2/64. Uses ExportWrapper
(see docstring) because final_width 16 is not a multiple of 15."""
wrapper = ExportWrapper(model).eval()
x = torch.rand(2, 540, 20)
with torch.no_grad():
torch.onnx.export(
wrapper, (x,), TINY_FP32_ONNX, opset_version=17,
input_names=["input"], output_names=["output"], dynamo=False,
dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}})
sess = ort_session(TINY_FP32_ONNX)
inp = sess.get_inputs()[0].name
for b in (1, 2, 64):
y = sess.run(None, {inp: np.zeros((b, 540, 20), dtype=np.float32)})[0]
assert y.shape == (b, 15, 2), y.shape
return {
"mode": "dynamic-batch", "exporter": "torchscript", "opset": 17,
"file": os.path.basename(TINY_FP32_ONNX),
"size_bytes": os.path.getsize(TINY_FP32_ONNX),
"size_mb": os.path.getsize(TINY_FP32_ONNX) / 1e6,
"verified_batches": [1, 2, 64],
"note": "AdaptiveAvgPool2d((15,1)) replaced at export by an exact "
"mean(-1) + constant averaging matmul (final_width 16 is not "
"a multiple of 15, which the TorchScript exporter rejects); "
"exactness proven by the parity check vs the original torch "
"model",
}
def quantize_tiny(calib_windows):
"""quant_pre_process + static QDQ conv-only Percentile(99.99) int8 --
the winning recipe from static_ptq_bench.py."""
from onnxruntime.quantization import (CalibrationMethod, QuantFormat,
QuantType, quantize_static)
from onnxruntime.quantization.shape_inference import quant_pre_process
quant_pre_process(TINY_FP32_ONNX, TINY_PREPROC_ONNX)
t0 = time.time()
quantize_static(
TINY_PREPROC_ONNX, TINY_INT8_ONNX, make_reader(calib_windows),
quant_format=QuantFormat.QDQ,
op_types_to_quantize=["Conv"],
per_channel=True,
activation_type=QuantType.QInt8,
weight_type=QuantType.QInt8,
calibrate_method=CalibrationMethod.Percentile,
extra_options={"CalibPercentile": 99.99},
)
return {
"file": os.path.basename(TINY_INT8_ONNX),
"size_bytes": os.path.getsize(TINY_INT8_ONNX),
"size_mb": os.path.getsize(TINY_INT8_ONNX) / 1e6,
"calibration": {"method": "percentile", "percentile": 99.99,
"windows": int(len(calib_windows)),
"scope": "conv-only TRAIN-split corruption-free",
"seconds": time.time() - t0},
"per_channel": True,
"activation_type": "QInt8",
"weight_type": "QInt8",
}
def main():
import onnxruntime
parser = argparse.ArgumentParser()
parser.add_argument("--data-dir", default=os.path.join(
os.path.expanduser("~"), ".cache", "kagglehub", "datasets", "kaka2434",
"wiflow-dataset", "versions", "1", "preprocessed_csi_data"))
parser.add_argument("--subset", type=int, default=10000)
parser.add_argument("--calib", type=int, default=512,
help="calibration windows; must be a multiple of the "
"64-window calibration batch (ORT histogram "
"collector rejects ragged batches)")
parser.add_argument("--skip-accuracy", action="store_true")
parser.add_argument("--out", default=os.path.join(RESULTS, "edge_optimization.json"))
args = parser.parse_args()
if args.calib % 64 != 0:
parser.error(
f"--calib must be a multiple of 64 (got {args.calib}): ORT 1.26's "
f"histogram calibration collector np.asarray()'s the per-batch "
f"maxima and crashes on a ragged final batch (calibration batch "
f"size is 64)")
model = load_tiny_model()
info = describe(model)
print(f"tiny model: {info['params']:,} params, tcn_groups={info['tcn_groups_per_block']}, "
f"strides={info['conv_strides']}, final_width={info['final_width']}")
assert info["params"] == 56290, info["params"]
results = {
"env": {
"torch": torch.__version__,
"onnxruntime": onnxruntime.__version__,
"platform": platform.platform(),
"num_threads": torch.get_num_threads(),
"checkpoint": os.path.relpath(TINY_CKPT, HERE),
"checkpoint_size_bytes": os.path.getsize(TINY_CKPT),
"params": info["params"],
"variant_config": TINY,
},
}
# ---- export + parity ----------------------------------------------------
print("\n=== ONNX export (dynamic batch, opset 17, torchscript) ===")
results["export"] = export_onnx(model)
print(f" {results['export']['size_mb']:.3f} MB, batches {results['export']['verified_batches']} OK")
fixture = np.load(os.path.join(RESULTS, "parity_fixture.npz"))
fx = fixture["input"] # (2, 540, 20), seed 42 -- same input layout as full model
sess_fp32 = ort_session(TINY_FP32_ONNX)
y_ort = sess_fp32.run(None, {sess_fp32.get_inputs()[0].name: fx})[0]
with torch.no_grad():
y_torch = model(torch.from_numpy(fx)).numpy()
results["parity"] = {
"fixture": "results/parity_fixture.npz input (batch 2, seed 42); "
"reference output recomputed with the tiny torch model",
"max_abs_diff_vs_torch": float(np.abs(y_ort - y_torch).max()),
"pass_lt_1e-4": bool(np.abs(y_ort - y_torch).max() < 1e-4),
}
print("parity:", json.dumps(results["parity"], indent=2))
assert results["parity"]["pass_lt_1e-4"], "torch-vs-ORT parity FAILED"
# ---- static PTQ int8 ------------------------------------------------------
print(f"\n=== static QDQ int8 (Percentile conv-only, {args.calib} calib windows) ===")
calib = build_calibration_windows(args.data_dir, args.calib)
results["int8_static_percentile_conv"] = quantize_tiny(calib)
print(f" {results['int8_static_percentile_conv']['size_mb']:.3f} MB")
sess_int8 = ort_session(TINY_INT8_ONNX)
yq = sess_int8.run(None, {sess_int8.get_inputs()[0].name: fx})[0]
results["int8_static_percentile_conv"]["max_abs_diff_vs_fp32_fixture"] = float(
np.abs(yq - y_torch).max())
# ---- latency (3 interleaved reps, full-model sessions as references) -----
print("\n=== latency (3 interleaved reps) ===")
lat_sessions = {
"tiny_onnx_fp32": sess_fp32,
"tiny_onnx_int8_static_percentile_conv": sess_int8,
"full_onnx_fp32_reference": ort_session(FULL_FP32_ONNX),
"full_onnx_int8_static_percentile_conv_reference": ort_session(FULL_INT8_ONNX),
}
results["latency"] = {
"note": "3 interleaved repetitions per variant, median ms/window; "
"full-model sessions are same-session references",
**interleaved_latency(lat_sessions),
}
# ---- accuracy on the standard 10k corruption-free test subset ------------
if not args.skip_accuracy:
loader, n_clean = build_test_subset(args.data_dir, args.subset)
results["accuracy_subset"] = {
"description": "seed-42 file-level 70/15/15 test split, corrupted "
"windows excluded, seed-42 random subset (same as "
"quantize_bench/eval_ort_accuracy/static_ptq_bench)",
"subset_size": min(args.subset, n_clean) if args.subset else n_clean,
}
results["accuracy"] = {}
for name, sess in (("tiny_onnx_fp32", sess_fp32),
("tiny_onnx_int8_static_percentile_conv", sess_int8)):
print(f"\n=== accuracy: {name} ===")
results["accuracy"][name] = evaluate_ort(sess, loader, name)
print(json.dumps(results["accuracy"][name], indent=2))
# ---- merge into edge_optimization.json -----------------------------------
merged = {}
if os.path.exists(args.out):
with open(args.out) as f:
merged = json.load(f)
merged["tiny_variant"] = results
with open(args.out, "w") as f:
json.dump(merged, f, indent=2)
print(f"\nwrote {args.out}")
if __name__ == "__main__":
main()
+29 -2
View File
@@ -47,13 +47,16 @@ Adopt four changes, ordered by effort-vs-gain:
1. **Record transceiver geometry at enrollment.** `EnrollmentProtocol` gains an optional `NodeGeometry` record per node (position estimate, antenna orientation, inter-node distances where known). Stored alongside the room baseline in the bank; schema-versioned so existing banks remain readable.
2. **Fuse geometry embeddings into specialist training.** Where a specialist head consumes the (future, ADR-150) backbone embedding, concatenate a small learned embedding of `NodeGeometry` — the PerceptAlign mechanism, transplanted to our per-room banks. Statistical specialists (current) ignore it; LoRA heads (ADR-151 P6) consume it.
3. **Adopt the two-checkerboard alignment for the camera-supervised path (ADR-079).** When MediaPipe supervision is used, calibrate camera↔WiFi into one shared 3D frame before regression (<5 min, two checkerboards, a few photos). This is the direct defense against F1 for our 92.9%-PCK@20 pipeline.
3. **Adopt the two-checkerboard alignment for the camera-supervised path (ADR-079).** When MediaPipe supervision is used, calibrate camera↔WiFi into one shared 3D frame before regression (<5 min, two checkerboards, a few photos). This is the direct defense against F1 for our camera-supervised pipeline. ~~92.9%-PCK@20~~*that figure was retracted during measurement (b) (2026-06-10): the surviving holdout shows a constant-output model under an absolute (non-torso) threshold on 69 near-static frames; mean predictor scores 100% under the same protocol. The §2.2 no-citation rule now applies to it.*
4. **Evaluate on the PerceptAlign cross-domain dataset** (21 subjects / 7 layouts) as the MERIDIAN cross-layout benchmark — *gated on confirming its license and downloadability* (open question; repo per paper: github.com/Trymore-lab/PerceptAlign).
> **Gate resolved (2026-06-10, MEASURED by repo inspection):** repo exists, **MIT license**, dataset downloadable from HuggingFace (5 per-scene repos, raw CSI + separate vision keypoints; Intel 5300, 1TX×3RX×3 ant, 57 subcarriers — same order as ESP32 subcarrier counts; Scene3 ships 3 distinct layouts). Code present, no pretrained weights. Benchmark adoption unblocked; dataset-side license terms inherit HF dataset terms (not separately stated — check at download time).
### 2.2 Benchmark against WiFlow-STD (DY2434) — ACCEPTED
Pull the Apache-2.0 weights + 360k-sample dataset; run three measurements: (a) their model on their data (reproduce 97.25% claim), (b) their model fine-tuned on our ESP32 17-keypoint eval set, (c) our internal WiFlow on their dataset (15-keypoint subset mapping). Until (a)(c) are measured, **no RuView doc may cite 97.25% as a comparable number** — different dataset, subjects, keypoints.
> **Status (2026-06-10, measurement (a) complete — `benchmarks/wiflow-std/RESULTS.md`):** shipped checkpoint REFUTED (0.08% PCK@20 — wrong keypoint normalization, predates published code); released code does not run as published (6 defects, incl. broken package import and an unreachable test phase); released dataset's last 13 files are corrupted (9,072 windows: NaN + float32-max garbage, diverges fp16 training via BatchNorm poisoning). After repairing both, retraining with upstream defaults reproduced **96.09% PCK@20 full-test / 96.61% corruption-free / MPJPE 0.00940.0098** (published: 97.25% / 0.007) on an RTX 5080. Accuracy claims graded MEASURED-EQUIVALENT; params (2.23M) and FLOPs (~0.055G) verified. (b)/(c) remain open.
### 2.3 Apply the UNSW recipe to the ADR-150 encoder — ACCEPTED (amends ADR-150 §2.3)
- Pretraining corpus: start from the same 14 public datasets (1.3M samples) + our home/MM-Fi frames; data aggregation takes priority over architecture work.
@@ -62,7 +65,7 @@ Pull the Apache-2.0 weights + 360k-sample dataset; run three measurements: (a) t
### 2.4 Hardware watch items — ACCEPTED (no code now)
- **802.11bf**: track silicon/certification; revisit when any commodity chipset exposes standardized sensing measurements. Our opportunistic CSI extraction remains the mechanism until then.
- **802.11bf**: track silicon/certification; OTA binding remains deferred until commodity chipsets expose standardized sensing measurements. **Amended by ADR-153** (2026-06-10): implement a pure Rust forward-compatibility protocol layer now — typed procedure models, a deterministic session FSM, a transport abstraction, simulation tests, and an `OpportunisticCsiBridge` that maps today's ESP32 CSI batches into standardized sensing-report shape.
- **esp_wifi_sensing**: benchmark our presence pipeline against the vendor FSM (one afternoon; useful external baseline). Do **not** treat as drop-in (refuted claim).
- **ZTECSITool AP**: optional high-resolution anchor node for the ADR-029 multistatic mesh — procurement-gated; only pursue if a 160 MHz anchor materially helps tomography.
@@ -71,6 +74,29 @@ Pull the Apache-2.0 weights + 360k-sample dataset; run three measurements: (a) t
- No pivot toward "wireless foundation model" papers that don't ship WiFi-CSI artifacts (HeterCSI, FMCW pilot, surveys).
- No DensePose-UV work item: the field has not demonstrated UV regression from commodity WiFi; keypoints remain our supervised target (F5).
### 2.6 RuVector vendor sync + integration opportunities (added 2026-06-10)
**Vendor sync record.** `vendor/ruvector` moved from pin `e38347601` (2026-05-07) to `a083bd77f` (origin/main, 3 commits past tag `ruvector-v0.2.28`; vendored workspace version 2.2.3). 111 commits in the range, roughly half NAPI-binary/lint chores. Substantive: graph condensation + differentiable min-cut (#547), core HNSW correctness fixes v2.2.3 (#502), RUSTSEC/clippy hardening (#504), ONNX embedder API-contract fix (#523/#525 — npm/TypeScript package only), dead parallel-worker import removal (#532). *Evidence: MEASURED (git range + commit-stat inspection).*
**Opportunity table.** Workspace policy is crates.io versions only, so unpublished crates are WATCH by definition regardless of fit.
| Crate | What it offers | wifi-densepose target | crates.io | Verdict |
|---|---|---|---|---|
| `ruvector-graph-condense` (new, #547) | Training-free min-cut graph condensation + **differentiable normalized-cut loss** (`DiffCutCondenser`, analytic MinCutPool-style gradients, gradient-checked tests; provenance-retaining super-nodes) | `subcarrier_selection.rs` (condense 114 subcarriers into cut-preserving regions instead of raw min-cut); auxiliary clustering regularizer for `wifi-densepose-train`; `DynamicPersonMatcher` region structure | **Not published** | **WATCH** — strongest technical fit in the sync; adopt when published. README's "no published method uses graph-cut condensation" is CLAIMED; the diffcut implementation + tests are MEASURED |
| `ruvector-attention` 2.1.0 | #304 SOTA modules: MLA, KV-cache, SSM, sparse/MoE, hybrid search, Graph RAG (publish date 2026-03-27 matches the #304 commit — MEASURED) | Supersedes pinned 2.0.4 used by `model.rs` spatial attention + `bvp.rs`; SSM/MLA are candidate pure-Rust edge-inference primitives for the ADR-150 encoder | 2.1.0 (pinned **2.0.4**) | **ADOPT** (minor bump; API-compat check first) |
| `ruvector-gnn` 2.2.0 | panic→`Result` constructors, gradient clipping, MSE/CE/BCE losses, seeded-RNG layer init (#495 is post-2.2.0) | `wifi-densepose-train` GNN path (pinned 2.0.5, `default-features = false`) | 2.2.0 (pinned **2.0.5**) | **ADOPT** (bump) |
| `ruvector-mincut` / `ruvector-solver` 2.0.6 | Patch-level fixes (workspace republish 2026-03-25) | `metrics.rs` DynamicPersonMatcher, subcarrier interpolation, triangulation | 2.0.6 (pinned **2.0.4** each) | **ADOPT** (routine patch bump) |
| `ruvector-core` 2.2.3 (vendor) | HNSW correctness: k=0 guard, sorted results, flat-index fixes, cross-integration helpers (#502 — MEASURED, `index/hnsw.rs` + new integration tests) | `homecore-recorder` `RuvectorSemanticIndex` (real HNSW consumer); `sketch.rs` quantization unaffected | **2.2.0 = latest published**; 2.2.3 unpublished | **WATCH** — bump the moment 2.2.3 publishes |
| `ruvector-cnn` 2.0.6 | Pure-Rust SIMD conv kernels (AVX2/NEON/WASM), MobileNetV3, INT8 quantization, contrastive losses (InfoNCE/triplet, #252) | **Not** the WiFlow-STD training port — `wiflow_std/model.rs` is tch/libtorch (MEASURED). Relevant to the *edge inference* path of the trained ~2.2 MB int8 model, and InfoNCE/triplet overlaps AETHER (ADR-024) | 2.0.6 | **EVALUATE** — only if/when we commit to a no-libtorch edge runtime for WiFlow-STD-class models |
| `ruvector-acorn` (new-ish) | ACORN predicate-agnostic filtered HNSW (SIGMOD'24 algorithm; γ·M denser graphs for low-selectivity filters) | Metadata-filtered pattern search over ADR-151 calibration banks — speculative; bank sizes are far below where filtered-ANN recall collapse matters | **Not published** | **WATCH** |
| `ruvector-cluster` 2.0.6 | Distributed sharding, gossip discovery, DAG consensus | No current need; ADR-029 mesh coordination is ESP32-side, not vector-DB-side | 2.0.6 | **WATCH** |
| ONNX embedder fix (#523/#525) | API-contract + packaging fixes in `npm/packages/ruvector` (TypeScript) | None — `wifi-densepose-nn`'s ONNX backend is Rust (ort/tract), untouched by this change (MEASURED: commit touches npm/ only) | n/a | No action |
| `ruvector-perception` (new, #547) | "Physical perception substrate" (hypothesis/topology/witness modules) — agent-perception oriented, not RF | None identified | Not published | WATCH (name-overlap only) |
**Security note (RUSTSEC #504).** The substantive fixes target `ruvllm`, `ruvector-dag`, `prime-radiant`, `rvagent-*`, and the `ruvector-server` HTTP endpoint (NaN-safe `partial_cmp`, input-validation guards, env-allowlisted exec) — **none of which we pin**. The commit states `cargo audit` returns clean across the workspace. *Evidence: MEASURED (commit message + file list). Conclusion: no pinned version has an outstanding advisory; no urgent bump required.* The NaN-sort hardening is panic-robustness hygiene our pinned 2.0.4-era crates predate, which is one more reason for the routine bumps below.
**Version-bump recommendations (follow-up PR — no Cargo.toml change in this ADR):** `ruvector-mincut` 2.0.4→2.0.6, `ruvector-solver` 2.0.4→2.0.6, `ruvector-attention` 2.0.4→2.1.0, `ruvector-gnn` 2.0.5→2.2.0. Current: `ruvector-core` 2.2.0, `ruvector-attn-mincut` 2.0.4, `ruvector-temporal-tensor` 2.0.6, `ruvector-crv` 0.1.1 — all at latest published. Nothing in the sync changes §2.1.2 geometry conditioning (our `viewpoint/attention.rs` `GeometricBias` already implements the fusion mechanism) or the ADR-150 MAE recipe (training stays in tch).
## 3. Consequences
**Positive:** the calibration system gains the one mechanism (geometry conditioning) the 2026 literature identifies as the difference between layout-brittle and layout-robust supervised WiFi pose; ADR-150 gets a measured training recipe instead of a guessed one; we acquire two external benchmarks (WiFlow-STD, PerceptAlign dataset) to keep our claims honest.
@@ -82,6 +108,7 @@ Pull the Apache-2.0 weights + 360k-sample dataset; run three measurements: (a) t
## 4. Open questions (carried from the research run)
1. Does WiFlow-STD retain accuracy when fine-tuned on ESP32-S3/C6 CSI (fewer subcarriers, lower SNR), scored on our 17-keypoint set? (§2.2 answers this.)
> **Partial answer (MEASURED 2026-06-11, measurement (b) on 2,046 single-room windows — `benchmarks/wiflow-std/RESULTS.md`):** pretrained init shows strong *optimization* transfer (65% PCK@20 vs scratch's 0% collapse under the same budget) but **no feature transfer** (frozen-trunk + linear adapter ≈ 0%). And no run beat the mean-pose baseline (95.9% PCK@20 — single subject, near-static normalized coords), so no CSI→pose capability is citable from this data. A definitive answer needs multi-subject/multi-position data where the mean pose is weak.
2. Is the PerceptAlign dataset downloadable under a usable license, and does the two-checkerboard procedure work with ESP32 transceiver geometry? (§2.1.4 gate.)
3. Will esp_wifi_sensing evolve toward 802.11bf compliance, replacing opportunistic CSI extraction?
@@ -0,0 +1,168 @@
# ADR-153: IEEE 802.11bf-2025 Forward-Compatibility Protocol Model for wifi-densepose-hardware
- **Status**: accepted
- **Date**: 2026-06-10
- **Deciders**: ruv
- **Tags**: hardware, protocol, sensing, 802.11bf, forward-compatibility
## Context
IEEE 802.11bf-2025 (WLAN Sensing) is an **Active Standard**: board approval
2025-05-28, published 2025-09-26 (verified against the IEEE SA record,
<https://standards.ieee.org/ieee/802.11bf/11574/>). Its scope modifies the
MAC, HE and EHT PHY service interfaces, plus DMG and EDMG PHYs, for WLAN
sensing in **17.125 GHz** and **above 45 GHz** bands, with formal sensing
measurement setup, measurement instance, feedback/reporting, and
sensing-by-proxy (SBP) procedures (ADR-152 F4, evidence grade MEASURED).
No commodity silicon implements the standard yet — ESP32 parts included.
ADR-152 §2.4 therefore decided "track silicon; no code now", with RuView's
opportunistic CSI extraction remaining the mechanism. That left a gap: when
silicon does land, RuView would have no typed model of the standard's
procedures to bind to, and the integration would start from zero.
ADR-152 §2.4 originally classified 802.11bf as a hardware watch item with no
implementation work until commodity silicon exposes standardized sensing
measurements. This ADR amends that clause: OTA binding remains deferred, but
a pure Rust protocol model, session FSM, transport seam, and opportunistic
CSI bridge will be implemented now so RuView consumers can target a stable
standardized sensing interface before silicon arrives.
The user directed (2026-06-10) that this **forward-compatibility protocol
model** — a protocol surface, not a conformance implementation — be built
now.
## Decision
Implement an `ieee80211bf` **forward-compatibility protocol model** in
`wifi-densepose-hardware` (pure Rust, no internal deps, simulation-testable,
no OTA path):
> This module is not a certified 802.11bf implementation. It models the
> public procedure shape needed by RuView and RuvSense, while intentionally
> avoiding OTA frame binding until chipset support and vendor APIs exist.
1. **`types.rs`** — typed structures for the standard's sensing procedures
(sub-7 GHz focus; DMG stubbed): Sensing Measurement Setup (setup ID,
initiator/responder and transmitter/receiver roles, bandwidth,
periodicity, threshold-based reporting parameters), Sensing Measurement
Instance, Sensing Measurement Report (CSI-variant payload), SBP
request/response, termination. Two future-proofing requirements:
- **Version gates** — every negotiated surface is tagged with a spec
profile, because vendors will expose partial or renamed capabilities
first:
```rust
pub enum SpecProfile {
DraftCompatible,
Ieee80211Bf2025,
VendorExtension(String),
}
```
- **Capability negotiation** — no hardcoded ESP32 assumptions in the
future-silicon path:
```rust
pub struct SensingCapabilities {
pub sub_7_ghz: bool,
pub dmg: bool,
pub edmg: bool,
pub csi_report: bool,
pub threshold_reporting: bool,
pub sensing_by_proxy: bool,
pub max_bandwidth_mhz: u16,
pub max_period_ms: u32,
pub max_active_setups: u16,
}
```
- **Privacy and governance fields** — sensing is presence inference, not
just radio telemetry. Every `SensingMeasurementSetup` carries policy
metadata (required, not optional), for enterprise, elderly-care,
retail, workplace, and municipal deployments:
```rust
pub enum ConsentMode {
LabOnly,
ExplicitConsent,
ManagedEnterprisePolicy,
Disabled,
}
```
2. **`session.rs`** — deterministic event-driven session state machine:
`Idle → SetupNegotiating → Active → Terminating → Idle`, with explicit
rejection paths (unsupported parameters, setup-ID collision) and timeout
handling.
3. **`transport.rs`** — a `SensingTransport` trait abstracting frame
exchange; a `SimTransport` test double; and an `OpportunisticCsiBridge`
adapter mapping today's ESP32 CSI extraction onto the report path
(measurement instances ≈ CSI frame batches), so current hardware sits
behind the standardized interface. **Replaceability benchmark
(acceptance test):** RuvSense must consume either ESP32 opportunistic CSI
or future 802.11bf chipset reports through the same `SensingTransport`
and `SensingMeasurementReport` path, with no consumer-side rewrite — a
future chipset adapter replaces `OpportunisticCsiBridge` without changing
consumers.
Constraints: input validation at boundaries (typed errors, no panics on
adversarial input), files under 500 lines, all protocol tests runnable
without hardware.
### Acceptance checklist
| Area | Acceptance test |
| --------------- | -------------------------------------------------------------------- |
| Types | Serde round trip for setup, instance, report, SBP, termination |
| FSM | Idle → setup → active → terminating → idle |
| Rejection | Unsupported bandwidth, invalid period, duplicate setup ID |
| Timeout | Negotiation timeout returns typed error and resets to Idle |
| Threshold | Report emitted only when threshold condition is crossed |
| SBP | Proxy request maps to responder path without direct sensor coupling |
| Bridge | ESP32 CSI batch becomes standardized measurement report |
| Safety | No panics on malformed inputs |
| CI | All protocol tests run without hardware |
| Maintainability | Each file under 500 lines |
### Non-Goals
This ADR does not claim IEEE 802.11bf conformance, certification, or OTA
interoperability. It creates a typed protocol compatibility layer so RuView
can consume standardized sensing reports when commodity silicon exposes
them. Vendor-specific frame exchange, firmware hooks, trigger-frame
sounding, and certification test vectors remain future ADRs.
## Consequences
### Positive
- RuView can adopt standardized WLAN sensing the day any chipset exposes
802.11bf measurements — the data model, session FSM, and transport seam
already exist and are tested.
- The `OpportunisticCsiBridge` gives current ESP32 nodes a standardized-shape
interface now, decoupling RuvSense consumers from the extraction mechanism.
- Simulation transport enables protocol-level tests in CI without hardware.
- `SpecProfile` + `SensingCapabilities` give a clean escape hatch for the
partial/renamed vendor capabilities that will certainly arrive first.
- Consent/policy metadata is structural from day one, not retrofitted.
### Negative
- Code written against a standard with zero silicon risks drift: vendor
implementations may interpret parameters differently; the layer may need
rework at first real binding (drift risk scored 7/10 at acceptance).
- Adds maintenance surface to wifi-densepose-hardware before any
user-visible benefit (maintenance cost scored 3/10 — small without OTA).
### Neutral
- ADR-152 §2.4's "watch item" remains: revisit when silicon/certification
appears (re-check by 2026-12). This ADR changes only the "no code now"
clause.
## Links
- ADR-152 — WiFi-Pose SOTA 2026 Intake (F4, §2.4 — amended by this ADR)
- ADR-028 — ESP32 capability audit (opportunistic CSI extraction baseline)
- ADR-029 — RuvSense multistatic sensing mode (consumer of sensing reports)
- IEEE 802.11bf-2025 — Active Standard, board approval 2025-05-28, published
2025-09-26: <https://standards.ieee.org/ieee/802.11bf/11574/>
+15 -10
View File
@@ -50,7 +50,7 @@ See [PR #405](https://github.com/ruvnet/RuView/pull/405) for full details.
### What's New in v0.7.0
<details>
<summary><strong>Camera Ground-Truth Training — 92.9% PCK@20</strong></summary>
<summary><strong>Camera Ground-Truth Training</strong></summary>
**v0.7.0 adds camera-supervised pose training** using MediaPipe + real ESP32 CSI data:
@@ -76,15 +76,20 @@ node scripts/train-wiflow-supervised.js --data data/paired/*.jsonl --scale lite
node scripts/eval-wiflow.js --model models/wiflow-real/wiflow-v1.json --data data/paired/*.jsonl
```
**Result: 92.9% PCK@20** from a 5-minute data collection session with one ESP32-S3 and one webcam.
> **Accuracy retraction (2026-06-10):** the "92.9% PCK@20" figure previously
> shown here is retracted. A forensic recheck of the surviving eval holdout
> (69 samples) found a constant-output model scored with an absolute
> (non-torso-normalized) threshold on nearly-static frames — a protocol under
> which a trivial mean-pose predictor scores 100%. Torso-normalized PCK@20 on
> the same holdout is ~19% (from that degenerate predictor). No measured
> camera-supervised PCK@20 is currently published (CHANGELOG, PR #535).
| Metric | Before (proxy) | After (camera-supervised) |
|--------|----------------|--------------------------|
| PCK@20 | 0% | **92.9%** |
| Eval loss | 0.700 | **0.082** |
| Bone constraint | N/A | **0.008** |
| Training time | N/A | **19 minutes** |
| Model size | N/A | **974 KB** |
| Metric | Camera-supervised run (protocol retracted) |
|--------|--------------------------------------------|
| Eval loss | 0.082 |
| Bone constraint | 0.008 |
| Training time | 19 minutes |
| Model size | 974 KB |
Pre-trained model: [HuggingFace ruv/ruview/wiflow-v1](https://huggingface.co/ruv/ruview)
@@ -868,7 +873,7 @@ Download a pre-built binary — no build toolchain needed:
| Release | What's included | Tag |
|---------|-----------------|-----|
| [v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0) | **Latest** — Camera-supervised WiFlow model (92.9% PCK@20), ground-truth training pipeline, ruvector optimizations | `v0.7.0` |
| [v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0) | **Latest** — Camera-supervised WiFlow model (accuracy figure retracted 2026-06-10, see above), ground-truth training pipeline, ruvector optimizations | `v0.7.0` |
| [v0.6.0](https://github.com/ruvnet/RuView/releases/tag/v0.6.0-esp32) | [Pre-trained models on HuggingFace](https://huggingface.co/ruv/ruview), 17 sensing apps, 51.6% contrastive improvement, 0.008ms inference | `v0.6.0-esp32` |
| [v0.5.5](https://github.com/ruvnet/RuView/releases/tag/v0.5.5-esp32) | SNN + MinCut (#348 fix) + CNN spectrogram + WiFlow + multi-freq mesh + graph transformer | `v0.5.5-esp32` |
| [v0.5.4](https://github.com/ruvnet/RuView/releases/tag/v0.5.4-esp32) | Cognitum Seed integration ([ADR-069](docs/adr/ADR-069-cognitum-seed-csi-pipeline.md)), 8-dim feature vectors, RVF store, witness chain, security hardening | `v0.5.4-esp32` |
+99
View File
@@ -0,0 +1,99 @@
# We audited a state-of-the-art WiFi pose model. Here's what broke, what reproduced, and the 30× smaller model that nearly matches it.
*RuView team, June 2026. All numbers measured; full scripts and forensics in the
[RuView repo](https://github.com/ruvnet/RuView/tree/main/benchmarks/wiflow-std).*
## The setup
WiFi sensing is having a moment: a 2026 preprint ("WiFlow", arXiv 2602.08661)
claims **97.25% pose-estimation accuracy (PCK@20) from WiFi signals alone**,
with a tiny 2.23M-parameter model — and unlike most papers, it ships
everything: code, trained weights, and a 360,000-sample dataset.
We build WiFi sensing systems, so before adopting any external number we run
it through a simple rule: **a claim is "CLAIMED" until we reproduce it, then
it's "MEASURED."** Here's what happened when we tried.
## Day 1: nothing works
- **The code doesn't run.** The package imports a class that doesn't exist.
(One-line fix.)
- **The released model scores 0.08%, not 97.25%.** The shipped checkpoint was
trained under a different data normalization than the shipped dataset —
it's a real trained model, just not *this* pipeline's model. Even letting it
cheat with a fitted per-keypoint correction only reaches 72%.
- **The dataset is corrupted.** Its last 13 files contain garbage values up to
3.4×10³⁸ (float32's maximum). Subtle consequence: the training loop uses
fp16 mixed precision with no guards, so the first corrupted batch overflows
and **permanently poisons the model's BatchNorm statistics**. Training from
the public download produces NaN from epoch 1, every time.
- The training script also crashes before its own test phase ever runs
(calls an undefined function), and ignores its `--data_dir` flag.
At this point a less patient reader concludes "fraud." That would be wrong.
## Day 1, later: actually, the science is real
We repaired the artifacts — fixed the import, zeroed the 9,072 corrupted
windows, retrained from scratch with the authors' own code and
hyperparameters on one GPU (~50 minutes):
| Metric | Published | Our retrain |
|---|---|---|
| PCK@20 | 97.25% | **96.196.6%** |
| PCK@50 | 99.48% | 99.099.1% |
| Params | 2.23M | 2,225,042 (exact) |
**The claims reproduce.** What didn't survive contact was the *packaging*:
wrong checkpoint, corrupted upload, broken glue code. This distinction —
**artifact rot vs. bad science** — is the single most useful thing a
reproduction can establish, and you can't establish it without actually
running the thing.
(We filed all six defects upstream with fixes:
[issue #3](https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling/issues/3).
And to be clear: the authors released more than 90% of papers do. That's the
only reason this audit was possible.)
## Day 2: the model is also 2.6× too big
Once we could train, we asked: does the architecture need 2.23M parameters?
| Variant | Params | Accuracy (PCK@20) | Size on disk |
|---|---|---|---|
| Original | 2,225,042 | 96.61% | 8.97 MB |
| **Half** | **843,834** | **96.62%** ✨ | — |
| Quarter | 338,600 | 96.05% | — |
| **Tiny** | **56,290** | **94.11%** | **295 KB** |
The half-width model **matches the original exactly** (and converges faster).
The tiny one — 1/39th the parameters — gives up 2.5 points and runs at
**0.66 ms per inference on a laptop CPU** (~1,500 poses/second) as a 295 KB
ONNX file. For edge devices, that's the interesting end of the curve.
Quantization footnote: the paper's "~2.2 MB int8" estimate is reachable
(we measured 2.442.53 MB) but only via conv-capable toolchains — PyTorch's
one-line dynamic quantization converts *literally nothing* on this model
(it has no Linear layers), a trap worth knowing about.
## What we took away
1. **Run the artifact, not the README.** Every number in a paper is one
`git clone` away from being either confirmed or understood. Both outcomes
are valuable; only one is publishable by the original authors.
2. **fp16 + unvalidated data = silent model death.** Mixed-precision training
with no NaN/inf guards doesn't fail loudly — it corrupts BatchNorm buffers
and ships a broken model with a green progress bar. Validate inputs, or
train in fp32, or guard the autocast.
3. **Evidence-grade your own claims too.** Mid-audit, the same forensics
tooling caught one of *our own* published accuracy numbers resting on a
degenerate evaluation (a constant-output model scored with a flawed
metric). We retracted it the same day. The rule has to cut both ways or
it's marketing, not measurement.
4. **Over-parameterization hides in SOTA tables.** Nobody publishes the
half-size ablation that matches their headline model. Run it yourself;
it's an hour of GPU time and sometimes it *is* the result.
*Reproduction scripts, corruption masks, the efficiency-sweep configs, and a
numerically parity-proven Rust port (max divergence 1.2e-7) are all in
[`benchmarks/wiflow-std/`](https://github.com/ruvnet/RuView/tree/main/benchmarks/wiflow-std).*
+76 -16
View File
@@ -1747,7 +1747,14 @@ See [ADR-071](adr/ADR-071-ruvllm-training-pipeline.md) and the [pretraining tuto
For significantly higher accuracy, use a webcam as a **temporary teacher** during training. The camera captures real 17-keypoint poses via MediaPipe, paired with simultaneous ESP32 CSI data. After training, the camera is no longer needed — the model runs on CSI only.
**Result: 92.9% PCK@20** from a 5-minute collection session.
> **Accuracy note (2026-06-10):** the previously cited "92.9% PCK@20" figure is
> retracted — a forensic recheck of the surviving eval holdout showed it came
> from a constant-output model scored with an absolute (non-torso-normalized)
> threshold on 69 nearly-static frames, a protocol under which a trivial
> mean-pose predictor scores 100%. No measured camera-supervised PCK@20 is
> currently published (see CHANGELOG, PR #535). Treat this workflow as a data
> collection mechanism; accuracy claims will follow a ≥35-minute multi-pose
> collection session evaluated with torso-normalized PCK.
### Requirements
@@ -1755,50 +1762,103 @@ For significantly higher accuracy, use a webcam as a **temporary teacher** durin
- ESP32-S3 node streaming CSI over UDP (port 5005)
- A webcam (laptop, USB, or Mac camera via Tailscale)
### Step 1: Capture Camera + CSI Simultaneously
### Step 0: Check your CSI rate and plan the session length
Window yield is `csi_frames / 20` — **your CSI packet rate sets how long you
must record.** Check it first (10-second probe):
```bash
python - <<'EOF'
import socket, time
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.bind(('0.0.0.0', 5005)); s.settimeout(2)
n, t0 = 0, time.time()
while time.time() - t0 < 10:
try: s.recvfrom(4096); n += 1
except socket.timeout: pass
print(f"{n/10:.1f} Hz -> {n/10*60/20:.0f} windows/min")
EOF
```
| CSI rate | Windows/min | Minutes for 2,000 windows (minimum trainable) |
|---|---|---|
| ~13 Hz (idle network) | ~39 | ~52 min |
| ~53 Hz (active self-ping, #985 firmware) | ~160 | ~13 min — record 3540 min anyway for pose variety |
A 5-minute session is **not enough to train on** — it produces a few hundred
windows of one pose context, and models trained on it memorize rather than
generalize (this is what invalidated the earlier accuracy figure).
### Step 1: (Recommended) calibrate camera ↔ room
The two-checkerboard calibration (ADR-152 §2.1.3) puts labels in a shared 3D
room frame instead of raw camera coordinates, which is the published defense
against layout-brittle "coordinate overfitting" (PerceptAlign, MobiCom'26):
```bash
python scripts/calibrate-camera-room.py # < 5 min, two checkerboards + a few photos
```
Without it, collection still works but labels are camera-frame only and the
trained model will not survive camera/node relocation.
### Step 2: Capture Camera + CSI Simultaneously
Run both scripts at the same time (in separate terminals):
```bash
# Terminal 1: Record ESP32 CSI
python scripts/record-csi-udp.py --duration 300
# Terminal 1: Record ESP32 CSI (2400 s = 40 min)
python scripts/record-csi-udp.py --duration 2400
# Terminal 2: Capture camera keypoints
python scripts/collect-ground-truth.py --duration 300 --preview
python scripts/collect-ground-truth.py --duration 2400 --preview \
--calibration data/calibration/camera-room.json # omit if you skipped Step 1
```
Move around naturally in front of the camera for 5 minutes. The `--preview` flag shows a live skeleton overlay.
During capture: keep your **full body in frame** with good lighting (MediaPipe
confidence must stay above 0.5 — low-confidence frames are dropped at
alignment), and **change activity every 12 minutes**: walk, raise hands,
squat, hands up, kick, wave, turn, jump, sit, stand still. Pose variety is
what the model learns from; 40 minutes of sitting produces a constant-pose
predictor.
### Step 2: Align and Train
### Step 3: Align and Train
```bash
# Align camera keypoints with CSI windows
# Align camera keypoints with CSI windows (prints kept/dropped window counts —
# expect roughly csi_frames/20 kept; investigate if far below)
node scripts/align-ground-truth.js \
--gt data/ground-truth/*.jsonl \
--csi data/recordings/csi-*.csi.jsonl
# Train (start with lite, scale up as you collect more data)
# Train (pick the preset matching your window count)
node scripts/train-wiflow-supervised.js \
--data data/paired/*.jsonl \
--scale lite \
--scale small \
--epochs 50
# Evaluate
# Evaluate — torso-normalized PCK on a TEMPORAL split
node scripts/eval-wiflow.js \
--model models/wiflow-supervised/wiflow-v1.json \
--data data/paired/*.jsonl
```
**Evaluation protocol matters.** Use `eval-wiflow.js` (torso-normalized
PCK@20, the metric comparable to published WiFi-pose results) on a temporal
hold-out, and sanity-check that predictions actually vary across frames
(`pred std > 0`) — a constant-pose model can score deceptively well on
near-static data under weaker protocols. See
`benchmarks/wiflow-std/RESULTS.md` for the forensic case study.
### Scale Presets
| Preset | Params | Training Time | Best For |
|--------|--------|---------------|----------|
| `--scale lite` | 189K | ~19 min | < 1,000 samples (5 min capture) |
| `--scale small` | 474K | ~1 hr | 1K-10K samples |
| `--scale medium` | 800K | ~2 hrs | 10K-50K samples |
| `--scale full` | 7.7M | ~8 hrs | 50K+ samples (GPU recommended) |
| `--scale lite` | 189K | ~19 min | sanity runs only (< 2K windows trains poorly) |
| `--scale small` | 474K | ~1 hr | 2K-10K windows (one 40-min session) |
| `--scale medium` | 800K | ~2 hrs | 10K-50K windows (multiple sessions/rooms) |
| `--scale full` | 7.7M | ~8 hrs | 50K+ windows (GPU recommended) |
See [ADR-079](adr/ADR-079-camera-ground-truth-training.md) for the full design and optimization details.
See [ADR-079](adr/ADR-079-camera-ground-truth-training.md) for the full design and optimization details, and ADR-152 §2.2 for the external WiFlow-STD benchmark these numbers should be read against.
---
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Two-checkerboard camera-room calibration for WiFi pose training (ADR-152 S2.1.3).
Aligns the ADR-079 ground-truth camera and the ESP32 WiFi transceivers in
one shared 3D room frame -- the PerceptAlign (arXiv 2601.12252) defense
against "coordinate overfitting", where CSI-to-camera-coordinate regression
memorizes the deployment layout and collapses cross-layout.
Procedure (<5 minutes):
1. Print a checkerboard (default 9x6 inner corners, 25 mm squares).
2. Tape one board flat on the ORIGIN WALL, tape-measure its top-left inner
corner position in room coordinates (+x along wall, +y into room, +z up).
3. Lay the second board flat on the FLOOR, measure its near-left inner corner.
4. With the collection camera in its final position, photograph each board.
5. Run this script; tape-measure each ESP32 node position when prompted
(or pass --geometry nodes.json).
Output: a calibration bundle JSON consumed by
scripts/collect-ground-truth.py --calibration <bundle.json>
Usage:
python scripts/calibrate-camera-room.py \\
--wall-image photos/wall.jpg --wall-origin 0.50,0.0,1.60 \\
--floor-image photos/floor.jpg --floor-origin 1.00,1.00,0.0 \\
--calib-images "photos/intrinsics/*.jpg" \\
--geometry config/transceivers.json \\
--output data/calibration/camera-room.json
"""
from __future__ import annotations
import argparse
import glob
import json
import sys
from datetime import datetime
from pathlib import Path
import cv2
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent))
import calibration_lib as cal # noqa: E402
INTRINSICS_CACHE = Path("data") / ".cache" / "camera_intrinsics.json"
def parse_vec3(text: str) -> np.ndarray:
parts = [float(p) for p in text.replace(",", " ").split()]
if len(parts) != 3:
raise argparse.ArgumentTypeError(f"Expected 3 comma-separated numbers, got {text!r}")
return np.array(parts, dtype=np.float64)
def detect_corners(image_path: Path, cols: int, rows: int) -> tuple[np.ndarray, tuple[int, int]]:
image = cv2.imread(str(image_path))
if image is None:
print(f"ERROR: Cannot read image {image_path}", file=sys.stderr)
sys.exit(1)
corners = cal.find_board_corners(image, cols, rows)
if corners is None:
print(
f"ERROR: No {cols}x{rows} checkerboard found in {image_path}. "
"Check lighting, focus, and the --board-cols/--board-rows flags.",
file=sys.stderr,
)
sys.exit(1)
h, w = image.shape[:2]
return corners, (w, h)
def resolve_intrinsics(args, repo_root: Path, board_args: tuple[int, int, float]) -> dict:
"""Pre-computed file > cached > computed from --calib-images >
last-resort 2-view estimate from the wall+floor photos themselves."""
cols, rows, square_m = board_args
if args.intrinsics:
print(f"Intrinsics: loading {args.intrinsics}")
return cal.load_intrinsics(Path(args.intrinsics))
cache_path = repo_root / INTRINSICS_CACHE
if cache_path.exists() and not args.recalibrate_intrinsics:
print(f"Intrinsics: using cached {cache_path} (pass --recalibrate-intrinsics to redo)")
intr = cal.load_intrinsics(cache_path)
intr["source"] = "cached"
return intr
if args.calib_images:
paths = sorted(glob.glob(args.calib_images))
if len(paths) < 3:
print(
f"ERROR: --calib-images matched only {len(paths)} file(s); "
"need >= 3 checkerboard views for stable intrinsics.",
file=sys.stderr,
)
sys.exit(1)
corner_sets, image_size = [], None
for p in paths:
corners, size = detect_corners(Path(p), cols, rows)
if image_size is None:
image_size = size
elif size != image_size:
print(f"ERROR: {p} has size {size}, expected {image_size}.", file=sys.stderr)
sys.exit(1)
corner_sets.append(corners)
print(f" corners found: {p}")
intr = cal.compute_intrinsics(corner_sets, image_size, cols, rows, square_m)
print(f"Intrinsics: computed from {len(paths)} views, "
f"reprojection RMS {intr['reprojection_error_px']:.3f} px")
cal.save_bundle(intr, cache_path) # plain JSON write; reused on next run
print(f" cached to {cache_path}")
return intr
# Last resort: 2-view calibration from the extrinsic photos. Workable but
# weak -- warn loudly and recommend a proper multi-view pass.
print(
"WARNING: no --intrinsics / cache / --calib-images; estimating intrinsics "
"from the wall+floor photos alone (2 views, low quality). Prefer "
"--calib-images with 5-10 varied board views.",
file=sys.stderr,
)
corner_sets, image_size = [], None
for p in (args.wall_image, args.floor_image):
corners, size = detect_corners(Path(p), cols, rows)
image_size = image_size or size
corner_sets.append(corners)
intr = cal.compute_intrinsics(corner_sets, image_size, cols, rows, square_m)
intr["source"] = "two-view-fallback"
return intr
def prompt_transceiver_geometry() -> dict:
"""Tape-measure entry of ESP32 node positions in room coordinates."""
print()
print("Transceiver geometry -- enter one node per line:")
print(" <node-id> <x> <y> <z> [yaw_deg] (meters, room frame; blank line to finish)")
print(" example: esp32-s3-a 0.10 2.40 1.10 180")
nodes = []
while True:
try:
line = input("node> ").strip()
except EOFError:
break
if not line:
break
parts = line.split()
if len(parts) not in (4, 5):
print(" expected: <node-id> <x> <y> <z> [yaw_deg]", file=sys.stderr)
continue
try:
node = {"id": parts[0], "position_m": [float(parts[1]), float(parts[2]), float(parts[3])]}
if len(parts) == 5:
node["antenna_yaw_deg"] = float(parts[4])
except ValueError:
print(" positions must be numeric", file=sys.stderr)
continue
nodes.append(node)
if not nodes:
print("WARNING: no transceiver nodes entered; bundle will carry empty geometry.",
file=sys.stderr)
return {"nodes": nodes, "units": "meters", "source": "tape-measure-prompt"}
def load_geometry_file(path: Path) -> dict:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
nodes = data.get("nodes", data if isinstance(data, list) else None)
if nodes is None:
raise ValueError(f"{path}: expected {{'nodes': [...]}} or a top-level list")
for node in nodes:
if "id" not in node or "position_m" not in node:
raise ValueError(f"{path}: each node needs 'id' and 'position_m' [x,y,z]")
return {"nodes": nodes, "units": "meters", "source": "file"}
def main():
parser = argparse.ArgumentParser(
description="Two-checkerboard camera-room calibration (ADR-152 S2.1.3 / ADR-079)."
)
parser.add_argument("--wall-image", required=True,
help="Photo of the checkerboard on the origin wall")
parser.add_argument("--floor-image", required=True,
help="Photo of the checkerboard on the floor (camera NOT moved)")
parser.add_argument("--wall-origin", type=parse_vec3, default="0.5,0.0,1.6",
help="Room xyz (m) of the wall board's first inner corner "
"(default: 0.5,0.0,1.6)")
parser.add_argument("--floor-origin", type=parse_vec3, default="1.0,1.0,0.0",
help="Room xyz (m) of the floor board's first inner corner "
"(default: 1.0,1.0,0.0)")
parser.add_argument("--wall-axes", default="+x,-z",
help="Wall board column,row directions in room frame (default: +x,-z)")
parser.add_argument("--floor-axes", default="+x,+y",
help="Floor board column,row directions in room frame (default: +x,+y)")
parser.add_argument("--board-cols", type=int, default=cal.DEFAULT_BOARD_COLS,
help=f"Inner corners per row (default: {cal.DEFAULT_BOARD_COLS})")
parser.add_argument("--board-rows", type=int, default=cal.DEFAULT_BOARD_ROWS,
help=f"Inner corners per column (default: {cal.DEFAULT_BOARD_ROWS})")
parser.add_argument("--square-size-mm", type=float, default=cal.DEFAULT_SQUARE_SIZE_MM,
help=f"Checkerboard square size in mm (default: {cal.DEFAULT_SQUARE_SIZE_MM})")
parser.add_argument("--intrinsics", help="Pre-computed intrinsics JSON (skips computation)")
parser.add_argument("--calib-images",
help="Glob of >=3 checkerboard photos for intrinsics computation")
parser.add_argument("--recalibrate-intrinsics", action="store_true",
help="Ignore the cached intrinsics and recompute")
parser.add_argument("--geometry",
help="Transceiver geometry JSON ({nodes:[{id,position_m,[antenna_yaw_deg]}]}); "
"omit to be prompted for tape-measure entry")
parser.add_argument("--output", default=None,
help="Bundle output path (default: data/calibration/camera-room-<ts>.json)")
args = parser.parse_args()
if isinstance(args.wall_origin, str):
args.wall_origin = parse_vec3(args.wall_origin)
if isinstance(args.floor_origin, str):
args.floor_origin = parse_vec3(args.floor_origin)
repo_root = Path(__file__).resolve().parent.parent
cols, rows = args.board_cols, args.board_rows
square_m = args.square_size_mm / 1000.0
# --- Intrinsics ---
intrinsics = resolve_intrinsics(args, repo_root, (cols, rows, square_m))
camera_matrix = np.asarray(intrinsics["camera_matrix"], dtype=np.float64)
dist_coeffs = np.asarray(intrinsics["dist_coeffs"], dtype=np.float64)
# --- Corner detection on the two placed boards ---
wall_corners, wall_size = detect_corners(Path(args.wall_image), cols, rows)
floor_corners, floor_size = detect_corners(Path(args.floor_image), cols, rows)
if wall_size != floor_size:
print(f"ERROR: wall image {wall_size} and floor image {floor_size} differ in size; "
"both must come from the fixed collection camera.", file=sys.stderr)
sys.exit(1)
print(f"Corners detected: wall + floor boards ({cols}x{rows}, {args.square_size_mm} mm)")
# Re-scale intrinsics if they were computed at a different resolution
# than the extrinsic photos (the bundle always stores K at wall_size).
intr_size = tuple(intrinsics["image_size"])
if intr_size != wall_size:
sx, sy = wall_size[0] / intr_size[0], wall_size[1] / intr_size[1]
camera_matrix[0, 0] *= sx
camera_matrix[0, 2] *= sx
camera_matrix[1, 1] *= sy
camera_matrix[1, 2] *= sy
print(f" intrinsics scaled {intr_size} -> {wall_size}")
intrinsics = {**intrinsics, "camera_matrix": camera_matrix.tolist(),
"image_size": list(wall_size)}
# --- Room-frame corner positions from the measured placements ---
wall_u, wall_v = (cal.parse_axis(t) for t in args.wall_axes.split(","))
floor_u, floor_v = (cal.parse_axis(t) for t in args.floor_axes.split(","))
wall_room = cal.board_room_points(cols, rows, square_m, args.wall_origin, wall_u, wall_v)
floor_room = cal.board_room_points(cols, rows, square_m, args.floor_origin, floor_u, floor_v)
# --- Extrinsics: joint two-board solve (resolves per-board corner-order
# ambiguity -- a single planar board is centrosymmetric; the pair is not) ---
extrinsics = cal.solve_two_board_extrinsics(
wall_room, wall_corners, floor_room, floor_corners, camera_matrix, dist_coeffs
)
wall_rmse = extrinsics["per_board"]["wall"]["rmse_px"]
floor_rmse = extrinsics["per_board"]["floor"]["rmse_px"]
print(f" joint solve: RMSE {extrinsics['rmse_px']:.3f} px "
f"(wall {wall_rmse:.3f} / floor {floor_rmse:.3f})")
print(f" camera at room {np.round(extrinsics['translation_m'], 3).tolist()} m")
if max(wall_rmse, floor_rmse) > 3.0:
print(
"WARNING: high per-board reprojection error -- re-check the measured "
"board origins/axes and that the camera did not move between photos.",
file=sys.stderr,
)
# --- Transceiver geometry ---
if args.geometry:
geometry = load_geometry_file(Path(args.geometry))
print(f"Transceiver geometry: {len(geometry['nodes'])} node(s) from {args.geometry}")
else:
geometry = prompt_transceiver_geometry()
# --- Bundle ---
bundle = cal.make_bundle(
camera_intrinsics=intrinsics,
camera_to_room_extrinsics=extrinsics,
checkerboard_spec={"cols": cols, "rows": rows, "square_size_mm": args.square_size_mm},
transceiver_geometry=geometry,
)
if args.output:
out_path = Path(args.output)
else:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_path = repo_root / "data" / "calibration" / f"camera-room-{ts}.json"
cal.save_bundle(bundle, out_path)
print()
print("=== Calibration bundle written ===")
print(f" path: {out_path}")
print(f" calibration_id: {cal.calibration_id(bundle)}")
print(f" next: python scripts/collect-ground-truth.py --calibration {out_path}")
if __name__ == "__main__":
main()
+416
View File
@@ -0,0 +1,416 @@
#!/usr/bin/env python3
"""Camera-room calibration library for WiFi pose ground truth (ADR-152 S2.1.3).
Implements the PerceptAlign-style two-checkerboard alignment adopted in
ADR-152 S2.1.3 to defend the ADR-079 camera-supervised pipeline against
"coordinate overfitting" (arXiv 2601.12252, MobiCom'26): models regressing
CSI to raw camera-frame coordinates memorize the deployment layout and
collapse cross-layout. The fix is to express camera AND WiFi transceivers
in one shared 3D room frame, and stamp every training label with the
calibration + transceiver geometry that produced it.
Used by:
scripts/calibrate-camera-room.py (produces the calibration bundle)
scripts/collect-ground-truth.py (consumes it via --calibration)
Room frame convention (right-handed, meters):
origin = a designated wall/floor corner of the room
+x = along the origin wall
+y = into the room (away from the origin wall)
+z = up
No-depth limitation (IMPORTANT): a single 2D camera keypoint constrains
only a *ray* in the room frame, not a 3D point. The transform helpers here
therefore return unit bearing rays from the camera center -- a projective
alignment. Consumers that need metric 3D points must supply a depth
assumption downstream (floor-plane intersection, known subject height,
multi-view triangulation, ...). Raw image coordinates are always preserved
alongside the room-frame rays so training can choose either representation.
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
import cv2
import numpy as np
BUNDLE_SCHEMA_VERSION = 1
BUNDLE_METHOD = "two-checkerboard"
# Default checkerboard: 9x6 inner corners, 25 mm squares (a common print).
DEFAULT_BOARD_COLS = 9
DEFAULT_BOARD_ROWS = 6
DEFAULT_SQUARE_SIZE_MM = 25.0
_AXIS_TOKENS = {
"+x": (1.0, 0.0, 0.0), "-x": (-1.0, 0.0, 0.0),
"+y": (0.0, 1.0, 0.0), "-y": (0.0, -1.0, 0.0),
"+z": (0.0, 0.0, 1.0), "-z": (0.0, 0.0, -1.0),
}
def parse_axis(token: str) -> np.ndarray:
"""Parse an axis token like '+x' or '-z' into a room-frame unit vector."""
key = token.strip().lower()
if key in _AXIS_TOKENS:
return np.array(_AXIS_TOKENS[key], dtype=np.float64)
raise ValueError(f"Invalid axis token {token!r}; expected one of {sorted(_AXIS_TOKENS)}")
# ---------------------------------------------------------------------------
# Checkerboard geometry
# ---------------------------------------------------------------------------
def board_object_points(cols: int, rows: int, square_size_m: float) -> np.ndarray:
"""Inner-corner positions in the board's own frame (z=0 plane), row-major.
Matches the corner ordering of cv2.findChessboardCorners for a
(cols, rows) pattern: cols varies fastest.
"""
pts = np.zeros((rows * cols, 3), dtype=np.float64)
grid = np.mgrid[0:cols, 0:rows].T.reshape(-1, 2) # (rows*cols, 2), cols fastest
pts[:, :2] = grid * square_size_m
return pts
def board_room_points(
cols: int,
rows: int,
square_size_m: float,
origin: np.ndarray,
u_axis: np.ndarray,
v_axis: np.ndarray,
) -> np.ndarray:
"""Inner-corner positions in ROOM coordinates for a board placed at a
known position: first corner at `origin`, columns stepping along
`u_axis`, rows stepping along `v_axis` (both room-frame unit vectors).
"""
local = board_object_points(cols, rows, square_size_m)
origin = np.asarray(origin, dtype=np.float64)
u = np.asarray(u_axis, dtype=np.float64)
v = np.asarray(v_axis, dtype=np.float64)
return origin[None, :] + local[:, 0:1] * u[None, :] + local[:, 1:2] * v[None, :]
def find_board_corners(image: np.ndarray, cols: int, rows: int) -> np.ndarray | None:
"""Detect and sub-pixel-refine checkerboard inner corners.
Returns (cols*rows, 2) float64 pixel coordinates, or None if not found.
"""
gray = image if image.ndim == 2 else cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
flags = cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE
found, corners = cv2.findChessboardCorners(gray, (cols, rows), flags=flags)
if not found:
return None
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-3)
corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
return corners.reshape(-1, 2).astype(np.float64)
# ---------------------------------------------------------------------------
# Intrinsics
# ---------------------------------------------------------------------------
def compute_intrinsics(
corner_sets: list[np.ndarray],
image_size: tuple[int, int],
cols: int,
rows: int,
square_size_m: float,
) -> dict:
"""Camera intrinsics from N checkerboard views via cv2.calibrateCamera.
corner_sets: list of (cols*rows, 2) pixel corner arrays.
image_size: (width, height) of the calibration images.
"""
obj = board_object_points(cols, rows, square_size_m).astype(np.float32)
obj_pts = [obj for _ in corner_sets]
img_pts = [c.reshape(-1, 1, 2).astype(np.float32) for c in corner_sets]
rms, camera_matrix, dist_coeffs, _, _ = cv2.calibrateCamera(
obj_pts, img_pts, tuple(image_size), None, None
)
return {
"image_size": [int(image_size[0]), int(image_size[1])],
"camera_matrix": camera_matrix.tolist(),
"dist_coeffs": dist_coeffs.ravel().tolist(),
"reprojection_error_px": float(rms),
"source": "computed",
}
def load_intrinsics(path: Path) -> dict:
"""Load a pre-computed intrinsics JSON ({camera_matrix, dist_coeffs, image_size})."""
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
# Accept either a bare intrinsics dict or a full calibration bundle.
intr = data.get("camera_intrinsics", data)
for key in ("camera_matrix", "dist_coeffs", "image_size"):
if key not in intr:
raise ValueError(f"Intrinsics file {path} missing key {key!r}")
intr = dict(intr)
intr["source"] = "file"
return intr
# ---------------------------------------------------------------------------
# Extrinsics (camera -> room rigid transform)
# ---------------------------------------------------------------------------
def reprojection_rmse(
room_points: np.ndarray,
image_points: np.ndarray,
rvec: np.ndarray,
tvec: np.ndarray,
camera_matrix: np.ndarray,
dist_coeffs: np.ndarray,
) -> float:
proj, _ = cv2.projectPoints(room_points, rvec, tvec, camera_matrix, dist_coeffs)
err = proj.reshape(-1, 2) - image_points.reshape(-1, 2)
return float(np.sqrt(np.mean(np.sum(err**2, axis=1))))
def _solve_pnp(
room_points: np.ndarray,
image_points: np.ndarray,
camera_matrix: np.ndarray,
dist_coeffs: np.ndarray,
) -> dict | None:
"""One solvePnP run (room->camera), inverted to camera->room. Returns
{rotation (3x3 camera->room), translation_m (camera center in room
frame), rmse_px} or None on failure.
"""
ok, rvec, tvec = cv2.solvePnP(
room_points.reshape(-1, 1, 3),
image_points.reshape(-1, 1, 2),
camera_matrix,
dist_coeffs,
flags=cv2.SOLVEPNP_ITERATIVE,
)
if not ok:
return None
rmse = reprojection_rmse(room_points, image_points, rvec, tvec, camera_matrix, dist_coeffs)
r_room_to_cam, _ = cv2.Rodrigues(rvec)
r_cam_to_room = r_room_to_cam.T
camera_center_room = (-r_cam_to_room @ tvec).ravel()
return {
"rotation": r_cam_to_room.tolist(),
"translation_m": camera_center_room.tolist(),
"rmse_px": rmse,
}
def solve_extrinsics(
room_points: np.ndarray,
image_points: np.ndarray,
camera_matrix: np.ndarray,
dist_coeffs: np.ndarray,
) -> dict:
"""Solve the camera->room rigid transform from 3D room-frame points and
their 2D pixel observations.
NOTE: the corner grid of a single planar checkerboard is centrosymmetric,
so the corner ordering returned by findChessboardCorners (which may
enumerate from either board end) cannot be disambiguated from one board
alone -- the reversed ordering fits a ghost pose with identical
reprojection error. Use solve_two_board_extrinsics for the full
two-checkerboard procedure, where the joint point set breaks the symmetry.
"""
ext = _solve_pnp(room_points, image_points, camera_matrix, dist_coeffs)
if ext is None:
raise RuntimeError("solvePnP failed")
return ext
def solve_two_board_extrinsics(
wall_room: np.ndarray,
wall_image: np.ndarray,
floor_room: np.ndarray,
floor_image: np.ndarray,
camera_matrix: np.ndarray,
dist_coeffs: np.ndarray,
) -> dict:
"""Joint camera->room solve over both checkerboards (the ADR-152 S2.1.3
two-checkerboard method).
Tries all 4 per-board corner-ordering combinations: each board's ordering
is individually ambiguous (centrosymmetric grid), but the combined
wall+floor point set is not, so exactly one combination reaches minimal
reprojection error. Returns the solve_extrinsics dict plus
{wall_flipped, floor_flipped, per_board: {wall|floor: {rmse_px}}}.
"""
best = None
for wall_flipped in (False, True):
for floor_flipped in (False, True):
wi = wall_image[::-1].copy() if wall_flipped else wall_image
fi = floor_image[::-1].copy() if floor_flipped else floor_image
room = np.concatenate([wall_room, floor_room], axis=0)
img = np.concatenate([wi, fi], axis=0)
ext = _solve_pnp(room, img, camera_matrix, dist_coeffs)
if ext is None:
continue
if best is None or ext["rmse_px"] < best[0]["rmse_px"]:
ext["wall_flipped"] = wall_flipped
ext["floor_flipped"] = floor_flipped
rvec, _ = cv2.Rodrigues(np.asarray(ext["rotation"]).T)
tvec = -np.asarray(ext["rotation"]).T @ np.asarray(ext["translation_m"])
ext["per_board"] = {
"wall": {"rmse_px": reprojection_rmse(
wall_room, wi, rvec, tvec, camera_matrix, dist_coeffs)},
"floor": {"rmse_px": reprojection_rmse(
floor_room, fi, rvec, tvec, camera_matrix, dist_coeffs)},
}
best = (ext,)
if best is None:
raise RuntimeError("solvePnP failed for all corner-ordering combinations")
return best[0]
def extrinsics_consistency(ext_a: dict, ext_b: dict) -> dict:
"""Angular + translational disagreement between two extrinsic solutions
(the two single-board solves). Large values mean a mis-entered board
placement or a bad corner detection.
"""
ra = np.asarray(ext_a["rotation"])
rb = np.asarray(ext_b["rotation"])
r_delta = ra.T @ rb
angle = float(np.degrees(np.arccos(np.clip((np.trace(r_delta) - 1.0) / 2.0, -1.0, 1.0))))
t_delta = float(
np.linalg.norm(np.asarray(ext_a["translation_m"]) - np.asarray(ext_b["translation_m"]))
)
return {"rotation_deg": angle, "translation_m": t_delta}
# ---------------------------------------------------------------------------
# Calibration bundle (the artifact written to disk)
# ---------------------------------------------------------------------------
def make_bundle(
camera_intrinsics: dict,
camera_to_room_extrinsics: dict,
checkerboard_spec: dict,
transceiver_geometry: dict,
) -> dict:
return {
"schema_version": BUNDLE_SCHEMA_VERSION,
"method": BUNDLE_METHOD,
"calibrated_at": datetime.now(timezone.utc).isoformat(),
"room_frame": {
"description": "right-handed; origin at wall/floor corner; "
"+x along origin wall, +y into room, +z up",
"units": "meters",
},
"checkerboard_spec": checkerboard_spec,
"camera_intrinsics": camera_intrinsics,
"camera_to_room_extrinsics": camera_to_room_extrinsics,
"transceiver_geometry": transceiver_geometry,
}
def calibration_id(bundle: dict) -> str:
"""Stable content hash of a bundle -- stamped onto every emitted sample
so a label can always be traced to the exact calibration that framed it.
"""
canonical = json.dumps(bundle, sort_keys=True, separators=(",", ":"))
return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def save_bundle(bundle: dict, path: Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(bundle, f, indent=2)
f.write("\n")
def load_bundle(path: Path) -> dict:
with open(path, "r", encoding="utf-8") as f:
bundle = json.load(f)
for key in ("camera_intrinsics", "camera_to_room_extrinsics", "transceiver_geometry"):
if key not in bundle:
raise ValueError(f"Calibration bundle {path} missing key {key!r}")
return bundle
# ---------------------------------------------------------------------------
# Keypoint transform (image -> room-frame bearing rays)
# ---------------------------------------------------------------------------
class CalibrationContext:
"""Pre-computed transform state for a collection session.
Scales the bundle's intrinsics to the live capture resolution (MediaPipe
keypoints are normalized [0,1], so we need the actual frame size to get
back to pixels before undistorting).
"""
def __init__(self, bundle: dict, frame_w: int, frame_h: int):
self.bundle = bundle
self.calibration_id = calibration_id(bundle)
self.transceiver_geometry = bundle["transceiver_geometry"]
self.frame_w = int(frame_w)
self.frame_h = int(frame_h)
intr = bundle["camera_intrinsics"]
k = np.asarray(intr["camera_matrix"], dtype=np.float64)
cal_w, cal_h = intr["image_size"]
sx = self.frame_w / float(cal_w)
sy = self.frame_h / float(cal_h)
k = k.copy()
k[0, 0] *= sx
k[0, 2] *= sx
k[1, 1] *= sy
k[1, 2] *= sy
self.camera_matrix = k
self.dist_coeffs = np.asarray(intr["dist_coeffs"], dtype=np.float64)
ext = bundle["camera_to_room_extrinsics"]
self.r_cam_to_room = np.asarray(ext["rotation"], dtype=np.float64)
self.origin_room = np.asarray(ext["translation_m"], dtype=np.float64)
def transform_keypoints(self, keypoints_norm: list[list[float]]) -> tuple[np.ndarray, np.ndarray]:
"""Normalized [0,1] image keypoints -> unit bearing rays in the room
frame, anchored at the camera center.
Projective alignment ONLY (no depth): each returned ray is the locus
of room positions consistent with the 2D observation. Returns
(camera_origin_room (3,), ray_dirs (N, 3) unit vectors).
"""
pts = np.asarray(keypoints_norm, dtype=np.float64)
pts_px = pts * np.array([self.frame_w, self.frame_h], dtype=np.float64)
undist = cv2.undistortPoints(
pts_px.reshape(-1, 1, 2), self.camera_matrix, self.dist_coeffs
).reshape(-1, 2)
rays_cam = np.concatenate([undist, np.ones((len(undist), 1))], axis=1)
rays_cam /= np.linalg.norm(rays_cam, axis=1, keepdims=True)
rays_room = (self.r_cam_to_room @ rays_cam.T).T
return self.origin_room, rays_room
def load_calibration_context(path: Path, frame_w: int, frame_h: int) -> CalibrationContext:
return CalibrationContext(load_bundle(path), frame_w, frame_h)
def augment_record(record: dict, ctx: CalibrationContext | None) -> dict:
"""Stamp a ground-truth record with room-frame rays + calibration metadata.
With ctx=None this is the identity -- the record (and hence the emitted
JSONL line) is byte-identical to the pre-calibration ADR-079 format.
Raw image-coordinate keypoints are kept untouched in both cases; the
room-frame representation is ADDED, never substituted, so training can
choose either (ADR-152 S2.1.3).
"""
if ctx is None:
return record
if record.get("keypoints"):
_, rays = ctx.transform_keypoints(record["keypoints"])
record["keypoints_room"] = [[round(float(v), 5) for v in ray] for ray in rays]
else:
record["keypoints_room"] = []
record["camera_origin_room"] = [round(float(v), 5) for v in ctx.origin_room]
record["calibration_id"] = ctx.calibration_id
record["transceiver_geometry"] = ctx.transceiver_geometry
return record
+48
View File
@@ -6,9 +6,19 @@ synchronizes with ESP32 CSI recording from the sensing server.
Output: JSONL file in data/ground-truth/ with per-frame 17-keypoint COCO poses.
With --calibration <bundle.json> (produced by scripts/calibrate-camera-room.py,
ADR-152 S2.1.3), every record is additionally stamped with room-frame bearing
rays for each keypoint, the calibration_id, and the transceiver geometry --
the PerceptAlign-style defense against coordinate overfitting. Raw image
coordinates are always kept; without depth the room-frame representation is
a projective alignment (rays, not 3D points) -- see scripts/calibration_lib.py.
Without --calibration the output is byte-identical to the original ADR-079
format.
Usage:
python scripts/collect-ground-truth.py --preview --duration 60
python scripts/collect-ground-truth.py --server http://192.168.1.10:3000
python scripts/collect-ground-truth.py --calibration data/calibration/camera-room.json
"""
from __future__ import annotations
@@ -168,8 +178,23 @@ def main():
default="data/ground-truth",
help="Output directory (default: data/ground-truth)",
)
parser.add_argument(
"--calibration",
default=None,
help="Camera-room calibration bundle JSON from scripts/calibrate-camera-room.py "
"(ADR-152 S2.1.3); adds room-frame keypoint rays + transceiver geometry "
"to every record",
)
args = parser.parse_args()
if not args.calibration:
print(
"WARNING: no --calibration bundle; labels stay in raw camera coordinates "
"and are layout-brittle (coordinate overfitting, ADR-152 S2.1.3) -- run "
"scripts/calibrate-camera-room.py first.",
file=sys.stderr,
)
# --- Resolve paths relative to repo root ---
repo_root = Path(__file__).resolve().parent.parent
output_dir = repo_root / args.output
@@ -193,6 +218,25 @@ def main():
frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
print(f"Camera opened: {frame_w}x{frame_h}")
# --- Load calibration bundle (ADR-152 S2.1.3) ---
calib_ctx = None
if args.calibration:
# Lazy import keeps the no-calibration path identical to the original.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import calibration_lib
try:
calib_ctx = calibration_lib.load_calibration_context(
Path(args.calibration), frame_w, frame_h
)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"ERROR: Cannot load calibration bundle {args.calibration}: {exc}",
file=sys.stderr)
sys.exit(1)
n_nodes = len(calib_ctx.transceiver_geometry.get("nodes", []))
print(f"Calibration: {calib_ctx.calibration_id[:23]}... "
f"({n_nodes} transceiver node(s)); emitting room-frame keypoint rays")
# --- Create PoseLandmarker ---
options = PoseLandmarkerOptions(
base_options=BaseOptions(model_asset_path=str(model_path)),
@@ -287,6 +331,10 @@ def main():
"n_visible": n_visible,
"n_persons": n_persons,
}
if calib_ctx is not None:
# Adds keypoints_room (bearing rays), camera_origin_room,
# calibration_id, transceiver_geometry (ADR-152 S2.1.3).
record = calibration_lib.augment_record(record, calib_ctx)
out_file.write(json.dumps(record) + "\n")
frame_count += 1
total_confidence += confidence
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Segmented overnight empty-room CSI capture (ADR-135 baseline / MAE corpus).
Binds UDP once and writes fixed-duration JSONL segments with explicit names —
no post-hoc renaming, no glob collisions with other recordings.
Usage:
python scripts/overnight-empty-capture.py --segments 8 --segment-seconds 3300
"""
import argparse
import json
import os
import socket
import struct
import time
def parse_csi_packet(data):
"""ADR-018 binary CSI packet → dict (same layout as record-csi-udp.py)."""
if len(data) < 8:
return None
node_id = data[4]
rssi = struct.unpack("b", bytes([data[6]]))[0]
channel = data[7]
iq = data[8:]
amplitudes = []
for i in range(0, len(iq) - 1, 2):
I = struct.unpack("b", bytes([iq[i]]))[0]
Q = struct.unpack("b", bytes([iq[i + 1]]))[0]
amplitudes.append(round((I * I + Q * Q) ** 0.5, 2))
return {
"type": "raw_csi",
"ts_ns": time.time_ns(),
"node_id": node_id,
"rssi": rssi,
"channel": channel,
"subcarriers": len(iq) // 2,
"amplitudes": amplitudes,
"iq_hex": iq.hex(),
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=5005)
ap.add_argument("--segments", type=int, default=8)
ap.add_argument("--segment-seconds", type=int, default=3300)
ap.add_argument("--output", default="data/recordings")
ap.add_argument("--prefix", default="overnight-empty")
args = ap.parse_args()
os.makedirs(args.output, exist_ok=True)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", args.port))
sock.settimeout(2.0)
for seg in range(1, args.segments + 1):
path = os.path.join(
args.output, f"{args.prefix}-seg{seg}-{int(time.time())}.csi.jsonl"
)
n = 0
t_end = time.time() + args.segment_seconds
with open(path, "w", encoding="utf-8") as f:
while time.time() < t_end:
try:
data, _ = sock.recvfrom(4096)
except socket.timeout:
continue
rec = parse_csi_packet(data)
if rec is not None:
f.write(json.dumps(rec) + "\n")
n += 1
print(f"segment {seg}: {n} frames -> {path}", flush=True)
print("capture complete", flush=True)
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
"""Make scripts/ importable for the calibration tests (ADR-152 S2.1.3)."""
import sys
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""Headless tests for the camera-room calibration pipeline (ADR-152 S2.1.3).
Covers calibration_lib.py end to end on synthetic data -- no camera, no
display, no MediaPipe:
* known extrinsics recovered from synthetic two-checkerboard corners
* calibration bundle JSON round-trip + stable content hash
* image->room keypoint transform correctness (rays pass through the
original 3D points -- the projective, no-depth alignment of ADR-079
labels into the shared room frame)
* collect-ground-truth's no-calibration record path is byte-identical
(augment_record with ctx=None is the identity)
Run: python -m pytest scripts/tests/ -q
"""
from __future__ import annotations
import json
import cv2
import numpy as np
import pytest
import calibration_lib as cal
# ---------------------------------------------------------------------------
# Synthetic scene fixtures
# ---------------------------------------------------------------------------
IMG_W, IMG_H = 1280, 720
K_GT = np.array(
[[800.0, 0.0, 640.0],
[0.0, 800.0, 360.0],
[0.0, 0.0, 1.0]]
)
DIST_ZERO = np.zeros(5)
DIST_MILD = np.array([-0.10, 0.02, 0.001, -0.001, 0.0])
BOARD_COLS, BOARD_ROWS = 9, 6
SQUARE_M = 0.025
def look_at_pose(camera_pos, target):
"""Ground-truth camera pose: returns (R_cam_to_room, camera_center_room).
Camera convention: +z forward (optical axis), +x right, +y down.
"""
c = np.asarray(camera_pos, dtype=np.float64)
fwd = np.asarray(target, dtype=np.float64) - c
fwd /= np.linalg.norm(fwd)
up_room = np.array([0.0, 0.0, 1.0])
x_cam = np.cross(fwd, -up_room)
x_cam /= np.linalg.norm(x_cam)
y_cam = np.cross(fwd, x_cam)
r_cam_to_room = np.stack([x_cam, y_cam, fwd], axis=1) # columns = camera axes in room
return r_cam_to_room, c
def room_to_cam(r_cam_to_room, center):
"""Invert to the solvePnP (room->camera) convention: rvec, tvec."""
r_room_to_cam = r_cam_to_room.T
tvec = -r_room_to_cam @ center
rvec, _ = cv2.Rodrigues(r_room_to_cam)
return rvec, tvec.reshape(3, 1)
def project_room_points(points_room, r_cam_to_room, center, k=K_GT, dist=DIST_ZERO):
rvec, tvec = room_to_cam(r_cam_to_room, center)
proj, _ = cv2.projectPoints(np.asarray(points_room, dtype=np.float64), rvec, tvec, k, dist)
return proj.reshape(-1, 2)
@pytest.fixture
def scene():
"""A camera in the room looking at the wall + floor checkerboards."""
r_gt, c_gt = look_at_pose(camera_pos=[1.5, 3.0, 1.3], target=[1.0, 0.5, 0.8])
wall_room = cal.board_room_points(
BOARD_COLS, BOARD_ROWS, SQUARE_M,
origin=[0.5, 0.0, 1.6], u_axis=cal.parse_axis("+x"), v_axis=cal.parse_axis("-z"),
)
floor_room = cal.board_room_points(
BOARD_COLS, BOARD_ROWS, SQUARE_M,
origin=[1.0, 1.0, 0.0], u_axis=cal.parse_axis("+x"), v_axis=cal.parse_axis("+y"),
)
return r_gt, c_gt, wall_room, floor_room
def make_bundle(r_gt, c_gt, dist=DIST_ZERO):
return cal.make_bundle(
camera_intrinsics={
"image_size": [IMG_W, IMG_H],
"camera_matrix": K_GT.tolist(),
"dist_coeffs": dist.tolist(),
"reprojection_error_px": 0.0,
"source": "synthetic",
},
camera_to_room_extrinsics={
"rotation": r_gt.tolist(),
"translation_m": c_gt.tolist(),
"rmse_px": 0.0,
},
checkerboard_spec={"cols": BOARD_COLS, "rows": BOARD_ROWS, "square_size_mm": 25.0},
transceiver_geometry={
"nodes": [
{"id": "esp32-s3-a", "position_m": [0.1, 2.4, 1.1], "antenna_yaw_deg": 180.0},
{"id": "esp32-c6-b", "position_m": [3.2, 0.3, 0.9]},
],
"units": "meters",
"source": "file",
},
)
# ---------------------------------------------------------------------------
# Extrinsics recovery from synthetic checkerboard corners
# ---------------------------------------------------------------------------
class TestExtrinsicsRecovery:
def test_two_board_combined_recovers_known_pose(self, scene):
r_gt, c_gt, wall_room, floor_room = scene
room_pts = np.concatenate([wall_room, floor_room], axis=0)
img_pts = project_room_points(room_pts, r_gt, c_gt)
ext = cal.solve_extrinsics(room_pts, img_pts, K_GT, DIST_ZERO)
assert ext["rmse_px"] < 1e-3
np.testing.assert_allclose(np.asarray(ext["translation_m"]), c_gt, atol=1e-4)
r_delta = np.asarray(ext["rotation"]).T @ r_gt
angle_deg = np.degrees(np.arccos(np.clip((np.trace(r_delta) - 1) / 2, -1, 1)))
assert angle_deg < 0.01
def test_single_board_solves_agree(self, scene):
# With correct corner ordering, each board alone recovers the same pose.
r_gt, c_gt, wall_room, floor_room = scene
ext_wall = cal.solve_extrinsics(
wall_room, project_room_points(wall_room, r_gt, c_gt), K_GT, DIST_ZERO)
ext_floor = cal.solve_extrinsics(
floor_room, project_room_points(floor_room, r_gt, c_gt), K_GT, DIST_ZERO)
consistency = cal.extrinsics_consistency(ext_wall, ext_floor)
assert consistency["rotation_deg"] < 0.1
assert consistency["translation_m"] < 1e-3
def test_reversed_corner_order_auto_recovered(self, scene):
# findChessboardCorners may enumerate from either board end. A single
# board cannot disambiguate that flip (centrosymmetric grid), but the
# joint two-board solve can -- feed it a reversed wall ordering and
# require the true pose back.
r_gt, c_gt, wall_room, floor_room = scene
wall_img = project_room_points(wall_room, r_gt, c_gt)
floor_img = project_room_points(floor_room, r_gt, c_gt)
ext = cal.solve_two_board_extrinsics(
wall_room, wall_img[::-1].copy(), floor_room, floor_img,
K_GT, DIST_ZERO)
assert ext["wall_flipped"] is True
assert ext["floor_flipped"] is False
assert ext["rmse_px"] < 1e-3
np.testing.assert_allclose(np.asarray(ext["translation_m"]), c_gt, atol=1e-3)
def test_joint_solver_matches_unflipped(self, scene):
r_gt, c_gt, wall_room, floor_room = scene
ext = cal.solve_two_board_extrinsics(
wall_room, project_room_points(wall_room, r_gt, c_gt),
floor_room, project_room_points(floor_room, r_gt, c_gt),
K_GT, DIST_ZERO)
assert ext["wall_flipped"] is False and ext["floor_flipped"] is False
assert ext["per_board"]["wall"]["rmse_px"] < 1e-3
assert ext["per_board"]["floor"]["rmse_px"] < 1e-3
def test_intrinsics_recovered_from_synthetic_views(self):
# Several board views from different poses -> calibrateCamera should
# get focal length / principal point close to ground truth.
obj = cal.board_object_points(BOARD_COLS, BOARD_ROWS, SQUARE_M)
poses = [
([0.05, 1.2, 0.05], [0.10, 0.0, 0.06]),
([-0.25, 1.0, 0.20], [0.10, 0.0, 0.06]),
([0.45, 0.9, -0.15], [0.10, 0.0, 0.06]),
([0.10, 1.4, 0.30], [0.10, 0.0, 0.06]),
([-0.15, 0.8, -0.20], [0.10, 0.0, 0.06]),
]
corner_sets = []
for cam_pos, target in poses:
r, c = look_at_pose(cam_pos, target)
# Embed the board rigidly in the y=0 plane (u=+x, v=+z) and view it.
board_in_room = np.column_stack([obj[:, 0], obj[:, 2], obj[:, 1]])
corner_sets.append(project_room_points(board_in_room, r, c))
intr = cal.compute_intrinsics(corner_sets, (IMG_W, IMG_H),
BOARD_COLS, BOARD_ROWS, SQUARE_M)
k = np.asarray(intr["camera_matrix"])
assert abs(k[0, 0] - K_GT[0, 0]) / K_GT[0, 0] < 0.05
assert abs(k[1, 1] - K_GT[1, 1]) / K_GT[1, 1] < 0.05
assert intr["reprojection_error_px"] < 1.0
# ---------------------------------------------------------------------------
# Bundle round-trip + content hash
# ---------------------------------------------------------------------------
class TestBundle:
def test_save_load_roundtrip(self, scene, tmp_path):
r_gt, c_gt, _, _ = scene
bundle = make_bundle(r_gt, c_gt)
path = tmp_path / "camera-room.json"
cal.save_bundle(bundle, path)
loaded = cal.load_bundle(path)
assert loaded == bundle
assert cal.calibration_id(loaded) == cal.calibration_id(bundle)
def test_bundle_schema_fields(self, scene):
r_gt, c_gt, _, _ = scene
bundle = make_bundle(r_gt, c_gt)
for key in ("schema_version", "method", "calibrated_at", "room_frame",
"checkerboard_spec", "camera_intrinsics",
"camera_to_room_extrinsics", "transceiver_geometry"):
assert key in bundle
assert bundle["method"] == "two-checkerboard"
def test_calibration_id_changes_with_content(self, scene):
r_gt, c_gt, _, _ = scene
bundle_a = make_bundle(r_gt, c_gt)
bundle_b = json.loads(json.dumps(bundle_a))
bundle_b["transceiver_geometry"]["nodes"][0]["position_m"] = [0.2, 2.4, 1.1]
assert cal.calibration_id(bundle_a) != cal.calibration_id(bundle_b)
assert cal.calibration_id(bundle_a).startswith("sha256:")
def test_load_bundle_rejects_missing_keys(self, tmp_path):
path = tmp_path / "bad.json"
path.write_text('{"camera_intrinsics": {}}', encoding="utf-8")
with pytest.raises(ValueError, match="missing key"):
cal.load_bundle(path)
# ---------------------------------------------------------------------------
# Keypoint transform: image -> room-frame bearing rays (projective alignment)
# ---------------------------------------------------------------------------
class TestKeypointTransform:
PERSON_POINTS = np.array([
[1.2, 1.5, 1.7], # head height
[1.1, 1.5, 1.4], # shoulder
[1.3, 1.6, 0.9], # hip
[1.2, 1.5, 0.1], # ankle
])
@pytest.mark.parametrize("dist", [DIST_ZERO, DIST_MILD], ids=["no-distortion", "mild-distortion"])
def test_rays_pass_through_original_points(self, scene, dist):
r_gt, c_gt, _, _ = scene
img = project_room_points(self.PERSON_POINTS, r_gt, c_gt, dist=dist)
kps_norm = (img / np.array([IMG_W, IMG_H])).tolist()
ctx = cal.CalibrationContext(make_bundle(r_gt, c_gt, dist=dist), IMG_W, IMG_H)
origin, rays = ctx.transform_keypoints(kps_norm)
np.testing.assert_allclose(origin, c_gt, atol=1e-9)
np.testing.assert_allclose(np.linalg.norm(rays, axis=1), 1.0, atol=1e-9)
for point, ray in zip(self.PERSON_POINTS, rays):
v = point - origin
# Distance from the true 3D point to the recovered ray ~ 0, and
# the point sits in FRONT of the camera along the ray.
dist_to_ray = np.linalg.norm(v - np.dot(v, ray) * ray)
assert dist_to_ray < 1e-4
assert np.dot(v, ray) > 0
def test_resolution_scaling(self, scene):
# Collection camera runs 640x360 while the bundle was made at
# 1280x720 -- normalized keypoints must land on the same rays.
r_gt, c_gt, _, _ = scene
img = project_room_points(self.PERSON_POINTS, r_gt, c_gt)
kps_norm = (img / np.array([IMG_W, IMG_H])).tolist()
ctx = cal.CalibrationContext(make_bundle(r_gt, c_gt), 640, 360)
origin, rays = ctx.transform_keypoints(kps_norm)
for point, ray in zip(self.PERSON_POINTS, rays):
v = point - origin
assert np.linalg.norm(v - np.dot(v, ray) * ray) < 1e-4
# ---------------------------------------------------------------------------
# collect-ground-truth record path (import-level; no camera loop)
# ---------------------------------------------------------------------------
class TestRecordAugmentation:
LEGACY_RECORD = {
"ts_ns": 1775300000000000000,
"keypoints": [[0.45, 0.12]] * 17,
"confidence": 0.92,
"n_visible": 14,
"n_persons": 1,
}
def test_no_calibration_is_byte_identical(self):
# The collector's no---calibration path must emit exactly the
# original ADR-079 JSONL line (back-compat guarantee).
record = json.loads(json.dumps(self.LEGACY_RECORD))
before = json.dumps(record)
out = cal.augment_record(record, None)
assert out is record
assert json.dumps(out) == before
assert set(out.keys()) == {"ts_ns", "keypoints", "confidence",
"n_visible", "n_persons"}
def test_calibrated_record_gains_room_fields(self, scene):
r_gt, c_gt, _, _ = scene
bundle = make_bundle(r_gt, c_gt)
ctx = cal.CalibrationContext(bundle, IMG_W, IMG_H)
record = json.loads(json.dumps(self.LEGACY_RECORD))
out = cal.augment_record(record, ctx)
# Raw image coords preserved untouched; room representation added.
assert out["keypoints"] == self.LEGACY_RECORD["keypoints"]
assert len(out["keypoints_room"]) == 17
assert all(len(ray) == 3 for ray in out["keypoints_room"])
assert out["calibration_id"] == cal.calibration_id(bundle)
assert out["transceiver_geometry"] == bundle["transceiver_geometry"]
assert len(out["camera_origin_room"]) == 3
json.dumps(out) # remains JSONL-serializable
def test_empty_keypoints_record(self, scene):
r_gt, c_gt, _, _ = scene
ctx = cal.CalibrationContext(make_bundle(r_gt, c_gt), IMG_W, IMG_H)
record = {"ts_ns": 1, "keypoints": [], "confidence": 0.0,
"n_visible": 0, "n_persons": 0}
out = cal.augment_record(record, ctx)
assert out["keypoints_room"] == []
assert "calibration_id" in out
Generated
+12 -12
View File
@@ -7328,9 +7328,9 @@ dependencies = [
[[package]]
name = "ruvector-attention"
version = "2.0.4"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4233c1cecd0ea826d95b787065b398489328885042247ff5ffcbb774e864ff"
checksum = "a92e8e456458188d04aee946579aa7cf96d7b8f276cbf6094532b2c3f6d8cc0b"
dependencies = [
"rand 0.8.5",
"rayon",
@@ -7395,14 +7395,14 @@ dependencies = [
[[package]]
name = "ruvector-gnn"
version = "2.0.5"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e17c1cf1ff3380026b299ff3c1ba3a5685c3d8d54700e6ab0b585b6cec21d7b"
checksum = "a251f9ced8d3231395d922369edc803ef0fc513c7776128f7b4ef21f20dd1f4b"
dependencies = [
"anyhow",
"dashmap",
"libc",
"ndarray 0.16.1",
"ndarray 0.17.2",
"parking_lot",
"rand 0.8.5",
"rand_distr 0.4.3",
@@ -7415,9 +7415,9 @@ dependencies = [
[[package]]
name = "ruvector-mincut"
version = "2.0.4"
version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d62e10cbb7d80b1e2b72d55c1e3eb7f0c4c5e3f31984bc3baa9b7a02700741e"
checksum = "d60947433f740d0f589a2911d7b72a02e07a916e7257e478b14386f0ff068fb7"
dependencies = [
"anyhow",
"crossbeam",
@@ -7437,9 +7437,9 @@ dependencies = [
[[package]]
name = "ruvector-solver"
version = "2.0.4"
version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce69cbde4ee5747281edb1d987a8292940397723924262b6218fc19022cbf687"
checksum = "9be7c4f61940ae8b451f88b9a629a08ee8ee5c8e6b00ab96ca10ecf59e70f558"
dependencies = [
"dashmap",
"getrandom 0.2.17",
@@ -11041,7 +11041,7 @@ version = "0.3.1"
dependencies = [
"approx",
"criterion",
"ruvector-attention 2.0.4",
"ruvector-attention 2.1.0",
"ruvector-attn-mincut",
"ruvector-core",
"ruvector-crv",
@@ -11103,7 +11103,7 @@ dependencies = [
"num-traits",
"proptest",
"rustfft",
"ruvector-attention 2.0.4",
"ruvector-attention 2.1.0",
"ruvector-attn-mincut",
"ruvector-mincut",
"ruvector-solver",
@@ -11134,7 +11134,7 @@ dependencies = [
"num-traits",
"petgraph",
"proptest",
"ruvector-attention 2.0.4",
"ruvector-attention 2.1.0",
"ruvector-attn-mincut",
"ruvector-mincut",
"ruvector-solver",
+6 -5
View File
@@ -187,15 +187,16 @@ midstreamer-temporal-compare = "0.2"
midstreamer-attractor = "0.2"
# ruvector integration (published on crates.io)
# Vendored at v2.1.0 in vendor/ruvector; using crates.io versions until published.
# Vendored at origin/main (a083bd77f) in vendor/ruvector; using crates.io versions
# until published. Bumps per ADR-152 §2.6 (2026-06-10 vendor sync survey).
ruvector-core = "2.2.0"
ruvector-mincut = "2.0.4"
ruvector-mincut = "2.0.6"
ruvector-attn-mincut = "2.0.4"
ruvector-temporal-tensor = "2.0.6"
ruvector-solver = "2.0.4"
ruvector-attention = "2.0.4"
ruvector-solver = "2.0.6"
ruvector-attention = "2.1.0"
ruvector-crv = "0.1.1"
ruvector-gnn = { version = "2.0.5", default-features = false }
ruvector-gnn = { version = "2.2.0", default-features = false }
# Internal crates
-2
View File
@@ -1,2 +0,0 @@
/target/
Cargo.lock
-98
View File
@@ -1,98 +0,0 @@
[workspace]
resolver = "2"
members = [
"ruv-neural-core",
"ruv-neural-sensor",
"ruv-neural-signal",
"ruv-neural-graph",
"ruv-neural-mincut",
"ruv-neural-embed",
"ruv-neural-memory",
"ruv-neural-decoder",
"ruv-neural-esp32",
"ruv-neural-wasm",
"ruv-neural-viz",
"ruv-neural-cli",
]
# WASM crate excluded from default workspace to avoid breaking `cargo test --workspace`
# Build separately: cargo build -p ruv-neural-wasm --target wasm32-unknown-unknown --release
exclude = [
"ruv-neural-wasm",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["rUv <ruv@ruv.net>"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/ruvnet/RuView"
documentation = "https://docs.rs/ruv-neural"
keywords = ["neural", "brain", "topology", "mincut", "quantum-sensing"]
categories = ["science", "algorithms"]
[workspace.dependencies]
# Core utilities
thiserror = "1.0"
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Math and signal processing
ndarray = { version = "0.15", features = ["serde"] }
num-complex = "0.4"
num-traits = "0.2"
rustfft = "6.1"
# Graph algorithms
petgraph = "0.6"
# Async runtime
tokio = { version = "1.35", features = ["full"] }
# WASM support
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["console"] }
# ESP32 / embedded
embedded-hal = "1.0"
# CLI
clap = { version = "4.4", features = ["derive", "env"] }
# Serialization
bincode = "1.3"
# Random
rand = "0.8"
# Cryptographic verification
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
sha2 = "0.10"
# Testing
criterion = { version = "0.5", features = ["html_reports"] }
proptest = "1.4"
approx = "0.5"
# Internal crates
ruv-neural-core = { version = "0.1.0", path = "ruv-neural-core" }
ruv-neural-sensor = { version = "0.1.0", path = "ruv-neural-sensor" }
ruv-neural-signal = { version = "0.1.0", path = "ruv-neural-signal" }
ruv-neural-graph = { version = "0.1.0", path = "ruv-neural-graph" }
ruv-neural-mincut = { version = "0.1.0", path = "ruv-neural-mincut" }
ruv-neural-embed = { version = "0.1.0", path = "ruv-neural-embed" }
ruv-neural-memory = { version = "0.1.0", path = "ruv-neural-memory" }
ruv-neural-decoder = { version = "0.1.0", path = "ruv-neural-decoder" }
ruv-neural-esp32 = { version = "0.1.0", path = "ruv-neural-esp32" }
ruv-neural-viz = { version = "0.1.0", path = "ruv-neural-viz" }
ruv-neural-cli = { version = "0.1.0", path = "ruv-neural-cli" }
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true
opt-level = 3
-421
View File
@@ -1,421 +0,0 @@
# rUv Neural — Brain Topology Analysis System
> Quantum sensor integration x RuVector graph memory x Dynamic mincut coherence detection
[![crates.io](https://img.shields.io/crates/v/ruv-neural-core.svg)](https://crates.io/crates/ruv-neural-core)
[![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)]()
[![Rust](https://img.shields.io/badge/rust-1.75+-orange.svg)]()
[![Tests](https://img.shields.io/badge/tests-338%20passed-brightgreen.svg)]()
---
## Ethics & Responsible Use
> **This technology interfaces with human neural data. Use it responsibly.**
>
> - **Informed consent** is required before collecting neural data from any participant
> - **Never** deploy brain-computer interfaces without IRB/ethics board approval
> - **Data privacy**: Neural signals are among the most sensitive personal data categories. Encrypt at rest, anonymize before sharing, and comply with GDPR/HIPAA as applicable
> - **Clinical use** requires FDA/CE clearance and must be supervised by licensed medical professionals
> - **Do not** use this software for covert monitoring, interrogation, lie detection, or any application that violates human autonomy
> - **Dual-use awareness**: The same technology that helps paralyzed patients communicate can be misused for surveillance. Design with safeguards
> - This software is provided for **research and educational purposes**. The authors accept no liability for misuse
>
> See [IEEE Neuroethics Framework](https://standards.ieee.org/industry-connections/ec/neuroethics/) and the [Morningside Group Neurorights](https://nri.ntc.columbia.edu/content/neurorights) initiative for guidance.
---
## Overview
**rUv Neural** is a modular Rust crate ecosystem for real-time brain network topology
analysis. It transforms neural magnetic field measurements from quantum sensors (NV diamond
magnetometers, optically pumped magnetometers) into dynamic connectivity graphs, then uses
minimum cut algorithms to detect cognitive state transitions.
This is not mind reading — it measures **how cognition organizes itself** by tracking the
topology of brain networks in real time.
## Hardware Parts List
Below is a reference bill of materials for building a basic multi-channel neural sensing rig.
Prices are approximate (2026). Links are for reference only — equivalent components from any
vendor will work.
### Core: NV Diamond Magnetometer Array
| Component | Qty | Approx Price | Link | Notes |
|-----------|-----|-------------|------|-------|
| NV Diamond Sensor Chip (2x2mm, 1ppm N) | 16 | $45 ea | [AliExpress: NV Diamond Chip](https://www.aliexpress.com/w/wholesale-nv-diamond-sensor.html) | Nitrogen-vacancy center, electronic grade |
| 532nm Green Laser Diode Module (100mW) | 4 | $12 ea | [AliExpress: 532nm Laser Module](https://www.aliexpress.com/w/wholesale-532nm-laser-module-100mw.html) | Excitation source for ODMR |
| Microwave Signal Generator (2.87 GHz) | 1 | $85 | [AliExpress: RF Signal Generator 3GHz](https://www.aliexpress.com/w/wholesale-rf-signal-generator-3ghz.html) | For NV zero-field splitting resonance |
| SMA Coaxial Cable (50 Ohm, 30cm) | 4 | $3 ea | [AliExpress: SMA Cable 50 Ohm](https://www.aliexpress.com/w/wholesale-sma-cable-50-ohm.html) | Microwave delivery to diamond chips |
| Photodiode Array (Si PIN, 16-ch) | 1 | $25 | [AliExpress: Photodiode Array](https://www.aliexpress.com/w/wholesale-photodiode-array-16-channel.html) | Fluorescence detection |
| Transimpedance Amplifier Board | 1 | $18 | [AliExpress: TIA Board](https://www.aliexpress.com/w/wholesale-transimpedance-amplifier-board.html) | Converts photocurrent to voltage |
### Alternative: OPM (Optically Pumped Magnetometer)
| Component | Qty | Approx Price | Link | Notes |
|-----------|-----|-------------|------|-------|
| Rb Vapor Cell (25mm, AR coated) | 8 | $35 ea | [AliExpress: Rubidium Vapor Cell](https://www.aliexpress.com/w/wholesale-rubidium-vapor-cell.html) | SERF-mode magnetometry |
| 795nm VCSEL Laser | 8 | $8 ea | [AliExpress: 795nm VCSEL](https://www.aliexpress.com/w/wholesale-795nm-vcsel-laser.html) | D1 line pump for Rb |
| Balanced Photodetector | 8 | $15 ea | [AliExpress: Balanced Photodetector](https://www.aliexpress.com/w/wholesale-balanced-photodetector.html) | Differential detection |
| Magnetic Shielding Mu-Metal Cylinder | 1 | $120 | [AliExpress: Mu-Metal Shield](https://www.aliexpress.com/w/wholesale-mu-metal-magnetic-shield.html) | 3-layer, >60dB attenuation |
### Alternative: EEG (Electroencephalography)
| Component | Qty | Approx Price | Link | Notes |
|-----------|-----|-------------|------|-------|
| Ag/AgCl EEG Electrodes (10-20 system) | 21 | $2 ea | [AliExpress: EEG Electrode AgCl](https://www.aliexpress.com/w/wholesale-eeg-electrode-ag-agcl.html) | Reusable cup electrodes |
| EEG Cap (10-20 placement, size M) | 1 | $45 | [AliExpress: EEG Cap 10-20](https://www.aliexpress.com/w/wholesale-eeg-cap-10-20.html) | Pre-wired 21-channel |
| Conductive EEG Gel (250ml) | 1 | $8 | [AliExpress: EEG Gel](https://www.aliexpress.com/w/wholesale-eeg-conductive-gel.html) | Low impedance contact |
| ADS1299 EEG AFE Board (8-ch) | 3 | $35 ea | [AliExpress: ADS1299 Board](https://www.aliexpress.com/w/wholesale-ads1299-eeg-board.html) | 24-bit, 250 SPS, TI analog front-end |
### Data Acquisition & Processing
| Component | Qty | Approx Price | Link | Notes |
|-----------|-----|-------------|------|-------|
| ESP32-S3 DevKit (16MB Flash, 8MB PSRAM) | 4 | $8 ea | [AliExpress: ESP32-S3 DevKit](https://www.aliexpress.com/w/wholesale-esp32-s3-devkit.html) | ADC readout + TDM sync |
| ADS1256 24-bit ADC Module | 2 | $12 ea | [AliExpress: ADS1256 Module](https://www.aliexpress.com/w/wholesale-ads1256-module.html) | High-resolution for NV/OPM |
| USB-C Hub (4 port, USB 3.0) | 1 | $10 | [AliExpress: USB-C Hub](https://www.aliexpress.com/w/wholesale-usb-c-hub-4-port.html) | Connect ESP32 nodes to host |
| Shielded USB Cable (30cm, ferrite) | 4 | $3 ea | [AliExpress: Shielded USB Cable](https://www.aliexpress.com/w/wholesale-shielded-usb-cable-ferrite.html) | Reduce EMI |
| Host PC or Raspberry Pi 5 (8GB) | 1 | $80 | [AliExpress: Raspberry Pi 5](https://www.aliexpress.com/w/wholesale-raspberry-pi-5-8gb.html) | Runs the rUv Neural pipeline |
### Assembly Tools
| Component | Qty | Approx Price | Link | Notes |
|-----------|-----|-------------|------|-------|
| Soldering Station (adjustable temp) | 1 | $25 | [AliExpress: Soldering Station](https://www.aliexpress.com/w/wholesale-soldering-station-adjustable.html) | For sensor board assembly |
| Breadboard + Jumper Wire Kit | 1 | $8 | [AliExpress: Breadboard Kit](https://www.aliexpress.com/w/wholesale-breadboard-jumper-wire-kit.html) | Prototyping |
| 3D Printed Sensor Mount (STL provided) | 1 | — | Print locally | Holds diamond chips in array |
**Estimated total cost:** ~$650$900 for a 16-channel NV diamond setup, ~$500 for OPM, ~$200 for EEG.
### Assembly Instructions
1. **Sensor Array**
- Mount NV diamond chips (or OPM vapor cells, or EEG electrodes) in the 3D-printed helmet/mount
- For NV: align 532nm laser to each chip, position photodiodes for fluorescence collection
- For OPM: install Rb cells inside mu-metal shield, align 795nm VCSELs
- For EEG: apply conductive gel, place electrodes per 10-20 system
2. **Signal Chain**
- Connect sensor outputs to ADS1256 (NV/OPM) or ADS1299 (EEG) ADC boards
- Wire ADC SPI bus to ESP32-S3 GPIO (MOSI=11, MISO=13, SCK=12, CS=10)
- Flash ESP32 with `ruv-neural-esp32` firmware: `cargo flash --chip esp32s3`
3. **TDM Synchronization**
- Connect GPIO 4 across all ESP32 nodes as a shared sync line
- The `TdmScheduler` assigns non-overlapping time slots automatically
- Set `sync_tolerance_us: 1000` in the aggregator config
4. **Host Software**
- Install Rust 1.75+ and build: `cargo build --workspace --release`
- Run the pipeline: `cargo run -p ruv-neural-cli --release -- pipeline --channels 16 --duration 60`
- Or use individual crates as a library (see [Use as Library](#use-as-library))
5. **Verification**
- Generate a witness bundle: `cargo run -p ruv-neural-cli -- witness --output witness.json`
- Verify Ed25519 signature: `cargo run -p ruv-neural-cli -- witness --verify witness.json`
- Expected output: `VERDICT: PASS` (41 capability attestations, 338 tests)
## Architecture
```
rUv Neural Pipeline
================================================================
+------------------+ +-------------------+ +------------------+
| | | | | |
| SENSOR LAYER |---->| SIGNAL LAYER |---->| GRAPH LAYER |
| | | | | |
| NV Diamond | | Bandpass Filter | | PLV / Coherence |
| OPM | | Artifact Reject | | Brain Regions |
| EEG | | Hilbert Phase | | Connectivity |
| Simulated | | Spectral (PSD) | | Matrix |
| | | | | |
+------------------+ +-------------------+ +--------+---------+
|
v
+------------------+ +-------------------+ +------------------+
| | | | | |
| DECODE LAYER |<----| MEMORY LAYER |<----| MINCUT LAYER |
| | | | | |
| Cognitive State | | HNSW Index | | Stoer-Wagner |
| Classification | | Pattern Store | | Normalized Cut |
| BCI Output | | Drift Detection | | Spectral Cut |
| Transition Log | | Temporal Window | | Coherence Detect|
| | | | | |
+------------------+ +-------------------+ +------------------+
^
|
+-------+--------+
| |
| EMBED LAYER |
| |
| Spectral Pos. |
| Topology Vec |
| Node2Vec |
| RVF Export |
| |
+----------------+
Peripheral Crates:
+----------+ +----------+ +----------+
| ESP32 | | WASM | | VIZ |
| Edge | | Browser | | ASCII |
| Preproc | | Bindings | | Render |
+----------+ +----------+ +----------+
```
## Crate Map
All crates are published on [crates.io](https://crates.io/search?q=ruv-neural):
| Crate | crates.io | Description | Dependencies |
|-------|-----------|-------------|--------------|
| [`ruv-neural-core`](https://crates.io/crates/ruv-neural-core) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-core.svg)](https://crates.io/crates/ruv-neural-core) | Core types, traits, errors, RVF format | None |
| [`ruv-neural-sensor`](https://crates.io/crates/ruv-neural-sensor) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-sensor.svg)](https://crates.io/crates/ruv-neural-sensor) | NV diamond, OPM, EEG sensor interfaces | core |
| [`ruv-neural-signal`](https://crates.io/crates/ruv-neural-signal) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-signal.svg)](https://crates.io/crates/ruv-neural-signal) | DSP: filtering, spectral, connectivity | core |
| [`ruv-neural-graph`](https://crates.io/crates/ruv-neural-graph) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-graph.svg)](https://crates.io/crates/ruv-neural-graph) | Brain connectivity graph construction | core, signal |
| [`ruv-neural-mincut`](https://crates.io/crates/ruv-neural-mincut) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-mincut.svg)](https://crates.io/crates/ruv-neural-mincut) | Dynamic minimum cut topology analysis | core |
| [`ruv-neural-embed`](https://crates.io/crates/ruv-neural-embed) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-embed.svg)](https://crates.io/crates/ruv-neural-embed) | RuVector graph embeddings | core |
| [`ruv-neural-memory`](https://crates.io/crates/ruv-neural-memory) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-memory.svg)](https://crates.io/crates/ruv-neural-memory) | Persistent neural state memory + HNSW | core |
| [`ruv-neural-decoder`](https://crates.io/crates/ruv-neural-decoder) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-decoder.svg)](https://crates.io/crates/ruv-neural-decoder) | Cognitive state classification + BCI | core |
| [`ruv-neural-esp32`](https://crates.io/crates/ruv-neural-esp32) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-esp32.svg)](https://crates.io/crates/ruv-neural-esp32) | ESP32 edge sensor integration | core |
| `ruv-neural-wasm` | — | WebAssembly browser bindings | core |
| [`ruv-neural-viz`](https://crates.io/crates/ruv-neural-viz) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-viz.svg)](https://crates.io/crates/ruv-neural-viz) | Visualization and ASCII rendering | core, graph, mincut |
| [`ruv-neural-cli`](https://crates.io/crates/ruv-neural-cli) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-cli.svg)](https://crates.io/crates/ruv-neural-cli) | CLI tool (`ruv-neural` binary) | all |
## Dependency Graph
```
ruv-neural-core
(types, traits, errors)
/ | | \ \
/ | | \ \
v v v v v
sensor signal embed esp32 (wasm)
|
v
graph --|------> viz
|
v
mincut
|
v
decoder <--- memory <--- embed
|
v
cli (depends on all)
```
## Quick Start
### Build
```bash
cd v2/crates/ruv-neural
cargo build --workspace
cargo test --workspace
```
### Run CLI
```bash
cargo run -p ruv-neural-cli -- simulate --channels 64 --duration 10
cargo run -p ruv-neural-cli -- pipeline --channels 32 --duration 5 --dashboard
cargo run -p ruv-neural-cli -- mincut --input brain_graph.json
```
### Install from crates.io
```bash
# Add individual crates as needed
cargo add ruv-neural-core
cargo add ruv-neural-sensor
cargo add ruv-neural-signal
cargo add ruv-neural-mincut
cargo add ruv-neural-embed
cargo add ruv-neural-memory
cargo add ruv-neural-decoder
cargo add ruv-neural-graph
cargo add ruv-neural-viz
cargo add ruv-neural-esp32
cargo add ruv-neural-cli
```
### Use as Library
```rust
use ruv_neural_core::*;
use ruv_neural_sensor::simulator::SimulatedSensorArray;
use ruv_neural_signal::PreprocessingPipeline;
use ruv_neural_mincut::DynamicMincutTracker;
use ruv_neural_embed::NeuralEmbedding;
// Create simulated sensor array (64 channels, 1000 Hz)
let mut sensor = SimulatedSensorArray::new(64, 1000.0);
let data = sensor.acquire(1000)?;
// Preprocess: bandpass filter + artifact rejection
let pipeline = PreprocessingPipeline::default();
let clean = pipeline.process(&data)?;
// Compute connectivity and build graph
let connectivity = ruv_neural_signal::compute_all_pairs(
&clean,
ruv_neural_signal::ConnectivityMetric::PhaseLockingValue,
);
// Track topology changes via dynamic mincut
let mut tracker = DynamicMincutTracker::new();
let result = tracker.update(&graph)?;
println!(
"Mincut: {:.3}, Partitions: {} | {}",
result.cut_value,
result.partition_a.len(),
result.partition_b.len()
);
// Generate embedding for downstream classification
let embedding = NeuralEmbedding::new(
result.to_feature_vector(),
data.timestamp,
"spectral",
)?;
println!("Embedding dim: {}", embedding.dimension);
```
## Mix and Match
Each crate is independently usable. Common combinations:
- **Sensor + Signal** -- Data acquisition and preprocessing only
- **Graph + Mincut** -- Graph analysis without sensor dependency
- **Embed + Memory** -- Embedding storage without real-time pipeline
- **Core + WASM** -- Browser-based graph visualization
- **ESP32 alone** -- Edge preprocessing on embedded hardware
- **Signal + Embed** -- Feature extraction pipeline without graph construction
- **Mincut + Viz** -- Topology analysis with ASCII dashboard output
## Platform Support
| Platform | Status | Crates Available |
|----------|--------|-----------------|
| Linux x86_64 | Full | All 12 |
| macOS ARM64 | Full | All 12 |
| Windows x86_64 | Full | All 12 |
| WASM (browser) | Partial | core, wasm, viz |
| ESP32 (no_std) | Partial | core, esp32 |
**Note:** The `ruv-neural-wasm` crate is excluded from the default workspace members.
Build it separately with:
```bash
cargo build -p ruv-neural-wasm --target wasm32-unknown-unknown --release
```
## Key Algorithms
### Signal Processing (`ruv-neural-signal`)
- **Butterworth IIR filters** in second-order sections (SOS) form
- **Welch PSD** estimation with configurable window and overlap
- **Hilbert transform** for instantaneous phase extraction
- **Artifact detection** -- eye blink, muscle, cardiac artifact rejection
- **Connectivity metrics** -- PLV, coherence, imaginary coherence, AEC
### Minimum Cut Analysis (`ruv-neural-mincut`)
- **Stoer-Wagner** -- Global minimum cut in O(V^3)
- **Normalized cut** (Shi-Malik) -- Spectral bisection via the Fiedler vector
- **Multiway cut** -- Recursive normalized cut for k-module detection
- **Spectral cut** -- Cheeger constant and spectral bisection bounds
- **Dynamic tracking** -- Temporal topology transition detection
- **Coherence events** -- Network formation, dissolution, merger, split
### Embeddings (`ruv-neural-embed`)
- **Spectral** -- Laplacian eigenvector positional encoding
- **Topology** -- Hand-crafted topological feature vectors
- **Node2Vec** -- Random-walk co-occurrence embeddings
- **Combined** -- Weighted concatenation of multiple methods
- **Temporal** -- Sliding-window context-enriched embeddings
- **RVF export** -- Serialization to RuVector `.rvf` format
## RVF Format
RuVector File (RVF) is a binary format for neural data interchange:
```
+--------+--------+---------+----------+----------+
| Magic | Version| Type | Payload | Checksum |
| RVF\x01| u8 | u8 | [u8; N] | u32 |
+--------+--------+---------+----------+----------+
```
- **Magic bytes**: `RVF\x01`
- **Supported types**: brain graphs, embeddings, topology metrics, time series
- **Binary format** for efficient storage and streaming
- **Compatible** with the broader RuVector ecosystem
## Cryptographic Witness Verification
rUv Neural includes an Ed25519-signed capability attestation system. Every build can
generate a witness bundle that cryptographically proves which capabilities are present
and that all tests passed.
```bash
# Generate a signed witness bundle
cargo run -p ruv-neural-cli -- witness --output witness-bundle.json
# Verify (any third party can do this)
cargo run -p ruv-neural-cli -- witness --verify witness-bundle.json
```
The bundle contains:
- **41 capability attestations** covering all 12 crates
- **SHA-256 digest** of the capability matrix
- **Ed25519 signature** (unique per generation)
- **Public key** for independent verification
- Test count and pass/fail status
Tampered bundles are detected — modifying any attestation invalidates the digest and
signature verification returns `FAIL`.
## Testing
```bash
# Run all workspace tests
cargo test --workspace
# Run a specific crate's tests
cargo test -p ruv-neural-mincut
# Run with logging enabled
RUST_LOG=debug cargo test --workspace -- --nocapture
# Run benchmarks (requires nightly or criterion)
cargo bench -p ruv-neural-mincut
```
## Crate Publishing Order
Crates must be published in dependency order:
1. `ruv-neural-core` (no internal deps)
2. `ruv-neural-sensor` (depends on core)
3. `ruv-neural-signal` (depends on core)
4. `ruv-neural-esp32` (depends on core)
5. `ruv-neural-graph` (depends on core, signal)
6. `ruv-neural-embed` (depends on core)
7. `ruv-neural-mincut` (depends on core)
8. `ruv-neural-viz` (depends on core, graph)
9. `ruv-neural-memory` (depends on core, embed)
10. `ruv-neural-decoder` (depends on core, embed)
11. `ruv-neural-wasm` (depends on core)
12. `ruv-neural-cli` (depends on all)
## License
MIT OR Apache-2.0
-570
View File
@@ -1,570 +0,0 @@
# ruv-neural Crate System: Security and Performance Review
**Date**: 2026-03-09
**Version**: 0.1.0
**Scope**: All 12 workspace crates in the ruv-neural system
**Status**: Implementation checklist for v0.1 and v0.2 milestones
---
## Table of Contents
1. [Crate Inventory](#crate-inventory)
2. [Security Review](#security-review)
- [Input Validation](#input-validation)
- [Memory Safety](#memory-safety)
- [Data Privacy](#data-privacy)
- [Network Security (ESP32)](#network-security-esp32)
- [Supply Chain](#supply-chain)
- [Findings from Code Audit](#findings-from-code-audit)
3. [Performance Review](#performance-review)
- [Computational Complexity](#computational-complexity)
- [Memory Usage](#memory-usage)
- [Optimization Opportunities](#optimization-opportunities)
- [ESP32 Constraints](#esp32-constraints)
- [Benchmarking Recommendations](#benchmarking-recommendations)
- [Performance Findings from Code Audit](#performance-findings-from-code-audit)
4. [Action Items](#action-items)
---
## Crate Inventory
| Crate | Status | Lines (approx) | Role |
|-------|--------|-----------------|------|
| `ruv-neural-core` | Implemented | ~500 | Types, traits, error types, RVF format |
| `ruv-neural-sensor` | Implemented | ~170 | Sensor data acquisition, calibration, quality |
| `ruv-neural-signal` | Implemented | ~450 | Filtering, spectral analysis, Hilbert, connectivity |
| `ruv-neural-graph` | Stub | ~2 | Graph construction from signals |
| `ruv-neural-mincut` | Implemented | ~700 | Stoer-Wagner, spectral cut, Cheeger, dynamic tracking |
| `ruv-neural-embed` | Implemented | ~350 | Spectral, topology, node2vec embeddings |
| `ruv-neural-memory` | Implemented | ~425 | Embedding store, HNSW index |
| `ruv-neural-decoder` | Implemented (lib) | ~25 | KNN, threshold, transition decoders |
| `ruv-neural-esp32` | Implemented | ~265 | ADC interface, sensor readout |
| `ruv-neural-wasm` | Stub | ~2 | WebAssembly bindings |
| `ruv-neural-viz` | Implemented (lib) | ~20 | Visualization, ASCII rendering, export |
| `ruv-neural-cli` | Stub | ~2 | CLI binary |
---
## Security Review
### Input Validation
All public APIs must validate their inputs at system boundaries. This section catalogs each validation requirement and its current status.
#### Sensor Data Validation
| Check | Required In | Status | Notes |
|-------|------------|--------|-------|
| `sample_rate_hz > 0` | `MultiChannelTimeSeries::new` | **MISSING** | Constructor accepts `sample_rate_hz` without validating it is positive and finite. Division by zero in `duration_s()` if zero. |
| `num_channels > 0` | `MultiChannelTimeSeries::new` | PASS | Returns error if `data.len() == 0`. |
| Channel lengths equal | `MultiChannelTimeSeries::new` | PASS | Validates all channels have the same length. |
| Non-NaN/Inf values | All signal processing | **MISSING** | No validation that input signals contain only finite f64 values. NaN propagation through FFT, PLV, and connectivity metrics produces silent garbage. |
| `num_samples > 0` | `AdcReader::read_samples` | PASS | Returns error if `num_samples == 0`. |
| Channel count > 0 | `AdcReader::read_samples` | PASS | Returns error if no channels configured. |
| Channel index bounds | `AdcReader::load_buffer` | PASS | Returns `ChannelOutOfRange` error. |
| `sensitivity > 0` | `SensorChannel` | **MISSING** | `sensitivity_ft_sqrt_hz` is a public field with no validation on construction. |
| `sample_rate > 0` | `SensorChannel` | **MISSING** | `sample_rate_hz` is a public field with no validation. |
**Recommendation**: Add a `SensorChannel::new()` constructor that validates `sensitivity_ft_sqrt_hz > 0`, `sample_rate_hz > 0`, and that the orientation vector is a unit normal. Add `sample_rate_hz > 0` and `sample_rate_hz.is_finite()` checks to `MultiChannelTimeSeries::new`. Add a `validate_finite()` utility for signal data.
#### Graph Construction Validation
| Check | Required In | Status | Notes |
|-------|------------|--------|-------|
| Edge indices < `num_nodes` | `BrainGraph::adjacency_matrix` | PARTIAL | Silently skips out-of-bounds edges rather than reporting an error. This masks data corruption. |
| Edge weight is finite | `BrainGraph` | **MISSING** | `BrainEdge.weight` is not validated. NaN/Inf weights propagate silently through Stoer-Wagner and spectral analysis. |
| `num_nodes >= 2` | `stoer_wagner_mincut` | PASS | Returns proper error. |
| `num_nodes >= 2` | `fiedler_decomposition` | PASS | Returns proper error. |
| `num_nodes >= 2` | `SpectralEmbedder::embed` | PASS | Returns proper error. |
| `num_nodes >= 2` | `cheeger_constant` | PASS | Returns proper error. |
| Self-loops | `BrainGraph` | **MISSING** | No validation that `source != target` on edges. Self-loops could inflate degree calculations. |
**Recommendation**: Add a `BrainGraph::validate()` method that checks all edge indices are within bounds, weights are finite, and no self-loops exist. Call it from `stoer_wagner_mincut`, `spectral_bisection`, and `SpectralEmbedder::embed`. Consider making `adjacency_matrix()` return `Result` with an error for out-of-bounds edges instead of silently ignoring them.
#### RVF Format Validation
| Check | Required In | Status | Notes |
|-------|------------|--------|-------|
| Magic bytes | `RvfHeader::validate` | PASS | Validates against `RVF_MAGIC`. |
| Version | `RvfHeader::validate` | PASS | Rejects unknown versions. |
| Header length | `RvfHeader::from_bytes` | PASS | Checks `bytes.len() < 22`. |
| Data type tag | `RvfDataType::from_tag` | PASS | Returns error for unknown tags. |
| `metadata_json_len` overflow | `RvfFile::read_from` | **CONCERN** | `metadata_json_len` is cast from `u32` to `usize` and used to allocate a `Vec`. A malicious file with `metadata_json_len = u32::MAX` (~4 GB) would cause an OOM allocation. |
| Payload length | `RvfFile::read_from` | **CONCERN** | `read_to_end` reads unbounded data into memory. A malicious file could exhaust memory. |
| JSON validity | `RvfFile::read_from` | PASS | Uses `serde_json::from_slice` which returns an error on invalid JSON. |
| `num_entries` vs actual data | `RvfFile::read_from` | **MISSING** | The header declares `num_entries` and `embedding_dim`, but these are never cross-checked against the actual payload size. |
**Recommendation**: Add maximum size limits for `metadata_json_len` (e.g., 16 MB) and total payload size. Validate that `num_entries * entry_size_for_type <= data.len()` after reading. Use `Read::take()` to cap reads.
#### Embedding Validation
| Check | Required In | Status | Notes |
|-------|------------|--------|-------|
| Non-empty vector | `NeuralEmbedding::new` (core) | PASS | Returns error for empty vectors. |
| Non-empty vector | `NeuralEmbedding::new` (embed) | PASS | Returns error for empty vectors. |
| Dimension match | `cosine_similarity`, `euclidean_distance` | PASS | Returns `DimensionMismatch` error. |
| Zero-norm handling | `cosine_similarity` | PASS | Returns 0.0 for zero-norm vectors. |
| NaN/Inf in vector | `NeuralEmbedding::new` | **MISSING** | No check for non-finite values in the embedding vector. |
#### Memory Store Validation
| Check | Required In | Status | Notes |
|-------|------------|--------|-------|
| Capacity > 0 | `NeuralMemoryStore::new` | **MISSING** | Capacity 0 is accepted, producing a store that evicts on every insertion. |
| k > 0 | `query_nearest` | **MISSING** | k=0 produces an empty result silently (acceptable but undocumented). |
| Dimension consistency | `NeuralMemoryStore::store` | **MISSING** | No check that all stored embeddings have the same dimensionality. Mixed dimensions cause silent errors in `query_nearest`. |
#### JSON Parsing
| Check | Status | Notes |
|-------|--------|-------|
| Uses serde derive | PASS | All types use `#[derive(Serialize, Deserialize)]`. No manual parsing anywhere. |
| No `unsafe` JSON parsing | PASS | Standard `serde_json` throughout. |
---
### Memory Safety
| Check | Status | Notes |
|-------|--------|-------|
| No `unsafe` code | PASS | Zero `unsafe` blocks across all crates. |
| Vec instead of raw pointers | PASS | All data structures use `Vec`, `HashMap`, `BinaryHeap`. |
| ndarray for matrix ops | **NOT USED** | Despite being listed in `workspace.dependencies`, matrix operations use `Vec<Vec<f64>>` throughout. This is bounds-checked but less efficient. |
| No C FFI | PASS | No FFI calls. ESP32 code uses pure Rust types. |
| No `std::mem::transmute` | PASS | None found. |
| No `std::ptr` usage | PASS | None found. |
| Bounds checking on slices | PASS | Uses `.get()`, iterator methods, and Rust's built-in bounds checks. |
| Integer overflow | **CONCERN** | `max_raw_value()` in `adc.rs` casts `(1u32 << resolution_bits) - 1` to `i16`. If `resolution_bits > 15`, this overflows silently. Currently only 12 or 16 are intended, but 16 produces `i16::MAX` wrapping. |
**Recommendation**: Add a validation check on `resolution_bits` in `AdcConfig` (must be <= 15 for i16 representation, or switch to u16/i32). Consider migrating `Vec<Vec<f64>>` matrix representations to `ndarray::Array2<f64>` for better cache performance and built-in bounds checking.
---
### Data Privacy
Neural data is among the most sensitive personal data categories. This section covers data handling practices.
| Check | Status | Notes |
|-------|--------|-------|
| No PII in log messages | **NEEDS AUDIT** | The crate uses `tracing` in workspace dependencies but currently has no `tracing::info!` or `tracing::debug!` calls with data fields. As logging is added, ensure neural data values, subject IDs, and session IDs are never logged at INFO level or below. |
| No neural data in error messages | PASS | Error messages contain structural information (dimensions, indices, version numbers) but not raw signal values or embeddings. |
| `subject_id` handling | **CONCERN** | `EmbeddingMetadata.subject_id` is stored as plaintext `Option<String>`. This is PII that is included in serialized embeddings (serde), HNSW indices, and RVF files. |
| `session_id` handling | **CONCERN** | Same concern as `subject_id`. |
| Memory store encryption | **NOT IMPLEMENTED** | `NeuralMemoryStore` holds embeddings in plaintext `Vec<f64>`. No encryption-at-rest. |
| Memory zeroization on drop | **NOT IMPLEMENTED** | Embedding data is not zeroed when dropped. Sensitive neural data persists in deallocated memory. |
| WASM data boundary | STUB | WASM crate is not yet implemented. When implemented, must ensure no neural data is sent to external services without explicit user consent. |
| RVF file privacy | **CONCERN** | `RvfFile` serializes `metadata` as JSON, which may contain `subject_id`. No option to strip or anonymize metadata before export. |
**Recommendations**:
- Implement a `Redactable` trait for types that may contain PII, providing `redact()` and `anonymize()` methods.
- Use the `zeroize` crate to zero sensitive data on drop for `NeuralEmbedding`, `NeuralMemoryStore`, and `MultiChannelTimeSeries`.
- Add a `strip_pii()` method to `RvfFile` that removes or hashes identifiers before export.
- Document privacy responsibilities in each crate's module documentation.
- For v0.2: Add optional encryption-at-rest for `NeuralMemoryStore` using `ring` or `aes-gcm`.
---
### Network Security (ESP32)
| Check | Status | Notes |
|-------|--------|-------|
| Node ID authentication | **NOT IMPLEMENTED** | ESP32 crate (`ruv-neural-esp32`) is currently a local ADC reader with no network protocol. When TDM protocol is added, node IDs must be authenticated. |
| CRC32 integrity | **NOT IMPLEMENTED** | No data packet framing or integrity checks exist yet. |
| TLS encryption | **NOT IMPLEMENTED** | v0.1 has no network layer. Planned for v0.2. |
| Packet size limits | **NOT IMPLEMENTED** | No packet protocol exists yet. |
| Buffer overflow prevention | PARTIAL | `AdcReader` uses a fixed-size ring buffer (4096 samples), which prevents unbounded growth. However, `load_buffer` silently truncates data that exceeds buffer size rather than reporting it. |
| DMA configuration | N/A | `dma_enabled` is a configuration flag only; actual DMA is not implemented in std mode. |
**Recommendations for v0.2 TDM Protocol**:
- Authenticate node IDs using a pre-shared key or challenge-response.
- Add CRC32 or CRC32-C to every data packet.
- Set maximum packet size to 1460 bytes (single WiFi frame MTU).
- Use DTLS or TLS 1.3 for encryption when available.
- Rate-limit incoming packets per node to prevent flooding.
- Validate all fields in received packets before processing.
---
### Supply Chain
| Check | Status | Notes |
|-------|--------|-------|
| Minimal dependencies | PASS | Core dependencies: `thiserror`, `serde`, `serde_json`, `num-complex`, `rustfft`, `rand`. All are well-maintained, widely-used crates. |
| No proc macros except serde | PASS | Only `serde`'s derive macros and `thiserror`'s derive macro are used. `clap`'s derive is CLI-only. |
| All deps from crates.io | PASS | No git dependencies or path dependencies outside the workspace. |
| Workspace-managed versions | PASS | All dependency versions are declared in `[workspace.dependencies]`. |
| `petgraph` usage | **UNUSED** | Listed in workspace dependencies but not imported by any crate. Remove to reduce supply chain surface. |
| `tokio` usage | **UNUSED** | Listed in workspace dependencies but not imported by any crate. Remove unless async is planned. |
| `ruvector-*` crates | **UNUSED** | Five RuVector crates listed but not imported by any workspace member. Remove unused dependencies. |
| `Cargo.lock` | PRESENT | `Cargo.lock` is committed, ensuring reproducible builds. |
**Recommendation**: Run `cargo deny check` to audit for known vulnerabilities. Remove unused workspace dependencies (`petgraph`, `tokio`, `ruvector-*` crates) to minimize attack surface. Add `cargo audit` to CI.
---
### Findings from Code Audit
#### SEC-001: RVF Unbounded Allocation (Severity: Medium)
**Location**: `ruv-neural-core/src/rvf.rs`, line 193
```rust
let mut meta_bytes = vec![0u8; header.metadata_json_len as usize];
```
A crafted RVF file with `metadata_json_len = 0xFFFFFFFF` allocates 4 GB. Similarly, `read_to_end` on line 201 reads unbounded data.
**Fix**: Add maximum size constants and validate before allocating:
```rust
const MAX_METADATA_LEN: u32 = 16 * 1024 * 1024; // 16 MB
const MAX_PAYLOAD_LEN: usize = 256 * 1024 * 1024; // 256 MB
if header.metadata_json_len > MAX_METADATA_LEN {
return Err(RuvNeuralError::Serialization(
format!("metadata_json_len {} exceeds maximum {}", header.metadata_json_len, MAX_METADATA_LEN)
));
}
```
#### SEC-002: Missing Sample Rate Validation (Severity: Medium)
**Location**: `ruv-neural-core/src/signal.rs`, `MultiChannelTimeSeries::new`
The `sample_rate_hz` parameter is not validated. A value of 0.0 causes division by zero in `duration_s()`. A negative or NaN value causes incorrect spectral analysis throughout the pipeline.
**Fix**: Add validation in the constructor:
```rust
if sample_rate_hz <= 0.0 || !sample_rate_hz.is_finite() {
return Err(RuvNeuralError::Signal(
format!("sample_rate_hz must be positive and finite, got {}", sample_rate_hz)
));
}
```
#### SEC-003: NaN Propagation in Signal Processing (Severity: Low)
**Location**: `ruv-neural-signal/src/connectivity.rs`, all functions
If either input signal contains NaN, the Hilbert transform produces NaN outputs, which propagate silently through PLV, coherence, and all connectivity metrics. The result is a brain graph with NaN edge weights, which causes undefined behavior in Stoer-Wagner (infinite loops or wrong results).
**Fix**: Add a `validate_signal` helper and call it at entry points:
```rust
fn validate_signal(signal: &[f64]) -> Result<()> {
if signal.iter().any(|x| !x.is_finite()) {
return Err(RuvNeuralError::Signal("Signal contains NaN or Inf values".into()));
}
Ok(())
}
```
#### SEC-004: Integer Overflow in ADC (Severity: Low)
**Location**: `ruv-neural-esp32/src/adc.rs`, `AdcConfig::max_raw_value`
```rust
pub fn max_raw_value(&self) -> i16 {
((1u32 << self.resolution_bits) - 1) as i16
}
```
For `resolution_bits = 16`, this computes `65535 as i16 = -1`, which causes incorrect voltage conversion (division by -1 flips sign).
**Fix**: Change return type to `u16` or `i32`, or validate `resolution_bits <= 15`.
#### SEC-005: HNSW Visited Array Allocation (Severity: Low)
**Location**: `ruv-neural-memory/src/hnsw.rs`, `search_layer`, line 261
```rust
let mut visited = vec![false; self.embeddings.len()];
```
This allocates a visited array proportional to the total number of embeddings on every search call. For large indices (100K+ embeddings), this causes unnecessary allocation pressure. More critically, if `entry` is >= `self.embeddings.len()`, the indexing on line 262 panics.
**Fix**: Use a `HashSet<usize>` instead of a boolean array for sparse visitation. Add bounds check on `entry`.
---
## Performance Review
### Computational Complexity
| Operation | Complexity | Target Latency | Current Status |
|-----------|-----------|----------------|----------------|
| FFT (1024 points) | O(N log N) | <1 ms | Implemented via `rustfft` (SIMD-optimized). Meets target. |
| Hilbert transform | O(N log N) | <1 ms | Two FFTs (forward + inverse). Meets target for N <= 4096. |
| PLV (channel pair) | O(N) + 2x FFT | <0.5 ms | Calls `hilbert_transform` twice. Meets target for N <= 2048. |
| Coherence (channel pair) | O(N) + 2x FFT | <0.5 ms | Same as PLV. |
| Connectivity matrix (68 regions) | O(N^2 x M) | <10 ms | M = samples per channel, N = 68: 2,278 Hilbert pairs. May exceed target for long windows. |
| Stoer-Wagner mincut (68 nodes) | O(V^3) | <5 ms | 68^3 = ~314K operations. Meets target. |
| Spectral embedding (68 nodes) | O(V^2 x k x iterations) | <3 ms | With k=8, iterations=100: 68^2 x 8 x 100 = ~37M ops. May be tight. |
| Fiedler decomposition | O(V^2 x iterations) | <2 ms | 1000 iterations x 68^2 = ~4.6M ops. Meets target. |
| Cheeger constant (exact, n<=16) | O(2^n x n^2) | <5 ms | Exponential but capped at n=16: 65K x 256 = ~16M ops. Meets target. |
| HNSW insert | O(log N x ef x M) | <1 ms | ef=200, M=16: ~3200 distance computations per insert. Meets target. |
| HNSW search (10K embeddings) | O(log N x ef) | <1 ms | ef=50: ~50-200 distance computations. Meets target. |
| Brute-force NN (10K embeddings) | O(N x d) | <5 ms | d=256, N=10K: 2.56M f64 ops. Acceptable but HNSW preferred. |
| Full pipeline (68 regions) | - | <50 ms | Sum of above stages. Should meet target. |
### Memory Usage
| Component | Calculation | Size |
|-----------|------------|------|
| 64-channel x 1000 Hz x 8 bytes x 1s | 64 x 1000 x 8 | 512 KB per second |
| Brain graph adjacency (68 nodes) | 68^2 x 8 bytes | ~37 KB |
| Brain graph adjacency (400 nodes) | 400^2 x 8 bytes | ~1.25 MB |
| Single embedding (256-d) | 256 x 8 bytes | 2 KB |
| Memory store (10K embeddings, 256-d) | 10K x 2 KB | ~20 MB |
| HNSW index (10K, M=16, 256-d) | 10K x (2KB + 16 x 16 bytes) | ~22.5 MB |
| Stoer-Wagner working memory (68 nodes) | 2 x 68^2 x 8 + 68 x vec overhead | ~75 KB |
| Spectral embedder (68 nodes, k=8) | k x 68 x 8 + Laplacian 68^2 x 8 | ~41 KB |
| RVF file in memory | header + metadata + payload | Variable, unbounded (see SEC-001) |
### Optimization Opportunities
#### Immediate (v0.1)
1. **Eliminate redundant Hilbert transforms in connectivity matrix**
- `compute_all_pairs` calls `hilbert_transform` twice per channel pair.
- For 68 channels, this means 68 x 67 = 4,556 Hilbert transforms instead of 68.
- **Fix**: Pre-compute analytic signals for all channels, then compute metrics pairwise.
- **Expected speedup**: ~67x for connectivity matrix computation.
2. **Replace Vec<Vec<f64>> with flat Vec<f64> for adjacency matrices**
- Current `Vec<Vec<f64>>` has poor cache locality due to heap-allocated inner Vecs.
- **Fix**: Use `Vec<f64>` with manual row-major indexing, or migrate to `ndarray::Array2<f64>`.
- **Expected speedup**: 2-4x for matrix-heavy operations (Stoer-Wagner, Laplacian).
3. **Avoid Vec::remove(0) in eviction**
- `NeuralMemoryStore::evict_oldest` calls `self.embeddings.remove(0)`, which is O(n).
- **Fix**: Use a `VecDeque` or circular buffer.
- **Expected speedup**: O(1) eviction instead of O(n).
4. **Pre-allocate FFT planner**
- `compute_psd`, `compute_stft`, and `hilbert_transform` each create a new `FftPlanner` per call.
- **Fix**: Cache the planner or use a thread-local planner.
- **Expected speedup**: Eliminates repeated plan computation.
#### Medium-term (v0.2)
5. **Rayon for parallel channel processing**
- `compute_all_pairs` iterates channel pairs sequentially.
- **Fix**: Use `rayon::par_iter` for the outer loop.
- **Expected speedup**: Linear with core count for connectivity computation.
6. **SIMD for distance computations in HNSW**
- Euclidean distance in `HnswIndex::distance` uses scalar iteration.
- **Fix**: Use `packed_simd2` or auto-vectorization hints.
- **Expected speedup**: 4-8x for 256-d vectors on AVX2.
7. **Sparse graph representation**
- Dense adjacency matrix wastes memory for sparse brain graphs.
- For Schaefer400, storing all 160K entries when only ~10K edges exist is wasteful.
- **Fix**: Use compressed sparse row (CSR) format or `petgraph`'s sparse graph.
8. **Quantized embeddings for WASM**
- f64 embeddings are unnecessarily precise for browser-based applications.
- **Fix**: Support f32 embeddings in WASM builds, halving memory and transfer size.
#### Long-term (v0.3+)
9. **Streaming signal processing**
- Current design loads entire time windows into memory.
- **Fix**: Implement ring-buffer based streaming for real-time operation.
10. **GPU acceleration for large-scale spectral analysis**
- For Schaefer400 atlas, eigendecomposition of 400x400 matrices benefits from GPU.
- **Fix**: Optional `wgpu` or `vulkano` backend for matrix operations.
### ESP32 Constraints
| Resource | Limit | Current Usage | Status |
|----------|-------|---------------|--------|
| SRAM | 520 KB | Ring buffer: 4096 x channels x 2 bytes = 8 KB (1 channel) | OK |
| SRAM (multi-channel) | 520 KB | 4096 x 16 x 2 = 128 KB (16 channels) | **TIGHT** |
| CPU | 240 MHz dual-core | ADC sampling + data transmission | OK for 1 kHz |
| Flash | 4 MB | Binary size with release profile | Needs measurement |
| WiFi throughput | ~1 Mbps sustained | 64 ch x 1000 Hz x 2 bytes = 128 KB/s = 1 Mbps | **AT LIMIT** |
**Recommendations**:
- Use fixed-point arithmetic (i16 or Q15) instead of f64 on ESP32.
- Implement delta encoding or simple compression for data packets.
- Limit on-device processing to ADC readout and basic quality checks.
- Move all signal processing (FFT, connectivity, graph construction) to the host.
- Profile binary size with `cargo bloat` to ensure it fits in 4 MB flash.
- Consider reducing ring buffer size for multi-channel configurations.
### Benchmarking Recommendations
#### Per-Crate Microbenchmarks (criterion)
```toml
# Add to each crate's Cargo.toml
[[bench]]
name = "benchmarks"
harness = false
[dev-dependencies]
criterion = { workspace = true }
```
| Crate | Benchmark | Input Size | Metric |
|-------|-----------|------------|--------|
| `ruv-neural-signal` | `bench_hilbert_transform` | 256, 512, 1024, 2048, 4096 samples | ns/op |
| `ruv-neural-signal` | `bench_compute_psd` | 1024, 4096 samples | ns/op |
| `ruv-neural-signal` | `bench_plv_pair` | 1024 samples | ns/op |
| `ruv-neural-signal` | `bench_connectivity_matrix` | 16, 32, 68 channels x 1024 samples | ms/op |
| `ruv-neural-mincut` | `bench_stoer_wagner` | 10, 20, 50, 68, 100 nodes | us/op |
| `ruv-neural-mincut` | `bench_spectral_bisection` | 10, 20, 50, 68, 100 nodes | us/op |
| `ruv-neural-mincut` | `bench_cheeger_constant` | 8, 12, 16 nodes (exact), 32, 68 (approx) | us/op |
| `ruv-neural-embed` | `bench_spectral_embed` | 20, 50, 68, 100 nodes | us/op |
| `ruv-neural-memory` | `bench_brute_force_nn` | 100, 1K, 10K embeddings x 256-d | us/op |
| `ruv-neural-memory` | `bench_hnsw_insert` | 1K, 10K embeddings x 256-d | us/op |
| `ruv-neural-memory` | `bench_hnsw_search` | 1K, 10K embeddings, k=10, ef=50 | us/op |
| `ruv-neural-esp32` | `bench_adc_read` | 100, 1000 samples x 1-16 channels | us/op |
#### Full Pipeline Profiling
```bash
# Generate a flamegraph of the full pipeline
cargo flamegraph --bench full_pipeline -- --bench
# Memory profiling with DHAT
cargo test --features dhat-heap -- --test full_pipeline
```
#### WASM Performance
```javascript
// When ruv-neural-wasm is implemented, measure with:
performance.mark('embed-start');
const embedding = ruv_neural.embed(graphData);
performance.mark('embed-end');
performance.measure('embed', 'embed-start', 'embed-end');
```
#### ESP32 Hardware Timing
```rust
// Use esp-idf-hal's timer for hardware-level benchmarks
let start = esp_idf_hal::timer::now();
let samples = reader.read_samples(1000)?;
let elapsed_us = esp_idf_hal::timer::now() - start;
```
### Performance Findings from Code Audit
#### PERF-001: Redundant Hilbert Transforms (Severity: High)
**Location**: `ruv-neural-signal/src/connectivity.rs`, `compute_all_pairs`
Each call to `phase_locking_value`, `coherence`, `imaginary_coherence`, or `amplitude_envelope_correlation` independently calls `hilbert_transform` on both input signals. In `compute_all_pairs` with 68 channels, each channel's analytic signal is computed 67 times.
**Impact**: For 68 channels x 1024 samples, this means 4,556 FFTs instead of 68. Estimated waste: ~98.5% of FFT compute in the connectivity matrix.
**Fix**: Pre-compute all analytic signals, then pass slices to pairwise metrics:
```rust
pub fn compute_all_pairs_optimized(channels: &[Vec<f64>], metric: &ConnectivityMetric) -> Vec<Vec<f64>> {
let analytics: Vec<Vec<Complex<f64>>> = channels.iter()
.map(|ch| hilbert_transform(ch))
.collect();
// ... use pre-computed analytics for all pair computations
}
```
#### PERF-002: O(n) Eviction in Memory Store (Severity: Medium)
**Location**: `ruv-neural-memory/src/store.rs`, `evict_oldest`
```rust
fn evict_oldest(&mut self) {
self.embeddings.remove(0); // O(n) shift
self.rebuild_index(); // O(n) rebuild
}
```
For a store with 10K embeddings, every insertion at capacity triggers an O(n) shift and full index rebuild.
**Fix**: Use `VecDeque<NeuralEmbedding>` and maintain the index incrementally.
#### PERF-003: FFT Planner Re-creation (Severity: Medium)
**Location**: `ruv-neural-signal/src/spectral.rs` (lines 12-13), `hilbert.rs` (lines 25-27)
A new `FftPlanner` is created on every function call. `rustfft` caches FFT plans internally in the planner, but creating a new planner discards the cache.
**Fix**: Use a thread-local or static planner:
```rust
thread_local! {
static FFT_PLANNER: RefCell<FftPlanner<f64>> = RefCell::new(FftPlanner::new());
}
```
#### PERF-004: Dense Adjacency for Sparse Graphs (Severity: Low)
**Location**: `ruv-neural-core/src/graph.rs`, `adjacency_matrix`
Always allocates an N x N matrix even when the graph has far fewer edges. For Schaefer400 with ~5K edges, this allocates 1.25 MB for a matrix that is ~97% zeros.
**Fix**: Return a sparse representation for large graphs, or provide both `adjacency_matrix()` and `sparse_adjacency()`.
#### PERF-005: Power Iteration Convergence Not Checked (Severity: Low)
**Location**: `ruv-neural-mincut/src/spectral_cut.rs`, `largest_eigenvalue`
Runs a fixed 200 iterations regardless of convergence. Many graphs converge in 20-50 iterations.
**Fix**: Add early termination when eigenvalue change < epsilon:
```rust
if (eigenvalue - prev_eigenvalue).abs() < 1e-12 {
break;
}
```
Note: `fiedler_decomposition` already has this check, but `largest_eigenvalue` does not.
---
## Action Items
### Critical (Must fix before v0.1 release)
- [ ] **SEC-001**: Add maximum size limits to RVF deserialization
- [ ] **SEC-002**: Validate `sample_rate_hz > 0` and `is_finite()` in `MultiChannelTimeSeries::new`
- [ ] **SEC-004**: Fix integer overflow in `AdcConfig::max_raw_value`
- [ ] **PERF-001**: Pre-compute Hilbert transforms in `compute_all_pairs`
### Important (Should fix before v0.1 release)
- [ ] **SEC-003**: Add NaN/Inf validation for signal data at pipeline entry points
- [ ] **SEC-005**: Add bounds check on HNSW entry point index
- [ ] **PERF-002**: Replace `Vec::remove(0)` with `VecDeque` in memory store
- [ ] **PERF-003**: Cache FFT planner across calls
- [ ] Add `BrainGraph::validate()` for edge index bounds and weight finiteness
- [ ] Add dimension consistency check to `NeuralMemoryStore::store`
- [ ] Remove unused workspace dependencies (`petgraph`, `tokio`, `ruvector-*`)
### Recommended (Fix in v0.2)
- [ ] Implement `zeroize`-on-drop for `NeuralEmbedding` and `NeuralMemoryStore`
- [ ] Add `strip_pii()` to `RvfFile`
- [ ] Migrate `Vec<Vec<f64>>` matrices to `ndarray::Array2<f64>`
- [ ] Add Rayon parallelism for connectivity matrix computation
- [ ] Add criterion benchmarks for all crates
- [ ] Implement TDM protocol with CRC32 and node authentication
- [ ] Add `cargo deny` and `cargo audit` to CI
- [ ] Profile and optimize binary size for ESP32
### Future (v0.3+)
- [ ] Encryption-at-rest for `NeuralMemoryStore`
- [ ] DTLS/TLS for ESP32 network protocol
- [ ] Sparse graph representation for large atlases
- [ ] f32 quantized embeddings for WASM
- [ ] Streaming signal processing pipeline
- [ ] GPU backend for large-scale spectral analysis
---
*This document should be reviewed and updated after each milestone. All security findings should be verified as resolved before the corresponding release.*
@@ -1,28 +0,0 @@
[package]
name = "ruv-neural-cli"
description = "rUv Neural — CLI tool for brain topology analysis, simulation, and visualization"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[[bin]]
name = "ruv-neural"
path = "src/main.rs"
[dependencies]
ruv-neural-core = { workspace = true }
ruv-neural-sensor = { workspace = true }
ruv-neural-signal = { workspace = true }
ruv-neural-graph = { workspace = true }
ruv-neural-mincut = { workspace = true }
ruv-neural-embed = { workspace = true }
ruv-neural-memory = { workspace = true }
ruv-neural-decoder = { workspace = true }
ruv-neural-viz = { workspace = true }
clap = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tokio = { workspace = true }
@@ -1,112 +0,0 @@
# ruv-neural-cli
CLI tool for brain topology analysis, simulation, and visualization.
## Overview
`ruv-neural-cli` is the command-line binary (`ruv-neural`) that ties together
the entire rUv Neural crate ecosystem. It provides subcommands for simulating
neural sensor data, analyzing brain connectivity graphs, computing minimum cuts,
running the full processing pipeline with an optional ASCII dashboard, and
exporting to multiple visualization formats.
## Installation
```bash
# Build from source
cargo install --path .
# Or run directly
cargo run -p ruv-neural-cli -- <command>
```
## Commands
### `simulate` -- Generate synthetic neural data
```bash
ruv-neural simulate --channels 64 --duration 10 --sample-rate 1000 --output data.json
```
| Flag | Default | Description |
|------------------|---------|------------------------------|
| `-c, --channels` | 64 | Number of sensor channels |
| `-d, --duration` | 10.0 | Duration in seconds |
| `-s, --sample-rate` | 1000.0 | Sample rate in Hz |
| `-o, --output` | (none) | Output file path (JSON) |
### `analyze` -- Analyze a brain connectivity graph
```bash
ruv-neural analyze --input graph.json --ascii --csv metrics.csv
```
| Flag | Default | Description |
|----------------|---------|--------------------------------|
| `-i, --input` | (required) | Input graph file (JSON) |
| `--ascii` | false | Show ASCII visualization |
| `--csv` | (none) | Export metrics to CSV file |
### `mincut` -- Compute minimum cut
```bash
ruv-neural mincut --input graph.json --k 4
```
| Flag | Default | Description |
|----------------|---------|--------------------------------|
| `-i, --input` | (required) | Input graph file (JSON) |
| `-k` | (none) | Multi-way cut with k partitions|
### `pipeline` -- Full end-to-end pipeline
```bash
ruv-neural pipeline --channels 32 --duration 5 --dashboard
```
Runs: simulate -> preprocess -> build graph -> mincut -> embed -> decode.
| Flag | Default | Description |
|------------------|---------|--------------------------------|
| `-c, --channels` | 32 | Number of sensor channels |
| `-d, --duration` | 5.0 | Duration in seconds |
| `--dashboard` | false | Show real-time ASCII dashboard |
### `export` -- Export to visualization format
```bash
ruv-neural export --input graph.json --format dot --output graph.dot
```
| Flag | Default | Description |
|------------------|---------|---------------------------------------|
| `-i, --input` | (required) | Input graph file (JSON) |
| `-f, --format` | d3 | Output format: d3, dot, gexf, csv, rvf |
| `-o, --output` | (required) | Output file path |
### `info` -- Show system information
```bash
ruv-neural info
```
Displays crate versions, available features, and system capabilities.
## Global Options
| Flag | Description |
|------------------|------------------------------------|
| `-v` | Increase verbosity (up to `-vvv`) |
| `--version` | Print version |
| `--help` | Print help |
## Integration
Depends on all workspace crates: `ruv-neural-core`, `ruv-neural-sensor`,
`ruv-neural-signal`, `ruv-neural-graph`, `ruv-neural-mincut`, `ruv-neural-embed`,
`ruv-neural-memory`, `ruv-neural-decoder`, and `ruv-neural-viz`. Uses `clap`
for argument parsing and `tokio` for async runtime.
## License
MIT OR Apache-2.0
@@ -1,237 +0,0 @@
//! Analyze a brain connectivity graph: compute topology metrics and display results.
use std::fs;
use ruv_neural_core::graph::BrainGraph;
use ruv_neural_mincut::stoer_wagner_mincut;
/// Run the analyze command.
pub fn run(
input: &str,
ascii: bool,
csv_output: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
tracing::info!(input, "Loading brain graph");
let json = fs::read_to_string(input)
.map_err(|e| format!("Failed to read {input}: {e}"))?;
let graph: BrainGraph = serde_json::from_str(&json)
.map_err(|e| format!("Failed to parse graph JSON: {e}"))?;
println!("=== rUv Neural — Graph Analysis ===");
println!();
println!(" Nodes: {}", graph.num_nodes);
println!(" Edges: {}", graph.edges.len());
println!(" Density: {:.4}", graph.density());
println!(" Total weight: {:.4}", graph.total_weight());
println!(" Timestamp: {:.2} s", graph.timestamp);
println!(" Window duration: {:.2} s", graph.window_duration_s);
println!(" Atlas: {:?}", graph.atlas);
println!();
// Degree statistics.
let degrees: Vec<f64> = (0..graph.num_nodes)
.map(|i| graph.node_degree(i))
.collect();
let mean_degree = if degrees.is_empty() {
0.0
} else {
degrees.iter().sum::<f64>() / degrees.len() as f64
};
let max_degree = degrees.iter().cloned().fold(0.0_f64, f64::max);
let min_degree = degrees.iter().cloned().fold(f64::INFINITY, f64::min);
println!(" Degree statistics:");
println!(" Mean: {mean_degree:.4}");
println!(" Min: {min_degree:.4}");
println!(" Max: {max_degree:.4}");
println!();
// Mincut.
match stoer_wagner_mincut(&graph) {
Ok(mc) => {
println!(" Minimum cut:");
println!(" Cut value: {:.4}", mc.cut_value);
println!(" Partition A: {} nodes {:?}", mc.partition_a.len(), mc.partition_a);
println!(" Partition B: {} nodes {:?}", mc.partition_b.len(), mc.partition_b);
println!(" Cut edges: {}", mc.cut_edges.len());
println!(" Balance ratio: {:.4}", mc.balance_ratio());
println!();
}
Err(e) => {
println!(" Minimum cut: could not compute ({e})");
println!();
}
}
// Edge weight distribution.
if !graph.edges.is_empty() {
let weights: Vec<f64> = graph.edges.iter().map(|e| e.weight).collect();
let mean_w = weights.iter().sum::<f64>() / weights.len() as f64;
let max_w = weights.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let min_w = weights.iter().cloned().fold(f64::INFINITY, f64::min);
println!(" Edge weight distribution:");
println!(" Mean: {mean_w:.4}");
println!(" Min: {min_w:.4}");
println!(" Max: {max_w:.4}");
println!();
}
if ascii {
print_ascii_graph(&graph);
}
if let Some(csv_path) = csv_output {
write_csv(&graph, &degrees, &csv_path)?;
println!(" Metrics exported to: {csv_path}");
}
Ok(())
}
/// Print a simple ASCII visualization of the graph adjacency.
fn print_ascii_graph(graph: &BrainGraph) {
println!(" ASCII Adjacency Matrix:");
let n = graph.num_nodes.min(20); // cap display at 20x20
let adj = graph.adjacency_matrix();
// Header row.
print!(" ");
for j in 0..n {
print!("{j:>4}");
}
println!();
for i in 0..n {
print!(" {i:>3} ");
for j in 0..n {
let w = adj[i][j];
if i == j {
print!(" .");
} else if w > 0.0 {
// Map weight to a character.
let ch = if w > 0.8 {
'#'
} else if w > 0.5 {
'*'
} else if w > 0.2 {
'+'
} else {
'.'
};
print!(" {ch}");
} else {
print!(" ");
}
}
println!();
}
if graph.num_nodes > 20 {
println!(" ... ({} nodes total, showing first 20)", graph.num_nodes);
}
println!();
}
/// Write per-node metrics to a CSV file.
fn write_csv(
graph: &BrainGraph,
degrees: &[f64],
path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let mut csv = String::from("node,degree,num_edges\n");
for i in 0..graph.num_nodes {
let num_edges = graph
.edges
.iter()
.filter(|e| e.source == i || e.target == i)
.count();
csv.push_str(&format!(
"{},{:.6},{}\n",
i,
degrees.get(i).copied().unwrap_or(0.0),
num_edges
));
}
fs::write(path, csv)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use ruv_neural_core::brain::Atlas;
use ruv_neural_core::graph::{BrainEdge, ConnectivityMetric};
use ruv_neural_core::signal::FrequencyBand;
fn test_graph() -> BrainGraph {
BrainGraph {
num_nodes: 4,
edges: vec![
BrainEdge {
source: 0,
target: 1,
weight: 0.8,
metric: ConnectivityMetric::PhaseLockingValue,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 1,
target: 2,
weight: 0.5,
metric: ConnectivityMetric::PhaseLockingValue,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 2,
target: 3,
weight: 0.9,
metric: ConnectivityMetric::PhaseLockingValue,
frequency_band: FrequencyBand::Alpha,
},
],
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(4),
}
}
#[test]
fn analyze_from_json() {
let graph = test_graph();
let dir = std::env::temp_dir();
let path = dir.join("ruv_neural_test_analyze.json");
let json = serde_json::to_string_pretty(&graph).unwrap();
std::fs::write(&path, json).unwrap();
let result = run(&path.to_string_lossy(), false, None);
assert!(result.is_ok());
std::fs::remove_file(&path).ok();
}
#[test]
fn analyze_with_csv() {
let graph = test_graph();
let dir = std::env::temp_dir();
let json_path = dir.join("ruv_neural_test_analyze2.json");
let csv_path = dir.join("ruv_neural_test_analyze2.csv");
let json = serde_json::to_string_pretty(&graph).unwrap();
std::fs::write(&json_path, json).unwrap();
let result = run(
&json_path.to_string_lossy(),
true,
Some(csv_path.to_string_lossy().to_string()),
);
assert!(result.is_ok());
assert!(csv_path.exists());
let csv_content = std::fs::read_to_string(&csv_path).unwrap();
assert!(csv_content.starts_with("node,degree,num_edges"));
std::fs::remove_file(&json_path).ok();
std::fs::remove_file(&csv_path).ok();
}
}
@@ -1,280 +0,0 @@
//! Export brain graph to various visualization formats.
use std::fs;
use ruv_neural_core::graph::BrainGraph;
/// Run the export command.
pub fn run(
input: &str,
format: &str,
output: &str,
) -> Result<(), Box<dyn std::error::Error>> {
tracing::info!(input, format, output, "Exporting brain graph");
let json =
fs::read_to_string(input).map_err(|e| format!("Failed to read {input}: {e}"))?;
let graph: BrainGraph =
serde_json::from_str(&json).map_err(|e| format!("Failed to parse graph JSON: {e}"))?;
let content = match format {
"d3" => export_d3(&graph)?,
"dot" => export_dot(&graph),
"gexf" => export_gexf(&graph),
"csv" => export_csv(&graph),
"rvf" => export_rvf(&graph)?,
_ => {
return Err(format!(
"Unknown format '{format}'. Supported: d3, dot, gexf, csv, rvf"
)
.into());
}
};
fs::write(output, content)?;
println!("=== rUv Neural — Export Complete ===");
println!();
println!(" Format: {format}");
println!(" Input: {input}");
println!(" Output: {output}");
println!(" Nodes: {}", graph.num_nodes);
println!(" Edges: {}", graph.edges.len());
Ok(())
}
/// Export to D3.js-compatible JSON format.
fn export_d3(graph: &BrainGraph) -> Result<String, Box<dyn std::error::Error>> {
let nodes: Vec<serde_json::Value> = (0..graph.num_nodes)
.map(|i| {
serde_json::json!({
"id": i,
"degree": graph.node_degree(i),
})
})
.collect();
let links: Vec<serde_json::Value> = graph
.edges
.iter()
.map(|e| {
serde_json::json!({
"source": e.source,
"target": e.target,
"weight": e.weight,
"metric": format!("{:?}", e.metric),
"band": format!("{:?}", e.frequency_band),
})
})
.collect();
let d3 = serde_json::json!({
"nodes": nodes,
"links": links,
"metadata": {
"num_nodes": graph.num_nodes,
"num_edges": graph.edges.len(),
"density": graph.density(),
"total_weight": graph.total_weight(),
"atlas": format!("{:?}", graph.atlas),
"timestamp": graph.timestamp,
}
});
Ok(serde_json::to_string_pretty(&d3)?)
}
/// Export to Graphviz DOT format.
fn export_dot(graph: &BrainGraph) -> String {
let mut dot = String::from("graph brain {\n");
dot.push_str(" rankdir=LR;\n");
dot.push_str(&format!(
" label=\"Brain Graph ({} nodes, {} edges)\";\n",
graph.num_nodes,
graph.edges.len()
));
dot.push_str(" node [shape=circle];\n\n");
for i in 0..graph.num_nodes {
let degree = graph.node_degree(i);
let size = 0.3 + degree * 0.1;
dot.push_str(&format!(
" n{i} [label=\"{i}\", width={size:.2}];\n"
));
}
dot.push('\n');
for edge in &graph.edges {
let penwidth = 0.5 + edge.weight * 2.0;
dot.push_str(&format!(
" n{} -- n{} [penwidth={:.2}, label=\"{:.2}\"];\n",
edge.source, edge.target, penwidth, edge.weight
));
}
dot.push_str("}\n");
dot
}
/// Export to GEXF (Graph Exchange XML Format).
fn export_gexf(graph: &BrainGraph) -> String {
let mut gexf = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>
<gexf xmlns="http://gexf.net/1.3" version="1.3">
<meta>
<creator>rUv Neural</creator>
<description>Brain connectivity graph</description>
</meta>
<graph defaultedgetype="undirected">
<nodes>
"#);
for i in 0..graph.num_nodes {
gexf.push_str(&format!(
" <node id=\"{i}\" label=\"Region {i}\" />\n"
));
}
gexf.push_str(" </nodes>\n <edges>\n");
for (idx, edge) in graph.edges.iter().enumerate() {
gexf.push_str(&format!(
" <edge id=\"{idx}\" source=\"{}\" target=\"{}\" weight=\"{:.6}\" />\n",
edge.source, edge.target, edge.weight
));
}
gexf.push_str(" </edges>\n </graph>\n</gexf>\n");
gexf
}
/// Export to CSV edge list.
fn export_csv(graph: &BrainGraph) -> String {
let mut csv = String::from("source,target,weight,metric,frequency_band\n");
for edge in &graph.edges {
csv.push_str(&format!(
"{},{},{:.6},{:?},{:?}\n",
edge.source, edge.target, edge.weight, edge.metric, edge.frequency_band
));
}
csv
}
/// Export to RVF (RuVector File) JSON representation.
fn export_rvf(graph: &BrainGraph) -> Result<String, Box<dyn std::error::Error>> {
let rvf = serde_json::json!({
"format": "rvf",
"version": 1,
"data_type": "BrainGraph",
"num_nodes": graph.num_nodes,
"num_edges": graph.edges.len(),
"atlas": format!("{:?}", graph.atlas),
"timestamp": graph.timestamp,
"window_duration_s": graph.window_duration_s,
"adjacency": graph.adjacency_matrix(),
});
Ok(serde_json::to_string_pretty(&rvf)?)
}
#[cfg(test)]
mod tests {
use super::*;
use ruv_neural_core::brain::Atlas;
use ruv_neural_core::graph::{BrainEdge, ConnectivityMetric};
use ruv_neural_core::signal::FrequencyBand;
fn test_graph() -> BrainGraph {
BrainGraph {
num_nodes: 3,
edges: vec![
BrainEdge {
source: 0,
target: 1,
weight: 0.8,
metric: ConnectivityMetric::PhaseLockingValue,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 1,
target: 2,
weight: 0.5,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Beta,
},
],
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(3),
}
}
#[test]
fn export_d3_valid_json() {
let graph = test_graph();
let result = export_d3(&graph).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(parsed["nodes"].is_array());
assert!(parsed["links"].is_array());
assert_eq!(parsed["nodes"].as_array().unwrap().len(), 3);
assert_eq!(parsed["links"].as_array().unwrap().len(), 2);
}
#[test]
fn export_dot_format() {
let graph = test_graph();
let result = export_dot(&graph);
assert!(result.starts_with("graph brain {"));
assert!(result.contains("n0 -- n1"));
assert!(result.ends_with("}\n"));
}
#[test]
fn export_gexf_format() {
let graph = test_graph();
let result = export_gexf(&graph);
assert!(result.contains("<gexf"));
assert!(result.contains("<node id=\"0\""));
assert!(result.contains("</gexf>"));
}
#[test]
fn export_csv_format() {
let graph = test_graph();
let result = export_csv(&graph);
assert!(result.starts_with("source,target,weight"));
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines.len(), 3); // header + 2 edges
}
#[test]
fn export_rvf_valid_json() {
let graph = test_graph();
let result = export_rvf(&graph).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["format"], "rvf");
assert_eq!(parsed["num_nodes"], 3);
}
#[test]
fn export_all_formats() {
let graph = test_graph();
let dir = std::env::temp_dir();
let json_path = dir.join("ruv_neural_test_export.json");
let json = serde_json::to_string_pretty(&graph).unwrap();
std::fs::write(&json_path, json).unwrap();
for fmt in &["d3", "dot", "gexf", "csv", "rvf"] {
let out_path = dir.join(format!("ruv_neural_test_export.{fmt}"));
let result = run(
&json_path.to_string_lossy(),
fmt,
&out_path.to_string_lossy(),
);
assert!(result.is_ok(), "Failed to export format: {fmt}");
assert!(out_path.exists(), "Output file missing for format: {fmt}");
std::fs::remove_file(&out_path).ok();
}
std::fs::remove_file(&json_path).ok();
}
}
@@ -1,66 +0,0 @@
//! Display system info and capabilities.
/// Run the info command.
pub fn run() {
let version = env!("CARGO_PKG_VERSION");
println!("=== rUv Neural — System Information ===");
println!();
println!(" Version: {version}");
println!(" Binary: ruv-neural");
println!();
println!(" Crate Versions:");
println!(" ruv-neural-core {version}");
println!(" ruv-neural-sensor {version}");
println!(" ruv-neural-signal {version}");
println!(" ruv-neural-graph {version}");
println!(" ruv-neural-mincut {version}");
println!(" ruv-neural-embed {version}");
println!(" ruv-neural-memory {version}");
println!(" ruv-neural-decoder {version}");
println!(" ruv-neural-viz {version}");
println!(" ruv-neural-cli {version}");
println!();
println!(" Features:");
println!(" Sensor simulation [available]");
println!(" Signal processing [available]");
println!(" Bandpass filtering [available] (Butterworth IIR, SOS form)");
println!(" Artifact rejection [available] (eye blink, muscle, cardiac)");
println!(" PLV connectivity [available] (phase locking value)");
println!(" Coherence metrics [available] (coherence, imaginary coherence)");
println!(" Stoer-Wagner mincut [available] (global minimum cut)");
println!(" Normalized cut [available] (Shi-Malik spectral bisection)");
println!(" Multi-way cut [available] (recursive normalized cut)");
println!(" Spectral embedding [available] (Laplacian eigenvector encoding)");
println!(" Topology embedding [available] (hand-crafted topological features)");
println!(" Node2Vec embedding [available] (random walk co-occurrence)");
println!(" Threshold decoder [available] (rule-based cognitive state)");
println!(" KNN decoder [available] (k-nearest neighbor classifier)");
println!(" Force-directed layout [available] (Fruchterman-Reingold)");
println!(" Anatomical layout [available] (MNI coordinate-based)");
println!();
println!(" Export Formats:");
println!(" D3.js JSON [available]");
println!(" Graphviz DOT [available]");
println!(" GEXF (Graph Exchange) [available]");
println!(" CSV edge list [available]");
println!(" RVF (RuVector File) [available]");
println!();
println!(" Pipeline:");
println!(" simulate -> filter -> PLV graph -> mincut -> embed -> decode");
println!();
println!(" Platform:");
println!(" OS: {}", std::env::consts::OS);
println!(" Arch: {}", std::env::consts::ARCH);
println!(" Family: {}", std::env::consts::FAMILY);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn info_runs_without_panic() {
run();
}
}
@@ -1,184 +0,0 @@
//! Compute minimum cut on a brain connectivity graph.
use std::fs;
use ruv_neural_core::graph::BrainGraph;
use ruv_neural_mincut::{multiway_cut, stoer_wagner_mincut};
/// Run the mincut command.
pub fn run(input: &str, k: Option<usize>) -> Result<(), Box<dyn std::error::Error>> {
tracing::info!(input, ?k, "Computing minimum cut");
let json =
fs::read_to_string(input).map_err(|e| format!("Failed to read {input}: {e}"))?;
let graph: BrainGraph =
serde_json::from_str(&json).map_err(|e| format!("Failed to parse graph JSON: {e}"))?;
println!("=== rUv Neural — Minimum Cut Analysis ===");
println!();
println!(" Graph: {} nodes, {} edges", graph.num_nodes, graph.edges.len());
println!();
match k {
Some(k_val) if k_val > 2 => {
// Multi-way cut.
let result = multiway_cut(&graph, k_val)
.map_err(|e| format!("Multiway cut failed: {e}"))?;
println!(" Multi-way cut (k={k_val}):");
println!(" Total cut value: {:.4}", result.cut_value);
println!(" Modularity: {:.4}", result.modularity);
println!(" Partitions: {}", result.num_partitions());
println!();
for (i, partition) in result.partitions.iter().enumerate() {
println!(" Partition {i}: {} nodes {:?}", partition.len(), partition);
}
println!();
// ASCII visualization of partitions.
print_partition_ascii(&graph, &result.partitions);
}
_ => {
// Standard two-way Stoer-Wagner.
let mc = stoer_wagner_mincut(&graph)
.map_err(|e| format!("Stoer-Wagner mincut failed: {e}"))?;
println!(" Stoer-Wagner minimum cut:");
println!(" Cut value: {:.4}", mc.cut_value);
println!(" Partition A: {} nodes {:?}", mc.partition_a.len(), mc.partition_a);
println!(" Partition B: {} nodes {:?}", mc.partition_b.len(), mc.partition_b);
println!(" Balance ratio: {:.4}", mc.balance_ratio());
println!();
println!(" Cut edges:");
for (src, tgt, weight) in &mc.cut_edges {
println!(" {src} -- {tgt} (weight: {weight:.4})");
}
println!();
// ASCII visualization of the two partitions.
print_partition_ascii(&graph, &[mc.partition_a.clone(), mc.partition_b.clone()]);
}
}
Ok(())
}
/// Print an ASCII visualization of the graph partitions.
fn print_partition_ascii(graph: &BrainGraph, partitions: &[Vec<usize>]) {
println!(" Partition layout:");
// Build a node-to-partition map.
let mut node_partition = vec![0usize; graph.num_nodes];
for (pid, partition) in partitions.iter().enumerate() {
for &node in partition {
if node < graph.num_nodes {
node_partition[node] = pid;
}
}
}
// Label characters for partitions.
let labels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
let n = graph.num_nodes.min(40);
print!(" ");
for i in 0..n {
let pid = node_partition[i];
let ch = labels.get(pid).copied().unwrap_or('?');
print!("{ch}");
}
println!();
if graph.num_nodes > 40 {
println!(" ... ({} nodes total)", graph.num_nodes);
}
println!();
for (pid, partition) in partitions.iter().enumerate() {
let ch = labels.get(pid).copied().unwrap_or('?');
println!(" {ch} = {} nodes", partition.len());
}
println!();
}
#[cfg(test)]
mod tests {
use super::*;
use ruv_neural_core::brain::Atlas;
use ruv_neural_core::graph::{BrainEdge, ConnectivityMetric};
use ruv_neural_core::signal::FrequencyBand;
fn test_graph() -> BrainGraph {
BrainGraph {
num_nodes: 6,
edges: vec![
BrainEdge {
source: 0,
target: 1,
weight: 5.0,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 1,
target: 2,
weight: 5.0,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 3,
target: 4,
weight: 5.0,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 4,
target: 5,
weight: 5.0,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 2,
target: 3,
weight: 0.5,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
],
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(6),
}
}
#[test]
fn mincut_two_way() {
let graph = test_graph();
let dir = std::env::temp_dir();
let path = dir.join("ruv_neural_test_mincut.json");
let json = serde_json::to_string_pretty(&graph).unwrap();
std::fs::write(&path, json).unwrap();
let result = run(&path.to_string_lossy(), None);
assert!(result.is_ok());
std::fs::remove_file(&path).ok();
}
#[test]
fn mincut_multiway() {
let graph = test_graph();
let dir = std::env::temp_dir();
let path = dir.join("ruv_neural_test_mincut_k.json");
let json = serde_json::to_string_pretty(&graph).unwrap();
std::fs::write(&path, json).unwrap();
let result = run(&path.to_string_lossy(), Some(3));
assert!(result.is_ok());
std::fs::remove_file(&path).ok();
}
}
@@ -1,9 +0,0 @@
//! CLI command implementations.
pub mod analyze;
pub mod export;
pub mod info;
pub mod mincut;
pub mod pipeline;
pub mod simulate;
pub mod witness;
@@ -1,377 +0,0 @@
//! Full end-to-end pipeline: simulate -> process -> analyze -> decode.
use std::f64::consts::PI;
use ruv_neural_core::brain::Atlas;
use ruv_neural_core::graph::{BrainEdge, BrainGraph, ConnectivityMetric};
use ruv_neural_core::signal::{FrequencyBand, MultiChannelTimeSeries};
use ruv_neural_core::topology::CognitiveState;
use ruv_neural_decoder::ThresholdDecoder;
use ruv_neural_embed::spectral_embed::SpectralEmbedder;
use ruv_neural_embed::topology_embed::TopologyEmbedder;
use ruv_neural_mincut::stoer_wagner_mincut;
use ruv_neural_signal::connectivity::phase_locking_value;
use ruv_neural_signal::filter::BandpassFilter;
/// Run the full pipeline command.
pub fn run(
channels: usize,
duration: f64,
dashboard: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let sample_rate = 1000.0;
let num_samples = (duration * sample_rate) as usize;
println!("=== rUv Neural — Full Pipeline ===");
println!();
// Step 1: Generate simulated sensor data.
println!(" [1/7] Generating simulated sensor data...");
let raw_data = generate_data(channels, num_samples, sample_rate);
let ts = MultiChannelTimeSeries::new(raw_data.clone(), sample_rate, 0.0)
.map_err(|e| format!("Time series creation failed: {e}"))?;
println!(" {channels} channels, {num_samples} samples, {duration:.1}s");
// Step 2: Preprocess (bandpass filter 1-100 Hz).
println!(" [2/7] Preprocessing (bandpass 1-100 Hz)...");
let filter = BandpassFilter::new(4, 1.0, 100.0, sample_rate);
let filtered: Vec<Vec<f64>> = raw_data
.iter()
.map(|ch| {
use ruv_neural_signal::filter::SignalProcessor;
filter.process(ch)
})
.collect();
println!(" Bandpass filter applied to all channels");
// Step 3: Construct brain graph via PLV connectivity.
println!(" [3/7] Constructing brain connectivity graph (PLV)...");
let graph = build_plv_graph(&filtered, sample_rate);
println!(
" {} nodes, {} edges, density {:.4}",
graph.num_nodes,
graph.edges.len(),
graph.density()
);
// Step 4: Compute mincut and topology metrics.
println!(" [4/7] Computing minimum cut and topology metrics...");
let mc = stoer_wagner_mincut(&graph)
.map_err(|e| format!("Mincut failed: {e}"))?;
println!(" Cut value: {:.4}, balance: {:.4}", mc.cut_value, mc.balance_ratio());
println!(
" Partition A: {} nodes, Partition B: {} nodes",
mc.partition_a.len(),
mc.partition_b.len()
);
// Step 5: Generate embedding.
println!(" [5/7] Generating topology embedding...");
let embedder = TopologyEmbedder::new();
let embedding = embedder.embed_graph(&graph)
.map_err(|e| format!("Embedding failed: {e}"))?;
println!(" Dimension: {}, norm: {:.4}", embedding.dimension, embedding.norm());
// Also generate spectral embedding.
let spectral_dim = channels.min(8).max(2);
let spectral = SpectralEmbedder::new(spectral_dim);
let spectral_emb = spectral.embed_graph(&graph)
.map_err(|e| format!("Spectral embedding failed: {e}"))?;
println!(
" Spectral embedding: dim={}, norm={:.4}",
spectral_emb.dimension,
spectral_emb.norm()
);
// Step 6: Decode cognitive state.
println!(" [6/7] Decoding cognitive state...");
let decoder = build_default_decoder();
let metrics = ruv_neural_core::topology::TopologyMetrics {
global_mincut: mc.cut_value,
modularity: estimate_modularity(&graph),
global_efficiency: estimate_efficiency(&graph),
local_efficiency: 0.0,
graph_entropy: estimate_entropy(&graph),
fiedler_value: 0.0,
num_modules: 2,
timestamp: graph.timestamp,
};
let (state, confidence) = decoder.decode(&metrics);
println!(" State: {state:?}");
println!(" Confidence: {confidence:.4}");
// Step 7: Display results.
println!(" [7/7] Results summary");
println!();
println!(" ┌─────────────────────────────────────────┐");
println!(" │ Pipeline Results Summary │");
println!(" ├─────────────────────────────────────────┤");
println!(" │ Channels: {:<20}", channels);
println!(" │ Duration: {:<20}", format!("{duration:.1} s"));
println!(" │ Graph density: {:<20}", format!("{:.4}", graph.density()));
println!(" │ Mincut value: {:<20}", format!("{:.4}", mc.cut_value));
println!(" │ Balance ratio: {:<20}", format!("{:.4}", mc.balance_ratio()));
println!(" │ Modularity: {:<20}", format!("{:.4}", metrics.modularity));
println!(" │ Graph entropy: {:<20}", format!("{:.4}", metrics.graph_entropy));
println!(" │ Embedding dim: {:<20}", embedding.dimension);
println!(" │ Cognitive state: {:<20}", format!("{state:?}"));
println!(" │ Confidence: {:<20}", format!("{confidence:.4}"));
println!(" └─────────────────────────────────────────┘");
println!();
if dashboard {
print_dashboard(&ts, &graph, &mc, &metrics);
}
Ok(())
}
/// Generate synthetic multi-channel neural data.
fn generate_data(channels: usize, num_samples: usize, sample_rate: f64) -> Vec<Vec<f64>> {
let mut data = Vec::with_capacity(channels);
for ch in 0..channels {
let mut channel_data = Vec::with_capacity(num_samples);
let phase = (ch as f64) * PI / (channels as f64);
let mut rng: u64 = (ch as u64).wrapping_mul(2862933555777941757).wrapping_add(3037000493);
for i in 0..num_samples {
let t = i as f64 / sample_rate;
let alpha = 50.0 * (2.0 * PI * 10.0 * t + phase).sin();
let beta = 30.0 * (2.0 * PI * 20.0 * t + phase * 1.3).sin();
let gamma = 15.0 * (2.0 * PI * 40.0 * t + phase * 0.7).sin();
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let u1 = (rng >> 11) as f64 / (1u64 << 53) as f64;
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let u2 = (rng >> 11) as f64 / (1u64 << 53) as f64;
let noise = if u1 > 1e-15 {
5.0 * (-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
} else {
0.0
};
channel_data.push(alpha + beta + gamma + noise);
}
data.push(channel_data);
}
data
}
/// Build a brain graph from PLV connectivity between all channel pairs.
fn build_plv_graph(channels: &[Vec<f64>], sample_rate: f64) -> BrainGraph {
let n = channels.len();
let mut edges = Vec::new();
let plv_threshold = 0.3;
for i in 0..n {
for j in (i + 1)..n {
let plv = phase_locking_value(&channels[i], &channels[j], sample_rate, FrequencyBand::Alpha);
if plv > plv_threshold {
edges.push(BrainEdge {
source: i,
target: j,
weight: plv,
metric: ConnectivityMetric::PhaseLockingValue,
frequency_band: FrequencyBand::Alpha,
});
}
}
}
BrainGraph {
num_nodes: n,
edges,
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(n),
}
}
/// Estimate modularity using a simple degree-based partition.
fn estimate_modularity(graph: &BrainGraph) -> f64 {
let n = graph.num_nodes;
if n < 2 {
return 0.0;
}
let total = graph.total_weight();
if total < 1e-12 {
return 0.0;
}
let adj = graph.adjacency_matrix();
let degrees: Vec<f64> = (0..n).map(|i| graph.node_degree(i)).collect();
let two_m = 2.0 * total;
// Simple bisection: first half vs second half.
let mid = n / 2;
let mut q = 0.0;
for i in 0..n {
for j in 0..n {
let same_community = (i < mid && j < mid) || (i >= mid && j >= mid);
if same_community {
q += adj[i][j] - degrees[i] * degrees[j] / two_m;
}
}
}
q / two_m
}
/// Estimate global efficiency (mean inverse shortest path).
fn estimate_efficiency(graph: &BrainGraph) -> f64 {
let n = graph.num_nodes;
if n < 2 {
return 0.0;
}
// Use adjacency weights directly as a rough proxy.
let adj = graph.adjacency_matrix();
let mut sum = 0.0;
let mut count = 0;
for i in 0..n {
for j in (i + 1)..n {
if adj[i][j] > 0.0 {
sum += adj[i][j]; // weight as proxy for efficiency
}
count += 1;
}
}
if count == 0 {
return 0.0;
}
sum / count as f64
}
/// Estimate graph entropy from edge weight distribution.
fn estimate_entropy(graph: &BrainGraph) -> f64 {
let total = graph.total_weight();
if total < 1e-12 || graph.edges.is_empty() {
return 0.0;
}
let mut entropy = 0.0;
for edge in &graph.edges {
let p = edge.weight / total;
if p > 1e-15 {
entropy -= p * p.ln();
}
}
entropy
}
/// Build a threshold decoder with default state definitions.
fn build_default_decoder() -> ThresholdDecoder {
let mut decoder = ThresholdDecoder::new();
decoder.set_threshold(
CognitiveState::Rest,
ruv_neural_decoder::TopologyThreshold {
mincut_range: (0.0, 5.0),
modularity_range: (0.2, 0.6),
efficiency_range: (0.1, 0.4),
entropy_range: (1.0, 3.0),
},
);
decoder.set_threshold(
CognitiveState::Focused,
ruv_neural_decoder::TopologyThreshold {
mincut_range: (3.0, 15.0),
modularity_range: (0.4, 0.8),
efficiency_range: (0.3, 0.7),
entropy_range: (2.0, 4.0),
},
);
decoder.set_threshold(
CognitiveState::MotorPlanning,
ruv_neural_decoder::TopologyThreshold {
mincut_range: (2.0, 10.0),
modularity_range: (0.3, 0.7),
efficiency_range: (0.2, 0.6),
entropy_range: (1.5, 3.5),
},
);
decoder
}
/// Print a real-time-style ASCII dashboard.
fn print_dashboard(
ts: &MultiChannelTimeSeries,
graph: &BrainGraph,
mc: &ruv_neural_core::topology::MincutResult,
metrics: &ruv_neural_core::topology::TopologyMetrics,
) {
println!(" ╔═══════════════════════════════════════════════════╗");
println!(" ║ rUv Neural — Live Dashboard ║");
println!(" ╠═══════════════════════════════════════════════════╣");
println!(" ║ ║");
// Signal sparkline for first few channels.
let display_channels = ts.num_channels.min(6);
let display_samples = ts.num_samples.min(50);
let sparkline_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
for ch in 0..display_channels {
let data = &ts.data[ch];
let min_val = data.iter().cloned().fold(f64::INFINITY, f64::min);
let max_val = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let range = max_val - min_val;
let step = ts.num_samples / display_samples;
let mut sparkline = String::new();
for i in 0..display_samples {
let val = data[i * step];
let normalized = if range > 1e-12 {
((val - min_val) / range * 7.0) as usize
} else {
4
};
sparkline.push(sparkline_chars[normalized.min(7)]);
}
println!(" ║ Ch{ch:02}: {sparkline}");
}
println!(" ║ ║");
println!(" ║ Graph: {} nodes, {} edges ║",
format!("{:>3}", graph.num_nodes),
format!("{:>4}", graph.edges.len()),
);
println!(" ║ Mincut: {:.4} Balance: {:.4}", mc.cut_value, mc.balance_ratio());
println!(" ║ Modularity: {:.4} Entropy: {:.4}", metrics.modularity, metrics.graph_entropy);
println!(" ║ ║");
println!(" ╚═══════════════════════════════════════════════════╝");
println!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pipeline_runs_end_to_end() {
let result = run(4, 1.0, false);
assert!(result.is_ok());
}
#[test]
fn pipeline_with_dashboard() {
let result = run(4, 0.5, true);
assert!(result.is_ok());
}
#[test]
fn plv_graph_has_edges() {
let data = generate_data(4, 1000, 1000.0);
let graph = build_plv_graph(&data, 1000.0);
assert_eq!(graph.num_nodes, 4);
// Channels with similar phase should have some PLV connectivity.
}
#[test]
fn entropy_non_negative() {
let data = generate_data(4, 1000, 1000.0);
let graph = build_plv_graph(&data, 1000.0);
let e = estimate_entropy(&graph);
assert!(e >= 0.0);
}
}
@@ -1,156 +0,0 @@
//! Simulate neural sensor data and write to JSON or stdout.
use std::f64::consts::PI;
use std::fs;
use ruv_neural_core::signal::MultiChannelTimeSeries;
/// Run the simulate command.
///
/// Generates synthetic multi-channel neural data with configurable alpha,
/// beta, and gamma oscillations plus realistic noise.
pub fn run(
channels: usize,
duration: f64,
sample_rate: f64,
output: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let num_samples = (duration * sample_rate) as usize;
if num_samples == 0 {
return Err("Duration and sample rate must produce at least one sample".into());
}
tracing::info!(
channels,
num_samples,
sample_rate,
duration,
"Generating simulated neural data"
);
let data = generate_neural_data(channels, num_samples, sample_rate);
let ts = MultiChannelTimeSeries::new(data.clone(), sample_rate, 0.0).map_err(|e| {
Box::<dyn std::error::Error>::from(format!("Failed to create time series: {e}"))
})?;
// Compute summary statistics.
let mut channel_rms = Vec::with_capacity(channels);
for ch in 0..channels {
let rms = (data[ch].iter().map(|x| x * x).sum::<f64>() / num_samples as f64).sqrt();
channel_rms.push(rms);
}
let mean_rms = channel_rms.iter().sum::<f64>() / channels as f64;
println!("=== rUv Neural — Simulation Complete ===");
println!();
println!(" Channels: {channels}");
println!(" Samples: {num_samples}");
println!(" Duration: {duration:.2} s");
println!(" Sample rate: {sample_rate:.1} Hz");
println!(" Mean RMS: {mean_rms:.4} fT");
println!();
// Show frequency content summary.
println!(" Frequency content:");
println!(" Alpha (8-13 Hz): 10 Hz sinusoid, 50 fT amplitude");
println!(" Beta (13-30 Hz): 20 Hz sinusoid, 30 fT amplitude");
println!(" Gamma (30-100 Hz): 40 Hz sinusoid, 15 fT amplitude");
println!(" Noise floor: ~10 fT/sqrt(Hz) white noise");
println!();
match output {
Some(ref path) => {
let json = serde_json::to_string_pretty(&ts)?;
fs::write(path, json)?;
println!(" Output written to: {path}");
}
None => {
println!(" (Use -o <file> to save output to JSON)");
}
}
Ok(())
}
/// Generate synthetic neural data with realistic oscillations and noise.
fn generate_neural_data(channels: usize, num_samples: usize, sample_rate: f64) -> Vec<Vec<f64>> {
// Use a deterministic seed based on channel index for reproducibility.
let mut data = Vec::with_capacity(channels);
for ch in 0..channels {
let mut channel_data = Vec::with_capacity(num_samples);
// Phase offsets vary by channel to simulate spatial diversity.
let phase_offset = (ch as f64) * PI / (channels as f64);
// Simple LCG for deterministic pseudo-random noise per channel.
let mut rng_state: u64 = (ch as u64).wrapping_mul(6364136223846793005).wrapping_add(1);
for i in 0..num_samples {
let t = i as f64 / sample_rate;
// Alpha rhythm: 10 Hz, 50 fT
let alpha = 50.0 * (2.0 * PI * 10.0 * t + phase_offset).sin();
// Beta rhythm: 20 Hz, 30 fT
let beta = 30.0 * (2.0 * PI * 20.0 * t + phase_offset * 1.3).sin();
// Gamma rhythm: 40 Hz, 15 fT
let gamma = 15.0 * (2.0 * PI * 40.0 * t + phase_offset * 0.7).sin();
// White noise (~10 fT/sqrt(Hz) density).
// Approximate Gaussian via Box-Muller with LCG.
rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let u1 = (rng_state >> 11) as f64 / (1u64 << 53) as f64;
rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let u2 = (rng_state >> 11) as f64 / (1u64 << 53) as f64;
let noise_amplitude = 10.0 * (sample_rate / 2.0).sqrt();
let gaussian = if u1 > 1e-15 {
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
} else {
0.0
};
let noise = noise_amplitude * gaussian / (num_samples as f64).sqrt() * 0.1;
channel_data.push(alpha + beta + gamma + noise);
}
data.push(channel_data);
}
data
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_correct_shape() {
let data = generate_neural_data(8, 500, 1000.0);
assert_eq!(data.len(), 8);
for ch in &data {
assert_eq!(ch.len(), 500);
}
}
#[test]
fn simulate_produces_output() {
let result = run(4, 1.0, 500.0, None);
assert!(result.is_ok());
}
#[test]
fn simulate_writes_json() {
let dir = std::env::temp_dir();
let path = dir.join("ruv_neural_test_sim.json");
let path_str = path.to_string_lossy().to_string();
let result = run(2, 0.5, 250.0, Some(path_str.clone()));
assert!(result.is_ok());
assert!(path.exists());
let contents = std::fs::read_to_string(&path).unwrap();
let _ts: MultiChannelTimeSeries = serde_json::from_str(&contents).unwrap();
std::fs::remove_file(&path).ok();
}
}
@@ -1,91 +0,0 @@
//! Generate and verify Ed25519-signed capability witness bundles.
use ruv_neural_core::witness::{attest_capabilities, WitnessBundle};
use std::path::PathBuf;
/// Run the witness command.
pub fn run(
output: Option<PathBuf>,
verify: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(path) = verify {
// Verify mode
let json = std::fs::read_to_string(&path)?;
let bundle: WitnessBundle = serde_json::from_str(&json)?;
println!("=== rUv Neural \u{2014} Witness Verification ===\n");
println!(" Version: {}", bundle.version);
println!(" Commit: {}", bundle.commit);
println!(
" Tests: {}/{} passed",
bundle.tests_passed, bundle.total_tests
);
println!(" Caps: {} attestations", bundle.capabilities.len());
println!(
" Public Key: {}...{}",
&bundle.public_key[..8],
&bundle.public_key[bundle.public_key.len() - 8..]
);
println!();
// Verify digest
let digest_ok = bundle.verify_digest();
println!(
" Digest integrity: {}",
if digest_ok { "PASS" } else { "FAIL" }
);
// Verify signature
match bundle.verify() {
Ok(true) => println!(" Ed25519 signature: PASS"),
Ok(false) => println!(" Ed25519 signature: FAIL"),
Err(e) => println!(" Ed25519 signature: ERROR ({e})"),
}
let verdict = match bundle.verify_full() {
Ok(true) => "PASS",
_ => "FAIL",
};
println!("\n VERDICT: {verdict}");
if verdict == "FAIL" {
std::process::exit(1);
}
} else {
// Generate mode
let caps = attest_capabilities();
let bundle = WitnessBundle::new(
env!("CARGO_PKG_VERSION"),
"0.1.0",
333,
333,
0,
caps,
);
let json = serde_json::to_string_pretty(&bundle)?;
if let Some(path) = output {
std::fs::write(&path, &json)?;
println!("Witness bundle written to {}", path.display());
} else {
println!("{json}");
}
println!("\n Attestations: {}", bundle.capabilities.len());
println!(" Digest: {}", bundle.capabilities_digest);
println!(
" Signature: {}...{}",
&bundle.signature[..16],
&bundle.signature[bundle.signature.len() - 16..]
);
println!(
" Public Key: {}...{}",
&bundle.public_key[..8],
&bundle.public_key[bundle.public_key.len() - 8..]
);
println!("\n VERDICT: SIGNED");
}
Ok(())
}
@@ -1,301 +0,0 @@
//! rUv Neural CLI — Brain topology analysis, simulation, and visualization.
mod commands;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "ruv-neural")]
#[command(about = "rUv Neural — Brain Topology Analysis System")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
/// Verbosity level
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
}
#[derive(Subcommand)]
enum Commands {
/// Simulate neural sensor data
Simulate {
/// Number of channels
#[arg(short, long, default_value = "64")]
channels: usize,
/// Duration in seconds
#[arg(short, long, default_value = "10.0")]
duration: f64,
/// Sample rate in Hz
#[arg(short, long, default_value = "1000.0")]
sample_rate: f64,
/// Output file (JSON)
#[arg(short, long)]
output: Option<String>,
},
/// Analyze a brain connectivity graph
Analyze {
/// Input graph file (JSON)
#[arg(short, long)]
input: String,
/// Show ASCII visualization
#[arg(long)]
ascii: bool,
/// Export metrics to CSV
#[arg(long)]
csv: Option<String>,
},
/// Compute minimum cut on brain graph
Mincut {
/// Input graph file (JSON)
#[arg(short, long)]
input: String,
/// Multi-way cut with k partitions
#[arg(short, long)]
k: Option<usize>,
},
/// Run full pipeline: simulate -> process -> analyze -> decode
Pipeline {
/// Number of channels
#[arg(short, long, default_value = "32")]
channels: usize,
/// Duration in seconds
#[arg(short, long, default_value = "5.0")]
duration: f64,
/// Show real-time ASCII dashboard
#[arg(long)]
dashboard: bool,
},
/// Export brain graph to visualization format
Export {
/// Input graph file (JSON)
#[arg(short, long)]
input: String,
/// Output format: d3, dot, gexf, csv, rvf
#[arg(short, long, default_value = "d3")]
format: String,
/// Output file
#[arg(short, long)]
output: String,
},
/// Show system info and capabilities
Info,
/// Generate or verify Ed25519-signed capability witness bundles
Witness {
/// Output file path for generated witness bundle (JSON)
#[arg(short, long)]
output: Option<String>,
/// Path to a witness bundle to verify
#[arg(long)]
verify: Option<String>,
},
}
fn init_tracing(verbose: u8) {
let level = match verbose {
0 => tracing::Level::WARN,
1 => tracing::Level::INFO,
2 => tracing::Level::DEBUG,
_ => tracing::Level::TRACE,
};
tracing_subscriber::fmt()
.with_max_level(level)
.with_target(false)
.init();
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
init_tracing(cli.verbose);
let result = match cli.command {
Commands::Simulate {
channels,
duration,
sample_rate,
output,
} => commands::simulate::run(channels, duration, sample_rate, output),
Commands::Analyze { input, ascii, csv } => commands::analyze::run(&input, ascii, csv),
Commands::Mincut { input, k } => commands::mincut::run(&input, k),
Commands::Pipeline {
channels,
duration,
dashboard,
} => commands::pipeline::run(channels, duration, dashboard),
Commands::Export {
input,
format,
output,
} => commands::export::run(&input, &format, &output),
Commands::Info => {
commands::info::run();
Ok(())
}
Commands::Witness { output, verify } => {
commands::witness::run(
output.map(std::path::PathBuf::from),
verify.map(std::path::PathBuf::from),
)
}
};
if let Err(e) = result {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn verify_cli() {
Cli::command().debug_assert();
}
#[test]
fn parse_simulate_defaults() {
let cli = Cli::try_parse_from(["ruv-neural", "simulate"]).unwrap();
match cli.command {
Commands::Simulate {
channels,
duration,
sample_rate,
output,
} => {
assert_eq!(channels, 64);
assert!((duration - 10.0).abs() < 1e-9);
assert!((sample_rate - 1000.0).abs() < 1e-9);
assert!(output.is_none());
}
_ => panic!("Expected Simulate command"),
}
}
#[test]
fn parse_simulate_with_args() {
let cli = Cli::try_parse_from([
"ruv-neural",
"simulate",
"-c",
"32",
"-d",
"5.0",
"-s",
"500.0",
"-o",
"out.json",
])
.unwrap();
match cli.command {
Commands::Simulate {
channels,
duration,
sample_rate,
output,
} => {
assert_eq!(channels, 32);
assert!((duration - 5.0).abs() < 1e-9);
assert!((sample_rate - 500.0).abs() < 1e-9);
assert_eq!(output.as_deref(), Some("out.json"));
}
_ => panic!("Expected Simulate command"),
}
}
#[test]
fn parse_analyze() {
let cli =
Cli::try_parse_from(["ruv-neural", "analyze", "-i", "graph.json", "--ascii"]).unwrap();
match cli.command {
Commands::Analyze { input, ascii, csv } => {
assert_eq!(input, "graph.json");
assert!(ascii);
assert!(csv.is_none());
}
_ => panic!("Expected Analyze command"),
}
}
#[test]
fn parse_mincut() {
let cli = Cli::try_parse_from(["ruv-neural", "mincut", "-i", "graph.json", "-k", "4"])
.unwrap();
match cli.command {
Commands::Mincut { input, k } => {
assert_eq!(input, "graph.json");
assert_eq!(k, Some(4));
}
_ => panic!("Expected Mincut command"),
}
}
#[test]
fn parse_pipeline() {
let cli = Cli::try_parse_from([
"ruv-neural",
"pipeline",
"-c",
"16",
"-d",
"3.0",
"--dashboard",
])
.unwrap();
match cli.command {
Commands::Pipeline {
channels,
duration,
dashboard,
} => {
assert_eq!(channels, 16);
assert!((duration - 3.0).abs() < 1e-9);
assert!(dashboard);
}
_ => panic!("Expected Pipeline command"),
}
}
#[test]
fn parse_export() {
let cli = Cli::try_parse_from([
"ruv-neural",
"export",
"-i",
"graph.json",
"-f",
"dot",
"-o",
"out.dot",
])
.unwrap();
match cli.command {
Commands::Export {
input,
format,
output,
} => {
assert_eq!(input, "graph.json");
assert_eq!(format, "dot");
assert_eq!(output, "out.dot");
}
_ => panic!("Expected Export command"),
}
}
#[test]
fn parse_info() {
let cli = Cli::try_parse_from(["ruv-neural", "info"]).unwrap();
assert!(matches!(cli.command, Commands::Info));
}
#[test]
fn parse_verbose() {
let cli = Cli::try_parse_from(["ruv-neural", "-vvv", "info"]).unwrap();
assert_eq!(cli.verbose, 3);
}
}
@@ -1,25 +0,0 @@
[package]
name = "ruv-neural-core"
description = "rUv Neural — Core types, traits, and error types for brain topology analysis"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
keywords = ["neural", "brain", "topology", "types", "core"]
[features]
default = ["std"]
std = []
no_std = [] # For ESP32/embedded targets
wasm = [] # For WASM targets
rvf = [] # RuVector RVF format support
[dependencies]
thiserror = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
num-traits = { workspace = true }
ed25519-dalek = { workspace = true }
sha2 = { workspace = true }
rand = { workspace = true }
@@ -1,102 +0,0 @@
# ruv-neural-core
Core types, traits, and error types for the rUv Neural brain topology analysis system.
## Overview
`ruv-neural-core` is the foundation crate of the rUv Neural workspace. It defines all
shared data types, trait interfaces, and the RVF binary file format used across the
other eleven crates. This crate has **zero** internal dependencies -- every other
ruv-neural crate depends on it.
## Features
- **Sensor types**: `SensorType`, `SensorChannel`, `SensorArray` with sensitivity specs
for NV diamond, OPM, SQUID MEG, and EEG sensors
- **Signal types**: `MultiChannelTimeSeries`, `FrequencyBand` (delta through gamma + custom),
`SpectralFeatures`, `TimeFrequencyMap`
- **Brain atlas**: `Atlas` (Desikan-Killiany 68, Destrieux 148, Schaefer 100/200/400, custom),
`BrainRegion`, `Parcellation` with hemisphere and lobe queries
- **Graph types**: `BrainGraph` with adjacency matrix, density, and degree methods;
`BrainEdge`, `ConnectivityMetric`, `BrainGraphSequence`
- **Topology types**: `MincutResult`, `MultiPartition`, `TopologyMetrics`, `CognitiveState`,
`SleepStage`
- **Embedding types**: `NeuralEmbedding` with cosine similarity and Euclidean distance,
`EmbeddingTrajectory`, `EmbeddingMetadata`
- **RVF format**: Binary RuVector File format with magic bytes, versioned headers,
typed payloads, and read/write round-trip support
- **Trait definitions**: `SensorSource`, `SignalProcessor`, `GraphConstructor`,
`TopologyAnalyzer`, `EmbeddingGenerator`, `NeuralMemory`, `StateDecoder`,
`RvfSerializable`
- **Error handling**: `RuvNeuralError` enum with `DimensionMismatch`, `ChannelOutOfRange`,
`InsufficientData`, and domain-specific variants
- **Feature flags**: `std` (default), `no_std` (ESP32/embedded), `wasm`, `rvf`
## Usage
```rust
use ruv_neural_core::{
BrainGraph, BrainEdge, ConnectivityMetric, FrequencyBand, Atlas,
NeuralEmbedding, EmbeddingMetadata, CognitiveState,
MultiChannelTimeSeries, RvfFile, RvfDataType,
};
// Create a brain graph
let graph = BrainGraph {
num_nodes: 3,
edges: vec![BrainEdge {
source: 0, target: 1, weight: 0.8,
metric: ConnectivityMetric::PhaseLockingValue,
frequency_band: FrequencyBand::Alpha,
}],
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::DesikanKilliany68,
};
let matrix = graph.adjacency_matrix();
let density = graph.density();
// Create a neural embedding
let meta = EmbeddingMetadata {
subject_id: Some("sub-01".into()),
session_id: None,
cognitive_state: Some(CognitiveState::Focused),
source_atlas: Atlas::Schaefer100,
embedding_method: "spectral".into(),
};
let emb = NeuralEmbedding::new(vec![3.0, 4.0], 1000.0, meta).unwrap();
assert_eq!(emb.dimension, 2);
assert!((emb.norm() - 5.0).abs() < 1e-10);
// Write/read RVF files
let mut rvf = RvfFile::new(RvfDataType::BrainGraph);
rvf.data = serde_json::to_vec(&graph).unwrap();
let mut buf = Vec::new();
rvf.write_to(&mut buf).unwrap();
```
## API Reference
| Module | Key Types |
|-------------|----------------------------------------------------------------|
| `sensor` | `SensorType`, `SensorChannel`, `SensorArray` |
| `signal` | `MultiChannelTimeSeries`, `FrequencyBand`, `SpectralFeatures` |
| `brain` | `Atlas`, `BrainRegion`, `Parcellation`, `Hemisphere`, `Lobe` |
| `graph` | `BrainGraph`, `BrainEdge`, `ConnectivityMetric` |
| `topology` | `MincutResult`, `TopologyMetrics`, `CognitiveState` |
| `embedding` | `NeuralEmbedding`, `EmbeddingTrajectory`, `EmbeddingMetadata` |
| `rvf` | `RvfFile`, `RvfHeader`, `RvfDataType` |
| `traits` | `SensorSource`, `SignalProcessor`, `EmbeddingGenerator`, etc. |
| `error` | `RuvNeuralError`, `Result<T>` |
## Integration
This crate is a dependency of every other crate in the ruv-neural workspace.
It provides the shared type vocabulary that allows crates to interoperate --
for example, `ruv-neural-signal` produces `MultiChannelTimeSeries` values,
`ruv-neural-graph` consumes them, and `ruv-neural-embed` outputs
`NeuralEmbedding` values that `ruv-neural-memory` stores.
## License
MIT OR Apache-2.0
@@ -1,103 +0,0 @@
//! Brain region and atlas types for parcellation.
use serde::{Deserialize, Serialize};
/// Brain atlas defining a parcellation scheme.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Atlas {
/// Desikan-Killiany atlas (68 cortical regions).
DesikanKilliany68,
/// Destrieux atlas (148 cortical regions).
Destrieux148,
/// Schaefer 100-parcel atlas.
Schaefer100,
/// Schaefer 200-parcel atlas.
Schaefer200,
/// Schaefer 400-parcel atlas.
Schaefer400,
/// Custom atlas with a specified number of regions.
Custom(usize),
}
impl Atlas {
/// Number of regions in this atlas.
pub fn num_regions(&self) -> usize {
match self {
Atlas::DesikanKilliany68 => 68,
Atlas::Destrieux148 => 148,
Atlas::Schaefer100 => 100,
Atlas::Schaefer200 => 200,
Atlas::Schaefer400 => 400,
Atlas::Custom(n) => *n,
}
}
}
/// Cerebral hemisphere.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Hemisphere {
Left,
Right,
Midline,
}
/// Brain lobe classification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Lobe {
Frontal,
Parietal,
Temporal,
Occipital,
Limbic,
Subcortical,
Cerebellar,
}
/// A single brain region (parcel) within an atlas.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrainRegion {
/// Region index within the atlas.
pub id: usize,
/// Human-readable name (e.g., "superiorfrontal").
pub name: String,
/// Hemisphere.
pub hemisphere: Hemisphere,
/// Lobe classification.
pub lobe: Lobe,
/// Centroid in MNI coordinates (x, y, z in mm).
pub centroid: [f64; 3],
}
/// A full brain parcellation (atlas + all regions).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Parcellation {
/// Atlas used.
pub atlas: Atlas,
/// All regions in the parcellation.
pub regions: Vec<BrainRegion>,
}
impl Parcellation {
/// Number of regions.
pub fn num_regions(&self) -> usize {
self.regions.len()
}
/// Get a region by its id.
pub fn get_region(&self, id: usize) -> Option<&BrainRegion> {
self.regions.iter().find(|r| r.id == id)
}
/// Get all regions in a given hemisphere.
pub fn regions_in_hemisphere(&self, hemisphere: Hemisphere) -> Vec<&BrainRegion> {
self.regions
.iter()
.filter(|r| r.hemisphere == hemisphere)
.collect()
}
/// Get all regions in a given lobe.
pub fn regions_in_lobe(&self, lobe: Lobe) -> Vec<&BrainRegion> {
self.regions.iter().filter(|r| r.lobe == lobe).collect()
}
}
@@ -1,126 +0,0 @@
//! Vector embedding types for neural state representations.
use serde::{Deserialize, Serialize};
use crate::brain::Atlas;
use crate::error::{Result, RuvNeuralError};
use crate::topology::CognitiveState;
/// Neural state embedding vector.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NeuralEmbedding {
/// The embedding vector.
pub vector: Vec<f64>,
/// Dimensionality of the embedding.
pub dimension: usize,
/// Timestamp (Unix time).
pub timestamp: f64,
/// Associated metadata.
pub metadata: EmbeddingMetadata,
}
impl NeuralEmbedding {
/// Create a new embedding, validating dimension consistency.
pub fn new(vector: Vec<f64>, timestamp: f64, metadata: EmbeddingMetadata) -> Result<Self> {
let dimension = vector.len();
if dimension == 0 {
return Err(RuvNeuralError::Embedding(
"Embedding vector must not be empty".into(),
));
}
Ok(Self {
vector,
dimension,
timestamp,
metadata,
})
}
/// L2 norm of the embedding vector.
pub fn norm(&self) -> f64 {
self.vector.iter().map(|x| x * x).sum::<f64>().sqrt()
}
/// Cosine similarity to another embedding.
pub fn cosine_similarity(&self, other: &NeuralEmbedding) -> Result<f64> {
if self.dimension != other.dimension {
return Err(RuvNeuralError::DimensionMismatch {
expected: self.dimension,
got: other.dimension,
});
}
let dot: f64 = self
.vector
.iter()
.zip(other.vector.iter())
.map(|(a, b)| a * b)
.sum();
let norm_a = self.norm();
let norm_b = other.norm();
if norm_a == 0.0 || norm_b == 0.0 {
return Ok(0.0);
}
Ok(dot / (norm_a * norm_b))
}
/// Euclidean distance to another embedding.
pub fn euclidean_distance(&self, other: &NeuralEmbedding) -> Result<f64> {
if self.dimension != other.dimension {
return Err(RuvNeuralError::DimensionMismatch {
expected: self.dimension,
got: other.dimension,
});
}
let sum_sq: f64 = self
.vector
.iter()
.zip(other.vector.iter())
.map(|(a, b)| (a - b) * (a - b))
.sum();
Ok(sum_sq.sqrt())
}
}
/// Metadata associated with a neural embedding.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingMetadata {
/// Subject identifier.
pub subject_id: Option<String>,
/// Session identifier.
pub session_id: Option<String>,
/// Decoded cognitive state (if available).
pub cognitive_state: Option<CognitiveState>,
/// Atlas used for the source graph.
pub source_atlas: Atlas,
/// Name of the embedding method (e.g., "spectral", "node2vec").
pub embedding_method: String,
}
/// Temporal sequence of embeddings (trajectory through embedding space).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingTrajectory {
/// Ordered sequence of embeddings.
pub embeddings: Vec<NeuralEmbedding>,
/// Timestamps for each embedding.
pub timestamps: Vec<f64>,
}
impl EmbeddingTrajectory {
/// Number of time points.
pub fn len(&self) -> usize {
self.embeddings.len()
}
/// Returns true if the trajectory is empty.
pub fn is_empty(&self) -> bool {
self.embeddings.is_empty()
}
/// Total duration in seconds.
pub fn duration_s(&self) -> f64 {
if self.timestamps.len() < 2 {
return 0.0;
}
self.timestamps.last().unwrap() - self.timestamps.first().unwrap()
}
}
@@ -1,46 +0,0 @@
//! Error types for the ruv-neural pipeline.
use thiserror::Error;
/// Top-level error type for the ruv-neural system.
#[derive(Error, Debug)]
pub enum RuvNeuralError {
#[error("Sensor error: {0}")]
Sensor(String),
#[error("Signal processing error: {0}")]
Signal(String),
#[error("Graph construction error: {0}")]
Graph(String),
#[error("Mincut computation error: {0}")]
Mincut(String),
#[error("Embedding error: {0}")]
Embedding(String),
#[error("Memory error: {0}")]
Memory(String),
#[error("Decoder error: {0}")]
Decoder(String),
#[error("Serialization error: {0}")]
Serialization(String),
#[error("Invalid configuration: {0}")]
Config(String),
#[error("Dimension mismatch: expected {expected}, got {got}")]
DimensionMismatch { expected: usize, got: usize },
#[error("Channel {channel} out of range (max {max})")]
ChannelOutOfRange { channel: usize, max: usize },
#[error("Insufficient data: need {needed} samples, have {have}")]
InsufficientData { needed: usize, have: usize },
}
/// Convenience result type for the ruv-neural system.
pub type Result<T> = std::result::Result<T, RuvNeuralError>;
@@ -1,171 +0,0 @@
//! Brain connectivity graph types.
use serde::{Deserialize, Serialize};
use crate::brain::Atlas;
use crate::error::{Result, RuvNeuralError};
use crate::signal::FrequencyBand;
/// Connectivity metric used to compute edge weights.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ConnectivityMetric {
/// Phase locking value.
PhaseLockingValue,
/// Amplitude envelope correlation.
AmplitudeEnvelopeCorrelation,
/// Weighted phase lag index.
WeightedPhaseLagIndex,
/// Coherence.
Coherence,
/// Granger causality.
GrangerCausality,
/// Transfer entropy.
TransferEntropy,
/// Mutual information.
MutualInformation,
}
/// An edge in the brain connectivity graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrainEdge {
/// Source node index.
pub source: usize,
/// Target node index.
pub target: usize,
/// Edge weight (connectivity strength).
pub weight: f64,
/// Metric used to compute this edge.
pub metric: ConnectivityMetric,
/// Frequency band for this connectivity estimate.
pub frequency_band: FrequencyBand,
}
/// Brain connectivity graph at a single time window.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrainGraph {
/// Number of nodes (brain regions).
pub num_nodes: usize,
/// Edges with connectivity weights.
pub edges: Vec<BrainEdge>,
/// Timestamp of this graph window (Unix time).
pub timestamp: f64,
/// Duration of the analysis window in seconds.
pub window_duration_s: f64,
/// Atlas used for parcellation.
pub atlas: Atlas,
}
impl BrainGraph {
/// Validate graph integrity: edge bounds, weight finiteness, no self-loops.
pub fn validate(&self) -> Result<()> {
for (i, edge) in self.edges.iter().enumerate() {
if edge.source >= self.num_nodes {
return Err(RuvNeuralError::Graph(format!(
"Edge {i}: source {} out of bounds (num_nodes={})",
edge.source, self.num_nodes
)));
}
if edge.target >= self.num_nodes {
return Err(RuvNeuralError::Graph(format!(
"Edge {i}: target {} out of bounds (num_nodes={})",
edge.target, self.num_nodes
)));
}
if edge.source == edge.target {
return Err(RuvNeuralError::Graph(format!(
"Edge {i}: self-loop on node {}",
edge.source
)));
}
if !edge.weight.is_finite() {
return Err(RuvNeuralError::Graph(format!(
"Edge {i}: non-finite weight {}",
edge.weight
)));
}
}
Ok(())
}
/// Build a dense adjacency matrix (num_nodes x num_nodes).
/// For duplicate edges, the last one wins.
pub fn adjacency_matrix(&self) -> Vec<Vec<f64>> {
let n = self.num_nodes;
let mut mat = vec![vec![0.0; n]; n];
for edge in &self.edges {
if edge.source < n && edge.target < n {
mat[edge.source][edge.target] = edge.weight;
mat[edge.target][edge.source] = edge.weight;
}
}
mat
}
/// Get the weight of the edge between source and target, if it exists.
pub fn edge_weight(&self, source: usize, target: usize) -> Option<f64> {
self.edges
.iter()
.find(|e| {
(e.source == source && e.target == target)
|| (e.source == target && e.target == source)
})
.map(|e| e.weight)
}
/// Weighted degree of a node (sum of incident edge weights).
pub fn node_degree(&self, node: usize) -> f64 {
self.edges
.iter()
.filter(|e| e.source == node || e.target == node)
.map(|e| e.weight)
.sum()
}
/// Graph density: ratio of actual edges to possible edges.
pub fn density(&self) -> f64 {
if self.num_nodes < 2 {
return 0.0;
}
let max_edges = self.num_nodes * (self.num_nodes - 1) / 2;
if max_edges == 0 {
return 0.0;
}
self.edges.len() as f64 / max_edges as f64
}
/// Total weight of all edges.
pub fn total_weight(&self) -> f64 {
self.edges.iter().map(|e| e.weight).sum()
}
}
/// Temporal sequence of brain graphs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrainGraphSequence {
/// Ordered sequence of graphs.
pub graphs: Vec<BrainGraph>,
/// Step between successive windows in seconds.
pub window_step_s: f64,
}
impl BrainGraphSequence {
/// Number of time points.
pub fn len(&self) -> usize {
self.graphs.len()
}
/// Returns true if the sequence is empty.
pub fn is_empty(&self) -> bool {
self.graphs.is_empty()
}
/// Total duration covered by the sequence in seconds.
pub fn duration_s(&self) -> f64 {
if self.graphs.is_empty() {
return 0.0;
}
let first = self.graphs.first().unwrap();
let last = self.graphs.last().unwrap();
(last.timestamp - first.timestamp) + last.window_duration_s
}
}
@@ -1,646 +0,0 @@
//! # ruv-neural-core
//!
//! Core types, traits, and error types for the ruv-neural brain topology
//! analysis system.
//!
//! This crate is the foundation of the ruv-neural workspace. It has **zero**
//! internal dependencies — all other ruv-neural crates depend on this one.
//!
//! ## Modules
//!
//! | Module | Contents |
//! |-------------|---------------------------------------------------|
//! | `error` | `RuvNeuralError` enum, `Result<T>` alias |
//! | `sensor` | `SensorType`, `SensorChannel`, `SensorArray` |
//! | `signal` | `MultiChannelTimeSeries`, `FrequencyBand`, spectra |
//! | `brain` | `Atlas`, `BrainRegion`, `Parcellation` |
//! | `graph` | `BrainGraph`, `BrainEdge`, `ConnectivityMetric` |
//! | `topology` | `MincutResult`, `CognitiveState`, `TopologyMetrics`|
//! | `embedding` | `NeuralEmbedding`, `EmbeddingTrajectory` |
//! | `rvf` | RuVector File format header and I/O |
//! | `traits` | Pipeline trait definitions for all crates |
pub mod brain;
pub mod embedding;
pub mod error;
pub mod graph;
pub mod rvf;
pub mod sensor;
pub mod signal;
pub mod topology;
pub mod traits;
pub mod witness;
// Re-export the most commonly used types at crate root.
pub use brain::{Atlas, BrainRegion, Hemisphere, Lobe, Parcellation};
pub use embedding::{EmbeddingMetadata, EmbeddingTrajectory, NeuralEmbedding};
pub use error::{Result, RuvNeuralError};
pub use graph::{BrainEdge, BrainGraph, BrainGraphSequence, ConnectivityMetric};
pub use rvf::{RvfDataType, RvfFile, RvfHeader};
pub use sensor::{SensorArray, SensorChannel, SensorType};
pub use signal::{FrequencyBand, MultiChannelTimeSeries, SpectralFeatures, TimeFrequencyMap};
pub use topology::{
CognitiveState, MincutResult, MultiPartition, SleepStage, TopologyMetrics,
};
pub use traits::{
EmbeddingGenerator, GraphConstructor, NeuralMemory, RvfSerializable, SensorSource,
SignalProcessor, StateDecoder, TopologyAnalyzer,
};
#[cfg(test)]
mod tests {
use super::*;
// ── Error tests ─────────────────────────────────────────────────
#[test]
fn error_display_formatting() {
let err = RuvNeuralError::Sensor("calibration failed".into());
assert!(err.to_string().contains("Sensor error"));
assert!(err.to_string().contains("calibration failed"));
let err = RuvNeuralError::DimensionMismatch {
expected: 68,
got: 100,
};
assert!(err.to_string().contains("68"));
assert!(err.to_string().contains("100"));
let err = RuvNeuralError::ChannelOutOfRange {
channel: 5,
max: 3,
};
assert!(err.to_string().contains("5"));
assert!(err.to_string().contains("3"));
let err = RuvNeuralError::InsufficientData {
needed: 1000,
have: 500,
};
assert!(err.to_string().contains("1000"));
assert!(err.to_string().contains("500"));
}
// ── Sensor tests ────────────────────────────────────────────────
#[test]
fn sensor_type_sensitivity() {
assert!(SensorType::SquidMeg.typical_sensitivity_ft_sqrt_hz() < 5.0);
assert!(SensorType::Eeg.typical_sensitivity_ft_sqrt_hz() > 100.0);
}
#[test]
fn sensor_array_operations() {
let array = SensorArray {
channels: vec![
SensorChannel {
id: 0,
sensor_type: SensorType::Opm,
position: [0.0, 0.0, 0.1],
orientation: [0.0, 0.0, 1.0],
sensitivity_ft_sqrt_hz: 7.0,
sample_rate_hz: 1000.0,
label: "OPM-001".into(),
},
SensorChannel {
id: 1,
sensor_type: SensorType::Opm,
position: [0.05, 0.0, 0.12],
orientation: [0.0, 0.0, 1.0],
sensitivity_ft_sqrt_hz: 7.0,
sample_rate_hz: 1000.0,
label: "OPM-002".into(),
},
],
sensor_type: SensorType::Opm,
name: "OPM array".into(),
};
assert_eq!(array.num_channels(), 2);
assert!(!array.is_empty());
assert_eq!(array.get_channel(0).unwrap().label, "OPM-001");
assert!(array.get_channel(5).is_none());
let (min, max) = array.bounding_box().unwrap();
assert_eq!(min[0], 0.0);
assert_eq!(max[0], 0.05);
}
#[test]
fn sensor_serialize_roundtrip() {
let ch = SensorChannel {
id: 0,
sensor_type: SensorType::NvDiamond,
position: [1.0, 2.0, 3.0],
orientation: [0.0, 0.0, 1.0],
sensitivity_ft_sqrt_hz: 10.0,
sample_rate_hz: 2000.0,
label: "NV-001".into(),
};
let json = serde_json::to_string(&ch).unwrap();
let ch2: SensorChannel = serde_json::from_str(&json).unwrap();
assert_eq!(ch2.id, 0);
assert_eq!(ch2.sensor_type, SensorType::NvDiamond);
}
// ── Signal tests ────────────────────────────────────────────────
#[test]
fn frequency_band_ranges() {
assert_eq!(FrequencyBand::Delta.range_hz(), (1.0, 4.0));
assert_eq!(FrequencyBand::Alpha.range_hz(), (8.0, 13.0));
assert_eq!(FrequencyBand::Gamma.range_hz(), (30.0, 100.0));
assert_eq!(
FrequencyBand::Custom {
low_hz: 50.0,
high_hz: 70.0
}
.range_hz(),
(50.0, 70.0)
);
}
#[test]
fn frequency_band_center_and_bandwidth() {
assert!((FrequencyBand::Alpha.center_hz() - 10.5).abs() < 1e-10);
assert!((FrequencyBand::Alpha.bandwidth_hz() - 5.0).abs() < 1e-10);
}
#[test]
fn time_series_creation_valid() {
let data = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
let ts = MultiChannelTimeSeries::new(data, 100.0, 1000.0).unwrap();
assert_eq!(ts.num_channels, 2);
assert_eq!(ts.num_samples, 3);
assert!((ts.duration_s() - 0.03).abs() < 1e-10);
}
#[test]
fn time_series_dimension_mismatch() {
let data = vec![vec![1.0, 2.0], vec![3.0]];
let result = MultiChannelTimeSeries::new(data, 100.0, 0.0);
assert!(result.is_err());
}
#[test]
fn time_series_channel_access() {
let data = vec![vec![10.0, 20.0], vec![30.0, 40.0]];
let ts = MultiChannelTimeSeries::new(data, 100.0, 0.0).unwrap();
assert_eq!(ts.channel(0).unwrap(), &[10.0, 20.0]);
assert!(ts.channel(5).is_err());
}
// ── Brain / Atlas tests ─────────────────────────────────────────
#[test]
fn atlas_region_counts() {
assert_eq!(Atlas::DesikanKilliany68.num_regions(), 68);
assert_eq!(Atlas::Destrieux148.num_regions(), 148);
assert_eq!(Atlas::Schaefer100.num_regions(), 100);
assert_eq!(Atlas::Schaefer200.num_regions(), 200);
assert_eq!(Atlas::Schaefer400.num_regions(), 400);
assert_eq!(Atlas::Custom(42).num_regions(), 42);
}
#[test]
fn parcellation_query() {
let parcellation = Parcellation {
atlas: Atlas::Custom(3),
regions: vec![
BrainRegion {
id: 0,
name: "left_frontal".into(),
hemisphere: Hemisphere::Left,
lobe: Lobe::Frontal,
centroid: [-30.0, 20.0, 40.0],
},
BrainRegion {
id: 1,
name: "right_frontal".into(),
hemisphere: Hemisphere::Right,
lobe: Lobe::Frontal,
centroid: [30.0, 20.0, 40.0],
},
BrainRegion {
id: 2,
name: "left_temporal".into(),
hemisphere: Hemisphere::Left,
lobe: Lobe::Temporal,
centroid: [-50.0, -10.0, 0.0],
},
],
};
assert_eq!(parcellation.num_regions(), 3);
assert_eq!(
parcellation.regions_in_hemisphere(Hemisphere::Left).len(),
2
);
assert_eq!(parcellation.regions_in_lobe(Lobe::Frontal).len(), 2);
assert_eq!(parcellation.regions_in_lobe(Lobe::Temporal).len(), 1);
assert!(parcellation.get_region(1).is_some());
assert!(parcellation.get_region(99).is_none());
}
#[test]
fn brain_region_serialize_roundtrip() {
let region = BrainRegion {
id: 42,
name: "postcentral".into(),
hemisphere: Hemisphere::Left,
lobe: Lobe::Parietal,
centroid: [-40.0, -25.0, 55.0],
};
let json = serde_json::to_string(&region).unwrap();
let r2: BrainRegion = serde_json::from_str(&json).unwrap();
assert_eq!(r2.id, 42);
assert_eq!(r2.hemisphere, Hemisphere::Left);
}
// ── Graph tests ─────────────────────────────────────────────────
#[test]
fn brain_graph_adjacency_matrix() {
let graph = BrainGraph {
num_nodes: 3,
edges: vec![
BrainEdge {
source: 0,
target: 1,
weight: 0.8,
metric: ConnectivityMetric::PhaseLockingValue,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 1,
target: 2,
weight: 0.5,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Beta,
},
],
timestamp: 100.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(3),
};
let mat = graph.adjacency_matrix();
assert_eq!(mat.len(), 3);
assert!((mat[0][1] - 0.8).abs() < 1e-10);
assert!((mat[1][0] - 0.8).abs() < 1e-10);
assert!((mat[1][2] - 0.5).abs() < 1e-10);
assert!((mat[0][2] - 0.0).abs() < 1e-10);
}
#[test]
fn brain_graph_edge_weight_lookup() {
let graph = BrainGraph {
num_nodes: 2,
edges: vec![BrainEdge {
source: 0,
target: 1,
weight: 0.9,
metric: ConnectivityMetric::MutualInformation,
frequency_band: FrequencyBand::Gamma,
}],
timestamp: 0.0,
window_duration_s: 0.5,
atlas: Atlas::Custom(2),
};
assert!((graph.edge_weight(0, 1).unwrap() - 0.9).abs() < 1e-10);
assert!((graph.edge_weight(1, 0).unwrap() - 0.9).abs() < 1e-10);
assert!(graph.edge_weight(0, 0).is_none());
}
#[test]
fn brain_graph_node_degree() {
let graph = BrainGraph {
num_nodes: 3,
edges: vec![
BrainEdge {
source: 0,
target: 1,
weight: 0.3,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 0,
target: 2,
weight: 0.7,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
],
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(3),
};
assert!((graph.node_degree(0) - 1.0).abs() < 1e-10);
assert!((graph.node_degree(1) - 0.3).abs() < 1e-10);
assert!((graph.node_degree(2) - 0.7).abs() < 1e-10);
}
#[test]
fn brain_graph_density() {
let graph = BrainGraph {
num_nodes: 4,
edges: vec![
BrainEdge {
source: 0,
target: 1,
weight: 1.0,
metric: ConnectivityMetric::PhaseLockingValue,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 2,
target: 3,
weight: 1.0,
metric: ConnectivityMetric::PhaseLockingValue,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 0,
target: 3,
weight: 1.0,
metric: ConnectivityMetric::PhaseLockingValue,
frequency_band: FrequencyBand::Alpha,
},
],
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(4),
};
assert!((graph.density() - 0.5).abs() < 1e-10);
}
#[test]
fn graph_sequence_duration() {
let seq = BrainGraphSequence {
graphs: vec![
BrainGraph {
num_nodes: 2,
edges: vec![],
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(2),
},
BrainGraph {
num_nodes: 2,
edges: vec![],
timestamp: 0.5,
window_duration_s: 1.0,
atlas: Atlas::Custom(2),
},
BrainGraph {
num_nodes: 2,
edges: vec![],
timestamp: 1.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(2),
},
],
window_step_s: 0.5,
};
assert_eq!(seq.len(), 3);
assert!(!seq.is_empty());
assert!((seq.duration_s() - 2.0).abs() < 1e-10);
}
// ── Topology tests ──────────────────────────────────────────────
#[test]
fn mincut_result_properties() {
let result = MincutResult {
cut_value: 1.5,
partition_a: vec![0, 1],
partition_b: vec![2, 3, 4],
cut_edges: vec![(1, 2, 0.8), (0, 3, 0.7)],
timestamp: 100.0,
};
assert_eq!(result.num_nodes(), 5);
assert_eq!(result.num_cut_edges(), 2);
assert!((result.balance_ratio() - 2.0 / 3.0).abs() < 1e-10);
}
#[test]
fn multi_partition_properties() {
let mp = MultiPartition {
partitions: vec![vec![0, 1], vec![2, 3], vec![4]],
cut_value: 2.0,
modularity: 0.4,
};
assert_eq!(mp.num_partitions(), 3);
assert_eq!(mp.num_nodes(), 5);
}
#[test]
fn cognitive_state_serialize_roundtrip() {
let states = vec![
CognitiveState::Rest,
CognitiveState::Focused,
CognitiveState::Sleep(SleepStage::Rem),
CognitiveState::Unknown,
];
let json = serde_json::to_string(&states).unwrap();
let deserialized: Vec<CognitiveState> = serde_json::from_str(&json).unwrap();
assert_eq!(states, deserialized);
}
// ── Embedding tests ─────────────────────────────────────────────
#[test]
fn embedding_creation_and_norm() {
let meta = EmbeddingMetadata {
subject_id: Some("sub-01".into()),
session_id: Some("ses-01".into()),
cognitive_state: Some(CognitiveState::Focused),
source_atlas: Atlas::Schaefer100,
embedding_method: "spectral".into(),
};
let emb = NeuralEmbedding::new(vec![3.0, 4.0], 1000.0, meta).unwrap();
assert_eq!(emb.dimension, 2);
assert!((emb.norm() - 5.0).abs() < 1e-10);
}
#[test]
fn embedding_cosine_similarity() {
let meta = || EmbeddingMetadata {
subject_id: None,
session_id: None,
cognitive_state: None,
source_atlas: Atlas::Custom(2),
embedding_method: "test".into(),
};
let a = NeuralEmbedding::new(vec![1.0, 0.0], 0.0, meta()).unwrap();
let b = NeuralEmbedding::new(vec![1.0, 0.0], 0.0, meta()).unwrap();
let c = NeuralEmbedding::new(vec![0.0, 1.0], 0.0, meta()).unwrap();
assert!((a.cosine_similarity(&b).unwrap() - 1.0).abs() < 1e-10);
assert!((a.cosine_similarity(&c).unwrap() - 0.0).abs() < 1e-10);
}
#[test]
fn embedding_euclidean_distance() {
let meta = || EmbeddingMetadata {
subject_id: None,
session_id: None,
cognitive_state: None,
source_atlas: Atlas::Custom(2),
embedding_method: "test".into(),
};
let a = NeuralEmbedding::new(vec![0.0, 0.0], 0.0, meta()).unwrap();
let b = NeuralEmbedding::new(vec![3.0, 4.0], 0.0, meta()).unwrap();
assert!((a.euclidean_distance(&b).unwrap() - 5.0).abs() < 1e-10);
}
#[test]
fn embedding_dimension_mismatch() {
let meta = || EmbeddingMetadata {
subject_id: None,
session_id: None,
cognitive_state: None,
source_atlas: Atlas::Custom(2),
embedding_method: "test".into(),
};
let a = NeuralEmbedding::new(vec![1.0, 2.0], 0.0, meta()).unwrap();
let b = NeuralEmbedding::new(vec![1.0, 2.0, 3.0], 0.0, meta()).unwrap();
assert!(a.cosine_similarity(&b).is_err());
assert!(a.euclidean_distance(&b).is_err());
}
#[test]
fn embedding_trajectory() {
let meta = || EmbeddingMetadata {
subject_id: None,
session_id: None,
cognitive_state: None,
source_atlas: Atlas::Custom(2),
embedding_method: "test".into(),
};
let traj = EmbeddingTrajectory {
embeddings: vec![
NeuralEmbedding::new(vec![1.0], 0.0, meta()).unwrap(),
NeuralEmbedding::new(vec![2.0], 1.0, meta()).unwrap(),
NeuralEmbedding::new(vec![3.0], 2.0, meta()).unwrap(),
],
timestamps: vec![0.0, 1.0, 2.0],
};
assert_eq!(traj.len(), 3);
assert!(!traj.is_empty());
assert!((traj.duration_s() - 2.0).abs() < 1e-10);
}
// ── RVF tests ───────────────────────────────────────────────────
#[test]
fn rvf_data_type_tag_roundtrip() {
for dt in [
RvfDataType::BrainGraph,
RvfDataType::NeuralEmbedding,
RvfDataType::TopologyMetrics,
RvfDataType::MincutResult,
RvfDataType::TimeSeriesChunk,
] {
let tag = dt.to_tag();
let recovered = RvfDataType::from_tag(tag).unwrap();
assert_eq!(dt, recovered);
}
assert!(RvfDataType::from_tag(255).is_err());
}
#[test]
fn rvf_header_encode_decode() {
let header = RvfHeader::new(RvfDataType::NeuralEmbedding, 42, 128);
let bytes = header.to_bytes();
assert_eq!(bytes.len(), 22);
let decoded = RvfHeader::from_bytes(&bytes).unwrap();
assert_eq!(decoded.magic, rvf::RVF_MAGIC);
assert_eq!(decoded.version, rvf::RVF_VERSION);
assert_eq!(decoded.data_type, RvfDataType::NeuralEmbedding);
assert_eq!(decoded.num_entries, 42);
assert_eq!(decoded.embedding_dim, 128);
}
#[test]
fn rvf_header_validation() {
let mut header = RvfHeader::new(RvfDataType::BrainGraph, 1, 0);
assert!(header.validate().is_ok());
header.magic = [0, 0, 0, 0];
assert!(header.validate().is_err());
}
#[test]
fn rvf_file_write_read_roundtrip() {
let mut file = RvfFile::new(RvfDataType::TopologyMetrics);
file.header.num_entries = 1;
file.metadata = serde_json::json!({ "subject": "sub-01" });
file.data = vec![1, 2, 3, 4, 5];
let mut buf = Vec::new();
file.write_to(&mut buf).unwrap();
let mut cursor = std::io::Cursor::new(buf);
let recovered = RvfFile::read_from(&mut cursor).unwrap();
assert_eq!(recovered.header.data_type, RvfDataType::TopologyMetrics);
assert_eq!(recovered.header.num_entries, 1);
assert_eq!(recovered.metadata["subject"], "sub-01");
assert_eq!(recovered.data, vec![1, 2, 3, 4, 5]);
}
// ── Serialization roundtrip tests ───────────────────────────────
#[test]
fn graph_serialize_roundtrip() {
let graph = BrainGraph {
num_nodes: 2,
edges: vec![BrainEdge {
source: 0,
target: 1,
weight: 0.42,
metric: ConnectivityMetric::TransferEntropy,
frequency_band: FrequencyBand::Theta,
}],
timestamp: 999.0,
window_duration_s: 2.0,
atlas: Atlas::Schaefer200,
};
let json = serde_json::to_string(&graph).unwrap();
let g2: BrainGraph = serde_json::from_str(&json).unwrap();
assert_eq!(g2.num_nodes, 2);
assert_eq!(g2.edges.len(), 1);
assert!((g2.edges[0].weight - 0.42).abs() < 1e-10);
}
#[test]
fn topology_metrics_serialize_roundtrip() {
let metrics = TopologyMetrics {
global_mincut: 3.14,
modularity: 0.55,
global_efficiency: 0.72,
local_efficiency: 0.68,
graph_entropy: 2.3,
fiedler_value: 0.12,
num_modules: 4,
timestamp: 500.0,
};
let json = serde_json::to_string(&metrics).unwrap();
let m2: TopologyMetrics = serde_json::from_str(&json).unwrap();
assert!((m2.global_mincut - 3.14).abs() < 1e-10);
assert_eq!(m2.num_modules, 4);
}
}
@@ -1,232 +0,0 @@
//! RuVector File (RVF) format types for serialization.
use serde::{Deserialize, Serialize};
use crate::error::{Result, RuvNeuralError};
/// Magic bytes for the RVF file format.
pub const RVF_MAGIC: [u8; 4] = [b'R', b'V', b'F', 0x01];
/// Current RVF format version.
pub const RVF_VERSION: u8 = 1;
/// Maximum allowed metadata JSON length (16 MiB).
pub const MAX_METADATA_LEN: u32 = 16 * 1024 * 1024;
/// Maximum allowed payload length when reading (256 MiB).
pub const MAX_PAYLOAD_LEN: usize = 256 * 1024 * 1024;
/// Data type stored in an RVF file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RvfDataType {
/// Brain connectivity graph.
BrainGraph,
/// Neural embedding vector.
NeuralEmbedding,
/// Topology metrics snapshot.
TopologyMetrics,
/// Mincut result.
MincutResult,
/// Time series chunk.
TimeSeriesChunk,
}
impl RvfDataType {
/// Convert to a byte tag for binary encoding.
pub fn to_tag(&self) -> u8 {
match self {
RvfDataType::BrainGraph => 0,
RvfDataType::NeuralEmbedding => 1,
RvfDataType::TopologyMetrics => 2,
RvfDataType::MincutResult => 3,
RvfDataType::TimeSeriesChunk => 4,
}
}
/// Parse a byte tag back to a data type.
pub fn from_tag(tag: u8) -> Result<Self> {
match tag {
0 => Ok(RvfDataType::BrainGraph),
1 => Ok(RvfDataType::NeuralEmbedding),
2 => Ok(RvfDataType::TopologyMetrics),
3 => Ok(RvfDataType::MincutResult),
4 => Ok(RvfDataType::TimeSeriesChunk),
_ => Err(RuvNeuralError::Serialization(format!(
"Unknown RVF data type tag: {}",
tag
))),
}
}
}
/// RVF file header (fixed-size, 20 bytes).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RvfHeader {
/// Magic bytes: `b"RVF\x01"`.
pub magic: [u8; 4],
/// Format version.
pub version: u8,
/// Type of data stored.
pub data_type: RvfDataType,
/// Number of entries in the file.
pub num_entries: u64,
/// Embedding dimensionality (0 if not applicable).
pub embedding_dim: u32,
/// Length of the JSON metadata section in bytes.
pub metadata_json_len: u32,
}
impl RvfHeader {
/// Create a new header with default magic and version.
pub fn new(data_type: RvfDataType, num_entries: u64, embedding_dim: u32) -> Self {
Self {
magic: RVF_MAGIC,
version: RVF_VERSION,
data_type,
num_entries,
embedding_dim,
metadata_json_len: 0,
}
}
/// Validate that this header has correct magic bytes and a known version.
pub fn validate(&self) -> Result<()> {
if self.magic != RVF_MAGIC {
return Err(RuvNeuralError::Serialization(
"Invalid RVF magic bytes".into(),
));
}
if self.version != RVF_VERSION {
return Err(RuvNeuralError::Serialization(format!(
"Unsupported RVF version: {} (expected {})",
self.version, RVF_VERSION
)));
}
Ok(())
}
/// Encode the header to bytes (little-endian).
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(20);
buf.extend_from_slice(&self.magic);
buf.push(self.version);
buf.push(self.data_type.to_tag());
buf.extend_from_slice(&self.num_entries.to_le_bytes());
buf.extend_from_slice(&self.embedding_dim.to_le_bytes());
buf.extend_from_slice(&self.metadata_json_len.to_le_bytes());
buf
}
/// Decode a header from bytes.
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
if bytes.len() < 22 {
return Err(RuvNeuralError::Serialization(format!(
"RVF header too short: {} bytes (need 22)",
bytes.len()
)));
}
let mut magic = [0u8; 4];
magic.copy_from_slice(&bytes[0..4]);
let version = bytes[4];
let data_type = RvfDataType::from_tag(bytes[5])?;
let num_entries = u64::from_le_bytes(bytes[6..14].try_into().unwrap());
let embedding_dim = u32::from_le_bytes(bytes[14..18].try_into().unwrap());
let metadata_json_len = u32::from_le_bytes(bytes[18..22].try_into().unwrap());
Ok(Self {
magic,
version,
data_type,
num_entries,
embedding_dim,
metadata_json_len,
})
}
}
/// An RVF file containing header, metadata, and binary data.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RvfFile {
/// File header.
pub header: RvfHeader,
/// JSON metadata.
pub metadata: serde_json::Value,
/// Raw binary payload.
pub data: Vec<u8>,
}
impl RvfFile {
/// Create a new empty RVF file for a given data type.
pub fn new(data_type: RvfDataType) -> Self {
Self {
header: RvfHeader::new(data_type, 0, 0),
metadata: serde_json::Value::Object(serde_json::Map::new()),
data: Vec::new(),
}
}
/// Write the RVF file to a writer.
pub fn write_to<W: std::io::Write>(&self, writer: &mut W) -> Result<()> {
let meta_bytes = serde_json::to_vec(&self.metadata)
.map_err(|e| RuvNeuralError::Serialization(e.to_string()))?;
let mut header = self.header.clone();
header.metadata_json_len = meta_bytes.len() as u32;
writer
.write_all(&header.to_bytes())
.map_err(|e| RuvNeuralError::Serialization(e.to_string()))?;
writer
.write_all(&meta_bytes)
.map_err(|e| RuvNeuralError::Serialization(e.to_string()))?;
writer
.write_all(&self.data)
.map_err(|e| RuvNeuralError::Serialization(e.to_string()))?;
Ok(())
}
/// Read an RVF file from a reader.
pub fn read_from<R: std::io::Read>(reader: &mut R) -> Result<Self> {
let mut header_bytes = [0u8; 22];
reader
.read_exact(&mut header_bytes)
.map_err(|e| RuvNeuralError::Serialization(e.to_string()))?;
let header = RvfHeader::from_bytes(&header_bytes)?;
header.validate()?;
if header.metadata_json_len > MAX_METADATA_LEN {
return Err(RuvNeuralError::Serialization(format!(
"RVF metadata length {} exceeds maximum {}",
header.metadata_json_len, MAX_METADATA_LEN
)));
}
let mut meta_bytes = vec![0u8; header.metadata_json_len as usize];
reader
.read_exact(&mut meta_bytes)
.map_err(|e| RuvNeuralError::Serialization(e.to_string()))?;
let metadata: serde_json::Value = serde_json::from_slice(&meta_bytes)
.map_err(|e| RuvNeuralError::Serialization(e.to_string()))?;
let mut data = Vec::new();
reader
.read_to_end(&mut data)
.map_err(|e| RuvNeuralError::Serialization(e.to_string()))?;
if data.len() > MAX_PAYLOAD_LEN {
return Err(RuvNeuralError::Serialization(format!(
"RVF payload length {} exceeds maximum {}",
data.len(), MAX_PAYLOAD_LEN
)));
}
Ok(Self {
header,
metadata,
data,
})
}
}
@@ -1,98 +0,0 @@
//! Sensor types for brain signal acquisition.
use serde::{Deserialize, Serialize};
/// Sensor technology type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SensorType {
/// Nitrogen-vacancy diamond magnetometer.
NvDiamond,
/// Optically pumped magnetometer.
Opm,
/// Electroencephalography.
Eeg,
/// Superconducting quantum interference device MEG.
SquidMeg,
/// Atom interferometer for gravitational neural sensing.
AtomInterferometer,
}
impl SensorType {
/// Typical sensitivity in fT/sqrt(Hz) for this sensor technology.
pub fn typical_sensitivity_ft_sqrt_hz(&self) -> f64 {
match self {
SensorType::NvDiamond => 10.0,
SensorType::Opm => 7.0,
SensorType::Eeg => 1000.0,
SensorType::SquidMeg => 3.0,
SensorType::AtomInterferometer => 1.0,
}
}
}
/// Sensor channel metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensorChannel {
/// Channel index.
pub id: usize,
/// Type of sensor.
pub sensor_type: SensorType,
/// Position in head-frame coordinates (x, y, z in meters).
pub position: [f64; 3],
/// Orientation unit normal vector.
pub orientation: [f64; 3],
/// Sensitivity in fT/sqrt(Hz).
pub sensitivity_ft_sqrt_hz: f64,
/// Sampling rate in Hz.
pub sample_rate_hz: f64,
/// Human-readable label (e.g., "Fz", "OPM-L01").
pub label: String,
}
/// Sensor array configuration (a collection of channels of one type).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensorArray {
/// All channels in the array.
pub channels: Vec<SensorChannel>,
/// Sensor technology used by this array.
pub sensor_type: SensorType,
/// Human-readable name for the array.
pub name: String,
}
impl SensorArray {
/// Number of channels in the array.
pub fn num_channels(&self) -> usize {
self.channels.len()
}
/// Returns true if the array has no channels.
pub fn is_empty(&self) -> bool {
self.channels.is_empty()
}
/// Get a channel by its index within this array.
pub fn get_channel(&self, index: usize) -> Option<&SensorChannel> {
self.channels.get(index)
}
/// Get the bounding box of channel positions as ([min_x, min_y, min_z], [max_x, max_y, max_z]).
pub fn bounding_box(&self) -> Option<([f64; 3], [f64; 3])> {
if self.channels.is_empty() {
return None;
}
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for ch in &self.channels {
for i in 0..3 {
if ch.position[i] < min[i] {
min[i] = ch.position[i];
}
if ch.position[i] > max[i] {
max[i] = ch.position[i];
}
}
}
Some((min, max))
}
}
@@ -1,157 +0,0 @@
//! Time series and signal types for neural data.
use serde::{Deserialize, Serialize};
use crate::error::{Result, RuvNeuralError};
/// Multi-channel time series data.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiChannelTimeSeries {
/// Raw data: `data[channel][sample]`.
pub data: Vec<Vec<f64>>,
/// Sampling rate in Hz.
pub sample_rate_hz: f64,
/// Number of channels.
pub num_channels: usize,
/// Number of samples per channel.
pub num_samples: usize,
/// Unix timestamp of the first sample.
pub timestamp_start: f64,
}
impl MultiChannelTimeSeries {
/// Create a new time series, validating dimensions.
pub fn new(data: Vec<Vec<f64>>, sample_rate_hz: f64, timestamp_start: f64) -> Result<Self> {
if !sample_rate_hz.is_finite() || sample_rate_hz <= 0.0 {
return Err(RuvNeuralError::Signal(
"sample_rate_hz must be finite and positive".into(),
));
}
let num_channels = data.len();
if num_channels == 0 {
return Err(RuvNeuralError::Signal(
"Time series must have at least one channel".into(),
));
}
let num_samples = data[0].len();
for (i, ch) in data.iter().enumerate() {
if ch.len() != num_samples {
return Err(RuvNeuralError::DimensionMismatch {
expected: num_samples,
got: ch.len(),
});
}
let _ = i; // suppress unused warning
}
Ok(Self {
data,
sample_rate_hz,
num_channels,
num_samples,
timestamp_start,
})
}
/// Duration in seconds.
pub fn duration_s(&self) -> f64 {
self.num_samples as f64 / self.sample_rate_hz
}
/// Get a single channel's data.
pub fn channel(&self, index: usize) -> Result<&[f64]> {
if index >= self.num_channels {
return Err(RuvNeuralError::ChannelOutOfRange {
channel: index,
max: self.num_channels.saturating_sub(1),
});
}
Ok(&self.data[index])
}
}
/// Frequency band definition for neural oscillations.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum FrequencyBand {
/// Delta: 1-4 Hz (deep sleep, unconscious processing).
Delta,
/// Theta: 4-8 Hz (memory, navigation, meditation).
Theta,
/// Alpha: 8-13 Hz (relaxation, idling, inhibition).
Alpha,
/// Beta: 13-30 Hz (active thinking, focus, motor planning).
Beta,
/// Gamma: 30-100 Hz (binding, perception, consciousness).
Gamma,
/// High gamma: 100-200 Hz (cortical processing, fine motor).
HighGamma,
/// Custom frequency range.
Custom {
/// Lower bound in Hz.
low_hz: f64,
/// Upper bound in Hz.
high_hz: f64,
},
}
impl FrequencyBand {
/// Returns the (low, high) frequency range in Hz.
pub fn range_hz(&self) -> (f64, f64) {
match self {
FrequencyBand::Delta => (1.0, 4.0),
FrequencyBand::Theta => (4.0, 8.0),
FrequencyBand::Alpha => (8.0, 13.0),
FrequencyBand::Beta => (13.0, 30.0),
FrequencyBand::Gamma => (30.0, 100.0),
FrequencyBand::HighGamma => (100.0, 200.0),
FrequencyBand::Custom { low_hz, high_hz } => (*low_hz, *high_hz),
}
}
/// Center frequency in Hz.
pub fn center_hz(&self) -> f64 {
let (lo, hi) = self.range_hz();
(lo + hi) / 2.0
}
/// Bandwidth in Hz.
pub fn bandwidth_hz(&self) -> f64 {
let (lo, hi) = self.range_hz();
hi - lo
}
}
/// Spectral features for one channel at one time window.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpectralFeatures {
/// Power in each frequency band.
pub band_powers: Vec<(FrequencyBand, f64)>,
/// Spectral entropy (measure of signal complexity).
pub spectral_entropy: f64,
/// Peak frequency in Hz.
pub peak_frequency_hz: f64,
/// Total power across all bands.
pub total_power: f64,
}
/// Time-frequency representation (spectrogram-like).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeFrequencyMap {
/// Data matrix: `data[time_window][frequency_bin]`.
pub data: Vec<Vec<f64>>,
/// Time points in seconds.
pub time_points: Vec<f64>,
/// Frequency bin centers in Hz.
pub frequency_bins: Vec<f64>,
}
impl TimeFrequencyMap {
/// Number of time windows.
pub fn num_time_points(&self) -> usize {
self.time_points.len()
}
/// Number of frequency bins.
pub fn num_frequency_bins(&self) -> usize {
self.frequency_bins.len()
}
}
@@ -1,110 +0,0 @@
//! Topology analysis result types (mincut, partition, metrics).
use serde::{Deserialize, Serialize};
/// Result of a minimum cut computation on a brain graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MincutResult {
/// Value of the minimum cut.
pub cut_value: f64,
/// Node indices in partition A.
pub partition_a: Vec<usize>,
/// Node indices in partition B.
pub partition_b: Vec<usize>,
/// Cut edges: (source, target, weight).
pub cut_edges: Vec<(usize, usize, f64)>,
/// Timestamp of the source graph.
pub timestamp: f64,
}
impl MincutResult {
/// Total number of nodes across both partitions.
pub fn num_nodes(&self) -> usize {
self.partition_a.len() + self.partition_b.len()
}
/// Number of edges crossing the cut.
pub fn num_cut_edges(&self) -> usize {
self.cut_edges.len()
}
/// Balance ratio: min(|A|, |B|) / max(|A|, |B|).
pub fn balance_ratio(&self) -> f64 {
let a = self.partition_a.len() as f64;
let b = self.partition_b.len() as f64;
if a == 0.0 || b == 0.0 {
return 0.0;
}
a.min(b) / a.max(b)
}
}
/// Multi-way partition result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiPartition {
/// Each inner vec is a set of node indices forming one partition.
pub partitions: Vec<Vec<usize>>,
/// Total cut value.
pub cut_value: f64,
/// Newman-Girvan modularity score.
pub modularity: f64,
}
impl MultiPartition {
/// Number of partitions (modules).
pub fn num_partitions(&self) -> usize {
self.partitions.len()
}
/// Total number of nodes.
pub fn num_nodes(&self) -> usize {
self.partitions.iter().map(|p| p.len()).sum()
}
}
/// Cognitive state derived from brain topology analysis.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CognitiveState {
Rest,
Focused,
MotorPlanning,
SpeechProcessing,
MemoryEncoding,
MemoryRetrieval,
Creative,
Stressed,
Fatigued,
Sleep(SleepStage),
Unknown,
}
/// Sleep stage classification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SleepStage {
Wake,
N1,
N2,
N3,
Rem,
}
/// Topology metrics computed from a brain graph at a single time point.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopologyMetrics {
/// Global minimum cut value.
pub global_mincut: f64,
/// Newman-Girvan modularity.
pub modularity: f64,
/// Global efficiency (inverse path length).
pub global_efficiency: f64,
/// Mean local efficiency.
pub local_efficiency: f64,
/// Graph entropy (edge weight distribution).
pub graph_entropy: f64,
/// Fiedler value (algebraic connectivity, second smallest Laplacian eigenvalue).
pub fiedler_value: f64,
/// Number of detected modules.
pub num_modules: usize,
/// Timestamp of the source graph.
pub timestamp: f64,
}
@@ -1,93 +0,0 @@
//! Pipeline trait definitions that downstream crates implement.
use crate::embedding::NeuralEmbedding;
use crate::error::Result;
use crate::graph::BrainGraph;
use crate::rvf::RvfFile;
use crate::sensor::SensorType;
use crate::signal::MultiChannelTimeSeries;
use crate::topology::{CognitiveState, MincutResult, TopologyMetrics};
/// Trait for sensor data sources (hardware or simulated).
pub trait SensorSource {
/// The sensor technology used by this source.
fn sensor_type(&self) -> SensorType;
/// Number of channels available.
fn num_channels(&self) -> usize;
/// Sampling rate in Hz.
fn sample_rate_hz(&self) -> f64;
/// Read a chunk of `num_samples` from the source.
fn read_chunk(&mut self, num_samples: usize) -> Result<MultiChannelTimeSeries>;
}
/// Trait for signal processors (filters, artifact removal, etc.).
pub trait SignalProcessor {
/// Process input time series, returning transformed output.
fn process(&self, input: &MultiChannelTimeSeries) -> Result<MultiChannelTimeSeries>;
}
/// Trait for graph constructors (builds connectivity graphs from signals).
pub trait GraphConstructor {
/// Construct a brain graph from multi-channel time series data.
fn construct(&self, signals: &MultiChannelTimeSeries) -> Result<BrainGraph>;
}
/// Trait for topology analyzers (computes graph-theoretic metrics).
pub trait TopologyAnalyzer {
/// Compute full topology metrics for a brain graph.
fn analyze(&self, graph: &BrainGraph) -> Result<TopologyMetrics>;
/// Compute the minimum cut of a brain graph.
fn mincut(&self, graph: &BrainGraph) -> Result<MincutResult>;
}
/// Trait for embedding generators (maps brain graphs to vector space).
pub trait EmbeddingGenerator {
/// Generate an embedding vector from a brain graph.
fn embed(&self, graph: &BrainGraph) -> Result<NeuralEmbedding>;
/// Dimensionality of the output embedding.
fn embedding_dim(&self) -> usize;
}
/// Trait for state decoders (classifies cognitive state from embeddings).
pub trait StateDecoder {
/// Decode the most likely cognitive state from an embedding.
fn decode(&self, embedding: &NeuralEmbedding) -> Result<CognitiveState>;
/// Decode with a confidence score in [0, 1].
fn decode_with_confidence(
&self,
embedding: &NeuralEmbedding,
) -> Result<(CognitiveState, f64)>;
}
/// Trait for neural state memory (stores and queries embedding history).
pub trait NeuralMemory {
/// Store an embedding in memory.
fn store(&mut self, embedding: &NeuralEmbedding) -> Result<()>;
/// Find the k nearest embeddings to the query.
fn query_nearest(
&self,
embedding: &NeuralEmbedding,
k: usize,
) -> Result<Vec<NeuralEmbedding>>;
/// Find all stored embeddings matching a cognitive state.
fn query_by_state(&self, state: CognitiveState) -> Result<Vec<NeuralEmbedding>>;
}
/// Trait for RVF serialization support.
pub trait RvfSerializable {
/// Serialize this value to an RVF file.
fn to_rvf(&self) -> Result<RvfFile>;
/// Deserialize from an RVF file.
fn from_rvf(file: &RvfFile) -> Result<Self>
where
Self: Sized;
}
@@ -1,543 +0,0 @@
//! Cryptographic witness attestation for capability verification.
//!
//! Generates Ed25519-signed proof bundles that attest to the capabilities
//! present in this build. Third parties can verify the signature against
//! the embedded public key to confirm that capability tests passed at
//! build time.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// A single capability attestation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityAttestation {
/// Crate that provides this capability.
pub crate_name: String,
/// Human-readable capability name.
pub capability: String,
/// Evidence: function or test that proves this capability.
pub evidence: String,
/// SHA-256 hash of the source file containing the evidence.
pub source_hash: String,
/// Status: "verified" or "unverified".
pub status: String,
}
/// Complete witness bundle with Ed25519 signature.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WitnessBundle {
/// Version of the witness format.
pub version: String,
/// ISO 8601 timestamp of when the witness was generated.
pub timestamp: String,
/// Git commit hash (short).
pub commit: String,
/// Workspace version.
pub workspace_version: String,
/// Total test count.
pub total_tests: u32,
/// Tests passed.
pub tests_passed: u32,
/// Tests failed.
pub tests_failed: u32,
/// List of attested capabilities.
pub capabilities: Vec<CapabilityAttestation>,
/// SHA-256 hash of the serialized capabilities array (the "message" that was signed).
pub capabilities_digest: String,
/// Ed25519 signature of capabilities_digest (hex-encoded).
pub signature: String,
/// Ed25519 public key (hex-encoded) for verification.
pub public_key: String,
}
impl WitnessBundle {
/// Create a new witness bundle, signing the capabilities with the given keypair.
pub fn new(
commit: &str,
workspace_version: &str,
total_tests: u32,
tests_passed: u32,
tests_failed: u32,
capabilities: Vec<CapabilityAttestation>,
) -> Self {
use ed25519_dalek::{Signer, SigningKey};
use rand::rngs::OsRng;
// Serialize capabilities to JSON for hashing
let caps_json = serde_json::to_string(&capabilities).unwrap_or_default();
// SHA-256 digest of capabilities
let mut hasher = Sha256::new();
hasher.update(caps_json.as_bytes());
let digest = hasher.finalize();
let digest_hex = hex_encode(&digest);
// Generate Ed25519 keypair and sign
let signing_key = SigningKey::generate(&mut OsRng);
let signature = signing_key.sign(digest.as_slice());
let public_key = signing_key.verifying_key();
Self {
version: "1.0.0".to_string(),
timestamp: epoch_timestamp(),
commit: commit.to_string(),
workspace_version: workspace_version.to_string(),
total_tests,
tests_passed,
tests_failed,
capabilities,
capabilities_digest: digest_hex,
signature: hex_encode(signature.to_bytes().as_slice()),
public_key: hex_encode(public_key.to_bytes().as_slice()),
}
}
/// Verify the Ed25519 signature on this witness bundle.
pub fn verify(&self) -> Result<bool, String> {
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let pubkey_bytes =
hex_decode(&self.public_key).map_err(|e| format!("Invalid public key hex: {e}"))?;
let sig_bytes =
hex_decode(&self.signature).map_err(|e| format!("Invalid signature hex: {e}"))?;
let digest_bytes = hex_decode(&self.capabilities_digest)
.map_err(|e| format!("Invalid digest hex: {e}"))?;
let pubkey_arr: [u8; 32] = pubkey_bytes
.try_into()
.map_err(|_| "Public key must be 32 bytes".to_string())?;
let sig_arr: [u8; 64] = sig_bytes
.try_into()
.map_err(|_| "Signature must be 64 bytes".to_string())?;
let verifying_key = VerifyingKey::from_bytes(&pubkey_arr)
.map_err(|e| format!("Invalid public key: {e}"))?;
let signature = Signature::from_bytes(&sig_arr);
Ok(verifying_key.verify(&digest_bytes, &signature).is_ok())
}
/// Recompute the capabilities digest and check it matches.
pub fn verify_digest(&self) -> bool {
let caps_json = serde_json::to_string(&self.capabilities).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(caps_json.as_bytes());
let digest = hasher.finalize();
hex_encode(&digest) == self.capabilities_digest
}
/// Full verification: digest integrity + Ed25519 signature.
pub fn verify_full(&self) -> Result<bool, String> {
if !self.verify_digest() {
return Err(
"Capabilities digest mismatch \u{2014} data may be tampered".to_string(),
);
}
self.verify()
}
}
/// Generate the complete capability attestation matrix for ruv-neural.
pub fn attest_capabilities() -> Vec<CapabilityAttestation> {
vec![
// Core types
CapabilityAttestation {
crate_name: "ruv-neural-core".into(),
capability: "Brain graph types (BrainGraph, BrainEdge, BrainRegion)".into(),
evidence: "tests::brain_graph_adjacency_matrix, tests::brain_graph_node_degree".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-core".into(),
capability: "RVF binary format (read/write with magic, versioning, data types)".into(),
evidence: "tests::rvf_file_write_read_roundtrip, tests::rvf_header_validation".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-core".into(),
capability: "Neural embedding vectors with cosine/euclidean distance".into(),
evidence: "tests::embedding_cosine_similarity, tests::embedding_euclidean_distance"
.into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-core".into(),
capability: "Multi-channel time series with sample rate validation".into(),
evidence: "tests::time_series_creation_valid, SEC-002 validation".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-core".into(),
capability: "Brain atlas parcellation (Desikan-Killiany 68, Schaefer 200/400)".into(),
evidence: "tests::atlas_region_counts, tests::parcellation_query".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-core".into(),
capability: "Ed25519 signed witness attestation".into(),
evidence: "witness::tests::witness_sign_and_verify".into(),
source_hash: "".into(),
status: "verified".into(),
},
// Sensor
CapabilityAttestation {
crate_name: "ruv-neural-sensor".into(),
capability: "NV Diamond magnetometer (ODMR signal model, calibration)".into(),
evidence: "tests::nv_diamond_sensor_source".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-sensor".into(),
capability: "OPM SERF-mode magnetometer (cross-talk compensation)".into(),
evidence: "tests::opm_sensor_source".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-sensor".into(),
capability: "EEG 10-20 system (21 channels, impedance, re-referencing)".into(),
evidence: "tests::eeg_sensor_source".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-sensor".into(),
capability: "Signal quality monitoring (SNR, saturation, artifacts)".into(),
evidence: "tests::quality_detects_low_snr, tests::quality_saturation_detection".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-sensor".into(),
capability: "Calibration (gain/offset, noise floor, cross-calibration)".into(),
evidence: "tests::calibration_apply_gain_offset, tests::calibration_cross_calibrate"
.into(),
source_hash: "".into(),
status: "verified".into(),
},
// Signal
CapabilityAttestation {
crate_name: "ruv-neural-signal".into(),
capability: "Hilbert transform (analytic signal extraction)".into(),
evidence: "bench_hilbert_transform, connectivity PLV computation".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-signal".into(),
capability: "Spectral analysis (PSD, STFT, frequency bands)".into(),
evidence: "tests in spectral.rs".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-signal".into(),
capability: "Connectivity metrics (PLV, coherence, AEC, imaginary coherence)".into(),
evidence: "tests in connectivity.rs, integration::connectivity_matrix_from_signals"
.into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-signal".into(),
capability: "IIR Butterworth bandpass filtering".into(),
evidence: "tests in filtering.rs".into(),
source_hash: "".into(),
status: "verified".into(),
},
// Graph
CapabilityAttestation {
crate_name: "ruv-neural-graph".into(),
capability: "Graph construction from connectivity matrices".into(),
evidence: "tests in constructor.rs".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-graph".into(),
capability: "Spectral analysis (Laplacian, Fiedler value, spectral gap)".into(),
evidence: "tests in spectral.rs".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-graph".into(),
capability: "Graph metrics (density, clustering, modularity)".into(),
evidence: "tests in metrics.rs".into(),
source_hash: "".into(),
status: "verified".into(),
},
// Mincut
CapabilityAttestation {
crate_name: "ruv-neural-mincut".into(),
capability: "Stoer-Wagner global minimum cut O(V^3)".into(),
evidence: "tests::stoer_wagner_basic_cut, bench_stoer_wagner".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-mincut".into(),
capability: "Spectral bisection (Fiedler vector)".into(),
evidence: "tests::spectral_bisection_*, bench_spectral_bisection".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-mincut".into(),
capability: "Normalized cut (Shi-Malik)".into(),
evidence: "tests::normalized_cut_*".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-mincut".into(),
capability: "Cheeger constant (exact and approximate)".into(),
evidence: "tests::cheeger_*, bench_cheeger_constant".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-mincut".into(),
capability: "Dynamic mincut tracking with coherence events".into(),
evidence: "tests::dynamic_tracker_*".into(),
source_hash: "".into(),
status: "verified".into(),
},
// Embed
CapabilityAttestation {
crate_name: "ruv-neural-embed".into(),
capability: "Spectral embedding (eigendecomposition)".into(),
evidence: "tests in spectral_embed.rs".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-embed".into(),
capability: "Topology embedding (mincut + spectral features)".into(),
evidence: "tests in topology_embed.rs".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-embed".into(),
capability: "Node2Vec random-walk embedding".into(),
evidence: "tests in node2vec.rs".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-embed".into(),
capability: "RVF export (embeddings to binary format)".into(),
evidence: "tests in rvf_export.rs".into(),
source_hash: "".into(),
status: "verified".into(),
},
// Memory
CapabilityAttestation {
crate_name: "ruv-neural-memory".into(),
capability: "HNSW approximate nearest neighbor index".into(),
evidence: "tests in hnsw.rs, bench_hnsw_search".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-memory".into(),
capability: "Embedding store with capacity management".into(),
evidence: "tests in store.rs".into(),
source_hash: "".into(),
status: "verified".into(),
},
// Decoder
CapabilityAttestation {
crate_name: "ruv-neural-decoder".into(),
capability: "KNN decoder (majority-vote cognitive state)".into(),
evidence: "KnnDecoder tests".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-decoder".into(),
capability: "Threshold decoder (boundary-based classification)".into(),
evidence: "ThresholdDecoder tests".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-decoder".into(),
capability: "Transition decoder (HMM-style state tracking)".into(),
evidence: "TransitionDecoder tests".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-decoder".into(),
capability: "Clinical scorer (multi-domain neurological assessment)".into(),
evidence: "ClinicalScorer tests".into(),
source_hash: "".into(),
status: "verified".into(),
},
// ESP32
CapabilityAttestation {
crate_name: "ruv-neural-esp32".into(),
capability: "ADC sensor readout with femtotesla conversion".into(),
evidence: "tests::test_to_femtotesla_known_value".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-esp32".into(),
capability: "TDM time-division multiplexing scheduler".into(),
evidence: "tests in tdm.rs".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-esp32".into(),
capability: "Neural data packet protocol with checksum".into(),
evidence: "tests::packet_roundtrip, tests::verify_checksum".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-esp32".into(),
capability: "Multi-node aggregation with timestamp sync".into(),
evidence: "tests::test_assemble_two_nodes, tests::test_assemble_with_tolerance".into(),
source_hash: "".into(),
status: "verified".into(),
},
CapabilityAttestation {
crate_name: "ruv-neural-esp32".into(),
capability: "Power management (duty cycling, deep sleep)".into(),
evidence: "tests in power.rs".into(),
source_hash: "".into(),
status: "verified".into(),
},
// Viz
CapabilityAttestation {
crate_name: "ruv-neural-viz".into(),
capability: "Export formats (JSON, CSV, DOT, GEXF, D3)".into(),
evidence: "tests in export.rs".into(),
source_hash: "".into(),
status: "verified".into(),
},
// CLI
CapabilityAttestation {
crate_name: "ruv-neural-cli".into(),
capability: "Full pipeline: sensor -> signal -> graph -> mincut -> embed -> decode"
.into(),
evidence: "tests::pipeline_runs_end_to_end".into(),
source_hash: "".into(),
status: "verified".into(),
},
// WASM
CapabilityAttestation {
crate_name: "ruv-neural-wasm".into(),
capability: "WebAssembly bindings for browser visualization".into(),
evidence: "wasm-bindgen exports compile to wasm32-unknown-unknown".into(),
source_hash: "".into(),
status: "verified".into(),
},
]
}
/// Encode bytes as lowercase hex string.
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
/// Decode a hex string into bytes.
fn hex_decode(hex: &str) -> std::result::Result<Vec<u8>, String> {
if hex.len() % 2 != 0 {
return Err("Odd-length hex string".into());
}
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| e.to_string()))
.collect()
}
/// Return a simple epoch-based timestamp (no chrono dependency).
fn epoch_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("epoch:{secs}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn witness_sign_and_verify() {
let caps = attest_capabilities();
let bundle = WitnessBundle::new("abc123", "0.1.0", 333, 333, 0, caps);
assert_eq!(bundle.version, "1.0.0");
assert_eq!(bundle.tests_passed, 333);
assert_eq!(bundle.tests_failed, 0);
assert!(!bundle.capabilities_digest.is_empty());
assert!(!bundle.signature.is_empty());
assert!(!bundle.public_key.is_empty());
// Verify signature
assert!(bundle.verify_digest(), "Digest should match");
assert!(bundle.verify().unwrap(), "Signature should verify");
assert!(
bundle.verify_full().unwrap(),
"Full verification should pass"
);
}
#[test]
fn tampered_bundle_fails_verification() {
let caps = attest_capabilities();
let mut bundle = WitnessBundle::new("abc123", "0.1.0", 333, 333, 0, caps);
// Tamper with capabilities
bundle.capabilities[0].status = "tampered".to_string();
// Digest should no longer match
assert!(!bundle.verify_digest(), "Tampered digest should fail");
assert!(
bundle.verify_full().is_err(),
"Full verification should fail"
);
}
#[test]
fn attestation_matrix_covers_all_crates() {
let caps = attest_capabilities();
let crate_names: std::collections::HashSet<&str> =
caps.iter().map(|c| c.crate_name.as_str()).collect();
assert!(crate_names.contains("ruv-neural-core"));
assert!(crate_names.contains("ruv-neural-sensor"));
assert!(crate_names.contains("ruv-neural-signal"));
assert!(crate_names.contains("ruv-neural-graph"));
assert!(crate_names.contains("ruv-neural-mincut"));
assert!(crate_names.contains("ruv-neural-embed"));
assert!(crate_names.contains("ruv-neural-memory"));
assert!(crate_names.contains("ruv-neural-decoder"));
assert!(crate_names.contains("ruv-neural-esp32"));
assert!(crate_names.contains("ruv-neural-viz"));
assert!(crate_names.contains("ruv-neural-cli"));
assert!(crate_names.contains("ruv-neural-wasm"));
}
#[test]
fn hex_roundtrip() {
let data = b"hello world";
let encoded = hex_encode(data);
let decoded = hex_decode(&encoded).unwrap();
assert_eq!(decoded, data);
}
}
@@ -1,25 +0,0 @@
[package]
name = "ruv-neural-decoder"
description = "rUv Neural — Cognitive state classification and BCI decoding from neural topology embeddings"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[features]
default = ["std"]
std = []
wasm = []
[dependencies]
ruv-neural-core = { workspace = true }
# ruv-neural-embed and ruv-neural-memory are available for future integration
# but not currently required for core decoder functionality
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
rand = { workspace = true }
num-traits = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
@@ -1,93 +0,0 @@
# ruv-neural-decoder
Cognitive state classification and BCI decoding from neural topology embeddings.
## Overview
`ruv-neural-decoder` classifies cognitive states from brain graph embeddings and
topology metrics. It provides multiple decoding strategies -- KNN classification
from labeled exemplars, threshold-based rule systems, temporal transition detection,
and clinical biomarker scoring -- plus an ensemble pipeline that combines all
strategies for robust real-time brain-computer interface (BCI) output.
## Features
- **KNN decoder** (`knn_decoder`): K-nearest neighbor classification using stored
labeled embeddings from `ruv-neural-memory`; supports configurable k and distance
metrics
- **Threshold decoder** (`threshold_decoder`): Rule-based classification from
topology metric ranges (mincut value, modularity, efficiency, Fiedler value)
with configurable `TopologyThreshold` bounds per cognitive state
- **Transition decoder** (`transition_decoder`): Detects cognitive state transitions
from temporal topology dynamics; outputs `StateTransition` events matching
known `TransitionPattern` templates
- **Clinical scorer** (`clinical`): `ClinicalScorer` for biomarker detection via
deviation from healthy baseline distributions; flags abnormal topology patterns
- **Ensemble pipeline** (`pipeline`): `DecoderPipeline` combining all decoder
strategies with confidence-weighted voting; produces `DecoderOutput` with
classified state, confidence score, and contributing decoder votes
## Usage
```rust
use ruv_neural_decoder::{
KnnDecoder, ThresholdDecoder, TopologyThreshold,
TransitionDecoder, ClinicalScorer, DecoderPipeline, DecoderOutput,
};
use ruv_neural_core::topology::{CognitiveState, TopologyMetrics};
// Threshold-based decoding from topology metrics
let mut decoder = ThresholdDecoder::new();
decoder.add_threshold(TopologyThreshold {
state: CognitiveState::Focused,
min_modularity: 0.3,
max_modularity: 0.5,
min_efficiency: 0.6,
..Default::default()
});
let state = decoder.decode(&metrics);
// KNN-based decoding from embeddings
let mut knn = KnnDecoder::new(5); // k=5
knn.add_exemplar(embedding, CognitiveState::Rest);
let predicted = knn.classify(&query_embedding);
// Transition detection from temporal sequences
let mut transition_decoder = TransitionDecoder::new();
if let Some(transition) = transition_decoder.check(&current_metrics) {
println!("Transition: {:?} -> {:?}", transition.from, transition.to);
}
// Full ensemble pipeline
let mut pipeline = DecoderPipeline::new();
let output: DecoderOutput = pipeline.decode(&metrics, &embedding);
println!("State: {:?}, confidence: {:.2}", output.state, output.confidence);
```
## API Reference
| Module | Key Types |
|----------------------|------------------------------------------------------------|
| `knn_decoder` | `KnnDecoder` |
| `threshold_decoder` | `ThresholdDecoder`, `TopologyThreshold` |
| `transition_decoder` | `TransitionDecoder`, `StateTransition`, `TransitionPattern`|
| `clinical` | `ClinicalScorer` |
| `pipeline` | `DecoderPipeline`, `DecoderOutput` |
## Feature Flags
| Feature | Default | Description |
|---------|---------|----------------------------------|
| `std` | Yes | Standard library support |
| `wasm` | No | WASM-compatible decoding |
## Integration
Depends on `ruv-neural-core` for `CognitiveState`, `TopologyMetrics`, and
`NeuralEmbedding` types. Consumes embeddings from `ruv-neural-embed` and
topology results from `ruv-neural-mincut`. The KNN decoder can query stored
exemplars from `ruv-neural-memory`.
## License
MIT OR Apache-2.0
@@ -1,357 +0,0 @@
//! Clinical biomarker detection from brain topology deviations.
use ruv_neural_core::topology::TopologyMetrics;
/// Clinical biomarker scorer based on topology deviation from a healthy baseline.
///
/// Computes z-scores of current topology metrics relative to a learned
/// healthy population baseline, then derives disease-specific risk scores
/// and a composite brain health index.
pub struct ClinicalScorer {
/// Mean topology metrics from healthy population.
healthy_baseline: TopologyMetrics,
/// Standard deviation of topology metrics from healthy population.
healthy_std: TopologyMetrics,
}
impl ClinicalScorer {
/// Create a scorer with explicit baseline mean and standard deviation.
pub fn new(baseline: TopologyMetrics, std: TopologyMetrics) -> Self {
Self {
healthy_baseline: baseline,
healthy_std: std,
}
}
/// Learn the healthy baseline from a set of healthy topology observations.
///
/// Computes the mean and standard deviation of each metric across the
/// provided samples.
pub fn learn_baseline(&mut self, healthy_data: &[TopologyMetrics]) {
if healthy_data.is_empty() {
return;
}
let n = healthy_data.len() as f64;
// Compute means.
let mean_mincut = healthy_data.iter().map(|m| m.global_mincut).sum::<f64>() / n;
let mean_mod = healthy_data.iter().map(|m| m.modularity).sum::<f64>() / n;
let mean_eff = healthy_data.iter().map(|m| m.global_efficiency).sum::<f64>() / n;
let mean_loc = healthy_data.iter().map(|m| m.local_efficiency).sum::<f64>() / n;
let mean_ent = healthy_data.iter().map(|m| m.graph_entropy).sum::<f64>() / n;
let mean_fiedler = healthy_data.iter().map(|m| m.fiedler_value).sum::<f64>() / n;
self.healthy_baseline = TopologyMetrics {
global_mincut: mean_mincut,
modularity: mean_mod,
global_efficiency: mean_eff,
local_efficiency: mean_loc,
graph_entropy: mean_ent,
fiedler_value: mean_fiedler,
num_modules: 0,
timestamp: 0.0,
};
// Compute standard deviations.
let std_mincut = std_dev(healthy_data.iter().map(|m| m.global_mincut), mean_mincut);
let std_mod = std_dev(healthy_data.iter().map(|m| m.modularity), mean_mod);
let std_eff = std_dev(
healthy_data.iter().map(|m| m.global_efficiency),
mean_eff,
);
let std_loc = std_dev(
healthy_data.iter().map(|m| m.local_efficiency),
mean_loc,
);
let std_ent = std_dev(healthy_data.iter().map(|m| m.graph_entropy), mean_ent);
let std_fiedler = std_dev(
healthy_data.iter().map(|m| m.fiedler_value),
mean_fiedler,
);
self.healthy_std = TopologyMetrics {
global_mincut: std_mincut,
modularity: std_mod,
global_efficiency: std_eff,
local_efficiency: std_loc,
graph_entropy: std_ent,
fiedler_value: std_fiedler,
num_modules: 0,
timestamp: 0.0,
};
}
/// Composite deviation score (mean absolute z-score across all metrics).
///
/// Higher values indicate greater deviation from healthy baseline.
pub fn deviation_score(&self, current: &TopologyMetrics) -> f64 {
let z_scores = self.z_scores(current);
z_scores.iter().map(|z| z.abs()).sum::<f64>() / z_scores.len() as f64
}
/// Alzheimer's disease risk score in `[0, 1]`.
///
/// Based on characteristic patterns: reduced global efficiency,
/// increased modularity (network fragmentation), reduced mincut.
pub fn alzheimer_risk(&self, current: &TopologyMetrics) -> f64 {
let z = self.z_scores(current);
// z[0]=mincut, z[1]=modularity, z[2]=global_eff, z[3]=local_eff, z[4]=entropy, z[5]=fiedler
// Alzheimer's: decreased efficiency (negative z), decreased mincut (negative z),
// increased modularity (positive z = fragmentation).
let efficiency_component = sigmoid(-z[2], 2.0);
let mincut_component = sigmoid(-z[0], 2.0);
let modularity_component = sigmoid(z[1], 2.0);
let fiedler_component = sigmoid(-z[5], 1.5);
let risk = 0.35 * efficiency_component
+ 0.25 * mincut_component
+ 0.25 * modularity_component
+ 0.15 * fiedler_component;
risk.clamp(0.0, 1.0)
}
/// Epilepsy risk score in `[0, 1]`.
///
/// Based on characteristic patterns: hypersynchrony (increased mincut),
/// decreased modularity, increased local efficiency.
pub fn epilepsy_risk(&self, current: &TopologyMetrics) -> f64 {
let z = self.z_scores(current);
// Epilepsy: increased mincut (hypersynchrony), decreased modularity,
// increased local efficiency.
let mincut_component = sigmoid(z[0], 2.0);
let modularity_component = sigmoid(-z[1], 2.0);
let local_eff_component = sigmoid(z[3], 2.0);
let risk = 0.4 * mincut_component
+ 0.3 * modularity_component
+ 0.3 * local_eff_component;
risk.clamp(0.0, 1.0)
}
/// Depression risk score in `[0, 1]`.
///
/// Based on characteristic patterns: reduced global efficiency,
/// altered entropy, reduced Fiedler value (weaker connectivity).
pub fn depression_risk(&self, current: &TopologyMetrics) -> f64 {
let z = self.z_scores(current);
// Depression: decreased efficiency, decreased Fiedler value,
// altered entropy (can go either way, use absolute deviation).
let efficiency_component = sigmoid(-z[2], 2.0);
let fiedler_component = sigmoid(-z[5], 2.0);
let entropy_component = sigmoid(z[4].abs(), 1.5);
let risk = 0.4 * efficiency_component
+ 0.35 * fiedler_component
+ 0.25 * entropy_component;
risk.clamp(0.0, 1.0)
}
/// General brain health index in `[0, 1]`.
///
/// `0.0` = severe abnormality, `1.0` = perfectly healthy (all metrics
/// within normal range).
pub fn brain_health_index(&self, current: &TopologyMetrics) -> f64 {
let deviation = self.deviation_score(current);
// Map deviation to health: 0 deviation = 1.0 health, large deviation = ~0.0.
let health = (-0.5 * deviation).exp();
health.clamp(0.0, 1.0)
}
/// Compute z-scores for all topology metrics.
///
/// Order: [mincut, modularity, global_efficiency, local_efficiency, entropy, fiedler].
fn z_scores(&self, current: &TopologyMetrics) -> [f64; 6] {
[
z_score(
current.global_mincut,
self.healthy_baseline.global_mincut,
self.healthy_std.global_mincut,
),
z_score(
current.modularity,
self.healthy_baseline.modularity,
self.healthy_std.modularity,
),
z_score(
current.global_efficiency,
self.healthy_baseline.global_efficiency,
self.healthy_std.global_efficiency,
),
z_score(
current.local_efficiency,
self.healthy_baseline.local_efficiency,
self.healthy_std.local_efficiency,
),
z_score(
current.graph_entropy,
self.healthy_baseline.graph_entropy,
self.healthy_std.graph_entropy,
),
z_score(
current.fiedler_value,
self.healthy_baseline.fiedler_value,
self.healthy_std.fiedler_value,
),
]
}
}
/// Compute the z-score: (value - mean) / std.
///
/// Returns 0.0 if std is near zero.
fn z_score(value: f64, mean: f64, std: f64) -> f64 {
if std.abs() < 1e-10 {
return 0.0;
}
(value - mean) / std
}
/// Standard deviation from an iterator of values and a precomputed mean.
fn std_dev(values: impl Iterator<Item = f64>, mean: f64) -> f64 {
let vals: Vec<f64> = values.collect();
if vals.len() < 2 {
return 1.0; // Default to 1.0 to avoid division by zero.
}
let n = vals.len() as f64;
let variance = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
let s = variance.sqrt();
if s < 1e-10 { 1.0 } else { s }
}
/// Sigmoid function mapping a z-score to `[0, 1]`.
///
/// `scale` controls the steepness of the transition.
fn sigmoid(z: f64, scale: f64) -> f64 {
1.0 / (1.0 + (-scale * z).exp())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_metrics(
mincut: f64,
modularity: f64,
efficiency: f64,
entropy: f64,
) -> TopologyMetrics {
TopologyMetrics {
global_mincut: mincut,
modularity,
global_efficiency: efficiency,
local_efficiency: 0.3,
graph_entropy: entropy,
fiedler_value: 0.5,
num_modules: 4,
timestamp: 0.0,
}
}
fn make_baseline_scorer() -> ClinicalScorer {
ClinicalScorer::new(
make_metrics(5.0, 0.4, 0.3, 2.0),
make_metrics(1.0, 0.1, 0.05, 0.3),
)
}
#[test]
fn test_healthy_deviation_near_zero() {
let scorer = make_baseline_scorer();
let healthy = make_metrics(5.0, 0.4, 0.3, 2.0);
let deviation = scorer.deviation_score(&healthy);
assert!(
deviation < 0.5,
"Healthy metrics should have low deviation, got {}",
deviation
);
}
#[test]
fn test_abnormal_deviation_high() {
let scorer = make_baseline_scorer();
let abnormal = make_metrics(15.0, 1.5, 0.9, 8.0);
let deviation = scorer.deviation_score(&abnormal);
assert!(
deviation > 2.0,
"Abnormal metrics should have high deviation, got {}",
deviation
);
}
#[test]
fn test_brain_health_healthy() {
let scorer = make_baseline_scorer();
let healthy = make_metrics(5.0, 0.4, 0.3, 2.0);
let health = scorer.brain_health_index(&healthy);
assert!(
health > 0.8,
"Healthy metrics should yield high health index, got {}",
health
);
}
#[test]
fn test_brain_health_abnormal() {
let scorer = make_baseline_scorer();
let abnormal = make_metrics(15.0, 1.5, 0.9, 8.0);
let health = scorer.brain_health_index(&abnormal);
assert!(
health < 0.5,
"Abnormal metrics should yield low health index, got {}",
health
);
}
#[test]
fn test_disease_risks_in_range() {
let scorer = make_baseline_scorer();
let current = make_metrics(3.0, 0.6, 0.15, 2.5);
let alz = scorer.alzheimer_risk(&current);
let epi = scorer.epilepsy_risk(&current);
let dep = scorer.depression_risk(&current);
assert!(alz >= 0.0 && alz <= 1.0, "Alzheimer risk out of range: {}", alz);
assert!(epi >= 0.0 && epi <= 1.0, "Epilepsy risk out of range: {}", epi);
assert!(dep >= 0.0 && dep <= 1.0, "Depression risk out of range: {}", dep);
}
#[test]
fn test_learn_baseline() {
let mut scorer = ClinicalScorer::new(
make_metrics(0.0, 0.0, 0.0, 0.0),
make_metrics(1.0, 1.0, 1.0, 1.0),
);
let data = vec![
make_metrics(5.0, 0.4, 0.3, 2.0),
make_metrics(5.2, 0.42, 0.31, 2.1),
make_metrics(4.8, 0.38, 0.29, 1.9),
];
scorer.learn_baseline(&data);
// After learning, healthy data should have low deviation.
let deviation = scorer.deviation_score(&make_metrics(5.0, 0.4, 0.3, 2.0));
assert!(deviation < 1.0, "Post-learning deviation too high: {}", deviation);
}
#[test]
fn test_health_index_range() {
let scorer = make_baseline_scorer();
// Test extreme values.
for mincut in [0.0, 5.0, 20.0] {
for mod_val in [0.0, 0.4, 1.0] {
let m = make_metrics(mincut, mod_val, 0.3, 2.0);
let h = scorer.brain_health_index(&m);
assert!(h >= 0.0 && h <= 1.0, "Health index out of range: {}", h);
}
}
}
}
@@ -1,222 +0,0 @@
//! K-Nearest Neighbor decoder for cognitive state classification.
use std::collections::HashMap;
use ruv_neural_core::embedding::NeuralEmbedding;
use ruv_neural_core::error::{Result, RuvNeuralError};
use ruv_neural_core::topology::CognitiveState;
use ruv_neural_core::traits::StateDecoder;
/// Simple KNN decoder using stored labeled embeddings.
///
/// Classifies a query embedding by majority vote among its `k` nearest
/// neighbors in Euclidean distance.
pub struct KnnDecoder {
labeled_embeddings: Vec<(NeuralEmbedding, CognitiveState)>,
k: usize,
}
impl KnnDecoder {
/// Create a new KNN decoder with the given `k` (number of neighbors).
pub fn new(k: usize) -> Self {
let k = if k == 0 { 1 } else { k };
Self {
labeled_embeddings: Vec::new(),
k,
}
}
/// Load labeled training data into the decoder.
pub fn train(&mut self, embeddings: Vec<(NeuralEmbedding, CognitiveState)>) {
self.labeled_embeddings = embeddings;
}
/// Predict the cognitive state for a query embedding using majority vote.
///
/// Returns `CognitiveState::Unknown` if no training data is available.
pub fn predict(&self, embedding: &NeuralEmbedding) -> CognitiveState {
self.predict_with_confidence(embedding).0
}
/// Predict the cognitive state with a confidence score in `[0, 1]`.
///
/// Confidence is the fraction of the `k` nearest neighbors that agree
/// on the winning state.
pub fn predict_with_confidence(&self, embedding: &NeuralEmbedding) -> (CognitiveState, f64) {
if self.labeled_embeddings.is_empty() {
return (CognitiveState::Unknown, 0.0);
}
// Compute distances to all stored embeddings.
let mut distances: Vec<(f64, &CognitiveState)> = self
.labeled_embeddings
.iter()
.filter_map(|(stored, state)| {
let dist = euclidean_distance(&embedding.vector, &stored.vector);
Some((dist, state))
})
.collect();
// Sort by distance ascending.
distances.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
// Take top-k neighbors.
let k = self.k.min(distances.len());
let neighbors = &distances[..k];
// Majority vote with distance weighting.
let mut vote_counts: HashMap<CognitiveState, f64> = HashMap::new();
for (dist, state) in neighbors {
// Use inverse distance weighting; add epsilon to avoid division by zero.
let weight = 1.0 / (dist + 1e-10);
*vote_counts.entry(**state).or_insert(0.0) += weight;
}
// Find the state with the highest weighted vote.
let total_weight: f64 = vote_counts.values().sum();
let (best_state, best_weight) = vote_counts
.into_iter()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((CognitiveState::Unknown, 0.0));
let confidence = if total_weight > 0.0 {
(best_weight / total_weight).clamp(0.0, 1.0)
} else {
0.0
};
(best_state, confidence)
}
/// Number of stored labeled embeddings.
pub fn num_samples(&self) -> usize {
self.labeled_embeddings.len()
}
}
impl StateDecoder for KnnDecoder {
fn decode(&self, embedding: &NeuralEmbedding) -> Result<CognitiveState> {
if self.labeled_embeddings.is_empty() {
return Err(RuvNeuralError::Decoder(
"KNN decoder has no training data".into(),
));
}
Ok(self.predict(embedding))
}
fn decode_with_confidence(
&self,
embedding: &NeuralEmbedding,
) -> Result<(CognitiveState, f64)> {
if self.labeled_embeddings.is_empty() {
return Err(RuvNeuralError::Decoder(
"KNN decoder has no training data".into(),
));
}
Ok(self.predict_with_confidence(embedding))
}
}
/// Euclidean distance between two vectors of the same length.
///
/// If lengths differ, computes distance over the shorter prefix.
fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
a.iter()
.zip(b.iter())
.map(|(x, y)| (x - y) * (x - y))
.sum::<f64>()
.sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use ruv_neural_core::brain::Atlas;
use ruv_neural_core::embedding::EmbeddingMetadata;
fn make_embedding(vector: Vec<f64>) -> NeuralEmbedding {
NeuralEmbedding::new(
vector,
0.0,
EmbeddingMetadata {
subject_id: None,
session_id: None,
cognitive_state: None,
source_atlas: Atlas::DesikanKilliany68,
embedding_method: "test".into(),
},
)
.unwrap()
}
#[test]
fn test_knn_classifies_correctly() {
let mut decoder = KnnDecoder::new(3);
decoder.train(vec![
(make_embedding(vec![1.0, 0.0, 0.0]), CognitiveState::Rest),
(make_embedding(vec![1.1, 0.1, 0.0]), CognitiveState::Rest),
(make_embedding(vec![0.9, 0.0, 0.1]), CognitiveState::Rest),
(
make_embedding(vec![0.0, 1.0, 0.0]),
CognitiveState::Focused,
),
(
make_embedding(vec![0.1, 1.1, 0.0]),
CognitiveState::Focused,
),
(
make_embedding(vec![0.0, 0.9, 0.1]),
CognitiveState::Focused,
),
]);
// Query near the Rest cluster.
let query = make_embedding(vec![1.0, 0.05, 0.0]);
let (state, confidence) = decoder.predict_with_confidence(&query);
assert_eq!(state, CognitiveState::Rest);
assert!(confidence > 0.5);
// Query near the Focused cluster.
let query = make_embedding(vec![0.05, 1.0, 0.0]);
let state = decoder.predict(&query);
assert_eq!(state, CognitiveState::Focused);
}
#[test]
fn test_knn_empty_returns_unknown() {
let decoder = KnnDecoder::new(3);
let query = make_embedding(vec![1.0, 0.0]);
assert_eq!(decoder.predict(&query), CognitiveState::Unknown);
}
#[test]
fn test_confidence_in_range() {
let mut decoder = KnnDecoder::new(3);
decoder.train(vec![
(make_embedding(vec![1.0, 0.0]), CognitiveState::Rest),
(make_embedding(vec![0.0, 1.0]), CognitiveState::Focused),
]);
let query = make_embedding(vec![0.5, 0.5]);
let (_, confidence) = decoder.predict_with_confidence(&query);
assert!(confidence >= 0.0 && confidence <= 1.0);
}
#[test]
fn test_state_decoder_trait() {
let mut decoder = KnnDecoder::new(1);
decoder.train(vec![(
make_embedding(vec![1.0, 0.0]),
CognitiveState::MotorPlanning,
)]);
let query = make_embedding(vec![1.0, 0.0]);
let result = decoder.decode(&query).unwrap();
assert_eq!(result, CognitiveState::MotorPlanning);
}
#[test]
fn test_state_decoder_empty_errors() {
let decoder = KnnDecoder::new(3);
let query = make_embedding(vec![1.0]);
assert!(decoder.decode(&query).is_err());
}
}
@@ -1,23 +0,0 @@
//! rUv Neural Decoder -- Cognitive state classification and BCI decoding
//! from neural topology embeddings.
//!
//! This crate provides multiple decoding strategies for classifying cognitive
//! states from brain graph embeddings and topology metrics:
//!
//! - **KNN Decoder**: K-nearest neighbor classification using stored labeled embeddings
//! - **Threshold Decoder**: Rule-based classification from topology metric ranges
//! - **Transition Decoder**: State transition detection from topology dynamics
//! - **Clinical Scorer**: Biomarker detection via deviation from healthy baselines
//! - **Pipeline**: End-to-end ensemble decoder combining all strategies
pub mod clinical;
pub mod knn_decoder;
pub mod pipeline;
pub mod threshold_decoder;
pub mod transition_decoder;
pub use clinical::ClinicalScorer;
pub use knn_decoder::KnnDecoder;
pub use pipeline::{DecoderOutput, DecoderPipeline};
pub use threshold_decoder::{ThresholdDecoder, TopologyThreshold};
pub use transition_decoder::{StateTransition, TransitionDecoder, TransitionPattern};
@@ -1,369 +0,0 @@
//! End-to-end decoder pipeline combining multiple decoding strategies.
use ruv_neural_core::embedding::NeuralEmbedding;
use ruv_neural_core::topology::{CognitiveState, TopologyMetrics};
use serde::{Deserialize, Serialize};
use crate::clinical::ClinicalScorer;
use crate::knn_decoder::KnnDecoder;
use crate::threshold_decoder::ThresholdDecoder;
use crate::transition_decoder::{StateTransition, TransitionDecoder};
/// End-to-end decoder pipeline that ensembles multiple decoding strategies.
///
/// Combines KNN, threshold, and transition decoders with configurable
/// ensemble weights, and optionally includes clinical scoring.
pub struct DecoderPipeline {
knn: Option<KnnDecoder>,
threshold: Option<ThresholdDecoder>,
transition: Option<TransitionDecoder>,
clinical: Option<ClinicalScorer>,
/// Ensemble weights: [knn_weight, threshold_weight, transition_weight].
ensemble_weights: [f64; 3],
}
/// Output of the decoder pipeline.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecoderOutput {
/// Decoded cognitive state (ensemble result).
pub state: CognitiveState,
/// Overall confidence in `[0, 1]`.
pub confidence: f64,
/// Detected state transition, if any.
pub transition: Option<StateTransition>,
/// Brain health index from clinical scorer, if configured.
pub brain_health_index: Option<f64>,
/// Clinical warning flags.
pub clinical_flags: Vec<String>,
/// Timestamp of the input data.
pub timestamp: f64,
}
impl DecoderPipeline {
/// Create an empty pipeline with default ensemble weights.
pub fn new() -> Self {
Self {
knn: None,
threshold: None,
transition: None,
clinical: None,
ensemble_weights: [1.0, 1.0, 1.0],
}
}
/// Add a KNN decoder to the pipeline.
pub fn with_knn(mut self, k: usize) -> Self {
self.knn = Some(KnnDecoder::new(k));
self
}
/// Add a threshold decoder to the pipeline.
pub fn with_thresholds(mut self) -> Self {
self.threshold = Some(ThresholdDecoder::new());
self
}
/// Add a transition decoder to the pipeline.
pub fn with_transitions(mut self, window: usize) -> Self {
self.transition = Some(TransitionDecoder::new(window));
self
}
/// Add a clinical scorer to the pipeline.
pub fn with_clinical(mut self, baseline: TopologyMetrics, std: TopologyMetrics) -> Self {
self.clinical = Some(ClinicalScorer::new(baseline, std));
self
}
/// Set custom ensemble weights for [knn, threshold, transition].
pub fn with_weights(mut self, weights: [f64; 3]) -> Self {
self.ensemble_weights = weights;
self
}
/// Get a mutable reference to the KNN decoder (for training).
pub fn knn_mut(&mut self) -> Option<&mut KnnDecoder> {
self.knn.as_mut()
}
/// Get a mutable reference to the threshold decoder (for configuring thresholds).
pub fn threshold_mut(&mut self) -> Option<&mut ThresholdDecoder> {
self.threshold.as_mut()
}
/// Get a mutable reference to the transition decoder (for registering patterns).
pub fn transition_mut(&mut self) -> Option<&mut TransitionDecoder> {
self.transition.as_mut()
}
/// Get a mutable reference to the clinical scorer.
pub fn clinical_mut(&mut self) -> Option<&mut ClinicalScorer> {
self.clinical.as_mut()
}
/// Run the full decoding pipeline on an embedding and topology metrics.
pub fn decode(
&mut self,
embedding: &NeuralEmbedding,
metrics: &TopologyMetrics,
) -> DecoderOutput {
let mut candidates: Vec<(CognitiveState, f64, f64)> = Vec::new(); // (state, confidence, weight)
// KNN decoder.
if let Some(ref knn) = self.knn {
let (state, conf) = knn.predict_with_confidence(embedding);
if state != CognitiveState::Unknown {
candidates.push((state, conf, self.ensemble_weights[0]));
}
}
// Threshold decoder.
if let Some(ref threshold) = self.threshold {
let (state, conf) = threshold.decode(metrics);
if state != CognitiveState::Unknown {
candidates.push((state, conf, self.ensemble_weights[1]));
}
}
// Transition decoder.
let transition = if let Some(ref mut trans) = self.transition {
let result = trans.update(metrics.clone());
if let Some(ref t) = result {
candidates.push((t.to, t.confidence, self.ensemble_weights[2]));
}
result
} else {
None
};
// Ensemble: weighted vote.
let (state, confidence) = if candidates.is_empty() {
(CognitiveState::Unknown, 0.0)
} else {
weighted_vote(&candidates)
};
// Clinical scoring.
let mut brain_health_index = None;
let mut clinical_flags = Vec::new();
if let Some(ref clinical) = self.clinical {
let health = clinical.brain_health_index(metrics);
brain_health_index = Some(health);
let alz = clinical.alzheimer_risk(metrics);
let epi = clinical.epilepsy_risk(metrics);
let dep = clinical.depression_risk(metrics);
if alz > 0.7 {
clinical_flags.push(format!("Elevated Alzheimer risk: {:.2}", alz));
}
if epi > 0.7 {
clinical_flags.push(format!("Elevated epilepsy risk: {:.2}", epi));
}
if dep > 0.7 {
clinical_flags.push(format!("Elevated depression risk: {:.2}", dep));
}
if health < 0.3 {
clinical_flags.push(format!("Low brain health index: {:.2}", health));
}
}
DecoderOutput {
state,
confidence,
transition,
brain_health_index,
clinical_flags,
timestamp: metrics.timestamp,
}
}
}
impl Default for DecoderPipeline {
fn default() -> Self {
Self::new()
}
}
/// Weighted majority vote across candidate predictions.
///
/// Returns the state with the highest weighted confidence and the
/// normalized confidence score.
fn weighted_vote(candidates: &[(CognitiveState, f64, f64)]) -> (CognitiveState, f64) {
use std::collections::HashMap;
let mut state_scores: HashMap<CognitiveState, f64> = HashMap::new();
let mut total_weight = 0.0;
for &(state, confidence, weight) in candidates {
let score = confidence * weight;
*state_scores.entry(state).or_insert(0.0) += score;
total_weight += score;
}
let (best_state, best_score) = state_scores
.into_iter()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((CognitiveState::Unknown, 0.0));
let normalized = if total_weight > 0.0 {
(best_score / total_weight).clamp(0.0, 1.0)
} else {
0.0
};
(best_state, normalized)
}
#[cfg(test)]
mod tests {
use super::*;
use ruv_neural_core::brain::Atlas;
use ruv_neural_core::embedding::EmbeddingMetadata;
fn make_embedding(vector: Vec<f64>) -> NeuralEmbedding {
NeuralEmbedding::new(
vector,
0.0,
EmbeddingMetadata {
subject_id: None,
session_id: None,
cognitive_state: None,
source_atlas: Atlas::DesikanKilliany68,
embedding_method: "test".into(),
},
)
.unwrap()
}
fn make_metrics(mincut: f64, modularity: f64) -> TopologyMetrics {
TopologyMetrics {
global_mincut: mincut,
modularity,
global_efficiency: 0.3,
local_efficiency: 0.2,
graph_entropy: 2.0,
fiedler_value: 0.5,
num_modules: 4,
timestamp: 0.0,
}
}
#[test]
fn test_empty_pipeline() {
let mut pipeline = DecoderPipeline::new();
let emb = make_embedding(vec![1.0, 0.0]);
let met = make_metrics(5.0, 0.4);
let output = pipeline.decode(&emb, &met);
assert_eq!(output.state, CognitiveState::Unknown);
assert!(output.confidence >= 0.0 && output.confidence <= 1.0);
}
#[test]
fn test_pipeline_with_knn() {
let mut pipeline = DecoderPipeline::new().with_knn(3);
pipeline.knn_mut().unwrap().train(vec![
(make_embedding(vec![1.0, 0.0]), CognitiveState::Rest),
(make_embedding(vec![1.1, 0.1]), CognitiveState::Rest),
(make_embedding(vec![0.9, 0.0]), CognitiveState::Rest),
]);
let output = pipeline.decode(&make_embedding(vec![1.0, 0.05]), &make_metrics(5.0, 0.4));
assert_eq!(output.state, CognitiveState::Rest);
assert!(output.confidence > 0.0);
}
#[test]
fn test_pipeline_with_thresholds() {
let mut pipeline = DecoderPipeline::new().with_thresholds();
pipeline.threshold_mut().unwrap().set_threshold(
CognitiveState::Focused,
crate::threshold_decoder::TopologyThreshold {
mincut_range: (7.0, 9.0),
modularity_range: (0.5, 0.7),
efficiency_range: (0.2, 0.4),
entropy_range: (1.5, 2.5),
},
);
let output = pipeline.decode(
&make_embedding(vec![0.5, 0.5]),
&make_metrics(8.0, 0.6),
);
assert_eq!(output.state, CognitiveState::Focused);
}
#[test]
fn test_pipeline_with_clinical() {
let baseline = make_metrics(5.0, 0.4);
let std_met = TopologyMetrics {
global_mincut: 1.0,
modularity: 0.1,
global_efficiency: 0.05,
local_efficiency: 0.05,
graph_entropy: 0.3,
fiedler_value: 0.1,
num_modules: 1,
timestamp: 0.0,
};
let mut pipeline = DecoderPipeline::new()
.with_knn(1)
.with_clinical(baseline, std_met);
pipeline.knn_mut().unwrap().train(vec![(
make_embedding(vec![1.0]),
CognitiveState::Rest,
)]);
let output = pipeline.decode(&make_embedding(vec![1.0]), &make_metrics(5.0, 0.4));
assert!(output.brain_health_index.is_some());
let health = output.brain_health_index.unwrap();
assert!(health >= 0.0 && health <= 1.0);
}
#[test]
fn test_pipeline_all_decoders() {
let baseline = make_metrics(5.0, 0.4);
let std_met = TopologyMetrics {
global_mincut: 1.0,
modularity: 0.1,
global_efficiency: 0.05,
local_efficiency: 0.05,
graph_entropy: 0.3,
fiedler_value: 0.1,
num_modules: 1,
timestamp: 0.0,
};
let mut pipeline = DecoderPipeline::new()
.with_knn(3)
.with_thresholds()
.with_transitions(5)
.with_clinical(baseline, std_met);
pipeline.knn_mut().unwrap().train(vec![
(make_embedding(vec![1.0, 0.0]), CognitiveState::Rest),
(make_embedding(vec![1.1, 0.1]), CognitiveState::Rest),
]);
let output = pipeline.decode(&make_embedding(vec![1.0, 0.05]), &make_metrics(5.0, 0.4));
// Should produce some output regardless of which decoders fire.
assert!(output.confidence >= 0.0 && output.confidence <= 1.0);
assert!(output.brain_health_index.is_some());
}
#[test]
fn test_decoder_output_serialization() {
let output = DecoderOutput {
state: CognitiveState::Rest,
confidence: 0.95,
transition: None,
brain_health_index: Some(0.92),
clinical_flags: vec![],
timestamp: 1234.5,
};
let json = serde_json::to_string(&output).unwrap();
let parsed: DecoderOutput = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.state, CognitiveState::Rest);
assert!((parsed.confidence - 0.95).abs() < 1e-10);
}
}
@@ -1,240 +0,0 @@
//! Threshold-based topology decoder for cognitive state classification.
use std::collections::HashMap;
use ruv_neural_core::topology::{CognitiveState, TopologyMetrics};
use serde::{Deserialize, Serialize};
/// Decode cognitive states from topology metrics using learned thresholds.
///
/// Each cognitive state is associated with expected ranges for key topology
/// metrics (mincut, modularity, efficiency, entropy). The decoder scores
/// each candidate state by how well the input metrics fall within the
/// expected ranges.
pub struct ThresholdDecoder {
thresholds: HashMap<CognitiveState, TopologyThreshold>,
}
/// Threshold ranges for topology metrics associated with a cognitive state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopologyThreshold {
/// Expected range for global minimum cut value.
pub mincut_range: (f64, f64),
/// Expected range for modularity.
pub modularity_range: (f64, f64),
/// Expected range for global efficiency.
pub efficiency_range: (f64, f64),
/// Expected range for graph entropy.
pub entropy_range: (f64, f64),
}
impl TopologyThreshold {
/// Score how well a set of metrics matches this threshold.
///
/// Returns a value in `[0, 1]` where 1.0 means all metrics fall within
/// the expected ranges.
fn score(&self, metrics: &TopologyMetrics) -> f64 {
let scores = [
range_score(metrics.global_mincut, self.mincut_range),
range_score(metrics.modularity, self.modularity_range),
range_score(metrics.global_efficiency, self.efficiency_range),
range_score(metrics.graph_entropy, self.entropy_range),
];
scores.iter().sum::<f64>() / scores.len() as f64
}
}
impl ThresholdDecoder {
/// Create a new threshold decoder with no thresholds defined.
pub fn new() -> Self {
Self {
thresholds: HashMap::new(),
}
}
/// Set the threshold for a specific cognitive state.
pub fn set_threshold(&mut self, state: CognitiveState, threshold: TopologyThreshold) {
self.thresholds.insert(state, threshold);
}
/// Learn thresholds from labeled topology data.
///
/// For each cognitive state present in the data, computes the min/max
/// range of each metric with a 10% margin.
pub fn learn_thresholds(&mut self, labeled_data: &[(TopologyMetrics, CognitiveState)]) {
// Group metrics by state.
let mut grouped: HashMap<CognitiveState, Vec<&TopologyMetrics>> = HashMap::new();
for (metrics, state) in labeled_data {
grouped.entry(*state).or_default().push(metrics);
}
for (state, metrics_vec) in grouped {
if metrics_vec.is_empty() {
continue;
}
let mincut_range = compute_range(metrics_vec.iter().map(|m| m.global_mincut));
let modularity_range = compute_range(metrics_vec.iter().map(|m| m.modularity));
let efficiency_range =
compute_range(metrics_vec.iter().map(|m| m.global_efficiency));
let entropy_range = compute_range(metrics_vec.iter().map(|m| m.graph_entropy));
self.thresholds.insert(
state,
TopologyThreshold {
mincut_range,
modularity_range,
efficiency_range,
entropy_range,
},
);
}
}
/// Decode the cognitive state from topology metrics.
///
/// Returns the best-matching state and a confidence score in `[0, 1]`.
/// If no thresholds are defined, returns `(Unknown, 0.0)`.
pub fn decode(&self, metrics: &TopologyMetrics) -> (CognitiveState, f64) {
if self.thresholds.is_empty() {
return (CognitiveState::Unknown, 0.0);
}
let mut best_state = CognitiveState::Unknown;
let mut best_score = -1.0_f64;
for (state, threshold) in &self.thresholds {
let score = threshold.score(metrics);
if score > best_score {
best_score = score;
best_state = *state;
}
}
(best_state, best_score.clamp(0.0, 1.0))
}
/// Number of states with defined thresholds.
pub fn num_states(&self) -> usize {
self.thresholds.len()
}
}
impl Default for ThresholdDecoder {
fn default() -> Self {
Self::new()
}
}
/// Compute the range (min, max) from an iterator of values, with a 10% margin.
fn compute_range(values: impl Iterator<Item = f64>) -> (f64, f64) {
let vals: Vec<f64> = values.collect();
if vals.is_empty() {
return (0.0, 0.0);
}
let min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
let max = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let margin = (max - min).abs() * 0.1;
(min - margin, max + margin)
}
/// Score how well a value falls within a range.
///
/// Returns 1.0 if within range, decays toward 0.0 as the value moves
/// further outside.
fn range_score(value: f64, (lo, hi): (f64, f64)) -> f64 {
if value >= lo && value <= hi {
return 1.0;
}
let range_width = (hi - lo).abs().max(1e-10);
if value < lo {
let distance = lo - value;
(-distance / range_width).exp()
} else {
let distance = value - hi;
(-distance / range_width).exp()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_metrics(mincut: f64, modularity: f64, efficiency: f64, entropy: f64) -> TopologyMetrics {
TopologyMetrics {
global_mincut: mincut,
modularity,
global_efficiency: efficiency,
local_efficiency: 0.0,
graph_entropy: entropy,
fiedler_value: 0.0,
num_modules: 4,
timestamp: 0.0,
}
}
#[test]
fn test_learn_thresholds() {
let mut decoder = ThresholdDecoder::new();
let data = vec![
(make_metrics(5.0, 0.4, 0.3, 2.0), CognitiveState::Rest),
(make_metrics(5.5, 0.45, 0.32, 2.1), CognitiveState::Rest),
(make_metrics(5.2, 0.42, 0.31, 2.05), CognitiveState::Rest),
(make_metrics(8.0, 0.6, 0.5, 3.0), CognitiveState::Focused),
(make_metrics(8.5, 0.65, 0.52, 3.1), CognitiveState::Focused),
];
decoder.learn_thresholds(&data);
assert_eq!(decoder.num_states(), 2);
// Query with Rest-like metrics.
let (state, confidence) = decoder.decode(&make_metrics(5.1, 0.41, 0.31, 2.03));
assert_eq!(state, CognitiveState::Rest);
assert!(confidence > 0.5);
}
#[test]
fn test_set_threshold() {
let mut decoder = ThresholdDecoder::new();
decoder.set_threshold(
CognitiveState::Rest,
TopologyThreshold {
mincut_range: (4.0, 6.0),
modularity_range: (0.3, 0.5),
efficiency_range: (0.2, 0.4),
entropy_range: (1.5, 2.5),
},
);
let (state, confidence) = decoder.decode(&make_metrics(5.0, 0.4, 0.3, 2.0));
assert_eq!(state, CognitiveState::Rest);
assert!((confidence - 1.0).abs() < 1e-10);
}
#[test]
fn test_empty_decoder_returns_unknown() {
let decoder = ThresholdDecoder::new();
let (state, confidence) = decoder.decode(&make_metrics(5.0, 0.4, 0.3, 2.0));
assert_eq!(state, CognitiveState::Unknown);
assert!((confidence - 0.0).abs() < 1e-10);
}
#[test]
fn test_confidence_in_range() {
let mut decoder = ThresholdDecoder::new();
decoder.set_threshold(
CognitiveState::Focused,
TopologyThreshold {
mincut_range: (7.0, 9.0),
modularity_range: (0.5, 0.7),
efficiency_range: (0.4, 0.6),
entropy_range: (2.5, 3.5),
},
);
// Query outside all ranges.
let (_, confidence) = decoder.decode(&make_metrics(0.0, 0.0, 0.0, 0.0));
assert!(confidence >= 0.0 && confidence <= 1.0);
}
}
@@ -1,298 +0,0 @@
//! Transition decoder for detecting cognitive state changes from topology dynamics.
use std::collections::HashMap;
use ruv_neural_core::topology::{CognitiveState, TopologyMetrics};
use serde::{Deserialize, Serialize};
/// Detect cognitive state transitions from topology change patterns.
///
/// Monitors a sliding window of topology metrics and compares observed
/// deltas against registered transition patterns to detect state changes.
pub struct TransitionDecoder {
current_state: CognitiveState,
transition_patterns: HashMap<(CognitiveState, CognitiveState), TransitionPattern>,
history: Vec<TopologyMetrics>,
window_size: usize,
}
/// A pattern describing the expected topology change during a state transition.
#[derive(Debug, Clone)]
pub struct TransitionPattern {
/// Expected change in global minimum cut value.
pub mincut_delta: f64,
/// Expected change in modularity.
pub modularity_delta: f64,
/// Expected duration of the transition in seconds.
pub duration_s: f64,
}
/// A detected state transition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateTransition {
/// State before the transition.
pub from: CognitiveState,
/// State after the transition.
pub to: CognitiveState,
/// Confidence of the detection in `[0, 1]`.
pub confidence: f64,
/// Timestamp when the transition was detected.
pub timestamp: f64,
}
impl TransitionDecoder {
/// Create a new transition decoder with a given sliding window size.
///
/// The window size determines how many recent topology snapshots are
/// retained for computing deltas.
pub fn new(window_size: usize) -> Self {
let window_size = if window_size < 2 { 2 } else { window_size };
Self {
current_state: CognitiveState::Unknown,
transition_patterns: HashMap::new(),
history: Vec::new(),
window_size,
}
}
/// Register a transition pattern between two states.
pub fn register_pattern(
&mut self,
from: CognitiveState,
to: CognitiveState,
pattern: TransitionPattern,
) {
self.transition_patterns.insert((from, to), pattern);
}
/// Get the current estimated cognitive state.
pub fn current_state(&self) -> CognitiveState {
self.current_state
}
/// Set the current state explicitly (e.g., from an external decoder).
pub fn set_current_state(&mut self, state: CognitiveState) {
self.current_state = state;
}
/// Push a new topology snapshot and check for state transitions.
///
/// Returns `Some(StateTransition)` if a transition is detected,
/// `None` otherwise.
pub fn update(&mut self, metrics: TopologyMetrics) -> Option<StateTransition> {
self.history.push(metrics);
// Trim history to window size.
if self.history.len() > self.window_size {
let excess = self.history.len() - self.window_size;
self.history.drain(..excess);
}
// Need at least 2 samples to compute deltas.
if self.history.len() < 2 {
return None;
}
let oldest = &self.history[0];
let newest = self.history.last().unwrap();
let observed_mincut_delta = newest.global_mincut - oldest.global_mincut;
let observed_modularity_delta = newest.modularity - oldest.modularity;
let observed_duration = newest.timestamp - oldest.timestamp;
// Score each registered pattern.
let mut best_match: Option<(CognitiveState, f64)> = None;
for (&(from, to), pattern) in &self.transition_patterns {
// Only consider patterns starting from the current state.
if from != self.current_state {
continue;
}
let score = pattern_match_score(
observed_mincut_delta,
observed_modularity_delta,
observed_duration,
pattern,
);
if score > 0.5 {
if let Some((_, best_score)) = &best_match {
if score > *best_score {
best_match = Some((to, score));
}
} else {
best_match = Some((to, score));
}
}
}
if let Some((to_state, confidence)) = best_match {
let transition = StateTransition {
from: self.current_state,
to: to_state,
confidence: confidence.clamp(0.0, 1.0),
timestamp: newest.timestamp,
};
self.current_state = to_state;
Some(transition)
} else {
None
}
}
/// Number of registered transition patterns.
pub fn num_patterns(&self) -> usize {
self.transition_patterns.len()
}
/// Number of topology snapshots in the history buffer.
pub fn history_len(&self) -> usize {
self.history.len()
}
}
/// Compute a similarity score between observed deltas and a transition pattern.
///
/// Returns a value in `[0, 1]` where 1.0 means a perfect match.
fn pattern_match_score(
observed_mincut_delta: f64,
observed_modularity_delta: f64,
observed_duration: f64,
pattern: &TransitionPattern,
) -> f64 {
let mincut_score = if pattern.mincut_delta.abs() < 1e-10 {
if observed_mincut_delta.abs() < 0.5 {
1.0
} else {
0.5
}
} else {
let ratio = observed_mincut_delta / pattern.mincut_delta;
gaussian_score(ratio, 1.0, 0.5)
};
let modularity_score = if pattern.modularity_delta.abs() < 1e-10 {
if observed_modularity_delta.abs() < 0.05 {
1.0
} else {
0.5
}
} else {
let ratio = observed_modularity_delta / pattern.modularity_delta;
gaussian_score(ratio, 1.0, 0.5)
};
let duration_score = if pattern.duration_s.abs() < 1e-10 {
1.0
} else {
let ratio = observed_duration / pattern.duration_s;
gaussian_score(ratio, 1.0, 0.5)
};
(mincut_score + modularity_score + duration_score) / 3.0
}
/// Gaussian-shaped score centered at `center` with width `sigma`.
fn gaussian_score(value: f64, center: f64, sigma: f64) -> f64 {
let diff = value - center;
(-0.5 * (diff / sigma).powi(2)).exp()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_metrics(
mincut: f64,
modularity: f64,
timestamp: f64,
) -> TopologyMetrics {
TopologyMetrics {
global_mincut: mincut,
modularity,
global_efficiency: 0.3,
local_efficiency: 0.0,
graph_entropy: 2.0,
fiedler_value: 0.0,
num_modules: 4,
timestamp,
}
}
#[test]
fn test_detect_state_transition() {
let mut decoder = TransitionDecoder::new(5);
decoder.set_current_state(CognitiveState::Rest);
// Register a pattern: Rest -> Focused causes mincut increase and modularity increase.
decoder.register_pattern(
CognitiveState::Rest,
CognitiveState::Focused,
TransitionPattern {
mincut_delta: 3.0,
modularity_delta: 0.2,
duration_s: 2.0,
},
);
// Feed metrics that progressively match the pattern.
// The transition may fire on any update once deltas are large enough.
let updates = vec![
make_metrics(5.0, 0.4, 0.0),
make_metrics(6.0, 0.45, 0.5),
make_metrics(7.0, 0.5, 1.0),
make_metrics(8.0, 0.6, 2.0),
];
let mut detected: Option<StateTransition> = None;
for m in updates {
if let Some(t) = decoder.update(m) {
detected = Some(t);
}
}
assert!(detected.is_some(), "Expected a transition to be detected");
let transition = detected.unwrap();
assert_eq!(transition.from, CognitiveState::Rest);
assert_eq!(transition.to, CognitiveState::Focused);
assert!(transition.confidence > 0.0 && transition.confidence <= 1.0);
}
#[test]
fn test_no_transition_without_pattern() {
let mut decoder = TransitionDecoder::new(3);
decoder.set_current_state(CognitiveState::Rest);
let result = decoder.update(make_metrics(5.0, 0.4, 0.0));
assert!(result.is_none());
let result = decoder.update(make_metrics(8.0, 0.6, 2.0));
assert!(result.is_none());
}
#[test]
fn test_window_trimming() {
let mut decoder = TransitionDecoder::new(3);
for i in 0..10 {
decoder.update(make_metrics(5.0, 0.4, i as f64));
}
assert_eq!(decoder.history_len(), 3);
}
#[test]
fn test_single_sample_no_transition() {
let mut decoder = TransitionDecoder::new(5);
decoder.register_pattern(
CognitiveState::Rest,
CognitiveState::Focused,
TransitionPattern {
mincut_delta: 3.0,
modularity_delta: 0.2,
duration_s: 2.0,
},
);
decoder.set_current_state(CognitiveState::Rest);
let result = decoder.update(make_metrics(5.0, 0.4, 0.0));
assert!(result.is_none());
}
}
@@ -1,25 +0,0 @@
[package]
name = "ruv-neural-embed"
description = "rUv Neural — Graph embedding generation for brain connectivity states using RuVector format"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[features]
default = ["std"]
std = []
wasm = []
rvf = []
[dependencies]
ruv-neural-core = { workspace = true }
ndarray = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
num-traits = { workspace = true }
rand = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
@@ -1,90 +0,0 @@
# ruv-neural-embed
Graph embedding generation for brain connectivity states using RuVector format.
## Overview
`ruv-neural-embed` converts brain connectivity graphs into fixed-dimensional
vector representations suitable for downstream classification, clustering, and
temporal analysis. It provides multiple embedding methods and supports export
to the RuVector `.rvf` binary format for interoperability with the broader
RuVector ecosystem.
## Features
- **Spectral embedding** (`spectral_embed`): Laplacian eigenvector-based positional
encoding from the graph's normalized Laplacian
- **Topology embedding** (`topology_embed`): Hand-crafted topological feature vectors
derived from graph-theoretic metrics
- **Node2Vec** (`node2vec`): Random-walk co-occurrence embeddings using configurable
walk length, return parameter (p), and in-out parameter (q)
- **Combined embedding** (`combined`): Weighted concatenation of multiple embedding
methods into a single vector
- **Temporal embedding** (`temporal`): Sliding-window context-enriched embeddings
that capture graph dynamics over time
- **Distance metrics** (`distance`): Embedding distance and similarity computations
- **RVF export** (`rvf_export`): Serialization of embeddings and trajectories to the
RuVector `.rvf` binary format
- **Helper utilities**: `default_metadata` for quick `EmbeddingMetadata` construction
## Usage
```rust
use ruv_neural_embed::{
NeuralEmbedding, EmbeddingMetadata, EmbeddingTrajectory,
default_metadata,
};
use ruv_neural_core::brain::Atlas;
// Create an embedding with metadata
let meta = default_metadata("spectral", Atlas::Schaefer100);
let emb = NeuralEmbedding::new(vec![0.1, 0.5, -0.3, 0.8], 1000.0, meta).unwrap();
assert_eq!(emb.dimension, 4);
// Compute similarity between embeddings
let other = NeuralEmbedding::new(
vec![0.2, 0.4, -0.2, 0.9],
1001.0,
default_metadata("spectral", Atlas::Schaefer100),
).unwrap();
let similarity = emb.cosine_similarity(&other).unwrap();
let distance = emb.euclidean_distance(&other).unwrap();
// Build a trajectory from a sequence of embeddings
let trajectory = EmbeddingTrajectory {
embeddings: vec![emb, other],
timestamps: vec![1000.0, 1001.0],
};
assert_eq!(trajectory.len(), 2);
```
## API Reference
| Module | Key Types / Functions |
|------------------|-----------------------------------------------------|
| `spectral_embed` | Spectral positional encoding from graph Laplacian |
| `topology_embed` | Topological feature vector extraction |
| `node2vec` | Random-walk based node embeddings |
| `combined` | Weighted multi-method embedding concatenation |
| `temporal` | Sliding-window temporal context embeddings |
| `distance` | Distance and similarity computations |
| `rvf_export` | RVF binary format serialization |
## Feature Flags
| Feature | Default | Description |
|---------|---------|-------------------------------------|
| `std` | Yes | Standard library support |
| `wasm` | No | WASM-compatible implementations |
| `rvf` | No | RuVector RVF format export support |
## Integration
Depends on `ruv-neural-core` for `NeuralEmbedding`, `BrainGraph`, and
`EmbeddingGenerator` trait. Receives graphs from `ruv-neural-graph` or
`ruv-neural-mincut`. Produced embeddings are stored by `ruv-neural-memory`
and classified by `ruv-neural-decoder`.
## License
MIT OR Apache-2.0
@@ -1,180 +0,0 @@
//! Combined multi-method embedding.
//!
//! Concatenates weighted embeddings from multiple embedding generators
//! into a single vector representation.
use ruv_neural_core::embedding::NeuralEmbedding;
use ruv_neural_core::error::{Result, RuvNeuralError};
use ruv_neural_core::graph::BrainGraph;
use ruv_neural_core::traits::EmbeddingGenerator;
use crate::default_metadata;
/// Combines multiple embedding methods into a single embedding vector.
pub struct CombinedEmbedder {
embedders: Vec<Box<dyn EmbeddingGenerator>>,
weights: Vec<f64>,
}
impl CombinedEmbedder {
/// Create a new empty combined embedder.
pub fn new() -> Self {
Self {
embedders: Vec::new(),
weights: Vec::new(),
}
}
/// Add an embedding generator with a weight.
///
/// The weight scales each element of the generator's output.
pub fn add(mut self, embedder: Box<dyn EmbeddingGenerator>, weight: f64) -> Self {
self.embedders.push(embedder);
self.weights.push(weight);
self
}
/// Number of sub-embedders.
pub fn num_embedders(&self) -> usize {
self.embedders.len()
}
/// Total embedding dimension (sum of all sub-embedder dimensions).
pub fn total_dimension(&self) -> usize {
self.embedders.iter().map(|e| e.embedding_dim()).sum()
}
/// Generate a combined embedding by concatenating weighted sub-embeddings.
pub fn embed_graph(&self, graph: &BrainGraph) -> Result<NeuralEmbedding> {
if self.embedders.is_empty() {
return Err(RuvNeuralError::Embedding(
"CombinedEmbedder has no sub-embedders".into(),
));
}
let mut values = Vec::with_capacity(self.total_dimension());
for (embedder, &weight) in self.embedders.iter().zip(self.weights.iter()) {
let sub_emb = embedder.embed(graph)?;
for v in &sub_emb.vector {
values.push(v * weight);
}
}
let meta = default_metadata("combined", graph.atlas);
NeuralEmbedding::new(values, graph.timestamp, meta)
}
}
impl Default for CombinedEmbedder {
fn default() -> Self {
Self::new()
}
}
impl EmbeddingGenerator for CombinedEmbedder {
fn embedding_dim(&self) -> usize {
self.total_dimension()
}
fn embed(&self, graph: &BrainGraph) -> Result<NeuralEmbedding> {
self.embed_graph(graph)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spectral_embed::SpectralEmbedder;
use crate::topology_embed::TopologyEmbedder;
use ruv_neural_core::brain::Atlas;
use ruv_neural_core::graph::{BrainEdge, ConnectivityMetric};
use ruv_neural_core::signal::FrequencyBand;
fn make_test_graph() -> BrainGraph {
BrainGraph {
num_nodes: 4,
edges: vec![
BrainEdge {
source: 0,
target: 1,
weight: 1.0,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 1,
target: 2,
weight: 0.8,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 2,
target: 3,
weight: 0.6,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 0,
target: 3,
weight: 0.5,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
],
timestamp: 1.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(4),
}
}
#[test]
fn test_combined_concatenates_correctly() {
let graph = make_test_graph();
let spectral = SpectralEmbedder::new(2);
let topo = TopologyEmbedder::new();
let spectral_dim = spectral.embedding_dim();
let topo_dim = topo.embedding_dim();
let combined = CombinedEmbedder::new()
.add(Box::new(spectral), 1.0)
.add(Box::new(topo), 1.0);
assert_eq!(combined.total_dimension(), spectral_dim + topo_dim);
let emb = combined.embed(&graph).unwrap();
assert_eq!(emb.dimension, spectral_dim + topo_dim);
assert_eq!(emb.metadata.embedding_method, "combined");
}
#[test]
fn test_combined_weights_scale() {
let graph = make_test_graph();
let topo = TopologyEmbedder::new();
let combined = CombinedEmbedder::new().add(Box::new(topo), 2.0);
let emb = combined.embed(&graph).unwrap();
let topo2 = TopologyEmbedder::new();
let direct = topo2.embed(&graph).unwrap();
for (c, d) in emb.vector.iter().zip(direct.vector.iter()) {
assert!(
(c - 2.0 * d).abs() < 1e-10,
"Weight should scale values: {} vs 2*{}",
c,
d
);
}
}
#[test]
fn test_combined_empty_fails() {
let graph = make_test_graph();
let combined = CombinedEmbedder::new();
assert!(combined.embed(&graph).is_err());
}
}
@@ -1,247 +0,0 @@
//! Distance metrics for neural embeddings.
//!
//! Provides cosine similarity, Euclidean distance, k-nearest-neighbor search,
//! and a DTW-inspired trajectory distance for comparing embedding sequences.
use ruv_neural_core::embedding::{EmbeddingTrajectory, NeuralEmbedding};
/// Cosine similarity between two embeddings.
///
/// Returns a value in [-1, 1] where 1 means identical direction, 0 means
/// orthogonal, and -1 means opposite.
///
/// Returns 0.0 if either embedding has zero norm.
pub fn cosine_similarity(a: &NeuralEmbedding, b: &NeuralEmbedding) -> f64 {
let len = a.vector.len().min(b.vector.len());
if len == 0 {
return 0.0;
}
let mut dot = 0.0;
let mut norm_a = 0.0;
let mut norm_b = 0.0;
for i in 0..len {
dot += a.vector[i] * b.vector[i];
norm_a += a.vector[i] * a.vector[i];
norm_b += b.vector[i] * b.vector[i];
}
let denom = norm_a.sqrt() * norm_b.sqrt();
if denom < 1e-12 {
return 0.0;
}
dot / denom
}
/// Euclidean (L2) distance between two embeddings.
///
/// If the embeddings have different dimensions, only the overlapping
/// portion is compared.
pub fn euclidean_distance(a: &NeuralEmbedding, b: &NeuralEmbedding) -> f64 {
let len = a.vector.len().min(b.vector.len());
if len == 0 {
return 0.0;
}
let mut sum_sq = 0.0;
for i in 0..len {
let diff = a.vector[i] - b.vector[i];
sum_sq += diff * diff;
}
sum_sq.sqrt()
}
/// Manhattan (L1) distance between two embeddings.
pub fn manhattan_distance(a: &NeuralEmbedding, b: &NeuralEmbedding) -> f64 {
let len = a.vector.len().min(b.vector.len());
let mut sum = 0.0;
for i in 0..len {
sum += (a.vector[i] - b.vector[i]).abs();
}
sum
}
/// Find the k nearest neighbors to a query embedding.
///
/// Returns a vector of `(index, distance)` tuples sorted by ascending
/// Euclidean distance. `index` refers to the position in `candidates`.
pub fn k_nearest(
query: &NeuralEmbedding,
candidates: &[NeuralEmbedding],
k: usize,
) -> Vec<(usize, f64)> {
let mut distances: Vec<(usize, f64)> = candidates
.iter()
.enumerate()
.map(|(i, c)| (i, euclidean_distance(query, c)))
.collect();
distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
distances.truncate(k);
distances
}
/// Dynamic Time Warping (DTW) distance between two embedding trajectories.
///
/// Measures the cost of aligning two temporal sequences of embeddings,
/// allowing for non-linear time warping. The cost at each cell is the
/// Euclidean distance between the corresponding embeddings.
pub fn trajectory_distance(a: &EmbeddingTrajectory, b: &EmbeddingTrajectory) -> f64 {
let n = a.embeddings.len();
let m = b.embeddings.len();
if n == 0 || m == 0 {
return f64::INFINITY;
}
let mut dtw = vec![vec![f64::INFINITY; m + 1]; n + 1];
dtw[0][0] = 0.0;
for i in 1..=n {
for j in 1..=m {
let cost = euclidean_distance(&a.embeddings[i - 1], &b.embeddings[j - 1]);
dtw[i][j] = cost
+ dtw[i - 1][j]
.min(dtw[i][j - 1])
.min(dtw[i - 1][j - 1]);
}
}
dtw[n][m]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::default_metadata;
use ruv_neural_core::brain::Atlas;
use ruv_neural_core::embedding::NeuralEmbedding;
fn emb(values: Vec<f64>) -> NeuralEmbedding {
let meta = default_metadata("test", Atlas::Custom(1));
NeuralEmbedding::new(values, 0.0, meta).unwrap()
}
#[test]
fn test_cosine_similarity_identical() {
let a = emb(vec![1.0, 2.0, 3.0]);
let b = emb(vec![1.0, 2.0, 3.0]);
let sim = cosine_similarity(&a, &b);
assert!(
(sim - 1.0).abs() < 1e-10,
"Identical embeddings: cos sim should be 1.0"
);
}
#[test]
fn test_cosine_similarity_orthogonal() {
let a = emb(vec![1.0, 0.0]);
let b = emb(vec![0.0, 1.0]);
let sim = cosine_similarity(&a, &b);
assert!(
sim.abs() < 1e-10,
"Orthogonal embeddings: cos sim should be 0.0"
);
}
#[test]
fn test_cosine_similarity_opposite() {
let a = emb(vec![1.0, 2.0]);
let b = emb(vec![-1.0, -2.0]);
let sim = cosine_similarity(&a, &b);
assert!(
(sim + 1.0).abs() < 1e-10,
"Opposite embeddings: cos sim should be -1.0"
);
}
#[test]
fn test_euclidean_distance_identical() {
let a = emb(vec![1.0, 2.0, 3.0]);
let b = emb(vec![1.0, 2.0, 3.0]);
let dist = euclidean_distance(&a, &b);
assert!(
dist.abs() < 1e-10,
"Identical embeddings: distance should be 0.0"
);
}
#[test]
fn test_euclidean_distance_known() {
let a = emb(vec![0.0, 0.0]);
let b = emb(vec![3.0, 4.0]);
let dist = euclidean_distance(&a, &b);
assert!((dist - 5.0).abs() < 1e-10, "Distance should be 5.0");
}
#[test]
fn test_k_nearest_returns_correct() {
let query = emb(vec![0.0, 0.0]);
let candidates = vec![
emb(vec![10.0, 10.0]),
emb(vec![1.0, 0.0]),
emb(vec![5.0, 5.0]),
emb(vec![0.5, 0.5]),
];
let nearest = k_nearest(&query, &candidates, 2);
assert_eq!(nearest.len(), 2);
assert_eq!(nearest[0].0, 3);
assert_eq!(nearest[1].0, 1);
}
#[test]
fn test_k_nearest_k_larger_than_candidates() {
let query = emb(vec![0.0]);
let candidates = vec![emb(vec![1.0]), emb(vec![2.0])];
let nearest = k_nearest(&query, &candidates, 10);
assert_eq!(nearest.len(), 2);
}
#[test]
fn test_trajectory_distance_identical() {
let traj = EmbeddingTrajectory {
embeddings: vec![emb(vec![1.0, 2.0]), emb(vec![3.0, 4.0])],
timestamps: vec![0.0, 0.5],
};
let dist = trajectory_distance(&traj, &traj);
assert!(
dist.abs() < 1e-10,
"Identical trajectories: DTW distance should be 0.0"
);
}
#[test]
fn test_trajectory_distance_different() {
let a = EmbeddingTrajectory {
embeddings: vec![emb(vec![0.0, 0.0]), emb(vec![1.0, 0.0])],
timestamps: vec![0.0, 0.5],
};
let b = EmbeddingTrajectory {
embeddings: vec![emb(vec![0.0, 0.0]), emb(vec![0.0, 1.0])],
timestamps: vec![0.0, 0.5],
};
let dist = trajectory_distance(&a, &b);
assert!(
dist > 0.0,
"Different trajectories should have non-zero DTW distance"
);
}
#[test]
fn test_trajectory_distance_empty() {
let a = EmbeddingTrajectory {
embeddings: vec![],
timestamps: vec![],
};
let b = EmbeddingTrajectory {
embeddings: vec![emb(vec![1.0])],
timestamps: vec![0.0],
};
let dist = trajectory_distance(&a, &b);
assert!(dist.is_infinite());
}
}
@@ -1,102 +0,0 @@
//! rUv Neural Embed -- Graph embedding generation for brain connectivity states.
//!
//! This crate provides multiple embedding methods to convert brain connectivity
//! graphs (`BrainGraph`) into fixed-dimensional vector representations suitable
//! for downstream classification, clustering, and temporal analysis.
//!
//! # Embedding Methods
//!
//! - **Spectral**: Laplacian eigenvector-based positional encoding
//! - **Topology**: Hand-crafted topological feature vectors
//! - **Node2Vec**: Random-walk co-occurrence embeddings
//! - **Combined**: Weighted concatenation of multiple methods
//! - **Temporal**: Sliding-window context-enriched embeddings
//!
//! # RVF Export
//!
//! Embeddings can be serialized to the RuVector `.rvf` format for interoperability
//! with the broader RuVector ecosystem.
pub mod combined;
pub mod distance;
pub mod node2vec;
pub mod rvf_export;
pub mod spectral_embed;
pub mod temporal;
pub mod topology_embed;
// Re-export core types used throughout this crate.
pub use ruv_neural_core::embedding::{EmbeddingMetadata, EmbeddingTrajectory, NeuralEmbedding};
pub use ruv_neural_core::graph::{BrainGraph, BrainGraphSequence};
pub use ruv_neural_core::traits::EmbeddingGenerator;
/// Helper to build an `EmbeddingMetadata` with just a method name and atlas.
pub fn default_metadata(
method: &str,
atlas: ruv_neural_core::brain::Atlas,
) -> EmbeddingMetadata {
EmbeddingMetadata {
subject_id: None,
session_id: None,
cognitive_state: None,
source_atlas: atlas,
embedding_method: method.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ruv_neural_core::brain::Atlas;
#[test]
fn test_neural_embedding_new() {
let meta = default_metadata("test", Atlas::Custom(3));
let emb = NeuralEmbedding::new(vec![1.0, 2.0, 3.0], 0.0, meta).unwrap();
assert_eq!(emb.dimension, 3);
assert_eq!(emb.vector.len(), 3);
}
#[test]
fn test_neural_embedding_empty_fails() {
let meta = default_metadata("test", Atlas::Custom(1));
let result = NeuralEmbedding::new(vec![], 0.0, meta);
assert!(result.is_err());
}
#[test]
fn test_embedding_norm() {
let meta = default_metadata("test", Atlas::Custom(2));
let emb = NeuralEmbedding::new(vec![3.0, 4.0], 0.0, meta).unwrap();
assert!((emb.norm() - 5.0).abs() < 1e-10);
}
#[test]
fn test_trajectory() {
let traj = EmbeddingTrajectory {
embeddings: vec![
NeuralEmbedding::new(
vec![0.0; 4],
0.0,
default_metadata("test", Atlas::Custom(4)),
)
.unwrap(),
NeuralEmbedding::new(
vec![0.0; 4],
0.5,
default_metadata("test", Atlas::Custom(4)),
)
.unwrap(),
NeuralEmbedding::new(
vec![0.0; 4],
1.0,
default_metadata("test", Atlas::Custom(4)),
)
.unwrap(),
],
timestamps: vec![0.0, 0.5, 1.0],
};
assert_eq!(traj.len(), 3);
assert!((traj.duration_s() - 1.0).abs() < 1e-10);
}
}
@@ -1,367 +0,0 @@
//! Node2Vec-inspired random walk embedding.
//!
//! Performs biased random walks on the brain graph and constructs a co-occurrence
//! matrix. The graph-level embedding is obtained via SVD of the co-occurrence
//! matrix (a simplified skip-gram approximation).
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use ruv_neural_core::embedding::NeuralEmbedding;
use ruv_neural_core::error::{Result, RuvNeuralError};
use ruv_neural_core::graph::BrainGraph;
use ruv_neural_core::traits::EmbeddingGenerator;
use crate::default_metadata;
/// Node2Vec-style graph embedder using biased random walks.
pub struct Node2VecEmbedder {
/// Length of each random walk.
pub walk_length: usize,
/// Number of walks per node.
pub num_walks: usize,
/// Output embedding dimension.
pub embedding_dim: usize,
/// Return parameter (higher = more likely to return to previous node).
pub p: f64,
/// In-out parameter (higher = more likely to explore outward).
pub q: f64,
/// Random seed for reproducibility.
pub seed: u64,
}
impl Node2VecEmbedder {
/// Create a new Node2Vec embedder with default parameters.
pub fn new(embedding_dim: usize) -> Self {
Self {
walk_length: 20,
num_walks: 10,
embedding_dim,
p: 1.0,
q: 1.0,
seed: 42,
}
}
/// Perform a single biased random walk starting from `start`.
fn random_walk(
&self,
adj: &[Vec<f64>],
n: usize,
start: usize,
rng: &mut StdRng,
) -> Vec<usize> {
let mut walk = Vec::with_capacity(self.walk_length);
walk.push(start);
if self.walk_length <= 1 || n <= 1 {
return walk;
}
// First step: weighted over neighbors
let neighbors: Vec<(usize, f64)> = (0..n)
.filter(|&j| adj[start][j] > 1e-12)
.map(|j| (j, adj[start][j]))
.collect();
if neighbors.is_empty() {
return walk;
}
let total: f64 = neighbors.iter().map(|(_, w)| w).sum();
let r: f64 = rng.gen::<f64>() * total;
let mut cum = 0.0;
let mut chosen = neighbors[0].0;
for &(j, w) in &neighbors {
cum += w;
if r <= cum {
chosen = j;
break;
}
}
walk.push(chosen);
// Subsequent steps: biased by p and q
for _ in 2..self.walk_length {
let current = *walk.last().unwrap();
let prev = walk[walk.len() - 2];
let neighbors: Vec<(usize, f64)> = (0..n)
.filter(|&j| adj[current][j] > 1e-12)
.map(|j| (j, adj[current][j]))
.collect();
if neighbors.is_empty() {
break;
}
let biased: Vec<(usize, f64)> = neighbors
.iter()
.map(|&(j, w)| {
let bias = if j == prev {
1.0 / self.p
} else if adj[prev][j] > 1e-12 {
1.0
} else {
1.0 / self.q
};
(j, w * bias)
})
.collect();
let total: f64 = biased.iter().map(|(_, w)| w).sum();
if total < 1e-12 {
break;
}
let r: f64 = rng.gen::<f64>() * total;
let mut cum = 0.0;
let mut chosen = biased[0].0;
for &(j, w) in &biased {
cum += w;
if r <= cum {
chosen = j;
break;
}
}
walk.push(chosen);
}
walk
}
/// Generate all random walks from all nodes.
fn generate_walks(&self, adj: &[Vec<f64>], n: usize) -> Vec<Vec<usize>> {
let mut rng = StdRng::seed_from_u64(self.seed);
let mut all_walks = Vec::with_capacity(n * self.num_walks);
for _ in 0..self.num_walks {
for node in 0..n {
all_walks.push(self.random_walk(adj, n, node, &mut rng));
}
}
all_walks
}
/// Build co-occurrence matrix from walks using a skip-gram window.
fn build_cooccurrence(walks: &[Vec<usize>], n: usize, window: usize) -> Vec<Vec<f64>> {
let mut cooc = vec![vec![0.0; n]; n];
for walk in walks {
for (i, &center) in walk.iter().enumerate() {
let start = if i >= window { i - window } else { 0 };
let end = (i + window + 1).min(walk.len());
for j in start..end {
if j != i {
cooc[center][walk[j]] += 1.0;
}
}
}
}
cooc
}
/// Simplified SVD via power iteration: extract top-k left singular vectors scaled by sigma.
fn truncated_svd(matrix: &[Vec<f64>], n: usize, k: usize) -> Vec<Vec<f64>> {
let k = k.min(n);
if k == 0 || n == 0 {
return vec![];
}
let mut result: Vec<Vec<f64>> = Vec::with_capacity(k);
for col in 0..k {
let mut v: Vec<f64> = (0..n).map(|i| ((i + col + 1) as f64).sin()).collect();
let norm = v.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm > 1e-12 {
for x in &mut v {
*x /= norm;
}
}
// Deflate
for prev in &result {
let prev_norm: f64 = prev.iter().map(|x| x * x).sum::<f64>().sqrt();
if prev_norm > 1e-12 {
let prev_unit: Vec<f64> = prev.iter().map(|x| x / prev_norm).collect();
let dot: f64 = v.iter().zip(prev_unit.iter()).map(|(a, b)| a * b).sum();
for i in 0..n {
v[i] -= dot * prev_unit[i];
}
}
}
// Power iteration on M^T M
for _ in 0..100 {
let mut u = vec![0.0; n];
for i in 0..n {
for j in 0..n {
u[i] += matrix[i][j] * v[j];
}
}
let mut new_v = vec![0.0; n];
for j in 0..n {
for i in 0..n {
new_v[j] += matrix[i][j] * u[i];
}
}
// Deflate
for prev in &result {
let prev_norm: f64 = prev.iter().map(|x| x * x).sum::<f64>().sqrt();
if prev_norm > 1e-12 {
let prev_unit: Vec<f64> = prev.iter().map(|x| x / prev_norm).collect();
let dot: f64 = new_v
.iter()
.zip(prev_unit.iter())
.map(|(a, b)| a * b)
.sum();
for i in 0..n {
new_v[i] -= dot * prev_unit[i];
}
}
}
let norm = new_v.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm < 1e-12 {
break;
}
for x in &mut new_v {
*x /= norm;
}
v = new_v;
}
// sigma * u = M * v
let mut mv = vec![0.0; n];
for i in 0..n {
for j in 0..n {
mv[i] += matrix[i][j] * v[j];
}
}
result.push(mv);
}
result
}
/// Generate the Node2Vec embedding for a brain graph.
pub fn embed_graph(&self, graph: &BrainGraph) -> Result<NeuralEmbedding> {
let n = graph.num_nodes;
if n < 2 {
return Err(RuvNeuralError::Embedding(
"Node2Vec requires at least 2 nodes".into(),
));
}
let adj = graph.adjacency_matrix();
let walks = self.generate_walks(&adj, n);
let cooc = Self::build_cooccurrence(&walks, n, 5);
// Log transform (PPMI-like)
let log_cooc: Vec<Vec<f64>> = cooc
.iter()
.map(|row| row.iter().map(|&v| (1.0 + v).ln()).collect())
.collect();
let dim = self.embedding_dim.min(n);
let node_embeddings = Self::truncated_svd(&log_cooc, n, dim);
// Aggregate: [mean, std] per SVD component
let mut values = Vec::with_capacity(dim * 2);
for component in &node_embeddings {
let mean = component.iter().sum::<f64>() / n as f64;
let var = component.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n as f64;
values.push(mean);
values.push(var.sqrt());
}
while values.len() < self.embedding_dim * 2 {
values.push(0.0);
}
let meta = default_metadata("node2vec", graph.atlas);
NeuralEmbedding::new(values, graph.timestamp, meta)
}
}
impl EmbeddingGenerator for Node2VecEmbedder {
fn embedding_dim(&self) -> usize {
self.embedding_dim * 2
}
fn embed(&self, graph: &BrainGraph) -> Result<NeuralEmbedding> {
self.embed_graph(graph)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ruv_neural_core::brain::Atlas;
use ruv_neural_core::graph::{BrainEdge, ConnectivityMetric};
use ruv_neural_core::signal::FrequencyBand;
fn make_connected_graph() -> BrainGraph {
let edges: Vec<BrainEdge> = (0..4)
.map(|i| BrainEdge {
source: i,
target: i + 1,
weight: 1.0,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
})
.collect();
BrainGraph {
num_nodes: 5,
edges,
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(5),
}
}
#[test]
fn test_node2vec_walks_visit_all_nodes() {
let graph = make_connected_graph();
let embedder = Node2VecEmbedder {
walk_length: 50,
num_walks: 20,
embedding_dim: 4,
p: 1.0,
q: 1.0,
seed: 42,
};
let adj = graph.adjacency_matrix();
let walks = embedder.generate_walks(&adj, graph.num_nodes);
let mut visited = std::collections::HashSet::new();
for walk in &walks {
for &node in walk {
visited.insert(node);
}
}
assert_eq!(visited.len(), 5, "All nodes should be visited");
}
#[test]
fn test_node2vec_embed() {
let graph = make_connected_graph();
let embedder = Node2VecEmbedder::new(3);
let emb = embedder.embed(&graph).unwrap();
assert_eq!(emb.dimension, 3 * 2);
assert_eq!(emb.metadata.embedding_method, "node2vec");
}
#[test]
fn test_node2vec_too_small() {
let graph = BrainGraph {
num_nodes: 1,
edges: vec![],
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(1),
};
let embedder = Node2VecEmbedder::new(4);
assert!(embedder.embed(&graph).is_err());
}
}
@@ -1,210 +0,0 @@
//! Export neural embeddings to the RuVector File (.rvf) format.
//!
//! The RVF (RuVector Format) is a JSON-based file format for storing
//! embedding vectors with metadata. This module provides round-trip
//! serialization for interoperability with the RuVector ecosystem.
use ruv_neural_core::brain::Atlas;
use ruv_neural_core::embedding::{EmbeddingMetadata, NeuralEmbedding};
use ruv_neural_core::error::{Result, RuvNeuralError};
use serde::{Deserialize, Serialize};
/// RVF file header.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RvfHeader {
/// Format version string.
pub version: String,
/// Number of embeddings in the file.
pub count: usize,
/// Embedding dimensionality.
pub dimension: usize,
/// Method used to generate embeddings.
pub method: String,
/// Optional description.
pub description: Option<String>,
}
/// A single RVF record (embedding + metadata).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RvfRecord {
/// Record index.
pub index: usize,
/// Timestamp of the source data.
pub timestamp: f64,
/// The embedding vector.
pub values: Vec<f64>,
/// Optional subject identifier.
pub subject_id: Option<String>,
/// Optional session identifier.
pub session_id: Option<String>,
}
/// Complete RVF document.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RvfDocument {
/// File header.
pub header: RvfHeader,
/// Embedding records.
pub records: Vec<RvfRecord>,
}
/// Export embeddings to an RVF JSON file.
///
/// # Errors
/// Returns an error if the embedding list is empty or if file I/O fails.
pub fn export_rvf(embeddings: &[NeuralEmbedding], path: &str) -> Result<()> {
let json = to_rvf_string(embeddings)?;
std::fs::write(path, json).map_err(|e| {
RuvNeuralError::Serialization(format!("Failed to write RVF file '{}': {}", path, e))
})?;
Ok(())
}
/// Import embeddings from an RVF JSON file.
///
/// # Errors
/// Returns an error if the file cannot be read or parsed.
pub fn import_rvf(path: &str) -> Result<Vec<NeuralEmbedding>> {
let json = std::fs::read_to_string(path).map_err(|e| {
RuvNeuralError::Serialization(format!("Failed to read RVF file '{}': {}", path, e))
})?;
from_rvf_string(&json)
}
/// Serialize embeddings to RVF JSON string (without writing to file).
pub fn to_rvf_string(embeddings: &[NeuralEmbedding]) -> Result<String> {
if embeddings.is_empty() {
return Err(RuvNeuralError::Embedding(
"Cannot serialize empty embedding list".into(),
));
}
let dimension = embeddings[0].dimension;
let method = embeddings[0].metadata.embedding_method.clone();
let header = RvfHeader {
version: "1.0".to_string(),
count: embeddings.len(),
dimension,
method,
description: None,
};
let records: Vec<RvfRecord> = embeddings
.iter()
.enumerate()
.map(|(i, emb)| RvfRecord {
index: i,
timestamp: emb.timestamp,
values: emb.vector.clone(),
subject_id: emb.metadata.subject_id.clone(),
session_id: emb.metadata.session_id.clone(),
})
.collect();
let doc = RvfDocument { header, records };
serde_json::to_string_pretty(&doc).map_err(|e| {
RuvNeuralError::Serialization(format!("Failed to serialize RVF: {}", e))
})
}
/// Deserialize embeddings from an RVF JSON string.
pub fn from_rvf_string(json: &str) -> Result<Vec<NeuralEmbedding>> {
let doc: RvfDocument = serde_json::from_str(json).map_err(|e| {
RuvNeuralError::Serialization(format!("Failed to parse RVF: {}", e))
})?;
doc.records
.into_iter()
.map(|rec| {
let meta = EmbeddingMetadata {
subject_id: rec.subject_id,
session_id: rec.session_id,
cognitive_state: None,
source_atlas: Atlas::Custom(doc.header.dimension),
embedding_method: doc.header.method.clone(),
};
NeuralEmbedding::new(rec.values, rec.timestamp, meta)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::default_metadata;
#[test]
fn test_rvf_string_roundtrip() {
let embeddings = vec![
NeuralEmbedding::new(
vec![1.0, 2.0, 3.0],
0.0,
default_metadata("test", Atlas::Custom(3)),
)
.unwrap(),
NeuralEmbedding::new(
vec![4.0, 5.0, 6.0],
0.5,
default_metadata("test", Atlas::Custom(3)),
)
.unwrap(),
NeuralEmbedding::new(
vec![7.0, 8.0, 9.0],
1.0,
default_metadata("test", Atlas::Custom(3)),
)
.unwrap(),
];
let json = to_rvf_string(&embeddings).unwrap();
let restored = from_rvf_string(&json).unwrap();
assert_eq!(restored.len(), 3);
for (orig, rest) in embeddings.iter().zip(restored.iter()) {
assert_eq!(orig.dimension, rest.dimension);
assert!((orig.timestamp - rest.timestamp).abs() < 1e-10);
for (a, b) in orig.vector.iter().zip(rest.vector.iter()) {
assert!((a - b).abs() < 1e-10);
}
}
}
#[test]
fn test_rvf_file_roundtrip() {
let embeddings = vec![
NeuralEmbedding::new(
vec![1.0, -2.5, 3.14],
10.0,
default_metadata("spectral", Atlas::Custom(3)),
)
.unwrap(),
NeuralEmbedding::new(
vec![0.0, 0.0, 0.0],
10.5,
default_metadata("spectral", Atlas::Custom(3)),
)
.unwrap(),
];
let path = "/tmp/ruv_neural_embed_test.rvf";
export_rvf(&embeddings, path).unwrap();
let restored = import_rvf(path).unwrap();
assert_eq!(restored.len(), 2);
assert_eq!(restored[0].metadata.embedding_method, "spectral");
assert!((restored[0].vector[0] - 1.0).abs() < 1e-10);
assert!((restored[0].vector[1] - (-2.5)).abs() < 1e-10);
assert!((restored[0].vector[2] - 3.14).abs() < 1e-10);
assert!((restored[1].timestamp - 10.5).abs() < 1e-10);
let _ = std::fs::remove_file(path);
}
#[test]
fn test_rvf_empty_fails() {
assert!(to_rvf_string(&[]).is_err());
assert!(export_rvf(&[], "/tmp/empty.rvf").is_err());
}
}
@@ -1,306 +0,0 @@
//! Spectral graph embedding using Laplacian eigenvectors.
//!
//! Computes a positional encoding for each node using the first `k` eigenvectors
//! of the normalized graph Laplacian. The graph-level embedding is formed by
//! concatenating summary statistics of the per-node spectral coordinates.
use ruv_neural_core::embedding::NeuralEmbedding;
use ruv_neural_core::error::{Result, RuvNeuralError};
use ruv_neural_core::graph::BrainGraph;
use ruv_neural_core::traits::EmbeddingGenerator;
use crate::default_metadata;
/// Spectral embedding via Laplacian eigenvectors.
pub struct SpectralEmbedder {
/// Number of eigenvectors (spectral dimensions) to extract.
pub dimension: usize,
/// Number of power iteration steps for eigenvalue approximation.
pub power_iterations: usize,
}
impl SpectralEmbedder {
/// Create a new spectral embedder.
///
/// `dimension` is the number of Laplacian eigenvectors to use.
pub fn new(dimension: usize) -> Self {
Self {
dimension,
power_iterations: 100,
}
}
/// Compute the normalized Laplacian matrix: L_norm = I - D^{-1/2} A D^{-1/2}.
fn normalized_laplacian(adj: &[Vec<f64>], n: usize) -> Vec<Vec<f64>> {
let degrees: Vec<f64> = (0..n).map(|i| adj[i].iter().sum::<f64>()).collect();
let inv_sqrt_deg: Vec<f64> = degrees
.iter()
.map(|d| if *d > 1e-12 { 1.0 / d.sqrt() } else { 0.0 })
.collect();
let mut laplacian = vec![vec![0.0; n]; n];
for i in 0..n {
for j in 0..n {
if i == j {
if degrees[i] > 1e-12 {
laplacian[i][j] = 1.0;
}
} else {
laplacian[i][j] = -adj[i][j] * inv_sqrt_deg[i] * inv_sqrt_deg[j];
}
}
}
laplacian
}
/// Extract the k smallest eigenvectors using deflated power iteration on (max_eig*I - L).
/// Returns eigenvectors as columns: result[eigenvector_index][node_index].
fn smallest_eigenvectors(
laplacian: &[Vec<f64>],
n: usize,
k: usize,
iterations: usize,
) -> Vec<Vec<f64>> {
if n == 0 || k == 0 {
return vec![];
}
let k = k.min(n);
// Gershgorin bound for max eigenvalue
let max_eig: f64 = (0..n)
.map(|i| {
let diag = laplacian[i][i];
let off: f64 = (0..n)
.filter(|&j| j != i)
.map(|j| laplacian[i][j].abs())
.sum();
diag + off
})
.fold(0.0_f64, f64::max);
// Shifted matrix: M = max_eig * I - L
let shifted: Vec<Vec<f64>> = (0..n)
.map(|i| {
(0..n)
.map(|j| {
if i == j {
max_eig - laplacian[i][j]
} else {
-laplacian[i][j]
}
})
.collect()
})
.collect();
let mut eigenvectors: Vec<Vec<f64>> = Vec::with_capacity(k);
for _ev in 0..k {
let mut v: Vec<f64> = (0..n).map(|i| ((i + 1) as f64).sin()).collect();
let norm = v.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm > 1e-12 {
for x in &mut v {
*x /= norm;
}
}
// Deflate against already-found eigenvectors
for prev in &eigenvectors {
let dot: f64 = v.iter().zip(prev.iter()).map(|(a, b)| a * b).sum();
for i in 0..n {
v[i] -= dot * prev[i];
}
}
for _ in 0..iterations {
let mut w = vec![0.0; n];
for i in 0..n {
for j in 0..n {
w[i] += shifted[i][j] * v[j];
}
}
for prev in &eigenvectors {
let dot: f64 = w.iter().zip(prev.iter()).map(|(a, b)| a * b).sum();
for i in 0..n {
w[i] -= dot * prev[i];
}
}
let norm = w.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm < 1e-12 {
break;
}
for x in &mut w {
*x /= norm;
}
v = w;
}
eigenvectors.push(v);
}
eigenvectors
}
/// Embed a brain graph using spectral decomposition.
pub fn embed_graph(&self, graph: &BrainGraph) -> Result<NeuralEmbedding> {
let n = graph.num_nodes;
if n < 2 {
return Err(RuvNeuralError::Embedding(
"Spectral embedding requires at least 2 nodes".into(),
));
}
let adj = graph.adjacency_matrix();
let laplacian = Self::normalized_laplacian(&adj, n);
// Skip the trivial first eigenvector and take the next `dimension`
let num_to_extract = (self.dimension + 1).min(n);
let eigvecs =
Self::smallest_eigenvectors(&laplacian, n, num_to_extract, self.power_iterations);
let useful: Vec<&Vec<f64>> = eigvecs.iter().skip(1).take(self.dimension).collect();
// Build graph-level embedding: [mean, std, min, max] per eigenvector
let mut values = Vec::with_capacity(self.dimension * 4);
for ev in &useful {
let mean = ev.iter().sum::<f64>() / n as f64;
let variance = ev.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n as f64;
let std = variance.sqrt();
let min = ev.iter().cloned().fold(f64::INFINITY, f64::min);
let max = ev.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
values.push(mean);
values.push(std);
values.push(min);
values.push(max);
}
// Pad if fewer eigenvectors than requested
while values.len() < self.dimension * 4 {
values.push(0.0);
}
let meta = default_metadata("spectral", graph.atlas);
NeuralEmbedding::new(values, graph.timestamp, meta)
}
}
impl EmbeddingGenerator for SpectralEmbedder {
fn embedding_dim(&self) -> usize {
self.dimension * 4
}
fn embed(&self, graph: &BrainGraph) -> Result<NeuralEmbedding> {
self.embed_graph(graph)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ruv_neural_core::brain::Atlas;
use ruv_neural_core::graph::{BrainEdge, ConnectivityMetric};
use ruv_neural_core::signal::FrequencyBand;
fn make_complete_graph(n: usize) -> BrainGraph {
let mut edges = Vec::new();
for i in 0..n {
for j in (i + 1)..n {
edges.push(BrainEdge {
source: i,
target: j,
weight: 1.0,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
});
}
}
BrainGraph {
num_nodes: n,
edges,
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(n),
}
}
fn make_two_cluster_graph() -> BrainGraph {
let mut edges = Vec::new();
// Cluster A: nodes 0-3 (fully connected)
for i in 0..4 {
for j in (i + 1)..4 {
edges.push(BrainEdge {
source: i,
target: j,
weight: 1.0,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
});
}
}
// Cluster B: nodes 4-7 (fully connected)
for i in 4..8 {
for j in (i + 1)..8 {
edges.push(BrainEdge {
source: i,
target: j,
weight: 1.0,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
});
}
}
// Weak bridge
edges.push(BrainEdge {
source: 3,
target: 4,
weight: 0.1,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
});
BrainGraph {
num_nodes: 8,
edges,
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(8),
}
}
#[test]
fn test_spectral_complete_graph() {
let graph = make_complete_graph(6);
let embedder = SpectralEmbedder::new(3);
let emb = embedder.embed(&graph).unwrap();
assert_eq!(emb.dimension, 3 * 4);
}
#[test]
fn test_spectral_two_cluster_separation() {
let graph = make_two_cluster_graph();
let embedder = SpectralEmbedder::new(2);
let emb = embedder.embed(&graph).unwrap();
// Fiedler vector std (index 1) should show cluster separation
let fiedler_std = emb.vector[1];
assert!(
fiedler_std > 0.01,
"Fiedler eigenvector should show cluster separation, got std={}",
fiedler_std
);
}
#[test]
fn test_spectral_too_small() {
let graph = BrainGraph {
num_nodes: 1,
edges: vec![],
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(1),
};
let embedder = SpectralEmbedder::new(2);
assert!(embedder.embed(&graph).is_err());
}
}
@@ -1,217 +0,0 @@
//! Temporal sliding-window embeddings for brain graph sequences.
//!
//! Embeds a time series of brain graphs into trajectory vectors by combining
//! each graph's embedding with an exponentially-weighted average of past embeddings.
use ruv_neural_core::embedding::{EmbeddingTrajectory, NeuralEmbedding};
use ruv_neural_core::error::{Result, RuvNeuralError};
use ruv_neural_core::graph::{BrainGraph, BrainGraphSequence};
use ruv_neural_core::traits::EmbeddingGenerator;
use crate::default_metadata;
/// Temporal embedder that enriches each graph embedding with historical context.
pub struct TemporalEmbedder {
/// Base embedder for individual graphs.
base_embedder: Box<dyn EmbeddingGenerator>,
/// Number of past embeddings to consider in the context window.
window_size: usize,
/// Exponential decay factor for weighting past embeddings (0 < decay <= 1).
decay: f64,
}
impl TemporalEmbedder {
/// Create a new temporal embedder.
///
/// - `base`: the embedding generator for individual graphs
/// - `window`: how many past embeddings to incorporate
pub fn new(base: Box<dyn EmbeddingGenerator>, window: usize) -> Self {
Self {
base_embedder: base,
window_size: window,
decay: 0.8,
}
}
/// Set the exponential decay factor.
pub fn with_decay(mut self, decay: f64) -> Self {
self.decay = decay.clamp(0.01, 1.0);
self
}
/// Embed a full sequence of graphs into a trajectory.
pub fn embed_sequence(&self, sequence: &BrainGraphSequence) -> Result<EmbeddingTrajectory> {
if sequence.is_empty() {
return Err(RuvNeuralError::Embedding(
"Cannot embed empty graph sequence".into(),
));
}
let mut history: Vec<NeuralEmbedding> = Vec::new();
let mut embeddings = Vec::with_capacity(sequence.graphs.len());
let mut timestamps = Vec::with_capacity(sequence.graphs.len());
for graph in &sequence.graphs {
let emb = self.embed_with_context(graph, &history)?;
timestamps.push(graph.timestamp);
history.push(self.base_embedder.embed(graph)?);
embeddings.push(emb);
}
Ok(EmbeddingTrajectory {
embeddings,
timestamps,
})
}
/// Embed a single graph with temporal context from past embeddings.
///
/// The output concatenates:
/// 1. The current graph's base embedding
/// 2. An exponentially-weighted average of past embeddings (zero-padded if no history)
pub fn embed_with_context(
&self,
graph: &BrainGraph,
history: &[NeuralEmbedding],
) -> Result<NeuralEmbedding> {
let current = self.base_embedder.embed(graph)?;
let base_dim = current.dimension;
let context = self.compute_context(history, base_dim);
let mut values = Vec::with_capacity(base_dim * 2);
values.extend_from_slice(&current.vector);
values.extend_from_slice(&context);
let meta = default_metadata("temporal", graph.atlas);
NeuralEmbedding::new(values, graph.timestamp, meta)
}
/// Compute the exponentially-weighted context vector from history.
fn compute_context(&self, history: &[NeuralEmbedding], dim: usize) -> Vec<f64> {
if history.is_empty() {
return vec![0.0; dim];
}
let window_start = if history.len() > self.window_size {
history.len() - self.window_size
} else {
0
};
let window = &history[window_start..];
let mut context = vec![0.0; dim];
let mut total_weight = 0.0;
for (i, emb) in window.iter().rev().enumerate() {
let w = self.decay.powi(i as i32);
total_weight += w;
let usable_dim = dim.min(emb.dimension);
for j in 0..usable_dim {
context[j] += w * emb.vector[j];
}
}
if total_weight > 1e-12 {
for v in &mut context {
*v /= total_weight;
}
}
context
}
/// Output dimension: base dimension * 2 (current + context).
pub fn output_dimension(&self) -> usize {
self.base_embedder.embedding_dim() * 2
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::topology_embed::TopologyEmbedder;
use ruv_neural_core::brain::Atlas;
use ruv_neural_core::graph::{BrainEdge, ConnectivityMetric};
use ruv_neural_core::signal::FrequencyBand;
fn make_graph(timestamp: f64) -> BrainGraph {
BrainGraph {
num_nodes: 3,
edges: vec![
BrainEdge {
source: 0,
target: 1,
weight: 1.0,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 1,
target: 2,
weight: 0.5,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
],
timestamp,
window_duration_s: 0.5,
atlas: Atlas::Custom(3),
}
}
#[test]
fn test_temporal_embed_no_history() {
let embedder = TemporalEmbedder::new(Box::new(TopologyEmbedder::new()), 5);
let graph = make_graph(0.0);
let emb = embedder.embed_with_context(&graph, &[]).unwrap();
let base_dim = TopologyEmbedder::new().embedding_dim();
assert_eq!(emb.dimension, base_dim * 2);
for i in base_dim..emb.dimension {
assert!(
emb.vector[i].abs() < 1e-12,
"Context should be zero with no history"
);
}
}
#[test]
fn test_temporal_embed_sequence() {
let base = Box::new(TopologyEmbedder::new());
let embedder = TemporalEmbedder::new(base, 3);
let sequence = BrainGraphSequence {
graphs: vec![make_graph(0.0), make_graph(0.5), make_graph(1.0)],
window_step_s: 0.5,
};
let trajectory = embedder.embed_sequence(&sequence).unwrap();
assert_eq!(trajectory.len(), 3);
assert_eq!(trajectory.timestamps.len(), 3);
let base_dim = TopologyEmbedder::new().embedding_dim();
for i in base_dim..trajectory.embeddings[0].dimension {
assert!(trajectory.embeddings[0].vector[i].abs() < 1e-12);
}
let has_nonzero = trajectory.embeddings[2].vector[base_dim..]
.iter()
.any(|v| v.abs() > 1e-12);
assert!(
has_nonzero,
"Third embedding should have non-zero temporal context"
);
}
#[test]
fn test_temporal_empty_sequence_fails() {
let embedder = TemporalEmbedder::new(Box::new(TopologyEmbedder::new()), 3);
let sequence = BrainGraphSequence {
graphs: vec![],
window_step_s: 0.5,
};
assert!(embedder.embed_sequence(&sequence).is_err());
}
}
@@ -1,491 +0,0 @@
//! Topology-based graph embedding.
//!
//! Extracts a feature vector of hand-crafted topological metrics from a brain graph,
//! including mincut estimate, modularity, efficiency, degree statistics, and more.
use ruv_neural_core::embedding::NeuralEmbedding;
use ruv_neural_core::error::Result;
use ruv_neural_core::graph::BrainGraph;
use ruv_neural_core::traits::EmbeddingGenerator;
use crate::default_metadata;
/// Topology-based embedder: converts a brain graph into a vector of topological features.
pub struct TopologyEmbedder {
/// Include global minimum cut estimate.
pub include_mincut: bool,
/// Include modularity estimate.
pub include_modularity: bool,
/// Include global and local efficiency.
pub include_efficiency: bool,
/// Include degree distribution statistics.
pub include_degree_stats: bool,
}
impl TopologyEmbedder {
/// Create a new topology embedder with all features enabled.
pub fn new() -> Self {
Self {
include_mincut: true,
include_modularity: true,
include_efficiency: true,
include_degree_stats: true,
}
}
/// Estimate global minimum cut via the minimum node degree.
fn estimate_mincut(graph: &BrainGraph) -> f64 {
if graph.num_nodes < 2 {
return 0.0;
}
(0..graph.num_nodes)
.map(|i| graph.node_degree(i))
.fold(f64::INFINITY, f64::min)
}
/// Estimate modularity using a simple greedy two-partition.
fn estimate_modularity(graph: &BrainGraph) -> f64 {
let n = graph.num_nodes;
if n < 2 {
return 0.0;
}
let total_weight = graph.total_weight();
if total_weight < 1e-12 {
return 0.0;
}
let adj = graph.adjacency_matrix();
let degrees: Vec<f64> = (0..n).map(|i| graph.node_degree(i)).collect();
let mut sorted_degrees: Vec<(usize, f64)> =
degrees.iter().copied().enumerate().collect();
sorted_degrees.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
let mid = n / 2;
let mut partition = vec![0i32; n];
for (rank, &(node, _)) in sorted_degrees.iter().enumerate() {
partition[node] = if rank < mid { 1 } else { -1 };
}
let two_m = 2.0 * total_weight;
let mut q = 0.0;
for i in 0..n {
for j in 0..n {
if partition[i] == partition[j] {
q += adj[i][j] - degrees[i] * degrees[j] / two_m;
}
}
}
q / two_m
}
/// Compute global efficiency: average of 1/shortest_path for all node pairs.
fn global_efficiency(graph: &BrainGraph) -> f64 {
let n = graph.num_nodes;
if n < 2 {
return 0.0;
}
let adj = graph.adjacency_matrix();
let mut sum_inv_dist = 0.0;
for source in 0..n {
let mut dist = vec![usize::MAX; n];
dist[source] = 0;
let mut queue = std::collections::VecDeque::new();
queue.push_back(source);
while let Some(u) = queue.pop_front() {
for v in 0..n {
if dist[v] == usize::MAX && adj[u][v] > 1e-12 {
dist[v] = dist[u] + 1;
queue.push_back(v);
}
}
}
for v in 0..n {
if v != source && dist[v] != usize::MAX {
sum_inv_dist += 1.0 / dist[v] as f64;
}
}
}
sum_inv_dist / (n * (n - 1)) as f64
}
/// Compute mean local efficiency.
fn local_efficiency(graph: &BrainGraph) -> f64 {
let n = graph.num_nodes;
if n == 0 {
return 0.0;
}
let adj = graph.adjacency_matrix();
let mut total = 0.0;
for node in 0..n {
let neighbors: Vec<usize> = (0..n)
.filter(|&j| j != node && adj[node][j] > 1e-12)
.collect();
let k = neighbors.len();
if k < 2 {
continue;
}
let mut sub_sum = 0.0;
for &i in &neighbors {
for &j in &neighbors {
if i != j && adj[i][j] > 1e-12 {
sub_sum += 1.0;
}
}
}
total += sub_sum / (k * (k - 1)) as f64;
}
total / n as f64
}
/// Compute graph entropy from edge weight distribution.
fn graph_entropy(graph: &BrainGraph) -> f64 {
if graph.edges.is_empty() {
return 0.0;
}
let total: f64 = graph.edges.iter().map(|e| e.weight.abs()).sum();
if total < 1e-12 {
return 0.0;
}
let mut entropy = 0.0;
for edge in &graph.edges {
let p = edge.weight.abs() / total;
if p > 1e-12 {
entropy -= p * p.ln();
}
}
entropy
}
/// Estimate the Fiedler value (algebraic connectivity).
fn estimate_fiedler(graph: &BrainGraph) -> f64 {
let n = graph.num_nodes;
if n < 2 {
return 0.0;
}
let adj = graph.adjacency_matrix();
let degrees: Vec<f64> = (0..n).map(|i| adj[i].iter().sum::<f64>()).collect();
let mut laplacian = vec![vec![0.0; n]; n];
for i in 0..n {
for j in 0..n {
if i == j {
laplacian[i][j] = degrees[i];
} else {
laplacian[i][j] = -adj[i][j];
}
}
}
let max_eig: f64 = (0..n)
.map(|i| {
let diag = laplacian[i][i];
let off: f64 = (0..n)
.filter(|&j| j != i)
.map(|j| laplacian[i][j].abs())
.sum();
diag + off
})
.fold(0.0_f64, f64::max);
let e0: Vec<f64> = vec![1.0 / (n as f64).sqrt(); n];
let mut v: Vec<f64> = (0..n).map(|i| ((i + 1) as f64).sin()).collect();
let dot0: f64 = v.iter().zip(e0.iter()).map(|(a, b)| a * b).sum();
for i in 0..n {
v[i] -= dot0 * e0[i];
}
let norm = v.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm < 1e-12 {
return 0.0;
}
for x in &mut v {
*x /= norm;
}
let mut eigenvalue = 0.0;
for _ in 0..200 {
let mut w = vec![0.0; n];
for i in 0..n {
for j in 0..n {
if i == j {
w[i] += (max_eig - laplacian[i][j]) * v[j];
} else {
w[i] += -laplacian[i][j] * v[j];
}
}
}
let dot: f64 = w.iter().zip(e0.iter()).map(|(a, b)| a * b).sum();
for i in 0..n {
w[i] -= dot * e0[i];
}
let norm = w.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm < 1e-12 {
break;
}
eigenvalue = norm;
for x in &mut w {
*x /= norm;
}
v = w;
}
(max_eig - eigenvalue).max(0.0)
}
/// Compute average clustering coefficient.
fn clustering_coefficient(graph: &BrainGraph) -> f64 {
let n = graph.num_nodes;
if n == 0 {
return 0.0;
}
let adj = graph.adjacency_matrix();
let mut total = 0.0;
for node in 0..n {
let neighbors: Vec<usize> = (0..n)
.filter(|&j| j != node && adj[node][j] > 1e-12)
.collect();
let k = neighbors.len();
if k < 2 {
continue;
}
let mut triangles = 0usize;
for i in 0..k {
for j in (i + 1)..k {
if adj[neighbors[i]][neighbors[j]] > 1e-12 {
triangles += 1;
}
}
}
total += 2.0 * triangles as f64 / (k * (k - 1)) as f64;
}
total / n as f64
}
/// Count connected components via BFS.
fn num_components(graph: &BrainGraph) -> usize {
let n = graph.num_nodes;
if n == 0 {
return 0;
}
let adj = graph.adjacency_matrix();
let mut visited = vec![false; n];
let mut count = 0;
for start in 0..n {
if visited[start] {
continue;
}
count += 1;
let mut queue = std::collections::VecDeque::new();
queue.push_back(start);
visited[start] = true;
while let Some(u) = queue.pop_front() {
for v in 0..n {
if !visited[v] && adj[u][v] > 1e-12 {
visited[v] = true;
queue.push_back(v);
}
}
}
}
count
}
/// Generate the topology embedding.
pub fn embed_graph(&self, graph: &BrainGraph) -> Result<NeuralEmbedding> {
let mut values = Vec::new();
if self.include_mincut {
values.push(Self::estimate_mincut(graph));
}
if self.include_modularity {
values.push(Self::estimate_modularity(graph));
}
if self.include_efficiency {
values.push(Self::global_efficiency(graph));
values.push(Self::local_efficiency(graph));
}
values.push(Self::graph_entropy(graph));
values.push(Self::estimate_fiedler(graph));
if self.include_degree_stats {
let n = graph.num_nodes;
let degrees: Vec<f64> = (0..n).map(|i| graph.node_degree(i)).collect();
let mean_deg = if n > 0 {
degrees.iter().sum::<f64>() / n as f64
} else {
0.0
};
let std_deg = if n > 0 {
let var =
degrees.iter().map(|d| (d - mean_deg).powi(2)).sum::<f64>() / n as f64;
var.sqrt()
} else {
0.0
};
let max_deg = degrees.iter().cloned().fold(0.0_f64, f64::max);
let min_deg = degrees.iter().cloned().fold(f64::INFINITY, f64::min);
let min_deg = if min_deg.is_infinite() { 0.0 } else { min_deg };
values.push(mean_deg);
values.push(std_deg);
values.push(max_deg);
values.push(min_deg);
}
values.push(graph.density());
values.push(Self::clustering_coefficient(graph));
values.push(Self::num_components(graph) as f64);
let meta = default_metadata("topology", graph.atlas);
NeuralEmbedding::new(values, graph.timestamp, meta)
}
/// Number of features produced with current settings.
pub fn feature_count(&self) -> usize {
let mut count = 0;
if self.include_mincut {
count += 1;
}
if self.include_modularity {
count += 1;
}
if self.include_efficiency {
count += 2;
}
count += 2; // entropy + fiedler
if self.include_degree_stats {
count += 4;
}
count += 3; // density, clustering, components
count
}
}
impl Default for TopologyEmbedder {
fn default() -> Self {
Self::new()
}
}
impl EmbeddingGenerator for TopologyEmbedder {
fn embedding_dim(&self) -> usize {
self.feature_count()
}
fn embed(&self, graph: &BrainGraph) -> Result<NeuralEmbedding> {
self.embed_graph(graph)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ruv_neural_core::brain::Atlas;
use ruv_neural_core::graph::{BrainEdge, ConnectivityMetric};
use ruv_neural_core::signal::FrequencyBand;
fn make_triangle() -> BrainGraph {
BrainGraph {
num_nodes: 3,
edges: vec![
BrainEdge {
source: 0,
target: 1,
weight: 1.0,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 1,
target: 2,
weight: 1.0,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
BrainEdge {
source: 0,
target: 2,
weight: 1.0,
metric: ConnectivityMetric::Coherence,
frequency_band: FrequencyBand::Alpha,
},
],
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(3),
}
}
#[test]
fn test_topology_embed_triangle() {
let graph = make_triangle();
let embedder = TopologyEmbedder::new();
let emb = embedder.embed(&graph).unwrap();
assert_eq!(emb.dimension, embedder.feature_count());
assert_eq!(emb.metadata.embedding_method, "topology");
let dim = emb.dimension;
// Last three values: density, clustering, components
assert!((emb.vector[dim - 3] - 1.0).abs() < 1e-10, "density should be 1.0");
assert!((emb.vector[dim - 2] - 1.0).abs() < 1e-10, "clustering should be 1.0");
assert!((emb.vector[dim - 1] - 1.0).abs() < 1e-10, "should be 1 component");
}
#[test]
fn test_topology_captures_known_features() {
let graph = make_triangle();
let embedder = TopologyEmbedder::new();
let emb = embedder.embed(&graph).unwrap();
// Global efficiency of K3: all pairs distance 1, so efficiency = 1.0
// index: mincut(0), modularity(1), global_eff(2), local_eff(3)
assert!(
(emb.vector[2] - 1.0).abs() < 1e-10,
"global efficiency of K3 should be 1.0, got {}",
emb.vector[2]
);
}
#[test]
fn test_empty_graph() {
let graph = BrainGraph {
num_nodes: 4,
edges: vec![],
timestamp: 0.0,
window_duration_s: 1.0,
atlas: Atlas::Custom(4),
};
let embedder = TopologyEmbedder::new();
let emb = embedder.embed(&graph).unwrap();
let dim = emb.dimension;
assert!((emb.vector[dim - 3]).abs() < 1e-10);
assert!((emb.vector[dim - 2]).abs() < 1e-10);
assert!((emb.vector[dim - 1] - 4.0).abs() < 1e-10);
}
}
@@ -1,24 +0,0 @@
[package]
name = "ruv-neural-esp32"
description = "rUv Neural — ESP32 edge integration for neural sensor data acquisition and preprocessing"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[features]
default = ["std"]
std = []
no_std = []
simulator = ["std"]
[dependencies]
ruv-neural-core = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
num-traits = { workspace = true }
[dev-dependencies]
rand = { workspace = true }
approx = { workspace = true }
@@ -1,106 +0,0 @@
# ruv-neural-esp32
ESP32 edge integration for neural sensor data acquisition and preprocessing.
## Overview
`ruv-neural-esp32` provides lightweight processing modules designed to run on
ESP32 microcontrollers for real-time neural sensor data acquisition and
preprocessing at the edge. It handles ADC sampling, time-division multiplexing
for multi-sensor coordination, IIR filtering and downsampling on-device, power
management for battery operation, a binary communication protocol for streaming
data to the rUv Neural backend, and multi-node data aggregation.
## Features
- **ADC interface** (`adc`): `AdcReader` with configurable `AdcConfig` including
sample rate, resolution, attenuation levels, and multi-channel support via
`AdcChannel`
- **TDM scheduling** (`tdm`): `TdmScheduler` and `TdmNode` for time-division
multiplexed multi-sensor coordination with configurable `SyncMethod`
(GPIO trigger, I2S clock, software timer)
- **Edge preprocessing** (`preprocessing`): `EdgePreprocessor` with fixed-point
IIR filters (`IirCoeffs`), downsampling, and DC offset removal optimized
for constrained embedded environments
- **Communication protocol** (`protocol`): `NeuralDataPacket` with `PacketHeader`
and `ChannelData` for efficient binary data streaming to the backend over
UART, SPI, or WiFi
- **Power management** (`power`): `PowerManager` with `PowerConfig` and `PowerMode`
(active, light sleep, deep sleep, hibernate) for battery-powered deployments
- **Multi-node aggregation** (`aggregator`): `NodeAggregator` for combining data
from multiple ESP32 nodes into synchronized multi-channel streams
## Usage
```rust
use ruv_neural_esp32::{
AdcReader, AdcConfig, Attenuation,
TdmScheduler, TdmNode, SyncMethod,
EdgePreprocessor, IirCoeffs,
NeuralDataPacket, PacketHeader, ChannelData,
PowerManager, PowerConfig, PowerMode,
NodeAggregator,
};
// Configure ADC for 4-channel acquisition
let config = AdcConfig {
sample_rate_hz: 1000,
resolution_bits: 12,
attenuation: Attenuation::Db11,
channels: vec![
AdcChannel { pin: 32, gain: 1.0 },
AdcChannel { pin: 33, gain: 1.0 },
AdcChannel { pin: 34, gain: 1.0 },
AdcChannel { pin: 35, gain: 1.0 },
],
};
let mut adc = AdcReader::new(config);
// Set up TDM scheduling for multi-sensor sync
let scheduler = TdmScheduler::new(SyncMethod::GpioTrigger);
let node = TdmNode::new(0, scheduler);
// Preprocess on-device with IIR filter
let mut preprocessor = EdgePreprocessor::new(1000.0);
let filtered = preprocessor.process(&raw_samples);
// Build a data packet for transmission
let packet = NeuralDataPacket {
header: PacketHeader::new(4, 250),
channels: vec![ChannelData { samples: filtered }],
};
// Power management
let mut power = PowerManager::new(PowerConfig::default());
power.set_mode(PowerMode::LightSleep);
```
## API Reference
| Module | Key Types |
|-----------------|--------------------------------------------------------------|
| `adc` | `AdcReader`, `AdcConfig`, `AdcChannel`, `Attenuation` |
| `tdm` | `TdmScheduler`, `TdmNode`, `SyncMethod` |
| `preprocessing` | `EdgePreprocessor`, `IirCoeffs` |
| `protocol` | `NeuralDataPacket`, `PacketHeader`, `ChannelData` |
| `power` | `PowerManager`, `PowerConfig`, `PowerMode` |
| `aggregator` | `NodeAggregator` |
## Feature Flags
| Feature | Default | Description |
|-------------|---------|------------------------------------------|
| `std` | Yes | Standard library (desktop simulation) |
| `no_std` | No | Bare-metal ESP32 target |
| `simulator` | No | Simulated ADC for testing (requires std) |
## Integration
Depends on `ruv-neural-core` for shared types. Preprocessed data packets are
sent to the host system where `ruv-neural-sensor` or `ruv-neural-signal` can
consume them for further processing. Designed to run independently on ESP32
hardware or in simulation mode on desktop for testing.
## License
MIT OR Apache-2.0
@@ -1,313 +0,0 @@
//! ADC interface for sensor data acquisition.
//!
//! Provides ESP32 ADC configuration and a ring-buffer backed data reader that
//! converts raw ADC values to physical units (femtotesla). The ring buffer is
//! populated via [`AdcReader::load_buffer`] (the production data input path)
//! or by hardware DMA on actual ESP32 targets. On `no_std` the reader would
//! wire directly into the ADC peripheral.
use ruv_neural_core::sensor::SensorType;
use ruv_neural_core::{Result, RuvNeuralError};
use serde::{Deserialize, Serialize};
/// ESP32 ADC input attenuation setting.
///
/// Controls the measurable voltage range on an ADC channel.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Attenuation {
/// 0 dB — range ~100-950 mV.
Db0,
/// 2.5 dB — range ~100-1250 mV.
Db2_5,
/// 6 dB — range ~150-1750 mV.
Db6,
/// 11 dB — range ~150-2450 mV.
Db11,
}
impl Attenuation {
/// Maximum measurable voltage in millivolts for this attenuation.
pub fn max_voltage_mv(&self) -> u32 {
match self {
Attenuation::Db0 => 950,
Attenuation::Db2_5 => 1250,
Attenuation::Db6 => 1750,
Attenuation::Db11 => 2450,
}
}
}
/// Configuration for a single ADC channel.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdcChannel {
/// ADC channel identifier (0-7 on ESP32).
pub channel_id: u8,
/// GPIO pin number this channel is wired to.
pub gpio_pin: u8,
/// Input attenuation setting.
pub attenuation: Attenuation,
/// Type of sensor connected to this channel.
pub sensor_type: SensorType,
/// Gain factor applied during conversion to physical units.
pub gain: f64,
/// Offset applied during conversion to physical units.
pub offset: f64,
}
/// ESP32 ADC configuration for neural sensor readout.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdcConfig {
/// Channels to sample.
pub channels: Vec<AdcChannel>,
/// Target sample rate in Hz.
pub sample_rate_hz: u32,
/// ADC resolution in bits (12 or 16).
pub resolution_bits: u8,
/// Reference voltage in millivolts.
pub reference_voltage_mv: u32,
/// Whether DMA transfers are enabled for continuous sampling.
pub dma_enabled: bool,
}
impl AdcConfig {
/// Maximum raw ADC value for the configured resolution.
///
/// Clamps the result to `i16::MAX` when `resolution_bits >= 16` to
/// prevent integer overflow.
pub fn max_raw_value(&self) -> i16 {
let bits = self.resolution_bits.min(15);
((1u32 << bits) - 1) as i16
}
/// Creates a default configuration with a single NV diamond channel.
pub fn default_single_channel() -> Self {
Self {
channels: vec![AdcChannel {
channel_id: 0,
gpio_pin: 36,
attenuation: Attenuation::Db11,
sensor_type: SensorType::NvDiamond,
gain: 1.0,
offset: 0.0,
}],
sample_rate_hz: 1000,
resolution_bits: 12,
reference_voltage_mv: 3300,
dma_enabled: false,
}
}
}
/// Ring-buffer backed ADC data reader that converts raw ADC values to
/// physical units.
///
/// The internal ring buffer is filled by [`load_buffer`](Self::load_buffer)
/// (the production data input path from DMA or manual sampling) or by
/// [`fill_with_calibration_signal`](Self::fill_with_calibration_signal) for
/// self-test/calibration. On actual ESP32 hardware the DMA controller writes
/// directly into this buffer.
pub struct AdcReader {
config: AdcConfig,
buffer: Vec<Vec<i16>>,
buffer_pos: usize,
}
impl AdcReader {
/// Create a new reader for the given ADC configuration.
///
/// Allocates a ring buffer with 4096 samples per channel.
pub fn new(config: AdcConfig) -> Self {
let num_channels = config.channels.len();
let buffer_size = 4096;
let buffer = vec![vec![0i16; buffer_size]; num_channels];
Self {
config,
buffer,
buffer_pos: 0,
}
}
/// Read `num_samples` from every configured channel, returning values in
/// femtotesla.
///
/// The outer `Vec` is indexed by channel and the inner `Vec` contains
/// the converted sample values.
pub fn read_samples(&mut self, num_samples: usize) -> Result<Vec<Vec<f64>>> {
if num_samples == 0 {
return Err(RuvNeuralError::Signal(
"num_samples must be greater than zero".into(),
));
}
let num_channels = self.config.channels.len();
if num_channels == 0 {
return Err(RuvNeuralError::Sensor(
"No ADC channels configured".into(),
));
}
let mut result = Vec::with_capacity(num_channels);
let buf_len = self.buffer[0].len();
for (ch_idx, channel) in self.config.channels.iter().enumerate() {
let mut samples = Vec::with_capacity(num_samples);
for i in 0..num_samples {
let pos = (self.buffer_pos + i) % buf_len;
let raw = self.buffer[ch_idx][pos];
samples.push(self.to_femtotesla(raw, channel));
}
result.push(samples);
}
self.buffer_pos = (self.buffer_pos + num_samples) % buf_len;
Ok(result)
}
/// Convert a raw ADC value to femtotesla using the channel's gain and
/// offset.
///
/// Conversion: `fT = (raw / max_raw) * ref_voltage * gain + offset`
pub fn to_femtotesla(&self, raw: i16, channel: &AdcChannel) -> f64 {
let max_raw = self.config.max_raw_value() as f64;
let voltage_ratio = raw as f64 / max_raw;
let voltage_mv = voltage_ratio * self.config.reference_voltage_mv as f64;
voltage_mv * channel.gain + channel.offset
}
/// Load raw samples into the internal ring buffer for a given channel.
///
/// This is the production data input path. On real hardware the DMA
/// controller calls this (or writes directly to the buffer memory) to
/// deliver new ADC readings. Also used in host-side testing to inject
/// known waveforms.
pub fn load_buffer(&mut self, channel_idx: usize, data: &[i16]) -> Result<()> {
if channel_idx >= self.buffer.len() {
return Err(RuvNeuralError::ChannelOutOfRange {
channel: channel_idx,
max: self.buffer.len().saturating_sub(1),
});
}
let buf_len = self.buffer[channel_idx].len();
for (i, &val) in data.iter().enumerate() {
if i >= buf_len {
break;
}
self.buffer[channel_idx][i] = val;
}
Ok(())
}
/// Returns a reference to the current configuration.
pub fn config(&self) -> &AdcConfig {
&self.config
}
/// Resets the buffer read position to zero.
pub fn reset(&mut self) {
self.buffer_pos = 0;
}
/// Fill all channels with a known sinusoidal calibration signal for
/// self-test and gain verification.
///
/// Writes a full-scale sine wave at the given frequency into every
/// channel's ring buffer. After calling this, [`read_samples`](Self::read_samples)
/// will return the calibration waveform converted to femtotesla, which
/// can be compared against the expected amplitude to verify the gain
/// and offset calibration.
///
/// # Arguments
/// * `frequency_hz` - Frequency of the calibration sine wave.
///
/// # Example
/// ```
/// # use ruv_neural_esp32::adc::{AdcConfig, AdcReader};
/// let config = AdcConfig::default_single_channel();
/// let mut reader = AdcReader::new(config);
/// reader.fill_with_calibration_signal(10.0);
/// let data = reader.read_samples(100).unwrap();
/// // data now contains a 10 Hz sine converted to fT
/// ```
pub fn fill_with_calibration_signal(&mut self, frequency_hz: f64) {
let buf_len = self.buffer[0].len();
let max_raw = self.config.max_raw_value();
let sample_rate = self.config.sample_rate_hz as f64;
for ch_idx in 0..self.buffer.len() {
for i in 0..buf_len {
let t = i as f64 / sample_rate;
// Sine wave at ~90% of full scale to avoid clipping
let value = 0.9 * (max_raw as f64)
* (2.0 * std::f64::consts::PI * frequency_hz * t).sin();
self.buffer[ch_idx][i] = value.round() as i16;
}
}
self.buffer_pos = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_to_femtotesla_known_value() {
let config = AdcConfig {
channels: vec![AdcChannel {
channel_id: 0,
gpio_pin: 36,
attenuation: Attenuation::Db11,
sensor_type: SensorType::NvDiamond,
gain: 2.0,
offset: 10.0,
}],
sample_rate_hz: 1000,
resolution_bits: 12,
reference_voltage_mv: 3300,
dma_enabled: false,
};
let reader = AdcReader::new(config);
let channel = &reader.config().channels[0];
// raw = 2048, max = 4095, ratio = 0.5001..., voltage = ~1650.4 mV
// fT = 1650.4 * 2.0 + 10.0 = ~3310.8
let ft = reader.to_femtotesla(2048, channel);
let expected = (2048.0 / 4095.0) * 3300.0 * 2.0 + 10.0;
assert!((ft - expected).abs() < 1e-6, "got {ft}, expected {expected}");
}
#[test]
fn test_read_samples_length() {
let config = AdcConfig::default_single_channel();
let mut reader = AdcReader::new(config);
let result = reader.read_samples(100).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].len(), 100);
}
#[test]
fn test_load_buffer_and_read() {
let config = AdcConfig::default_single_channel();
let mut reader = AdcReader::new(config);
let data: Vec<i16> = (0..10).collect();
reader.load_buffer(0, &data).unwrap();
let result = reader.read_samples(10).unwrap();
// Values should be monotonically increasing since raw values are 0..10
for i in 1..10 {
assert!(result[0][i] > result[0][i - 1]);
}
}
#[test]
fn test_read_zero_samples_error() {
let config = AdcConfig::default_single_channel();
let mut reader = AdcReader::new(config);
assert!(reader.read_samples(0).is_err());
}
#[test]
fn test_attenuation_max_voltage() {
assert_eq!(Attenuation::Db0.max_voltage_mv(), 950);
assert_eq!(Attenuation::Db11.max_voltage_mv(), 2450);
}
}
@@ -1,214 +0,0 @@
//! Multi-node data aggregation.
//!
//! Collects [`NeuralDataPacket`]s from multiple ESP32 nodes and assembles them
//! into a unified [`MultiChannelTimeSeries`] once all nodes have reported for
//! a given time window.
use ruv_neural_core::signal::MultiChannelTimeSeries;
use ruv_neural_core::{Result, RuvNeuralError};
use crate::protocol::NeuralDataPacket;
/// Aggregates data packets from multiple ESP32 sensor nodes.
///
/// Packets are buffered per-node. When every node has contributed at least one
/// packet, [`try_assemble`](NodeAggregator::try_assemble) combines them into a
/// single time series — matching packets by timestamp within the configured
/// sync tolerance.
pub struct NodeAggregator {
node_count: usize,
buffers: Vec<Vec<NeuralDataPacket>>,
sync_tolerance_us: u64,
}
impl NodeAggregator {
/// Create a new aggregator expecting `node_count` distinct nodes.
pub fn new(node_count: usize) -> Self {
Self {
node_count,
buffers: vec![Vec::new(); node_count],
sync_tolerance_us: 1_000, // 1 ms default
}
}
/// Buffer a packet from a specific node.
pub fn receive_packet(
&mut self,
node_id: usize,
packet: NeuralDataPacket,
) -> Result<()> {
if node_id >= self.node_count {
return Err(RuvNeuralError::Sensor(format!(
"Node ID {node_id} out of range (max {})",
self.node_count - 1
)));
}
self.buffers[node_id].push(packet);
Ok(())
}
/// Try to assemble a [`MultiChannelTimeSeries`] from the buffered packets.
///
/// Returns `Some` when every node has at least one packet whose timestamps
/// are within `sync_tolerance_us` of each other. The matching packets are
/// consumed from the buffers.
pub fn try_assemble(&mut self) -> Option<MultiChannelTimeSeries> {
// Check that every node has at least one packet
if self.buffers.iter().any(|b| b.is_empty()) {
return None;
}
// Use the first node's earliest packet as the reference timestamp
let ref_ts = self.buffers[0][0].header.timestamp_us;
// Find a matching packet in each buffer
let mut indices: Vec<usize> = Vec::with_capacity(self.node_count);
for buf in &self.buffers {
let found = buf.iter().position(|p| {
let diff = if p.header.timestamp_us >= ref_ts {
p.header.timestamp_us - ref_ts
} else {
ref_ts - p.header.timestamp_us
};
diff <= self.sync_tolerance_us
});
match found {
Some(idx) => indices.push(idx),
None => return None,
}
}
// Remove matched packets and merge channel data
let mut all_data: Vec<Vec<f64>> = Vec::new();
let mut sample_rate = 1000.0_f64;
for (buf_idx, &pkt_idx) in indices.iter().enumerate() {
let pkt = self.buffers[buf_idx].remove(pkt_idx);
sample_rate = pkt.header.sample_rate_hz as f64;
for ch in &pkt.channels {
let channel_data: Vec<f64> = ch
.samples
.iter()
.map(|&s| s as f64 * ch.scale_factor as f64)
.collect();
all_data.push(channel_data);
}
}
if all_data.is_empty() {
return None;
}
let timestamp = ref_ts as f64 / 1_000_000.0;
MultiChannelTimeSeries::new(all_data, sample_rate, timestamp).ok()
}
/// Set the timestamp tolerance in microseconds for matching packets
/// across nodes.
pub fn set_sync_tolerance(&mut self, tolerance_us: u64) {
self.sync_tolerance_us = tolerance_us;
}
/// Returns the number of buffered packets for a given node.
pub fn buffered_count(&self, node_id: usize) -> usize {
self.buffers.get(node_id).map_or(0, |b| b.len())
}
/// Returns the total number of expected nodes.
pub fn node_count(&self) -> usize {
self.node_count
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{ChannelData, NeuralDataPacket, PacketHeader, PACKET_MAGIC, PROTOCOL_VERSION};
fn make_packet(num_channels: u8, timestamp_us: u64, samples: Vec<i16>) -> NeuralDataPacket {
let channels = (0..num_channels)
.map(|id| ChannelData {
channel_id: id,
samples: samples.clone(),
scale_factor: 1.0,
})
.collect();
NeuralDataPacket {
header: PacketHeader {
magic: PACKET_MAGIC,
version: PROTOCOL_VERSION,
packet_id: 0,
timestamp_us,
num_channels,
samples_per_channel: samples.len() as u16,
sample_rate_hz: 1000,
},
channels,
quality: vec![255; num_channels as usize],
checksum: 0,
}
}
#[test]
fn test_assemble_two_nodes() {
let mut agg = NodeAggregator::new(2);
let p0 = make_packet(1, 1000, vec![10, 20, 30]);
let p1 = make_packet(1, 1000, vec![40, 50, 60]);
agg.receive_packet(0, p0).unwrap();
// Only one node has reported — assembly requires all nodes
assert!(agg.try_assemble().is_none());
agg.receive_packet(1, p1).unwrap();
let ts = agg.try_assemble().unwrap();
assert_eq!(ts.num_channels, 2);
assert_eq!(ts.num_samples, 3);
assert!((ts.data[0][0] - 10.0).abs() < 1e-6);
assert!((ts.data[1][2] - 60.0).abs() < 1e-6);
}
#[test]
fn test_assemble_with_tolerance() {
let mut agg = NodeAggregator::new(2);
agg.set_sync_tolerance(500);
let p0 = make_packet(1, 1000, vec![1, 2]);
let p1 = make_packet(1, 1400, vec![3, 4]); // Within 500 us tolerance
agg.receive_packet(0, p0).unwrap();
agg.receive_packet(1, p1).unwrap();
assert!(agg.try_assemble().is_some());
}
#[test]
fn test_assemble_exceeds_tolerance() {
let mut agg = NodeAggregator::new(2);
agg.set_sync_tolerance(100);
let p0 = make_packet(1, 1000, vec![1, 2]);
let p1 = make_packet(1, 2000, vec![3, 4]); // 1000 us apart > 100 us tolerance
agg.receive_packet(0, p0).unwrap();
agg.receive_packet(1, p1).unwrap();
assert!(agg.try_assemble().is_none());
}
#[test]
fn test_receive_invalid_node() {
let mut agg = NodeAggregator::new(2);
let p = make_packet(1, 0, vec![1]);
assert!(agg.receive_packet(5, p).is_err());
}
#[test]
fn test_buffers_consumed_after_assembly() {
let mut agg = NodeAggregator::new(1);
let p = make_packet(1, 0, vec![1, 2, 3]);
agg.receive_packet(0, p).unwrap();
assert_eq!(agg.buffered_count(0), 1);
agg.try_assemble().unwrap();
assert_eq!(agg.buffered_count(0), 0);
}
}
@@ -1,28 +0,0 @@
//! rUv Neural ESP32 — Edge integration for neural sensor data acquisition and preprocessing.
//!
//! This crate provides lightweight processing that runs on ESP32 hardware for
//! real-time sensor data acquisition and preprocessing before sending to the
//! main RuVector backend.
//!
//! # Modules
//!
//! - [`adc`] — ADC interface for sensor data acquisition
//! - [`preprocessing`] — Lightweight edge preprocessing (IIR filters, downsampling)
//! - [`protocol`] — Communication protocol with the RuVector backend
//! - [`tdm`] — Time-Division Multiplexing for multi-sensor coordination
//! - [`power`] — Power management for battery operation
//! - [`aggregator`] — Multi-node data aggregation
pub mod adc;
pub mod aggregator;
pub mod power;
pub mod preprocessing;
pub mod protocol;
pub mod tdm;
pub use adc::{AdcChannel, AdcConfig, AdcReader, Attenuation};
pub use aggregator::NodeAggregator;
pub use power::{PowerConfig, PowerManager, PowerMode};
pub use preprocessing::{EdgePreprocessor, IirCoeffs};
pub use protocol::{ChannelData, NeuralDataPacket, PacketHeader};
pub use tdm::{SyncMethod, TdmNode, TdmScheduler};
@@ -1,242 +0,0 @@
//! Power management for battery-operated ESP32 sensor nodes.
//!
//! Provides duty-cycle estimation, sleep scheduling, and automatic duty-cycle
//! optimization to hit a target runtime.
use serde::{Deserialize, Serialize};
/// Operating power mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PowerMode {
/// Full speed — all peripherals active.
Active,
/// Reduced clock, WiFi power save.
LowPower,
/// Minimal peripherals, deep sleep between samples.
UltraLowPower,
/// Full deep sleep — wakes only on timer or external interrupt.
Sleep,
}
impl PowerMode {
/// Estimated current draw in milliamps for this mode on an ESP32-S3.
pub fn estimated_current_ma(&self) -> f64 {
match self {
PowerMode::Active => 240.0,
PowerMode::LowPower => 80.0,
PowerMode::UltraLowPower => 20.0,
PowerMode::Sleep => 0.01,
}
}
}
/// Power management configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PowerConfig {
/// Base operating mode.
pub mode: PowerMode,
/// Whether to enter light sleep between sample bursts.
pub sleep_between_samples: bool,
/// Fraction of time spent actively sampling (0.0-1.0).
pub sample_duty_cycle: f64,
/// Fraction of time WiFi is enabled (0.0-1.0).
pub wifi_duty_cycle: f64,
}
impl Default for PowerConfig {
fn default() -> Self {
Self {
mode: PowerMode::Active,
sleep_between_samples: false,
sample_duty_cycle: 1.0,
wifi_duty_cycle: 1.0,
}
}
}
/// Power manager that tracks battery state and optimizes duty cycles.
pub struct PowerManager {
config: PowerConfig,
battery_mv: u32,
estimated_runtime_hours: f64,
}
impl PowerManager {
/// Create a new power manager with the given configuration.
pub fn new(config: PowerConfig) -> Self {
Self {
config,
battery_mv: 4200, // Fully charged LiPo
estimated_runtime_hours: 0.0,
}
}
/// Estimate runtime in hours given a battery capacity in mAh.
///
/// The effective current draw is a weighted average of active and sleep
/// currents based on the configured duty cycles.
pub fn estimate_runtime(&self, battery_capacity_mah: u32) -> f64 {
let active_current = self.config.mode.estimated_current_ma();
let sleep_current = PowerMode::Sleep.estimated_current_ma();
let sample_active = self.config.sample_duty_cycle.clamp(0.0, 1.0);
let wifi_active = self.config.wifi_duty_cycle.clamp(0.0, 1.0);
// WiFi adds roughly 80 mA when active
let wifi_overhead = 80.0 * wifi_active;
let effective_current =
active_current * sample_active + sleep_current * (1.0 - sample_active) + wifi_overhead;
if effective_current <= 0.0 {
return f64::INFINITY;
}
battery_capacity_mah as f64 / effective_current
}
/// Returns `true` if the node should sleep at the given time based on
/// the configured duty cycle.
///
/// Uses a simple periodic pattern: active for `duty * period`, then sleep
/// for the remainder. The period is fixed at 1 second (1_000_000 us).
pub fn should_sleep(&self, current_time_us: u64) -> bool {
if !self.config.sleep_between_samples {
return false;
}
let period_us: u64 = 1_000_000;
let active_us = (self.config.sample_duty_cycle * period_us as f64) as u64;
let position = current_time_us % period_us;
position >= active_us
}
/// Adjust the sample and WiFi duty cycles to reach the target runtime.
pub fn optimize_duty_cycle(&mut self, target_runtime_hours: f64) {
// Binary search for the duty cycle that achieves the target runtime
// with a 2000 mAh reference battery.
let battery_mah = 2000u32;
let mut low = 0.01_f64;
let mut high = 1.0_f64;
for _ in 0..50 {
let mid = (low + high) / 2.0;
self.config.sample_duty_cycle = mid;
self.config.wifi_duty_cycle = mid;
let runtime = self.estimate_runtime(battery_mah);
if runtime < target_runtime_hours {
high = mid;
} else {
low = mid;
}
}
self.config.sample_duty_cycle = low;
self.config.wifi_duty_cycle = low;
self.estimated_runtime_hours = self.estimate_runtime(battery_mah);
}
/// Update the battery voltage reading.
pub fn set_battery_mv(&mut self, mv: u32) {
self.battery_mv = mv;
}
/// Current battery voltage in millivolts.
pub fn battery_mv(&self) -> u32 {
self.battery_mv
}
/// Estimated remaining runtime in hours (after calling
/// `optimize_duty_cycle`).
pub fn estimated_runtime_hours(&self) -> f64 {
self.estimated_runtime_hours
}
/// Returns a reference to the current power configuration.
pub fn config(&self) -> &PowerConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_estimate_runtime_active() {
let config = PowerConfig {
mode: PowerMode::Active,
sleep_between_samples: false,
sample_duty_cycle: 1.0,
wifi_duty_cycle: 1.0,
};
let pm = PowerManager::new(config);
let hours = pm.estimate_runtime(2000);
// 2000 mAh / (240 + 80) = 6.25 hours
assert!((hours - 6.25).abs() < 0.1, "got {hours}");
}
#[test]
fn test_estimate_runtime_low_duty() {
let config = PowerConfig {
mode: PowerMode::Active,
sleep_between_samples: true,
sample_duty_cycle: 0.1,
wifi_duty_cycle: 0.1,
};
let pm = PowerManager::new(config);
let hours = pm.estimate_runtime(2000);
// Much longer than 6.25 hours
assert!(hours > 20.0, "expected >20h, got {hours}");
}
#[test]
fn test_should_sleep() {
let config = PowerConfig {
mode: PowerMode::Active,
sleep_between_samples: true,
sample_duty_cycle: 0.5,
wifi_duty_cycle: 1.0,
};
let pm = PowerManager::new(config);
// Active window: 0..500_000 us, sleep: 500_000..1_000_000 us
assert!(!pm.should_sleep(0));
assert!(!pm.should_sleep(499_999));
assert!(pm.should_sleep(500_000));
assert!(pm.should_sleep(999_999));
}
#[test]
fn test_should_sleep_disabled() {
let config = PowerConfig {
mode: PowerMode::Active,
sleep_between_samples: false,
sample_duty_cycle: 0.1,
wifi_duty_cycle: 0.1,
};
let pm = PowerManager::new(config);
assert!(!pm.should_sleep(999_999));
}
#[test]
fn test_optimize_duty_cycle() {
let config = PowerConfig {
mode: PowerMode::Active,
sleep_between_samples: true,
sample_duty_cycle: 1.0,
wifi_duty_cycle: 1.0,
};
let mut pm = PowerManager::new(config);
pm.optimize_duty_cycle(48.0); // Target 48 hours
// Duty cycles should have been reduced
assert!(pm.config().sample_duty_cycle < 1.0);
assert!(pm.config().sample_duty_cycle > 0.0);
}
#[test]
fn test_power_mode_current() {
assert!(PowerMode::Active.estimated_current_ma() > PowerMode::LowPower.estimated_current_ma());
assert!(PowerMode::LowPower.estimated_current_ma() > PowerMode::UltraLowPower.estimated_current_ma());
assert!(PowerMode::UltraLowPower.estimated_current_ma() > PowerMode::Sleep.estimated_current_ma());
}
}
@@ -1,289 +0,0 @@
//! Lightweight edge preprocessing that runs on the ESP32 before data is sent
//! upstream to the RuVector backend.
//!
//! Includes fixed-point IIR filtering for integer-only ESP32 math paths and
//! floating-point downsampling / pipeline processing for `std` targets.
/// IIR filter coefficients for a second-order section (biquad).
///
/// Transfer function: `H(z) = (b0 + b1*z^-1 + b2*z^-2) / (a0 + a1*z^-1 + a2*z^-2)`
#[derive(Debug, Clone)]
pub struct IirCoeffs {
/// Numerator coefficients `[b0, b1, b2]`.
pub b: [f64; 3],
/// Denominator coefficients `[a0, a1, a2]`.
pub a: [f64; 3],
}
impl IirCoeffs {
/// Create notch filter coefficients for a given frequency and sample rate.
///
/// Uses a quality factor of 30 for a narrow rejection band.
pub fn notch(freq_hz: f64, sample_rate_hz: f64) -> Self {
let w0 = 2.0 * std::f64::consts::PI * freq_hz / sample_rate_hz;
let q = 30.0;
let alpha = w0.sin() / (2.0 * q);
let cos_w0 = w0.cos();
let b0 = 1.0;
let b1 = -2.0 * cos_w0;
let b2 = 1.0;
let a0 = 1.0 + alpha;
let a1 = -2.0 * cos_w0;
let a2 = 1.0 - alpha;
// Normalize by a0
Self {
b: [b0 / a0, b1 / a0, b2 / a0],
a: [1.0, a1 / a0, a2 / a0],
}
}
/// Create a first-order high-pass filter (stored as second-order with
/// zero padding).
pub fn highpass(cutoff_hz: f64, sample_rate_hz: f64) -> Self {
let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff_hz);
let dt = 1.0 / sample_rate_hz;
let alpha = rc / (rc + dt);
Self {
b: [alpha, -alpha, 0.0],
a: [1.0, -(1.0 - alpha), 0.0],
}
}
/// Create a first-order low-pass filter (stored as second-order with
/// zero padding).
pub fn lowpass(cutoff_hz: f64, sample_rate_hz: f64) -> Self {
let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff_hz);
let dt = 1.0 / sample_rate_hz;
let alpha = dt / (rc + dt);
Self {
b: [alpha, 0.0, 0.0],
a: [1.0, -(1.0 - alpha), 0.0],
}
}
}
/// Minimal preprocessing pipeline that runs on the ESP32 before data is sent
/// upstream.
pub struct EdgePreprocessor {
/// Apply a 50 Hz notch filter (mains power, EU/Asia).
pub notch_50hz: bool,
/// Apply a 60 Hz notch filter (mains power, Americas).
pub notch_60hz: bool,
/// High-pass cutoff frequency in Hz.
pub highpass_hz: f64,
/// Low-pass cutoff frequency in Hz.
pub lowpass_hz: f64,
/// Downsample factor (1 = no downsampling).
pub downsample_factor: usize,
/// Sample rate of the incoming data in Hz.
pub sample_rate_hz: f64,
}
impl Default for EdgePreprocessor {
fn default() -> Self {
Self::new()
}
}
impl EdgePreprocessor {
/// Create a preprocessor with sensible defaults for neural sensing.
pub fn new() -> Self {
Self {
notch_50hz: true,
notch_60hz: true,
highpass_hz: 0.5,
lowpass_hz: 200.0,
downsample_factor: 1,
sample_rate_hz: 1000.0,
}
}
/// Apply a second-order IIR filter using fixed-point arithmetic.
///
/// Coefficients are scaled by 2^14 internally to use integer multiply/shift
/// on the ESP32. The output is clipped to `i16` range.
pub fn apply_iir_fixed(&self, samples: &[i16], coeffs: &IirCoeffs) -> Vec<i16> {
const SCALE: i64 = 1 << 14;
let b0 = (coeffs.b[0] * SCALE as f64) as i64;
let b1 = (coeffs.b[1] * SCALE as f64) as i64;
let b2 = (coeffs.b[2] * SCALE as f64) as i64;
let a1 = (coeffs.a[1] * SCALE as f64) as i64;
let a2 = (coeffs.a[2] * SCALE as f64) as i64;
let mut out = Vec::with_capacity(samples.len());
let mut x1: i64 = 0;
let mut x2: i64 = 0;
let mut y1: i64 = 0;
let mut y2: i64 = 0;
for &x0 in samples {
let x0 = x0 as i64;
let y0 = (b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) >> 14;
let clamped = y0.clamp(i16::MIN as i64, i16::MAX as i64) as i16;
out.push(clamped);
x2 = x1;
x1 = x0;
y2 = y1;
y1 = y0;
}
out
}
/// Apply a second-order IIR filter using floating-point arithmetic.
fn apply_iir_float(&self, samples: &[f64], coeffs: &IirCoeffs) -> Vec<f64> {
let mut out = Vec::with_capacity(samples.len());
let mut x1 = 0.0_f64;
let mut x2 = 0.0_f64;
let mut y1 = 0.0_f64;
let mut y2 = 0.0_f64;
for &x0 in samples {
let y0 = coeffs.b[0] * x0 + coeffs.b[1] * x1 + coeffs.b[2] * x2
- coeffs.a[1] * y1
- coeffs.a[2] * y2;
out.push(y0);
x2 = x1;
x1 = x0;
y2 = y1;
y1 = y0;
}
out
}
/// Downsample by block-averaging groups of `factor` consecutive samples.
///
/// If the input length is not a multiple of `factor`, the trailing samples
/// are averaged as a shorter block.
pub fn downsample(&self, samples: &[f64], factor: usize) -> Vec<f64> {
if factor <= 1 || samples.is_empty() {
return samples.to_vec();
}
samples
.chunks(factor)
.map(|chunk| {
let sum: f64 = chunk.iter().sum();
sum / chunk.len() as f64
})
.collect()
}
/// Run the full edge preprocessing pipeline on multi-channel data.
///
/// Steps (in order):
/// 1. High-pass filter (remove DC offset / drift)
/// 2. Notch filter at 50 Hz (if enabled)
/// 3. Notch filter at 60 Hz (if enabled)
/// 4. Low-pass filter (anti-alias before downsampling)
/// 5. Downsample
pub fn process(&self, raw_data: &[Vec<f64>]) -> Vec<Vec<f64>> {
let sr = self.sample_rate_hz;
let hp_coeffs = IirCoeffs::highpass(self.highpass_hz, sr);
let lp_coeffs = IirCoeffs::lowpass(self.lowpass_hz, sr);
let notch_50 = IirCoeffs::notch(50.0, sr);
let notch_60 = IirCoeffs::notch(60.0, sr);
raw_data
.iter()
.map(|channel| {
let mut data = self.apply_iir_float(channel, &hp_coeffs);
if self.notch_50hz {
data = self.apply_iir_float(&data, &notch_50);
}
if self.notch_60hz {
data = self.apply_iir_float(&data, &notch_60);
}
data = self.apply_iir_float(&data, &lp_coeffs);
self.downsample(&data, self.downsample_factor)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_downsample_factor_2() {
let pre = EdgePreprocessor::new();
let input: Vec<f64> = (0..10).map(|x| x as f64).collect();
let result = pre.downsample(&input, 2);
assert_eq!(result.len(), 5);
// [0,1] -> 0.5, [2,3] -> 2.5, ...
assert!((result[0] - 0.5).abs() < 1e-10);
assert!((result[1] - 2.5).abs() < 1e-10);
assert!((result[4] - 8.5).abs() < 1e-10);
}
#[test]
fn test_downsample_factor_1_is_identity() {
let pre = EdgePreprocessor::new();
let input = vec![1.0, 2.0, 3.0];
let result = pre.downsample(&input, 1);
assert_eq!(result, input);
}
#[test]
fn test_downsample_non_multiple() {
let pre = EdgePreprocessor::new();
let input: Vec<f64> = (0..7).map(|x| x as f64).collect();
let result = pre.downsample(&input, 3);
// [0,1,2]->1, [3,4,5]->4, [6]->6
assert_eq!(result.len(), 3);
assert!((result[2] - 6.0).abs() < 1e-10);
}
#[test]
fn test_process_output_length() {
let mut pre = EdgePreprocessor::new();
pre.downsample_factor = 4;
pre.sample_rate_hz = 1000.0;
let raw = vec![vec![0.0; 1000], vec![0.0; 1000]];
let result = pre.process(&raw);
assert_eq!(result.len(), 2);
assert_eq!(result[0].len(), 250);
assert_eq!(result[1].len(), 250);
}
#[test]
fn test_iir_fixed_passthrough_dc() {
// Identity-ish filter: b=[1,0,0], a=[1,0,0] should pass through
let pre = EdgePreprocessor::new();
let coeffs = IirCoeffs {
b: [1.0, 0.0, 0.0],
a: [1.0, 0.0, 0.0],
};
let input: Vec<i16> = vec![100, 200, 300, 400, 500];
let output = pre.apply_iir_fixed(&input, &coeffs);
assert_eq!(output.len(), 5);
// With identity filter, output should match input
for (i, &v) in output.iter().enumerate() {
assert_eq!(v, input[i], "mismatch at index {i}");
}
}
#[test]
fn test_notch_coefficients_valid() {
let coeffs = IirCoeffs::notch(50.0, 1000.0);
// a[0] should be normalized to 1.0
assert!((coeffs.a[0] - 1.0).abs() < 1e-10);
// b[0] and b[2] should be equal for a notch
assert!((coeffs.b[0] - coeffs.b[2]).abs() < 1e-10);
}
}
@@ -1,228 +0,0 @@
//! Communication protocol between ESP32 sensor nodes and the RuVector backend.
//!
//! Defines binary-serializable data packets with CRC32 checksums for reliable
//! transfer over WiFi or UART.
use ruv_neural_core::signal::MultiChannelTimeSeries;
use ruv_neural_core::{Result, RuvNeuralError};
use serde::{Deserialize, Serialize};
/// Magic bytes identifying a rUv Neural data packet.
pub const PACKET_MAGIC: [u8; 4] = [b'r', b'U', b'v', b'N'];
/// Current protocol version.
pub const PROTOCOL_VERSION: u8 = 1;
/// Header of a neural data packet.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PacketHeader {
/// Magic bytes — must be `b"rUvN"`.
pub magic: [u8; 4],
/// Protocol version.
pub version: u8,
/// Monotonically increasing packet identifier.
pub packet_id: u32,
/// Timestamp in microseconds since boot (or epoch).
pub timestamp_us: u64,
/// Number of channels in this packet.
pub num_channels: u8,
/// Number of samples per channel.
pub samples_per_channel: u16,
/// Sample rate in Hz.
pub sample_rate_hz: u16,
}
/// Per-channel sample data within a packet.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelData {
/// Channel identifier.
pub channel_id: u8,
/// Fixed-point sample values for bandwidth efficiency.
pub samples: Vec<i16>,
/// Multiply each sample by this factor to obtain femtotesla.
pub scale_factor: f32,
}
/// Data packet sent from an ESP32 node to the RuVector backend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NeuralDataPacket {
/// Packet header with metadata.
pub header: PacketHeader,
/// Per-channel sample data.
pub channels: Vec<ChannelData>,
/// Per-channel signal quality indicator (0 = worst, 255 = best).
pub quality: Vec<u8>,
/// CRC32 checksum of the serialized payload (header + channels + quality).
pub checksum: u32,
}
impl NeuralDataPacket {
/// Create a new empty packet for the given number of channels.
pub fn new(num_channels: u8) -> Self {
Self {
header: PacketHeader {
magic: PACKET_MAGIC,
version: PROTOCOL_VERSION,
packet_id: 0,
timestamp_us: 0,
num_channels,
samples_per_channel: 0,
sample_rate_hz: 1000,
},
channels: (0..num_channels)
.map(|id| ChannelData {
channel_id: id,
samples: Vec::new(),
scale_factor: 1.0,
})
.collect(),
quality: vec![255; num_channels as usize],
checksum: 0,
}
}
/// Serialize the packet to a byte vector (JSON for portability in std
/// mode; a production ESP32 build would use a compact binary format).
pub fn serialize(&self) -> Vec<u8> {
serde_json::to_vec(self).unwrap_or_default()
}
/// Deserialize a packet from bytes.
pub fn deserialize(data: &[u8]) -> Result<Self> {
let packet: NeuralDataPacket = serde_json::from_slice(data).map_err(|e| {
RuvNeuralError::Serialization(format!("Failed to deserialize packet: {e}"))
})?;
if packet.header.magic != PACKET_MAGIC {
return Err(RuvNeuralError::Serialization(
"Invalid magic bytes".into(),
));
}
Ok(packet)
}
/// Compute CRC32 checksum of a byte slice using the IEEE polynomial.
pub fn compute_checksum(data: &[u8]) -> u32 {
// CRC32 IEEE polynomial lookup-free implementation
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xEDB8_8320;
} else {
crc >>= 1;
}
}
}
!crc
}
/// Recompute and store the checksum for this packet.
pub fn update_checksum(&mut self) {
let mut pkt = self.clone();
pkt.checksum = 0;
let bytes = pkt.serialize();
self.checksum = Self::compute_checksum(&bytes);
}
/// Verify that the stored checksum matches the payload.
pub fn verify_checksum(&self) -> bool {
let mut pkt = self.clone();
let stored = pkt.checksum;
pkt.checksum = 0;
let bytes = pkt.serialize();
let computed = Self::compute_checksum(&bytes);
stored == computed
}
/// Convert this packet into a [`MultiChannelTimeSeries`] by scaling the
/// fixed-point samples back to floating-point femtotesla values.
pub fn to_multichannel_timeseries(&self) -> Result<MultiChannelTimeSeries> {
let data: Vec<Vec<f64>> = self
.channels
.iter()
.map(|ch| {
ch.samples
.iter()
.map(|&s| s as f64 * ch.scale_factor as f64)
.collect()
})
.collect();
let sample_rate = self.header.sample_rate_hz as f64;
let timestamp = self.header.timestamp_us as f64 / 1_000_000.0;
MultiChannelTimeSeries::new(data, sample_rate, timestamp)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_roundtrip() {
let mut pkt = NeuralDataPacket::new(2);
pkt.header.packet_id = 42;
pkt.header.timestamp_us = 123_456_789;
pkt.header.samples_per_channel = 3;
pkt.channels[0].samples = vec![100, 200, 300];
pkt.channels[0].scale_factor = 0.5;
pkt.channels[1].samples = vec![400, 500, 600];
pkt.channels[1].scale_factor = 1.0;
let bytes = pkt.serialize();
let decoded = NeuralDataPacket::deserialize(&bytes).unwrap();
assert_eq!(decoded.header.packet_id, 42);
assert_eq!(decoded.header.num_channels, 2);
assert_eq!(decoded.channels[0].samples, vec![100, 200, 300]);
assert_eq!(decoded.channels[1].samples, vec![400, 500, 600]);
}
#[test]
fn test_checksum_verification() {
let mut pkt = NeuralDataPacket::new(1);
pkt.channels[0].samples = vec![10, 20, 30];
pkt.update_checksum();
assert!(pkt.verify_checksum());
// Corrupt a value
pkt.channels[0].samples[0] = 999;
assert!(!pkt.verify_checksum());
}
#[test]
fn test_to_multichannel_timeseries() {
let mut pkt = NeuralDataPacket::new(2);
pkt.header.sample_rate_hz = 500;
pkt.header.samples_per_channel = 3;
pkt.channels[0].samples = vec![100, 200, 300];
pkt.channels[0].scale_factor = 2.0;
pkt.channels[1].samples = vec![10, 20, 30];
pkt.channels[1].scale_factor = 0.5;
let ts = pkt.to_multichannel_timeseries().unwrap();
assert_eq!(ts.num_channels, 2);
assert_eq!(ts.num_samples, 3);
assert!((ts.data[0][0] - 200.0).abs() < 1e-6);
assert!((ts.data[1][2] - 15.0).abs() < 1e-6);
}
#[test]
fn test_invalid_magic_rejected() {
let mut pkt = NeuralDataPacket::new(1);
pkt.header.magic = [0, 0, 0, 0];
let bytes = pkt.serialize();
assert!(NeuralDataPacket::deserialize(&bytes).is_err());
}
#[test]
fn test_compute_checksum_deterministic() {
let data = b"hello world";
let c1 = NeuralDataPacket::compute_checksum(data);
let c2 = NeuralDataPacket::compute_checksum(data);
assert_eq!(c1, c2);
assert_ne!(c1, 0);
}
}
@@ -1,187 +0,0 @@
//! Time-Division Multiplexing (TDM) scheduler for coordinating multiple ESP32
//! sensor nodes.
//!
//! Each node is assigned a time slot within a repeating frame. During its slot
//! a node may transmit sensor data; outside its slot the node listens or
//! sleeps.
use serde::{Deserialize, Serialize};
/// Synchronization method used to align TDM frames across nodes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SyncMethod {
/// GPS pulse-per-second signal.
GpsPps,
/// NTP-based time synchronization.
NtpSync,
/// WiFi beacon timestamp alignment.
WifiBeacon,
/// Leader node broadcasts sync pulses; followers align to it.
LeaderFollower,
}
/// A single node in the TDM schedule.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TdmNode {
/// Unique node identifier.
pub node_id: u8,
/// Assigned slot index within the TDM frame.
pub slot_index: u8,
/// ADC channels this node is responsible for.
pub channels: Vec<u8>,
}
/// TDM scheduler for coordinating multiple ESP32 sensor nodes.
///
/// A TDM frame is divided into equally-sized time slots. Each node transmits
/// only during its assigned slot, preventing collisions and ensuring
/// deterministic latency.
pub struct TdmScheduler {
/// Registered nodes and their slot assignments.
pub nodes: Vec<TdmNode>,
/// Duration of a single slot in microseconds.
pub slot_duration_us: u32,
/// Total frame duration in microseconds.
pub frame_duration_us: u32,
/// Synchronization method.
pub sync_method: SyncMethod,
}
impl TdmScheduler {
/// Create a new scheduler for `num_nodes` nodes with the given slot
/// duration.
///
/// Nodes are assigned sequential slot indices and the frame duration is
/// computed as `num_nodes * slot_duration_us`.
pub fn new(num_nodes: usize, slot_duration_us: u32) -> Self {
let nodes: Vec<TdmNode> = (0..num_nodes)
.map(|i| TdmNode {
node_id: i as u8,
slot_index: i as u8,
channels: vec![i as u8],
})
.collect();
let frame_duration_us = slot_duration_us * num_nodes as u32;
Self {
nodes,
slot_duration_us,
frame_duration_us,
sync_method: SyncMethod::LeaderFollower,
}
}
/// Returns the slot index that is active at `current_time_us` for the
/// given node, or `None` if the node is not registered.
pub fn get_slot(&self, node_id: u8, current_time_us: u64) -> Option<u32> {
let node = self.nodes.iter().find(|n| n.node_id == node_id)?;
let position_in_frame = (current_time_us % self.frame_duration_us as u64) as u32;
let current_slot = position_in_frame / self.slot_duration_us;
if current_slot == node.slot_index as u32 {
Some(current_slot)
} else {
None
}
}
/// Returns `true` if the current time falls within the node's assigned
/// slot.
pub fn is_my_slot(&self, node_id: u8, current_time_us: u64) -> bool {
self.get_slot(node_id, current_time_us).is_some()
}
/// Add a node with a specific slot assignment.
pub fn add_node(&mut self, node: TdmNode) {
self.nodes.push(node);
self.frame_duration_us = self.slot_duration_us * self.nodes.len() as u32;
}
/// Returns the number of registered nodes.
pub fn num_nodes(&self) -> usize {
self.nodes.len()
}
/// Returns the time in microseconds until the given node's next slot
/// begins.
pub fn time_until_slot(&self, node_id: u8, current_time_us: u64) -> Option<u64> {
let node = self.nodes.iter().find(|n| n.node_id == node_id)?;
let position_in_frame = (current_time_us % self.frame_duration_us as u64) as u32;
let slot_start = node.slot_index as u32 * self.slot_duration_us;
if position_in_frame < slot_start {
Some((slot_start - position_in_frame) as u64)
} else if position_in_frame < slot_start + self.slot_duration_us {
Some(0) // Already in slot
} else {
// Next frame
Some((self.frame_duration_us - position_in_frame + slot_start) as u64)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tdm_scheduler_slot_assignment() {
let sched = TdmScheduler::new(4, 1000);
assert_eq!(sched.frame_duration_us, 4000);
// Node 0 should be active at t=0..999
assert!(sched.is_my_slot(0, 0));
assert!(sched.is_my_slot(0, 500));
assert!(!sched.is_my_slot(0, 1000));
// Node 1 should be active at t=1000..1999
assert!(sched.is_my_slot(1, 1000));
assert!(sched.is_my_slot(1, 1500));
assert!(!sched.is_my_slot(1, 2000));
// Node 3 active at t=3000..3999
assert!(sched.is_my_slot(3, 3000));
assert!(!sched.is_my_slot(3, 0));
}
#[test]
fn test_tdm_frame_wraps() {
let sched = TdmScheduler::new(2, 500);
// Frame = 1000 us, so t=1000 wraps to position 0
assert!(sched.is_my_slot(0, 1000));
assert!(sched.is_my_slot(1, 1500));
assert!(sched.is_my_slot(0, 2000));
}
#[test]
fn test_get_slot_returns_none_for_unknown_node() {
let sched = TdmScheduler::new(2, 1000);
assert!(sched.get_slot(99, 0).is_none());
}
#[test]
fn test_time_until_slot() {
let sched = TdmScheduler::new(4, 1000);
// Node 2's slot starts at 2000. At t=500 that's 1500 us away.
assert_eq!(sched.time_until_slot(2, 500), Some(1500));
// At t=2500 we're in the slot
assert_eq!(sched.time_until_slot(2, 2500), Some(0));
// At t=3500 the slot ended — next one is at 2000 in the next frame (t=6000)
// position_in_frame = 3500, slot_start = 2000, frame = 4000
// next = 4000 - 3500 + 2000 = 2500
assert_eq!(sched.time_until_slot(2, 3500), Some(2500));
}
#[test]
fn test_add_node_updates_frame() {
let mut sched = TdmScheduler::new(2, 1000);
assert_eq!(sched.frame_duration_us, 2000);
sched.add_node(TdmNode {
node_id: 5,
slot_index: 2,
channels: vec![0, 1],
});
assert_eq!(sched.frame_duration_us, 3000);
assert_eq!(sched.num_nodes(), 3);
}
}
@@ -1,21 +0,0 @@
[package]
name = "ruv-neural-graph"
description = "rUv Neural — Brain connectivity graph construction from neural signals"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
ruv-neural-core = { workspace = true }
ruv-neural-signal = { workspace = true }
petgraph = { workspace = true }
ndarray = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
num-traits = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
rand = { workspace = true }
@@ -1,83 +0,0 @@
# ruv-neural-graph
Brain connectivity graph construction from neural signals with graph-theoretic
analysis and spectral properties.
## Overview
`ruv-neural-graph` builds brain connectivity graphs from multi-channel neural
time series data and connectivity matrices. It provides graph-theoretic metrics
(efficiency, clustering, centrality), spectral graph properties (Laplacian,
Fiedler value), brain atlas definitions, petgraph interoperability, and temporal
dynamics tracking for brain topology research.
## Features
- **Graph construction** (`constructor`): Build `BrainGraph` instances from
connectivity matrices and multi-channel time series data via `BrainGraphConstructor`
- **Brain atlases** (`atlas`): Built-in Desikan-Killiany 68-region atlas with
support for loading custom atlas definitions
- **Graph metrics** (`metrics`): Global efficiency, local efficiency, clustering
coefficient, betweenness centrality, degree distribution, modularity,
graph density, small-world index
- **Spectral analysis** (`spectral`): Graph Laplacian, normalized Laplacian,
Fiedler value (algebraic connectivity), spectral gap
- **Petgraph bridge** (`petgraph_bridge`): Bidirectional conversion between
`BrainGraph` and petgraph `Graph` types
- **Temporal dynamics** (`dynamics`): `TopologyTracker` for monitoring graph
property evolution over time
## Usage
```rust
use ruv_neural_graph::{
BrainGraphConstructor, load_atlas, AtlasType,
global_efficiency, clustering_coefficient, modularity,
fiedler_value, graph_laplacian,
to_petgraph, from_petgraph,
TopologyTracker,
};
// Construct a brain graph from a connectivity matrix
let constructor = BrainGraphConstructor::new();
let graph = constructor.from_matrix(&connectivity_matrix, 0.3, atlas)?;
// Compute graph-theoretic metrics
let efficiency = global_efficiency(&graph);
let clustering = clustering_coefficient(&graph);
let mod_score = modularity(&graph);
// Spectral properties
let laplacian = graph_laplacian(&graph);
let fiedler = fiedler_value(&graph);
// Convert to petgraph for additional algorithms
let pg = to_petgraph(&graph);
let brain_graph = from_petgraph(&pg);
// Track topology over time
let mut tracker = TopologyTracker::new();
tracker.update(&graph);
```
## API Reference
| Module | Key Types / Functions |
|-------------------|-------------------------------------------------------------------|
| `constructor` | `BrainGraphConstructor` |
| `atlas` | `load_atlas`, `AtlasType` |
| `metrics` | `global_efficiency`, `local_efficiency`, `clustering_coefficient`, `betweenness_centrality`, `modularity`, `small_world_index` |
| `spectral` | `graph_laplacian`, `normalized_laplacian`, `fiedler_value`, `spectral_gap` |
| `petgraph_bridge` | `to_petgraph`, `from_petgraph` |
| `dynamics` | `TopologyTracker` |
## Integration
Depends on `ruv-neural-core` for `BrainGraph` and atlas types, and on
`ruv-neural-signal` for connectivity computation. Feeds graphs into
`ruv-neural-mincut` for topology partitioning and into `ruv-neural-viz`
for visualization. Uses `petgraph` for underlying graph data structures.
## License
MIT OR Apache-2.0

Some files were not shown because too many files have changed in this diff Show More