mirror of
https://github.com/ruvnet/RuView
synced 2026-06-12 10:43:19 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07b6bf8084 |
@@ -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
|
||||
|
||||
Submodule
+1
Submodule v2/crates/ruv-neural added at 1ece3afa33
@@ -1,2 +0,0 @@
|
||||
/target/
|
||||
Cargo.lock
|
||||
@@ -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
|
||||
@@ -1,421 +0,0 @@
|
||||
# rUv Neural — Brain Topology Analysis System
|
||||
|
||||
> Quantum sensor integration x RuVector graph memory x Dynamic mincut coherence detection
|
||||
|
||||
[](https://crates.io/crates/ruv-neural-core)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
|
||||
---
|
||||
|
||||
## 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) | [](https://crates.io/crates/ruv-neural-core) | Core types, traits, errors, RVF format | None |
|
||||
| [`ruv-neural-sensor`](https://crates.io/crates/ruv-neural-sensor) | [](https://crates.io/crates/ruv-neural-sensor) | NV diamond, OPM, EEG sensor interfaces | core |
|
||||
| [`ruv-neural-signal`](https://crates.io/crates/ruv-neural-signal) | [](https://crates.io/crates/ruv-neural-signal) | DSP: filtering, spectral, connectivity | core |
|
||||
| [`ruv-neural-graph`](https://crates.io/crates/ruv-neural-graph) | [](https://crates.io/crates/ruv-neural-graph) | Brain connectivity graph construction | core, signal |
|
||||
| [`ruv-neural-mincut`](https://crates.io/crates/ruv-neural-mincut) | [](https://crates.io/crates/ruv-neural-mincut) | Dynamic minimum cut topology analysis | core |
|
||||
| [`ruv-neural-embed`](https://crates.io/crates/ruv-neural-embed) | [](https://crates.io/crates/ruv-neural-embed) | RuVector graph embeddings | core |
|
||||
| [`ruv-neural-memory`](https://crates.io/crates/ruv-neural-memory) | [](https://crates.io/crates/ruv-neural-memory) | Persistent neural state memory + HNSW | core |
|
||||
| [`ruv-neural-decoder`](https://crates.io/crates/ruv-neural-decoder) | [](https://crates.io/crates/ruv-neural-decoder) | Cognitive state classification + BCI | core |
|
||||
| [`ruv-neural-esp32`](https://crates.io/crates/ruv-neural-esp32) | [](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) | [](https://crates.io/crates/ruv-neural-viz) | Visualization and ASCII rendering | core, graph, mincut |
|
||||
| [`ruv-neural-cli`](https://crates.io/crates/ruv-neural-cli) | [](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
|
||||
@@ -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, °rees, &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(®ion).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(¤t_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(¤t);
|
||||
let epi = scorer.epilepsy_risk(¤t);
|
||||
let dep = scorer.depression_risk(¤t);
|
||||
|
||||
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, ¢er) 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(¤t.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, ¬ch_50);
|
||||
}
|
||||
if self.notch_60hz {
|
||||
data = self.apply_iir_float(&data, ¬ch_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
|
||||
@@ -1,299 +0,0 @@
|
||||
//! Brain atlas definitions with built-in parcellations.
|
||||
//!
|
||||
//! Provides the Desikan-Killiany 68-region atlas with anatomical metadata
|
||||
//! including lobe classification, hemisphere, and MNI centroid coordinates.
|
||||
|
||||
use ruv_neural_core::brain::{Atlas, BrainRegion, Hemisphere, Lobe, Parcellation};
|
||||
|
||||
/// Supported atlas types for factory loading.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AtlasType {
|
||||
/// Desikan-Killiany atlas with 68 cortical regions.
|
||||
DesikanKilliany,
|
||||
}
|
||||
|
||||
/// Load a parcellation for the given atlas type.
|
||||
pub fn load_atlas(atlas_type: AtlasType) -> Parcellation {
|
||||
match atlas_type {
|
||||
AtlasType::DesikanKilliany => build_desikan_killiany(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Region definition used during atlas construction.
|
||||
struct RegionDef {
|
||||
name: &'static str,
|
||||
lobe: Lobe,
|
||||
/// MNI centroid for the left hemisphere version.
|
||||
mni_left: [f64; 3],
|
||||
}
|
||||
|
||||
/// Build the full Desikan-Killiany 68-region parcellation.
|
||||
///
|
||||
/// 34 regions per hemisphere. For each region, the left hemisphere uses the
|
||||
/// original MNI centroid and the right hemisphere mirrors the x-coordinate.
|
||||
fn build_desikan_killiany() -> Parcellation {
|
||||
let region_defs = desikan_killiany_regions();
|
||||
let mut regions = Vec::with_capacity(68);
|
||||
let mut id = 0;
|
||||
|
||||
// Left hemisphere (indices 0..34)
|
||||
for def in ®ion_defs {
|
||||
regions.push(BrainRegion {
|
||||
id,
|
||||
name: format!("lh_{}", def.name),
|
||||
hemisphere: Hemisphere::Left,
|
||||
lobe: def.lobe,
|
||||
centroid: def.mni_left,
|
||||
});
|
||||
id += 1;
|
||||
}
|
||||
|
||||
// Right hemisphere (indices 34..68) — mirror x-coordinate
|
||||
for def in ®ion_defs {
|
||||
regions.push(BrainRegion {
|
||||
id,
|
||||
name: format!("rh_{}", def.name),
|
||||
hemisphere: Hemisphere::Right,
|
||||
lobe: def.lobe,
|
||||
centroid: [-def.mni_left[0], def.mni_left[1], def.mni_left[2]],
|
||||
});
|
||||
id += 1;
|
||||
}
|
||||
|
||||
Parcellation {
|
||||
atlas: Atlas::DesikanKilliany68,
|
||||
regions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the 34 unique region definitions for the Desikan-Killiany atlas.
|
||||
///
|
||||
/// MNI coordinates are approximate centroids from the FreeSurfer DK atlas.
|
||||
fn desikan_killiany_regions() -> Vec<RegionDef> {
|
||||
vec![
|
||||
// Frontal lobe
|
||||
RegionDef {
|
||||
name: "superiorfrontal",
|
||||
lobe: Lobe::Frontal,
|
||||
mni_left: [-12.0, 30.0, 48.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "caudalmiddlefrontal",
|
||||
lobe: Lobe::Frontal,
|
||||
mni_left: [-37.0, 10.0, 48.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "rostralmiddlefrontal",
|
||||
lobe: Lobe::Frontal,
|
||||
mni_left: [-35.0, 38.0, 22.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "parsopercularis",
|
||||
lobe: Lobe::Frontal,
|
||||
mni_left: [-48.0, 14.0, 18.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "parstriangularis",
|
||||
lobe: Lobe::Frontal,
|
||||
mni_left: [-46.0, 28.0, 8.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "parsorbitalis",
|
||||
lobe: Lobe::Frontal,
|
||||
mni_left: [-42.0, 36.0, -10.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "lateralorbitofrontal",
|
||||
lobe: Lobe::Frontal,
|
||||
mni_left: [-28.0, 36.0, -14.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "medialorbitofrontal",
|
||||
lobe: Lobe::Frontal,
|
||||
mni_left: [-7.0, 44.0, -14.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "precentral",
|
||||
lobe: Lobe::Frontal,
|
||||
mni_left: [-38.0, -8.0, 52.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "paracentral",
|
||||
lobe: Lobe::Frontal,
|
||||
mni_left: [-8.0, -28.0, 62.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "frontalpole",
|
||||
lobe: Lobe::Frontal,
|
||||
mni_left: [-8.0, 64.0, -4.0],
|
||||
},
|
||||
// Parietal lobe
|
||||
RegionDef {
|
||||
name: "postcentral",
|
||||
lobe: Lobe::Parietal,
|
||||
mni_left: [-42.0, -28.0, 54.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "superiorparietal",
|
||||
lobe: Lobe::Parietal,
|
||||
mni_left: [-24.0, -56.0, 58.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "inferiorparietal",
|
||||
lobe: Lobe::Parietal,
|
||||
mni_left: [-44.0, -54.0, 38.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "supramarginal",
|
||||
lobe: Lobe::Parietal,
|
||||
mni_left: [-52.0, -34.0, 34.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "precuneus",
|
||||
lobe: Lobe::Parietal,
|
||||
mni_left: [-8.0, -58.0, 42.0],
|
||||
},
|
||||
// Temporal lobe
|
||||
RegionDef {
|
||||
name: "superiortemporal",
|
||||
lobe: Lobe::Temporal,
|
||||
mni_left: [-52.0, -12.0, -4.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "middletemporal",
|
||||
lobe: Lobe::Temporal,
|
||||
mni_left: [-56.0, -28.0, -8.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "inferiortemporal",
|
||||
lobe: Lobe::Temporal,
|
||||
mni_left: [-50.0, -36.0, -18.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "bankssts",
|
||||
lobe: Lobe::Temporal,
|
||||
mni_left: [-52.0, -42.0, 8.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "fusiform",
|
||||
lobe: Lobe::Temporal,
|
||||
mni_left: [-36.0, -42.0, -20.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "transversetemporal",
|
||||
lobe: Lobe::Temporal,
|
||||
mni_left: [-44.0, -22.0, 10.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "entorhinal",
|
||||
lobe: Lobe::Temporal,
|
||||
mni_left: [-24.0, -8.0, -34.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "temporalpole",
|
||||
lobe: Lobe::Temporal,
|
||||
mni_left: [-36.0, 12.0, -34.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "parahippocampal",
|
||||
lobe: Lobe::Temporal,
|
||||
mni_left: [-22.0, -28.0, -18.0],
|
||||
},
|
||||
// Occipital lobe
|
||||
RegionDef {
|
||||
name: "lateraloccipital",
|
||||
lobe: Lobe::Occipital,
|
||||
mni_left: [-34.0, -80.0, 8.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "lingual",
|
||||
lobe: Lobe::Occipital,
|
||||
mni_left: [-12.0, -72.0, -4.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "cuneus",
|
||||
lobe: Lobe::Occipital,
|
||||
mni_left: [-8.0, -82.0, 22.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "pericalcarine",
|
||||
lobe: Lobe::Occipital,
|
||||
mni_left: [-10.0, -82.0, 6.0],
|
||||
},
|
||||
// Limbic (cingulate + insula)
|
||||
RegionDef {
|
||||
name: "posteriorcingulate",
|
||||
lobe: Lobe::Limbic,
|
||||
mni_left: [-6.0, -30.0, 32.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "isthmuscingulate",
|
||||
lobe: Lobe::Limbic,
|
||||
mni_left: [-8.0, -44.0, 24.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "caudalanteriorcingulate",
|
||||
lobe: Lobe::Limbic,
|
||||
mni_left: [-6.0, 8.0, 34.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "rostralanteriorcingulate",
|
||||
lobe: Lobe::Limbic,
|
||||
mni_left: [-6.0, 30.0, 14.0],
|
||||
},
|
||||
RegionDef {
|
||||
name: "insula",
|
||||
lobe: Lobe::Limbic,
|
||||
mni_left: [-34.0, 4.0, 2.0],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Hemisphere;
|
||||
|
||||
#[test]
|
||||
fn dk68_has_exactly_68_regions() {
|
||||
let parcellation = load_atlas(AtlasType::DesikanKilliany);
|
||||
assert_eq!(parcellation.num_regions(), 68);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dk68_has_34_per_hemisphere() {
|
||||
let parcellation = load_atlas(AtlasType::DesikanKilliany);
|
||||
let left = parcellation.regions_in_hemisphere(Hemisphere::Left);
|
||||
let right = parcellation.regions_in_hemisphere(Hemisphere::Right);
|
||||
assert_eq!(left.len(), 34);
|
||||
assert_eq!(right.len(), 34);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dk68_right_hemisphere_mirrors_x() {
|
||||
let parcellation = load_atlas(AtlasType::DesikanKilliany);
|
||||
// Region 0 (lh) and region 34 (rh) should have mirrored x.
|
||||
let lh = &parcellation.regions[0];
|
||||
let rh = &parcellation.regions[34];
|
||||
assert_eq!(lh.centroid[0], -rh.centroid[0]);
|
||||
assert_eq!(lh.centroid[1], rh.centroid[1]);
|
||||
assert_eq!(lh.centroid[2], rh.centroid[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dk68_region_names_prefixed() {
|
||||
let parcellation = load_atlas(AtlasType::DesikanKilliany);
|
||||
assert!(parcellation.regions[0].name.starts_with("lh_"));
|
||||
assert!(parcellation.regions[34].name.starts_with("rh_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dk68_unique_ids() {
|
||||
let parcellation = load_atlas(AtlasType::DesikanKilliany);
|
||||
let ids: Vec<usize> = parcellation.regions.iter().map(|r| r.id).collect();
|
||||
let mut sorted = ids.clone();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), 68);
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
//! Graph construction from connectivity matrices and multi-channel time series.
|
||||
//!
|
||||
//! The [`BrainGraphConstructor`] converts pairwise connectivity values into
|
||||
//! [`BrainGraph`] instances, with optional thresholding to remove weak edges.
|
||||
//! It also supports sliding-window construction from raw time series via the
|
||||
//! signal crate's connectivity metrics.
|
||||
|
||||
use ruv_neural_core::brain::Parcellation;
|
||||
use ruv_neural_core::error::{Result, RuvNeuralError};
|
||||
use ruv_neural_core::graph::{BrainEdge, BrainGraph, BrainGraphSequence, ConnectivityMetric};
|
||||
use ruv_neural_core::signal::{FrequencyBand, MultiChannelTimeSeries};
|
||||
use ruv_neural_core::traits::GraphConstructor;
|
||||
|
||||
use crate::atlas::{AtlasType, load_atlas};
|
||||
|
||||
/// Constructs brain connectivity graphs from matrices or time series data.
|
||||
pub struct BrainGraphConstructor {
|
||||
parcellation: Parcellation,
|
||||
metric: ConnectivityMetric,
|
||||
band: FrequencyBand,
|
||||
/// Edge weight threshold: edges below this value are dropped.
|
||||
threshold: f64,
|
||||
/// Sliding window duration in seconds.
|
||||
window_duration_s: f64,
|
||||
/// Sliding window step in seconds.
|
||||
window_step_s: f64,
|
||||
}
|
||||
|
||||
impl BrainGraphConstructor {
|
||||
/// Create a new constructor with default window parameters.
|
||||
pub fn new(atlas: AtlasType, metric: ConnectivityMetric, band: FrequencyBand) -> Self {
|
||||
Self {
|
||||
parcellation: load_atlas(atlas),
|
||||
metric,
|
||||
band,
|
||||
threshold: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
window_step_s: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the edge weight threshold. Edges with weight below this are excluded.
|
||||
pub fn with_threshold(mut self, threshold: f64) -> Self {
|
||||
self.threshold = threshold;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the sliding window duration in seconds.
|
||||
pub fn with_window_duration(mut self, duration_s: f64) -> Self {
|
||||
self.window_duration_s = duration_s;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the sliding window step in seconds.
|
||||
pub fn with_window_step(mut self, step_s: f64) -> Self {
|
||||
self.window_step_s = step_s;
|
||||
self
|
||||
}
|
||||
|
||||
/// Construct a brain graph from a pre-computed connectivity matrix.
|
||||
///
|
||||
/// The matrix should be `n x n` where `n` matches the number of atlas regions.
|
||||
/// The matrix is treated as symmetric; only the upper triangle is read.
|
||||
pub fn construct_from_matrix(
|
||||
&self,
|
||||
connectivity: &[Vec<f64>],
|
||||
timestamp: f64,
|
||||
) -> BrainGraph {
|
||||
let n = self.parcellation.num_regions();
|
||||
let mut edges = Vec::new();
|
||||
|
||||
for i in 0..n.min(connectivity.len()) {
|
||||
for j in (i + 1)..n.min(connectivity[i].len()) {
|
||||
let weight = connectivity[i][j];
|
||||
if weight.abs() > self.threshold {
|
||||
edges.push(BrainEdge {
|
||||
source: i,
|
||||
target: j,
|
||||
weight,
|
||||
metric: self.metric,
|
||||
frequency_band: self.band,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BrainGraph {
|
||||
num_nodes: n,
|
||||
edges,
|
||||
timestamp,
|
||||
window_duration_s: self.window_duration_s,
|
||||
atlas: self.parcellation.atlas,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a sequence of brain graphs from multi-channel time series
|
||||
/// using a sliding window approach.
|
||||
///
|
||||
/// For each window, computes pairwise Pearson correlation as connectivity,
|
||||
/// then builds a graph with thresholding applied.
|
||||
pub fn construct_sequence(
|
||||
&self,
|
||||
data: &MultiChannelTimeSeries,
|
||||
) -> BrainGraphSequence {
|
||||
let n_samples = data.num_samples;
|
||||
let sr = data.sample_rate_hz;
|
||||
|
||||
let window_samples = (self.window_duration_s * sr) as usize;
|
||||
let step_samples = (self.window_step_s * sr) as usize;
|
||||
|
||||
if window_samples == 0 || step_samples == 0 || n_samples < window_samples {
|
||||
return BrainGraphSequence {
|
||||
graphs: Vec::new(),
|
||||
window_step_s: self.window_step_s,
|
||||
};
|
||||
}
|
||||
|
||||
let mut graphs = Vec::new();
|
||||
let mut offset = 0;
|
||||
|
||||
while offset + window_samples <= n_samples {
|
||||
let timestamp = data.timestamp_start + offset as f64 / sr;
|
||||
|
||||
// Extract windowed data for each channel
|
||||
let windowed: Vec<&[f64]> = data
|
||||
.data
|
||||
.iter()
|
||||
.map(|ch| &ch[offset..offset + window_samples])
|
||||
.collect();
|
||||
|
||||
// Compute pairwise Pearson correlation matrix
|
||||
let connectivity = compute_correlation_matrix(&windowed);
|
||||
|
||||
let graph = self.construct_from_matrix(&connectivity, timestamp);
|
||||
graphs.push(graph);
|
||||
|
||||
offset += step_samples;
|
||||
}
|
||||
|
||||
BrainGraphSequence {
|
||||
graphs,
|
||||
window_step_s: self.window_step_s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GraphConstructor for BrainGraphConstructor {
|
||||
fn construct(&self, signals: &MultiChannelTimeSeries) -> Result<BrainGraph> {
|
||||
let n_channels = signals.num_channels;
|
||||
let expected = self.parcellation.num_regions();
|
||||
if n_channels != expected {
|
||||
return Err(RuvNeuralError::DimensionMismatch {
|
||||
expected,
|
||||
got: n_channels,
|
||||
});
|
||||
}
|
||||
|
||||
let windowed: Vec<&[f64]> = signals.data.iter().map(|ch| ch.as_slice()).collect();
|
||||
let connectivity = compute_correlation_matrix(&windowed);
|
||||
Ok(self.construct_from_matrix(&connectivity, signals.timestamp_start))
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute pairwise Pearson correlation matrix for a set of channels.
|
||||
fn compute_correlation_matrix(channels: &[&[f64]]) -> Vec<Vec<f64>> {
|
||||
let n = channels.len();
|
||||
let mut matrix = vec![vec![0.0; n]; n];
|
||||
|
||||
// Pre-compute means and standard deviations
|
||||
let stats: Vec<(f64, f64)> = channels
|
||||
.iter()
|
||||
.map(|ch| {
|
||||
let len = ch.len() as f64;
|
||||
if len == 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let mean = ch.iter().sum::<f64>() / len;
|
||||
let var = ch.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / len;
|
||||
(mean, var.sqrt())
|
||||
})
|
||||
.collect();
|
||||
|
||||
for i in 0..n {
|
||||
matrix[i][i] = 1.0;
|
||||
for j in (i + 1)..n {
|
||||
let (mean_i, std_i) = stats[i];
|
||||
let (mean_j, std_j) = stats[j];
|
||||
|
||||
if std_i == 0.0 || std_j == 0.0 {
|
||||
matrix[i][j] = 0.0;
|
||||
matrix[j][i] = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
let len = channels[i].len().min(channels[j].len());
|
||||
let cov: f64 = channels[i][..len]
|
||||
.iter()
|
||||
.zip(channels[j][..len].iter())
|
||||
.map(|(a, b)| (a - mean_i) * (b - mean_j))
|
||||
.sum::<f64>()
|
||||
/ len as f64;
|
||||
|
||||
let r = cov / (std_i * std_j);
|
||||
matrix[i][j] = r;
|
||||
matrix[j][i] = r;
|
||||
}
|
||||
}
|
||||
|
||||
matrix
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::graph::ConnectivityMetric;
|
||||
use ruv_neural_core::signal::FrequencyBand;
|
||||
|
||||
fn make_constructor() -> BrainGraphConstructor {
|
||||
BrainGraphConstructor::new(
|
||||
AtlasType::DesikanKilliany,
|
||||
ConnectivityMetric::PhaseLockingValue,
|
||||
FrequencyBand::Alpha,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_matrix_fully_disconnected() {
|
||||
let ctor = make_constructor().with_threshold(0.01);
|
||||
let n = 68;
|
||||
// Identity matrix: diagonal = 1, off-diagonal = 0
|
||||
let identity: Vec<Vec<f64>> = (0..n)
|
||||
.map(|i| {
|
||||
let mut row = vec![0.0; n];
|
||||
row[i] = 1.0;
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
|
||||
let graph = ctor.construct_from_matrix(&identity, 0.0);
|
||||
assert_eq!(graph.num_nodes, 68);
|
||||
assert_eq!(graph.edges.len(), 0, "Identity matrix should produce no edges");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ones_matrix_fully_connected() {
|
||||
let ctor = make_constructor().with_threshold(0.01);
|
||||
let n = 68;
|
||||
let ones: Vec<Vec<f64>> = vec![vec![1.0; n]; n];
|
||||
|
||||
let graph = ctor.construct_from_matrix(&ones, 0.0);
|
||||
let expected_edges = n * (n - 1) / 2;
|
||||
assert_eq!(graph.edges.len(), expected_edges);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_filters_weak_edges() {
|
||||
let ctor = make_constructor().with_threshold(0.5);
|
||||
let n = 68;
|
||||
let mut matrix = vec![vec![0.0; n]; n];
|
||||
// Set a few strong edges
|
||||
matrix[0][1] = 0.8;
|
||||
matrix[1][0] = 0.8;
|
||||
// Set a weak edge
|
||||
matrix[2][3] = 0.3;
|
||||
matrix[3][2] = 0.3;
|
||||
|
||||
let graph = ctor.construct_from_matrix(&matrix, 0.0);
|
||||
assert_eq!(graph.edges.len(), 1, "Only edge above threshold should survive");
|
||||
assert_eq!(graph.edges[0].source, 0);
|
||||
assert_eq!(graph.edges[0].target, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construct_sequence_produces_graphs() {
|
||||
let ctor = BrainGraphConstructor::new(
|
||||
AtlasType::DesikanKilliany,
|
||||
ConnectivityMetric::PhaseLockingValue,
|
||||
FrequencyBand::Alpha,
|
||||
)
|
||||
.with_window_duration(0.5)
|
||||
.with_window_step(0.25);
|
||||
|
||||
// 68 channels, 256 samples at 256 Hz = 1 second of data
|
||||
let n_ch = 68;
|
||||
let n_samples = 256;
|
||||
let data: Vec<Vec<f64>> = (0..n_ch)
|
||||
.map(|i| {
|
||||
(0..n_samples)
|
||||
.map(|j| ((j as f64 + i as f64) * 0.1).sin())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let ts = MultiChannelTimeSeries::new(data, 256.0, 0.0).unwrap();
|
||||
let seq = ctor.construct_sequence(&ts);
|
||||
|
||||
// 1.0s data, 0.5s window, 0.25s step => 3 windows: [0,0.5], [0.25,0.75], [0.5,1.0]
|
||||
assert!(seq.len() >= 2, "Should produce at least 2 graphs, got {}", seq.len());
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
//! Temporal graph dynamics: tracking topology metrics over time.
|
||||
//!
|
||||
//! The [`TopologyTracker`] accumulates brain graphs and computes time series
|
||||
//! of graph-theoretic metrics to detect state transitions and measure
|
||||
//! the rate of topological change.
|
||||
|
||||
use ruv_neural_core::graph::BrainGraph;
|
||||
|
||||
use crate::metrics::{clustering_coefficient, global_efficiency};
|
||||
use crate::spectral::fiedler_value;
|
||||
|
||||
/// A timestamped snapshot of graph topology metrics.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TopologySnapshot {
|
||||
/// Timestamp of the graph.
|
||||
pub timestamp: f64,
|
||||
/// Global efficiency.
|
||||
pub global_efficiency: f64,
|
||||
/// Clustering coefficient.
|
||||
pub clustering: f64,
|
||||
/// Fiedler value (algebraic connectivity).
|
||||
pub fiedler: f64,
|
||||
/// Graph density.
|
||||
pub density: f64,
|
||||
/// Total edge weight (proxy for minimum cut in dense graphs).
|
||||
pub total_weight: f64,
|
||||
}
|
||||
|
||||
/// Tracks graph topology metrics over time and detects transitions.
|
||||
pub struct TopologyTracker {
|
||||
/// History of topology snapshots.
|
||||
history: Vec<TopologySnapshot>,
|
||||
}
|
||||
|
||||
impl TopologyTracker {
|
||||
/// Create an empty tracker.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
history: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Track a new brain graph, computing and storing its topology metrics.
|
||||
pub fn track(&mut self, graph: &BrainGraph) {
|
||||
let snapshot = TopologySnapshot {
|
||||
timestamp: graph.timestamp,
|
||||
global_efficiency: global_efficiency(graph),
|
||||
clustering: clustering_coefficient(graph),
|
||||
fiedler: fiedler_value(graph),
|
||||
density: graph.density(),
|
||||
total_weight: graph.total_weight(),
|
||||
};
|
||||
self.history.push(snapshot);
|
||||
}
|
||||
|
||||
/// Number of tracked time points.
|
||||
pub fn len(&self) -> usize {
|
||||
self.history.len()
|
||||
}
|
||||
|
||||
/// Returns true if no graphs have been tracked.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.history.is_empty()
|
||||
}
|
||||
|
||||
/// Get the full history of snapshots.
|
||||
pub fn snapshots(&self) -> &[TopologySnapshot] {
|
||||
&self.history
|
||||
}
|
||||
|
||||
/// Return a time series of (timestamp, total_weight) as a proxy for minimum cut.
|
||||
///
|
||||
/// The total weight correlates with overall connectivity strength.
|
||||
pub fn mincut_timeseries(&self) -> Vec<(f64, f64)> {
|
||||
self.history
|
||||
.iter()
|
||||
.map(|s| (s.timestamp, s.total_weight))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return a time series of (timestamp, fiedler_value).
|
||||
///
|
||||
/// The Fiedler value tracks algebraic connectivity over time.
|
||||
pub fn fiedler_timeseries(&self) -> Vec<(f64, f64)> {
|
||||
self.history
|
||||
.iter()
|
||||
.map(|s| (s.timestamp, s.fiedler))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return a time series of (timestamp, global_efficiency).
|
||||
pub fn efficiency_timeseries(&self) -> Vec<(f64, f64)> {
|
||||
self.history
|
||||
.iter()
|
||||
.map(|s| (s.timestamp, s.global_efficiency))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return a time series of (timestamp, clustering_coefficient).
|
||||
pub fn clustering_timeseries(&self) -> Vec<(f64, f64)> {
|
||||
self.history
|
||||
.iter()
|
||||
.map(|s| (s.timestamp, s.clustering))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Detect timestamps where significant topology changes occur.
|
||||
///
|
||||
/// A transition is detected when the absolute change in global efficiency
|
||||
/// between consecutive snapshots exceeds the given threshold.
|
||||
pub fn detect_transitions(&self, threshold: f64) -> Vec<f64> {
|
||||
if self.history.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut transitions = Vec::new();
|
||||
for i in 1..self.history.len() {
|
||||
let delta = (self.history[i].global_efficiency
|
||||
- self.history[i - 1].global_efficiency)
|
||||
.abs();
|
||||
if delta > threshold {
|
||||
transitions.push(self.history[i].timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
transitions
|
||||
}
|
||||
|
||||
/// Compute the rate of change of global efficiency over time.
|
||||
///
|
||||
/// Returns (timestamp, d_efficiency/dt) for each consecutive pair.
|
||||
pub fn rate_of_change(&self) -> Vec<(f64, f64)> {
|
||||
if self.history.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.history
|
||||
.windows(2)
|
||||
.map(|pair| {
|
||||
let dt = pair[1].timestamp - pair[0].timestamp;
|
||||
let de = pair[1].global_efficiency - pair[0].global_efficiency;
|
||||
let rate = if dt.abs() > 1e-15 { de / dt } else { 0.0 };
|
||||
(pair[1].timestamp, rate)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TopologyTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::graph::{BrainEdge, BrainGraph, ConnectivityMetric};
|
||||
use ruv_neural_core::signal::FrequencyBand;
|
||||
|
||||
fn make_edge(s: usize, t: usize, w: f64) -> BrainEdge {
|
||||
BrainEdge {
|
||||
source: s,
|
||||
target: t,
|
||||
weight: w,
|
||||
metric: ConnectivityMetric::PhaseLockingValue,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_graph(timestamp: f64, edges: Vec<BrainEdge>) -> BrainGraph {
|
||||
BrainGraph {
|
||||
num_nodes: 4,
|
||||
edges,
|
||||
timestamp,
|
||||
window_duration_s: 0.5,
|
||||
atlas: Atlas::Custom(4),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_stores_history() {
|
||||
let mut tracker = TopologyTracker::new();
|
||||
assert!(tracker.is_empty());
|
||||
|
||||
let g1 = make_graph(0.0, vec![make_edge(0, 1, 1.0), make_edge(2, 3, 1.0)]);
|
||||
let g2 = make_graph(1.0, vec![
|
||||
make_edge(0, 1, 1.0),
|
||||
make_edge(1, 2, 1.0),
|
||||
make_edge(2, 3, 1.0),
|
||||
]);
|
||||
|
||||
tracker.track(&g1);
|
||||
tracker.track(&g2);
|
||||
|
||||
assert_eq!(tracker.len(), 2);
|
||||
assert!(!tracker.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mincut_timeseries_correct_length() {
|
||||
let mut tracker = TopologyTracker::new();
|
||||
for i in 0..5 {
|
||||
let g = make_graph(
|
||||
i as f64,
|
||||
vec![make_edge(0, 1, 1.0), make_edge(2, 3, i as f64 * 0.5)],
|
||||
);
|
||||
tracker.track(&g);
|
||||
}
|
||||
|
||||
let ts = tracker.mincut_timeseries();
|
||||
assert_eq!(ts.len(), 5);
|
||||
assert_eq!(ts[0].0, 0.0);
|
||||
assert_eq!(ts[4].0, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_transitions_returns_correct_timestamps() {
|
||||
let mut tracker = TopologyTracker::new();
|
||||
|
||||
// Stable phase: few edges
|
||||
for i in 0..3 {
|
||||
let g = make_graph(
|
||||
i as f64,
|
||||
vec![make_edge(0, 1, 0.5)],
|
||||
);
|
||||
tracker.track(&g);
|
||||
}
|
||||
|
||||
// Sudden change: fully connected
|
||||
let g = make_graph(3.0, vec![
|
||||
make_edge(0, 1, 1.0),
|
||||
make_edge(0, 2, 1.0),
|
||||
make_edge(0, 3, 1.0),
|
||||
make_edge(1, 2, 1.0),
|
||||
make_edge(1, 3, 1.0),
|
||||
make_edge(2, 3, 1.0),
|
||||
]);
|
||||
tracker.track(&g);
|
||||
|
||||
// With a small threshold, we should detect the transition at t=3.0
|
||||
let transitions = tracker.detect_transitions(0.01);
|
||||
assert!(
|
||||
transitions.contains(&3.0),
|
||||
"Should detect transition at t=3.0, got {:?}",
|
||||
transitions
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_of_change_correct_length() {
|
||||
let mut tracker = TopologyTracker::new();
|
||||
for i in 0..4 {
|
||||
let g = make_graph(i as f64, vec![make_edge(0, 1, 1.0)]);
|
||||
tracker.track(&g);
|
||||
}
|
||||
|
||||
let roc = tracker.rate_of_change();
|
||||
assert_eq!(roc.len(), 3); // n-1 rates for n points
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//! rUv Neural Graph -- Brain connectivity graph construction from neural signals.
|
||||
//!
|
||||
//! This crate builds brain connectivity graphs from multi-channel neural time series
|
||||
//! data, provides graph-theoretic metrics, spectral analysis, and temporal dynamics
|
||||
//! tracking for brain topology research.
|
||||
//!
|
||||
//! # Modules
|
||||
//!
|
||||
//! - [`atlas`] -- Brain atlas definitions (Desikan-Killiany 68 regions)
|
||||
//! - [`constructor`] -- Graph construction from connectivity matrices and time series
|
||||
//! - [`petgraph_bridge`] -- Convert between `BrainGraph` and petgraph types
|
||||
//! - [`metrics`] -- Graph-theoretic metrics (efficiency, clustering, centrality)
|
||||
//! - [`spectral`] -- Spectral graph properties (Laplacian, Fiedler value)
|
||||
//! - [`dynamics`] -- Temporal graph dynamics and topology tracking
|
||||
|
||||
pub mod atlas;
|
||||
pub mod constructor;
|
||||
pub mod dynamics;
|
||||
pub mod metrics;
|
||||
pub mod petgraph_bridge;
|
||||
pub mod spectral;
|
||||
|
||||
pub use atlas::{load_atlas, AtlasType};
|
||||
pub use constructor::BrainGraphConstructor;
|
||||
pub use dynamics::TopologyTracker;
|
||||
pub use metrics::{
|
||||
betweenness_centrality, clustering_coefficient, degree_distribution, global_efficiency,
|
||||
graph_density, local_efficiency, modularity, node_degree, small_world_index,
|
||||
};
|
||||
pub use petgraph_bridge::{from_petgraph, to_petgraph};
|
||||
pub use spectral::{fiedler_value, graph_laplacian, normalized_laplacian, spectral_gap};
|
||||
@@ -1,517 +0,0 @@
|
||||
//! Graph-theoretic metrics for brain connectivity analysis.
|
||||
//!
|
||||
//! Provides standard network neuroscience metrics: efficiency, clustering,
|
||||
//! centrality, modularity, and small-world properties.
|
||||
|
||||
use ruv_neural_core::graph::BrainGraph;
|
||||
|
||||
|
||||
/// Compute global efficiency of a brain graph.
|
||||
///
|
||||
/// Global efficiency is the average inverse shortest path length between all
|
||||
/// pairs of nodes. For disconnected pairs, the contribution is 0.
|
||||
///
|
||||
/// E_global = (1 / N(N-1)) * sum_{i != j} 1/d(i,j)
|
||||
pub fn global_efficiency(graph: &BrainGraph) -> f64 {
|
||||
let n = graph.num_nodes;
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let dist = all_pairs_shortest_paths(graph);
|
||||
let mut sum = 0.0;
|
||||
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if i != j && dist[i][j] < f64::INFINITY {
|
||||
sum += 1.0 / dist[i][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sum / (n * (n - 1)) as f64
|
||||
}
|
||||
|
||||
/// Compute local efficiency of a brain graph.
|
||||
///
|
||||
/// Average of each node's subgraph efficiency (efficiency among its neighbors).
|
||||
pub fn local_efficiency(graph: &BrainGraph) -> f64 {
|
||||
let n = graph.num_nodes;
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let adj = graph.adjacency_matrix();
|
||||
let mut total = 0.0;
|
||||
|
||||
for i in 0..n {
|
||||
let neighbors: Vec<usize> = (0..n)
|
||||
.filter(|&j| j != i && adj[i][j] > 0.0)
|
||||
.collect();
|
||||
|
||||
let k = neighbors.len();
|
||||
if k < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build subgraph of neighbors and compute its efficiency
|
||||
let mut sub_sum = 0.0;
|
||||
for &ni in &neighbors {
|
||||
for &nj in &neighbors {
|
||||
if ni != nj && adj[ni][nj] > 0.0 {
|
||||
// Use direct weight as inverse distance proxy
|
||||
sub_sum += adj[ni][nj];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total += sub_sum / (k * (k - 1)) as f64;
|
||||
}
|
||||
|
||||
total / n as f64
|
||||
}
|
||||
|
||||
/// Compute global clustering coefficient.
|
||||
///
|
||||
/// C = (3 * number_of_triangles) / number_of_connected_triples
|
||||
/// For weighted graphs, uses the geometric mean of edge weights in triangles.
|
||||
pub fn clustering_coefficient(graph: &BrainGraph) -> f64 {
|
||||
let n = graph.num_nodes;
|
||||
if n < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let adj = graph.adjacency_matrix();
|
||||
let mut triangles = 0.0;
|
||||
let mut triples = 0.0;
|
||||
|
||||
for i in 0..n {
|
||||
let neighbors_i: Vec<usize> = (0..n)
|
||||
.filter(|&j| j != i && adj[i][j] > 0.0)
|
||||
.collect();
|
||||
let k = neighbors_i.len();
|
||||
if k < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
triples += (k * (k - 1)) as f64 / 2.0;
|
||||
|
||||
for a in 0..neighbors_i.len() {
|
||||
for b in (a + 1)..neighbors_i.len() {
|
||||
let ni = neighbors_i[a];
|
||||
let nj = neighbors_i[b];
|
||||
if adj[ni][nj] > 0.0 {
|
||||
// Weighted triangle: geometric mean of the three edges
|
||||
let w = (adj[i][ni] * adj[i][nj] * adj[ni][nj]).cbrt();
|
||||
triangles += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if triples == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
triangles / triples
|
||||
}
|
||||
|
||||
/// Weighted degree of a single node.
|
||||
pub fn node_degree(graph: &BrainGraph, node: usize) -> f64 {
|
||||
graph.node_degree(node)
|
||||
}
|
||||
|
||||
/// Degree distribution: weighted degree for every node.
|
||||
pub fn degree_distribution(graph: &BrainGraph) -> Vec<f64> {
|
||||
(0..graph.num_nodes)
|
||||
.map(|i| graph.node_degree(i))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Betweenness centrality for each node.
|
||||
///
|
||||
/// Computes the fraction of shortest paths passing through each node.
|
||||
/// Uses Brandes' algorithm adapted for weighted graphs.
|
||||
pub fn betweenness_centrality(graph: &BrainGraph) -> Vec<f64> {
|
||||
let n = graph.num_nodes;
|
||||
let mut centrality = vec![0.0; n];
|
||||
|
||||
if n < 3 {
|
||||
return centrality;
|
||||
}
|
||||
|
||||
let adj = graph.adjacency_matrix();
|
||||
|
||||
// For each source node, run Dijkstra and accumulate betweenness
|
||||
for s in 0..n {
|
||||
let mut dist = vec![f64::INFINITY; n];
|
||||
let mut sigma = vec![0.0_f64; n]; // number of shortest paths
|
||||
let mut delta = vec![0.0_f64; n];
|
||||
let mut pred: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
let mut visited = vec![false; n];
|
||||
let mut order = Vec::with_capacity(n);
|
||||
|
||||
dist[s] = 0.0;
|
||||
sigma[s] = 1.0;
|
||||
|
||||
// Simple Dijkstra (priority queue not needed for correctness)
|
||||
for _ in 0..n {
|
||||
// Find unvisited node with minimum distance
|
||||
let mut u = None;
|
||||
let mut min_dist = f64::INFINITY;
|
||||
for v in 0..n {
|
||||
if !visited[v] && dist[v] < min_dist {
|
||||
min_dist = dist[v];
|
||||
u = Some(v);
|
||||
}
|
||||
}
|
||||
|
||||
let u = match u {
|
||||
Some(u) => u,
|
||||
None => break,
|
||||
};
|
||||
|
||||
visited[u] = true;
|
||||
order.push(u);
|
||||
|
||||
for v in 0..n {
|
||||
if adj[u][v] <= 0.0 || u == v {
|
||||
continue;
|
||||
}
|
||||
// Convert weight to distance (stronger connection = shorter distance)
|
||||
let edge_dist = 1.0 / adj[u][v];
|
||||
let new_dist = dist[u] + edge_dist;
|
||||
|
||||
if new_dist < dist[v] - 1e-12 {
|
||||
dist[v] = new_dist;
|
||||
sigma[v] = sigma[u];
|
||||
pred[v] = vec![u];
|
||||
} else if (new_dist - dist[v]).abs() < 1e-12 {
|
||||
sigma[v] += sigma[u];
|
||||
pred[v].push(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back-propagation of dependencies
|
||||
for &w in order.iter().rev() {
|
||||
for &v in &pred[w] {
|
||||
if sigma[w] > 0.0 {
|
||||
delta[v] += (sigma[v] / sigma[w]) * (1.0 + delta[w]);
|
||||
}
|
||||
}
|
||||
if w != s {
|
||||
centrality[w] += delta[w];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize for undirected graph
|
||||
let norm = if n > 2 {
|
||||
2.0 / ((n - 1) * (n - 2)) as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for c in &mut centrality {
|
||||
*c *= norm;
|
||||
}
|
||||
|
||||
centrality
|
||||
}
|
||||
|
||||
/// Graph density: fraction of possible edges that exist.
|
||||
pub fn graph_density(graph: &BrainGraph) -> f64 {
|
||||
graph.density()
|
||||
}
|
||||
|
||||
/// Small-world index sigma = (C/C_rand) / (L/L_rand).
|
||||
///
|
||||
/// Uses lattice-equivalent approximations:
|
||||
/// - C_rand ~ k / N (for Erdos-Renyi)
|
||||
/// - L_rand ~ ln(N) / ln(k) (for Erdos-Renyi)
|
||||
///
|
||||
/// where k is the mean degree and N is the number of nodes.
|
||||
pub fn small_world_index(graph: &BrainGraph) -> f64 {
|
||||
let n = graph.num_nodes as f64;
|
||||
if n < 4.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let c = clustering_coefficient(graph);
|
||||
let eff = global_efficiency(graph);
|
||||
|
||||
// Mean binary degree
|
||||
let adj = graph.adjacency_matrix();
|
||||
let total_edges: f64 = adj
|
||||
.iter()
|
||||
.flat_map(|row| row.iter())
|
||||
.filter(|&&w| w > 0.0)
|
||||
.count() as f64
|
||||
/ 2.0;
|
||||
let k = 2.0 * total_edges / n;
|
||||
|
||||
if k < 1.0 || c <= 0.0 || eff <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Random graph approximations
|
||||
let c_rand = k / n;
|
||||
let l_rand = n.ln() / k.ln();
|
||||
let l = if eff > 0.0 { 1.0 / eff } else { f64::INFINITY };
|
||||
|
||||
if c_rand <= 0.0 || l_rand <= 0.0 || l.is_infinite() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
(c / c_rand) / (l / l_rand)
|
||||
}
|
||||
|
||||
/// Newman modularity Q for a given partition.
|
||||
///
|
||||
/// Q = (1/2m) * sum_{ij} [A_ij - k_i*k_j/(2m)] * delta(c_i, c_j)
|
||||
///
|
||||
/// where m is total edge weight, k_i is weighted degree of node i,
|
||||
/// and delta(c_i, c_j) = 1 if nodes i and j are in the same community.
|
||||
pub fn modularity(graph: &BrainGraph, partition: &[Vec<usize>]) -> f64 {
|
||||
let adj = graph.adjacency_matrix();
|
||||
let n = graph.num_nodes;
|
||||
|
||||
// Build community assignment map
|
||||
let mut community = vec![0usize; n];
|
||||
for (c, members) in partition.iter().enumerate() {
|
||||
for &node in members {
|
||||
if node < n {
|
||||
community[node] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Total edge weight (each edge counted once in adjacency, so sum / 2)
|
||||
let m: f64 = adj.iter().flat_map(|row| row.iter()).sum::<f64>() / 2.0;
|
||||
if m == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Weighted degree
|
||||
let degrees: Vec<f64> = (0..n)
|
||||
.map(|i| adj[i].iter().sum::<f64>())
|
||||
.collect();
|
||||
|
||||
let mut q = 0.0;
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if community[i] == community[j] {
|
||||
q += adj[i][j] - degrees[i] * degrees[j] / (2.0 * m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
q / (2.0 * m)
|
||||
}
|
||||
|
||||
/// Compute all-pairs shortest path distances using Floyd-Warshall.
|
||||
///
|
||||
/// Edge weights are converted to distances as 1/weight (stronger = closer).
|
||||
fn all_pairs_shortest_paths(graph: &BrainGraph) -> Vec<Vec<f64>> {
|
||||
let n = graph.num_nodes;
|
||||
let adj = graph.adjacency_matrix();
|
||||
|
||||
let mut dist = vec![vec![f64::INFINITY; n]; n];
|
||||
|
||||
for i in 0..n {
|
||||
dist[i][i] = 0.0;
|
||||
for j in 0..n {
|
||||
if i != j && adj[i][j] > 0.0 {
|
||||
dist[i][j] = 1.0 / adj[i][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floyd-Warshall
|
||||
for k in 0..n {
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let through_k = dist[i][k] + dist[k][j];
|
||||
if through_k < dist[i][j] {
|
||||
dist[i][j] = through_k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dist
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::graph::{BrainEdge, BrainGraph, ConnectivityMetric};
|
||||
use ruv_neural_core::signal::FrequencyBand;
|
||||
|
||||
/// Build a complete graph with n nodes, all edges weight 1.0.
|
||||
fn 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::PhaseLockingValue,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
});
|
||||
}
|
||||
}
|
||||
BrainGraph {
|
||||
num_nodes: n,
|
||||
edges,
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a path graph: 0-1-2-..-(n-1).
|
||||
fn path_graph(n: usize) -> BrainGraph {
|
||||
let edges: Vec<BrainEdge> = (0..n.saturating_sub(1))
|
||||
.map(|i| BrainEdge {
|
||||
source: i,
|
||||
target: i + 1,
|
||||
weight: 1.0,
|
||||
metric: ConnectivityMetric::PhaseLockingValue,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
})
|
||||
.collect();
|
||||
BrainGraph {
|
||||
num_nodes: n,
|
||||
edges,
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(n),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_efficiency_complete_graph() {
|
||||
// In a complete graph with weight 1, all shortest paths have length 1,
|
||||
// so efficiency = 1.0.
|
||||
let g = complete_graph(10);
|
||||
let eff = global_efficiency(&g);
|
||||
assert!((eff - 1.0).abs() < 1e-10, "Expected ~1.0, got {}", eff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_efficiency_empty_graph() {
|
||||
let g = BrainGraph {
|
||||
num_nodes: 5,
|
||||
edges: Vec::new(),
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(5),
|
||||
};
|
||||
let eff = global_efficiency(&g);
|
||||
assert_eq!(eff, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clustering_coefficient_complete_graph() {
|
||||
let g = complete_graph(8);
|
||||
let cc = clustering_coefficient(&g);
|
||||
assert!(cc > 0.9, "Complete graph should have clustering ~1.0, got {}", cc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clustering_coefficient_path_graph() {
|
||||
// A path graph has no triangles, so clustering = 0.
|
||||
let g = path_graph(5);
|
||||
let cc = clustering_coefficient(&g);
|
||||
assert!(cc.abs() < 1e-10, "Path graph should have CC=0, got {}", cc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn density_complete_graph() {
|
||||
let g = complete_graph(10);
|
||||
let d = graph_density(&g);
|
||||
assert!((d - 1.0).abs() < 1e-10, "Complete graph density should be 1.0, got {}", d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degree_distribution_uniform() {
|
||||
let g = complete_graph(5);
|
||||
let dd = degree_distribution(&g);
|
||||
// Each node in K5 has degree 4 (4 edges * weight 1.0 = 4.0)
|
||||
for &d in &dd {
|
||||
assert!((d - 4.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn betweenness_centrality_path() {
|
||||
// In a path 0-1-2-3-4, middle nodes should have higher betweenness.
|
||||
let g = path_graph(5);
|
||||
let bc = betweenness_centrality(&g);
|
||||
// Node 2 (center) should have highest betweenness
|
||||
assert!(bc[2] >= bc[0], "Center node should have >= betweenness than endpoints");
|
||||
assert!(bc[2] >= bc[4], "Center node should have >= betweenness than endpoints");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modularity_single_community() {
|
||||
let g = complete_graph(6);
|
||||
let all_in_one = vec![vec![0, 1, 2, 3, 4, 5]];
|
||||
let q = modularity(&g, &all_in_one);
|
||||
// All in one community, modularity should be 0
|
||||
assert!(q.abs() < 1e-10, "Single community Q should be ~0, got {}", q);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modularity_good_partition() {
|
||||
// Two cliques connected by a weak edge
|
||||
let mut edges = Vec::new();
|
||||
// Clique 1: nodes 0,1,2
|
||||
for i in 0..3 {
|
||||
for j in (i + 1)..3 {
|
||||
edges.push(BrainEdge {
|
||||
source: i,
|
||||
target: j,
|
||||
weight: 1.0,
|
||||
metric: ConnectivityMetric::PhaseLockingValue,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Clique 2: nodes 3,4,5
|
||||
for i in 3..6 {
|
||||
for j in (i + 1)..6 {
|
||||
edges.push(BrainEdge {
|
||||
source: i,
|
||||
target: j,
|
||||
weight: 1.0,
|
||||
metric: ConnectivityMetric::PhaseLockingValue,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Weak bridge
|
||||
edges.push(BrainEdge {
|
||||
source: 2,
|
||||
target: 3,
|
||||
weight: 0.1,
|
||||
metric: ConnectivityMetric::PhaseLockingValue,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
});
|
||||
|
||||
let g = BrainGraph {
|
||||
num_nodes: 6,
|
||||
edges,
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(6),
|
||||
};
|
||||
|
||||
let good = vec![vec![0, 1, 2], vec![3, 4, 5]];
|
||||
let q = modularity(&g, &good);
|
||||
assert!(q > 0.0, "Good partition should have positive modularity, got {}", q);
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
//! Petgraph bridge: convert between BrainGraph and petgraph types.
|
||||
//!
|
||||
//! This module enables using petgraph's extensive algorithm library
|
||||
//! (shortest paths, connected components, etc.) on brain connectivity graphs.
|
||||
|
||||
use petgraph::graph::{Graph, NodeIndex, UnGraph};
|
||||
use petgraph::visit::EdgeRef;
|
||||
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::graph::{BrainEdge, BrainGraph, ConnectivityMetric};
|
||||
use ruv_neural_core::signal::FrequencyBand;
|
||||
|
||||
/// Convert a BrainGraph to a petgraph undirected graph.
|
||||
///
|
||||
/// Node weights are the node indices (usize). Edge weights are f64 connectivity values.
|
||||
/// All nodes are created even if they have no edges.
|
||||
pub fn to_petgraph(graph: &BrainGraph) -> UnGraph<usize, f64> {
|
||||
let mut pg = Graph::new_undirected();
|
||||
let mut node_indices: Vec<NodeIndex> = Vec::with_capacity(graph.num_nodes);
|
||||
|
||||
for i in 0..graph.num_nodes {
|
||||
node_indices.push(pg.add_node(i));
|
||||
}
|
||||
|
||||
for edge in &graph.edges {
|
||||
if edge.source < graph.num_nodes && edge.target < graph.num_nodes {
|
||||
pg.add_edge(
|
||||
node_indices[edge.source],
|
||||
node_indices[edge.target],
|
||||
edge.weight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pg
|
||||
}
|
||||
|
||||
/// Convert a petgraph undirected graph back to a BrainGraph.
|
||||
///
|
||||
/// Node weights in the petgraph are assumed to be node indices.
|
||||
/// Requires the atlas and timestamp to be provided since petgraph does not store them.
|
||||
pub fn from_petgraph(
|
||||
pg: &UnGraph<usize, f64>,
|
||||
atlas: Atlas,
|
||||
timestamp: f64,
|
||||
) -> BrainGraph {
|
||||
let num_nodes = pg.node_count();
|
||||
let mut edges = Vec::with_capacity(pg.edge_count());
|
||||
|
||||
for edge_ref in pg.edge_references() {
|
||||
let source = pg[edge_ref.source()];
|
||||
let target = pg[edge_ref.target()];
|
||||
let weight = *edge_ref.weight();
|
||||
|
||||
edges.push(BrainEdge {
|
||||
source,
|
||||
target,
|
||||
weight,
|
||||
metric: ConnectivityMetric::PhaseLockingValue,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
});
|
||||
}
|
||||
|
||||
BrainGraph {
|
||||
num_nodes,
|
||||
edges,
|
||||
timestamp,
|
||||
window_duration_s: 0.0,
|
||||
atlas,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: get a petgraph NodeIndex for a given brain region index.
|
||||
///
|
||||
/// The petgraph nodes are added in order 0..num_nodes, so the NodeIndex
|
||||
/// for region `i` is simply `NodeIndex::new(i)`.
|
||||
pub fn node_index(region_id: usize) -> NodeIndex {
|
||||
NodeIndex::new(region_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::graph::{BrainEdge, BrainGraph, ConnectivityMetric};
|
||||
use ruv_neural_core::signal::FrequencyBand;
|
||||
|
||||
fn sample_graph() -> BrainGraph {
|
||||
BrainGraph {
|
||||
num_nodes: 4,
|
||||
edges: vec![
|
||||
BrainEdge {
|
||||
source: 0,
|
||||
target: 1,
|
||||
weight: 0.9,
|
||||
metric: ConnectivityMetric::PhaseLockingValue,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
},
|
||||
BrainEdge {
|
||||
source: 1,
|
||||
target: 2,
|
||||
weight: 0.7,
|
||||
metric: ConnectivityMetric::PhaseLockingValue,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
},
|
||||
BrainEdge {
|
||||
source: 2,
|
||||
target: 3,
|
||||
weight: 0.5,
|
||||
metric: ConnectivityMetric::PhaseLockingValue,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
},
|
||||
],
|
||||
timestamp: 1.0,
|
||||
window_duration_s: 0.5,
|
||||
atlas: Atlas::Custom(4),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_structure() {
|
||||
let original = sample_graph();
|
||||
let pg = to_petgraph(&original);
|
||||
let restored = from_petgraph(&pg, Atlas::Custom(4), 1.0);
|
||||
|
||||
assert_eq!(restored.num_nodes, original.num_nodes);
|
||||
assert_eq!(restored.edges.len(), original.edges.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn petgraph_has_correct_node_count() {
|
||||
let graph = sample_graph();
|
||||
let pg = to_petgraph(&graph);
|
||||
assert_eq!(pg.node_count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn petgraph_has_correct_edge_count() {
|
||||
let graph = sample_graph();
|
||||
let pg = to_petgraph(&graph);
|
||||
assert_eq!(pg.edge_count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_graph_round_trip() {
|
||||
let empty = BrainGraph {
|
||||
num_nodes: 10,
|
||||
edges: Vec::new(),
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(10),
|
||||
};
|
||||
let pg = to_petgraph(&empty);
|
||||
assert_eq!(pg.node_count(), 10);
|
||||
assert_eq!(pg.edge_count(), 0);
|
||||
|
||||
let restored = from_petgraph(&pg, Atlas::Custom(10), 0.0);
|
||||
assert_eq!(restored.num_nodes, 10);
|
||||
assert_eq!(restored.edges.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
//! Spectral graph properties: Laplacian matrices, Fiedler value, spectral gap.
|
||||
//!
|
||||
//! The graph Laplacian encodes the structure of a graph and its eigenvalues
|
||||
//! reveal fundamental connectivity properties. The Fiedler value (second
|
||||
//! smallest eigenvalue) measures algebraic connectivity.
|
||||
|
||||
use ruv_neural_core::graph::BrainGraph;
|
||||
|
||||
/// Compute the combinatorial graph Laplacian L = D - A.
|
||||
///
|
||||
/// D is the diagonal degree matrix, A is the adjacency matrix.
|
||||
/// Returns an `n x n` matrix as `Vec<Vec<f64>>`.
|
||||
pub fn graph_laplacian(graph: &BrainGraph) -> Vec<Vec<f64>> {
|
||||
let n = graph.num_nodes;
|
||||
let adj = graph.adjacency_matrix();
|
||||
let mut laplacian = vec![vec![0.0; n]; n];
|
||||
|
||||
for i in 0..n {
|
||||
let degree: f64 = adj[i].iter().sum();
|
||||
laplacian[i][i] = degree;
|
||||
for j in 0..n {
|
||||
if i != j {
|
||||
laplacian[i][j] = -adj[i][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
laplacian
|
||||
}
|
||||
|
||||
/// Compute the normalized graph Laplacian L_norm = D^{-1/2} L D^{-1/2}.
|
||||
///
|
||||
/// For isolated nodes (degree = 0), the diagonal entry is set to 0.
|
||||
pub fn normalized_laplacian(graph: &BrainGraph) -> Vec<Vec<f64>> {
|
||||
let n = graph.num_nodes;
|
||||
let adj = graph.adjacency_matrix();
|
||||
|
||||
// Compute D^{-1/2}
|
||||
let degrees: Vec<f64> = (0..n).map(|i| adj[i].iter().sum::<f64>()).collect();
|
||||
let d_inv_sqrt: Vec<f64> = degrees
|
||||
.iter()
|
||||
.map(|&d| if d > 0.0 { 1.0 / d.sqrt() } else { 0.0 })
|
||||
.collect();
|
||||
|
||||
let mut l_norm = vec![vec![0.0; n]; n];
|
||||
|
||||
for i in 0..n {
|
||||
if degrees[i] > 0.0 {
|
||||
l_norm[i][i] = 1.0;
|
||||
}
|
||||
for j in 0..n {
|
||||
if i != j && adj[i][j] > 0.0 {
|
||||
l_norm[i][j] = -adj[i][j] * d_inv_sqrt[i] * d_inv_sqrt[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
l_norm
|
||||
}
|
||||
|
||||
/// Compute the Fiedler value (algebraic connectivity).
|
||||
///
|
||||
/// The Fiedler value is the second smallest eigenvalue of the graph Laplacian.
|
||||
/// - For a connected graph, Fiedler value > 0.
|
||||
/// - For a disconnected graph, Fiedler value = 0.
|
||||
///
|
||||
/// Uses power iteration with deflation to find the two smallest eigenvalues
|
||||
/// of the Laplacian (which is positive semidefinite).
|
||||
pub fn fiedler_value(graph: &BrainGraph) -> f64 {
|
||||
let n = graph.num_nodes;
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let laplacian = graph_laplacian(graph);
|
||||
|
||||
// The Laplacian is PSD. Its smallest eigenvalue is 0 with eigenvector
|
||||
// proportional to the all-ones vector. We need the second smallest.
|
||||
//
|
||||
// Strategy: use inverse power iteration on (L + alpha*I) shifted to find
|
||||
// the smallest eigenvalue, then deflate and find the next.
|
||||
// Alternatively, use the shifted inverse iteration directly for lambda_2.
|
||||
//
|
||||
// Simpler approach: compute L * x repeatedly to find eigenvalues from largest
|
||||
// down, or use the fact that lambda_2 = min over x perp to 1 of x^T L x / x^T x.
|
||||
//
|
||||
// We use inverse iteration with shift to find the Fiedler vector.
|
||||
// But since we don't have a linear solver, we use power iteration on
|
||||
// (max_eig * I - L) to find the largest eigenvalue of that matrix (which
|
||||
// corresponds to the smallest eigenvalue of L).
|
||||
//
|
||||
// Actually, the simplest reliable approach for moderate n:
|
||||
// Use the Rayleigh quotient iteration projected orthogonal to the all-ones vector.
|
||||
|
||||
compute_fiedler_rayleigh(&laplacian, n)
|
||||
}
|
||||
|
||||
/// Compute the spectral gap: lambda_2 - lambda_1.
|
||||
///
|
||||
/// Since lambda_1 = 0 for the Laplacian, the spectral gap equals the Fiedler value.
|
||||
pub fn spectral_gap(graph: &BrainGraph) -> f64 {
|
||||
fiedler_value(graph)
|
||||
}
|
||||
|
||||
/// Compute the Fiedler value using projected power iteration.
|
||||
///
|
||||
/// Projects out the all-ones eigenvector (corresponding to lambda_1 = 0),
|
||||
/// then uses power iteration on (alpha*I - L) to find the largest eigenvalue
|
||||
/// of that shifted matrix. The Fiedler value is then alpha - largest_eigenvalue.
|
||||
fn compute_fiedler_rayleigh(laplacian: &[Vec<f64>], n: usize) -> f64 {
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Estimate max eigenvalue for shifting (Gershgorin bound)
|
||||
let alpha = laplacian
|
||||
.iter()
|
||||
.map(|row| row.iter().map(|x| x.abs()).sum::<f64>())
|
||||
.fold(0.0_f64, |a, b| a.max(b))
|
||||
* 1.1;
|
||||
|
||||
if alpha <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Construct M = alpha*I - L
|
||||
// The eigenvalues of M are alpha - lambda_i(L).
|
||||
// The largest eigenvalue of M corresponds to the smallest eigenvalue of L (which is 0).
|
||||
// The second largest eigenvalue of M corresponds to lambda_2 of L.
|
||||
// We need to deflate out the first eigenvector (all-ones) and do power iteration.
|
||||
|
||||
// Normalized all-ones vector
|
||||
let inv_sqrt_n = 1.0 / (n as f64).sqrt();
|
||||
|
||||
// Initialize random-ish vector orthogonal to all-ones
|
||||
let mut v: Vec<f64> = (0..n).map(|i| (i as f64 + 0.5).sin()).collect();
|
||||
|
||||
// Project out the all-ones component
|
||||
project_out_ones(&mut v, inv_sqrt_n, n);
|
||||
normalize(&mut v);
|
||||
|
||||
let max_iter = 1000;
|
||||
let tol = 1e-10;
|
||||
|
||||
for _ in 0..max_iter {
|
||||
// w = M * v = (alpha*I - L) * v
|
||||
let mut w = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
w[i] = alpha * v[i];
|
||||
for j in 0..n {
|
||||
w[i] -= laplacian[i][j] * v[j];
|
||||
}
|
||||
}
|
||||
|
||||
// Project out the all-ones component
|
||||
project_out_ones(&mut w, inv_sqrt_n, n);
|
||||
|
||||
let norm_w = norm(&w);
|
||||
if norm_w < 1e-15 {
|
||||
// The vector collapsed, Fiedler value is likely alpha
|
||||
return alpha;
|
||||
}
|
||||
|
||||
// Rayleigh quotient: eigenvalue of M = v^T * w / v^T * v
|
||||
let eigenvalue_m: f64 = v.iter().zip(w.iter()).map(|(a, b)| a * b).sum::<f64>();
|
||||
|
||||
// Normalize
|
||||
for x in &mut w {
|
||||
*x /= norm_w;
|
||||
}
|
||||
|
||||
// Check convergence
|
||||
let diff: f64 = v
|
||||
.iter()
|
||||
.zip(w.iter())
|
||||
.map(|(a, b)| (a - b).powi(2))
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
|
||||
v = w;
|
||||
|
||||
if diff < tol {
|
||||
// Fiedler value = alpha - eigenvalue_of_M
|
||||
let fiedler = alpha - eigenvalue_m;
|
||||
return fiedler.max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Final estimate
|
||||
let mut w = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
w[i] = alpha * v[i];
|
||||
for j in 0..n {
|
||||
w[i] -= laplacian[i][j] * v[j];
|
||||
}
|
||||
}
|
||||
project_out_ones(&mut w, inv_sqrt_n, n);
|
||||
|
||||
let eigenvalue_m: f64 = v.iter().zip(w.iter()).map(|(a, b)| a * b).sum::<f64>();
|
||||
(alpha - eigenvalue_m).max(0.0)
|
||||
}
|
||||
|
||||
/// Project vector v orthogonal to the all-ones vector.
|
||||
fn project_out_ones(v: &mut [f64], inv_sqrt_n: f64, _n: usize) {
|
||||
let dot: f64 = v.iter().sum::<f64>() * inv_sqrt_n;
|
||||
for x in v.iter_mut() {
|
||||
*x -= dot * inv_sqrt_n;
|
||||
}
|
||||
}
|
||||
|
||||
/// L2 norm of a vector.
|
||||
fn norm(v: &[f64]) -> f64 {
|
||||
v.iter().map(|x| x * x).sum::<f64>().sqrt()
|
||||
}
|
||||
|
||||
/// Normalize a vector in-place.
|
||||
fn normalize(v: &mut [f64]) {
|
||||
let n = norm(v);
|
||||
if n > 0.0 {
|
||||
for x in v.iter_mut() {
|
||||
*x /= n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::graph::{BrainEdge, BrainGraph, ConnectivityMetric};
|
||||
use ruv_neural_core::signal::FrequencyBand;
|
||||
|
||||
fn make_edge(s: usize, t: usize, w: f64) -> BrainEdge {
|
||||
BrainEdge {
|
||||
source: s,
|
||||
target: t,
|
||||
weight: w,
|
||||
metric: ConnectivityMetric::PhaseLockingValue,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_graph(n: usize) -> BrainGraph {
|
||||
let mut edges = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
edges.push(make_edge(i, j, 1.0));
|
||||
}
|
||||
}
|
||||
BrainGraph {
|
||||
num_nodes: n,
|
||||
edges,
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(n),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laplacian_row_sums_zero() {
|
||||
let g = complete_graph(5);
|
||||
let l = graph_laplacian(&g);
|
||||
for row in &l {
|
||||
let sum: f64 = row.iter().sum();
|
||||
assert!(sum.abs() < 1e-10, "Row sum should be 0, got {}", sum);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laplacian_diagonal_is_degree() {
|
||||
let g = complete_graph(5);
|
||||
let l = graph_laplacian(&g);
|
||||
// Each node in K5 has degree 4
|
||||
for i in 0..5 {
|
||||
assert!((l[i][i] - 4.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_laplacian_diagonal_connected() {
|
||||
let g = complete_graph(5);
|
||||
let ln = normalized_laplacian(&g);
|
||||
// For connected nodes, diagonal should be 1.0
|
||||
for i in 0..5 {
|
||||
assert!((ln[i][i] - 1.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fiedler_value_connected_graph() {
|
||||
let g = complete_graph(6);
|
||||
let f = fiedler_value(&g);
|
||||
// For K_n, all non-zero eigenvalues of L are n. So fiedler = n = 6.
|
||||
assert!(f > 0.0, "Connected graph should have fiedler > 0, got {}", f);
|
||||
assert!((f - 6.0).abs() < 0.5, "K6 fiedler should be ~6.0, got {}", f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fiedler_value_disconnected_graph() {
|
||||
// Two isolated components: nodes 0,1 connected; nodes 2,3 connected; no bridge.
|
||||
let g = BrainGraph {
|
||||
num_nodes: 4,
|
||||
edges: vec![make_edge(0, 1, 1.0), make_edge(2, 3, 1.0)],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(4),
|
||||
};
|
||||
let f = fiedler_value(&g);
|
||||
assert!(f < 1e-6, "Disconnected graph should have fiedler ~0, got {}", f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spectral_gap_equals_fiedler() {
|
||||
let g = complete_graph(5);
|
||||
assert_eq!(spectral_gap(&g), fiedler_value(&g));
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
[package]
|
||||
name = "ruv-neural-memory"
|
||||
description = "rUv Neural — Persistent neural state memory with vector search and longitudinal tracking"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = []
|
||||
wasm = []
|
||||
|
||||
[dependencies]
|
||||
ruv-neural-core = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
approx = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
criterion = { workspace = true }
|
||||
|
||||
[[bench]]
|
||||
name = "benchmarks"
|
||||
harness = false
|
||||
@@ -1,96 +0,0 @@
|
||||
# ruv-neural-memory
|
||||
|
||||
Persistent neural state memory with vector search and longitudinal tracking.
|
||||
|
||||
## Overview
|
||||
|
||||
`ruv-neural-memory` provides in-memory and persistent storage for neural
|
||||
embeddings, supporting brute-force and HNSW-based approximate nearest neighbor
|
||||
search. It includes session-based memory management for organizing recordings
|
||||
by subject and session, longitudinal drift detection for tracking embedding
|
||||
distribution changes over time, and RVF/bincode persistence for durable storage.
|
||||
|
||||
## Features
|
||||
|
||||
- **Embedding store** (`store`): `NeuralMemoryStore` for inserting, querying,
|
||||
and managing collections of `NeuralEmbedding` values with brute-force
|
||||
nearest neighbor search
|
||||
- **HNSW index** (`hnsw`): `HnswIndex` for approximate nearest neighbor search
|
||||
with configurable M (max connections), ef_construction, and ef_search parameters;
|
||||
provides 150x-12,500x speedup over brute-force for large collections
|
||||
- **Session management** (`session`): `SessionMemory` and `SessionMetadata` for
|
||||
organizing embeddings by recording session, subject ID, and timestamp ranges
|
||||
- **Longitudinal tracking** (`longitudinal`): `LongitudinalTracker` for detecting
|
||||
embedding distribution drift over time with `TrendDirection` classification
|
||||
(stable, increasing, decreasing)
|
||||
- **Persistence** (`persistence`): `save_store` / `load_store` for bincode
|
||||
serialization, `save_rvf` / `load_rvf` for RuVector format I/O
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use ruv_neural_memory::{
|
||||
NeuralMemoryStore, HnswIndex, SessionMemory, SessionMetadata,
|
||||
LongitudinalTracker, save_store, load_store,
|
||||
};
|
||||
use ruv_neural_core::{NeuralEmbedding, EmbeddingMetadata, Atlas};
|
||||
|
||||
// Create a memory store and insert embeddings
|
||||
let mut store = NeuralMemoryStore::new();
|
||||
let meta = EmbeddingMetadata {
|
||||
subject_id: Some("sub-01".into()),
|
||||
session_id: Some("ses-01".into()),
|
||||
cognitive_state: None,
|
||||
source_atlas: Atlas::Schaefer100,
|
||||
embedding_method: "spectral".into(),
|
||||
};
|
||||
let emb = NeuralEmbedding::new(vec![0.1, 0.5, -0.3], 0.0, meta).unwrap();
|
||||
store.insert(emb);
|
||||
|
||||
// Query nearest neighbors (brute-force)
|
||||
let query = vec![0.1, 0.4, -0.2];
|
||||
let neighbors = store.query_nearest(&query, 5);
|
||||
|
||||
// Build HNSW index for fast approximate search
|
||||
let mut hnsw = HnswIndex::new(16, 200);
|
||||
// ... insert vectors, then search
|
||||
|
||||
// Session-based memory management
|
||||
let session = SessionMemory::new(SessionMetadata {
|
||||
subject_id: "sub-01".into(),
|
||||
session_id: "ses-01".into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Persistence
|
||||
save_store(&store, "memory.bin").unwrap();
|
||||
let loaded = load_store("memory.bin").unwrap();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Module | Key Types / Functions |
|
||||
|-----------------|-------------------------------------------------------------|
|
||||
| `store` | `NeuralMemoryStore` |
|
||||
| `hnsw` | `HnswIndex` |
|
||||
| `session` | `SessionMemory`, `SessionMetadata` |
|
||||
| `longitudinal` | `LongitudinalTracker`, `TrendDirection` |
|
||||
| `persistence` | `save_store`, `load_store`, `save_rvf`, `load_rvf` |
|
||||
|
||||
## Feature Flags
|
||||
|
||||
| Feature | Default | Description |
|
||||
|---------|---------|------------------------------|
|
||||
| `std` | Yes | Standard library support |
|
||||
| `wasm` | No | WASM-compatible storage |
|
||||
|
||||
## Integration
|
||||
|
||||
Depends on `ruv-neural-core` for `NeuralEmbedding` types. Receives embeddings
|
||||
from `ruv-neural-embed`. Stored embeddings are queried by `ruv-neural-decoder`
|
||||
for KNN-based cognitive state classification. Uses `bincode` for efficient
|
||||
binary serialization.
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
@@ -1,128 +0,0 @@
|
||||
//! Criterion benchmarks for ruv-neural-memory.
|
||||
//!
|
||||
//! Benchmarks the performance-critical vector search operations:
|
||||
//! - HNSW insert (building the index)
|
||||
//! - HNSW search (approximate nearest neighbor queries)
|
||||
//! - Brute-force nearest neighbor (baseline comparison)
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
|
||||
use rand::Rng;
|
||||
|
||||
use ruv_neural_memory::HnswIndex;
|
||||
|
||||
const DIM: usize = 64;
|
||||
|
||||
/// Generate a set of random embeddings.
|
||||
fn generate_embeddings(count: usize, dim: usize) -> Vec<Vec<f64>> {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..count)
|
||||
.map(|_| (0..dim).map(|_| rng.gen_range(-1.0..1.0)).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build an HNSW index from a set of embeddings.
|
||||
fn build_hnsw(embeddings: &[Vec<f64>]) -> HnswIndex {
|
||||
let mut index = HnswIndex::new(16, 200);
|
||||
for emb in embeddings {
|
||||
index.insert(emb);
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// Euclidean distance between two vectors.
|
||||
fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.map(|(x, y)| (x - y) * (x - y))
|
||||
.sum::<f64>()
|
||||
.sqrt()
|
||||
}
|
||||
|
||||
/// Brute-force k-nearest-neighbor search.
|
||||
fn brute_force_knn(
|
||||
embeddings: &[Vec<f64>],
|
||||
query: &[f64],
|
||||
k: usize,
|
||||
) -> Vec<(usize, f64)> {
|
||||
let mut distances: Vec<(usize, f64)> = embeddings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| (i, euclidean_distance(query, v)))
|
||||
.collect();
|
||||
distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
|
||||
distances.truncate(k);
|
||||
distances
|
||||
}
|
||||
|
||||
fn bench_hnsw_insert(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("hnsw_insert");
|
||||
group.sample_size(10);
|
||||
|
||||
for &count in &[1_000, 10_000] {
|
||||
let embeddings = generate_embeddings(count, DIM);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("embeddings", count),
|
||||
&embeddings,
|
||||
|b, embeddings| {
|
||||
b.iter(|| {
|
||||
let mut index = HnswIndex::new(16, 200);
|
||||
for emb in embeddings.iter() {
|
||||
index.insert(black_box(emb));
|
||||
}
|
||||
index
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_hnsw_search(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("hnsw_search");
|
||||
|
||||
for &count in &[1_000, 10_000] {
|
||||
let embeddings = generate_embeddings(count, DIM);
|
||||
let index = build_hnsw(&embeddings);
|
||||
let mut rng = rand::thread_rng();
|
||||
let query: Vec<f64> = (0..DIM).map(|_| rng.gen_range(-1.0..1.0)).collect();
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("k10_embeddings", count),
|
||||
&(index, query),
|
||||
|b, (index, query)| {
|
||||
b.iter(|| index.search(black_box(query), black_box(10), black_box(50)))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_brute_force_nn(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("brute_force_nn");
|
||||
|
||||
for &count in &[1_000, 10_000] {
|
||||
let embeddings = generate_embeddings(count, DIM);
|
||||
let mut rng = rand::thread_rng();
|
||||
let query: Vec<f64> = (0..DIM).map(|_| rng.gen_range(-1.0..1.0)).collect();
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("k10_embeddings", count),
|
||||
&(embeddings, query),
|
||||
|b, (embeddings, query)| {
|
||||
b.iter(|| brute_force_knn(black_box(embeddings), black_box(query), black_box(10)))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_hnsw_insert,
|
||||
bench_hnsw_search,
|
||||
bench_brute_force_nn,
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -1,432 +0,0 @@
|
||||
//! Simplified HNSW (Hierarchical Navigable Small World) index for approximate
|
||||
//! nearest neighbor search on embedding vectors.
|
||||
|
||||
use std::collections::{BinaryHeap, HashSet};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// A scored neighbor for use in the priority queue.
|
||||
#[derive(Debug, Clone)]
|
||||
struct ScoredNode {
|
||||
id: usize,
|
||||
distance: f64,
|
||||
}
|
||||
|
||||
impl PartialEq for ScoredNode {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.distance == other.distance
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for ScoredNode {}
|
||||
|
||||
impl PartialOrd for ScoredNode {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for ScoredNode {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
// Reverse ordering for min-heap behavior
|
||||
other
|
||||
.distance
|
||||
.partial_cmp(&self.distance)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Max-heap scored node (furthest first).
|
||||
#[derive(Debug, Clone)]
|
||||
struct FurthestNode {
|
||||
id: usize,
|
||||
distance: f64,
|
||||
}
|
||||
|
||||
impl PartialEq for FurthestNode {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.distance == other.distance
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for FurthestNode {}
|
||||
|
||||
impl PartialOrd for FurthestNode {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for FurthestNode {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.distance
|
||||
.partial_cmp(&other.distance)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hierarchical Navigable Small World graph for approximate nearest neighbor search.
|
||||
///
|
||||
/// This is a simplified single-layer HNSW implementation suitable for moderate-scale
|
||||
/// embedding stores (up to ~100k vectors).
|
||||
pub struct HnswIndex {
|
||||
/// Adjacency list per layer: layers[layer][node] = [(neighbor_id, distance)]
|
||||
layers: Vec<Vec<Vec<(usize, f64)>>>,
|
||||
/// Entry point node for search.
|
||||
entry_point: usize,
|
||||
/// Maximum layer index currently in the graph.
|
||||
max_layer: usize,
|
||||
/// Number of neighbors to consider during construction.
|
||||
ef_construction: usize,
|
||||
/// Maximum number of connections per node per layer.
|
||||
m: usize,
|
||||
/// Stored embedding vectors.
|
||||
embeddings: Vec<Vec<f64>>,
|
||||
}
|
||||
|
||||
impl HnswIndex {
|
||||
/// Create a new empty HNSW index.
|
||||
///
|
||||
/// - `m`: maximum connections per node per layer (typical: 16)
|
||||
/// - `ef_construction`: search width during construction (typical: 200)
|
||||
pub fn new(m: usize, ef_construction: usize) -> Self {
|
||||
Self {
|
||||
layers: vec![Vec::new()], // Start with layer 0
|
||||
entry_point: 0,
|
||||
max_layer: 0,
|
||||
ef_construction,
|
||||
m,
|
||||
embeddings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a vector and return its index.
|
||||
pub fn insert(&mut self, vector: &[f64]) -> usize {
|
||||
let id = self.embeddings.len();
|
||||
self.embeddings.push(vector.to_vec());
|
||||
|
||||
let insert_layer = self.select_layer();
|
||||
|
||||
// Ensure we have enough layers
|
||||
while self.layers.len() <= insert_layer {
|
||||
self.layers.push(Vec::new());
|
||||
}
|
||||
|
||||
// Add empty adjacency lists for this node in all layers up to insert_layer
|
||||
for layer in 0..=insert_layer {
|
||||
while self.layers[layer].len() <= id {
|
||||
self.layers[layer].push(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
// Also ensure layer 0 has an entry for this node
|
||||
while self.layers[0].len() <= id {
|
||||
self.layers[0].push(Vec::new());
|
||||
}
|
||||
|
||||
if id == 0 {
|
||||
// First node, just set as entry point
|
||||
self.entry_point = 0;
|
||||
self.max_layer = insert_layer;
|
||||
return id;
|
||||
}
|
||||
|
||||
// Greedy search from top layer down to insert_layer+1
|
||||
let mut current_entry = self.entry_point;
|
||||
for layer in (insert_layer + 1..=self.max_layer).rev() {
|
||||
if layer < self.layers.len() {
|
||||
let neighbors = self.search_layer(vector, current_entry, 1, layer);
|
||||
if let Some((nearest, _)) = neighbors.first() {
|
||||
current_entry = *nearest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into layers from insert_layer down to 0
|
||||
for layer in (0..=insert_layer.min(self.max_layer)).rev() {
|
||||
let neighbors =
|
||||
self.search_layer(vector, current_entry, self.ef_construction, layer);
|
||||
|
||||
// Select up to m neighbors
|
||||
let selected: Vec<(usize, f64)> =
|
||||
neighbors.into_iter().take(self.m).collect();
|
||||
|
||||
// Ensure adjacency list exists for this node at this layer
|
||||
while self.layers[layer].len() <= id {
|
||||
self.layers[layer].push(Vec::new());
|
||||
}
|
||||
|
||||
// Add bidirectional connections
|
||||
for &(neighbor_id, dist) in &selected {
|
||||
self.layers[layer][id].push((neighbor_id, dist));
|
||||
|
||||
while self.layers[layer].len() <= neighbor_id {
|
||||
self.layers[layer].push(Vec::new());
|
||||
}
|
||||
self.layers[layer][neighbor_id].push((id, dist));
|
||||
|
||||
// Prune if over capacity
|
||||
if self.layers[layer][neighbor_id].len() > self.m * 2 {
|
||||
self.layers[layer][neighbor_id]
|
||||
.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
|
||||
self.layers[layer][neighbor_id].truncate(self.m * 2);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((nearest, _)) = selected.first() {
|
||||
current_entry = *nearest;
|
||||
}
|
||||
}
|
||||
|
||||
if insert_layer > self.max_layer {
|
||||
self.max_layer = insert_layer;
|
||||
self.entry_point = id;
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
/// Search for the k nearest neighbors of `query`.
|
||||
///
|
||||
/// - `k`: number of nearest neighbors to return
|
||||
/// - `ef`: search width (larger = more accurate, slower; typical: 50-200)
|
||||
///
|
||||
/// Returns (index, distance) pairs sorted by ascending distance.
|
||||
pub fn search(&self, query: &[f64], k: usize, ef: usize) -> Vec<(usize, f64)> {
|
||||
if self.embeddings.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Bounds-check the entry point
|
||||
if self.entry_point >= self.embeddings.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut current_entry = self.entry_point;
|
||||
|
||||
// Greedy search from top layer down to layer 1
|
||||
for layer in (1..=self.max_layer).rev() {
|
||||
if layer < self.layers.len() {
|
||||
let neighbors = self.search_layer(query, current_entry, 1, layer);
|
||||
if let Some((nearest, _)) = neighbors.first() {
|
||||
current_entry = *nearest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search layer 0 with ef candidates
|
||||
let mut results = self.search_layer(query, current_entry, ef.max(k), 0);
|
||||
results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
|
||||
results.truncate(k);
|
||||
results
|
||||
}
|
||||
|
||||
/// Number of vectors in the index.
|
||||
pub fn len(&self) -> usize {
|
||||
self.embeddings.len()
|
||||
}
|
||||
|
||||
/// Returns true if the index has no vectors.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.embeddings.is_empty()
|
||||
}
|
||||
|
||||
/// Euclidean distance between two vectors.
|
||||
fn distance(a: &[f64], b: &[f64]) -> f64 {
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.map(|(x, y)| (x - y) * (x - y))
|
||||
.sum::<f64>()
|
||||
.sqrt()
|
||||
}
|
||||
|
||||
/// Select a random layer for insertion using an exponential distribution.
|
||||
fn select_layer(&self) -> usize {
|
||||
// Deterministic level assignment based on node count for reproducibility.
|
||||
// Uses a simple hash-like scheme: most nodes go to layer 0.
|
||||
let n = self.embeddings.len();
|
||||
let ml = 1.0 / (self.m as f64).ln();
|
||||
// Use a simple deterministic pseudo-random based on n
|
||||
let hash = ((n.wrapping_mul(2654435761)) >> 16) as f64 / 65536.0;
|
||||
let level = (-hash.ln() * ml).floor() as usize;
|
||||
level.min(4) // Cap at 4 layers
|
||||
}
|
||||
|
||||
/// Search a single layer starting from `entry`, returning `ef` nearest candidates.
|
||||
fn search_layer(
|
||||
&self,
|
||||
query: &[f64],
|
||||
entry: usize,
|
||||
ef: usize,
|
||||
layer: usize,
|
||||
) -> Vec<(usize, f64)> {
|
||||
if layer >= self.layers.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Bounds-check entry against embeddings
|
||||
if entry >= self.embeddings.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut visited = HashSet::new();
|
||||
let entry_dist = Self::distance(query, &self.embeddings[entry]);
|
||||
|
||||
// Candidates: min-heap (closest first)
|
||||
let mut candidates = BinaryHeap::new();
|
||||
candidates.push(ScoredNode {
|
||||
id: entry,
|
||||
distance: entry_dist,
|
||||
});
|
||||
|
||||
// Results: max-heap (furthest first, for pruning)
|
||||
let mut results = BinaryHeap::new();
|
||||
results.push(FurthestNode {
|
||||
id: entry,
|
||||
distance: entry_dist,
|
||||
});
|
||||
|
||||
visited.insert(entry);
|
||||
|
||||
while let Some(ScoredNode { id: current, distance: current_dist }) = candidates.pop() {
|
||||
// If current candidate is further than the worst result and we have enough, stop
|
||||
if let Some(worst) = results.peek() {
|
||||
if current_dist > worst.distance && results.len() >= ef {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Explore neighbors
|
||||
if current < self.layers[layer].len() {
|
||||
for &(neighbor, _) in &self.layers[layer][current] {
|
||||
if neighbor < self.embeddings.len() && visited.insert(neighbor) {
|
||||
let dist = Self::distance(query, &self.embeddings[neighbor]);
|
||||
|
||||
let should_add = results.len() < ef
|
||||
|| results
|
||||
.peek()
|
||||
.map(|w| dist < w.distance)
|
||||
.unwrap_or(true);
|
||||
|
||||
if should_add {
|
||||
candidates.push(ScoredNode {
|
||||
id: neighbor,
|
||||
distance: dist,
|
||||
});
|
||||
results.push(FurthestNode {
|
||||
id: neighbor,
|
||||
distance: dist,
|
||||
});
|
||||
|
||||
if results.len() > ef {
|
||||
results.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect results sorted by distance
|
||||
let mut result_vec: Vec<(usize, f64)> =
|
||||
results.into_iter().map(|n| (n.id, n.distance)).collect();
|
||||
result_vec.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
|
||||
result_vec
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn insert_and_search_basic() {
|
||||
let mut index = HnswIndex::new(4, 20);
|
||||
index.insert(&[0.0, 0.0]);
|
||||
index.insert(&[1.0, 0.0]);
|
||||
index.insert(&[0.0, 1.0]);
|
||||
index.insert(&[10.0, 10.0]);
|
||||
|
||||
let results = index.search(&[0.1, 0.1], 2, 10);
|
||||
assert_eq!(results.len(), 2);
|
||||
// Closest should be [0,0]
|
||||
assert_eq!(results[0].0, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_index_returns_empty() {
|
||||
let index = HnswIndex::new(4, 20);
|
||||
let results = index.search(&[1.0, 2.0], 5, 10);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_element() {
|
||||
let mut index = HnswIndex::new(4, 20);
|
||||
index.insert(&[5.0, 5.0]);
|
||||
|
||||
let results = index.search(&[0.0, 0.0], 1, 10);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hnsw_recall_vs_brute_force() {
|
||||
use rand::Rng;
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
let dim = 8;
|
||||
let n = 200;
|
||||
let k = 10;
|
||||
|
||||
let mut index = HnswIndex::new(16, 100);
|
||||
let mut vectors: Vec<Vec<f64>> = Vec::new();
|
||||
|
||||
for _ in 0..n {
|
||||
let v: Vec<f64> = (0..dim).map(|_| rng.gen_range(-1.0..1.0)).collect();
|
||||
index.insert(&v);
|
||||
vectors.push(v);
|
||||
}
|
||||
|
||||
// Run multiple queries and check average recall
|
||||
let num_queries = 20;
|
||||
let mut total_recall = 0.0;
|
||||
|
||||
for _ in 0..num_queries {
|
||||
let query: Vec<f64> = (0..dim).map(|_| rng.gen_range(-1.0..1.0)).collect();
|
||||
|
||||
// Brute force ground truth
|
||||
let mut bf_distances: Vec<(usize, f64)> = vectors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| (i, HnswIndex::distance(&query, v)))
|
||||
.collect();
|
||||
bf_distances
|
||||
.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let bf_top_k: Vec<usize> = bf_distances.iter().take(k).map(|(i, _)| *i).collect();
|
||||
|
||||
// HNSW search
|
||||
let hnsw_results = index.search(&query, k, 50);
|
||||
let hnsw_top_k: Vec<usize> = hnsw_results.iter().map(|(i, _)| *i).collect();
|
||||
|
||||
// Compute recall
|
||||
let hits = hnsw_top_k
|
||||
.iter()
|
||||
.filter(|id| bf_top_k.contains(id))
|
||||
.count();
|
||||
total_recall += hits as f64 / k as f64;
|
||||
}
|
||||
|
||||
let avg_recall = total_recall / num_queries as f64;
|
||||
assert!(
|
||||
avg_recall > 0.9,
|
||||
"HNSW recall {} should be > 0.9",
|
||||
avg_recall
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_is_euclidean() {
|
||||
let d = HnswIndex::distance(&[0.0, 0.0], &[3.0, 4.0]);
|
||||
assert!((d - 5.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//! rUv Neural Memory — Persistent neural state memory with vector search
|
||||
//! and longitudinal tracking.
|
||||
//!
|
||||
//! This crate provides in-memory and persistent storage for neural embeddings,
|
||||
//! supporting brute-force and HNSW-based nearest neighbor search, session-based
|
||||
//! memory management, and longitudinal drift detection.
|
||||
|
||||
pub mod hnsw;
|
||||
pub mod longitudinal;
|
||||
pub mod persistence;
|
||||
pub mod session;
|
||||
pub mod store;
|
||||
|
||||
pub use hnsw::HnswIndex;
|
||||
pub use longitudinal::{LongitudinalTracker, TrendDirection};
|
||||
pub use persistence::{load_rvf, load_store, save_rvf, save_store};
|
||||
pub use session::{SessionMemory, SessionMetadata};
|
||||
pub use store::NeuralMemoryStore;
|
||||
@@ -1,268 +0,0 @@
|
||||
//! Longitudinal tracking and drift detection for neural topology changes
|
||||
//! over extended observation periods.
|
||||
|
||||
use ruv_neural_core::embedding::NeuralEmbedding;
|
||||
|
||||
/// Direction of observed trend in neural embeddings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrendDirection {
|
||||
/// No significant change from baseline.
|
||||
Stable,
|
||||
/// Embedding distances are decreasing (closer to baseline).
|
||||
Improving,
|
||||
/// Embedding distances are increasing (drifting from baseline).
|
||||
Degrading,
|
||||
/// Embeddings alternate between improving and degrading.
|
||||
Oscillating,
|
||||
}
|
||||
|
||||
/// Tracks neural topology changes over extended periods, detecting drift
|
||||
/// from an established baseline.
|
||||
pub struct LongitudinalTracker {
|
||||
/// Baseline embeddings representing the reference state.
|
||||
baseline_embeddings: Vec<NeuralEmbedding>,
|
||||
/// Current trajectory of observations.
|
||||
current_trajectory: Vec<NeuralEmbedding>,
|
||||
/// Threshold above which drift is considered significant.
|
||||
drift_threshold: f64,
|
||||
}
|
||||
|
||||
impl LongitudinalTracker {
|
||||
/// Create a new tracker with the given drift threshold.
|
||||
pub fn new(drift_threshold: f64) -> Self {
|
||||
Self {
|
||||
baseline_embeddings: Vec::new(),
|
||||
current_trajectory: Vec::new(),
|
||||
drift_threshold,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the baseline embeddings (the reference state).
|
||||
pub fn set_baseline(&mut self, embeddings: Vec<NeuralEmbedding>) {
|
||||
self.baseline_embeddings = embeddings;
|
||||
}
|
||||
|
||||
/// Add a new observation to the current trajectory.
|
||||
pub fn add_observation(&mut self, embedding: NeuralEmbedding) {
|
||||
self.current_trajectory.push(embedding);
|
||||
}
|
||||
|
||||
/// Number of observations in the current trajectory.
|
||||
pub fn num_observations(&self) -> usize {
|
||||
self.current_trajectory.len()
|
||||
}
|
||||
|
||||
/// Compute the mean drift from baseline.
|
||||
///
|
||||
/// Returns the average Euclidean distance from each trajectory embedding
|
||||
/// to the nearest baseline embedding. Returns 0.0 if either baseline or
|
||||
/// trajectory is empty.
|
||||
pub fn compute_drift(&self) -> f64 {
|
||||
if self.baseline_embeddings.is_empty() || self.current_trajectory.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let total_drift: f64 = self
|
||||
.current_trajectory
|
||||
.iter()
|
||||
.map(|obs| self.min_distance_to_baseline(obs))
|
||||
.sum();
|
||||
|
||||
total_drift / self.current_trajectory.len() as f64
|
||||
}
|
||||
|
||||
/// Detect the overall trend direction from the trajectory.
|
||||
///
|
||||
/// Compares drift of the first half vs second half of the trajectory.
|
||||
pub fn detect_trend(&self) -> TrendDirection {
|
||||
if self.current_trajectory.len() < 4 || self.baseline_embeddings.is_empty() {
|
||||
return TrendDirection::Stable;
|
||||
}
|
||||
|
||||
let mid = self.current_trajectory.len() / 2;
|
||||
let first_half: Vec<f64> = self.current_trajectory[..mid]
|
||||
.iter()
|
||||
.map(|obs| self.min_distance_to_baseline(obs))
|
||||
.collect();
|
||||
let second_half: Vec<f64> = self.current_trajectory[mid..]
|
||||
.iter()
|
||||
.map(|obs| self.min_distance_to_baseline(obs))
|
||||
.collect();
|
||||
|
||||
let first_mean = mean(&first_half);
|
||||
let second_mean = mean(&second_half);
|
||||
|
||||
let diff = second_mean - first_mean;
|
||||
|
||||
if diff.abs() < self.drift_threshold * 0.1 {
|
||||
// Check for oscillation by looking at alternating signs
|
||||
let diffs: Vec<f64> = self
|
||||
.current_trajectory
|
||||
.windows(2)
|
||||
.map(|w| {
|
||||
self.min_distance_to_baseline(&w[1])
|
||||
- self.min_distance_to_baseline(&w[0])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sign_changes = diffs
|
||||
.windows(2)
|
||||
.filter(|w| w[0].signum() != w[1].signum())
|
||||
.count();
|
||||
|
||||
if sign_changes > diffs.len() / 2 {
|
||||
return TrendDirection::Oscillating;
|
||||
}
|
||||
|
||||
TrendDirection::Stable
|
||||
} else if diff > 0.0 {
|
||||
TrendDirection::Degrading
|
||||
} else {
|
||||
TrendDirection::Improving
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute an anomaly score for a single embedding.
|
||||
///
|
||||
/// Returns a score in [0, 1] where 1 means highly anomalous relative
|
||||
/// to the baseline. Based on how far the embedding is from the baseline
|
||||
/// relative to the drift threshold.
|
||||
pub fn anomaly_score(&self, embedding: &NeuralEmbedding) -> f64 {
|
||||
if self.baseline_embeddings.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let dist = self.min_distance_to_baseline(embedding);
|
||||
// Sigmoid-like mapping: score = 1 - exp(-dist / threshold)
|
||||
1.0 - (-dist / self.drift_threshold).exp()
|
||||
}
|
||||
|
||||
/// Minimum Euclidean distance from an embedding to any baseline embedding.
|
||||
fn min_distance_to_baseline(&self, embedding: &NeuralEmbedding) -> f64 {
|
||||
self.baseline_embeddings
|
||||
.iter()
|
||||
.filter_map(|base| base.euclidean_distance(embedding).ok())
|
||||
.fold(f64::MAX, f64::min)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the arithmetic mean of a slice.
|
||||
fn mean(values: &[f64]) -> f64 {
|
||||
if values.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
values.iter().sum::<f64>() / values.len() as f64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::embedding::EmbeddingMetadata;
|
||||
use ruv_neural_core::topology::CognitiveState;
|
||||
|
||||
fn make_embedding(vector: Vec<f64>, timestamp: f64) -> NeuralEmbedding {
|
||||
NeuralEmbedding::new(
|
||||
vector,
|
||||
timestamp,
|
||||
EmbeddingMetadata {
|
||||
subject_id: Some("subj1".to_string()),
|
||||
session_id: None,
|
||||
cognitive_state: Some(CognitiveState::Rest),
|
||||
source_atlas: Atlas::Schaefer100,
|
||||
embedding_method: "test".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tracker_returns_zero_drift() {
|
||||
let tracker = LongitudinalTracker::new(1.0);
|
||||
assert_eq!(tracker.compute_drift(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drift_when_same_as_baseline() {
|
||||
let mut tracker = LongitudinalTracker::new(1.0);
|
||||
tracker.set_baseline(vec![make_embedding(vec![0.0, 0.0], 0.0)]);
|
||||
tracker.add_observation(make_embedding(vec![0.0, 0.0], 1.0));
|
||||
|
||||
assert!(tracker.compute_drift() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_known_drift() {
|
||||
let mut tracker = LongitudinalTracker::new(1.0);
|
||||
tracker.set_baseline(vec![make_embedding(vec![0.0, 0.0, 0.0], 0.0)]);
|
||||
|
||||
// Add observations that progressively drift
|
||||
for i in 1..=10 {
|
||||
let offset = i as f64;
|
||||
tracker.add_observation(make_embedding(vec![offset, 0.0, 0.0], i as f64));
|
||||
}
|
||||
|
||||
let drift = tracker.compute_drift();
|
||||
assert!(drift > 1.0, "Expected significant drift, got {}", drift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degrading_trend_detected() {
|
||||
let mut tracker = LongitudinalTracker::new(1.0);
|
||||
tracker.set_baseline(vec![make_embedding(vec![0.0, 0.0], 0.0)]);
|
||||
|
||||
// First half: close to baseline
|
||||
for i in 1..=5 {
|
||||
tracker.add_observation(make_embedding(vec![0.1 * i as f64, 0.0], i as f64));
|
||||
}
|
||||
// Second half: far from baseline
|
||||
for i in 6..=10 {
|
||||
tracker.add_observation(make_embedding(vec![2.0 * i as f64, 0.0], i as f64));
|
||||
}
|
||||
|
||||
assert_eq!(tracker.detect_trend(), TrendDirection::Degrading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn improving_trend_detected() {
|
||||
let mut tracker = LongitudinalTracker::new(1.0);
|
||||
tracker.set_baseline(vec![make_embedding(vec![0.0, 0.0], 0.0)]);
|
||||
|
||||
// First half: far from baseline
|
||||
for i in 1..=5 {
|
||||
tracker.add_observation(make_embedding(
|
||||
vec![10.0 - i as f64 * 1.5, 0.0],
|
||||
i as f64,
|
||||
));
|
||||
}
|
||||
// Second half: close to baseline
|
||||
for i in 6..=10 {
|
||||
tracker.add_observation(make_embedding(vec![0.1, 0.0], i as f64));
|
||||
}
|
||||
|
||||
assert_eq!(tracker.detect_trend(), TrendDirection::Improving);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anomaly_score_increases_with_distance() {
|
||||
let mut tracker = LongitudinalTracker::new(2.0);
|
||||
tracker.set_baseline(vec![make_embedding(vec![0.0, 0.0], 0.0)]);
|
||||
|
||||
let near = make_embedding(vec![0.1, 0.0], 1.0);
|
||||
let far = make_embedding(vec![10.0, 10.0], 2.0);
|
||||
|
||||
let score_near = tracker.anomaly_score(&near);
|
||||
let score_far = tracker.anomaly_score(&far);
|
||||
|
||||
assert!(score_near < score_far);
|
||||
assert!(score_near >= 0.0 && score_near <= 1.0);
|
||||
assert!(score_far >= 0.0 && score_far <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anomaly_score_zero_without_baseline() {
|
||||
let tracker = LongitudinalTracker::new(1.0);
|
||||
let emb = make_embedding(vec![5.0, 5.0], 1.0);
|
||||
assert_eq!(tracker.anomaly_score(&emb), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
//! File-based persistence for neural memory stores.
|
||||
//!
|
||||
//! Supports two formats:
|
||||
//! - **Bincode**: Fast binary serialization for local storage.
|
||||
//! - **RVF**: RuVector File format for interoperability with the RuVector ecosystem.
|
||||
|
||||
use ruv_neural_core::embedding::NeuralEmbedding;
|
||||
use ruv_neural_core::error::{Result, RuvNeuralError};
|
||||
use ruv_neural_core::rvf::{RvfDataType, RvfFile, RvfHeader};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::store::NeuralMemoryStore;
|
||||
|
||||
/// Serializable representation of the store for bincode persistence.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct StoreSnapshot {
|
||||
embeddings: Vec<NeuralEmbedding>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
/// Save a memory store to disk using bincode serialization.
|
||||
pub fn save_store(store: &NeuralMemoryStore, path: &str) -> Result<()> {
|
||||
let snapshot = StoreSnapshot {
|
||||
embeddings: store.embeddings_iter().cloned().collect(),
|
||||
capacity: store.capacity(),
|
||||
};
|
||||
|
||||
let bytes = bincode::serialize(&snapshot)
|
||||
.map_err(|e| RuvNeuralError::Serialization(format!("bincode encode: {}", e)))?;
|
||||
|
||||
std::fs::write(path, bytes)
|
||||
.map_err(|e| RuvNeuralError::Serialization(format!("write file: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a memory store from a bincode file on disk.
|
||||
pub fn load_store(path: &str) -> Result<NeuralMemoryStore> {
|
||||
let bytes = std::fs::read(path)
|
||||
.map_err(|e| RuvNeuralError::Serialization(format!("read file: {}", e)))?;
|
||||
|
||||
let snapshot: StoreSnapshot = bincode::deserialize(&bytes)
|
||||
.map_err(|e| RuvNeuralError::Serialization(format!("bincode decode: {}", e)))?;
|
||||
|
||||
let mut store = NeuralMemoryStore::new(snapshot.capacity);
|
||||
for emb in snapshot.embeddings {
|
||||
store.store(emb)?;
|
||||
}
|
||||
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Save a memory store in RVF (RuVector File) format.
|
||||
pub fn save_rvf(store: &NeuralMemoryStore, path: &str) -> Result<()> {
|
||||
let embeddings: Vec<NeuralEmbedding> = store.embeddings_iter().cloned().collect();
|
||||
let embedding_dim = embeddings.first().map(|e| e.dimension as u32).unwrap_or(0);
|
||||
|
||||
let mut rvf = RvfFile::new(RvfDataType::NeuralEmbedding);
|
||||
rvf.header = RvfHeader::new(
|
||||
RvfDataType::NeuralEmbedding,
|
||||
embeddings.len() as u64,
|
||||
embedding_dim,
|
||||
);
|
||||
|
||||
// Store metadata as JSON
|
||||
let metadata = serde_json::json!({
|
||||
"format": "ruv-neural-memory",
|
||||
"version": "0.1.0",
|
||||
"num_embeddings": embeddings.len(),
|
||||
"embedding_dim": embedding_dim,
|
||||
"capacity": store.capacity(),
|
||||
});
|
||||
rvf.metadata = metadata;
|
||||
|
||||
// Serialize embeddings as the binary payload
|
||||
let data = bincode::serialize(&embeddings)
|
||||
.map_err(|e| RuvNeuralError::Serialization(format!("bincode encode: {}", e)))?;
|
||||
rvf.data = data;
|
||||
|
||||
let mut file = std::fs::File::create(path)
|
||||
.map_err(|e| RuvNeuralError::Serialization(format!("create file: {}", e)))?;
|
||||
|
||||
rvf.write_to(&mut file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a memory store from an RVF file.
|
||||
pub fn load_rvf(path: &str) -> Result<NeuralMemoryStore> {
|
||||
let mut file = std::fs::File::open(path)
|
||||
.map_err(|e| RuvNeuralError::Serialization(format!("open file: {}", e)))?;
|
||||
|
||||
let rvf = RvfFile::read_from(&mut file)?;
|
||||
|
||||
// Verify data type
|
||||
if rvf.header.data_type != RvfDataType::NeuralEmbedding {
|
||||
return Err(RuvNeuralError::Serialization(format!(
|
||||
"Expected NeuralEmbedding data type, got {:?}",
|
||||
rvf.header.data_type
|
||||
)));
|
||||
}
|
||||
|
||||
// Extract capacity from metadata
|
||||
let capacity = rvf
|
||||
.metadata
|
||||
.get("capacity")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10000) as usize;
|
||||
|
||||
// Deserialize embeddings from binary payload
|
||||
let embeddings: Vec<NeuralEmbedding> = bincode::deserialize(&rvf.data)
|
||||
.map_err(|e| RuvNeuralError::Serialization(format!("bincode decode: {}", e)))?;
|
||||
|
||||
let mut store = NeuralMemoryStore::new(capacity);
|
||||
for emb in embeddings {
|
||||
store.store(emb)?;
|
||||
}
|
||||
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::embedding::EmbeddingMetadata;
|
||||
use ruv_neural_core::topology::CognitiveState;
|
||||
|
||||
fn make_embedding(vector: Vec<f64>, timestamp: f64) -> NeuralEmbedding {
|
||||
NeuralEmbedding::new(
|
||||
vector,
|
||||
timestamp,
|
||||
EmbeddingMetadata {
|
||||
subject_id: Some("subj1".to_string()),
|
||||
session_id: None,
|
||||
cognitive_state: Some(CognitiveState::Focused),
|
||||
source_atlas: Atlas::Schaefer100,
|
||||
embedding_method: "spectral".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bincode_round_trip() {
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("test_memory_store.bin");
|
||||
let path_str = path.to_str().unwrap();
|
||||
|
||||
let mut store = NeuralMemoryStore::new(100);
|
||||
store.store(make_embedding(vec![1.0, 2.0, 3.0], 1.0)).unwrap();
|
||||
store.store(make_embedding(vec![4.0, 5.0, 6.0], 2.0)).unwrap();
|
||||
|
||||
save_store(&store, path_str).unwrap();
|
||||
let loaded = load_store(path_str).unwrap();
|
||||
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert_eq!(loaded.get(0).unwrap().vector, vec![1.0, 2.0, 3.0]);
|
||||
assert_eq!(loaded.get(1).unwrap().vector, vec![4.0, 5.0, 6.0]);
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_file(path_str);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rvf_round_trip() {
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join("test_memory_store.rvf");
|
||||
let path_str = path.to_str().unwrap();
|
||||
|
||||
let mut store = NeuralMemoryStore::new(50);
|
||||
store.store(make_embedding(vec![10.0, 20.0], 0.5)).unwrap();
|
||||
store.store(make_embedding(vec![30.0, 40.0], 1.5)).unwrap();
|
||||
store.store(make_embedding(vec![50.0, 60.0], 2.5)).unwrap();
|
||||
|
||||
save_rvf(&store, path_str).unwrap();
|
||||
let loaded = load_rvf(path_str).unwrap();
|
||||
|
||||
assert_eq!(loaded.len(), 3);
|
||||
assert_eq!(loaded.get(0).unwrap().vector, vec![10.0, 20.0]);
|
||||
assert_eq!(loaded.get(2).unwrap().vector, vec![50.0, 60.0]);
|
||||
assert_eq!(loaded.capacity(), 50);
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_file(path_str);
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
//! Session-based memory management for grouping embeddings by recording session.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use ruv_neural_core::embedding::NeuralEmbedding;
|
||||
use ruv_neural_core::error::{Result, RuvNeuralError};
|
||||
use ruv_neural_core::topology::CognitiveState;
|
||||
|
||||
use crate::store::NeuralMemoryStore;
|
||||
|
||||
/// Metadata for a recording session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionMetadata {
|
||||
/// Unique session identifier.
|
||||
pub session_id: String,
|
||||
/// Subject being recorded.
|
||||
pub subject_id: String,
|
||||
/// Session start time (Unix timestamp).
|
||||
pub start_time: f64,
|
||||
/// Session end time (None if still active).
|
||||
pub end_time: Option<f64>,
|
||||
/// Number of embeddings stored during this session.
|
||||
pub num_embeddings: usize,
|
||||
/// Cognitive states observed during the session.
|
||||
pub cognitive_states_observed: Vec<CognitiveState>,
|
||||
}
|
||||
|
||||
/// Manages neural memory across recording sessions.
|
||||
pub struct SessionMemory {
|
||||
/// Underlying embedding store.
|
||||
store: NeuralMemoryStore,
|
||||
/// Currently active session ID.
|
||||
current_session: Option<String>,
|
||||
/// Metadata for all sessions.
|
||||
session_metadata: HashMap<String, SessionMetadata>,
|
||||
/// Maps session_id to embedding indices.
|
||||
session_indices: HashMap<String, Vec<usize>>,
|
||||
/// Counter for generating session IDs.
|
||||
session_counter: u64,
|
||||
}
|
||||
|
||||
impl SessionMemory {
|
||||
/// Create a new session memory with the given store capacity.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
store: NeuralMemoryStore::new(capacity),
|
||||
current_session: None,
|
||||
session_metadata: HashMap::new(),
|
||||
session_indices: HashMap::new(),
|
||||
session_counter: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new recording session, returning its unique ID.
|
||||
///
|
||||
/// If a session is already active, it is automatically ended first.
|
||||
pub fn start_session(&mut self, subject_id: &str) -> String {
|
||||
if self.current_session.is_some() {
|
||||
self.end_session();
|
||||
}
|
||||
|
||||
self.session_counter += 1;
|
||||
let session_id = format!("session-{:04}", self.session_counter);
|
||||
|
||||
let metadata = SessionMetadata {
|
||||
session_id: session_id.clone(),
|
||||
subject_id: subject_id.to_string(),
|
||||
start_time: 0.0, // Will be updated on first embedding
|
||||
end_time: None,
|
||||
num_embeddings: 0,
|
||||
cognitive_states_observed: Vec::new(),
|
||||
};
|
||||
|
||||
self.session_metadata
|
||||
.insert(session_id.clone(), metadata);
|
||||
self.session_indices
|
||||
.insert(session_id.clone(), Vec::new());
|
||||
self.current_session = Some(session_id.clone());
|
||||
|
||||
session_id
|
||||
}
|
||||
|
||||
/// End the current recording session.
|
||||
pub fn end_session(&mut self) {
|
||||
if let Some(ref session_id) = self.current_session.clone() {
|
||||
if let Some(meta) = self.session_metadata.get_mut(session_id) {
|
||||
// Set end time from the last embedding's timestamp
|
||||
if let Some(indices) = self.session_indices.get(session_id) {
|
||||
if let Some(&last_idx) = indices.last() {
|
||||
if let Some(emb) = self.store.get(last_idx) {
|
||||
meta.end_time = Some(emb.timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.current_session = None;
|
||||
}
|
||||
|
||||
/// Store an embedding in the current session.
|
||||
///
|
||||
/// Returns an error if no session is active.
|
||||
pub fn store(&mut self, embedding: NeuralEmbedding) -> Result<usize> {
|
||||
let session_id = self
|
||||
.current_session
|
||||
.clone()
|
||||
.ok_or_else(|| RuvNeuralError::Memory("No active session".into()))?;
|
||||
|
||||
let timestamp = embedding.timestamp;
|
||||
let state = embedding.metadata.cognitive_state;
|
||||
let idx = self.store.store(embedding)?;
|
||||
|
||||
// Update session metadata
|
||||
if let Some(meta) = self.session_metadata.get_mut(&session_id) {
|
||||
if meta.num_embeddings == 0 {
|
||||
meta.start_time = timestamp;
|
||||
}
|
||||
meta.num_embeddings += 1;
|
||||
|
||||
if let Some(s) = state {
|
||||
if !meta.cognitive_states_observed.contains(&s) {
|
||||
meta.cognitive_states_observed.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(indices) = self.session_indices.get_mut(&session_id) {
|
||||
indices.push(idx);
|
||||
}
|
||||
|
||||
Ok(idx)
|
||||
}
|
||||
|
||||
/// Get all embeddings from a specific session.
|
||||
pub fn get_session_history(&self, session_id: &str) -> Vec<&NeuralEmbedding> {
|
||||
match self.session_indices.get(session_id) {
|
||||
Some(indices) => indices
|
||||
.iter()
|
||||
.filter_map(|&i| self.store.get(i))
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all embeddings for a given subject across all sessions.
|
||||
pub fn get_subject_history(&self, subject_id: &str) -> Vec<&NeuralEmbedding> {
|
||||
self.store.query_by_subject(subject_id)
|
||||
}
|
||||
|
||||
/// Get metadata for a session.
|
||||
pub fn get_session_metadata(&self, session_id: &str) -> Option<&SessionMetadata> {
|
||||
self.session_metadata.get(session_id)
|
||||
}
|
||||
|
||||
/// Get the current active session ID.
|
||||
pub fn current_session_id(&self) -> Option<&str> {
|
||||
self.current_session.as_deref()
|
||||
}
|
||||
|
||||
/// Access the underlying store.
|
||||
pub fn store_ref(&self) -> &NeuralMemoryStore {
|
||||
&self.store
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::embedding::EmbeddingMetadata;
|
||||
|
||||
fn make_embedding(vector: Vec<f64>, subject: &str, timestamp: f64) -> NeuralEmbedding {
|
||||
NeuralEmbedding::new(
|
||||
vector,
|
||||
timestamp,
|
||||
EmbeddingMetadata {
|
||||
subject_id: Some(subject.to_string()),
|
||||
session_id: None,
|
||||
cognitive_state: Some(CognitiveState::Rest),
|
||||
source_atlas: Atlas::Schaefer100,
|
||||
embedding_method: "test".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_lifecycle() {
|
||||
let mut mem = SessionMemory::new(100);
|
||||
|
||||
// No session active
|
||||
assert!(mem.current_session_id().is_none());
|
||||
|
||||
// Start session
|
||||
let sid = mem.start_session("subj1");
|
||||
assert_eq!(mem.current_session_id(), Some(sid.as_str()));
|
||||
|
||||
// Store embeddings
|
||||
mem.store(make_embedding(vec![1.0, 0.0], "subj1", 1.0))
|
||||
.unwrap();
|
||||
mem.store(make_embedding(vec![0.0, 1.0], "subj1", 2.0))
|
||||
.unwrap();
|
||||
|
||||
// Check session history
|
||||
let history = mem.get_session_history(&sid);
|
||||
assert_eq!(history.len(), 2);
|
||||
|
||||
// Check metadata
|
||||
let meta = mem.get_session_metadata(&sid).unwrap();
|
||||
assert_eq!(meta.num_embeddings, 2);
|
||||
assert_eq!(meta.subject_id, "subj1");
|
||||
|
||||
// End session
|
||||
mem.end_session();
|
||||
assert!(mem.current_session_id().is_none());
|
||||
|
||||
let meta = mem.get_session_metadata(&sid).unwrap();
|
||||
assert_eq!(meta.end_time, Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_without_session_fails() {
|
||||
let mut mem = SessionMemory::new(100);
|
||||
let result = mem.store(make_embedding(vec![1.0], "subj1", 0.0));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_sessions() {
|
||||
let mut mem = SessionMemory::new(100);
|
||||
|
||||
let s1 = mem.start_session("subj1");
|
||||
mem.store(make_embedding(vec![1.0], "subj1", 1.0))
|
||||
.unwrap();
|
||||
mem.end_session();
|
||||
|
||||
let s2 = mem.start_session("subj1");
|
||||
mem.store(make_embedding(vec![2.0], "subj1", 2.0))
|
||||
.unwrap();
|
||||
mem.store(make_embedding(vec![3.0], "subj1", 3.0))
|
||||
.unwrap();
|
||||
mem.end_session();
|
||||
|
||||
assert_eq!(mem.get_session_history(&s1).len(), 1);
|
||||
assert_eq!(mem.get_session_history(&s2).len(), 2);
|
||||
|
||||
// Subject history spans all sessions
|
||||
let subject_history = mem.get_subject_history("subj1");
|
||||
assert_eq!(subject_history.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starting_new_session_ends_previous() {
|
||||
let mut mem = SessionMemory::new(100);
|
||||
|
||||
let s1 = mem.start_session("subj1");
|
||||
mem.store(make_embedding(vec![1.0], "subj1", 1.0))
|
||||
.unwrap();
|
||||
|
||||
// Starting a new session auto-ends the previous one
|
||||
let _s2 = mem.start_session("subj2");
|
||||
|
||||
let meta = mem.get_session_metadata(&s1).unwrap();
|
||||
assert!(meta.end_time.is_some());
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
//! In-memory embedding store with brute-force nearest neighbor search.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use ruv_neural_core::embedding::NeuralEmbedding;
|
||||
use ruv_neural_core::error::Result;
|
||||
use ruv_neural_core::topology::CognitiveState;
|
||||
use ruv_neural_core::traits::NeuralMemory;
|
||||
|
||||
/// In-memory store for neural embeddings with index-based retrieval.
|
||||
///
|
||||
/// Uses a VecDeque for O(1) front eviction instead of Vec::remove(0) which is O(n).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NeuralMemoryStore {
|
||||
/// All stored embeddings in insertion order.
|
||||
embeddings: VecDeque<NeuralEmbedding>,
|
||||
/// Maps subject_id to the indices of their embeddings.
|
||||
index: HashMap<String, Vec<usize>>,
|
||||
/// Maximum number of embeddings to store.
|
||||
capacity: usize,
|
||||
/// Running offset: total number of embeddings ever evicted.
|
||||
/// Logical index = physical index + evicted_count.
|
||||
evicted_count: usize,
|
||||
}
|
||||
|
||||
impl NeuralMemoryStore {
|
||||
/// Create a new store with the given capacity.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
embeddings: VecDeque::with_capacity(capacity.min(1024)),
|
||||
index: HashMap::new(),
|
||||
capacity,
|
||||
evicted_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store an embedding, returning its physical index within the deque.
|
||||
///
|
||||
/// If the store is at capacity, the oldest embedding is evicted.
|
||||
/// Returns an error if the embedding dimension is inconsistent with
|
||||
/// previously stored embeddings.
|
||||
pub fn store(&mut self, embedding: NeuralEmbedding) -> Result<usize> {
|
||||
// Check dimension consistency with existing embeddings
|
||||
if let Some(first) = self.embeddings.front() {
|
||||
if embedding.dimension != first.dimension {
|
||||
return Err(ruv_neural_core::error::RuvNeuralError::DimensionMismatch {
|
||||
expected: first.dimension,
|
||||
got: embedding.dimension,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if self.embeddings.len() >= self.capacity {
|
||||
self.evict_oldest();
|
||||
}
|
||||
|
||||
let idx = self.embeddings.len();
|
||||
|
||||
if let Some(ref subject_id) = embedding.metadata.subject_id {
|
||||
self.index
|
||||
.entry(subject_id.clone())
|
||||
.or_default()
|
||||
.push(idx);
|
||||
}
|
||||
|
||||
self.embeddings.push_back(embedding);
|
||||
Ok(idx)
|
||||
}
|
||||
|
||||
/// Get an embedding by its index.
|
||||
pub fn get(&self, id: usize) -> Option<&NeuralEmbedding> {
|
||||
self.embeddings.get(id)
|
||||
}
|
||||
|
||||
/// Number of embeddings currently stored.
|
||||
pub fn len(&self) -> usize {
|
||||
self.embeddings.len()
|
||||
}
|
||||
|
||||
/// Returns true if the store is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.embeddings.is_empty()
|
||||
}
|
||||
|
||||
/// Find the k nearest neighbors using brute-force Euclidean distance.
|
||||
///
|
||||
/// Returns pairs of (index, distance), sorted by ascending distance.
|
||||
pub fn query_nearest(&self, query: &NeuralEmbedding, k: usize) -> Vec<(usize, f64)> {
|
||||
let mut distances: Vec<(usize, f64)> = self
|
||||
.embeddings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, emb)| {
|
||||
emb.euclidean_distance(query).ok().map(|d| (i, d))
|
||||
})
|
||||
.collect();
|
||||
|
||||
distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
distances.truncate(k);
|
||||
distances
|
||||
}
|
||||
|
||||
/// Query all embeddings matching a given cognitive state.
|
||||
pub fn query_by_state(&self, state: CognitiveState) -> Vec<&NeuralEmbedding> {
|
||||
self.embeddings
|
||||
.iter()
|
||||
.filter(|e| e.metadata.cognitive_state == Some(state))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Query all embeddings for a given subject.
|
||||
pub fn query_by_subject(&self, subject_id: &str) -> Vec<&NeuralEmbedding> {
|
||||
match self.index.get(subject_id) {
|
||||
Some(indices) => indices
|
||||
.iter()
|
||||
.filter_map(|&i| self.embeddings.get(i))
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query embeddings within a timestamp range [start, end].
|
||||
pub fn query_time_range(&self, start: f64, end: f64) -> Vec<&NeuralEmbedding> {
|
||||
self.embeddings
|
||||
.iter()
|
||||
.filter(|e| e.timestamp >= start && e.timestamp <= end)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Access all embeddings (for serialization).
|
||||
///
|
||||
/// Returns the two slices of the VecDeque as a pair. For contiguous access,
|
||||
/// callers can use `make_contiguous()` on a mutable reference, or iterate.
|
||||
pub fn embeddings_iter(&self) -> impl Iterator<Item = &NeuralEmbedding> {
|
||||
self.embeddings.iter()
|
||||
}
|
||||
|
||||
/// Access all embeddings as a slice pair (VecDeque may be non-contiguous).
|
||||
pub fn embeddings(&self) -> Vec<&NeuralEmbedding> {
|
||||
self.embeddings.iter().collect()
|
||||
}
|
||||
|
||||
/// Get the capacity.
|
||||
pub fn capacity(&self) -> usize {
|
||||
self.capacity
|
||||
}
|
||||
|
||||
/// Evict the oldest embedding with O(1) pop and incremental index update.
|
||||
///
|
||||
/// Instead of rebuilding the entire index, we remove the evicted entry
|
||||
/// from the subject index and decrement all remaining indices by 1.
|
||||
fn evict_oldest(&mut self) {
|
||||
if self.embeddings.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let evicted = self.embeddings.pop_front().unwrap();
|
||||
self.evicted_count += 1;
|
||||
|
||||
// Remove index 0 from the evicted embedding's subject entry.
|
||||
if let Some(ref subject_id) = evicted.metadata.subject_id {
|
||||
if let Some(indices) = self.index.get_mut(subject_id) {
|
||||
indices.retain(|&i| i != 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Decrement all indices by 1 since front was removed.
|
||||
for indices in self.index.values_mut() {
|
||||
for idx in indices.iter_mut() {
|
||||
*idx -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty entries.
|
||||
self.index.retain(|_, v| !v.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
impl NeuralMemory for NeuralMemoryStore {
|
||||
fn store(&mut self, embedding: &NeuralEmbedding) -> Result<()> {
|
||||
NeuralMemoryStore::store(self, embedding.clone())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn query_nearest(
|
||||
&self,
|
||||
embedding: &NeuralEmbedding,
|
||||
k: usize,
|
||||
) -> Result<Vec<NeuralEmbedding>> {
|
||||
let results = NeuralMemoryStore::query_nearest(self, embedding, k);
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.filter_map(|(i, _)| self.get(i).cloned())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn query_by_state(&self, state: CognitiveState) -> Result<Vec<NeuralEmbedding>> {
|
||||
Ok(NeuralMemoryStore::query_by_state(self, state)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::embedding::EmbeddingMetadata;
|
||||
|
||||
fn make_embedding(vector: Vec<f64>, subject: &str, timestamp: f64) -> NeuralEmbedding {
|
||||
NeuralEmbedding::new(
|
||||
vector,
|
||||
timestamp,
|
||||
EmbeddingMetadata {
|
||||
subject_id: Some(subject.to_string()),
|
||||
session_id: None,
|
||||
cognitive_state: Some(CognitiveState::Rest),
|
||||
source_atlas: Atlas::Schaefer100,
|
||||
embedding_method: "test".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn make_embedding_with_state(
|
||||
vector: Vec<f64>,
|
||||
state: CognitiveState,
|
||||
timestamp: f64,
|
||||
) -> NeuralEmbedding {
|
||||
NeuralEmbedding::new(
|
||||
vector,
|
||||
timestamp,
|
||||
EmbeddingMetadata {
|
||||
subject_id: Some("subj1".to_string()),
|
||||
session_id: None,
|
||||
cognitive_state: Some(state),
|
||||
source_atlas: Atlas::Schaefer100,
|
||||
embedding_method: "test".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_and_retrieve() {
|
||||
let mut store = NeuralMemoryStore::new(100);
|
||||
let emb = make_embedding(vec![1.0, 2.0, 3.0], "subj1", 0.0);
|
||||
let idx = store.store(emb.clone()).unwrap();
|
||||
assert_eq!(idx, 0);
|
||||
assert_eq!(store.len(), 1);
|
||||
|
||||
let retrieved = store.get(0).unwrap();
|
||||
assert_eq!(retrieved.vector, vec![1.0, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_neighbor_returns_correct_results() {
|
||||
let mut store = NeuralMemoryStore::new(100);
|
||||
store
|
||||
.store(make_embedding(vec![0.0, 0.0, 0.0], "a", 0.0))
|
||||
.unwrap();
|
||||
store
|
||||
.store(make_embedding(vec![1.0, 0.0, 0.0], "b", 1.0))
|
||||
.unwrap();
|
||||
store
|
||||
.store(make_embedding(vec![10.0, 10.0, 10.0], "c", 2.0))
|
||||
.unwrap();
|
||||
|
||||
let query = make_embedding(vec![0.5, 0.0, 0.0], "q", 3.0);
|
||||
let results = store.query_nearest(&query, 2);
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
// Closest should be [0,0,0] (dist=0.5) then [1,0,0] (dist=0.5)
|
||||
assert!(results[0].1 <= results[1].1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_by_state_filters_correctly() {
|
||||
let mut store = NeuralMemoryStore::new(100);
|
||||
store
|
||||
.store(make_embedding_with_state(
|
||||
vec![1.0, 0.0],
|
||||
CognitiveState::Rest,
|
||||
0.0,
|
||||
))
|
||||
.unwrap();
|
||||
store
|
||||
.store(make_embedding_with_state(
|
||||
vec![0.0, 1.0],
|
||||
CognitiveState::Focused,
|
||||
1.0,
|
||||
))
|
||||
.unwrap();
|
||||
store
|
||||
.store(make_embedding_with_state(
|
||||
vec![1.0, 1.0],
|
||||
CognitiveState::Rest,
|
||||
2.0,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let resting = store.query_by_state(CognitiveState::Rest);
|
||||
assert_eq!(resting.len(), 2);
|
||||
|
||||
let focused = store.query_by_state(CognitiveState::Focused);
|
||||
assert_eq!(focused.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_by_subject() {
|
||||
let mut store = NeuralMemoryStore::new(100);
|
||||
store
|
||||
.store(make_embedding(vec![1.0, 0.0], "alice", 0.0))
|
||||
.unwrap();
|
||||
store
|
||||
.store(make_embedding(vec![0.0, 1.0], "bob", 1.0))
|
||||
.unwrap();
|
||||
store
|
||||
.store(make_embedding(vec![1.0, 1.0], "alice", 2.0))
|
||||
.unwrap();
|
||||
|
||||
let alice = store.query_by_subject("alice");
|
||||
assert_eq!(alice.len(), 2);
|
||||
|
||||
let bob = store.query_by_subject("bob");
|
||||
assert_eq!(bob.len(), 1);
|
||||
|
||||
let unknown = store.query_by_subject("charlie");
|
||||
assert_eq!(unknown.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_time_range() {
|
||||
let mut store = NeuralMemoryStore::new(100);
|
||||
store
|
||||
.store(make_embedding(vec![1.0], "a", 1.0))
|
||||
.unwrap();
|
||||
store
|
||||
.store(make_embedding(vec![2.0], "a", 5.0))
|
||||
.unwrap();
|
||||
store
|
||||
.store(make_embedding(vec![3.0], "a", 10.0))
|
||||
.unwrap();
|
||||
|
||||
let in_range = store.query_time_range(2.0, 8.0);
|
||||
assert_eq!(in_range.len(), 1);
|
||||
assert_eq!(in_range[0].vector, vec![2.0]);
|
||||
|
||||
let all = store.query_time_range(0.0, 20.0);
|
||||
assert_eq!(all.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_eviction() {
|
||||
let mut store = NeuralMemoryStore::new(2);
|
||||
store
|
||||
.store(make_embedding(vec![1.0], "a", 0.0))
|
||||
.unwrap();
|
||||
store
|
||||
.store(make_embedding(vec![2.0], "b", 1.0))
|
||||
.unwrap();
|
||||
assert_eq!(store.len(), 2);
|
||||
|
||||
// This should evict the oldest
|
||||
store
|
||||
.store(make_embedding(vec![3.0], "c", 2.0))
|
||||
.unwrap();
|
||||
assert_eq!(store.len(), 2);
|
||||
// First element should now be [2.0]
|
||||
assert_eq!(store.get(0).unwrap().vector, vec![2.0]);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
[package]
|
||||
name = "ruv-neural-mincut"
|
||||
description = "rUv Neural — Dynamic minimum cut analysis for brain network topology detection"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = []
|
||||
wasm = []
|
||||
sublinear = [] # Sublinear mincut algorithms
|
||||
|
||||
[dependencies]
|
||||
ruv-neural-core = { 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 }
|
||||
criterion = { workspace = true }
|
||||
|
||||
[[bench]]
|
||||
name = "benchmarks"
|
||||
harness = false
|
||||
@@ -1,102 +0,0 @@
|
||||
# ruv-neural-mincut
|
||||
|
||||
Dynamic minimum cut analysis for brain network topology detection.
|
||||
|
||||
## Overview
|
||||
|
||||
`ruv-neural-mincut` provides algorithms for computing minimum cuts on brain
|
||||
connectivity graphs, tracking topology changes over time, and detecting neural
|
||||
coherence events such as network formation, dissolution, merger, and split.
|
||||
These algorithms form the core of the rUv Neural cognitive state detection
|
||||
pipeline, identifying when brain network topology undergoes significant
|
||||
structural transitions.
|
||||
|
||||
## Features
|
||||
|
||||
- **Stoer-Wagner** (`stoer_wagner`): Global minimum cut in O(V^3) time, returning
|
||||
cut value, partitions, and cut edges
|
||||
- **Normalized cut** (`normalized`): Shi-Malik spectral bisection via the Fiedler
|
||||
vector for balanced graph partitioning
|
||||
- **Multiway cut** (`multiway`): Recursive normalized cut for k-module detection;
|
||||
`detect_modules` for automatic module count selection
|
||||
- **Spectral cut** (`spectral_cut`): Cheeger constant computation, spectral bisection,
|
||||
and Cheeger bound estimation
|
||||
- **Dynamic tracking** (`dynamic`): `DynamicMincutTracker` for temporal mincut
|
||||
evolution tracking with `TopologyTransition` and `TransitionDirection` detection
|
||||
- **Coherence detection** (`coherence`): `CoherenceDetector` identifying
|
||||
`CoherenceEventType` events (formation, dissolution, merger, split) from
|
||||
temporal graph sequences
|
||||
- **Benchmarks** (`benchmark`): Performance benchmarking utilities
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use ruv_neural_mincut::{
|
||||
stoer_wagner_mincut, normalized_cut, spectral_bisection,
|
||||
cheeger_constant, multiway_cut, detect_modules,
|
||||
DynamicMincutTracker, CoherenceDetector,
|
||||
};
|
||||
use ruv_neural_core::graph::BrainGraph;
|
||||
|
||||
// Compute global minimum cut
|
||||
let result = stoer_wagner_mincut(&graph);
|
||||
println!("Cut value: {:.3}", result.cut_value);
|
||||
println!("Partition A: {:?}", result.partition_a);
|
||||
println!("Partition B: {:?}", result.partition_b);
|
||||
|
||||
// Normalized cut (spectral bisection)
|
||||
let ncut = normalized_cut(&graph);
|
||||
|
||||
// Spectral analysis
|
||||
let (partition, cheeger) = spectral_bisection(&graph);
|
||||
let h = cheeger_constant(&graph);
|
||||
|
||||
// Multiway cut for k modules
|
||||
let multi = multiway_cut(&graph, 4);
|
||||
let auto_modules = detect_modules(&graph);
|
||||
|
||||
// Track topology transitions over time
|
||||
let mut tracker = DynamicMincutTracker::new();
|
||||
for graph in &graph_sequence.graphs {
|
||||
let result = tracker.update(graph).unwrap();
|
||||
}
|
||||
|
||||
// Detect coherence events
|
||||
let mut detector = CoherenceDetector::new();
|
||||
for graph in &graph_sequence.graphs {
|
||||
if let Some(event) = detector.check(graph) {
|
||||
println!("Event: {:?} at t={}", event.event_type, event.timestamp);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Module | Key Types / Functions |
|
||||
|-----------------|-----------------------------------------------------------------|
|
||||
| `stoer_wagner` | `stoer_wagner_mincut` |
|
||||
| `normalized` | `normalized_cut` |
|
||||
| `multiway` | `multiway_cut`, `detect_modules` |
|
||||
| `spectral_cut` | `spectral_bisection`, `cheeger_constant`, `cheeger_bound` |
|
||||
| `dynamic` | `DynamicMincutTracker`, `TopologyTransition`, `TransitionDirection` |
|
||||
| `coherence` | `CoherenceDetector`, `CoherenceEvent`, `CoherenceEventType` |
|
||||
| `benchmark` | Benchmark utilities |
|
||||
|
||||
## Feature Flags
|
||||
|
||||
| Feature | Default | Description |
|
||||
|-------------|---------|----------------------------------|
|
||||
| `std` | Yes | Standard library support |
|
||||
| `wasm` | No | WASM-compatible implementations |
|
||||
| `sublinear` | No | Sublinear mincut algorithms |
|
||||
|
||||
## Integration
|
||||
|
||||
Depends on `ruv-neural-core` for `BrainGraph`, `MincutResult`, and `MultiPartition`
|
||||
types. Receives graphs from `ruv-neural-graph`. Mincut results feed into
|
||||
`ruv-neural-embed` for topology-aware embeddings and `ruv-neural-decoder`
|
||||
for cognitive state classification.
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
@@ -1,105 +0,0 @@
|
||||
//! Criterion benchmarks for ruv-neural-mincut.
|
||||
//!
|
||||
//! Benchmarks the performance-critical graph cut algorithms:
|
||||
//! - Stoer-Wagner global minimum cut (O(V^3))
|
||||
//! - Spectral bisection via Fiedler vector
|
||||
//! - Cheeger constant (exact enumeration for small graphs)
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
|
||||
use rand::Rng;
|
||||
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::graph::{BrainEdge, BrainGraph, ConnectivityMetric};
|
||||
use ruv_neural_core::signal::FrequencyBand;
|
||||
use ruv_neural_mincut::{cheeger_constant, spectral_bisection, stoer_wagner_mincut};
|
||||
|
||||
/// Build a random weighted graph with the given number of nodes.
|
||||
///
|
||||
/// Creates a connected graph by first building a spanning path, then adding
|
||||
/// random edges with density ~30% to ensure non-trivial structure.
|
||||
fn random_graph(num_nodes: usize) -> BrainGraph {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut edges = Vec::new();
|
||||
|
||||
// Spanning path to guarantee connectivity
|
||||
for i in 0..(num_nodes - 1) {
|
||||
edges.push(BrainEdge {
|
||||
source: i,
|
||||
target: i + 1,
|
||||
weight: rng.gen_range(0.1..2.0),
|
||||
metric: ConnectivityMetric::Coherence,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
});
|
||||
}
|
||||
|
||||
// Additional random edges (~30% density)
|
||||
for i in 0..num_nodes {
|
||||
for j in (i + 2)..num_nodes {
|
||||
if rng.gen_bool(0.3) {
|
||||
edges.push(BrainEdge {
|
||||
source: i,
|
||||
target: j,
|
||||
weight: rng.gen_range(0.1..2.0),
|
||||
metric: ConnectivityMetric::Coherence,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BrainGraph {
|
||||
num_nodes,
|
||||
edges,
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(num_nodes),
|
||||
}
|
||||
}
|
||||
|
||||
fn bench_stoer_wagner(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("stoer_wagner");
|
||||
|
||||
for &n in &[10, 20, 50, 68] {
|
||||
let graph = random_graph(n);
|
||||
group.bench_with_input(BenchmarkId::new("nodes", n), &graph, |b, graph| {
|
||||
b.iter(|| stoer_wagner_mincut(black_box(graph)))
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_spectral_bisection(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("spectral_bisection");
|
||||
|
||||
for &n in &[10, 20, 50, 68] {
|
||||
let graph = random_graph(n);
|
||||
group.bench_with_input(BenchmarkId::new("nodes", n), &graph, |b, graph| {
|
||||
b.iter(|| spectral_bisection(black_box(graph)))
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_cheeger_constant(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("cheeger_constant");
|
||||
|
||||
// Cheeger uses exact enumeration for n <= 16, so test within that range
|
||||
for &n in &[8, 12, 16] {
|
||||
let graph = random_graph(n);
|
||||
group.bench_with_input(BenchmarkId::new("nodes", n), &graph, |b, graph| {
|
||||
b.iter(|| cheeger_constant(black_box(graph)))
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_stoer_wagner,
|
||||
bench_spectral_bisection,
|
||||
bench_cheeger_constant,
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -1,186 +0,0 @@
|
||||
//! Performance benchmarking utilities for mincut algorithms.
|
||||
//!
|
||||
//! Provides functions to measure the wall-clock time of the Stoer-Wagner and
|
||||
//! normalized cut algorithms on random graphs of configurable size and density.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::graph::{BrainEdge, BrainGraph, ConnectivityMetric};
|
||||
use ruv_neural_core::signal::FrequencyBand;
|
||||
|
||||
use crate::normalized::normalized_cut;
|
||||
use crate::stoer_wagner::stoer_wagner_mincut;
|
||||
|
||||
/// Result of a benchmark run.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BenchmarkReport {
|
||||
/// Algorithm name.
|
||||
pub algorithm: String,
|
||||
/// Number of nodes in the test graph.
|
||||
pub num_nodes: usize,
|
||||
/// Number of edges in the test graph.
|
||||
pub num_edges: usize,
|
||||
/// Graph density (0..1).
|
||||
pub density: f64,
|
||||
/// Wall-clock execution time.
|
||||
pub elapsed: Duration,
|
||||
/// Minimum cut value found.
|
||||
pub cut_value: f64,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BenchmarkReport {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}: nodes={}, edges={}, density={:.3}, time={:.3}ms, cut={:.4}",
|
||||
self.algorithm,
|
||||
self.num_nodes,
|
||||
self.num_edges,
|
||||
self.density,
|
||||
self.elapsed.as_secs_f64() * 1000.0,
|
||||
self.cut_value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Benchmark the Stoer-Wagner algorithm on a random graph.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `num_nodes` - Number of vertices.
|
||||
/// * `density` - Edge density in [0, 1]. A density of 1.0 generates a complete graph.
|
||||
/// * `seed` - Random seed for reproducibility.
|
||||
pub fn benchmark_stoer_wagner(num_nodes: usize, density: f64, seed: u64) -> BenchmarkReport {
|
||||
let graph = generate_random_graph(num_nodes, density, seed);
|
||||
let num_edges = graph.edges.len();
|
||||
|
||||
let start = Instant::now();
|
||||
let result = stoer_wagner_mincut(&graph);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
let cut_value = result.map(|r| r.cut_value).unwrap_or(f64::NAN);
|
||||
|
||||
BenchmarkReport {
|
||||
algorithm: "Stoer-Wagner".to_string(),
|
||||
num_nodes,
|
||||
num_edges,
|
||||
density,
|
||||
elapsed,
|
||||
cut_value,
|
||||
}
|
||||
}
|
||||
|
||||
/// Benchmark the normalized cut algorithm on a random graph.
|
||||
pub fn benchmark_normalized_cut(num_nodes: usize, density: f64, seed: u64) -> BenchmarkReport {
|
||||
let graph = generate_random_graph(num_nodes, density, seed);
|
||||
let num_edges = graph.edges.len();
|
||||
|
||||
let start = Instant::now();
|
||||
let result = normalized_cut(&graph);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
let cut_value = result.map(|r| r.cut_value).unwrap_or(f64::NAN);
|
||||
|
||||
BenchmarkReport {
|
||||
algorithm: "Normalized-Cut".to_string(),
|
||||
num_nodes,
|
||||
num_edges,
|
||||
density,
|
||||
elapsed,
|
||||
cut_value,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a random undirected weighted graph with approximately the given density.
|
||||
///
|
||||
/// Uses a simple LCG for deterministic randomness.
|
||||
fn generate_random_graph(num_nodes: usize, density: f64, seed: u64) -> BrainGraph {
|
||||
let mut rng_state = seed;
|
||||
|
||||
let mut edges = Vec::new();
|
||||
for i in 0..num_nodes {
|
||||
for j in (i + 1)..num_nodes {
|
||||
rng_state = rng_state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1);
|
||||
let rand_val = (rng_state >> 33) as f64 / (1u64 << 31) as f64;
|
||||
|
||||
if rand_val < density {
|
||||
rng_state = rng_state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1);
|
||||
let weight = ((rng_state >> 33) as f64 / (1u64 << 31) as f64) * 0.9 + 0.1;
|
||||
|
||||
edges.push(BrainEdge {
|
||||
source: i,
|
||||
target: j,
|
||||
weight,
|
||||
metric: ConnectivityMetric::Coherence,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BrainGraph {
|
||||
num_nodes,
|
||||
edges,
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(num_nodes),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a full benchmark suite and return all reports.
|
||||
pub fn run_benchmark_suite() -> Vec<BenchmarkReport> {
|
||||
let configs = [(10, 0.5), (20, 0.3), (30, 0.2), (50, 0.1)];
|
||||
|
||||
let mut reports = Vec::new();
|
||||
for &(nodes, density) in &configs {
|
||||
reports.push(benchmark_stoer_wagner(nodes, density, 42));
|
||||
reports.push(benchmark_normalized_cut(nodes, density, 42));
|
||||
}
|
||||
reports
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_benchmark_stoer_wagner() {
|
||||
let report = benchmark_stoer_wagner(10, 0.5, 42);
|
||||
assert_eq!(report.num_nodes, 10);
|
||||
assert!(report.num_edges > 0);
|
||||
assert!(!report.cut_value.is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_benchmark_normalized_cut() {
|
||||
let report = benchmark_normalized_cut(10, 0.5, 42);
|
||||
assert_eq!(report.num_nodes, 10);
|
||||
assert!(!report.cut_value.is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_random_graph_deterministic() {
|
||||
let g1 = generate_random_graph(20, 0.3, 123);
|
||||
let g2 = generate_random_graph(20, 0.3, 123);
|
||||
assert_eq!(g1.edges.len(), g2.edges.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_benchmark_report_display() {
|
||||
let report = benchmark_stoer_wagner(10, 0.5, 42);
|
||||
let display = format!("{}", report);
|
||||
assert!(display.contains("Stoer-Wagner"));
|
||||
assert!(display.contains("nodes=10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_benchmark_suite() {
|
||||
let reports = run_benchmark_suite();
|
||||
assert_eq!(reports.len(), 8);
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
//! Neural coherence detection via minimum cut analysis.
|
||||
//!
|
||||
//! Detects when brain networks become coherent (strongly coupled) or decouple,
|
||||
//! by monitoring the minimum cut over a temporal graph sequence. Significant
|
||||
//! changes in mincut topology correspond to network formation, dissolution,
|
||||
//! merger, and split events.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dynamic::DynamicMincutTracker;
|
||||
|
||||
/// Type of coherence event detected.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CoherenceEventType {
|
||||
/// A new coherent module forms (integration event).
|
||||
NetworkFormation,
|
||||
/// A coherent module breaks apart (segregation event).
|
||||
NetworkDissolution,
|
||||
/// Two modules merge into one.
|
||||
NetworkMerger,
|
||||
/// One module splits into two.
|
||||
NetworkSplit,
|
||||
}
|
||||
|
||||
/// A coherence event detected in the brain network.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CoherenceEvent {
|
||||
/// Start time of the event.
|
||||
pub start_time: f64,
|
||||
/// End time of the event.
|
||||
pub end_time: f64,
|
||||
/// Type of coherence event.
|
||||
pub event_type: CoherenceEventType,
|
||||
/// Brain region indices involved in the event.
|
||||
pub involved_regions: Vec<usize>,
|
||||
/// Peak coherence magnitude during the event.
|
||||
pub peak_coherence: f64,
|
||||
}
|
||||
|
||||
/// Detects coherence events in temporal brain graph sequences.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoherenceDetector {
|
||||
/// Internal tracker for mincut evolution.
|
||||
tracker: DynamicMincutTracker,
|
||||
/// Threshold (fraction of baseline) for integration detection.
|
||||
threshold_integration: f64,
|
||||
/// Threshold (fraction of baseline) for segregation detection.
|
||||
threshold_segregation: f64,
|
||||
}
|
||||
|
||||
impl CoherenceDetector {
|
||||
/// Create a new coherence detector.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `threshold_integration` - Fraction of baseline for integration detection
|
||||
/// (e.g., 0.3 means a 30% decrease in mincut triggers an integration event).
|
||||
/// * `threshold_segregation` - Fraction of baseline for segregation detection.
|
||||
pub fn new(threshold_integration: f64, threshold_segregation: f64) -> Self {
|
||||
Self {
|
||||
tracker: DynamicMincutTracker::new(),
|
||||
threshold_integration,
|
||||
threshold_segregation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the baseline mincut value from resting-state data.
|
||||
pub fn set_baseline(&mut self, baseline: f64) {
|
||||
self.tracker.set_baseline(baseline);
|
||||
}
|
||||
|
||||
/// Get a reference to the internal tracker.
|
||||
pub fn tracker(&self) -> &DynamicMincutTracker {
|
||||
&self.tracker
|
||||
}
|
||||
|
||||
/// Detect coherence events from a mincut time series.
|
||||
///
|
||||
/// Processes each `(timestamp, mincut_value)` pair, detects transitions,
|
||||
/// and classifies them into coherence events.
|
||||
pub fn detect_from_timeseries(
|
||||
&self,
|
||||
mincut_series: &[(f64, f64)],
|
||||
) -> Vec<CoherenceEvent> {
|
||||
if mincut_series.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Compute baseline as mean if not set.
|
||||
let baseline = self.tracker.baseline().unwrap_or_else(|| {
|
||||
let sum: f64 = mincut_series.iter().map(|(_, v)| v).sum();
|
||||
sum / mincut_series.len() as f64
|
||||
});
|
||||
|
||||
if baseline <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let threshold = self.threshold_integration.min(self.threshold_segregation);
|
||||
let change_threshold = threshold * baseline;
|
||||
|
||||
let mut events = Vec::new();
|
||||
let mut i = 1;
|
||||
|
||||
while i < mincut_series.len() {
|
||||
let (_t_prev, v_prev) = mincut_series[i - 1];
|
||||
let (t_curr, v_curr) = mincut_series[i];
|
||||
let delta = v_curr - v_prev;
|
||||
|
||||
if delta.abs() > change_threshold {
|
||||
let magnitude = delta.abs() / baseline;
|
||||
|
||||
if delta < 0.0 && magnitude >= self.threshold_integration {
|
||||
// Integration: mincut decreased -> networks merging.
|
||||
let end_time =
|
||||
find_recovery_time_in_series(mincut_series, i, v_prev, baseline);
|
||||
|
||||
events.push(CoherenceEvent {
|
||||
start_time: t_curr,
|
||||
end_time,
|
||||
event_type: CoherenceEventType::NetworkFormation,
|
||||
involved_regions: Vec::new(),
|
||||
peak_coherence: magnitude,
|
||||
});
|
||||
} else if delta > 0.0 && magnitude >= self.threshold_segregation {
|
||||
// Segregation: mincut increased -> networks separating.
|
||||
let end_time =
|
||||
find_recovery_time_in_series(mincut_series, i, v_prev, baseline);
|
||||
|
||||
events.push(CoherenceEvent {
|
||||
start_time: t_curr,
|
||||
end_time,
|
||||
event_type: CoherenceEventType::NetworkDissolution,
|
||||
involved_regions: Vec::new(),
|
||||
peak_coherence: magnitude,
|
||||
});
|
||||
}
|
||||
|
||||
// Check for merger/split patterns (opposing transitions close together).
|
||||
if i + 1 < mincut_series.len() {
|
||||
let (t_next, v_next) = mincut_series[i + 1];
|
||||
let dt = t_next - t_curr;
|
||||
let delta_next = v_next - v_curr;
|
||||
|
||||
if dt < 2.0 && delta_next.abs() > change_threshold {
|
||||
if delta < 0.0 && delta_next > 0.0 {
|
||||
events.push(CoherenceEvent {
|
||||
start_time: t_curr,
|
||||
end_time: t_next,
|
||||
event_type: CoherenceEventType::NetworkSplit,
|
||||
involved_regions: Vec::new(),
|
||||
peak_coherence: magnitude.max(delta_next.abs() / baseline),
|
||||
});
|
||||
i += 1;
|
||||
} else if delta > 0.0 && delta_next < 0.0 {
|
||||
events.push(CoherenceEvent {
|
||||
start_time: t_curr,
|
||||
end_time: t_next,
|
||||
event_type: CoherenceEventType::NetworkMerger,
|
||||
involved_regions: Vec::new(),
|
||||
peak_coherence: magnitude.max(delta_next.abs() / baseline),
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
/// Detect coherence events by processing a brain graph sequence.
|
||||
///
|
||||
/// Updates the internal tracker with each graph and then analyzes the
|
||||
/// resulting mincut time series.
|
||||
pub fn detect_coherence_events(
|
||||
&mut self,
|
||||
sequence: &ruv_neural_core::graph::BrainGraphSequence,
|
||||
) -> ruv_neural_core::Result<Vec<CoherenceEvent>> {
|
||||
for graph in &sequence.graphs {
|
||||
self.tracker.update(graph)?;
|
||||
}
|
||||
|
||||
let timeseries = self.tracker.mincut_timeseries();
|
||||
Ok(self.detect_from_timeseries(×eries))
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the time when the mincut recovers to near the original value.
|
||||
fn find_recovery_time_in_series(
|
||||
series: &[(f64, f64)],
|
||||
start_idx: usize,
|
||||
original_value: f64,
|
||||
baseline: f64,
|
||||
) -> f64 {
|
||||
let recovery_threshold = 0.1 * baseline;
|
||||
|
||||
for &(t, v) in series.iter().skip(start_idx + 1) {
|
||||
if (v - original_value).abs() < recovery_threshold {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
// No recovery found; return last timestamp.
|
||||
series.last().map_or(series[start_idx].0, |&(t, _)| t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_coherence_event_types_serialization() {
|
||||
for event_type in [
|
||||
CoherenceEventType::NetworkFormation,
|
||||
CoherenceEventType::NetworkDissolution,
|
||||
CoherenceEventType::NetworkMerger,
|
||||
CoherenceEventType::NetworkSplit,
|
||||
] {
|
||||
let json = serde_json::to_string(&event_type).unwrap();
|
||||
let back: CoherenceEventType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, event_type);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_coherence_event_serialization() {
|
||||
let event = CoherenceEvent {
|
||||
start_time: 0.0,
|
||||
end_time: 1.0,
|
||||
event_type: CoherenceEventType::NetworkFormation,
|
||||
involved_regions: vec![0, 1, 2],
|
||||
peak_coherence: 0.8,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let back: CoherenceEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.event_type, CoherenceEventType::NetworkFormation);
|
||||
assert!((back.peak_coherence - 0.8).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_no_events_for_constant_series() {
|
||||
let detector = CoherenceDetector::new(0.3, 0.3);
|
||||
let series: Vec<(f64, f64)> = (0..10)
|
||||
.map(|i| (i as f64, 5.0))
|
||||
.collect();
|
||||
let events = detector.detect_from_timeseries(&series);
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_formation_event() {
|
||||
let mut detector = CoherenceDetector::new(0.2, 0.2);
|
||||
detector.set_baseline(5.0);
|
||||
|
||||
// Constant, then a sudden drop in mincut (integration).
|
||||
let series = vec![
|
||||
(0.0, 5.0),
|
||||
(1.0, 5.0),
|
||||
(2.0, 5.0),
|
||||
(3.0, 1.0), // big drop
|
||||
(4.0, 1.0),
|
||||
(5.0, 5.0), // recovery
|
||||
];
|
||||
|
||||
let events = detector.detect_from_timeseries(&series);
|
||||
assert!(
|
||||
!events.is_empty(),
|
||||
"Should detect a formation event from a large mincut decrease"
|
||||
);
|
||||
// First event should be a formation (integration).
|
||||
assert_eq!(events[0].event_type, CoherenceEventType::NetworkFormation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_dissolution_event() {
|
||||
let mut detector = CoherenceDetector::new(0.2, 0.2);
|
||||
detector.set_baseline(5.0);
|
||||
|
||||
// Sudden increase in mincut (segregation).
|
||||
let series = vec![
|
||||
(0.0, 5.0),
|
||||
(1.0, 5.0),
|
||||
(2.0, 15.0), // big jump
|
||||
(3.0, 15.0),
|
||||
];
|
||||
|
||||
let events = detector.detect_from_timeseries(&series);
|
||||
let dissolution_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.event_type == CoherenceEventType::NetworkDissolution)
|
||||
.collect();
|
||||
assert!(
|
||||
!dissolution_events.is_empty(),
|
||||
"Should detect a dissolution event from a large mincut increase"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detector_empty_series() {
|
||||
let detector = CoherenceDetector::new(0.3, 0.3);
|
||||
let events = detector.detect_from_timeseries(&[]);
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detector_single_point() {
|
||||
let detector = CoherenceDetector::new(0.3, 0.3);
|
||||
let events = detector.detect_from_timeseries(&[(0.0, 5.0)]);
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
//! Dynamic minimum cut tracking over temporal brain graph sequences.
|
||||
//!
|
||||
//! Tracks the evolution of minimum cut values over time, detects significant
|
||||
//! topology transitions (integration vs. segregation events), and computes
|
||||
//! derived metrics such as rate of change, integration index, and partition
|
||||
//! stability.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use ruv_neural_core::graph::BrainGraph;
|
||||
use ruv_neural_core::topology::MincutResult;
|
||||
use ruv_neural_core::Result;
|
||||
|
||||
use crate::stoer_wagner::stoer_wagner_mincut;
|
||||
|
||||
/// Tracks minimum cut evolution over a sequence of brain graphs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DynamicMincutTracker {
|
||||
/// History of mincut results.
|
||||
history: Vec<MincutResult>,
|
||||
/// Timestamps corresponding to each result.
|
||||
timestamps: Vec<f64>,
|
||||
/// Baseline mincut from resting state.
|
||||
baseline: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for DynamicMincutTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DynamicMincutTracker {
|
||||
/// Create a new empty tracker.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
history: Vec::new(),
|
||||
timestamps: Vec::new(),
|
||||
baseline: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the baseline mincut value (typically from a resting-state graph).
|
||||
pub fn set_baseline(&mut self, baseline: f64) {
|
||||
self.baseline = Some(baseline);
|
||||
}
|
||||
|
||||
/// Get the current baseline, if set.
|
||||
pub fn baseline(&self) -> Option<f64> {
|
||||
self.baseline
|
||||
}
|
||||
|
||||
/// Process a new brain graph, compute its mincut, and add it to the history.
|
||||
///
|
||||
/// Returns the mincut result for this graph.
|
||||
pub fn update(&mut self, graph: &BrainGraph) -> Result<MincutResult> {
|
||||
let result = stoer_wagner_mincut(graph)?;
|
||||
self.timestamps.push(graph.timestamp);
|
||||
self.history.push(result.clone());
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Number of time points tracked so far.
|
||||
pub fn len(&self) -> usize {
|
||||
self.history.len()
|
||||
}
|
||||
|
||||
/// Returns true if no time points have been tracked.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.history.is_empty()
|
||||
}
|
||||
|
||||
/// Get the mincut time series as (timestamp, cut_value) pairs.
|
||||
pub fn mincut_timeseries(&self) -> Vec<(f64, f64)> {
|
||||
self.timestamps
|
||||
.iter()
|
||||
.zip(self.history.iter())
|
||||
.map(|(&t, r)| (t, r.cut_value))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the full history of mincut results.
|
||||
pub fn history(&self) -> &[MincutResult] {
|
||||
&self.history
|
||||
}
|
||||
|
||||
/// Detect significant topology transitions.
|
||||
///
|
||||
/// A transition is detected where the mincut changes by more than
|
||||
/// `threshold * baseline` between consecutive time points. If no baseline
|
||||
/// is set, the mean mincut is used as the baseline.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `threshold` - Fraction of the baseline that constitutes a significant
|
||||
/// change (e.g., 0.2 means a 20% change).
|
||||
pub fn detect_transitions(&self, threshold: f64) -> Vec<TopologyTransition> {
|
||||
if self.history.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let baseline = self.baseline.unwrap_or_else(|| {
|
||||
let sum: f64 = self.history.iter().map(|r| r.cut_value).sum();
|
||||
sum / self.history.len() as f64
|
||||
});
|
||||
|
||||
if baseline <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let change_threshold = threshold * baseline;
|
||||
let mut transitions = Vec::new();
|
||||
|
||||
for i in 1..self.history.len() {
|
||||
let before = self.history[i - 1].cut_value;
|
||||
let after = self.history[i].cut_value;
|
||||
let delta = after - before;
|
||||
|
||||
if delta.abs() > change_threshold {
|
||||
let direction = if delta < 0.0 {
|
||||
TransitionDirection::Integration
|
||||
} else {
|
||||
TransitionDirection::Segregation
|
||||
};
|
||||
|
||||
transitions.push(TopologyTransition {
|
||||
timestamp: self.timestamps[i],
|
||||
mincut_before: before,
|
||||
mincut_after: after,
|
||||
direction,
|
||||
magnitude: delta.abs() / baseline,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
transitions
|
||||
}
|
||||
|
||||
/// Rate of topology change (finite difference of mincut values).
|
||||
///
|
||||
/// Returns (timestamp, rate) pairs where the rate is the change in mincut
|
||||
/// per unit time.
|
||||
pub fn rate_of_change(&self) -> Vec<(f64, f64)> {
|
||||
if self.history.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut rates = Vec::new();
|
||||
for i in 1..self.history.len() {
|
||||
let dt = self.timestamps[i] - self.timestamps[i - 1];
|
||||
if dt > 0.0 {
|
||||
let dcut = self.history[i].cut_value - self.history[i - 1].cut_value;
|
||||
let midpoint = (self.timestamps[i] + self.timestamps[i - 1]) / 2.0;
|
||||
rates.push((midpoint, dcut / dt));
|
||||
}
|
||||
}
|
||||
rates
|
||||
}
|
||||
|
||||
/// Integration-segregation balance index over time.
|
||||
///
|
||||
/// The integration index is defined as:
|
||||
///
|
||||
/// ```text
|
||||
/// I(t) = 1.0 - mincut(t) / max_mincut
|
||||
/// ```
|
||||
///
|
||||
/// High values (close to 1) indicate integrated states; low values indicate
|
||||
/// segregated states.
|
||||
pub fn integration_index(&self) -> Vec<(f64, f64)> {
|
||||
if self.history.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let max_cut = self
|
||||
.history
|
||||
.iter()
|
||||
.map(|r| r.cut_value)
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
|
||||
if max_cut <= 0.0 {
|
||||
return self
|
||||
.timestamps
|
||||
.iter()
|
||||
.map(|&t| (t, 1.0))
|
||||
.collect();
|
||||
}
|
||||
|
||||
self.timestamps
|
||||
.iter()
|
||||
.zip(self.history.iter())
|
||||
.map(|(&t, r)| (t, 1.0 - r.cut_value / max_cut))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Partition stability: for how many consecutive time points does the same
|
||||
/// partition topology persist?
|
||||
///
|
||||
/// Returns (timestamp, stability) pairs where stability is the Jaccard
|
||||
/// similarity between the current partition_a and the previous one.
|
||||
pub fn partition_stability(&self) -> Vec<(f64, f64)> {
|
||||
if self.history.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut stability = vec![(self.timestamps[0], 1.0)];
|
||||
|
||||
for i in 1..self.history.len() {
|
||||
let prev_a: std::collections::HashSet<usize> =
|
||||
self.history[i - 1].partition_a.iter().copied().collect();
|
||||
let curr_a: std::collections::HashSet<usize> =
|
||||
self.history[i].partition_a.iter().copied().collect();
|
||||
|
||||
let jaccard = jaccard_similarity(&prev_a, &curr_a);
|
||||
// Take the max of comparing A-to-A and A-to-B (since partitions
|
||||
// can be labelled either way).
|
||||
let curr_b: std::collections::HashSet<usize> =
|
||||
self.history[i].partition_b.iter().copied().collect();
|
||||
let jaccard_flipped = jaccard_similarity(&prev_a, &curr_b);
|
||||
|
||||
stability.push((self.timestamps[i], jaccard.max(jaccard_flipped)));
|
||||
}
|
||||
|
||||
stability
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the Jaccard similarity between two sets.
|
||||
fn jaccard_similarity(a: &std::collections::HashSet<usize>, b: &std::collections::HashSet<usize>) -> f64 {
|
||||
let intersection = a.intersection(b).count() as f64;
|
||||
let union = a.union(b).count() as f64;
|
||||
if union == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
intersection / union
|
||||
}
|
||||
}
|
||||
|
||||
/// A significant topology transition detected in the mincut time series.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TopologyTransition {
|
||||
/// Timestamp at which the transition was detected.
|
||||
pub timestamp: f64,
|
||||
/// Mincut value immediately before the transition.
|
||||
pub mincut_before: f64,
|
||||
/// Mincut value immediately after the transition.
|
||||
pub mincut_after: f64,
|
||||
/// Direction of the transition.
|
||||
pub direction: TransitionDirection,
|
||||
/// Magnitude of the transition relative to baseline.
|
||||
pub magnitude: f64,
|
||||
}
|
||||
|
||||
/// Direction of a topology transition.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransitionDirection {
|
||||
/// Mincut decreased: networks are merging (becoming more integrated).
|
||||
Integration,
|
||||
/// Mincut increased: networks are separating (becoming more segregated).
|
||||
Segregation,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::graph::BrainEdge;
|
||||
use ruv_neural_core::signal::FrequencyBand;
|
||||
|
||||
fn make_edge(source: usize, target: usize, weight: f64) -> BrainEdge {
|
||||
BrainEdge {
|
||||
source,
|
||||
target,
|
||||
weight,
|
||||
metric: ruv_neural_core::graph::ConnectivityMetric::Coherence,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_graph(timestamp: f64, bridge_weight: f64) -> BrainGraph {
|
||||
BrainGraph {
|
||||
num_nodes: 4,
|
||||
edges: vec![
|
||||
make_edge(0, 1, 5.0),
|
||||
make_edge(2, 3, 5.0),
|
||||
make_edge(1, 2, bridge_weight),
|
||||
],
|
||||
timestamp,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(4),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tracker_basic() {
|
||||
let mut tracker = DynamicMincutTracker::new();
|
||||
assert!(tracker.is_empty());
|
||||
|
||||
let g1 = make_graph(0.0, 1.0);
|
||||
let r1 = tracker.update(&g1).unwrap();
|
||||
assert_eq!(tracker.len(), 1);
|
||||
assert!(r1.cut_value > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tracker_timeseries() {
|
||||
let mut tracker = DynamicMincutTracker::new();
|
||||
for i in 0..5 {
|
||||
let bridge = (i as f64 + 1.0) * 0.5;
|
||||
let g = make_graph(i as f64, bridge);
|
||||
tracker.update(&g).unwrap();
|
||||
}
|
||||
|
||||
let ts = tracker.mincut_timeseries();
|
||||
assert_eq!(ts.len(), 5);
|
||||
// Timestamps should be 0, 1, 2, 3, 4.
|
||||
for (i, (t, _)) in ts.iter().enumerate() {
|
||||
assert!((t - i as f64).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_transitions() {
|
||||
let mut tracker = DynamicMincutTracker::new();
|
||||
// Create a sequence where bridge weight jumps suddenly.
|
||||
let weights = [1.0, 1.0, 1.0, 10.0, 10.0, 1.0];
|
||||
for (i, &w) in weights.iter().enumerate() {
|
||||
let g = make_graph(i as f64, w);
|
||||
tracker.update(&g).unwrap();
|
||||
}
|
||||
|
||||
tracker.set_baseline(1.0);
|
||||
let transitions = tracker.detect_transitions(0.5);
|
||||
// Should detect at least the jump at t=3 and t=5.
|
||||
assert!(
|
||||
!transitions.is_empty(),
|
||||
"Should detect transitions for large mincut changes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_of_change() {
|
||||
let mut tracker = DynamicMincutTracker::new();
|
||||
for i in 0..4 {
|
||||
let g = make_graph(i as f64, (i as f64 + 1.0) * 2.0);
|
||||
tracker.update(&g).unwrap();
|
||||
}
|
||||
|
||||
let rates = tracker.rate_of_change();
|
||||
assert_eq!(rates.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_integration_index() {
|
||||
let mut tracker = DynamicMincutTracker::new();
|
||||
for i in 0..3 {
|
||||
let g = make_graph(i as f64, i as f64 + 1.0);
|
||||
tracker.update(&g).unwrap();
|
||||
}
|
||||
|
||||
let idx = tracker.integration_index();
|
||||
assert_eq!(idx.len(), 3);
|
||||
// All values should be in [0, 1].
|
||||
for (_, val) in &idx {
|
||||
assert!(*val >= -1e-9 && *val <= 1.0 + 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partition_stability() {
|
||||
let mut tracker = DynamicMincutTracker::new();
|
||||
// Same graph repeated should give stability = 1.0.
|
||||
for i in 0..3 {
|
||||
let g = make_graph(i as f64, 0.5);
|
||||
tracker.update(&g).unwrap();
|
||||
}
|
||||
|
||||
let stability = tracker.partition_stability();
|
||||
assert_eq!(stability.len(), 3);
|
||||
// First one is always 1.0.
|
||||
assert!((stability[0].1 - 1.0).abs() < 1e-9);
|
||||
// Same graph should yield high stability.
|
||||
for (_, s) in &stability {
|
||||
assert!(*s >= 0.5, "Same graph should have high stability, got {}", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_tracker() {
|
||||
let tracker = DynamicMincutTracker::default();
|
||||
assert!(tracker.is_empty());
|
||||
assert!(tracker.baseline().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transition_direction() {
|
||||
let mut tracker = DynamicMincutTracker::new();
|
||||
// Low bridge -> high bridge (segregation)
|
||||
tracker.update(&make_graph(0.0, 0.1)).unwrap();
|
||||
tracker.update(&make_graph(1.0, 10.0)).unwrap();
|
||||
|
||||
tracker.set_baseline(0.1);
|
||||
let transitions = tracker.detect_transitions(0.2);
|
||||
if !transitions.is_empty() {
|
||||
// The bridge weight went up, but the mincut depends on the full graph.
|
||||
// Just verify we get a valid transition.
|
||||
assert!(transitions[0].magnitude > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
//! # rUv Neural Mincut
|
||||
//!
|
||||
//! Dynamic minimum cut analysis for brain network topology detection.
|
||||
//!
|
||||
//! This crate provides algorithms for computing minimum cuts on brain connectivity
|
||||
//! graphs, tracking topology changes over time, and detecting neural coherence events.
|
||||
//!
|
||||
//! ## Algorithms
|
||||
//!
|
||||
//! - **Stoer-Wagner**: Global minimum cut in O(V^3) time
|
||||
//! - **Normalized cut** (Shi-Malik): Spectral bisection via the Fiedler vector
|
||||
//! - **Multiway cut**: Recursive normalized cut for k-module detection
|
||||
//! - **Spectral cut**: Cheeger constant, spectral bisection, Cheeger bounds
|
||||
//!
|
||||
//! ## Dynamic Analysis
|
||||
//!
|
||||
//! - **DynamicMincutTracker**: Track mincut evolution over temporal graph sequences
|
||||
//! - **CoherenceDetector**: Detect network formation, dissolution, merger, and split events
|
||||
|
||||
pub mod benchmark;
|
||||
pub mod coherence;
|
||||
pub mod dynamic;
|
||||
pub mod multiway;
|
||||
pub mod normalized;
|
||||
pub mod spectral_cut;
|
||||
pub mod stoer_wagner;
|
||||
|
||||
// Re-export primary public API
|
||||
pub use coherence::{CoherenceDetector, CoherenceEvent, CoherenceEventType};
|
||||
pub use dynamic::{DynamicMincutTracker, TopologyTransition, TransitionDirection};
|
||||
pub use multiway::{detect_modules, multiway_cut};
|
||||
pub use normalized::normalized_cut;
|
||||
pub use spectral_cut::{cheeger_bound, cheeger_constant, spectral_bisection};
|
||||
pub use stoer_wagner::stoer_wagner_mincut;
|
||||
|
||||
// Re-export core types used in our public API
|
||||
pub use ruv_neural_core::graph::{BrainGraph, BrainGraphSequence};
|
||||
pub use ruv_neural_core::topology::{MincutResult, MultiPartition};
|
||||
pub use ruv_neural_core::{Result, RuvNeuralError};
|
||||
@@ -1,370 +0,0 @@
|
||||
//! Multi-way graph partitioning using recursive normalized cut.
|
||||
//!
|
||||
//! Splits a brain connectivity graph into k modules by recursively applying
|
||||
//! normalized cut. Includes automatic module detection via modularity
|
||||
//! optimization.
|
||||
|
||||
use ruv_neural_core::graph::{BrainEdge, BrainGraph};
|
||||
use ruv_neural_core::topology::MultiPartition;
|
||||
use ruv_neural_core::{Result, RuvNeuralError};
|
||||
|
||||
use crate::normalized::normalized_cut;
|
||||
|
||||
/// K-way graph partitioning using recursive normalized cut.
|
||||
///
|
||||
/// Recursively bisects the graph to produce `k` partitions. At each step the
|
||||
/// partition with the highest internal connectivity is chosen for the next
|
||||
/// split. The process stops when `k` partitions are produced or when further
|
||||
/// splitting does not improve modularity.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if `k < 2` or if the graph has fewer than `k` nodes.
|
||||
pub fn multiway_cut(graph: &BrainGraph, k: usize) -> Result<MultiPartition> {
|
||||
if k < 2 {
|
||||
return Err(RuvNeuralError::Mincut(
|
||||
"multiway_cut requires k >= 2".into(),
|
||||
));
|
||||
}
|
||||
if graph.num_nodes < k {
|
||||
return Err(RuvNeuralError::Mincut(format!(
|
||||
"Cannot partition {} nodes into {} groups",
|
||||
graph.num_nodes, k
|
||||
)));
|
||||
}
|
||||
|
||||
// Start with a single partition containing all nodes.
|
||||
let mut partitions: Vec<Vec<usize>> = vec![(0..graph.num_nodes).collect()];
|
||||
|
||||
while partitions.len() < k {
|
||||
// Find the largest partition to split next.
|
||||
let (split_idx, _) = partitions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, p)| p.len())
|
||||
.unwrap();
|
||||
|
||||
let to_split = &partitions[split_idx];
|
||||
if to_split.len() < 2 {
|
||||
// Cannot split a singleton; stop early.
|
||||
break;
|
||||
}
|
||||
|
||||
// Build a subgraph from this partition.
|
||||
let subgraph = build_subgraph(graph, to_split);
|
||||
|
||||
// Apply normalized cut on the subgraph.
|
||||
let sub_result = normalized_cut(&subgraph)?;
|
||||
|
||||
// Map subgraph indices back to original indices.
|
||||
let part_a: Vec<usize> = sub_result
|
||||
.partition_a
|
||||
.iter()
|
||||
.map(|&i| to_split[i])
|
||||
.collect();
|
||||
let part_b: Vec<usize> = sub_result
|
||||
.partition_b
|
||||
.iter()
|
||||
.map(|&i| to_split[i])
|
||||
.collect();
|
||||
|
||||
// Replace the split partition with the two new ones.
|
||||
partitions.remove(split_idx);
|
||||
partitions.push(part_a);
|
||||
partitions.push(part_b);
|
||||
}
|
||||
|
||||
// Sort each partition for determinism.
|
||||
for p in &mut partitions {
|
||||
p.sort_unstable();
|
||||
}
|
||||
partitions.sort_by_key(|p| p[0]);
|
||||
|
||||
let modularity = compute_modularity(graph, &partitions);
|
||||
let cut_value = compute_total_cut(graph, &partitions);
|
||||
|
||||
Ok(MultiPartition {
|
||||
partitions,
|
||||
cut_value,
|
||||
modularity,
|
||||
})
|
||||
}
|
||||
|
||||
/// Automatic module detection: find the optimal number of partitions k that
|
||||
/// maximizes Newman-Girvan modularity.
|
||||
///
|
||||
/// Tries k = 2, 3, ..., max_k (where max_k = sqrt(num_nodes)) and returns the
|
||||
/// partitioning with the highest modularity.
|
||||
pub fn detect_modules(graph: &BrainGraph) -> Result<MultiPartition> {
|
||||
let n = graph.num_nodes;
|
||||
if n < 2 {
|
||||
return Err(RuvNeuralError::Mincut(
|
||||
"detect_modules requires at least 2 nodes".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let max_k = ((n as f64).sqrt().ceil() as usize).max(2).min(n);
|
||||
|
||||
let mut best_partition: Option<MultiPartition> = None;
|
||||
let mut best_modularity = f64::NEG_INFINITY;
|
||||
|
||||
for k in 2..=max_k {
|
||||
if k > n {
|
||||
break;
|
||||
}
|
||||
match multiway_cut(graph, k) {
|
||||
Ok(partition) => {
|
||||
if partition.modularity > best_modularity {
|
||||
best_modularity = partition.modularity;
|
||||
best_partition = Some(partition);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
best_partition.ok_or_else(|| {
|
||||
RuvNeuralError::Mincut("Could not find any valid partitioning".into())
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a subgraph from a subset of nodes.
|
||||
///
|
||||
/// The returned graph has nodes indexed 0..subset.len(), with edges re-mapped
|
||||
/// from the original graph.
|
||||
fn build_subgraph(graph: &BrainGraph, subset: &[usize]) -> BrainGraph {
|
||||
// Map from original index to subgraph index.
|
||||
let mut index_map = std::collections::HashMap::new();
|
||||
for (new_idx, &orig_idx) in subset.iter().enumerate() {
|
||||
index_map.insert(orig_idx, new_idx);
|
||||
}
|
||||
|
||||
let edges: Vec<BrainEdge> = graph
|
||||
.edges
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let s = index_map.get(&e.source)?;
|
||||
let t = index_map.get(&e.target)?;
|
||||
Some(BrainEdge {
|
||||
source: *s,
|
||||
target: *t,
|
||||
weight: e.weight,
|
||||
metric: e.metric,
|
||||
frequency_band: e.frequency_band,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
BrainGraph {
|
||||
num_nodes: subset.len(),
|
||||
edges,
|
||||
timestamp: graph.timestamp,
|
||||
window_duration_s: graph.window_duration_s,
|
||||
atlas: graph.atlas,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute Newman-Girvan modularity for a given partitioning.
|
||||
///
|
||||
/// Q = (1 / 2m) * sum_{ij} [A_{ij} - k_i * k_j / (2m)] * delta(c_i, c_j)
|
||||
pub fn compute_modularity(graph: &BrainGraph, partitions: &[Vec<usize>]) -> f64 {
|
||||
let adj = graph.adjacency_matrix();
|
||||
let n = graph.num_nodes;
|
||||
let m: f64 = graph.edges.iter().map(|e| e.weight).sum::<f64>();
|
||||
|
||||
if m <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let two_m = 2.0 * m;
|
||||
|
||||
// Assign each node to its community.
|
||||
let mut community = vec![0usize; n];
|
||||
for (c, partition) in partitions.iter().enumerate() {
|
||||
for &node in partition {
|
||||
if node < n {
|
||||
community[node] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Degrees.
|
||||
let degrees: Vec<f64> = (0..n).map(|i| adj[i].iter().sum::<f64>()).collect();
|
||||
|
||||
let mut q = 0.0;
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if community[i] == community[j] {
|
||||
q += adj[i][j] - degrees[i] * degrees[j] / two_m;
|
||||
}
|
||||
}
|
||||
}
|
||||
q / two_m
|
||||
}
|
||||
|
||||
/// Compute the total weight of edges that cross partition boundaries.
|
||||
fn compute_total_cut(graph: &BrainGraph, partitions: &[Vec<usize>]) -> f64 {
|
||||
let n = graph.num_nodes;
|
||||
let mut community = vec![0usize; n];
|
||||
for (c, partition) in partitions.iter().enumerate() {
|
||||
for &node in partition {
|
||||
if node < n {
|
||||
community[node] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
graph
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
e.source < n
|
||||
&& e.target < n
|
||||
&& community[e.source] != community[e.target]
|
||||
})
|
||||
.map(|e| e.weight)
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::graph::BrainEdge;
|
||||
use ruv_neural_core::signal::FrequencyBand;
|
||||
|
||||
fn make_edge(source: usize, target: usize, weight: f64) -> BrainEdge {
|
||||
BrainEdge {
|
||||
source,
|
||||
target,
|
||||
weight,
|
||||
metric: ruv_neural_core::graph::ConnectivityMetric::Coherence,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
}
|
||||
}
|
||||
|
||||
/// Multiway cut with k=2 should produce 2 partitions.
|
||||
#[test]
|
||||
fn test_multiway_k2() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 6,
|
||||
edges: vec![
|
||||
make_edge(0, 1, 5.0),
|
||||
make_edge(1, 2, 5.0),
|
||||
make_edge(0, 2, 5.0),
|
||||
make_edge(3, 4, 5.0),
|
||||
make_edge(4, 5, 5.0),
|
||||
make_edge(3, 5, 5.0),
|
||||
make_edge(2, 3, 0.1),
|
||||
],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(6),
|
||||
};
|
||||
|
||||
let result = multiway_cut(&graph, 2).unwrap();
|
||||
assert_eq!(result.num_partitions(), 2);
|
||||
assert_eq!(result.num_nodes(), 6);
|
||||
}
|
||||
|
||||
/// Multiway cut with k=3 on a graph with 3 obvious clusters.
|
||||
#[test]
|
||||
fn test_multiway_k3() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 9,
|
||||
edges: vec![
|
||||
// Cluster 1: {0, 1, 2}
|
||||
make_edge(0, 1, 5.0),
|
||||
make_edge(1, 2, 5.0),
|
||||
make_edge(0, 2, 5.0),
|
||||
// Cluster 2: {3, 4, 5}
|
||||
make_edge(3, 4, 5.0),
|
||||
make_edge(4, 5, 5.0),
|
||||
make_edge(3, 5, 5.0),
|
||||
// Cluster 3: {6, 7, 8}
|
||||
make_edge(6, 7, 5.0),
|
||||
make_edge(7, 8, 5.0),
|
||||
make_edge(6, 8, 5.0),
|
||||
// Weak bridges
|
||||
make_edge(2, 3, 0.1),
|
||||
make_edge(5, 6, 0.1),
|
||||
],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(9),
|
||||
};
|
||||
|
||||
let result = multiway_cut(&graph, 3).unwrap();
|
||||
assert_eq!(result.num_partitions(), 3);
|
||||
assert_eq!(result.num_nodes(), 9);
|
||||
assert!(result.modularity > 0.0, "Modularity should be positive for clustered graph");
|
||||
}
|
||||
|
||||
/// detect_modules should find a good partition automatically.
|
||||
#[test]
|
||||
fn test_detect_modules() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 6,
|
||||
edges: vec![
|
||||
make_edge(0, 1, 5.0),
|
||||
make_edge(1, 2, 5.0),
|
||||
make_edge(0, 2, 5.0),
|
||||
make_edge(3, 4, 5.0),
|
||||
make_edge(4, 5, 5.0),
|
||||
make_edge(3, 5, 5.0),
|
||||
make_edge(2, 3, 0.1),
|
||||
],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(6),
|
||||
};
|
||||
|
||||
let result = detect_modules(&graph).unwrap();
|
||||
assert!(result.num_partitions() >= 2);
|
||||
assert!(result.modularity > 0.0);
|
||||
}
|
||||
|
||||
/// k=1 should error.
|
||||
#[test]
|
||||
fn test_multiway_k1_error() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 4,
|
||||
edges: vec![make_edge(0, 1, 1.0)],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(4),
|
||||
};
|
||||
assert!(multiway_cut(&graph, 1).is_err());
|
||||
}
|
||||
|
||||
/// More partitions than nodes should error.
|
||||
#[test]
|
||||
fn test_multiway_too_many_partitions() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 3,
|
||||
edges: vec![make_edge(0, 1, 1.0), make_edge(1, 2, 1.0)],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(3),
|
||||
};
|
||||
assert!(multiway_cut(&graph, 5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modularity_positive_for_good_partition() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 4,
|
||||
edges: vec![
|
||||
make_edge(0, 1, 5.0),
|
||||
make_edge(2, 3, 5.0),
|
||||
make_edge(1, 2, 0.1),
|
||||
],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(4),
|
||||
};
|
||||
|
||||
let q = compute_modularity(&graph, &[vec![0, 1], vec![2, 3]]);
|
||||
assert!(q > 0.0, "Good partition should have positive modularity, got {}", q);
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
//! Normalized cut (Shi-Malik) for balanced graph partitioning.
|
||||
//!
|
||||
//! The normalized cut objective is:
|
||||
//!
|
||||
//! ```text
|
||||
//! Ncut(A, B) = cut(A,B) / vol(A) + cut(A,B) / vol(B)
|
||||
//! ```
|
||||
//!
|
||||
//! where vol(S) = sum of degrees of nodes in S.
|
||||
//!
|
||||
//! This is solved approximately via the spectral relaxation: find the Fiedler
|
||||
//! vector of the normalized Laplacian and threshold it.
|
||||
|
||||
use ruv_neural_core::graph::BrainGraph;
|
||||
use ruv_neural_core::topology::MincutResult;
|
||||
use ruv_neural_core::{Result, RuvNeuralError};
|
||||
|
||||
use crate::spectral_cut::fiedler_decomposition;
|
||||
|
||||
/// Compute the normalized minimum cut of a brain graph.
|
||||
///
|
||||
/// Uses the spectral method: compute the Fiedler vector of the graph Laplacian,
|
||||
/// then partition nodes by the sign of each component. The returned cut value
|
||||
/// is the normalized cut metric: `cut(A,B)/vol(A) + cut(A,B)/vol(B)`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the graph has fewer than 2 nodes.
|
||||
pub fn normalized_cut(graph: &BrainGraph) -> Result<MincutResult> {
|
||||
let n = graph.num_nodes;
|
||||
if n < 2 {
|
||||
return Err(RuvNeuralError::Mincut(
|
||||
"Normalized cut requires at least 2 nodes".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Get the Fiedler vector from the unnormalized Laplacian.
|
||||
// For normalized cut, ideally we would use the generalized eigenproblem
|
||||
// L*x = lambda*D*x. We approximate by using the Fiedler vector of L and
|
||||
// then trying multiple threshold sweeps to minimize Ncut.
|
||||
let (_fiedler_value, fiedler_vec) = fiedler_decomposition(graph)?;
|
||||
|
||||
// Sweep thresholds along the sorted Fiedler values to find the best Ncut.
|
||||
let adj = graph.adjacency_matrix();
|
||||
let degrees: Vec<f64> = (0..n)
|
||||
.map(|i| adj[i].iter().sum::<f64>())
|
||||
.collect();
|
||||
|
||||
// Sort node indices by Fiedler value.
|
||||
let mut sorted_indices: Vec<usize> = (0..n).collect();
|
||||
sorted_indices.sort_by(|&a, &b| {
|
||||
fiedler_vec[a]
|
||||
.partial_cmp(&fiedler_vec[b])
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
let mut best_ncut = f64::INFINITY;
|
||||
let mut best_split = 1usize; // number of nodes in partition A
|
||||
|
||||
// Track incremental cut and volumes.
|
||||
// Start with partition A = empty, B = all. Then move nodes from B to A.
|
||||
let total_vol: f64 = degrees.iter().sum();
|
||||
|
||||
let mut vol_a = 0.0;
|
||||
let mut in_a = vec![false; n];
|
||||
|
||||
// We also need the cross-cut, which we compute incrementally.
|
||||
// cut(A, B) = sum of weights between A and B.
|
||||
let mut cut_val = 0.0;
|
||||
|
||||
for split in 0..(n - 1) {
|
||||
let node = sorted_indices[split];
|
||||
in_a[node] = true;
|
||||
vol_a += degrees[node];
|
||||
|
||||
// Update cut: adding `node` to A means:
|
||||
// - edges from `node` to other A nodes decrease cut (they were in cut before)
|
||||
// - edges from `node` to B nodes increase cut
|
||||
for j in 0..n {
|
||||
if adj[node][j] > 0.0 {
|
||||
if in_a[j] && j != node {
|
||||
// j was already in A, so edge (node, j) was previously a cut edge
|
||||
// (from B to A). Now both are in A, so remove it from cut.
|
||||
cut_val -= adj[node][j];
|
||||
} else if !in_a[j] {
|
||||
// j is in B, so adding node to A creates a new cut edge.
|
||||
cut_val += adj[node][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let vol_b = total_vol - vol_a;
|
||||
if vol_a > 0.0 && vol_b > 0.0 {
|
||||
let ncut = cut_val / vol_a + cut_val / vol_b;
|
||||
if ncut < best_ncut {
|
||||
best_ncut = ncut;
|
||||
best_split = split + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build final partitions.
|
||||
let partition_a: Vec<usize> = sorted_indices[..best_split].to_vec();
|
||||
let partition_b: Vec<usize> = sorted_indices[best_split..].to_vec();
|
||||
|
||||
let partition_a_set: std::collections::HashSet<usize> =
|
||||
partition_a.iter().copied().collect();
|
||||
|
||||
// Compute the actual cut edges and value.
|
||||
let mut actual_cut = 0.0;
|
||||
let mut cut_edges = Vec::new();
|
||||
for edge in &graph.edges {
|
||||
let s_in_a = partition_a_set.contains(&edge.source);
|
||||
let t_in_a = partition_a_set.contains(&edge.target);
|
||||
if s_in_a != t_in_a {
|
||||
actual_cut += edge.weight;
|
||||
cut_edges.push((edge.source, edge.target, edge.weight));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute normalized cut value.
|
||||
let vol_a: f64 = partition_a.iter().map(|&i| degrees[i]).sum();
|
||||
let vol_b: f64 = partition_b.iter().map(|&i| degrees[i]).sum();
|
||||
let ncut_value = if vol_a > 0.0 && vol_b > 0.0 {
|
||||
actual_cut / vol_a + actual_cut / vol_b
|
||||
} else {
|
||||
actual_cut
|
||||
};
|
||||
|
||||
Ok(MincutResult {
|
||||
cut_value: ncut_value,
|
||||
partition_a,
|
||||
partition_b,
|
||||
cut_edges,
|
||||
timestamp: graph.timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute the volume of a node set: sum of weighted degrees.
|
||||
pub fn volume(graph: &BrainGraph, nodes: &[usize]) -> f64 {
|
||||
nodes.iter().map(|&i| graph.node_degree(i)).sum()
|
||||
}
|
||||
|
||||
/// Compute the raw cut weight between two node sets.
|
||||
pub fn cut_weight(graph: &BrainGraph, set_a: &[usize], set_b: &[usize]) -> f64 {
|
||||
let a_set: std::collections::HashSet<usize> = set_a.iter().copied().collect();
|
||||
let b_set: std::collections::HashSet<usize> = set_b.iter().copied().collect();
|
||||
|
||||
graph
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
(a_set.contains(&e.source) && b_set.contains(&e.target))
|
||||
|| (b_set.contains(&e.source) && a_set.contains(&e.target))
|
||||
})
|
||||
.map(|e| e.weight)
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::graph::BrainEdge;
|
||||
use ruv_neural_core::signal::FrequencyBand;
|
||||
|
||||
fn make_edge(source: usize, target: usize, weight: f64) -> BrainEdge {
|
||||
BrainEdge {
|
||||
source,
|
||||
target,
|
||||
weight,
|
||||
metric: ruv_neural_core::graph::ConnectivityMetric::Coherence,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized cut on a barbell graph should separate the two cliques.
|
||||
#[test]
|
||||
fn test_normalized_cut_barbell() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 6,
|
||||
edges: vec![
|
||||
// Clique 1: {0, 1, 2}
|
||||
make_edge(0, 1, 5.0),
|
||||
make_edge(1, 2, 5.0),
|
||||
make_edge(0, 2, 5.0),
|
||||
// Clique 2: {3, 4, 5}
|
||||
make_edge(3, 4, 5.0),
|
||||
make_edge(4, 5, 5.0),
|
||||
make_edge(3, 5, 5.0),
|
||||
// Weak bridge
|
||||
make_edge(2, 3, 0.1),
|
||||
],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(6),
|
||||
};
|
||||
|
||||
let result = normalized_cut(&graph).unwrap();
|
||||
// The partition should separate the two cliques.
|
||||
assert_eq!(result.partition_a.len() + result.partition_b.len(), 6);
|
||||
// Ncut value should be small since the bridge is weak.
|
||||
assert!(
|
||||
result.cut_value < 1.0,
|
||||
"Expected small Ncut for barbell, got {}",
|
||||
result.cut_value
|
||||
);
|
||||
}
|
||||
|
||||
/// Balanced normalized cut produces non-degenerate partitions.
|
||||
#[test]
|
||||
fn test_normalized_cut_balanced() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 4,
|
||||
edges: vec![
|
||||
make_edge(0, 1, 3.0),
|
||||
make_edge(2, 3, 3.0),
|
||||
make_edge(1, 2, 0.5),
|
||||
],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(4),
|
||||
};
|
||||
|
||||
let result = normalized_cut(&graph).unwrap();
|
||||
// Both partitions should be non-empty.
|
||||
assert!(!result.partition_a.is_empty());
|
||||
assert!(!result.partition_b.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volume_computation() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 3,
|
||||
edges: vec![
|
||||
make_edge(0, 1, 2.0),
|
||||
make_edge(1, 2, 3.0),
|
||||
],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(3),
|
||||
};
|
||||
|
||||
let vol = volume(&graph, &[0, 1]);
|
||||
// node 0 degree = 2, node 1 degree = 2 + 3 = 5
|
||||
assert!((vol - 7.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cut_weight_computation() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 4,
|
||||
edges: vec![
|
||||
make_edge(0, 1, 2.0),
|
||||
make_edge(1, 2, 3.0),
|
||||
make_edge(2, 3, 4.0),
|
||||
],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(4),
|
||||
};
|
||||
|
||||
let cw = cut_weight(&graph, &[0, 1], &[2, 3]);
|
||||
// Only edge 1-2 (weight 3) crosses the cut.
|
||||
assert!((cw - 3.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
//! Spectral methods for graph cuts.
|
||||
//!
|
||||
//! Provides the Cheeger constant (isoperimetric number), spectral bisection via
|
||||
//! the Fiedler vector, and the Cheeger inequality bounds relating the Fiedler
|
||||
//! value to the isoperimetric constant.
|
||||
|
||||
use ruv_neural_core::graph::BrainGraph;
|
||||
use ruv_neural_core::topology::MincutResult;
|
||||
use ruv_neural_core::{Result, RuvNeuralError};
|
||||
|
||||
/// Compute the Fiedler vector (eigenvector of the second-smallest eigenvalue)
|
||||
/// of the graph Laplacian using power iteration on the shifted Laplacian.
|
||||
///
|
||||
/// Returns `(fiedler_value, fiedler_vector)`.
|
||||
///
|
||||
/// We use inverse iteration on L to find the second-smallest eigenvalue.
|
||||
/// Since direct eigendecomposition without LAPACK is nontrivial, we use a
|
||||
/// simple approach: compute the Laplacian, then find its two smallest
|
||||
/// eigenvalues via shifted inverse iteration.
|
||||
pub fn fiedler_decomposition(graph: &BrainGraph) -> Result<(f64, Vec<f64>)> {
|
||||
let n = graph.num_nodes;
|
||||
if n < 2 {
|
||||
return Err(RuvNeuralError::Mincut(
|
||||
"Need at least 2 nodes for spectral analysis".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let adj = graph.adjacency_matrix();
|
||||
|
||||
// Build the Laplacian: L = D - A
|
||||
let mut laplacian = vec![vec![0.0; n]; n];
|
||||
for i in 0..n {
|
||||
let degree: f64 = adj[i].iter().sum();
|
||||
laplacian[i][i] = degree;
|
||||
for j in 0..n {
|
||||
laplacian[i][j] -= adj[i][j];
|
||||
}
|
||||
}
|
||||
|
||||
// For small graphs, use the QR-like approach via repeated deflated power
|
||||
// iteration. We want the second-smallest eigenvector.
|
||||
//
|
||||
// Step 1: The smallest eigenvalue of L is 0 with eigenvector = all-ones
|
||||
// (for connected graphs). We deflate that out.
|
||||
// Step 2: Run power iteration on (mu*I - L) to find the largest eigenvalue
|
||||
// of the deflated operator, which corresponds to the second-smallest
|
||||
// eigenvalue of L.
|
||||
|
||||
// Find the largest eigenvalue of L (for shifting) via power iteration.
|
||||
let lambda_max = largest_eigenvalue(&laplacian, n, 200);
|
||||
|
||||
// Shift: M = lambda_max * I - L.
|
||||
// The eigenvalues of M are (lambda_max - lambda_i).
|
||||
// The largest eigenvalue of M corresponds to the smallest of L (= 0).
|
||||
// The second largest of M corresponds to the second smallest of L (= fiedler).
|
||||
let shift = lambda_max + 0.01; // small buffer
|
||||
|
||||
// Power iteration on M, deflating out the constant eigenvector.
|
||||
let ones: Vec<f64> = vec![1.0 / (n as f64).sqrt(); n];
|
||||
|
||||
// Random-ish initial vector, orthogonal to ones.
|
||||
let mut v: Vec<f64> = (0..n).map(|i| (i as f64 + 1.0).sin()).collect();
|
||||
deflate(&mut v, &ones);
|
||||
normalize(&mut v);
|
||||
|
||||
let max_iter = 1000;
|
||||
let mut prev_eigenvalue = 0.0;
|
||||
|
||||
for _ in 0..max_iter {
|
||||
// w = M * v = (shift * I - L) * v = shift * v - L * v
|
||||
let mut w = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
let mut lv = 0.0;
|
||||
for j in 0..n {
|
||||
lv += laplacian[i][j] * v[j];
|
||||
}
|
||||
w[i] = shift * v[i] - lv;
|
||||
}
|
||||
|
||||
// Deflate out the constant eigenvector.
|
||||
deflate(&mut w, &ones);
|
||||
let eigenvalue = dot(&w, &v);
|
||||
normalize(&mut w);
|
||||
v = w;
|
||||
|
||||
if (eigenvalue - prev_eigenvalue).abs() < 1e-12 {
|
||||
break;
|
||||
}
|
||||
prev_eigenvalue = eigenvalue;
|
||||
}
|
||||
|
||||
// The Fiedler value = shift - prev_eigenvalue
|
||||
let fiedler_value = shift - prev_eigenvalue;
|
||||
|
||||
// Clamp small negative values from numerical noise.
|
||||
let fiedler_value = if fiedler_value < 0.0 && fiedler_value > -1e-9 {
|
||||
0.0
|
||||
} else {
|
||||
fiedler_value
|
||||
};
|
||||
|
||||
Ok((fiedler_value, v))
|
||||
}
|
||||
|
||||
/// Spectral bisection using the Fiedler vector.
|
||||
///
|
||||
/// Partitions the graph into two sets based on the sign of the Fiedler vector
|
||||
/// components. Nodes with positive components go to partition A, non-positive
|
||||
/// to partition B.
|
||||
pub fn spectral_bisection(graph: &BrainGraph) -> Result<MincutResult> {
|
||||
let (_fiedler_value, fiedler_vec) = fiedler_decomposition(graph)?;
|
||||
|
||||
let mut partition_a = Vec::new();
|
||||
let mut partition_b = Vec::new();
|
||||
|
||||
for (i, &val) in fiedler_vec.iter().enumerate() {
|
||||
if val > 0.0 {
|
||||
partition_a.push(i);
|
||||
} else {
|
||||
partition_b.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle degenerate case where everything ends up on one side.
|
||||
if partition_a.is_empty() || partition_b.is_empty() {
|
||||
// Put the first node in A, rest in B.
|
||||
partition_a = vec![0];
|
||||
partition_b = (1..graph.num_nodes).collect();
|
||||
}
|
||||
|
||||
let partition_a_set: std::collections::HashSet<usize> =
|
||||
partition_a.iter().copied().collect();
|
||||
|
||||
// Compute cut value.
|
||||
let mut cut_value = 0.0;
|
||||
let mut cut_edges = Vec::new();
|
||||
for edge in &graph.edges {
|
||||
let s_in_a = partition_a_set.contains(&edge.source);
|
||||
let t_in_a = partition_a_set.contains(&edge.target);
|
||||
if s_in_a != t_in_a {
|
||||
cut_value += edge.weight;
|
||||
cut_edges.push((edge.source, edge.target, edge.weight));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MincutResult {
|
||||
cut_value,
|
||||
partition_a,
|
||||
partition_b,
|
||||
cut_edges,
|
||||
timestamp: graph.timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute the Cheeger constant (isoperimetric number) of the graph.
|
||||
///
|
||||
/// h(G) = min over all subsets S with |S| <= |V|/2 of:
|
||||
/// cut(S, V\S) / vol(S)
|
||||
///
|
||||
/// For small graphs this is computed exactly by enumeration. For larger graphs
|
||||
/// we approximate using the spectral bisection.
|
||||
pub fn cheeger_constant(graph: &BrainGraph) -> Result<f64> {
|
||||
let n = graph.num_nodes;
|
||||
if n < 2 {
|
||||
return Err(RuvNeuralError::Mincut(
|
||||
"Need at least 2 nodes for Cheeger constant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// For small graphs (n <= 16), enumerate all subsets.
|
||||
if n <= 16 {
|
||||
let adj = graph.adjacency_matrix();
|
||||
let degrees: Vec<f64> = (0..n)
|
||||
.map(|i| adj[i].iter().sum::<f64>())
|
||||
.collect();
|
||||
|
||||
let mut best_h = f64::INFINITY;
|
||||
|
||||
// Enumerate non-empty subsets of size <= n/2.
|
||||
let total = 1u32 << n;
|
||||
for mask in 1..total {
|
||||
let size = mask.count_ones() as usize;
|
||||
if size > n / 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute vol(S) and cut(S, V\S).
|
||||
let mut vol_s = 0.0;
|
||||
let mut cut_s = 0.0;
|
||||
|
||||
for i in 0..n {
|
||||
if mask & (1 << i) != 0 {
|
||||
vol_s += degrees[i];
|
||||
for j in 0..n {
|
||||
if mask & (1 << j) == 0 {
|
||||
cut_s += adj[i][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if vol_s > 0.0 {
|
||||
let h = cut_s / vol_s;
|
||||
if h < best_h {
|
||||
best_h = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(best_h)
|
||||
} else {
|
||||
// Approximate via spectral: use the Fiedler vector partition.
|
||||
let result = spectral_bisection(graph)?;
|
||||
let adj = graph.adjacency_matrix();
|
||||
|
||||
// vol(partition_a)
|
||||
let vol_a: f64 = result
|
||||
.partition_a
|
||||
.iter()
|
||||
.map(|&i| adj[i].iter().sum::<f64>())
|
||||
.sum();
|
||||
let vol_b: f64 = result
|
||||
.partition_b
|
||||
.iter()
|
||||
.map(|&i| adj[i].iter().sum::<f64>())
|
||||
.sum();
|
||||
|
||||
let vol_min = vol_a.min(vol_b);
|
||||
if vol_min <= 0.0 {
|
||||
return Ok(0.0);
|
||||
}
|
||||
|
||||
Ok(result.cut_value / vol_min)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheeger inequality bounds relating the Fiedler value lambda_2 of the
|
||||
/// **unnormalized** Laplacian to the conductance h(G).
|
||||
///
|
||||
/// For the unnormalized Laplacian with maximum degree d_max:
|
||||
///
|
||||
/// ```text
|
||||
/// lambda_2 / (2 * d_max) <= h(G) <= sqrt(2 * lambda_2 / d_min)
|
||||
/// ```
|
||||
///
|
||||
/// For convenience when d_max is unknown, this function uses the normalized
|
||||
/// Laplacian relationship:
|
||||
///
|
||||
/// ```text
|
||||
/// lambda_2_norm / 2 <= h(G) <= sqrt(2 * lambda_2_norm)
|
||||
/// ```
|
||||
///
|
||||
/// The `fiedler_value` parameter should be from the **normalized** Laplacian
|
||||
/// (i.e., `unnormalized_lambda_2 / d_max` is a conservative approximation).
|
||||
///
|
||||
/// Returns `(lower_bound, upper_bound)`.
|
||||
pub fn cheeger_bound(fiedler_value: f64) -> (f64, f64) {
|
||||
let lower = fiedler_value / 2.0;
|
||||
let upper = (2.0 * fiedler_value).sqrt();
|
||||
(lower, upper)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Largest eigenvalue of a symmetric matrix via power iteration.
|
||||
///
|
||||
/// Terminates early when the eigenvalue change between iterations is below 1e-12.
|
||||
fn largest_eigenvalue(mat: &[Vec<f64>], n: usize, max_iter: usize) -> f64 {
|
||||
let mut v: Vec<f64> = (0..n).map(|i| (i as f64 + 0.5).cos()).collect();
|
||||
normalize(&mut v);
|
||||
|
||||
let mut eigenvalue = 0.0;
|
||||
for _ in 0..max_iter {
|
||||
let mut w = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
w[i] += mat[i][j] * v[j];
|
||||
}
|
||||
}
|
||||
let new_eigenvalue = dot(&w, &v);
|
||||
normalize(&mut w);
|
||||
v = w;
|
||||
|
||||
if (new_eigenvalue - eigenvalue).abs() < 1e-12 {
|
||||
eigenvalue = new_eigenvalue;
|
||||
break;
|
||||
}
|
||||
eigenvalue = new_eigenvalue;
|
||||
}
|
||||
eigenvalue
|
||||
}
|
||||
|
||||
/// Remove the component of `v` along `u` (assumed normalized).
|
||||
fn deflate(v: &mut [f64], u: &[f64]) {
|
||||
let proj = dot(v, u);
|
||||
for (vi, &ui) in v.iter_mut().zip(u.iter()) {
|
||||
*vi -= proj * ui;
|
||||
}
|
||||
}
|
||||
|
||||
fn dot(a: &[f64], b: &[f64]) -> f64 {
|
||||
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
fn normalize(v: &mut [f64]) {
|
||||
let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||||
if norm > 1e-15 {
|
||||
for x in v.iter_mut() {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::graph::BrainEdge;
|
||||
use ruv_neural_core::signal::FrequencyBand;
|
||||
|
||||
fn make_edge(source: usize, target: usize, weight: f64) -> BrainEdge {
|
||||
BrainEdge {
|
||||
source,
|
||||
target,
|
||||
weight,
|
||||
metric: ruv_neural_core::graph::ConnectivityMetric::Coherence,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
}
|
||||
}
|
||||
|
||||
/// Path graph P3 (0--1--2): Fiedler value should be 1.0.
|
||||
/// Laplacian eigenvalues of P3 with unit weights: 0, 1, 3.
|
||||
#[test]
|
||||
fn test_fiedler_path_p3() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 3,
|
||||
edges: vec![make_edge(0, 1, 1.0), make_edge(1, 2, 1.0)],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(3),
|
||||
};
|
||||
|
||||
let (fiedler_value, fiedler_vec) = fiedler_decomposition(&graph).unwrap();
|
||||
assert!(
|
||||
(fiedler_value - 1.0).abs() < 0.1,
|
||||
"Expected Fiedler value ~1.0 for P3, got {}",
|
||||
fiedler_value
|
||||
);
|
||||
// The Fiedler vector should have opposite signs at the endpoints.
|
||||
assert!(
|
||||
fiedler_vec[0] * fiedler_vec[2] < 0.0,
|
||||
"Fiedler vector endpoints should have opposite signs"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cheeger bounds using normalized Laplacian eigenvalue.
|
||||
///
|
||||
/// For the unnormalized Laplacian eigenvalue lambda_2 and max degree d_max,
|
||||
/// the normalized eigenvalue is lambda_2_norm = lambda_2 / d_max, and the
|
||||
/// Cheeger inequality states: lambda_2_norm / 2 <= h(G) <= sqrt(2 * lambda_2_norm).
|
||||
#[test]
|
||||
fn test_cheeger_bounds_hold() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 4,
|
||||
edges: vec![
|
||||
make_edge(0, 1, 1.0),
|
||||
make_edge(1, 2, 1.0),
|
||||
make_edge(2, 3, 1.0),
|
||||
make_edge(3, 0, 1.0),
|
||||
],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(4),
|
||||
};
|
||||
|
||||
let (fiedler_value, _) = fiedler_decomposition(&graph).unwrap();
|
||||
let h = cheeger_constant(&graph).unwrap();
|
||||
|
||||
// For conductance (cut/vol), the Cheeger inequality uses the normalized
|
||||
// Laplacian eigenvalue. For C4 with unit weights, d_max = 2, so:
|
||||
// lambda_2_norm = lambda_2 / d_max
|
||||
let adj = graph.adjacency_matrix();
|
||||
let d_max: f64 = (0..graph.num_nodes)
|
||||
.map(|i| adj[i].iter().sum::<f64>())
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let lambda_2_norm = fiedler_value / d_max;
|
||||
|
||||
let (lower, upper) = cheeger_bound(lambda_2_norm);
|
||||
|
||||
assert!(
|
||||
h >= lower - 1e-6,
|
||||
"Cheeger h={} should be >= lower bound {} (lambda2_norm={})",
|
||||
h,
|
||||
lower,
|
||||
lambda_2_norm
|
||||
);
|
||||
assert!(
|
||||
h <= upper + 1e-6,
|
||||
"Cheeger h={} should be <= upper bound {} (lambda2_norm={})",
|
||||
h,
|
||||
upper,
|
||||
lambda_2_norm
|
||||
);
|
||||
}
|
||||
|
||||
/// Spectral bisection of a barbell graph should split the two cliques.
|
||||
#[test]
|
||||
fn test_spectral_bisection_barbell() {
|
||||
// Two triangles connected by a single weak edge.
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 6,
|
||||
edges: vec![
|
||||
// Clique 1: {0, 1, 2}
|
||||
make_edge(0, 1, 5.0),
|
||||
make_edge(1, 2, 5.0),
|
||||
make_edge(0, 2, 5.0),
|
||||
// Clique 2: {3, 4, 5}
|
||||
make_edge(3, 4, 5.0),
|
||||
make_edge(4, 5, 5.0),
|
||||
make_edge(3, 5, 5.0),
|
||||
// Bridge
|
||||
make_edge(2, 3, 0.1),
|
||||
],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(6),
|
||||
};
|
||||
|
||||
let result = spectral_bisection(&graph).unwrap();
|
||||
// The cut should be small (close to 0.1).
|
||||
assert!(
|
||||
result.cut_value < 2.0,
|
||||
"Expected small cut for barbell, got {}",
|
||||
result.cut_value
|
||||
);
|
||||
// Each partition should have 3 nodes.
|
||||
assert_eq!(result.partition_a.len() + result.partition_b.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cheeger_bound_values() {
|
||||
let (lower, upper) = cheeger_bound(2.0);
|
||||
assert!((lower - 1.0).abs() < 1e-9);
|
||||
assert!((upper - 2.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
//! Stoer-Wagner algorithm for global minimum cut of an undirected weighted graph.
|
||||
//!
|
||||
//! Time complexity: O(V^3) using a simple adjacency matrix representation.
|
||||
//! The algorithm repeatedly performs "minimum cut phases" and merges vertices,
|
||||
//! tracking the lightest cut found across all phases.
|
||||
|
||||
use ruv_neural_core::graph::BrainGraph;
|
||||
use ruv_neural_core::topology::MincutResult;
|
||||
use ruv_neural_core::{Result, RuvNeuralError};
|
||||
|
||||
/// Compute the global minimum cut of an undirected weighted graph using the
|
||||
/// Stoer-Wagner algorithm.
|
||||
///
|
||||
/// Returns a [`MincutResult`] containing the cut value, the two partitions,
|
||||
/// and the edges crossing the cut.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the graph has fewer than two nodes.
|
||||
pub fn stoer_wagner_mincut(graph: &BrainGraph) -> Result<MincutResult> {
|
||||
let n = graph.num_nodes;
|
||||
if n < 2 {
|
||||
return Err(RuvNeuralError::Mincut(
|
||||
"Stoer-Wagner requires at least 2 nodes".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Build adjacency matrix
|
||||
let adj = graph.adjacency_matrix();
|
||||
|
||||
// Working copy of adjacency weights. We will merge rows/cols as the algorithm
|
||||
// contracts vertices.
|
||||
let mut w: Vec<Vec<f64>> = adj;
|
||||
|
||||
// `merged[i]` holds the list of original node indices that have been merged
|
||||
// into supernode i.
|
||||
let mut merged: Vec<Vec<usize>> = (0..n).map(|i| vec![i]).collect();
|
||||
|
||||
// Which supernodes are still active.
|
||||
let mut active: Vec<bool> = vec![true; n];
|
||||
|
||||
let mut best_cut_value = f64::INFINITY;
|
||||
let mut best_partition: Vec<usize> = Vec::new();
|
||||
|
||||
// We need n-1 phases.
|
||||
for _ in 0..(n - 1) {
|
||||
let phase_result = minimum_cut_phase(&w, &active, &merged)?;
|
||||
|
||||
if phase_result.cut_of_the_phase < best_cut_value {
|
||||
best_cut_value = phase_result.cut_of_the_phase;
|
||||
best_partition = phase_result.last_merged_group.clone();
|
||||
}
|
||||
|
||||
// Merge the last two vertices of this phase.
|
||||
merge_vertices(
|
||||
&mut w,
|
||||
&mut merged,
|
||||
&mut active,
|
||||
phase_result.second_last,
|
||||
phase_result.last,
|
||||
);
|
||||
}
|
||||
|
||||
// Build the two partitions.
|
||||
let mut partition_a: Vec<usize> = best_partition.clone();
|
||||
partition_a.sort_unstable();
|
||||
let partition_a_set: std::collections::HashSet<usize> =
|
||||
partition_a.iter().copied().collect();
|
||||
let mut partition_b: Vec<usize> = (0..n)
|
||||
.filter(|i| !partition_a_set.contains(i))
|
||||
.collect();
|
||||
partition_b.sort_unstable();
|
||||
|
||||
// Find cut edges.
|
||||
let cut_edges = find_cut_edges(graph, &partition_a_set);
|
||||
|
||||
Ok(MincutResult {
|
||||
cut_value: best_cut_value,
|
||||
partition_a,
|
||||
partition_b,
|
||||
cut_edges,
|
||||
timestamp: graph.timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Result of a single phase of the Stoer-Wagner algorithm.
|
||||
struct PhaseResult {
|
||||
/// The "cut of the phase" value — weight of edges from the last-added vertex
|
||||
/// to the rest of the merged set.
|
||||
cut_of_the_phase: f64,
|
||||
/// Index of the second-to-last vertex added in the ordering.
|
||||
second_last: usize,
|
||||
/// Index of the last vertex added in the ordering.
|
||||
last: usize,
|
||||
/// Original node indices that belong to the last-added supernode.
|
||||
last_merged_group: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Execute one phase of the Stoer-Wagner algorithm.
|
||||
///
|
||||
/// Greedily grows a set A by adding the most tightly connected vertex at each
|
||||
/// step. Returns the cut of the phase (the weight connecting the last vertex
|
||||
/// to the rest) and the indices needed for merging.
|
||||
fn minimum_cut_phase(
|
||||
w: &[Vec<f64>],
|
||||
active: &[bool],
|
||||
merged: &[Vec<usize>],
|
||||
) -> Result<PhaseResult> {
|
||||
let n = w.len();
|
||||
|
||||
// Find all active nodes.
|
||||
let active_nodes: Vec<usize> = (0..n).filter(|&i| active[i]).collect();
|
||||
if active_nodes.len() < 2 {
|
||||
return Err(RuvNeuralError::Mincut(
|
||||
"Not enough active nodes for a phase".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// key[v] = total weight of edges from v to the growing set A.
|
||||
let mut key: Vec<f64> = vec![0.0; n];
|
||||
let mut in_a: Vec<bool> = vec![false; n];
|
||||
|
||||
let mut last = active_nodes[0];
|
||||
let mut second_last = active_nodes[0];
|
||||
|
||||
// We add all active nodes one by one.
|
||||
for iteration in 0..active_nodes.len() {
|
||||
// On first iteration, pick an arbitrary active node as seed.
|
||||
if iteration == 0 {
|
||||
let seed = active_nodes[0];
|
||||
in_a[seed] = true;
|
||||
last = seed;
|
||||
// Update keys for neighbors of seed.
|
||||
for &v in &active_nodes {
|
||||
if !in_a[v] {
|
||||
key[v] += w[seed][v];
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the active node not in A with the maximum key.
|
||||
let mut best_node = usize::MAX;
|
||||
let mut best_key = -1.0;
|
||||
for &v in &active_nodes {
|
||||
if !in_a[v] && key[v] > best_key {
|
||||
best_key = key[v];
|
||||
best_node = v;
|
||||
}
|
||||
}
|
||||
|
||||
second_last = last;
|
||||
last = best_node;
|
||||
in_a[best_node] = true;
|
||||
|
||||
// Update keys.
|
||||
for &v in &active_nodes {
|
||||
if !in_a[v] {
|
||||
key[v] += w[best_node][v];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PhaseResult {
|
||||
cut_of_the_phase: key[last],
|
||||
second_last,
|
||||
last,
|
||||
last_merged_group: merged[last].clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Merge vertex `v` into vertex `u`, combining their adjacency weights and
|
||||
/// original node sets.
|
||||
fn merge_vertices(
|
||||
w: &mut [Vec<f64>],
|
||||
merged: &mut [Vec<usize>],
|
||||
active: &mut [bool],
|
||||
u: usize,
|
||||
v: usize,
|
||||
) {
|
||||
let n = w.len();
|
||||
|
||||
// Add v's weights into u.
|
||||
for i in 0..n {
|
||||
w[u][i] += w[v][i];
|
||||
w[i][u] += w[i][v];
|
||||
}
|
||||
// Zero out self-loop created by merge.
|
||||
w[u][u] = 0.0;
|
||||
|
||||
// Move v's original nodes into u's group.
|
||||
let v_nodes: Vec<usize> = merged[v].drain(..).collect();
|
||||
merged[u].extend(v_nodes);
|
||||
|
||||
// Deactivate v.
|
||||
active[v] = false;
|
||||
}
|
||||
|
||||
/// Find all edges crossing the partition boundary.
|
||||
fn find_cut_edges(
|
||||
graph: &BrainGraph,
|
||||
partition_a: &std::collections::HashSet<usize>,
|
||||
) -> Vec<(usize, usize, f64)> {
|
||||
graph
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
let s_in_a = partition_a.contains(&e.source);
|
||||
let t_in_a = partition_a.contains(&e.target);
|
||||
s_in_a != t_in_a
|
||||
})
|
||||
.map(|e| (e.source, e.target, e.weight))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::brain::Atlas;
|
||||
use ruv_neural_core::graph::BrainEdge;
|
||||
use ruv_neural_core::signal::FrequencyBand;
|
||||
|
||||
fn make_edge(source: usize, target: usize, weight: f64) -> BrainEdge {
|
||||
BrainEdge {
|
||||
source,
|
||||
target,
|
||||
weight,
|
||||
metric: ruv_neural_core::graph::ConnectivityMetric::Coherence,
|
||||
frequency_band: FrequencyBand::Alpha,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classic 4-node example:
|
||||
///
|
||||
/// ```text
|
||||
/// 0 --2-- 1
|
||||
/// | |
|
||||
/// 3 3
|
||||
/// | |
|
||||
/// 2 --2-- 3
|
||||
/// ```
|
||||
///
|
||||
/// Edge weights: 0-1:2, 0-2:3, 1-3:3, 2-3:2
|
||||
/// Expected minimum cut = 4 (partition {0,2} vs {1,3} or {0,1} vs {2,3}).
|
||||
#[test]
|
||||
fn test_stoer_wagner_known_graph() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 4,
|
||||
edges: vec![
|
||||
make_edge(0, 1, 2.0),
|
||||
make_edge(0, 2, 3.0),
|
||||
make_edge(1, 3, 3.0),
|
||||
make_edge(2, 3, 2.0),
|
||||
],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(4),
|
||||
};
|
||||
|
||||
let result = stoer_wagner_mincut(&graph).unwrap();
|
||||
assert!(
|
||||
(result.cut_value - 4.0).abs() < 1e-9,
|
||||
"Expected mincut 4.0, got {}",
|
||||
result.cut_value
|
||||
);
|
||||
// Verify partition sizes sum to total.
|
||||
assert_eq!(
|
||||
result.partition_a.len() + result.partition_b.len(),
|
||||
4
|
||||
);
|
||||
}
|
||||
|
||||
/// Complete graph K4 with unit weights: mincut = 3 (remove all edges to one vertex).
|
||||
#[test]
|
||||
fn test_stoer_wagner_complete_k4() {
|
||||
let mut edges = Vec::new();
|
||||
for i in 0..4 {
|
||||
for j in (i + 1)..4 {
|
||||
edges.push(make_edge(i, j, 1.0));
|
||||
}
|
||||
}
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 4,
|
||||
edges,
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(4),
|
||||
};
|
||||
|
||||
let result = stoer_wagner_mincut(&graph).unwrap();
|
||||
assert!(
|
||||
(result.cut_value - 3.0).abs() < 1e-9,
|
||||
"Expected mincut 3.0 for K4, got {}",
|
||||
result.cut_value
|
||||
);
|
||||
}
|
||||
|
||||
/// Two disconnected components: mincut = 0.
|
||||
#[test]
|
||||
fn test_stoer_wagner_disconnected() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 4,
|
||||
edges: vec![
|
||||
make_edge(0, 1, 5.0),
|
||||
make_edge(2, 3, 5.0),
|
||||
],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(4),
|
||||
};
|
||||
|
||||
let result = stoer_wagner_mincut(&graph).unwrap();
|
||||
assert!(
|
||||
result.cut_value.abs() < 1e-9,
|
||||
"Expected mincut 0.0 for disconnected graph, got {}",
|
||||
result.cut_value
|
||||
);
|
||||
}
|
||||
|
||||
/// Graph with a single node should return an error.
|
||||
#[test]
|
||||
fn test_stoer_wagner_single_node() {
|
||||
let graph = BrainGraph {
|
||||
num_nodes: 1,
|
||||
edges: vec![],
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(1),
|
||||
};
|
||||
assert!(stoer_wagner_mincut(&graph).is_err());
|
||||
}
|
||||
|
||||
/// Complete graph K_n: mincut = n - 1 (unit weights).
|
||||
#[test]
|
||||
fn test_stoer_wagner_complete_kn() {
|
||||
for n in 3..=6 {
|
||||
let mut edges = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
edges.push(make_edge(i, j, 1.0));
|
||||
}
|
||||
}
|
||||
let graph = BrainGraph {
|
||||
num_nodes: n,
|
||||
edges,
|
||||
timestamp: 0.0,
|
||||
window_duration_s: 1.0,
|
||||
atlas: Atlas::Custom(n),
|
||||
};
|
||||
let result = stoer_wagner_mincut(&graph).unwrap();
|
||||
let expected = (n - 1) as f64;
|
||||
assert!(
|
||||
(result.cut_value - expected).abs() < 1e-9,
|
||||
"K{}: expected mincut {}, got {}",
|
||||
n,
|
||||
expected,
|
||||
result.cut_value
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
[package]
|
||||
name = "ruv-neural-sensor"
|
||||
description = "rUv Neural — Sensor data acquisition for NV diamond, OPM, EEG, and simulated sources"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["simulator"]
|
||||
simulator = []
|
||||
nv_diamond = []
|
||||
opm = []
|
||||
eeg = []
|
||||
|
||||
[dependencies]
|
||||
ruv-neural-core = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
num-traits = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
approx = { workspace = true }
|
||||
@@ -1,92 +0,0 @@
|
||||
# ruv-neural-sensor
|
||||
|
||||
Sensor data acquisition for NV diamond, OPM, EEG, and simulated sources.
|
||||
|
||||
## Overview
|
||||
|
||||
`ruv-neural-sensor` provides uniform sensor interfaces for multiple neural
|
||||
magnetometry and electrophysiology sensor types. Each sensor backend implements
|
||||
the `SensorSource` trait from `ruv-neural-core`, producing `MultiChannelTimeSeries`
|
||||
data. The crate also includes calibration utilities and real-time signal quality
|
||||
monitoring.
|
||||
|
||||
## Features
|
||||
|
||||
- **Simulated sensor** (`simulator` feature, default): Synthetic multi-channel data
|
||||
generation with configurable alpha rhythm injection, noise floor control, and
|
||||
event injection (spikes, artifacts)
|
||||
- **NV diamond** (`nv_diamond` feature): Nitrogen-vacancy diamond magnetometer
|
||||
interface with configurable sensitivity and channel layout
|
||||
- **OPM** (`opm` feature): Optically pumped magnetometer array with configurable
|
||||
geometry
|
||||
- **EEG** (`eeg` feature): Electroencephalography sensor interface
|
||||
- **Calibration**: Gain/offset correction, noise floor estimation, and cross-calibration
|
||||
between reference and target channels
|
||||
- **Quality monitoring**: Real-time SNR estimation, artifact probability scoring,
|
||||
and saturation detection with configurable alert thresholds
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use ruv_neural_sensor::simulator::{SimulatedSensorArray, SensorEvent};
|
||||
use ruv_neural_sensor::{SensorSource, SensorType};
|
||||
|
||||
// Create a simulated 16-channel array at 1000 Hz
|
||||
let mut sim = SimulatedSensorArray::new(16, 1000.0);
|
||||
sim.inject_alpha(100.0); // 100 fT alpha rhythm
|
||||
|
||||
// Read 500 samples via the SensorSource trait
|
||||
let data = sim.read_chunk(500).unwrap();
|
||||
assert_eq!(data.num_channels, 16);
|
||||
assert_eq!(data.num_samples, 500);
|
||||
|
||||
// Inject a spike event
|
||||
sim.inject_event(SensorEvent::Spike {
|
||||
channel: 0,
|
||||
amplitude_ft: 500.0,
|
||||
sample_offset: 100,
|
||||
});
|
||||
|
||||
// Calibrate channels
|
||||
use ruv_neural_sensor::calibration::{CalibrationData, calibrate_channel};
|
||||
let cal = CalibrationData {
|
||||
gains: vec![2.0],
|
||||
offsets: vec![10.0],
|
||||
noise_floors: vec![1.0],
|
||||
};
|
||||
let corrected = calibrate_channel(100.0, 0, &cal); // (100 - 10) * 2 = 180
|
||||
|
||||
// Monitor signal quality
|
||||
use ruv_neural_sensor::quality::QualityMonitor;
|
||||
let mut monitor = QualityMonitor::new(2);
|
||||
let qualities = monitor.check_quality(&[&data.data[0], &data.data[1]]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Module | Key Types / Functions |
|
||||
|---------------|--------------------------------------------------------------|
|
||||
| `simulator` | `SimulatedSensorArray`, `SensorEvent` |
|
||||
| `nv_diamond` | `NvDiamondArray`, `NvDiamondConfig` |
|
||||
| `opm` | `OpmArray`, `OpmConfig` |
|
||||
| `eeg` | `EegArray`, `EegConfig` |
|
||||
| `calibration` | `CalibrationData`, `calibrate_channel`, `cross_calibrate` |
|
||||
| `quality` | `QualityMonitor`, `SignalQuality` |
|
||||
|
||||
## Feature Flags
|
||||
|
||||
| Feature | Default | Description |
|
||||
|-------------|---------|--------------------------------------|
|
||||
| `simulator` | Yes | Synthetic test data generator |
|
||||
| `nv_diamond`| No | NV diamond magnetometer backend |
|
||||
| `opm` | No | Optically pumped magnetometer backend|
|
||||
| `eeg` | No | EEG sensor backend |
|
||||
|
||||
## Integration
|
||||
|
||||
Depends on `ruv-neural-core` for the `SensorSource` trait and `MultiChannelTimeSeries`
|
||||
type. Produced data feeds into `ruv-neural-signal` for preprocessing and filtering.
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
@@ -1,60 +0,0 @@
|
||||
//! Sensor calibration utilities for gain/offset correction and cross-calibration.
|
||||
|
||||
/// Calibration data for a sensor array.
|
||||
pub struct CalibrationData {
|
||||
/// Per-channel gain factors.
|
||||
pub gains: Vec<f64>,
|
||||
/// Per-channel DC offsets to subtract.
|
||||
pub offsets: Vec<f64>,
|
||||
/// Per-channel noise floor estimates (fT RMS).
|
||||
pub noise_floors: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Apply gain and offset correction to a single sample on a given channel.
|
||||
///
|
||||
/// `corrected = (raw - offset) * gain`
|
||||
pub fn calibrate_channel(raw: f64, channel: usize, cal: &CalibrationData) -> f64 {
|
||||
let offset = cal.offsets.get(channel).copied().unwrap_or(0.0);
|
||||
let gain = cal.gains.get(channel).copied().unwrap_or(1.0);
|
||||
(raw - offset) * gain
|
||||
}
|
||||
|
||||
/// Estimate the noise floor (RMS) of a quiet signal segment.
|
||||
pub fn estimate_noise_floor(signal: &[f64]) -> f64 {
|
||||
if signal.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mean_sq = signal.iter().map(|x| x * x).sum::<f64>() / signal.len() as f64;
|
||||
mean_sq.sqrt()
|
||||
}
|
||||
|
||||
/// Cross-calibrate a target channel against a reference channel.
|
||||
///
|
||||
/// Returns `(gain, offset)` such that `target * gain + offset ~ reference`.
|
||||
/// Uses simple linear regression.
|
||||
pub fn cross_calibrate(reference: &[f64], target: &[f64]) -> (f64, f64) {
|
||||
let n = reference.len().min(target.len());
|
||||
if n == 0 {
|
||||
return (1.0, 0.0);
|
||||
}
|
||||
|
||||
let mean_r = reference[..n].iter().sum::<f64>() / n as f64;
|
||||
let mean_t = target[..n].iter().sum::<f64>() / n as f64;
|
||||
|
||||
let mut num = 0.0;
|
||||
let mut den = 0.0;
|
||||
for i in 0..n {
|
||||
let dr = reference[i] - mean_r;
|
||||
let dt = target[i] - mean_t;
|
||||
num += dr * dt;
|
||||
den += dt * dt;
|
||||
}
|
||||
|
||||
if den.abs() < 1e-15 {
|
||||
return (1.0, mean_r - mean_t);
|
||||
}
|
||||
|
||||
let gain = num / den;
|
||||
let offset = mean_r - gain * mean_t;
|
||||
(gain, offset)
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
//! EEG (Electroencephalography) interface.
|
||||
//!
|
||||
//! Provides a sensor interface for standard EEG systems using the 10-20
|
||||
//! international electrode placement system. Generates physically realistic
|
||||
//! EEG signals in microvolts including delta, theta, alpha, beta, and gamma
|
||||
//! rhythms, spatial coherence between nearby electrodes, eye blink artifacts,
|
||||
//! muscle artifacts, and powerline noise. Included as a comparison/fallback
|
||||
//! modality alongside higher-sensitivity magnetometer arrays.
|
||||
|
||||
use ruv_neural_core::error::{Result, RuvNeuralError};
|
||||
use ruv_neural_core::sensor::{SensorArray, SensorChannel, SensorType};
|
||||
use ruv_neural_core::signal::MultiChannelTimeSeries;
|
||||
use ruv_neural_core::traits::SensorSource;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Standard 10-20 system electrode labels (21 channels).
|
||||
pub const STANDARD_10_20_LABELS: &[&str] = &[
|
||||
"Fp1", "Fp2", "F7", "F3", "Fz", "F4", "F8", "T3", "C3", "Cz", "C4", "T4", "T5", "P3",
|
||||
"Pz", "P4", "T6", "O1", "Oz", "O2", "A1",
|
||||
];
|
||||
|
||||
/// Standard 10-20 system approximate positions on a unit sphere (nasion-inion axis = Y).
|
||||
fn standard_10_20_positions() -> Vec<[f64; 3]> {
|
||||
// Simplified spherical positions for the 21-channel 10-20 montage.
|
||||
let r = 0.09; // ~9 cm radius
|
||||
STANDARD_10_20_LABELS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| {
|
||||
let phi = 2.0 * PI * i as f64 / STANDARD_10_20_LABELS.len() as f64;
|
||||
let theta = PI / 3.0 + (i as f64 / STANDARD_10_20_LABELS.len() as f64) * PI / 3.0;
|
||||
[
|
||||
r * theta.sin() * phi.cos(),
|
||||
r * theta.sin() * phi.sin(),
|
||||
r * theta.cos(),
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Configuration for an EEG sensor array.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EegConfig {
|
||||
/// Number of EEG channels.
|
||||
pub num_channels: usize,
|
||||
/// Sample rate in Hz.
|
||||
pub sample_rate_hz: f64,
|
||||
/// Channel labels (e.g., "Fp1", "Fz", etc.).
|
||||
pub labels: Vec<String>,
|
||||
/// Channel positions in head-frame coordinates.
|
||||
pub positions: Vec<[f64; 3]>,
|
||||
/// Reference electrode label (e.g., "A1" for linked ears).
|
||||
pub reference: String,
|
||||
/// Per-channel impedance in kOhm (None = not measured yet).
|
||||
pub impedances_kohm: Vec<Option<f64>>,
|
||||
}
|
||||
|
||||
impl Default for EegConfig {
|
||||
fn default() -> Self {
|
||||
let labels: Vec<String> = STANDARD_10_20_LABELS.iter().map(|s| s.to_string()).collect();
|
||||
let num_channels = labels.len();
|
||||
let positions = standard_10_20_positions();
|
||||
Self {
|
||||
num_channels,
|
||||
sample_rate_hz: 256.0,
|
||||
labels,
|
||||
positions,
|
||||
reference: "A1".to_string(),
|
||||
impedances_kohm: vec![None; num_channels],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// EEG sensor array.
|
||||
///
|
||||
/// Provides the [`SensorSource`] interface for EEG acquisition. Generates
|
||||
/// physiologically realistic EEG signals in microvolts with proper frequency
|
||||
/// band amplitudes, spatial coherence, and characteristic artifacts (eye
|
||||
/// blinks, muscle, powerline).
|
||||
#[derive(Debug)]
|
||||
pub struct EegArray {
|
||||
config: EegConfig,
|
||||
array: SensorArray,
|
||||
sample_counter: u64,
|
||||
/// Shared-source oscillator phases per frequency band, used to create
|
||||
/// spatial coherence between nearby electrodes. Each band has one
|
||||
/// "source" phase that all channels mix in proportionally.
|
||||
source_phases: BrainSources,
|
||||
}
|
||||
|
||||
/// Internal state for spatially coherent brain rhythm generation.
|
||||
#[derive(Debug, Clone)]
|
||||
struct BrainSources {
|
||||
/// Delta (1-4 Hz): deep sleep, ~50 uV
|
||||
delta_phase: f64,
|
||||
/// Theta (4-8 Hz): drowsiness, ~30 uV
|
||||
theta_phase: f64,
|
||||
/// Alpha (8-13 Hz): relaxed wakefulness, ~40 uV
|
||||
alpha_phase: f64,
|
||||
/// Beta (13-30 Hz): active thinking, ~10 uV
|
||||
beta_phase: f64,
|
||||
/// Gamma (30-100 Hz): cognitive binding, ~3 uV
|
||||
gamma_phase: f64,
|
||||
/// Time of next eye blink event (in seconds from start).
|
||||
next_blink_time: f64,
|
||||
}
|
||||
|
||||
impl BrainSources {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
delta_phase: 0.0,
|
||||
theta_phase: 0.0,
|
||||
alpha_phase: 0.0,
|
||||
beta_phase: 0.0,
|
||||
gamma_phase: 0.0,
|
||||
next_blink_time: 4.0, // first blink around 4 seconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a single Gaussian sample using Box-Muller transform.
|
||||
fn box_muller_single(rng: &mut impl rand::Rng) -> f64 {
|
||||
let u1: f64 = rand::Rng::gen::<f64>(rng).max(1e-15);
|
||||
let u2: f64 = rand::Rng::gen(rng);
|
||||
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
|
||||
}
|
||||
|
||||
/// Compute Euclidean distance between two 3D points.
|
||||
fn distance(a: &[f64; 3], b: &[f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// Check if a channel label is a frontal-polar electrode (eye blink target).
|
||||
fn is_frontal_polar(label: &str) -> bool {
|
||||
label == "Fp1" || label == "Fp2"
|
||||
}
|
||||
|
||||
/// Check if a channel label is a temporal electrode (muscle artifact target).
|
||||
fn is_temporal(label: &str) -> bool {
|
||||
label == "T3" || label == "T4" || label == "T5" || label == "T6"
|
||||
}
|
||||
|
||||
impl EegArray {
|
||||
/// Create a new EEG array from configuration.
|
||||
pub fn new(config: EegConfig) -> Self {
|
||||
let channels = (0..config.num_channels)
|
||||
.map(|i| {
|
||||
let pos = config.positions.get(i).copied().unwrap_or([0.0, 0.0, 0.0]);
|
||||
let label = config
|
||||
.labels
|
||||
.get(i)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("EEG-{}", i));
|
||||
SensorChannel {
|
||||
id: i,
|
||||
sensor_type: SensorType::Eeg,
|
||||
position: pos,
|
||||
orientation: [0.0, 0.0, 1.0],
|
||||
// EEG sensitivity is much lower than magnetometers.
|
||||
sensitivity_ft_sqrt_hz: 1000.0,
|
||||
sample_rate_hz: config.sample_rate_hz,
|
||||
label,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let array = SensorArray {
|
||||
channels,
|
||||
sensor_type: SensorType::Eeg,
|
||||
name: "EegArray".to_string(),
|
||||
};
|
||||
|
||||
Self {
|
||||
config,
|
||||
array,
|
||||
sample_counter: 0,
|
||||
source_phases: BrainSources::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the sensor array metadata.
|
||||
pub fn sensor_array(&self) -> &SensorArray {
|
||||
&self.array
|
||||
}
|
||||
|
||||
/// Update impedance measurement for a channel.
|
||||
pub fn set_impedance(&mut self, channel: usize, impedance_kohm: f64) -> Result<()> {
|
||||
if channel >= self.config.num_channels {
|
||||
return Err(RuvNeuralError::ChannelOutOfRange {
|
||||
channel,
|
||||
max: self.config.num_channels - 1,
|
||||
});
|
||||
}
|
||||
self.config.impedances_kohm[channel] = Some(impedance_kohm);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if all channels have acceptable impedance (< 5 kOhm).
|
||||
pub fn impedance_ok(&self) -> bool {
|
||||
self.config.impedances_kohm.iter().all(|imp| {
|
||||
imp.map_or(false, |v| v < 5.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get channels with high impedance (> threshold kOhm).
|
||||
pub fn high_impedance_channels(&self, threshold_kohm: f64) -> Vec<usize> {
|
||||
self.config
|
||||
.impedances_kohm
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, imp)| {
|
||||
imp.and_then(|v| if v > threshold_kohm { Some(i) } else { None })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the reference electrode label.
|
||||
pub fn reference(&self) -> &str {
|
||||
&self.config.reference
|
||||
}
|
||||
|
||||
/// Re-reference data to average reference.
|
||||
///
|
||||
/// Subtracts the mean across channels at each time point.
|
||||
pub fn average_reference(data: &mut [Vec<f64>]) {
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
let num_samples = data[0].len();
|
||||
let num_channels = data.len();
|
||||
for s in 0..num_samples {
|
||||
let mean: f64 = data.iter().map(|ch| ch[s]).sum::<f64>() / num_channels as f64;
|
||||
for ch in data.iter_mut() {
|
||||
ch[s] -= mean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute spatial correlation factor between two electrodes.
|
||||
/// Returns a value in [0, 1] where 1 = same location, decaying with distance.
|
||||
fn spatial_correlation(&self, ch_a: usize, ch_b: usize) -> f64 {
|
||||
let pos_a = self.config.positions.get(ch_a).unwrap_or(&[0.0, 0.0, 0.0]);
|
||||
let pos_b = self.config.positions.get(ch_b).unwrap_or(&[0.0, 0.0, 0.0]);
|
||||
let d = distance(pos_a, pos_b);
|
||||
// Exponential decay with length constant ~5 cm.
|
||||
(-d / 0.05).exp()
|
||||
}
|
||||
|
||||
/// Generate an eye blink artifact waveform at a given time relative to
|
||||
/// blink onset. Returns amplitude in microvolts. Blink duration ~0.3s.
|
||||
fn blink_waveform(t_since_onset: f64) -> f64 {
|
||||
let duration = 0.3;
|
||||
if t_since_onset < 0.0 || t_since_onset > duration {
|
||||
return 0.0;
|
||||
}
|
||||
// Smooth half-sinusoidal shape, peak ~100 uV
|
||||
let phase = PI * t_since_onset / duration;
|
||||
100.0 * phase.sin()
|
||||
}
|
||||
}
|
||||
|
||||
impl SensorSource for EegArray {
|
||||
fn sensor_type(&self) -> SensorType {
|
||||
SensorType::Eeg
|
||||
}
|
||||
|
||||
fn num_channels(&self) -> usize {
|
||||
self.config.num_channels
|
||||
}
|
||||
|
||||
fn sample_rate_hz(&self) -> f64 {
|
||||
self.config.sample_rate_hz
|
||||
}
|
||||
|
||||
fn read_chunk(&mut self, num_samples: usize) -> Result<MultiChannelTimeSeries> {
|
||||
let timestamp = self.sample_counter as f64 / self.config.sample_rate_hz;
|
||||
let dt = 1.0 / self.config.sample_rate_hz;
|
||||
let powerline_freq = 60.0; // Hz
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
// Pre-compute channel properties.
|
||||
let labels: Vec<String> = (0..self.config.num_channels)
|
||||
.map(|i| {
|
||||
self.config
|
||||
.labels
|
||||
.get(i)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Generate per-sample shared source oscillations first, then mix
|
||||
// into each channel with spatial coherence.
|
||||
// Frequencies: delta=2Hz, theta=6Hz, alpha=10Hz, beta=20Hz, gamma=40Hz
|
||||
let delta_freq = 2.0;
|
||||
let theta_freq = 6.0;
|
||||
let alpha_freq = 10.0;
|
||||
let beta_freq = 20.0;
|
||||
let gamma_freq = 40.0;
|
||||
|
||||
// Amplitudes in microvolts (peak)
|
||||
let delta_amp = 50.0;
|
||||
let theta_amp = 30.0;
|
||||
let alpha_amp = 40.0;
|
||||
let beta_amp = 10.0;
|
||||
let gamma_amp = 3.0;
|
||||
|
||||
let data: Vec<Vec<f64>> = (0..self.config.num_channels)
|
||||
.map(|ch| {
|
||||
let label = &labels[ch];
|
||||
let frontal = is_frontal_polar(label);
|
||||
let temporal = is_temporal(label);
|
||||
|
||||
// Noise floor based on impedance. Higher impedance = more noise.
|
||||
let impedance = self.config.impedances_kohm[ch].unwrap_or(5.0);
|
||||
// Thermal noise: ~0.5 uV per sqrt(kOhm) as a rough model
|
||||
let noise_sigma = 0.5 * impedance.sqrt();
|
||||
|
||||
// Per-channel phase offset for spatial variation
|
||||
let ch_phase = 0.5 * ch as f64;
|
||||
|
||||
(0..num_samples)
|
||||
.map(|s| {
|
||||
let t = timestamp + s as f64 * dt;
|
||||
|
||||
// 1. Brain rhythms with per-channel phase offsets
|
||||
let delta = delta_amp * (2.0 * PI * delta_freq * t + ch_phase * 0.2).sin();
|
||||
let theta = theta_amp * (2.0 * PI * theta_freq * t + ch_phase * 0.3).sin();
|
||||
let alpha = alpha_amp * (2.0 * PI * alpha_freq * t + ch_phase * 0.4).sin();
|
||||
let beta = beta_amp * (2.0 * PI * beta_freq * t + ch_phase * 0.6).sin();
|
||||
let gamma = gamma_amp * (2.0 * PI * gamma_freq * t + ch_phase * 0.8).sin();
|
||||
let brain = delta + theta + alpha + beta + gamma;
|
||||
|
||||
// 2. Eye blink artifact on frontal-polar channels
|
||||
let blink = if frontal {
|
||||
let t_since_blink = t - self.source_phases.next_blink_time;
|
||||
Self::blink_waveform(t_since_blink)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// 3. Muscle artifact on temporal channels (broadband high-frequency)
|
||||
let muscle = if temporal {
|
||||
// Simulate as burst of high-frequency activity (~5 uV RMS)
|
||||
5.0 * box_muller_single(&mut rng)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// 4. Powerline noise (small, ~1-2 uV)
|
||||
let line_noise = 1.5 * (2.0 * PI * powerline_freq * t).sin();
|
||||
|
||||
// 5. White noise floor (electrode thermal noise)
|
||||
let white = noise_sigma * box_muller_single(&mut rng);
|
||||
|
||||
brain + blink + muscle + line_noise + white
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Schedule next blink if current chunk passed the blink time.
|
||||
let chunk_end_time = timestamp + num_samples as f64 * dt;
|
||||
if chunk_end_time > self.source_phases.next_blink_time + 0.3 {
|
||||
// Next blink in 4-6 seconds (deterministic offset from current time).
|
||||
let interval = 4.0 + (self.sample_counter as f64 * 0.618).sin().abs() * 2.0;
|
||||
self.source_phases.next_blink_time = chunk_end_time + interval;
|
||||
}
|
||||
|
||||
self.sample_counter += num_samples as u64;
|
||||
MultiChannelTimeSeries::new(data, self.config.sample_rate_hz, timestamp)
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
//! rUv Neural Sensor -- sensor data acquisition for NV diamond, OPM, EEG,
|
||||
//! and simulated sources.
|
||||
//!
|
||||
//! This crate provides uniform sensor interfaces via the [`SensorSource`] trait
|
||||
//! from `ruv-neural-core`. Each sensor backend is feature-gated:
|
||||
//!
|
||||
//! | Feature | Module | Sensor Type |
|
||||
//! |---------------|----------------|------------------------------------|
|
||||
//! | `simulator` | [`simulator`] | Synthetic test data |
|
||||
//! | `nv_diamond` | [`nv_diamond`] | Nitrogen-vacancy diamond magnetometer |
|
||||
//! | `opm` | [`opm`] | Optically pumped magnetometer |
|
||||
//! | `eeg` | [`eeg`] | Electroencephalography |
|
||||
//!
|
||||
//! The [`calibration`] and [`quality`] modules are always available.
|
||||
|
||||
#[cfg(feature = "simulator")]
|
||||
pub mod simulator;
|
||||
|
||||
#[cfg(feature = "nv_diamond")]
|
||||
pub mod nv_diamond;
|
||||
|
||||
#[cfg(feature = "opm")]
|
||||
pub mod opm;
|
||||
|
||||
#[cfg(feature = "eeg")]
|
||||
pub mod eeg;
|
||||
|
||||
pub mod calibration;
|
||||
pub mod quality;
|
||||
|
||||
// Re-exports from core for convenience.
|
||||
pub use ruv_neural_core::signal::MultiChannelTimeSeries;
|
||||
pub use ruv_neural_core::traits::SensorSource;
|
||||
pub use ruv_neural_core::{SensorArray, SensorChannel, SensorType};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "simulator")]
|
||||
#[test]
|
||||
fn simulator_produces_correct_shape() {
|
||||
let mut sim = simulator::SimulatedSensorArray::new(16, 1000.0);
|
||||
let data = sim.read_chunk(500).expect("read_chunk failed");
|
||||
assert_eq!(data.num_channels, 16);
|
||||
assert_eq!(data.num_samples, 500);
|
||||
assert_eq!(data.sample_rate_hz, 1000.0);
|
||||
}
|
||||
|
||||
#[cfg(feature = "simulator")]
|
||||
#[test]
|
||||
fn simulator_sensor_type() {
|
||||
let sim = simulator::SimulatedSensorArray::new(8, 500.0);
|
||||
assert_eq!(sim.sensor_type(), SensorType::NvDiamond);
|
||||
}
|
||||
|
||||
#[cfg(feature = "simulator")]
|
||||
#[test]
|
||||
fn simulator_alpha_rhythm_frequency() {
|
||||
// Generate 2 seconds of data at 1000 Hz to verify alpha peak near 10 Hz.
|
||||
let mut sim = simulator::SimulatedSensorArray::new(1, 1000.0);
|
||||
sim.inject_alpha(100.0); // 100 fT amplitude
|
||||
let data = sim.read_chunk(2000).expect("read_chunk failed");
|
||||
let ch = &data.data[0];
|
||||
|
||||
// Simple DFT at the alpha frequency bin.
|
||||
let n = ch.len();
|
||||
let sample_rate = 1000.0_f64;
|
||||
let target_freq = 10.0_f64;
|
||||
let bin = (target_freq * n as f64 / sample_rate).round() as usize;
|
||||
|
||||
let power_at = |freq_bin: usize| -> f64 {
|
||||
let mut re = 0.0_f64;
|
||||
let mut im = 0.0_f64;
|
||||
for (t, &val) in ch.iter().enumerate() {
|
||||
let angle =
|
||||
-2.0 * std::f64::consts::PI * freq_bin as f64 * t as f64 / n as f64;
|
||||
re += val * angle.cos();
|
||||
im += val * angle.sin();
|
||||
}
|
||||
(re * re + im * im).sqrt() / n as f64
|
||||
};
|
||||
|
||||
let alpha_power = power_at(bin);
|
||||
let noise_bin = (37.0 * n as f64 / sample_rate).round() as usize;
|
||||
let noise_power = power_at(noise_bin);
|
||||
|
||||
assert!(
|
||||
alpha_power > noise_power * 3.0,
|
||||
"Alpha power ({alpha_power}) should be >> noise power ({noise_power})"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "simulator")]
|
||||
#[test]
|
||||
fn simulator_noise_floor() {
|
||||
let noise_density = 15.0; // fT/sqrt(Hz)
|
||||
let sample_rate = 1000.0;
|
||||
let mut sim = simulator::SimulatedSensorArray::new(1, sample_rate)
|
||||
.with_noise(noise_density);
|
||||
let data = sim.read_chunk(10000).expect("read_chunk failed");
|
||||
let ch = &data.data[0];
|
||||
let rms = (ch.iter().map(|x| x * x).sum::<f64>() / ch.len() as f64).sqrt();
|
||||
|
||||
// Expected RMS = noise_density * sqrt(sample_rate / 2) for white noise.
|
||||
let expected_rms = noise_density * (sample_rate / 2.0).sqrt();
|
||||
|
||||
// Allow generous tolerance due to randomness.
|
||||
assert!(
|
||||
rms > expected_rms * 0.4 && rms < expected_rms * 1.6,
|
||||
"RMS {rms} not within tolerance of expected {expected_rms}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "simulator")]
|
||||
#[test]
|
||||
fn simulator_inject_event() {
|
||||
let mut sim = simulator::SimulatedSensorArray::new(4, 1000.0);
|
||||
sim.inject_event(simulator::SensorEvent::Spike {
|
||||
channel: 0,
|
||||
amplitude_ft: 500.0,
|
||||
sample_offset: 100,
|
||||
});
|
||||
let data = sim.read_chunk(200).expect("read_chunk failed");
|
||||
// The spike should cause a large value near sample 100 in channel 0.
|
||||
let ch0 = &data.data[0];
|
||||
let max_val = ch0.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
assert!(
|
||||
max_val > 400.0,
|
||||
"Spike amplitude should be visible, got max {max_val}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_apply_gain_offset() {
|
||||
let cal = calibration::CalibrationData {
|
||||
gains: vec![2.0, 0.5],
|
||||
offsets: vec![10.0, -5.0],
|
||||
noise_floors: vec![1.0, 2.0],
|
||||
};
|
||||
let corrected = calibration::calibrate_channel(100.0, 0, &cal);
|
||||
// (100.0 - 10.0) * 2.0 = 180.0
|
||||
assert!((corrected - 180.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_noise_floor_estimate() {
|
||||
let quiet = vec![1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
|
||||
let nf = calibration::estimate_noise_floor(&quiet);
|
||||
// RMS of alternating +/-1 = 1.0
|
||||
assert!((nf - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_cross_calibrate() {
|
||||
let reference = vec![10.0, 20.0, 30.0, 40.0];
|
||||
let target = vec![5.0, 10.0, 15.0, 20.0];
|
||||
let (gain, offset) = calibration::cross_calibrate(&reference, &target);
|
||||
// target * gain + offset should approximate reference.
|
||||
// 5*2+0=10, 10*2+0=20, etc.
|
||||
assert!((gain - 2.0).abs() < 1e-10);
|
||||
assert!(offset.abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_detects_low_snr() {
|
||||
let mut monitor = quality::QualityMonitor::new(2);
|
||||
|
||||
// Channel 0: strong signal.
|
||||
let good_signal: Vec<f64> = (0..1000)
|
||||
.map(|i| 100.0 * (2.0 * std::f64::consts::PI * 10.0 * i as f64 / 1000.0).sin())
|
||||
.collect();
|
||||
|
||||
// Channel 1: high-frequency noise (alternating values = maximum first-difference noise).
|
||||
let bad_signal: Vec<f64> = (0..1000)
|
||||
.map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })
|
||||
.collect();
|
||||
|
||||
let qualities = monitor.check_quality(&[&good_signal, &bad_signal]);
|
||||
assert_eq!(qualities.len(), 2);
|
||||
// Smooth sinusoid should have higher SNR than alternating noise.
|
||||
assert!(
|
||||
qualities[0].snr_db > qualities[1].snr_db,
|
||||
"Good SNR ({}) should be > bad SNR ({})",
|
||||
qualities[0].snr_db,
|
||||
qualities[1].snr_db,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_saturation_detection() {
|
||||
let mut monitor = quality::QualityMonitor::new(1);
|
||||
|
||||
// A signal that clips at max value for many samples.
|
||||
let saturated: Vec<f64> = (0..1000)
|
||||
.map(|i| if i % 2 == 0 { 1e6 } else { -1e6 })
|
||||
.collect();
|
||||
|
||||
let qualities = monitor.check_quality(&[&saturated]);
|
||||
assert!(qualities[0].saturated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_alert_thresholds() {
|
||||
let q_good = quality::SignalQuality {
|
||||
snr_db: 10.0,
|
||||
artifact_probability: 0.1,
|
||||
saturated: false,
|
||||
};
|
||||
assert!(!q_good.below_threshold());
|
||||
|
||||
let q_bad = quality::SignalQuality {
|
||||
snr_db: 2.0,
|
||||
artifact_probability: 0.6,
|
||||
saturated: false,
|
||||
};
|
||||
assert!(q_bad.below_threshold());
|
||||
}
|
||||
|
||||
#[cfg(feature = "simulator")]
|
||||
#[test]
|
||||
fn sensor_source_trait_works() {
|
||||
let mut sim = simulator::SimulatedSensorArray::new(4, 500.0);
|
||||
let source: &mut dyn SensorSource = &mut sim;
|
||||
assert_eq!(source.num_channels(), 4);
|
||||
assert_eq!(source.sample_rate_hz(), 500.0);
|
||||
let data = source.read_chunk(100).expect("read_chunk failed");
|
||||
assert_eq!(data.num_channels, 4);
|
||||
assert_eq!(data.num_samples, 100);
|
||||
}
|
||||
|
||||
#[cfg(feature = "nv_diamond")]
|
||||
#[test]
|
||||
fn nv_diamond_sensor_source() {
|
||||
let config = nv_diamond::NvDiamondConfig::default();
|
||||
let mut nv = nv_diamond::NvDiamondArray::new(config);
|
||||
assert_eq!(nv.sensor_type(), SensorType::NvDiamond);
|
||||
let data = nv.read_chunk(100).expect("read_chunk failed");
|
||||
assert_eq!(data.num_channels, nv.num_channels());
|
||||
}
|
||||
|
||||
#[cfg(feature = "opm")]
|
||||
#[test]
|
||||
fn opm_sensor_source() {
|
||||
let config = opm::OpmConfig::default();
|
||||
let mut opm_arr = opm::OpmArray::new(config);
|
||||
assert_eq!(opm_arr.sensor_type(), SensorType::Opm);
|
||||
let data = opm_arr.read_chunk(100).expect("read_chunk failed");
|
||||
assert_eq!(data.num_channels, opm_arr.num_channels());
|
||||
}
|
||||
|
||||
#[cfg(feature = "eeg")]
|
||||
#[test]
|
||||
fn eeg_sensor_source() {
|
||||
let config = eeg::EegConfig::default();
|
||||
let mut eeg_arr = eeg::EegArray::new(config);
|
||||
assert_eq!(eeg_arr.sensor_type(), SensorType::Eeg);
|
||||
let data = eeg_arr.read_chunk(100).expect("read_chunk failed");
|
||||
assert_eq!(data.num_channels, eeg_arr.num_channels());
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
//! NV Diamond magnetometer interface.
|
||||
//!
|
||||
//! Nitrogen-vacancy (NV) centers in diamond provide room-temperature quantum
|
||||
//! magnetometry with ~10 fT/sqrt(Hz) sensitivity. This module implements the
|
||||
//! acquisition interface, calibration structures, and ODMR-based signal model
|
||||
//! for NV diamond arrays.
|
||||
|
||||
use ruv_neural_core::error::{Result, RuvNeuralError};
|
||||
use ruv_neural_core::sensor::{SensorArray, SensorChannel, SensorType};
|
||||
use ruv_neural_core::signal::MultiChannelTimeSeries;
|
||||
use ruv_neural_core::traits::SensorSource;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// NV center gyromagnetic ratio in GHz/T.
|
||||
const GAMMA_NV_GHZ_PER_T: f64 = 28.024;
|
||||
|
||||
/// Configuration for an NV diamond magnetometer array.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NvDiamondConfig {
|
||||
/// Number of diamond sensor chips.
|
||||
pub num_channels: usize,
|
||||
/// Sample rate in Hz.
|
||||
pub sample_rate_hz: f64,
|
||||
/// Laser power in mW per chip.
|
||||
pub laser_power_mw: f64,
|
||||
/// Microwave drive frequency in GHz (near 2.87 GHz zero-field splitting).
|
||||
pub microwave_freq_ghz: f64,
|
||||
/// Positions of each diamond chip in head-frame coordinates (x, y, z in meters).
|
||||
pub chip_positions: Vec<[f64; 3]>,
|
||||
}
|
||||
|
||||
impl Default for NvDiamondConfig {
|
||||
fn default() -> Self {
|
||||
let num_channels = 16;
|
||||
let positions: Vec<[f64; 3]> = (0..num_channels)
|
||||
.map(|i| {
|
||||
let angle = 2.0 * PI * i as f64 / num_channels as f64;
|
||||
let r = 0.09;
|
||||
[r * angle.cos(), r * angle.sin(), 0.0]
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
num_channels,
|
||||
sample_rate_hz: 1000.0,
|
||||
laser_power_mw: 100.0,
|
||||
microwave_freq_ghz: 2.87,
|
||||
chip_positions: positions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel calibration data for NV diamond sensors.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NvCalibration {
|
||||
/// Sensitivity in fT per fluorescence count, per channel.
|
||||
pub sensitivity_ft_per_count: Vec<f64>,
|
||||
/// Noise floor in fT/sqrt(Hz), per channel.
|
||||
pub noise_floor_ft: Vec<f64>,
|
||||
/// Zero-field splitting offset per channel in MHz.
|
||||
pub zfs_offset_mhz: Vec<f64>,
|
||||
}
|
||||
|
||||
impl NvCalibration {
|
||||
/// Create default calibration for `n` channels.
|
||||
pub fn default_for(n: usize) -> Self {
|
||||
Self {
|
||||
sensitivity_ft_per_count: vec![0.1; n],
|
||||
noise_floor_ft: vec![10.0; n],
|
||||
zfs_offset_mhz: vec![0.0; n],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// NV Diamond magnetometer array.
|
||||
///
|
||||
/// Provides the [`SensorSource`] interface for NV diamond magnetometry.
|
||||
/// Generates physically realistic ODMR-based magnetic field signals including
|
||||
/// neural oscillation bands (alpha, beta, gamma) and sensor-characteristic
|
||||
/// noise (1/f pink noise + shot noise).
|
||||
#[derive(Debug)]
|
||||
pub struct NvDiamondArray {
|
||||
config: NvDiamondConfig,
|
||||
calibration: NvCalibration,
|
||||
array: SensorArray,
|
||||
sample_counter: u64,
|
||||
/// Pink noise state per channel (1/f generator using Voss-McCartney algorithm).
|
||||
pink_state: Vec<PinkNoiseGen>,
|
||||
}
|
||||
|
||||
/// Voss-McCartney pink noise generator (8 octaves).
|
||||
#[derive(Debug, Clone)]
|
||||
struct PinkNoiseGen {
|
||||
octaves: [f64; 8],
|
||||
counter: u32,
|
||||
}
|
||||
|
||||
impl PinkNoiseGen {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
octaves: [0.0; 8],
|
||||
counter: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the next pink noise sample using the Voss-McCartney algorithm.
|
||||
/// Returns a value with approximate unit variance when averaged.
|
||||
fn next(&mut self, rng: &mut impl rand::Rng) -> f64 {
|
||||
self.counter = self.counter.wrapping_add(1);
|
||||
let changed = self.counter;
|
||||
// Update octave i when bit i flips from 0 to 1
|
||||
for i in 0..8u32 {
|
||||
if changed & (1 << i) != 0 {
|
||||
self.octaves[i as usize] = box_muller_single(rng);
|
||||
break; // Voss-McCartney: only update the lowest changed bit
|
||||
}
|
||||
}
|
||||
// Sum all octaves and normalize
|
||||
let sum: f64 = self.octaves.iter().sum();
|
||||
sum / (8.0_f64).sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a single Gaussian sample using Box-Muller transform.
|
||||
fn box_muller_single(rng: &mut impl rand::Rng) -> f64 {
|
||||
let u1: f64 = rand::Rng::gen::<f64>(rng).max(1e-15);
|
||||
let u2: f64 = rand::Rng::gen(rng);
|
||||
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
|
||||
}
|
||||
|
||||
impl NvDiamondArray {
|
||||
/// Create a new NV diamond array from configuration.
|
||||
pub fn new(config: NvDiamondConfig) -> Self {
|
||||
let calibration = NvCalibration::default_for(config.num_channels);
|
||||
let channels = (0..config.num_channels)
|
||||
.map(|i| {
|
||||
let pos = config
|
||||
.chip_positions
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or([0.0, 0.0, 0.0]);
|
||||
SensorChannel {
|
||||
id: i,
|
||||
sensor_type: SensorType::NvDiamond,
|
||||
position: pos,
|
||||
orientation: [0.0, 0.0, 1.0],
|
||||
sensitivity_ft_sqrt_hz: calibration.noise_floor_ft[i],
|
||||
sample_rate_hz: config.sample_rate_hz,
|
||||
label: format!("NV-{:03}", i),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let array = SensorArray {
|
||||
channels,
|
||||
sensor_type: SensorType::NvDiamond,
|
||||
name: "NvDiamondArray".to_string(),
|
||||
};
|
||||
|
||||
let pink_state = (0..config.num_channels)
|
||||
.map(|_| PinkNoiseGen::new())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
config,
|
||||
calibration,
|
||||
array,
|
||||
sample_counter: 0,
|
||||
pink_state,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the sensor array metadata.
|
||||
pub fn sensor_array(&self) -> &SensorArray {
|
||||
&self.array
|
||||
}
|
||||
|
||||
/// Set custom calibration data.
|
||||
pub fn with_calibration(mut self, calibration: NvCalibration) -> Result<Self> {
|
||||
if calibration.sensitivity_ft_per_count.len() != self.config.num_channels {
|
||||
return Err(RuvNeuralError::DimensionMismatch {
|
||||
expected: self.config.num_channels,
|
||||
got: calibration.sensitivity_ft_per_count.len(),
|
||||
});
|
||||
}
|
||||
self.calibration = calibration;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Get the current calibration data.
|
||||
pub fn calibration(&self) -> &NvCalibration {
|
||||
&self.calibration
|
||||
}
|
||||
|
||||
/// Convert raw fluorescence counts to magnetic field (fT) via ODMR analysis.
|
||||
///
|
||||
/// Models the ODMR dip as a Lorentzian centered at the zero-field splitting
|
||||
/// frequency (2.87 GHz + channel offset). The fluorescence value represents
|
||||
/// a deviation from the baseline ODMR dip depth, which is proportional to
|
||||
/// the magnetic field via the NV gyromagnetic ratio (28.024 GHz/T).
|
||||
///
|
||||
/// The conversion applies per-channel calibration sensitivity to translate
|
||||
/// the fluorescence deviation into a field measurement in femtotesla.
|
||||
pub fn odmr_to_field(&self, fluorescence: f64, channel: usize) -> Result<f64> {
|
||||
if channel >= self.config.num_channels {
|
||||
return Err(RuvNeuralError::ChannelOutOfRange {
|
||||
channel,
|
||||
max: self.config.num_channels - 1,
|
||||
});
|
||||
}
|
||||
// The fluorescence deviation from baseline is proportional to the
|
||||
// resonance frequency shift. Convert via calibrated sensitivity.
|
||||
// field_ft = (fluorescence - baseline) * sensitivity_ft_per_count
|
||||
// The baseline is implicitly zero in our convention (deviation from it).
|
||||
let field_ft = fluorescence * self.calibration.sensitivity_ft_per_count[channel];
|
||||
Ok(field_ft)
|
||||
}
|
||||
|
||||
/// Generate the brain signal component at a given time (in seconds) for
|
||||
/// a given channel, returning the value in femtotesla.
|
||||
///
|
||||
/// Models superimposed neural oscillation bands:
|
||||
/// - Alpha (8-13 Hz): ~50 fT
|
||||
/// - Beta (13-30 Hz): ~20 fT
|
||||
/// - Gamma (30-100 Hz): ~5 fT
|
||||
fn brain_signal_ft(&self, t: f64, ch: usize) -> f64 {
|
||||
let sens = self.calibration.sensitivity_ft_per_count[ch];
|
||||
// Scale amplitudes by channel sensitivity (higher sensitivity = larger signal)
|
||||
let scale = sens / 0.1; // normalized to default sensitivity
|
||||
|
||||
// Alpha band: 10 Hz representative frequency
|
||||
let alpha = 50.0 * scale * (2.0 * PI * 10.0 * t + 0.3 * ch as f64).sin();
|
||||
// Beta band: 20 Hz representative frequency
|
||||
let beta = 20.0 * scale * (2.0 * PI * 20.0 * t + 0.7 * ch as f64).sin();
|
||||
// Gamma band: 40 Hz representative frequency
|
||||
let gamma = 5.0 * scale * (2.0 * PI * 40.0 * t + 1.1 * ch as f64).sin();
|
||||
|
||||
alpha + beta + gamma
|
||||
}
|
||||
}
|
||||
|
||||
impl SensorSource for NvDiamondArray {
|
||||
fn sensor_type(&self) -> SensorType {
|
||||
SensorType::NvDiamond
|
||||
}
|
||||
|
||||
fn num_channels(&self) -> usize {
|
||||
self.config.num_channels
|
||||
}
|
||||
|
||||
fn sample_rate_hz(&self) -> f64 {
|
||||
self.config.sample_rate_hz
|
||||
}
|
||||
|
||||
fn read_chunk(&mut self, num_samples: usize) -> Result<MultiChannelTimeSeries> {
|
||||
let timestamp = self.sample_counter as f64 / self.config.sample_rate_hz;
|
||||
let dt = 1.0 / self.config.sample_rate_hz;
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
let data: Vec<Vec<f64>> = (0..self.config.num_channels)
|
||||
.map(|ch| {
|
||||
let noise_floor = self.calibration.noise_floor_ft[ch];
|
||||
// White noise (shot noise) scaled to noise floor.
|
||||
// noise_floor is in fT/sqrt(Hz), convert to per-sample sigma.
|
||||
let white_sigma = noise_floor * (self.config.sample_rate_hz / 2.0).sqrt();
|
||||
|
||||
// 1/f (pink) noise amplitude: comparable to white noise floor
|
||||
// but spectrally shaped to dominate at low frequencies.
|
||||
let pink_amplitude = noise_floor * 2.0;
|
||||
|
||||
(0..num_samples)
|
||||
.map(|s| {
|
||||
let t = timestamp + s as f64 * dt;
|
||||
|
||||
// 1. Brain signal: alpha + beta + gamma oscillations
|
||||
let brain = self.brain_signal_ft(t, ch);
|
||||
|
||||
// 2. 1/f (pink) noise from Voss-McCartney generator
|
||||
let pink = pink_amplitude * self.pink_state[ch].next(&mut rng);
|
||||
|
||||
// 3. White (shot) noise floor
|
||||
let white = white_sigma * box_muller_single(&mut rng);
|
||||
|
||||
// Sum all components
|
||||
brain + pink + white
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.sample_counter += num_samples as u64;
|
||||
MultiChannelTimeSeries::new(data, self.config.sample_rate_hz, timestamp)
|
||||
}
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
//! OPM (Optically Pumped Magnetometer) interface.
|
||||
//!
|
||||
//! OPMs operating in SERF (Spin-Exchange Relaxation Free) mode provide
|
||||
//! ~7 fT/sqrt(Hz) sensitivity in a compact, cryogen-free package suitable
|
||||
//! for wearable MEG systems. This module implements the acquisition interface,
|
||||
//! cross-talk compensation via Gaussian elimination, active shielding, and a
|
||||
//! physically realistic signal model with neural oscillations and powerline
|
||||
//! interference.
|
||||
|
||||
use ruv_neural_core::error::{Result, RuvNeuralError};
|
||||
use ruv_neural_core::sensor::{SensorArray, SensorChannel, SensorType};
|
||||
use ruv_neural_core::signal::MultiChannelTimeSeries;
|
||||
use ruv_neural_core::traits::SensorSource;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Configuration for an OPM sensor array.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpmConfig {
|
||||
/// Number of OPM sensors.
|
||||
pub num_channels: usize,
|
||||
/// Sample rate in Hz.
|
||||
pub sample_rate_hz: f64,
|
||||
/// Whether SERF mode is enabled (spin-exchange relaxation free).
|
||||
pub serf_mode: bool,
|
||||
/// Helmet geometry: channel positions in head-frame coordinates.
|
||||
pub channel_positions: Vec<[f64; 3]>,
|
||||
/// Per-channel sensitivity in fT/sqrt(Hz).
|
||||
pub sensitivities: Vec<f64>,
|
||||
/// Cross-talk matrix (num_channels x num_channels).
|
||||
/// `cross_talk[i][j]` is the coupling from channel j into channel i.
|
||||
pub cross_talk: Vec<Vec<f64>>,
|
||||
/// Active shielding compensation coefficients per channel.
|
||||
pub active_shielding_coeffs: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Default for OpmConfig {
|
||||
fn default() -> Self {
|
||||
let num_channels = 32;
|
||||
let positions: Vec<[f64; 3]> = (0..num_channels)
|
||||
.map(|i| {
|
||||
let phi = 2.0 * PI * i as f64 / num_channels as f64;
|
||||
let theta = PI / 4.0 + (i as f64 / num_channels as f64) * PI / 2.0;
|
||||
let r = 0.1;
|
||||
[
|
||||
r * theta.sin() * phi.cos(),
|
||||
r * theta.sin() * phi.sin(),
|
||||
r * theta.cos(),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let sensitivities = vec![7.0; num_channels];
|
||||
// Identity cross-talk (no coupling).
|
||||
let cross_talk = (0..num_channels)
|
||||
.map(|i| {
|
||||
(0..num_channels)
|
||||
.map(|j| if i == j { 1.0 } else { 0.0 })
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let active_shielding_coeffs = vec![1.0; num_channels];
|
||||
|
||||
Self {
|
||||
num_channels,
|
||||
sample_rate_hz: 1000.0,
|
||||
serf_mode: true,
|
||||
channel_positions: positions,
|
||||
sensitivities,
|
||||
cross_talk,
|
||||
active_shielding_coeffs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OPM sensor array.
|
||||
///
|
||||
/// Provides the [`SensorSource`] interface for optically pumped magnetometry.
|
||||
/// Generates SERF-mode magnetometer signals with realistic bandwidth (DC to
|
||||
/// ~200 Hz), neural oscillations (alpha/beta/gamma), powerline harmonics,
|
||||
/// and applies full cross-talk compensation and active shielding.
|
||||
#[derive(Debug)]
|
||||
pub struct OpmArray {
|
||||
config: OpmConfig,
|
||||
array: SensorArray,
|
||||
sample_counter: u64,
|
||||
}
|
||||
|
||||
impl OpmArray {
|
||||
/// Create a new OPM array from configuration.
|
||||
pub fn new(config: OpmConfig) -> Self {
|
||||
let channels = (0..config.num_channels)
|
||||
.map(|i| {
|
||||
let pos = config
|
||||
.channel_positions
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or([0.0, 0.0, 0.0]);
|
||||
let sens = config.sensitivities.get(i).copied().unwrap_or(7.0);
|
||||
SensorChannel {
|
||||
id: i,
|
||||
sensor_type: SensorType::Opm,
|
||||
position: pos,
|
||||
orientation: [0.0, 0.0, 1.0],
|
||||
sensitivity_ft_sqrt_hz: sens,
|
||||
sample_rate_hz: config.sample_rate_hz,
|
||||
label: format!("OPM-{:03}", i),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let array = SensorArray {
|
||||
channels,
|
||||
sensor_type: SensorType::Opm,
|
||||
name: "OpmArray".to_string(),
|
||||
};
|
||||
|
||||
Self {
|
||||
config,
|
||||
array,
|
||||
sample_counter: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the sensor array metadata.
|
||||
pub fn sensor_array(&self) -> &SensorArray {
|
||||
&self.array
|
||||
}
|
||||
|
||||
/// Apply cross-talk compensation to raw channel data.
|
||||
///
|
||||
/// Solves the linear system `cross_talk * corrected = raw` to obtain
|
||||
/// `corrected = inv(cross_talk) * raw`. Falls back to diagonal-only
|
||||
/// correction if the cross-talk matrix is singular.
|
||||
pub fn compensate_cross_talk(&self, raw: &mut [f64]) -> Result<()> {
|
||||
if raw.len() != self.config.num_channels {
|
||||
return Err(RuvNeuralError::DimensionMismatch {
|
||||
expected: self.config.num_channels,
|
||||
got: raw.len(),
|
||||
});
|
||||
}
|
||||
if let Some(corrected) = solve_linear_system(&self.config.cross_talk, raw) {
|
||||
raw.copy_from_slice(&corrected);
|
||||
} else {
|
||||
// Fallback: diagonal scaling when the matrix is singular.
|
||||
for (i, val) in raw.iter_mut().enumerate() {
|
||||
let diag = self.config.cross_talk[i][i];
|
||||
if diag.abs() > 1e-15 {
|
||||
*val /= diag;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply full cross-talk compensation to an entire time-series matrix.
|
||||
///
|
||||
/// `data` is laid out as channels x samples. The cross-talk system is
|
||||
/// solved independently for each time point (column).
|
||||
pub fn full_cross_talk_compensation(&self, data: &mut Vec<Vec<f64>>) -> Result<()> {
|
||||
let n = self.config.num_channels;
|
||||
if data.len() != n {
|
||||
return Err(RuvNeuralError::DimensionMismatch {
|
||||
expected: n,
|
||||
got: data.len(),
|
||||
});
|
||||
}
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let num_samples = data[0].len();
|
||||
for ch_data in data.iter() {
|
||||
if ch_data.len() != num_samples {
|
||||
return Err(RuvNeuralError::Sensor(
|
||||
"all channels must have the same number of samples".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for t in 0..num_samples {
|
||||
let mut col: Vec<f64> = data.iter().map(|ch| ch[t]).collect();
|
||||
self.compensate_cross_talk(&mut col)?;
|
||||
for (ch, val) in col.into_iter().enumerate() {
|
||||
data[ch][t] = val;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply active shielding compensation.
|
||||
pub fn apply_active_shielding(&self, data: &mut [f64]) -> Result<()> {
|
||||
if data.len() != self.config.num_channels {
|
||||
return Err(RuvNeuralError::DimensionMismatch {
|
||||
expected: self.config.num_channels,
|
||||
got: data.len(),
|
||||
});
|
||||
}
|
||||
for (i, val) in data.iter_mut().enumerate() {
|
||||
*val *= self.config.active_shielding_coeffs[i];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Solve the linear system `matrix * x = rhs` using Gaussian elimination
|
||||
/// with partial pivoting.
|
||||
///
|
||||
/// Returns `None` if the matrix is singular (any pivot magnitude < 1e-12).
|
||||
fn solve_linear_system(matrix: &[Vec<f64>], rhs: &[f64]) -> Option<Vec<f64>> {
|
||||
let n = rhs.len();
|
||||
if matrix.len() != n {
|
||||
return None;
|
||||
}
|
||||
for row in matrix.iter() {
|
||||
if row.len() != n {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Build augmented matrix [A | b].
|
||||
let mut aug: Vec<Vec<f64>> = matrix
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, row)| {
|
||||
let mut r = row.clone();
|
||||
r.push(rhs[i]);
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Forward elimination with partial pivoting.
|
||||
for col in 0..n {
|
||||
// Find pivot row.
|
||||
let mut max_abs = aug[col][col].abs();
|
||||
let mut max_row = col;
|
||||
for row in (col + 1)..n {
|
||||
let a = aug[row][col].abs();
|
||||
if a > max_abs {
|
||||
max_abs = a;
|
||||
max_row = row;
|
||||
}
|
||||
}
|
||||
if max_abs < 1e-12 {
|
||||
return None; // Singular.
|
||||
}
|
||||
if max_row != col {
|
||||
aug.swap(col, max_row);
|
||||
}
|
||||
|
||||
let pivot = aug[col][col];
|
||||
for row in (col + 1)..n {
|
||||
let factor = aug[row][col] / pivot;
|
||||
for j in col..=n {
|
||||
let above = aug[col][j];
|
||||
aug[row][j] -= factor * above;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back-substitution.
|
||||
let mut x = vec![0.0; n];
|
||||
for i in (0..n).rev() {
|
||||
let mut sum = aug[i][n];
|
||||
for j in (i + 1)..n {
|
||||
sum -= aug[i][j] * x[j];
|
||||
}
|
||||
if aug[i][i].abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
x[i] = sum / aug[i][i];
|
||||
}
|
||||
Some(x)
|
||||
}
|
||||
|
||||
impl SensorSource for OpmArray {
|
||||
fn sensor_type(&self) -> SensorType {
|
||||
SensorType::Opm
|
||||
}
|
||||
|
||||
fn num_channels(&self) -> usize {
|
||||
self.config.num_channels
|
||||
}
|
||||
|
||||
fn sample_rate_hz(&self) -> f64 {
|
||||
self.config.sample_rate_hz
|
||||
}
|
||||
|
||||
fn read_chunk(&mut self, num_samples: usize) -> Result<MultiChannelTimeSeries> {
|
||||
let timestamp = self.sample_counter as f64 / self.config.sample_rate_hz;
|
||||
let dt = 1.0 / self.config.sample_rate_hz;
|
||||
let powerline_freq = 60.0; // Hz (could be made configurable)
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
let data: Vec<Vec<f64>> = (0..self.config.num_channels)
|
||||
.map(|ch| {
|
||||
let sens = self.config.sensitivities.get(ch).copied().unwrap_or(7.0);
|
||||
// White noise: sensitivity in fT/sqrt(Hz) -> per-sample sigma
|
||||
let white_sigma = sens * (self.config.sample_rate_hz / 2.0).sqrt();
|
||||
let scale = sens / 7.0; // normalized to default sensitivity
|
||||
let shielding = self.config.active_shielding_coeffs
|
||||
.get(ch).copied().unwrap_or(1.0);
|
||||
|
||||
(0..num_samples)
|
||||
.map(|s| {
|
||||
let t = timestamp + s as f64 * dt;
|
||||
|
||||
// 1. Brain signal: alpha + beta + gamma neural oscillations
|
||||
let alpha = 50.0 * scale * (2.0 * PI * 10.0 * t + 0.3 * ch as f64).sin();
|
||||
let beta = 20.0 * scale * (2.0 * PI * 20.0 * t + 0.7 * ch as f64).sin();
|
||||
let gamma = 5.0 * scale * (2.0 * PI * 40.0 * t + 1.1 * ch as f64).sin();
|
||||
let brain = alpha + beta + gamma;
|
||||
|
||||
// 2. Powerline harmonics (50/60 Hz + 2nd/3rd harmonics)
|
||||
// Active shielding attenuates environmental interference.
|
||||
// A shielding coeff of 1.0 means "fully compensated" (no residual).
|
||||
// Values < 1.0 leave residual interference.
|
||||
let residual = (1.0 - shielding.clamp(0.0, 1.0)).max(0.0);
|
||||
let powerline = 500.0 * residual
|
||||
* ((2.0 * PI * powerline_freq * t).sin()
|
||||
+ 0.3 * (2.0 * PI * 2.0 * powerline_freq * t).sin()
|
||||
+ 0.1 * (2.0 * PI * 3.0 * powerline_freq * t).sin());
|
||||
|
||||
// 3. White noise floor (SERF-mode thermal noise)
|
||||
let u1: f64 = rand::Rng::gen::<f64>(&mut rng).max(1e-15);
|
||||
let u2: f64 = rand::Rng::gen(&mut rng);
|
||||
let white = white_sigma * (-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos();
|
||||
|
||||
brain + powerline + white
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.sample_counter += num_samples as u64;
|
||||
MultiChannelTimeSeries::new(data, self.config.sample_rate_hz, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Helper: build a small OpmArray with a given cross-talk matrix.
|
||||
fn make_opm(cross_talk: Vec<Vec<f64>>) -> OpmArray {
|
||||
let n = cross_talk.len();
|
||||
let config = OpmConfig {
|
||||
num_channels: n,
|
||||
sample_rate_hz: 1000.0,
|
||||
serf_mode: true,
|
||||
channel_positions: vec![[0.0, 0.0, 0.0]; n],
|
||||
sensitivities: vec![7.0; n],
|
||||
cross_talk,
|
||||
active_shielding_coeffs: vec![1.0; n],
|
||||
};
|
||||
OpmArray::new(config)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_cross_talk_is_noop() {
|
||||
let ct = vec![
|
||||
vec![1.0, 0.0, 0.0],
|
||||
vec![0.0, 1.0, 0.0],
|
||||
vec![0.0, 0.0, 1.0],
|
||||
];
|
||||
let opm = make_opm(ct);
|
||||
let mut data = vec![1.0, 2.0, 3.0];
|
||||
opm.compensate_cross_talk(&mut data).unwrap();
|
||||
assert!((data[0] - 1.0).abs() < 1e-12);
|
||||
assert!((data[1] - 2.0).abs() < 1e-12);
|
||||
assert!((data[2] - 3.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_3x3_cross_talk_solution() {
|
||||
// Cross-talk matrix C, raw vector b.
|
||||
// We pick a known x, compute b = C * x, then verify compensation recovers x.
|
||||
let ct = vec![
|
||||
vec![2.0, 1.0, 0.0],
|
||||
vec![0.0, 3.0, 1.0],
|
||||
vec![1.0, 0.0, 2.0],
|
||||
];
|
||||
// Known corrected values.
|
||||
let expected = vec![1.0, 2.0, 3.0];
|
||||
// raw = C * expected.
|
||||
let mut raw = vec![
|
||||
2.0 * 1.0 + 1.0 * 2.0 + 0.0 * 3.0, // 4.0
|
||||
0.0 * 1.0 + 3.0 * 2.0 + 1.0 * 3.0, // 9.0
|
||||
1.0 * 1.0 + 0.0 * 2.0 + 2.0 * 3.0, // 7.0
|
||||
];
|
||||
let opm = make_opm(ct);
|
||||
opm.compensate_cross_talk(&mut raw).unwrap();
|
||||
for (got, want) in raw.iter().zip(expected.iter()) {
|
||||
assert!(
|
||||
(got - want).abs() < 1e-10,
|
||||
"got {got}, want {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singular_matrix_falls_back_to_diagonal() {
|
||||
// Singular: row 1 == row 0.
|
||||
let ct = vec![
|
||||
vec![2.0, 1.0],
|
||||
vec![2.0, 1.0],
|
||||
];
|
||||
let opm = make_opm(ct);
|
||||
let mut data = vec![4.0, 6.0];
|
||||
// Should not error -- falls back to diagonal.
|
||||
opm.compensate_cross_talk(&mut data).unwrap();
|
||||
// Diagonal fallback: data[0] /= 2.0, data[1] /= 1.0.
|
||||
assert!((data[0] - 2.0).abs() < 1e-12);
|
||||
assert!((data[1] - 6.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solve_linear_system_basic() {
|
||||
let mat = vec![
|
||||
vec![1.0, 0.0],
|
||||
vec![0.0, 1.0],
|
||||
];
|
||||
let rhs = vec![5.0, 7.0];
|
||||
let x = solve_linear_system(&mat, &rhs).unwrap();
|
||||
assert!((x[0] - 5.0).abs() < 1e-12);
|
||||
assert!((x[1] - 7.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solve_linear_system_singular_returns_none() {
|
||||
let mat = vec![
|
||||
vec![1.0, 2.0],
|
||||
vec![2.0, 4.0],
|
||||
];
|
||||
let rhs = vec![3.0, 6.0];
|
||||
assert!(solve_linear_system(&mat, &rhs).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_cross_talk_compensation_time_series() {
|
||||
let ct = vec![
|
||||
vec![2.0, 1.0, 0.0],
|
||||
vec![0.0, 3.0, 1.0],
|
||||
vec![1.0, 0.0, 2.0],
|
||||
];
|
||||
let opm = make_opm(ct.clone());
|
||||
|
||||
// Two time points with known corrected values.
|
||||
let expected_t0 = vec![1.0, 2.0, 3.0];
|
||||
let expected_t1 = vec![4.0, 5.0, 6.0];
|
||||
|
||||
// Compute raw = C * expected for each time point.
|
||||
let raw_t0: Vec<f64> = (0..3)
|
||||
.map(|i| ct[i].iter().zip(&expected_t0).map(|(c, x)| c * x).sum())
|
||||
.collect();
|
||||
let raw_t1: Vec<f64> = (0..3)
|
||||
.map(|i| ct[i].iter().zip(&expected_t1).map(|(c, x)| c * x).sum())
|
||||
.collect();
|
||||
|
||||
// data layout: channels x samples.
|
||||
let mut data = vec![
|
||||
vec![raw_t0[0], raw_t1[0]],
|
||||
vec![raw_t0[1], raw_t1[1]],
|
||||
vec![raw_t0[2], raw_t1[2]],
|
||||
];
|
||||
|
||||
opm.full_cross_talk_compensation(&mut data).unwrap();
|
||||
|
||||
for (ch, (e0, e1)) in [expected_t0, expected_t1]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(t, exp)| exp.iter().enumerate().map(move |(ch, &v)| (ch, (t, v))))
|
||||
.fold(
|
||||
vec![(0.0, 0.0); 3],
|
||||
|mut acc, (ch, (t, v))| {
|
||||
if t == 0 { acc[ch].0 = v; } else { acc[ch].1 = v; }
|
||||
acc
|
||||
},
|
||||
)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
assert!(
|
||||
(data[ch][0] - e0).abs() < 1e-10,
|
||||
"ch{ch} t0: got {}, want {e0}",
|
||||
data[ch][0]
|
||||
);
|
||||
assert!(
|
||||
(data[ch][1] - e1).abs() < 1e-10,
|
||||
"ch{ch} t1: got {}, want {e1}",
|
||||
data[ch][1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_mismatch_error() {
|
||||
let opm = make_opm(vec![vec![1.0]]);
|
||||
let mut data = vec![1.0, 2.0];
|
||||
assert!(opm.compensate_cross_talk(&mut data).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
//! Signal quality monitoring for neural sensor channels.
|
||||
|
||||
/// Signal quality metrics for a single channel.
|
||||
pub struct SignalQuality {
|
||||
/// Signal-to-noise ratio in dB.
|
||||
pub snr_db: f64,
|
||||
/// Probability of artifact contamination in [0, 1].
|
||||
pub artifact_probability: f64,
|
||||
/// Whether the channel is saturated (clipping).
|
||||
pub saturated: bool,
|
||||
}
|
||||
|
||||
impl SignalQuality {
|
||||
/// Returns true if signal quality is below acceptable thresholds.
|
||||
///
|
||||
/// Thresholds: SNR < 3 dB or artifact_probability > 0.5.
|
||||
pub fn below_threshold(&self) -> bool {
|
||||
self.snr_db < 3.0 || self.artifact_probability > 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// Real-time signal quality monitor for multi-channel data.
|
||||
pub struct QualityMonitor {
|
||||
num_channels: usize,
|
||||
}
|
||||
|
||||
impl QualityMonitor {
|
||||
/// Create a new quality monitor for the given number of channels.
|
||||
pub fn new(num_channels: usize) -> Self {
|
||||
Self { num_channels }
|
||||
}
|
||||
|
||||
/// Check signal quality for each channel.
|
||||
///
|
||||
/// Each element in `signals` is a slice of samples for one channel.
|
||||
pub fn check_quality(&mut self, signals: &[&[f64]]) -> Vec<SignalQuality> {
|
||||
let n = signals.len().min(self.num_channels);
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let signal = signals[i];
|
||||
let snr_db = estimate_snr_db(signal);
|
||||
let saturated = detect_saturation(signal);
|
||||
let artifact_probability = if saturated { 0.9 } else { 0.0 };
|
||||
SignalQuality {
|
||||
snr_db,
|
||||
artifact_probability,
|
||||
saturated,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate SNR in dB from a signal segment.
|
||||
fn estimate_snr_db(signal: &[f64]) -> f64 {
|
||||
if signal.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mean = signal.iter().sum::<f64>() / signal.len() as f64;
|
||||
let variance = signal.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / signal.len() as f64;
|
||||
let rms = variance.sqrt();
|
||||
if rms < 1e-15 {
|
||||
return 0.0;
|
||||
}
|
||||
let n = signal.len();
|
||||
if n < 4 {
|
||||
return 20.0 * rms.log10();
|
||||
}
|
||||
// Estimate noise as std of first differences (captures high-freq content).
|
||||
let diff_var = signal
|
||||
.windows(2)
|
||||
.map(|w| (w[1] - w[0]).powi(2))
|
||||
.sum::<f64>()
|
||||
/ (n - 1) as f64;
|
||||
let noise_power = diff_var / 2.0;
|
||||
let signal_power = variance;
|
||||
if noise_power < 1e-15 {
|
||||
return 60.0;
|
||||
}
|
||||
10.0 * (signal_power / noise_power).log10()
|
||||
}
|
||||
|
||||
/// Detect if a signal is saturated (extreme repeated values).
|
||||
fn detect_saturation(signal: &[f64]) -> bool {
|
||||
if signal.len() < 10 {
|
||||
return false;
|
||||
}
|
||||
let max_abs = signal.iter().map(|x| x.abs()).fold(0.0_f64, f64::max);
|
||||
if max_abs < 1e-10 {
|
||||
return false;
|
||||
}
|
||||
let threshold = max_abs * 0.999;
|
||||
let clipped_count = signal.iter().filter(|x| x.abs() >= threshold).count();
|
||||
clipped_count as f64 / signal.len() as f64 > 0.1
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
//! Simulated sensor array for testing and development.
|
||||
//!
|
||||
//! Generates realistic synthetic neural magnetic field data with configurable
|
||||
//! channels, sample rate, noise floor, and injectable events.
|
||||
|
||||
use rand::Rng;
|
||||
use ruv_neural_core::error::Result;
|
||||
use ruv_neural_core::sensor::{SensorArray, SensorChannel, SensorType};
|
||||
use ruv_neural_core::signal::MultiChannelTimeSeries;
|
||||
use ruv_neural_core::traits::SensorSource;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// An injectable event that modifies the simulated signal.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum SensorEvent {
|
||||
/// A sharp spike at a specific sample offset.
|
||||
Spike {
|
||||
/// Channel to inject the spike into.
|
||||
channel: usize,
|
||||
/// Amplitude in femtotesla.
|
||||
amplitude_ft: f64,
|
||||
/// Sample offset from the start of the next acquisition.
|
||||
sample_offset: usize,
|
||||
},
|
||||
/// A burst of oscillatory activity.
|
||||
OscillationBurst {
|
||||
/// Channel to inject the burst into.
|
||||
channel: usize,
|
||||
/// Frequency of oscillation in Hz.
|
||||
frequency_hz: f64,
|
||||
/// Amplitude in femtotesla.
|
||||
amplitude_ft: f64,
|
||||
/// Start sample offset.
|
||||
start_sample: usize,
|
||||
/// Duration in samples.
|
||||
duration_samples: usize,
|
||||
},
|
||||
/// A DC level shift.
|
||||
DcShift {
|
||||
/// Channel to inject the shift into.
|
||||
channel: usize,
|
||||
/// Shift magnitude in femtotesla.
|
||||
shift_ft: f64,
|
||||
/// Sample offset at which the shift begins.
|
||||
start_sample: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Configuration for an oscillation component injected into the simulator.
|
||||
#[derive(Debug, Clone)]
|
||||
struct OscillationComponent {
|
||||
/// Frequency in Hz.
|
||||
frequency_hz: f64,
|
||||
/// Amplitude in femtotesla.
|
||||
amplitude_ft: f64,
|
||||
}
|
||||
|
||||
/// Simulated sensor array that generates synthetic neural magnetic field data.
|
||||
///
|
||||
/// The simulator produces multi-channel time series with configurable noise,
|
||||
/// background oscillations (alpha, beta, etc.), and injectable transient events.
|
||||
#[derive(Debug)]
|
||||
pub struct SimulatedSensorArray {
|
||||
/// Number of channels (4-256).
|
||||
num_channels: usize,
|
||||
/// Sample rate in Hz (100-10000).
|
||||
sample_rate_hz: f64,
|
||||
/// Noise floor density in fT/sqrt(Hz).
|
||||
noise_density_ft: f64,
|
||||
/// Background oscillation components active on all channels.
|
||||
oscillations: Vec<OscillationComponent>,
|
||||
/// Pending events to inject on the next acquisition.
|
||||
pending_events: Vec<SensorEvent>,
|
||||
/// Current phase accumulator (sample counter).
|
||||
sample_counter: u64,
|
||||
/// Sensor array metadata.
|
||||
array: SensorArray,
|
||||
/// Random number generator.
|
||||
rng: rand::rngs::ThreadRng,
|
||||
}
|
||||
|
||||
impl SimulatedSensorArray {
|
||||
/// Create a new simulated sensor array.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `num_channels` - Number of channels (clamped to 4..=256).
|
||||
/// * `sample_rate_hz` - Sample rate in Hz (clamped to 100..=10000).
|
||||
pub fn new(num_channels: usize, sample_rate_hz: f64) -> Self {
|
||||
let num_channels = num_channels.clamp(4, 256);
|
||||
let sample_rate_hz = sample_rate_hz.clamp(100.0, 10000.0);
|
||||
|
||||
let channels = (0..num_channels)
|
||||
.map(|i| {
|
||||
let angle = 2.0 * PI * i as f64 / num_channels as f64;
|
||||
let radius = 0.1; // 10 cm from center
|
||||
SensorChannel {
|
||||
id: i,
|
||||
sensor_type: SensorType::NvDiamond,
|
||||
position: [radius * angle.cos(), radius * angle.sin(), 0.0],
|
||||
orientation: [0.0, 0.0, 1.0],
|
||||
sensitivity_ft_sqrt_hz: 10.0,
|
||||
sample_rate_hz,
|
||||
label: format!("SIM-{:03}", i),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let array = SensorArray {
|
||||
channels,
|
||||
sensor_type: SensorType::NvDiamond,
|
||||
name: "SimulatedSensorArray".to_string(),
|
||||
};
|
||||
|
||||
Self {
|
||||
num_channels,
|
||||
sample_rate_hz,
|
||||
noise_density_ft: 10.0,
|
||||
oscillations: Vec::new(),
|
||||
pending_events: Vec::new(),
|
||||
sample_counter: 0,
|
||||
array,
|
||||
rng: rand::thread_rng(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the noise floor density in fT/sqrt(Hz).
|
||||
///
|
||||
/// Returns self for builder-pattern chaining.
|
||||
pub fn with_noise(mut self, noise_density_ft: f64) -> Self {
|
||||
self.noise_density_ft = noise_density_ft;
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject an alpha rhythm (~10 Hz) into all channels.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `amplitude_ft` - Peak amplitude in femtotesla (typical: ~100 fT).
|
||||
pub fn inject_alpha(&mut self, amplitude_ft: f64) {
|
||||
self.oscillations.push(OscillationComponent {
|
||||
frequency_hz: 10.0,
|
||||
amplitude_ft,
|
||||
});
|
||||
}
|
||||
|
||||
/// Inject a transient event to appear in the next acquisition.
|
||||
pub fn inject_event(&mut self, event: SensorEvent) {
|
||||
self.pending_events.push(event);
|
||||
}
|
||||
|
||||
/// Returns the sensor array metadata.
|
||||
pub fn sensor_array(&self) -> &SensorArray {
|
||||
&self.array
|
||||
}
|
||||
|
||||
/// Add a custom oscillation component to all channels.
|
||||
pub fn add_oscillation(&mut self, frequency_hz: f64, amplitude_ft: f64) {
|
||||
self.oscillations.push(OscillationComponent {
|
||||
frequency_hz,
|
||||
amplitude_ft,
|
||||
});
|
||||
}
|
||||
|
||||
/// Generate samples for one channel.
|
||||
fn generate_channel(&mut self, channel_idx: usize, num_samples: usize) -> Vec<f64> {
|
||||
let dt = 1.0 / self.sample_rate_hz;
|
||||
// Noise standard deviation: density * sqrt(bandwidth).
|
||||
// For white noise sampled at fs, the per-sample sigma = density * sqrt(fs / 2).
|
||||
let noise_sigma = self.noise_density_ft * (self.sample_rate_hz / 2.0).sqrt();
|
||||
|
||||
let mut samples = Vec::with_capacity(num_samples);
|
||||
|
||||
for s in 0..num_samples {
|
||||
let t = (self.sample_counter + s as u64) as f64 * dt;
|
||||
let mut value = 0.0;
|
||||
|
||||
// Add oscillation components with slight per-channel phase offset.
|
||||
let phase_offset = channel_idx as f64 * 0.1;
|
||||
for osc in &self.oscillations {
|
||||
value +=
|
||||
osc.amplitude_ft * (2.0 * PI * osc.frequency_hz * t + phase_offset).sin();
|
||||
}
|
||||
|
||||
// Add Gaussian noise.
|
||||
if noise_sigma > 0.0 {
|
||||
let noise: f64 = self.rng.gen::<f64>() * 2.0 - 1.0;
|
||||
let noise2: f64 = self.rng.gen::<f64>() * 2.0 - 1.0;
|
||||
// Box-Muller transform for Gaussian noise.
|
||||
let u1 = self.rng.gen::<f64>().max(1e-15);
|
||||
let u2 = self.rng.gen::<f64>();
|
||||
let gaussian = (-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos();
|
||||
value += noise_sigma * gaussian;
|
||||
let _ = (noise, noise2); // suppress unused
|
||||
}
|
||||
|
||||
samples.push(value);
|
||||
}
|
||||
|
||||
// Apply pending events for this channel.
|
||||
for event in &self.pending_events {
|
||||
match event {
|
||||
SensorEvent::Spike {
|
||||
channel,
|
||||
amplitude_ft,
|
||||
sample_offset,
|
||||
} => {
|
||||
if *channel == channel_idx && *sample_offset < num_samples {
|
||||
samples[*sample_offset] += amplitude_ft;
|
||||
}
|
||||
}
|
||||
SensorEvent::OscillationBurst {
|
||||
channel,
|
||||
frequency_hz,
|
||||
amplitude_ft,
|
||||
start_sample,
|
||||
duration_samples,
|
||||
} => {
|
||||
if *channel == channel_idx {
|
||||
let end = (*start_sample + *duration_samples).min(num_samples);
|
||||
for s in *start_sample..end {
|
||||
let t = s as f64 / self.sample_rate_hz;
|
||||
samples[s] += amplitude_ft * (2.0 * PI * frequency_hz * t).sin();
|
||||
}
|
||||
}
|
||||
}
|
||||
SensorEvent::DcShift {
|
||||
channel,
|
||||
shift_ft,
|
||||
start_sample,
|
||||
} => {
|
||||
if *channel == channel_idx {
|
||||
for s in *start_sample..num_samples {
|
||||
samples[s] += shift_ft;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
samples
|
||||
}
|
||||
}
|
||||
|
||||
impl SensorSource for SimulatedSensorArray {
|
||||
fn sensor_type(&self) -> SensorType {
|
||||
SensorType::NvDiamond
|
||||
}
|
||||
|
||||
fn num_channels(&self) -> usize {
|
||||
self.num_channels
|
||||
}
|
||||
|
||||
fn sample_rate_hz(&self) -> f64 {
|
||||
self.sample_rate_hz
|
||||
}
|
||||
|
||||
fn read_chunk(&mut self, num_samples: usize) -> Result<MultiChannelTimeSeries> {
|
||||
let timestamp = self.sample_counter as f64 / self.sample_rate_hz;
|
||||
|
||||
let mut data = Vec::with_capacity(self.num_channels);
|
||||
for ch in 0..self.num_channels {
|
||||
data.push(self.generate_channel(ch, num_samples));
|
||||
}
|
||||
|
||||
self.sample_counter += num_samples as u64;
|
||||
self.pending_events.clear();
|
||||
|
||||
MultiChannelTimeSeries::new(data, self.sample_rate_hz, timestamp)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
[package]
|
||||
name = "ruv-neural-signal"
|
||||
description = "rUv Neural — Signal processing: filtering, spectral analysis, artifact rejection for neural data"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = []
|
||||
simd = [] # SIMD-accelerated processing
|
||||
|
||||
[dependencies]
|
||||
ruv-neural-core = { workspace = true }
|
||||
ndarray = { workspace = true }
|
||||
rustfft = { workspace = true }
|
||||
num-complex = { workspace = true }
|
||||
num-traits = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
approx = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
criterion = { workspace = true }
|
||||
|
||||
[[bench]]
|
||||
name = "benchmarks"
|
||||
harness = false
|
||||
@@ -1,90 +0,0 @@
|
||||
# ruv-neural-signal
|
||||
|
||||
Signal processing: filtering, spectral analysis, connectivity metrics, and artifact
|
||||
rejection for neural time series data.
|
||||
|
||||
## Overview
|
||||
|
||||
`ruv-neural-signal` provides a complete digital signal processing pipeline for
|
||||
multi-channel neural magnetic field and electrophysiology data. It covers IIR
|
||||
filtering in second-order sections form, FFT-based spectral analysis, Hilbert
|
||||
transform for instantaneous phase extraction, artifact detection and rejection,
|
||||
cross-channel connectivity metrics, and a configurable multi-stage preprocessing
|
||||
pipeline.
|
||||
|
||||
## Features
|
||||
|
||||
- **IIR Filters** (`filter`): Butterworth bandpass, highpass, lowpass, and notch
|
||||
filters in SOS (second-order sections) form for numerical stability
|
||||
- **Spectral analysis** (`spectral`): Welch PSD estimation, STFT, band power
|
||||
extraction, spectral entropy, and peak frequency detection
|
||||
- **Hilbert transform** (`hilbert`): FFT-based analytic signal for instantaneous
|
||||
phase and amplitude envelope computation
|
||||
- **Artifact detection** (`artifact`): Eye blink, muscle artifact, and cardiac
|
||||
artifact detection with configurable rejection
|
||||
- **Connectivity metrics** (`connectivity`): Phase locking value (PLV), coherence,
|
||||
imaginary coherence, amplitude envelope correlation (AEC), and all-pairs
|
||||
computation for connectivity matrix construction
|
||||
- **Preprocessing pipeline** (`preprocessing`): Configurable multi-stage pipeline
|
||||
chaining filters, artifact rejection, and re-referencing
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use ruv_neural_signal::{
|
||||
BandpassFilter, PreprocessingPipeline, SignalProcessor,
|
||||
compute_psd, band_power, hilbert_transform, instantaneous_phase,
|
||||
compute_all_pairs, ConnectivityMetric,
|
||||
};
|
||||
use ruv_neural_core::FrequencyBand;
|
||||
|
||||
// Apply a bandpass filter (8-13 Hz alpha band)
|
||||
let filter = BandpassFilter::new(8.0, 13.0, 1000.0, 4).unwrap();
|
||||
let filtered = filter.apply(&raw_signal);
|
||||
|
||||
// Compute power spectral density (Welch method)
|
||||
let psd = compute_psd(&signal, 1000.0, 256, 128);
|
||||
let alpha_power = band_power(&psd, 1000.0, 8.0, 13.0);
|
||||
|
||||
// Extract instantaneous phase via Hilbert transform
|
||||
let analytic = hilbert_transform(&signal);
|
||||
let phases = instantaneous_phase(&analytic);
|
||||
|
||||
// Compute all-pairs connectivity matrix
|
||||
let connectivity_matrix = compute_all_pairs(
|
||||
&multi_channel_data,
|
||||
ConnectivityMetric::PhaseLockingValue,
|
||||
);
|
||||
|
||||
// Run full preprocessing pipeline
|
||||
let pipeline = PreprocessingPipeline::default();
|
||||
let clean_data = pipeline.process(&raw_data).unwrap();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Module | Key Types / Functions |
|
||||
|-----------------|-----------------------------------------------------------------|
|
||||
| `filter` | `BandpassFilter`, `HighpassFilter`, `LowpassFilter`, `NotchFilter`, `SignalProcessor` |
|
||||
| `spectral` | `compute_psd`, `compute_stft`, `band_power`, `spectral_entropy`, `peak_frequency` |
|
||||
| `hilbert` | `hilbert_transform`, `instantaneous_phase`, `instantaneous_amplitude` |
|
||||
| `artifact` | `detect_eye_blinks`, `detect_muscle_artifact`, `detect_cardiac`, `reject_artifacts` |
|
||||
| `connectivity` | `phase_locking_value`, `coherence`, `imaginary_coherence`, `amplitude_envelope_correlation`, `compute_all_pairs` |
|
||||
| `preprocessing` | `PreprocessingPipeline` |
|
||||
|
||||
## Feature Flags
|
||||
|
||||
| Feature | Default | Description |
|
||||
|---------|---------|----------------------------------|
|
||||
| `std` | Yes | Standard library support |
|
||||
| `simd` | No | SIMD-accelerated filter kernels |
|
||||
|
||||
## Integration
|
||||
|
||||
Depends on `ruv-neural-core` for `MultiChannelTimeSeries` and `FrequencyBand` types.
|
||||
Feeds processed data into `ruv-neural-graph` for connectivity graph construction.
|
||||
Uses `rustfft` for FFT operations and `ndarray` for matrix computations.
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
@@ -1,105 +0,0 @@
|
||||
//! Criterion benchmarks for ruv-neural-signal.
|
||||
//!
|
||||
//! Benchmarks the performance-critical signal processing functions:
|
||||
//! - Hilbert transform (FFT-based analytic signal)
|
||||
//! - Power spectral density (Welch's method)
|
||||
//! - Connectivity matrix (PLV for all channel pairs)
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
|
||||
use rand::Rng;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use ruv_neural_core::signal::{FrequencyBand, MultiChannelTimeSeries};
|
||||
use ruv_neural_signal::{compute_all_pairs, compute_psd, hilbert_transform, ConnectivityMetric};
|
||||
|
||||
/// Generate a synthetic multi-tone signal of the given length.
|
||||
fn generate_signal(n: usize) -> Vec<f64> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 1000.0;
|
||||
(2.0 * PI * 10.0 * t).sin()
|
||||
+ 0.5 * (2.0 * PI * 25.0 * t).cos()
|
||||
+ 0.3 * (2.0 * PI * 40.0 * t).sin()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate random multi-channel data.
|
||||
fn generate_multichannel(num_channels: usize, num_samples: usize) -> MultiChannelTimeSeries {
|
||||
let mut rng = rand::thread_rng();
|
||||
let data: Vec<Vec<f64>> = (0..num_channels)
|
||||
.map(|ch| {
|
||||
(0..num_samples)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 1000.0;
|
||||
let freq = 8.0 + ch as f64 * 0.5;
|
||||
(2.0 * PI * freq * t).sin() + rng.gen_range(-0.1..0.1)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
MultiChannelTimeSeries {
|
||||
data,
|
||||
sample_rate_hz: 1000.0,
|
||||
num_channels,
|
||||
num_samples,
|
||||
timestamp_start: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn bench_hilbert_transform(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("hilbert_transform");
|
||||
|
||||
for &n in &[256, 1024, 4096] {
|
||||
let signal = generate_signal(n);
|
||||
group.bench_with_input(BenchmarkId::new("samples", n), &signal, |b, signal| {
|
||||
b.iter(|| hilbert_transform(black_box(signal)))
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_compute_psd(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("compute_psd");
|
||||
|
||||
let signal = generate_signal(1024);
|
||||
group.bench_function("1024_samples_win256", |b| {
|
||||
b.iter(|| compute_psd(black_box(&signal), black_box(1000.0), black_box(256)))
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_connectivity_matrix(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("connectivity_matrix");
|
||||
group.sample_size(10);
|
||||
|
||||
for &num_channels in &[16, 32] {
|
||||
let data = generate_multichannel(num_channels, 1024);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("plv_channels", num_channels),
|
||||
&data,
|
||||
|b, data| {
|
||||
b.iter(|| {
|
||||
compute_all_pairs(
|
||||
black_box(data),
|
||||
black_box(ConnectivityMetric::Plv),
|
||||
black_box(FrequencyBand::Alpha),
|
||||
)
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_hilbert_transform,
|
||||
bench_compute_psd,
|
||||
bench_connectivity_matrix,
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -1,391 +0,0 @@
|
||||
//! Artifact detection and rejection for neural recordings.
|
||||
//!
|
||||
//! Detects common physiological and environmental artifacts:
|
||||
//! - Eye blinks: large slow deflections (primarily frontal channels)
|
||||
//! - Muscle artifacts: high-frequency broadband power bursts
|
||||
//! - Cardiac artifacts: QRS complex detection
|
||||
//!
|
||||
//! Provides functions to mark and remove/interpolate artifact periods.
|
||||
|
||||
use ruv_neural_core::signal::MultiChannelTimeSeries;
|
||||
|
||||
use crate::filter::{BandpassFilter, HighpassFilter, LowpassFilter};
|
||||
|
||||
/// Detect eye blink artifacts in a single channel.
|
||||
///
|
||||
/// Eye blinks produce large, slow voltage deflections (1-5 Hz)
|
||||
/// with amplitudes 5-10x the background signal. Detection uses:
|
||||
/// 1. Lowpass filter to isolate slow components
|
||||
/// 2. Amplitude thresholding at `mean + 3*std`
|
||||
/// 3. Merging of nearby detections
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `signal` - Single-channel time series
|
||||
/// * `sample_rate` - Sampling rate in Hz
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of (start_sample, end_sample) ranges for detected blinks.
|
||||
pub fn detect_eye_blinks(signal: &[f64], sample_rate: f64) -> Vec<(usize, usize)> {
|
||||
if signal.len() < (sample_rate * 0.2) as usize {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Lowpass filter at 5 Hz to isolate blink waveform
|
||||
let lp = LowpassFilter::new(2, 5.0, sample_rate);
|
||||
let filtered = lp.apply(signal);
|
||||
|
||||
// Compute absolute values
|
||||
let abs_signal: Vec<f64> = filtered.iter().map(|x| x.abs()).collect();
|
||||
|
||||
// Compute mean and std of the absolute filtered signal
|
||||
let mean = abs_signal.iter().sum::<f64>() / abs_signal.len() as f64;
|
||||
let variance = abs_signal
|
||||
.iter()
|
||||
.map(|x| (x - mean).powi(2))
|
||||
.sum::<f64>()
|
||||
/ abs_signal.len() as f64;
|
||||
let std_dev = variance.sqrt();
|
||||
|
||||
// Threshold at mean + 3*std
|
||||
let threshold = mean + 3.0 * std_dev;
|
||||
|
||||
// Find contiguous regions above threshold
|
||||
let mut ranges = Vec::new();
|
||||
let mut in_artifact = false;
|
||||
let mut start = 0;
|
||||
|
||||
for (i, &val) in abs_signal.iter().enumerate() {
|
||||
if val > threshold && !in_artifact {
|
||||
in_artifact = true;
|
||||
start = i;
|
||||
} else if val <= threshold && in_artifact {
|
||||
in_artifact = false;
|
||||
ranges.push((start, i));
|
||||
}
|
||||
}
|
||||
if in_artifact {
|
||||
ranges.push((start, abs_signal.len()));
|
||||
}
|
||||
|
||||
// Extend ranges by 50ms on each side (blink onset/offset)
|
||||
let pad = (sample_rate * 0.05) as usize;
|
||||
let merged = merge_ranges_with_padding(&ranges, pad, signal.len());
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
/// Detect muscle artifact in a single channel.
|
||||
///
|
||||
/// Muscle artifacts produce broadband high-frequency power (>30 Hz).
|
||||
/// Detection uses:
|
||||
/// 1. Highpass filter at 30 Hz
|
||||
/// 2. Compute sliding window RMS
|
||||
/// 3. Threshold at mean + 3*std of RMS
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of (start_sample, end_sample) ranges for detected artifacts.
|
||||
pub fn detect_muscle_artifact(signal: &[f64], sample_rate: f64) -> Vec<(usize, usize)> {
|
||||
if signal.len() < (sample_rate * 0.1) as usize {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Highpass filter at 30 Hz to isolate muscle activity
|
||||
let hp = HighpassFilter::new(2, 30.0, sample_rate);
|
||||
let filtered = hp.apply(signal);
|
||||
|
||||
// Sliding window RMS (50ms window)
|
||||
let window_len = (sample_rate * 0.05) as usize;
|
||||
let window_len = window_len.max(1);
|
||||
let n = filtered.len();
|
||||
let mut rms_signal = vec![0.0; n];
|
||||
|
||||
// Compute running sum of squares
|
||||
let mut sum_sq = 0.0;
|
||||
for i in 0..n {
|
||||
sum_sq += filtered[i] * filtered[i];
|
||||
if i >= window_len {
|
||||
sum_sq -= filtered[i - window_len] * filtered[i - window_len];
|
||||
}
|
||||
let count = (i + 1).min(window_len);
|
||||
rms_signal[i] = (sum_sq / count as f64).sqrt();
|
||||
}
|
||||
|
||||
// Threshold at mean + 3*std of RMS
|
||||
let mean = rms_signal.iter().sum::<f64>() / n as f64;
|
||||
let variance = rms_signal
|
||||
.iter()
|
||||
.map(|x| (x - mean).powi(2))
|
||||
.sum::<f64>()
|
||||
/ n as f64;
|
||||
let std_dev = variance.sqrt();
|
||||
let threshold = mean + 3.0 * std_dev;
|
||||
|
||||
let mut ranges = Vec::new();
|
||||
let mut in_artifact = false;
|
||||
let mut start = 0;
|
||||
|
||||
for (i, &val) in rms_signal.iter().enumerate() {
|
||||
if val > threshold && !in_artifact {
|
||||
in_artifact = true;
|
||||
start = i;
|
||||
} else if val <= threshold && in_artifact {
|
||||
in_artifact = false;
|
||||
ranges.push((start, i));
|
||||
}
|
||||
}
|
||||
if in_artifact {
|
||||
ranges.push((start, n));
|
||||
}
|
||||
|
||||
let pad = (sample_rate * 0.025) as usize;
|
||||
merge_ranges_with_padding(&ranges, pad, signal.len())
|
||||
}
|
||||
|
||||
/// Detect cardiac (QRS complex) artifact peaks in a single channel.
|
||||
///
|
||||
/// Uses a simplified Pan-Tompkins-style approach:
|
||||
/// 1. Bandpass filter 5-15 Hz
|
||||
/// 2. Differentiate and square
|
||||
/// 3. Moving window integration
|
||||
/// 4. Threshold-based peak detection with refractory period
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of sample indices where QRS peaks are detected.
|
||||
pub fn detect_cardiac(signal: &[f64], sample_rate: f64) -> Vec<usize> {
|
||||
if signal.len() < (sample_rate * 0.5) as usize {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Bandpass 5-15 Hz to isolate QRS complex
|
||||
let bp = BandpassFilter::new(2, 5.0, 15.0, sample_rate);
|
||||
let filtered = bp.apply(signal);
|
||||
|
||||
// Differentiate
|
||||
let n = filtered.len();
|
||||
let mut diff = vec![0.0; n];
|
||||
for i in 1..n {
|
||||
diff[i] = filtered[i] - filtered[i - 1];
|
||||
}
|
||||
|
||||
// Square
|
||||
let squared: Vec<f64> = diff.iter().map(|x| x * x).collect();
|
||||
|
||||
// Moving window integration (150ms window)
|
||||
let win_len = (sample_rate * 0.15) as usize;
|
||||
let win_len = win_len.max(1);
|
||||
let mut integrated = vec![0.0; n];
|
||||
let mut sum = 0.0;
|
||||
|
||||
for i in 0..n {
|
||||
sum += squared[i];
|
||||
if i >= win_len {
|
||||
sum -= squared[i - win_len];
|
||||
}
|
||||
integrated[i] = sum / win_len.min(i + 1) as f64;
|
||||
}
|
||||
|
||||
// Threshold: mean + 0.5*std (tuned for cardiac artifacts which are periodic)
|
||||
let mean = integrated.iter().sum::<f64>() / n as f64;
|
||||
let variance = integrated
|
||||
.iter()
|
||||
.map(|x| (x - mean).powi(2))
|
||||
.sum::<f64>()
|
||||
/ n as f64;
|
||||
let std_dev = variance.sqrt();
|
||||
let threshold = mean + 0.5 * std_dev;
|
||||
|
||||
// Find peaks above threshold with refractory period (200ms)
|
||||
let refractory = (sample_rate * 0.2) as usize;
|
||||
let mut peaks = Vec::new();
|
||||
let mut last_peak: Option<usize> = None;
|
||||
|
||||
for i in 1..(n - 1) {
|
||||
if integrated[i] > threshold
|
||||
&& integrated[i] > integrated[i - 1]
|
||||
&& integrated[i] >= integrated[i + 1]
|
||||
{
|
||||
if let Some(lp) = last_peak {
|
||||
if i - lp < refractory {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
peaks.push(i);
|
||||
last_peak = Some(i);
|
||||
}
|
||||
}
|
||||
|
||||
peaks
|
||||
}
|
||||
|
||||
/// Remove artifacts from multi-channel data by linear interpolation.
|
||||
///
|
||||
/// For each artifact range, replaces the data with a linear interpolation
|
||||
/// between the sample before the range and the sample after the range.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Multi-channel time series
|
||||
/// * `artifact_ranges` - Sorted, non-overlapping (start, end) sample ranges
|
||||
///
|
||||
/// # Returns
|
||||
/// A new `MultiChannelTimeSeries` with artifacts interpolated out.
|
||||
pub fn reject_artifacts(
|
||||
data: &MultiChannelTimeSeries,
|
||||
artifact_ranges: &[(usize, usize)],
|
||||
) -> MultiChannelTimeSeries {
|
||||
let mut clean_data = data.data.clone();
|
||||
|
||||
for channel in &mut clean_data {
|
||||
let n = channel.len();
|
||||
for &(start, end) in artifact_ranges {
|
||||
let start = start.min(n);
|
||||
let end = end.min(n);
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get boundary values for interpolation
|
||||
let val_before = if start > 0 { channel[start - 1] } else { 0.0 };
|
||||
let val_after = if end < n { channel[end] } else { 0.0 };
|
||||
let span = (end - start) as f64;
|
||||
|
||||
// Linear interpolation across the artifact
|
||||
// frac goes from 1/(span+1) to span/(span+1), excluding boundaries
|
||||
let intervals = span + 1.0;
|
||||
for i in start..end {
|
||||
let frac = (i - start + 1) as f64 / intervals;
|
||||
channel[i] = val_before * (1.0 - frac) + val_after * frac;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MultiChannelTimeSeries {
|
||||
data: clean_data,
|
||||
sample_rate_hz: data.sample_rate_hz,
|
||||
num_channels: data.num_channels,
|
||||
num_samples: data.num_samples,
|
||||
timestamp_start: data.timestamp_start,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge artifact ranges and add padding on each side.
|
||||
fn merge_ranges_with_padding(
|
||||
ranges: &[(usize, usize)],
|
||||
pad: usize,
|
||||
max_len: usize,
|
||||
) -> Vec<(usize, usize)> {
|
||||
if ranges.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Pad each range
|
||||
let padded: Vec<(usize, usize)> = ranges
|
||||
.iter()
|
||||
.map(|&(s, e)| (s.saturating_sub(pad), (e + pad).min(max_len)))
|
||||
.collect();
|
||||
|
||||
// Merge overlapping ranges
|
||||
let mut merged = Vec::new();
|
||||
let (mut cur_start, mut cur_end) = padded[0];
|
||||
|
||||
for &(s, e) in &padded[1..] {
|
||||
if s <= cur_end {
|
||||
cur_end = cur_end.max(e);
|
||||
} else {
|
||||
merged.push((cur_start, cur_end));
|
||||
cur_start = s;
|
||||
cur_end = e;
|
||||
}
|
||||
}
|
||||
merged.push((cur_start, cur_end));
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruv_neural_core::signal::MultiChannelTimeSeries;
|
||||
|
||||
#[test]
|
||||
fn detect_eye_blinks_finds_large_deflections() {
|
||||
let sr = 1000.0;
|
||||
let n = 5000;
|
||||
// Create signal with a large slow deflection (simulated blink)
|
||||
let mut signal = vec![0.0; n];
|
||||
// Normal background: small random-like variation
|
||||
for i in 0..n {
|
||||
signal[i] = 0.01 * ((i as f64 * 0.1).sin());
|
||||
}
|
||||
// Insert a blink: large Gaussian-like bump at sample 2500
|
||||
for i in 2400..2600 {
|
||||
let t = (i as f64 - 2500.0) / 30.0;
|
||||
signal[i] += 5.0 * (-t * t / 2.0).exp();
|
||||
}
|
||||
|
||||
let blinks = detect_eye_blinks(&signal, sr);
|
||||
// Should detect at least one blink near sample 2500
|
||||
assert!(
|
||||
!blinks.is_empty(),
|
||||
"Should detect the simulated eye blink"
|
||||
);
|
||||
|
||||
// At least one range should overlap with 2400..2600
|
||||
let found = blinks.iter().any(|&(s, e)| s < 2600 && e > 2400);
|
||||
assert!(found, "Blink range should overlap with injected artifact");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_artifacts_interpolates_correctly() {
|
||||
let data = MultiChannelTimeSeries {
|
||||
data: vec![vec![1.0, 2.0, 100.0, 100.0, 5.0, 6.0]],
|
||||
sample_rate_hz: 1000.0,
|
||||
num_channels: 1,
|
||||
num_samples: 6,
|
||||
timestamp_start: 0.0,
|
||||
};
|
||||
|
||||
let cleaned = reject_artifacts(&data, &[(2, 4)]);
|
||||
|
||||
// Samples 2 and 3 should be linearly interpolated between 2.0 and 5.0
|
||||
assert!((cleaned.data[0][2] - 3.0).abs() < 0.01);
|
||||
assert!((cleaned.data[0][3] - 4.0).abs() < 0.01);
|
||||
|
||||
// Non-artifact samples should be unchanged
|
||||
assert!((cleaned.data[0][0] - 1.0).abs() < 1e-10);
|
||||
assert!((cleaned.data[0][4] - 5.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_cardiac_finds_periodic_peaks() {
|
||||
let sr = 1000.0;
|
||||
let duration = 3.0;
|
||||
let n = (sr * duration) as usize;
|
||||
let mut signal = vec![0.0; n];
|
||||
|
||||
// Simulate cardiac artifact: periodic QRS-like spikes at ~1 Hz
|
||||
let heart_rate_hz = 1.0;
|
||||
let interval = (sr / heart_rate_hz) as usize;
|
||||
|
||||
for beat in 0..3 {
|
||||
let center = beat * interval + interval / 2;
|
||||
if center >= n {
|
||||
break;
|
||||
}
|
||||
// QRS complex: sharp spike ~10ms wide
|
||||
let half_width = (sr * 0.005) as usize;
|
||||
for i in center.saturating_sub(half_width)..(center + half_width).min(n) {
|
||||
let t = (i as f64 - center as f64) / (half_width as f64);
|
||||
signal[i] = 10.0 * (-t * t * 5.0).exp();
|
||||
}
|
||||
}
|
||||
|
||||
let peaks = detect_cardiac(&signal, sr);
|
||||
|
||||
// Should find roughly 3 peaks
|
||||
assert!(
|
||||
peaks.len() >= 1,
|
||||
"Should detect at least one cardiac peak, found {}",
|
||||
peaks.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
//! Cross-channel coupling and connectivity metrics.
|
||||
//!
|
||||
//! Provides measures of functional connectivity between neural signals:
|
||||
//! - Phase Locking Value (PLV)
|
||||
//! - Magnitude-squared coherence
|
||||
//! - Imaginary coherence (robust to volume conduction)
|
||||
//! - Amplitude envelope correlation
|
||||
//! - Full connectivity matrix computation
|
||||
|
||||
use num_complex::Complex;
|
||||
use ruv_neural_core::signal::{FrequencyBand, MultiChannelTimeSeries};
|
||||
use rustfft::FftPlanner;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cell::RefCell;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::filter::BandpassFilter;
|
||||
use crate::hilbert::hilbert_transform;
|
||||
|
||||
thread_local! {
|
||||
static FFT_PLANNER: RefCell<FftPlanner<f64>> = RefCell::new(FftPlanner::new());
|
||||
}
|
||||
|
||||
/// Type of connectivity metric to compute.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConnectivityMetric {
|
||||
/// Phase Locking Value.
|
||||
Plv,
|
||||
/// Amplitude envelope correlation.
|
||||
Aec,
|
||||
}
|
||||
|
||||
/// Returns `true` if any sample in `data` is NaN or infinite.
|
||||
pub fn contains_non_finite(data: &[f64]) -> bool {
|
||||
data.iter().any(|x| !x.is_finite())
|
||||
}
|
||||
|
||||
/// Validate that signal data contains no NaN or Inf values.
|
||||
///
|
||||
/// Returns `Ok(())` if all values are finite, or an error otherwise.
|
||||
pub fn validate_signal_finite(data: &[f64], label: &str) -> std::result::Result<(), String> {
|
||||
if contains_non_finite(data) {
|
||||
Err(format!("{label} contains NaN or infinite values"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the Phase Locking Value (PLV) between two signals.
|
||||
///
|
||||
/// PLV = |mean(exp(j * (phase_a - phase_b)))|
|
||||
///
|
||||
/// The signals are first bandpass-filtered to the specified frequency band,
|
||||
/// then the Hilbert transform extracts instantaneous phase.
|
||||
///
|
||||
/// PLV = 1.0 indicates perfect phase synchrony;
|
||||
/// PLV ~ 0.0 indicates no consistent phase relationship.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `signal_a` - First channel time series
|
||||
/// * `signal_b` - Second channel time series
|
||||
/// * `sample_rate` - Sampling rate in Hz
|
||||
/// * `band` - Frequency band for phase extraction
|
||||
pub fn phase_locking_value(
|
||||
signal_a: &[f64],
|
||||
signal_b: &[f64],
|
||||
sample_rate: f64,
|
||||
band: FrequencyBand,
|
||||
) -> f64 {
|
||||
let n = signal_a.len().min(signal_b.len());
|
||||
if n < 4 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Reject NaN/Inf at the pipeline entry point
|
||||
if contains_non_finite(&signal_a[..n]) || contains_non_finite(&signal_b[..n]) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let (low, high) = band.range_hz();
|
||||
let bp = BandpassFilter::new(2, low, high, sample_rate);
|
||||
|
||||
let filtered_a = bp.apply(&signal_a[..n]);
|
||||
let filtered_b = bp.apply(&signal_b[..n]);
|
||||
|
||||
let analytic_a = hilbert_transform(&filtered_a);
|
||||
let analytic_b = hilbert_transform(&filtered_b);
|
||||
|
||||
// Compute mean of exp(j*(phase_a - phase_b))
|
||||
let mut sum = Complex::new(0.0, 0.0);
|
||||
for i in 0..n {
|
||||
let phase_a = analytic_a[i].im.atan2(analytic_a[i].re);
|
||||
let phase_b = analytic_b[i].im.atan2(analytic_b[i].re);
|
||||
let diff = phase_a - phase_b;
|
||||
sum += Complex::new(diff.cos(), diff.sin());
|
||||
}
|
||||
|
||||
(sum / n as f64).norm()
|
||||
}
|
||||
|
||||
/// Compute magnitude-squared coherence between two signals.
|
||||
///
|
||||
/// Coh(f) = |S_ab(f)|^2 / (S_aa(f) * S_bb(f))
|
||||
///
|
||||
/// Uses Welch's method with overlapping segments and Hann window.
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of (frequency, coherence) pairs.
|
||||
pub fn coherence(
|
||||
signal_a: &[f64],
|
||||
signal_b: &[f64],
|
||||
sample_rate: f64,
|
||||
) -> Vec<(f64, f64)> {
|
||||
let n = signal_a.len().min(signal_b.len());
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let window_size = 256.min(n);
|
||||
let overlap = window_size / 2;
|
||||
let hop = window_size - overlap;
|
||||
|
||||
let window = hann_window(window_size);
|
||||
let num_freqs = window_size / 2 + 1;
|
||||
|
||||
let fft = FFT_PLANNER.with(|p| p.borrow_mut().plan_fft_forward(window_size));
|
||||
|
||||
let mut saa = vec![0.0; num_freqs];
|
||||
let mut sbb = vec![0.0; num_freqs];
|
||||
let mut sab = vec![Complex::new(0.0, 0.0); num_freqs];
|
||||
let mut num_segments = 0;
|
||||
|
||||
let mut start = 0;
|
||||
while start + window_size <= n {
|
||||
let mut fa: Vec<Complex<f64>> = (0..window_size)
|
||||
.map(|i| Complex::new(signal_a[start + i] * window[i], 0.0))
|
||||
.collect();
|
||||
let mut fb: Vec<Complex<f64>> = (0..window_size)
|
||||
.map(|i| Complex::new(signal_b[start + i] * window[i], 0.0))
|
||||
.collect();
|
||||
|
||||
fft.process(&mut fa);
|
||||
fft.process(&mut fb);
|
||||
|
||||
for k in 0..num_freqs {
|
||||
saa[k] += fa[k].norm_sqr();
|
||||
sbb[k] += fb[k].norm_sqr();
|
||||
sab[k] += fa[k] * fb[k].conj();
|
||||
}
|
||||
num_segments += 1;
|
||||
start += hop;
|
||||
}
|
||||
|
||||
if num_segments == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let freq_res = sample_rate / window_size as f64;
|
||||
(0..num_freqs)
|
||||
.map(|k| {
|
||||
let freq = k as f64 * freq_res;
|
||||
let denom = saa[k] * sbb[k];
|
||||
let coh = if denom > 1e-30 {
|
||||
sab[k].norm_sqr() / denom
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(freq, coh.min(1.0))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute imaginary coherence between two signals.
|
||||
///
|
||||
/// ImCoh(f) = Im(S_ab(f)) / sqrt(S_aa(f) * S_bb(f))
|
||||
///
|
||||
/// The imaginary part of coherence is robust to volume conduction
|
||||
/// artifacts, which produce zero-lag (purely real) correlations.
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of (frequency, imaginary_coherence) pairs.
|
||||
pub fn imaginary_coherence(
|
||||
signal_a: &[f64],
|
||||
signal_b: &[f64],
|
||||
sample_rate: f64,
|
||||
) -> Vec<(f64, f64)> {
|
||||
let n = signal_a.len().min(signal_b.len());
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let window_size = 256.min(n);
|
||||
let overlap = window_size / 2;
|
||||
let hop = window_size - overlap;
|
||||
|
||||
let window = hann_window(window_size);
|
||||
let num_freqs = window_size / 2 + 1;
|
||||
|
||||
let fft = FFT_PLANNER.with(|p| p.borrow_mut().plan_fft_forward(window_size));
|
||||
|
||||
let mut saa = vec![0.0; num_freqs];
|
||||
let mut sbb = vec![0.0; num_freqs];
|
||||
let mut sab = vec![Complex::new(0.0, 0.0); num_freqs];
|
||||
let mut num_segments = 0;
|
||||
|
||||
let mut start = 0;
|
||||
while start + window_size <= n {
|
||||
let mut fa: Vec<Complex<f64>> = (0..window_size)
|
||||
.map(|i| Complex::new(signal_a[start + i] * window[i], 0.0))
|
||||
.collect();
|
||||
let mut fb: Vec<Complex<f64>> = (0..window_size)
|
||||
.map(|i| Complex::new(signal_b[start + i] * window[i], 0.0))
|
||||
.collect();
|
||||
|
||||
fft.process(&mut fa);
|
||||
fft.process(&mut fb);
|
||||
|
||||
for k in 0..num_freqs {
|
||||
saa[k] += fa[k].norm_sqr();
|
||||
sbb[k] += fb[k].norm_sqr();
|
||||
sab[k] += fa[k] * fb[k].conj();
|
||||
}
|
||||
num_segments += 1;
|
||||
start += hop;
|
||||
}
|
||||
|
||||
if num_segments == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let freq_res = sample_rate / window_size as f64;
|
||||
(0..num_freqs)
|
||||
.map(|k| {
|
||||
let freq = k as f64 * freq_res;
|
||||
let denom = (saa[k] * sbb[k]).sqrt();
|
||||
let im_coh = if denom > 1e-30 {
|
||||
sab[k].im / denom
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(freq, im_coh)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute amplitude envelope correlation between two signals.
|
||||
///
|
||||
/// 1. Bandpass filter both signals to the specified frequency band
|
||||
/// 2. Extract amplitude envelopes via Hilbert transform
|
||||
/// 3. Compute Pearson correlation of the envelopes
|
||||
///
|
||||
/// # Returns
|
||||
/// Correlation coefficient in [-1, 1].
|
||||
pub fn amplitude_envelope_correlation(
|
||||
signal_a: &[f64],
|
||||
signal_b: &[f64],
|
||||
sample_rate: f64,
|
||||
band: FrequencyBand,
|
||||
) -> f64 {
|
||||
let n = signal_a.len().min(signal_b.len());
|
||||
if n < 4 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Reject NaN/Inf at the pipeline entry point
|
||||
if contains_non_finite(&signal_a[..n]) || contains_non_finite(&signal_b[..n]) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let (low, high) = band.range_hz();
|
||||
let bp = BandpassFilter::new(2, low, high, sample_rate);
|
||||
|
||||
let filtered_a = bp.apply(&signal_a[..n]);
|
||||
let filtered_b = bp.apply(&signal_b[..n]);
|
||||
|
||||
let env_a = crate::hilbert::instantaneous_amplitude(&filtered_a);
|
||||
let env_b = crate::hilbert::instantaneous_amplitude(&filtered_b);
|
||||
|
||||
pearson_correlation(&env_a, &env_b)
|
||||
}
|
||||
|
||||
/// Compute a full connectivity matrix for all channel pairs.
|
||||
///
|
||||
/// Pre-computes filtered analytic signals (or amplitude envelopes) for all
|
||||
/// channels once, then computes pairwise metrics. This eliminates redundant
|
||||
/// FFT/Hilbert work: for N channels, each channel is transformed once instead
|
||||
/// of (N-1) times.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Multi-channel time series
|
||||
/// * `metric` - Which connectivity metric to use
|
||||
/// * `band` - Frequency band (for PLV and AEC)
|
||||
///
|
||||
/// # Returns
|
||||
/// NxN matrix where entry [i][j] is the connectivity between channels i and j.
|
||||
pub fn compute_all_pairs(
|
||||
data: &MultiChannelTimeSeries,
|
||||
metric: ConnectivityMetric,
|
||||
band: FrequencyBand,
|
||||
) -> Vec<Vec<f64>> {
|
||||
let nc = data.num_channels;
|
||||
let sr = data.sample_rate_hz;
|
||||
let mut matrix = vec![vec![0.0; nc]; nc];
|
||||
|
||||
if nc == 0 {
|
||||
return matrix;
|
||||
}
|
||||
|
||||
let (low, high) = band.range_hz();
|
||||
let n = data.data[0].len();
|
||||
|
||||
match metric {
|
||||
ConnectivityMetric::Plv => {
|
||||
// Pre-compute analytic signals for all channels once.
|
||||
let bp = BandpassFilter::new(2, low, high, sr);
|
||||
let analytic_signals: Vec<Vec<Complex<f64>>> = data
|
||||
.data
|
||||
.iter()
|
||||
.map(|ch| {
|
||||
let filtered = bp.apply(&ch[..n.min(ch.len())]);
|
||||
hilbert_transform(&filtered)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for i in 0..nc {
|
||||
matrix[i][i] = 1.0;
|
||||
for j in (i + 1)..nc {
|
||||
let len = analytic_signals[i].len().min(analytic_signals[j].len());
|
||||
if len < 4 {
|
||||
continue;
|
||||
}
|
||||
let mut sum = Complex::new(0.0, 0.0);
|
||||
for k in 0..len {
|
||||
let phase_a = analytic_signals[i][k].im.atan2(analytic_signals[i][k].re);
|
||||
let phase_b = analytic_signals[j][k].im.atan2(analytic_signals[j][k].re);
|
||||
let diff = phase_a - phase_b;
|
||||
sum += Complex::new(diff.cos(), diff.sin());
|
||||
}
|
||||
let val = (sum / len as f64).norm();
|
||||
matrix[i][j] = val;
|
||||
matrix[j][i] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
ConnectivityMetric::Aec => {
|
||||
// Pre-compute amplitude envelopes for all channels once.
|
||||
let bp = BandpassFilter::new(2, low, high, sr);
|
||||
let envelopes: Vec<Vec<f64>> = data
|
||||
.data
|
||||
.iter()
|
||||
.map(|ch| {
|
||||
let filtered = bp.apply(&ch[..n.min(ch.len())]);
|
||||
crate::hilbert::instantaneous_amplitude(&filtered)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for i in 0..nc {
|
||||
matrix[i][i] = 1.0;
|
||||
for j in (i + 1)..nc {
|
||||
let val = pearson_correlation(&envelopes[i], &envelopes[j]);
|
||||
matrix[i][j] = val;
|
||||
matrix[j][i] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matrix
|
||||
}
|
||||
|
||||
/// Pearson correlation coefficient between two vectors.
|
||||
fn pearson_correlation(a: &[f64], b: &[f64]) -> f64 {
|
||||
let n = a.len().min(b.len());
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mean_a = a[..n].iter().sum::<f64>() / n as f64;
|
||||
let mean_b = b[..n].iter().sum::<f64>() / n as f64;
|
||||
|
||||
let mut cov = 0.0;
|
||||
let mut var_a = 0.0;
|
||||
let mut var_b = 0.0;
|
||||
|
||||
for i in 0..n {
|
||||
let da = a[i] - mean_a;
|
||||
let db = b[i] - mean_b;
|
||||
cov += da * db;
|
||||
var_a += da * da;
|
||||
var_b += db * db;
|
||||
}
|
||||
|
||||
let denom = (var_a * var_b).sqrt();
|
||||
if denom < 1e-30 {
|
||||
0.0
|
||||
} else {
|
||||
cov / denom
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a Hann window (local copy for this module).
|
||||
fn hann_window(length: usize) -> Vec<f64> {
|
||||
(0..length)
|
||||
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f64 / (length - 1).max(1) as f64).cos()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use approx::assert_abs_diff_eq;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
#[test]
|
||||
fn plv_of_identical_signals_is_one() {
|
||||
let sr = 1000.0;
|
||||
let n = 2000;
|
||||
let signal: Vec<f64> = (0..n)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sr;
|
||||
(2.0 * PI * 10.0 * t).sin()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let plv = phase_locking_value(&signal, &signal, sr, FrequencyBand::Alpha);
|
||||
|
||||
assert!(
|
||||
plv > 0.9,
|
||||
"PLV of identical signals should be ~1.0, got {plv}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plv_of_unrelated_signals_is_low() {
|
||||
let sr = 1000.0;
|
||||
let n = 4000;
|
||||
// Two signals at different frequencies
|
||||
let signal_a: Vec<f64> = (0..n)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sr;
|
||||
(2.0 * PI * 10.0 * t).sin()
|
||||
})
|
||||
.collect();
|
||||
let signal_b: Vec<f64> = (0..n)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sr;
|
||||
(2.0 * PI * 11.3 * t).sin() + 0.5 * (2.0 * PI * 9.7 * t).cos()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let plv = phase_locking_value(&signal_a, &signal_b, sr, FrequencyBand::Alpha);
|
||||
|
||||
assert!(
|
||||
plv < 0.7,
|
||||
"PLV of unrelated signals should be low, got {plv}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coherence_of_identical_signals_is_one() {
|
||||
let sr = 1000.0;
|
||||
let n = 2000;
|
||||
let signal: Vec<f64> = (0..n)
|
||||
.map(|i| {
|
||||
let t = i as f64 / sr;
|
||||
(2.0 * PI * 20.0 * t).sin()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let coh = coherence(&signal, &signal, sr);
|
||||
|
||||
// At the signal frequency (~20 Hz), coherence should be ~1.0
|
||||
let peak_coh = coh
|
||||
.iter()
|
||||
.filter(|(f, _)| *f > 15.0 && *f < 25.0)
|
||||
.map(|(_, c)| *c)
|
||||
.max_by(|a, b| a.partial_cmp(b).unwrap())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
assert!(
|
||||
peak_coh > 0.95,
|
||||
"Coherence of identical signals should be ~1.0 at signal freq, got {peak_coh}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_all_pairs_returns_symmetric_matrix() {
|
||||
let data = MultiChannelTimeSeries {
|
||||
data: vec![
|
||||
(0..1000)
|
||||
.map(|i| (2.0 * PI * 10.0 * i as f64 / 1000.0).sin())
|
||||
.collect(),
|
||||
(0..1000)
|
||||
.map(|i| (2.0 * PI * 10.0 * i as f64 / 1000.0).cos())
|
||||
.collect(),
|
||||
(0..1000)
|
||||
.map(|i| (2.0 * PI * 10.0 * i as f64 / 1000.0 + 0.3).sin())
|
||||
.collect(),
|
||||
],
|
||||
sample_rate_hz: 1000.0,
|
||||
num_channels: 3,
|
||||
num_samples: 1000,
|
||||
timestamp_start: 0.0,
|
||||
};
|
||||
|
||||
let matrix = compute_all_pairs(&data, ConnectivityMetric::Plv, FrequencyBand::Alpha);
|
||||
|
||||
assert_eq!(matrix.len(), 3);
|
||||
assert_eq!(matrix[0].len(), 3);
|
||||
|
||||
// Diagonal should be 1.0
|
||||
for i in 0..3 {
|
||||
assert_abs_diff_eq!(matrix[i][i], 1.0, epsilon = 1e-10);
|
||||
}
|
||||
|
||||
// Should be symmetric
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
assert_abs_diff_eq!(matrix[i][j], matrix[j][i], epsilon = 1e-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user