diff --git a/.gitmodules b/.gitmodules index f0f41072..7ad0cdff 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/v2/crates/ruv-neural b/v2/crates/ruv-neural new file mode 160000 index 00000000..1ece3afa --- /dev/null +++ b/v2/crates/ruv-neural @@ -0,0 +1 @@ +Subproject commit 1ece3afa3346e50b95ee2542c4b328356147bea6 diff --git a/v2/crates/ruv-neural/.gitignore b/v2/crates/ruv-neural/.gitignore deleted file mode 100644 index ca98cd96..00000000 --- a/v2/crates/ruv-neural/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target/ -Cargo.lock diff --git a/v2/crates/ruv-neural/Cargo.toml b/v2/crates/ruv-neural/Cargo.toml deleted file mode 100644 index 9e0418c8..00000000 --- a/v2/crates/ruv-neural/Cargo.toml +++ /dev/null @@ -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 "] -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 diff --git a/v2/crates/ruv-neural/README.md b/v2/crates/ruv-neural/README.md deleted file mode 100644 index 09c1c928..00000000 --- a/v2/crates/ruv-neural/README.md +++ /dev/null @@ -1,421 +0,0 @@ -# rUv Neural — Brain Topology Analysis System - -> Quantum sensor integration x RuVector graph memory x Dynamic mincut coherence detection - -[![crates.io](https://img.shields.io/crates/v/ruv-neural-core.svg)](https://crates.io/crates/ruv-neural-core) -[![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)]() -[![Rust](https://img.shields.io/badge/rust-1.75+-orange.svg)]() -[![Tests](https://img.shields.io/badge/tests-338%20passed-brightgreen.svg)]() - ---- - -## Ethics & Responsible Use - -> **This technology interfaces with human neural data. Use it responsibly.** -> -> - **Informed consent** is required before collecting neural data from any participant -> - **Never** deploy brain-computer interfaces without IRB/ethics board approval -> - **Data privacy**: Neural signals are among the most sensitive personal data categories. Encrypt at rest, anonymize before sharing, and comply with GDPR/HIPAA as applicable -> - **Clinical use** requires FDA/CE clearance and must be supervised by licensed medical professionals -> - **Do not** use this software for covert monitoring, interrogation, lie detection, or any application that violates human autonomy -> - **Dual-use awareness**: The same technology that helps paralyzed patients communicate can be misused for surveillance. Design with safeguards -> - This software is provided for **research and educational purposes**. The authors accept no liability for misuse -> -> See [IEEE Neuroethics Framework](https://standards.ieee.org/industry-connections/ec/neuroethics/) and the [Morningside Group Neurorights](https://nri.ntc.columbia.edu/content/neurorights) initiative for guidance. - ---- - -## Overview - -**rUv Neural** is a modular Rust crate ecosystem for real-time brain network topology -analysis. It transforms neural magnetic field measurements from quantum sensors (NV diamond -magnetometers, optically pumped magnetometers) into dynamic connectivity graphs, then uses -minimum cut algorithms to detect cognitive state transitions. - -This is not mind reading — it measures **how cognition organizes itself** by tracking the -topology of brain networks in real time. - -## Hardware Parts List - -Below is a reference bill of materials for building a basic multi-channel neural sensing rig. -Prices are approximate (2026). Links are for reference only — equivalent components from any -vendor will work. - -### Core: NV Diamond Magnetometer Array - -| Component | Qty | Approx Price | Link | Notes | -|-----------|-----|-------------|------|-------| -| NV Diamond Sensor Chip (2x2mm, 1ppm N) | 16 | $45 ea | [AliExpress: NV Diamond Chip](https://www.aliexpress.com/w/wholesale-nv-diamond-sensor.html) | Nitrogen-vacancy center, electronic grade | -| 532nm Green Laser Diode Module (100mW) | 4 | $12 ea | [AliExpress: 532nm Laser Module](https://www.aliexpress.com/w/wholesale-532nm-laser-module-100mw.html) | Excitation source for ODMR | -| Microwave Signal Generator (2.87 GHz) | 1 | $85 | [AliExpress: RF Signal Generator 3GHz](https://www.aliexpress.com/w/wholesale-rf-signal-generator-3ghz.html) | For NV zero-field splitting resonance | -| SMA Coaxial Cable (50 Ohm, 30cm) | 4 | $3 ea | [AliExpress: SMA Cable 50 Ohm](https://www.aliexpress.com/w/wholesale-sma-cable-50-ohm.html) | Microwave delivery to diamond chips | -| Photodiode Array (Si PIN, 16-ch) | 1 | $25 | [AliExpress: Photodiode Array](https://www.aliexpress.com/w/wholesale-photodiode-array-16-channel.html) | Fluorescence detection | -| Transimpedance Amplifier Board | 1 | $18 | [AliExpress: TIA Board](https://www.aliexpress.com/w/wholesale-transimpedance-amplifier-board.html) | Converts photocurrent to voltage | - -### Alternative: OPM (Optically Pumped Magnetometer) - -| Component | Qty | Approx Price | Link | Notes | -|-----------|-----|-------------|------|-------| -| Rb Vapor Cell (25mm, AR coated) | 8 | $35 ea | [AliExpress: Rubidium Vapor Cell](https://www.aliexpress.com/w/wholesale-rubidium-vapor-cell.html) | SERF-mode magnetometry | -| 795nm VCSEL Laser | 8 | $8 ea | [AliExpress: 795nm VCSEL](https://www.aliexpress.com/w/wholesale-795nm-vcsel-laser.html) | D1 line pump for Rb | -| Balanced Photodetector | 8 | $15 ea | [AliExpress: Balanced Photodetector](https://www.aliexpress.com/w/wholesale-balanced-photodetector.html) | Differential detection | -| Magnetic Shielding Mu-Metal Cylinder | 1 | $120 | [AliExpress: Mu-Metal Shield](https://www.aliexpress.com/w/wholesale-mu-metal-magnetic-shield.html) | 3-layer, >60dB attenuation | - -### Alternative: EEG (Electroencephalography) - -| Component | Qty | Approx Price | Link | Notes | -|-----------|-----|-------------|------|-------| -| Ag/AgCl EEG Electrodes (10-20 system) | 21 | $2 ea | [AliExpress: EEG Electrode AgCl](https://www.aliexpress.com/w/wholesale-eeg-electrode-ag-agcl.html) | Reusable cup electrodes | -| EEG Cap (10-20 placement, size M) | 1 | $45 | [AliExpress: EEG Cap 10-20](https://www.aliexpress.com/w/wholesale-eeg-cap-10-20.html) | Pre-wired 21-channel | -| Conductive EEG Gel (250ml) | 1 | $8 | [AliExpress: EEG Gel](https://www.aliexpress.com/w/wholesale-eeg-conductive-gel.html) | Low impedance contact | -| ADS1299 EEG AFE Board (8-ch) | 3 | $35 ea | [AliExpress: ADS1299 Board](https://www.aliexpress.com/w/wholesale-ads1299-eeg-board.html) | 24-bit, 250 SPS, TI analog front-end | - -### Data Acquisition & Processing - -| Component | Qty | Approx Price | Link | Notes | -|-----------|-----|-------------|------|-------| -| ESP32-S3 DevKit (16MB Flash, 8MB PSRAM) | 4 | $8 ea | [AliExpress: ESP32-S3 DevKit](https://www.aliexpress.com/w/wholesale-esp32-s3-devkit.html) | ADC readout + TDM sync | -| ADS1256 24-bit ADC Module | 2 | $12 ea | [AliExpress: ADS1256 Module](https://www.aliexpress.com/w/wholesale-ads1256-module.html) | High-resolution for NV/OPM | -| USB-C Hub (4 port, USB 3.0) | 1 | $10 | [AliExpress: USB-C Hub](https://www.aliexpress.com/w/wholesale-usb-c-hub-4-port.html) | Connect ESP32 nodes to host | -| Shielded USB Cable (30cm, ferrite) | 4 | $3 ea | [AliExpress: Shielded USB Cable](https://www.aliexpress.com/w/wholesale-shielded-usb-cable-ferrite.html) | Reduce EMI | -| Host PC or Raspberry Pi 5 (8GB) | 1 | $80 | [AliExpress: Raspberry Pi 5](https://www.aliexpress.com/w/wholesale-raspberry-pi-5-8gb.html) | Runs the rUv Neural pipeline | - -### Assembly Tools - -| Component | Qty | Approx Price | Link | Notes | -|-----------|-----|-------------|------|-------| -| Soldering Station (adjustable temp) | 1 | $25 | [AliExpress: Soldering Station](https://www.aliexpress.com/w/wholesale-soldering-station-adjustable.html) | For sensor board assembly | -| Breadboard + Jumper Wire Kit | 1 | $8 | [AliExpress: Breadboard Kit](https://www.aliexpress.com/w/wholesale-breadboard-jumper-wire-kit.html) | Prototyping | -| 3D Printed Sensor Mount (STL provided) | 1 | — | Print locally | Holds diamond chips in array | - -**Estimated total cost:** ~$650–$900 for a 16-channel NV diamond setup, ~$500 for OPM, ~$200 for EEG. - -### Assembly Instructions - -1. **Sensor Array** - - Mount NV diamond chips (or OPM vapor cells, or EEG electrodes) in the 3D-printed helmet/mount - - For NV: align 532nm laser to each chip, position photodiodes for fluorescence collection - - For OPM: install Rb cells inside mu-metal shield, align 795nm VCSELs - - For EEG: apply conductive gel, place electrodes per 10-20 system - -2. **Signal Chain** - - Connect sensor outputs to ADS1256 (NV/OPM) or ADS1299 (EEG) ADC boards - - Wire ADC SPI bus to ESP32-S3 GPIO (MOSI=11, MISO=13, SCK=12, CS=10) - - Flash ESP32 with `ruv-neural-esp32` firmware: `cargo flash --chip esp32s3` - -3. **TDM Synchronization** - - Connect GPIO 4 across all ESP32 nodes as a shared sync line - - The `TdmScheduler` assigns non-overlapping time slots automatically - - Set `sync_tolerance_us: 1000` in the aggregator config - -4. **Host Software** - - Install Rust 1.75+ and build: `cargo build --workspace --release` - - Run the pipeline: `cargo run -p ruv-neural-cli --release -- pipeline --channels 16 --duration 60` - - Or use individual crates as a library (see [Use as Library](#use-as-library)) - -5. **Verification** - - Generate a witness bundle: `cargo run -p ruv-neural-cli -- witness --output witness.json` - - Verify Ed25519 signature: `cargo run -p ruv-neural-cli -- witness --verify witness.json` - - Expected output: `VERDICT: PASS` (41 capability attestations, 338 tests) - -## Architecture - -``` - rUv Neural Pipeline - ================================================================ - - +------------------+ +-------------------+ +------------------+ - | | | | | | - | SENSOR LAYER |---->| SIGNAL LAYER |---->| GRAPH LAYER | - | | | | | | - | NV Diamond | | Bandpass Filter | | PLV / Coherence | - | OPM | | Artifact Reject | | Brain Regions | - | EEG | | Hilbert Phase | | Connectivity | - | Simulated | | Spectral (PSD) | | Matrix | - | | | | | | - +------------------+ +-------------------+ +--------+---------+ - | - v - +------------------+ +-------------------+ +------------------+ - | | | | | | - | DECODE LAYER |<----| MEMORY LAYER |<----| MINCUT LAYER | - | | | | | | - | Cognitive State | | HNSW Index | | Stoer-Wagner | - | Classification | | Pattern Store | | Normalized Cut | - | BCI Output | | Drift Detection | | Spectral Cut | - | Transition Log | | Temporal Window | | Coherence Detect| - | | | | | | - +------------------+ +-------------------+ +------------------+ - ^ - | - +-------+--------+ - | | - | EMBED LAYER | - | | - | Spectral Pos. | - | Topology Vec | - | Node2Vec | - | RVF Export | - | | - +----------------+ - - Peripheral Crates: - +----------+ +----------+ +----------+ - | ESP32 | | WASM | | VIZ | - | Edge | | Browser | | ASCII | - | Preproc | | Bindings | | Render | - +----------+ +----------+ +----------+ -``` - -## Crate Map - -All crates are published on [crates.io](https://crates.io/search?q=ruv-neural): - -| Crate | crates.io | Description | Dependencies | -|-------|-----------|-------------|--------------| -| [`ruv-neural-core`](https://crates.io/crates/ruv-neural-core) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-core.svg)](https://crates.io/crates/ruv-neural-core) | Core types, traits, errors, RVF format | None | -| [`ruv-neural-sensor`](https://crates.io/crates/ruv-neural-sensor) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-sensor.svg)](https://crates.io/crates/ruv-neural-sensor) | NV diamond, OPM, EEG sensor interfaces | core | -| [`ruv-neural-signal`](https://crates.io/crates/ruv-neural-signal) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-signal.svg)](https://crates.io/crates/ruv-neural-signal) | DSP: filtering, spectral, connectivity | core | -| [`ruv-neural-graph`](https://crates.io/crates/ruv-neural-graph) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-graph.svg)](https://crates.io/crates/ruv-neural-graph) | Brain connectivity graph construction | core, signal | -| [`ruv-neural-mincut`](https://crates.io/crates/ruv-neural-mincut) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-mincut.svg)](https://crates.io/crates/ruv-neural-mincut) | Dynamic minimum cut topology analysis | core | -| [`ruv-neural-embed`](https://crates.io/crates/ruv-neural-embed) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-embed.svg)](https://crates.io/crates/ruv-neural-embed) | RuVector graph embeddings | core | -| [`ruv-neural-memory`](https://crates.io/crates/ruv-neural-memory) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-memory.svg)](https://crates.io/crates/ruv-neural-memory) | Persistent neural state memory + HNSW | core | -| [`ruv-neural-decoder`](https://crates.io/crates/ruv-neural-decoder) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-decoder.svg)](https://crates.io/crates/ruv-neural-decoder) | Cognitive state classification + BCI | core | -| [`ruv-neural-esp32`](https://crates.io/crates/ruv-neural-esp32) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-esp32.svg)](https://crates.io/crates/ruv-neural-esp32) | ESP32 edge sensor integration | core | -| `ruv-neural-wasm` | — | WebAssembly browser bindings | core | -| [`ruv-neural-viz`](https://crates.io/crates/ruv-neural-viz) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-viz.svg)](https://crates.io/crates/ruv-neural-viz) | Visualization and ASCII rendering | core, graph, mincut | -| [`ruv-neural-cli`](https://crates.io/crates/ruv-neural-cli) | [![crates.io](https://img.shields.io/crates/v/ruv-neural-cli.svg)](https://crates.io/crates/ruv-neural-cli) | CLI tool (`ruv-neural` binary) | all | - -## Dependency Graph - -``` - ruv-neural-core - (types, traits, errors) - / | | \ \ - / | | \ \ - v v v v v - sensor signal embed esp32 (wasm) - | - v - graph --|------> viz - | - v - mincut - | - v - decoder <--- memory <--- embed - | - v - cli (depends on all) -``` - -## Quick Start - -### Build - -```bash -cd v2/crates/ruv-neural -cargo build --workspace -cargo test --workspace -``` - -### Run CLI - -```bash -cargo run -p ruv-neural-cli -- simulate --channels 64 --duration 10 -cargo run -p ruv-neural-cli -- pipeline --channels 32 --duration 5 --dashboard -cargo run -p ruv-neural-cli -- mincut --input brain_graph.json -``` - -### Install from crates.io - -```bash -# Add individual crates as needed -cargo add ruv-neural-core -cargo add ruv-neural-sensor -cargo add ruv-neural-signal -cargo add ruv-neural-mincut -cargo add ruv-neural-embed -cargo add ruv-neural-memory -cargo add ruv-neural-decoder -cargo add ruv-neural-graph -cargo add ruv-neural-viz -cargo add ruv-neural-esp32 -cargo add ruv-neural-cli -``` - -### Use as Library - -```rust -use ruv_neural_core::*; -use ruv_neural_sensor::simulator::SimulatedSensorArray; -use ruv_neural_signal::PreprocessingPipeline; -use ruv_neural_mincut::DynamicMincutTracker; -use ruv_neural_embed::NeuralEmbedding; - -// Create simulated sensor array (64 channels, 1000 Hz) -let mut sensor = SimulatedSensorArray::new(64, 1000.0); -let data = sensor.acquire(1000)?; - -// Preprocess: bandpass filter + artifact rejection -let pipeline = PreprocessingPipeline::default(); -let clean = pipeline.process(&data)?; - -// Compute connectivity and build graph -let connectivity = ruv_neural_signal::compute_all_pairs( - &clean, - ruv_neural_signal::ConnectivityMetric::PhaseLockingValue, -); - -// Track topology changes via dynamic mincut -let mut tracker = DynamicMincutTracker::new(); -let result = tracker.update(&graph)?; -println!( - "Mincut: {:.3}, Partitions: {} | {}", - result.cut_value, - result.partition_a.len(), - result.partition_b.len() -); - -// Generate embedding for downstream classification -let embedding = NeuralEmbedding::new( - result.to_feature_vector(), - data.timestamp, - "spectral", -)?; -println!("Embedding dim: {}", embedding.dimension); -``` - -## Mix and Match - -Each crate is independently usable. Common combinations: - -- **Sensor + Signal** -- Data acquisition and preprocessing only -- **Graph + Mincut** -- Graph analysis without sensor dependency -- **Embed + Memory** -- Embedding storage without real-time pipeline -- **Core + WASM** -- Browser-based graph visualization -- **ESP32 alone** -- Edge preprocessing on embedded hardware -- **Signal + Embed** -- Feature extraction pipeline without graph construction -- **Mincut + Viz** -- Topology analysis with ASCII dashboard output - -## Platform Support - -| Platform | Status | Crates Available | -|----------|--------|-----------------| -| Linux x86_64 | Full | All 12 | -| macOS ARM64 | Full | All 12 | -| Windows x86_64 | Full | All 12 | -| WASM (browser) | Partial | core, wasm, viz | -| ESP32 (no_std) | Partial | core, esp32 | - -**Note:** The `ruv-neural-wasm` crate is excluded from the default workspace members. -Build it separately with: - -```bash -cargo build -p ruv-neural-wasm --target wasm32-unknown-unknown --release -``` - -## Key Algorithms - -### Signal Processing (`ruv-neural-signal`) - -- **Butterworth IIR filters** in second-order sections (SOS) form -- **Welch PSD** estimation with configurable window and overlap -- **Hilbert transform** for instantaneous phase extraction -- **Artifact detection** -- eye blink, muscle, cardiac artifact rejection -- **Connectivity metrics** -- PLV, coherence, imaginary coherence, AEC - -### Minimum Cut Analysis (`ruv-neural-mincut`) - -- **Stoer-Wagner** -- Global minimum cut in O(V^3) -- **Normalized cut** (Shi-Malik) -- Spectral bisection via the Fiedler vector -- **Multiway cut** -- Recursive normalized cut for k-module detection -- **Spectral cut** -- Cheeger constant and spectral bisection bounds -- **Dynamic tracking** -- Temporal topology transition detection -- **Coherence events** -- Network formation, dissolution, merger, split - -### Embeddings (`ruv-neural-embed`) - -- **Spectral** -- Laplacian eigenvector positional encoding -- **Topology** -- Hand-crafted topological feature vectors -- **Node2Vec** -- Random-walk co-occurrence embeddings -- **Combined** -- Weighted concatenation of multiple methods -- **Temporal** -- Sliding-window context-enriched embeddings -- **RVF export** -- Serialization to RuVector `.rvf` format - -## RVF Format - -RuVector File (RVF) is a binary format for neural data interchange: - -``` -+--------+--------+---------+----------+----------+ -| Magic | Version| Type | Payload | Checksum | -| RVF\x01| u8 | u8 | [u8; N] | u32 | -+--------+--------+---------+----------+----------+ -``` - -- **Magic bytes**: `RVF\x01` -- **Supported types**: brain graphs, embeddings, topology metrics, time series -- **Binary format** for efficient storage and streaming -- **Compatible** with the broader RuVector ecosystem - -## Cryptographic Witness Verification - -rUv Neural includes an Ed25519-signed capability attestation system. Every build can -generate a witness bundle that cryptographically proves which capabilities are present -and that all tests passed. - -```bash -# Generate a signed witness bundle -cargo run -p ruv-neural-cli -- witness --output witness-bundle.json - -# Verify (any third party can do this) -cargo run -p ruv-neural-cli -- witness --verify witness-bundle.json -``` - -The bundle contains: -- **41 capability attestations** covering all 12 crates -- **SHA-256 digest** of the capability matrix -- **Ed25519 signature** (unique per generation) -- **Public key** for independent verification -- Test count and pass/fail status - -Tampered bundles are detected — modifying any attestation invalidates the digest and -signature verification returns `FAIL`. - -## Testing - -```bash -# Run all workspace tests -cargo test --workspace - -# Run a specific crate's tests -cargo test -p ruv-neural-mincut - -# Run with logging enabled -RUST_LOG=debug cargo test --workspace -- --nocapture - -# Run benchmarks (requires nightly or criterion) -cargo bench -p ruv-neural-mincut -``` - -## Crate Publishing Order - -Crates must be published in dependency order: - -1. `ruv-neural-core` (no internal deps) -2. `ruv-neural-sensor` (depends on core) -3. `ruv-neural-signal` (depends on core) -4. `ruv-neural-esp32` (depends on core) -5. `ruv-neural-graph` (depends on core, signal) -6. `ruv-neural-embed` (depends on core) -7. `ruv-neural-mincut` (depends on core) -8. `ruv-neural-viz` (depends on core, graph) -9. `ruv-neural-memory` (depends on core, embed) -10. `ruv-neural-decoder` (depends on core, embed) -11. `ruv-neural-wasm` (depends on core) -12. `ruv-neural-cli` (depends on all) - -## License - -MIT OR Apache-2.0 diff --git a/v2/crates/ruv-neural/SECURITY_REVIEW.md b/v2/crates/ruv-neural/SECURITY_REVIEW.md deleted file mode 100644 index bc5a44db..00000000 --- a/v2/crates/ruv-neural/SECURITY_REVIEW.md +++ /dev/null @@ -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>` 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>` matrix representations to `ndarray::Array2` 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`. 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`. 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` 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> with flat Vec for adjacency matrices** - - Current `Vec>` has poor cache locality due to heap-allocated inner Vecs. - - **Fix**: Use `Vec` with manual row-major indexing, or migrate to `ndarray::Array2`. - - **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], metric: &ConnectivityMetric) -> Vec> { - let analytics: Vec>> = 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` 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> = 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>` matrices to `ndarray::Array2` -- [ ] 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.* diff --git a/v2/crates/ruv-neural/ruv-neural-cli/Cargo.toml b/v2/crates/ruv-neural/ruv-neural-cli/Cargo.toml deleted file mode 100644 index 8d23b837..00000000 --- a/v2/crates/ruv-neural/ruv-neural-cli/Cargo.toml +++ /dev/null @@ -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 } diff --git a/v2/crates/ruv-neural/ruv-neural-cli/README.md b/v2/crates/ruv-neural/ruv-neural-cli/README.md deleted file mode 100644 index a20c70af..00000000 --- a/v2/crates/ruv-neural/ruv-neural-cli/README.md +++ /dev/null @@ -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 -- -``` - -## 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 diff --git a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/analyze.rs b/v2/crates/ruv-neural/ruv-neural-cli/src/commands/analyze.rs deleted file mode 100644 index be05bfb5..00000000 --- a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/analyze.rs +++ /dev/null @@ -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, -) -> Result<(), Box> { - 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 = (0..graph.num_nodes) - .map(|i| graph.node_degree(i)) - .collect(); - let mean_degree = if degrees.is_empty() { - 0.0 - } else { - degrees.iter().sum::() / 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 = graph.edges.iter().map(|e| e.weight).collect(); - let mean_w = weights.iter().sum::() / 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> { - 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(); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/export.rs b/v2/crates/ruv-neural/ruv-neural-cli/src/commands/export.rs deleted file mode 100644 index 70ce84ff..00000000 --- a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/export.rs +++ /dev/null @@ -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> { - 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> { - let nodes: Vec = (0..graph.num_nodes) - .map(|i| { - serde_json::json!({ - "id": i, - "degree": graph.node_degree(i), - }) - }) - .collect(); - - let links: Vec = 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#" - - - rUv Neural - Brain connectivity graph - - - -"#); - - for i in 0..graph.num_nodes { - gexf.push_str(&format!( - " \n" - )); - } - - gexf.push_str(" \n \n"); - - for (idx, edge) in graph.edges.iter().enumerate() { - gexf.push_str(&format!( - " \n", - edge.source, edge.target, edge.weight - )); - } - - gexf.push_str(" \n \n\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> { - 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("")); - } - - #[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(); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/info.rs b/v2/crates/ruv-neural/ruv-neural-cli/src/commands/info.rs deleted file mode 100644 index 08aa1383..00000000 --- a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/info.rs +++ /dev/null @@ -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(); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/mincut.rs b/v2/crates/ruv-neural/ruv-neural-cli/src/commands/mincut.rs deleted file mode 100644 index bda78ec2..00000000 --- a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/mincut.rs +++ /dev/null @@ -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) -> Result<(), Box> { - 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]) { - 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(); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/mod.rs b/v2/crates/ruv-neural/ruv-neural-cli/src/commands/mod.rs deleted file mode 100644 index afb99897..00000000 --- a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/mod.rs +++ /dev/null @@ -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; diff --git a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/pipeline.rs b/v2/crates/ruv-neural/ruv-neural-cli/src/commands/pipeline.rs deleted file mode 100644 index 2f18a4c5..00000000 --- a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/pipeline.rs +++ /dev/null @@ -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> { - 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> = 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> { - 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], 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 = (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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/simulate.rs b/v2/crates/ruv-neural/ruv-neural-cli/src/commands/simulate.rs deleted file mode 100644 index 3ba9f788..00000000 --- a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/simulate.rs +++ /dev/null @@ -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, -) -> Result<(), Box> { - 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::::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::() / num_samples as f64).sqrt(); - channel_rms.push(rms); - } - let mean_rms = channel_rms.iter().sum::() / 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 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> { - // 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(); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/witness.rs b/v2/crates/ruv-neural/ruv-neural-cli/src/commands/witness.rs deleted file mode 100644 index 1d859e85..00000000 --- a/v2/crates/ruv-neural/ruv-neural-cli/src/commands/witness.rs +++ /dev/null @@ -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, - verify: Option, -) -> Result<(), Box> { - 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(()) -} diff --git a/v2/crates/ruv-neural/ruv-neural-cli/src/main.rs b/v2/crates/ruv-neural/ruv-neural-cli/src/main.rs deleted file mode 100644 index 084e6eec..00000000 --- a/v2/crates/ruv-neural/ruv-neural-cli/src/main.rs +++ /dev/null @@ -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, - }, - /// 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, - }, - /// 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, - }, - /// 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, - /// Path to a witness bundle to verify - #[arg(long)] - verify: Option, - }, -} - -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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-core/Cargo.toml b/v2/crates/ruv-neural/ruv-neural-core/Cargo.toml deleted file mode 100644 index 0f7a2633..00000000 --- a/v2/crates/ruv-neural/ruv-neural-core/Cargo.toml +++ /dev/null @@ -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 } diff --git a/v2/crates/ruv-neural/ruv-neural-core/README.md b/v2/crates/ruv-neural/ruv-neural-core/README.md deleted file mode 100644 index 6bf96792..00000000 --- a/v2/crates/ruv-neural/ruv-neural-core/README.md +++ /dev/null @@ -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` | - -## 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 diff --git a/v2/crates/ruv-neural/ruv-neural-core/src/brain.rs b/v2/crates/ruv-neural/ruv-neural-core/src/brain.rs deleted file mode 100644 index c5f2f8db..00000000 --- a/v2/crates/ruv-neural/ruv-neural-core/src/brain.rs +++ /dev/null @@ -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, -} - -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() - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-core/src/embedding.rs b/v2/crates/ruv-neural/ruv-neural-core/src/embedding.rs deleted file mode 100644 index 032636ef..00000000 --- a/v2/crates/ruv-neural/ruv-neural-core/src/embedding.rs +++ /dev/null @@ -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, - /// 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, timestamp: f64, metadata: EmbeddingMetadata) -> Result { - 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::().sqrt() - } - - /// Cosine similarity to another embedding. - pub fn cosine_similarity(&self, other: &NeuralEmbedding) -> Result { - 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 { - 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, - /// Session identifier. - pub session_id: Option, - /// Decoded cognitive state (if available). - pub cognitive_state: Option, - /// 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, - /// Timestamps for each embedding. - pub timestamps: Vec, -} - -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() - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-core/src/error.rs b/v2/crates/ruv-neural/ruv-neural-core/src/error.rs deleted file mode 100644 index 710eca1a..00000000 --- a/v2/crates/ruv-neural/ruv-neural-core/src/error.rs +++ /dev/null @@ -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 = std::result::Result; diff --git a/v2/crates/ruv-neural/ruv-neural-core/src/graph.rs b/v2/crates/ruv-neural/ruv-neural-core/src/graph.rs deleted file mode 100644 index 56b18509..00000000 --- a/v2/crates/ruv-neural/ruv-neural-core/src/graph.rs +++ /dev/null @@ -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, - /// 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> { - 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 { - 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, - /// 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 - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-core/src/lib.rs b/v2/crates/ruv-neural/ruv-neural-core/src/lib.rs deleted file mode 100644 index e0385597..00000000 --- a/v2/crates/ruv-neural/ruv-neural-core/src/lib.rs +++ /dev/null @@ -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` 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 = 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-core/src/rvf.rs b/v2/crates/ruv-neural/ruv-neural-core/src/rvf.rs deleted file mode 100644 index a85210fe..00000000 --- a/v2/crates/ruv-neural/ruv-neural-core/src/rvf.rs +++ /dev/null @@ -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 { - 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 { - 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 { - 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, -} - -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(&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(reader: &mut R) -> Result { - 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, - }) - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-core/src/sensor.rs b/v2/crates/ruv-neural/ruv-neural-core/src/sensor.rs deleted file mode 100644 index b3208b17..00000000 --- a/v2/crates/ruv-neural/ruv-neural-core/src/sensor.rs +++ /dev/null @@ -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, - /// 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)) - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-core/src/signal.rs b/v2/crates/ruv-neural/ruv-neural-core/src/signal.rs deleted file mode 100644 index bbaabf86..00000000 --- a/v2/crates/ruv-neural/ruv-neural-core/src/signal.rs +++ /dev/null @@ -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>, - /// 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>, sample_rate_hz: f64, timestamp_start: f64) -> Result { - 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>, - /// Time points in seconds. - pub time_points: Vec, - /// Frequency bin centers in Hz. - pub frequency_bins: Vec, -} - -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() - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-core/src/topology.rs b/v2/crates/ruv-neural/ruv-neural-core/src/topology.rs deleted file mode 100644 index 4ed37d6a..00000000 --- a/v2/crates/ruv-neural/ruv-neural-core/src/topology.rs +++ /dev/null @@ -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, - /// Node indices in partition B. - pub partition_b: Vec, - /// 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>, - /// 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, -} diff --git a/v2/crates/ruv-neural/ruv-neural-core/src/traits.rs b/v2/crates/ruv-neural/ruv-neural-core/src/traits.rs deleted file mode 100644 index de3b3c82..00000000 --- a/v2/crates/ruv-neural/ruv-neural-core/src/traits.rs +++ /dev/null @@ -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; -} - -/// Trait for signal processors (filters, artifact removal, etc.). -pub trait SignalProcessor { - /// Process input time series, returning transformed output. - fn process(&self, input: &MultiChannelTimeSeries) -> Result; -} - -/// 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; -} - -/// 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; - - /// Compute the minimum cut of a brain graph. - fn mincut(&self, graph: &BrainGraph) -> Result; -} - -/// 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; - - /// 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; - - /// 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>; - - /// Find all stored embeddings matching a cognitive state. - fn query_by_state(&self, state: CognitiveState) -> Result>; -} - -/// Trait for RVF serialization support. -pub trait RvfSerializable { - /// Serialize this value to an RVF file. - fn to_rvf(&self) -> Result; - - /// Deserialize from an RVF file. - fn from_rvf(file: &RvfFile) -> Result - where - Self: Sized; -} diff --git a/v2/crates/ruv-neural/ruv-neural-core/src/witness.rs b/v2/crates/ruv-neural/ruv-neural-core/src/witness.rs deleted file mode 100644 index bd2d7215..00000000 --- a/v2/crates/ruv-neural/ruv-neural-core/src/witness.rs +++ /dev/null @@ -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, - /// 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, - ) -> 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 { - 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 { - 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 { - 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, 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-decoder/Cargo.toml b/v2/crates/ruv-neural/ruv-neural-decoder/Cargo.toml deleted file mode 100644 index 8fa00ce7..00000000 --- a/v2/crates/ruv-neural/ruv-neural-decoder/Cargo.toml +++ /dev/null @@ -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 } diff --git a/v2/crates/ruv-neural/ruv-neural-decoder/README.md b/v2/crates/ruv-neural/ruv-neural-decoder/README.md deleted file mode 100644 index 72cbd58f..00000000 --- a/v2/crates/ruv-neural/ruv-neural-decoder/README.md +++ /dev/null @@ -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 diff --git a/v2/crates/ruv-neural/ruv-neural-decoder/src/clinical.rs b/v2/crates/ruv-neural/ruv-neural-decoder/src/clinical.rs deleted file mode 100644 index c844c6c5..00000000 --- a/v2/crates/ruv-neural/ruv-neural-decoder/src/clinical.rs +++ /dev/null @@ -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::() / n; - let mean_mod = healthy_data.iter().map(|m| m.modularity).sum::() / n; - let mean_eff = healthy_data.iter().map(|m| m.global_efficiency).sum::() / n; - let mean_loc = healthy_data.iter().map(|m| m.local_efficiency).sum::() / n; - let mean_ent = healthy_data.iter().map(|m| m.graph_entropy).sum::() / n; - let mean_fiedler = healthy_data.iter().map(|m| m.fiedler_value).sum::() / 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::() / 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, mean: f64) -> f64 { - let vals: Vec = 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::() / (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); - } - } - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-decoder/src/knn_decoder.rs b/v2/crates/ruv-neural/ruv-neural-decoder/src/knn_decoder.rs deleted file mode 100644 index 5cb82d85..00000000 --- a/v2/crates/ruv-neural/ruv-neural-decoder/src/knn_decoder.rs +++ /dev/null @@ -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 = 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 { - 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::() - .sqrt() -} - -#[cfg(test)] -mod tests { - use super::*; - use ruv_neural_core::brain::Atlas; - use ruv_neural_core::embedding::EmbeddingMetadata; - - fn make_embedding(vector: Vec) -> 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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-decoder/src/lib.rs b/v2/crates/ruv-neural/ruv-neural-decoder/src/lib.rs deleted file mode 100644 index ed579a71..00000000 --- a/v2/crates/ruv-neural/ruv-neural-decoder/src/lib.rs +++ /dev/null @@ -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}; diff --git a/v2/crates/ruv-neural/ruv-neural-decoder/src/pipeline.rs b/v2/crates/ruv-neural/ruv-neural-decoder/src/pipeline.rs deleted file mode 100644 index 31779b31..00000000 --- a/v2/crates/ruv-neural/ruv-neural-decoder/src/pipeline.rs +++ /dev/null @@ -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, - threshold: Option, - transition: Option, - clinical: Option, - /// 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, - /// Brain health index from clinical scorer, if configured. - pub brain_health_index: Option, - /// Clinical warning flags. - pub clinical_flags: Vec, - /// 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 = 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) -> 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-decoder/src/threshold_decoder.rs b/v2/crates/ruv-neural/ruv-neural-decoder/src/threshold_decoder.rs deleted file mode 100644 index 28903764..00000000 --- a/v2/crates/ruv-neural/ruv-neural-decoder/src/threshold_decoder.rs +++ /dev/null @@ -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, -} - -/// 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::() / 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> = 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) -> (f64, f64) { - let vals: Vec = 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-decoder/src/transition_decoder.rs b/v2/crates/ruv-neural/ruv-neural-decoder/src/transition_decoder.rs deleted file mode 100644 index 9d4cf2b8..00000000 --- a/v2/crates/ruv-neural/ruv-neural-decoder/src/transition_decoder.rs +++ /dev/null @@ -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, - 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 { - 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 = 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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-embed/Cargo.toml b/v2/crates/ruv-neural/ruv-neural-embed/Cargo.toml deleted file mode 100644 index e5d40226..00000000 --- a/v2/crates/ruv-neural/ruv-neural-embed/Cargo.toml +++ /dev/null @@ -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 } diff --git a/v2/crates/ruv-neural/ruv-neural-embed/README.md b/v2/crates/ruv-neural/ruv-neural-embed/README.md deleted file mode 100644 index be1e29fe..00000000 --- a/v2/crates/ruv-neural/ruv-neural-embed/README.md +++ /dev/null @@ -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 diff --git a/v2/crates/ruv-neural/ruv-neural-embed/src/combined.rs b/v2/crates/ruv-neural/ruv-neural-embed/src/combined.rs deleted file mode 100644 index 09e15f33..00000000 --- a/v2/crates/ruv-neural/ruv-neural-embed/src/combined.rs +++ /dev/null @@ -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>, - weights: Vec, -} - -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, 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 { - 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 { - 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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-embed/src/distance.rs b/v2/crates/ruv-neural/ruv-neural-embed/src/distance.rs deleted file mode 100644 index b0644487..00000000 --- a/v2/crates/ruv-neural/ruv-neural-embed/src/distance.rs +++ /dev/null @@ -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) -> 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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-embed/src/lib.rs b/v2/crates/ruv-neural/ruv-neural-embed/src/lib.rs deleted file mode 100644 index ebfde321..00000000 --- a/v2/crates/ruv-neural/ruv-neural-embed/src/lib.rs +++ /dev/null @@ -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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-embed/src/node2vec.rs b/v2/crates/ruv-neural/ruv-neural-embed/src/node2vec.rs deleted file mode 100644 index 5eb97dcd..00000000 --- a/v2/crates/ruv-neural/ruv-neural-embed/src/node2vec.rs +++ /dev/null @@ -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], - n: usize, - start: usize, - rng: &mut StdRng, - ) -> Vec { - 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::() * 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::() * 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], n: usize) -> Vec> { - 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], n: usize, window: usize) -> Vec> { - 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], n: usize, k: usize) -> Vec> { - let k = k.min(n); - if k == 0 || n == 0 { - return vec![]; - } - - let mut result: Vec> = Vec::with_capacity(k); - - for col in 0..k { - let mut v: Vec = (0..n).map(|i| ((i + col + 1) as f64).sin()).collect(); - let norm = v.iter().map(|x| x * x).sum::().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::().sqrt(); - if prev_norm > 1e-12 { - let prev_unit: Vec = 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::().sqrt(); - if prev_norm > 1e-12 { - let prev_unit: Vec = 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::().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 { - 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> = 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::() / n as f64; - let var = component.iter().map(|x| (x - mean).powi(2)).sum::() / 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 { - 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 = (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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-embed/src/rvf_export.rs b/v2/crates/ruv-neural/ruv-neural-embed/src/rvf_export.rs deleted file mode 100644 index 7eafd023..00000000 --- a/v2/crates/ruv-neural/ruv-neural-embed/src/rvf_export.rs +++ /dev/null @@ -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, -} - -/// 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, - /// Optional subject identifier. - pub subject_id: Option, - /// Optional session identifier. - pub session_id: Option, -} - -/// Complete RVF document. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RvfDocument { - /// File header. - pub header: RvfHeader, - /// Embedding records. - pub records: Vec, -} - -/// 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> { - 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 { - 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 = 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> { - 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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-embed/src/spectral_embed.rs b/v2/crates/ruv-neural/ruv-neural-embed/src/spectral_embed.rs deleted file mode 100644 index 2b9cf9e8..00000000 --- a/v2/crates/ruv-neural/ruv-neural-embed/src/spectral_embed.rs +++ /dev/null @@ -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], n: usize) -> Vec> { - let degrees: Vec = (0..n).map(|i| adj[i].iter().sum::()).collect(); - - let inv_sqrt_deg: Vec = 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], - n: usize, - k: usize, - iterations: usize, - ) -> Vec> { - 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> = (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::with_capacity(k); - - for _ev in 0..k { - let mut v: Vec = (0..n).map(|i| ((i + 1) as f64).sin()).collect(); - let norm = v.iter().map(|x| x * x).sum::().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::().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 { - 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> = 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::() / n as f64; - let variance = ev.iter().map(|x| (x - mean).powi(2)).sum::() / 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 { - 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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-embed/src/temporal.rs b/v2/crates/ruv-neural/ruv-neural-embed/src/temporal.rs deleted file mode 100644 index e22dd985..00000000 --- a/v2/crates/ruv-neural/ruv-neural-embed/src/temporal.rs +++ /dev/null @@ -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, - /// 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, 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 { - if sequence.is_empty() { - return Err(RuvNeuralError::Embedding( - "Cannot embed empty graph sequence".into(), - )); - } - - let mut history: Vec = 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 { - 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 { - 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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-embed/src/topology_embed.rs b/v2/crates/ruv-neural/ruv-neural-embed/src/topology_embed.rs deleted file mode 100644 index c620f4a3..00000000 --- a/v2/crates/ruv-neural/ruv-neural-embed/src/topology_embed.rs +++ /dev/null @@ -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 = (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 = (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 = (0..n).map(|i| adj[i].iter().sum::()).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 = vec![1.0 / (n as f64).sqrt(); n]; - - let mut v: Vec = (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::().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::().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 = (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 { - 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 = (0..n).map(|i| graph.node_degree(i)).collect(); - - let mean_deg = if n > 0 { - degrees.iter().sum::() / n as f64 - } else { - 0.0 - }; - let std_deg = if n > 0 { - let var = - degrees.iter().map(|d| (d - mean_deg).powi(2)).sum::() / 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 { - 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-esp32/Cargo.toml b/v2/crates/ruv-neural/ruv-neural-esp32/Cargo.toml deleted file mode 100644 index f4d130ff..00000000 --- a/v2/crates/ruv-neural/ruv-neural-esp32/Cargo.toml +++ /dev/null @@ -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 } diff --git a/v2/crates/ruv-neural/ruv-neural-esp32/README.md b/v2/crates/ruv-neural/ruv-neural-esp32/README.md deleted file mode 100644 index ecea5f37..00000000 --- a/v2/crates/ruv-neural/ruv-neural-esp32/README.md +++ /dev/null @@ -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 diff --git a/v2/crates/ruv-neural/ruv-neural-esp32/src/adc.rs b/v2/crates/ruv-neural/ruv-neural-esp32/src/adc.rs deleted file mode 100644 index 0937f389..00000000 --- a/v2/crates/ruv-neural/ruv-neural-esp32/src/adc.rs +++ /dev/null @@ -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, - /// 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>, - 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>> { - 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 = (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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-esp32/src/aggregator.rs b/v2/crates/ruv-neural/ruv-neural-esp32/src/aggregator.rs deleted file mode 100644 index 11a87fd8..00000000 --- a/v2/crates/ruv-neural/ruv-neural-esp32/src/aggregator.rs +++ /dev/null @@ -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>, - 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 { - // 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 = 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::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 = 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) -> 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-esp32/src/lib.rs b/v2/crates/ruv-neural/ruv-neural-esp32/src/lib.rs deleted file mode 100644 index 56e97985..00000000 --- a/v2/crates/ruv-neural/ruv-neural-esp32/src/lib.rs +++ /dev/null @@ -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}; diff --git a/v2/crates/ruv-neural/ruv-neural-esp32/src/power.rs b/v2/crates/ruv-neural/ruv-neural-esp32/src/power.rs deleted file mode 100644 index 085c2cd8..00000000 --- a/v2/crates/ruv-neural/ruv-neural-esp32/src/power.rs +++ /dev/null @@ -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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-esp32/src/preprocessing.rs b/v2/crates/ruv-neural/ruv-neural-esp32/src/preprocessing.rs deleted file mode 100644 index 5a4cbf47..00000000 --- a/v2/crates/ruv-neural/ruv-neural-esp32/src/preprocessing.rs +++ /dev/null @@ -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 { - 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 { - 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 { - 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]) -> Vec> { - 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 = (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 = (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 = 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-esp32/src/protocol.rs b/v2/crates/ruv-neural/ruv-neural-esp32/src/protocol.rs deleted file mode 100644 index 0ccf252a..00000000 --- a/v2/crates/ruv-neural/ruv-neural-esp32/src/protocol.rs +++ /dev/null @@ -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, - /// 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, - /// Per-channel signal quality indicator (0 = worst, 255 = best). - pub quality: Vec, - /// 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 { - serde_json::to_vec(self).unwrap_or_default() - } - - /// Deserialize a packet from bytes. - pub fn deserialize(data: &[u8]) -> Result { - 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 { - let data: Vec> = 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-esp32/src/tdm.rs b/v2/crates/ruv-neural/ruv-neural-esp32/src/tdm.rs deleted file mode 100644 index cee4ed52..00000000 --- a/v2/crates/ruv-neural/ruv-neural-esp32/src/tdm.rs +++ /dev/null @@ -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, -} - -/// 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, - /// 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 = (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 { - 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 { - 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-graph/Cargo.toml b/v2/crates/ruv-neural/ruv-neural-graph/Cargo.toml deleted file mode 100644 index 97747878..00000000 --- a/v2/crates/ruv-neural/ruv-neural-graph/Cargo.toml +++ /dev/null @@ -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 } diff --git a/v2/crates/ruv-neural/ruv-neural-graph/README.md b/v2/crates/ruv-neural/ruv-neural-graph/README.md deleted file mode 100644 index 5b52fb4b..00000000 --- a/v2/crates/ruv-neural/ruv-neural-graph/README.md +++ /dev/null @@ -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 diff --git a/v2/crates/ruv-neural/ruv-neural-graph/src/atlas.rs b/v2/crates/ruv-neural/ruv-neural-graph/src/atlas.rs deleted file mode 100644 index 0d23f51e..00000000 --- a/v2/crates/ruv-neural/ruv-neural-graph/src/atlas.rs +++ /dev/null @@ -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 { - 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 = parcellation.regions.iter().map(|r| r.id).collect(); - let mut sorted = ids.clone(); - sorted.sort(); - sorted.dedup(); - assert_eq!(sorted.len(), 68); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-graph/src/constructor.rs b/v2/crates/ruv-neural/ruv-neural-graph/src/constructor.rs deleted file mode 100644 index 9fdb1ea0..00000000 --- a/v2/crates/ruv-neural/ruv-neural-graph/src/constructor.rs +++ /dev/null @@ -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], - 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 { - 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> { - 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::() / len; - let var = ch.iter().map(|x| (x - mean).powi(2)).sum::() / 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::() - / 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> = (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![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> = (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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-graph/src/dynamics.rs b/v2/crates/ruv-neural/ruv-neural-graph/src/dynamics.rs deleted file mode 100644 index 0ce529fb..00000000 --- a/v2/crates/ruv-neural/ruv-neural-graph/src/dynamics.rs +++ /dev/null @@ -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, -} - -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 { - 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) -> 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 - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-graph/src/lib.rs b/v2/crates/ruv-neural/ruv-neural-graph/src/lib.rs deleted file mode 100644 index 9a143f2f..00000000 --- a/v2/crates/ruv-neural/ruv-neural-graph/src/lib.rs +++ /dev/null @@ -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}; diff --git a/v2/crates/ruv-neural/ruv-neural-graph/src/metrics.rs b/v2/crates/ruv-neural/ruv-neural-graph/src/metrics.rs deleted file mode 100644 index 7caca7d2..00000000 --- a/v2/crates/ruv-neural/ruv-neural-graph/src/metrics.rs +++ /dev/null @@ -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 = (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 = (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 { - (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 { - 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![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]) -> 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::() / 2.0; - if m == 0.0 { - return 0.0; - } - - // Weighted degree - let degrees: Vec = (0..n) - .map(|i| adj[i].iter().sum::()) - .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> { - 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 = (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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs b/v2/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs deleted file mode 100644 index 21224e22..00000000 --- a/v2/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs +++ /dev/null @@ -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 { - let mut pg = Graph::new_undirected(); - let mut node_indices: Vec = 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, - 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-graph/src/spectral.rs b/v2/crates/ruv-neural/ruv-neural-graph/src/spectral.rs deleted file mode 100644 index 126fd0ba..00000000 --- a/v2/crates/ruv-neural/ruv-neural-graph/src/spectral.rs +++ /dev/null @@ -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>`. -pub fn graph_laplacian(graph: &BrainGraph) -> Vec> { - 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> { - let n = graph.num_nodes; - let adj = graph.adjacency_matrix(); - - // Compute D^{-1/2} - let degrees: Vec = (0..n).map(|i| adj[i].iter().sum::()).collect(); - let d_inv_sqrt: Vec = 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], 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::()) - .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 = (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::(); - - // 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::() - .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::(); - (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::() * 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::().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)); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-memory/Cargo.toml b/v2/crates/ruv-neural/ruv-neural-memory/Cargo.toml deleted file mode 100644 index fff7ff2a..00000000 --- a/v2/crates/ruv-neural/ruv-neural-memory/Cargo.toml +++ /dev/null @@ -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 diff --git a/v2/crates/ruv-neural/ruv-neural-memory/README.md b/v2/crates/ruv-neural/ruv-neural-memory/README.md deleted file mode 100644 index a0b8d7f4..00000000 --- a/v2/crates/ruv-neural/ruv-neural-memory/README.md +++ /dev/null @@ -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 diff --git a/v2/crates/ruv-neural/ruv-neural-memory/benches/benchmarks.rs b/v2/crates/ruv-neural/ruv-neural-memory/benches/benchmarks.rs deleted file mode 100644 index a00923ef..00000000 --- a/v2/crates/ruv-neural/ruv-neural-memory/benches/benchmarks.rs +++ /dev/null @@ -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> { - 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]) -> 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::() - .sqrt() -} - -/// Brute-force k-nearest-neighbor search. -fn brute_force_knn( - embeddings: &[Vec], - 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 = (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 = (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); diff --git a/v2/crates/ruv-neural/ruv-neural-memory/src/hnsw.rs b/v2/crates/ruv-neural/ruv-neural-memory/src/hnsw.rs deleted file mode 100644 index 10779e6e..00000000 --- a/v2/crates/ruv-neural/ruv-neural-memory/src/hnsw.rs +++ /dev/null @@ -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 { - 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 { - 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>>, - /// 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>, -} - -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::() - .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::new(); - - for _ in 0..n { - let v: Vec = (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 = (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 = bf_distances.iter().take(k).map(|(i, _)| *i).collect(); - - // HNSW search - let hnsw_results = index.search(&query, k, 50); - let hnsw_top_k: Vec = 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-memory/src/lib.rs b/v2/crates/ruv-neural/ruv-neural-memory/src/lib.rs deleted file mode 100644 index e41b26ec..00000000 --- a/v2/crates/ruv-neural/ruv-neural-memory/src/lib.rs +++ /dev/null @@ -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; diff --git a/v2/crates/ruv-neural/ruv-neural-memory/src/longitudinal.rs b/v2/crates/ruv-neural/ruv-neural-memory/src/longitudinal.rs deleted file mode 100644 index e045b044..00000000 --- a/v2/crates/ruv-neural/ruv-neural-memory/src/longitudinal.rs +++ /dev/null @@ -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, - /// Current trajectory of observations. - current_trajectory: Vec, - /// 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) { - 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 = self.current_trajectory[..mid] - .iter() - .map(|obs| self.min_distance_to_baseline(obs)) - .collect(); - let second_half: Vec = 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 = 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::() / 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, 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-memory/src/persistence.rs b/v2/crates/ruv-neural/ruv-neural-memory/src/persistence.rs deleted file mode 100644 index b6077d2d..00000000 --- a/v2/crates/ruv-neural/ruv-neural-memory/src/persistence.rs +++ /dev/null @@ -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, - 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 { - 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 = 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 { - 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 = 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, 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-memory/src/session.rs b/v2/crates/ruv-neural/ruv-neural-memory/src/session.rs deleted file mode 100644 index 82c60fd8..00000000 --- a/v2/crates/ruv-neural/ruv-neural-memory/src/session.rs +++ /dev/null @@ -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, - /// Number of embeddings stored during this session. - pub num_embeddings: usize, - /// Cognitive states observed during the session. - pub cognitive_states_observed: Vec, -} - -/// Manages neural memory across recording sessions. -pub struct SessionMemory { - /// Underlying embedding store. - store: NeuralMemoryStore, - /// Currently active session ID. - current_session: Option, - /// Metadata for all sessions. - session_metadata: HashMap, - /// Maps session_id to embedding indices. - session_indices: HashMap>, - /// 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 { - 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, 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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-memory/src/store.rs b/v2/crates/ruv-neural/ruv-neural-memory/src/store.rs deleted file mode 100644 index 997c61db..00000000 --- a/v2/crates/ruv-neural/ruv-neural-memory/src/store.rs +++ /dev/null @@ -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, - /// Maps subject_id to the indices of their embeddings. - index: HashMap>, - /// 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 { - // 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 { - 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> { - 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> { - 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, 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, - 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]); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-mincut/Cargo.toml b/v2/crates/ruv-neural/ruv-neural-mincut/Cargo.toml deleted file mode 100644 index 8a284ab4..00000000 --- a/v2/crates/ruv-neural/ruv-neural-mincut/Cargo.toml +++ /dev/null @@ -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 diff --git a/v2/crates/ruv-neural/ruv-neural-mincut/README.md b/v2/crates/ruv-neural/ruv-neural-mincut/README.md deleted file mode 100644 index fa9e3ab1..00000000 --- a/v2/crates/ruv-neural/ruv-neural-mincut/README.md +++ /dev/null @@ -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 diff --git a/v2/crates/ruv-neural/ruv-neural-mincut/benches/benchmarks.rs b/v2/crates/ruv-neural/ruv-neural-mincut/benches/benchmarks.rs deleted file mode 100644 index bcd759c9..00000000 --- a/v2/crates/ruv-neural/ruv-neural-mincut/benches/benchmarks.rs +++ /dev/null @@ -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); diff --git a/v2/crates/ruv-neural/ruv-neural-mincut/src/benchmark.rs b/v2/crates/ruv-neural/ruv-neural-mincut/src/benchmark.rs deleted file mode 100644 index c76e13ef..00000000 --- a/v2/crates/ruv-neural/ruv-neural-mincut/src/benchmark.rs +++ /dev/null @@ -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 { - 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-mincut/src/coherence.rs b/v2/crates/ruv-neural/ruv-neural-mincut/src/coherence.rs deleted file mode 100644 index f6b85c4b..00000000 --- a/v2/crates/ruv-neural/ruv-neural-mincut/src/coherence.rs +++ /dev/null @@ -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, - /// 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 { - 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> { - 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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-mincut/src/dynamic.rs b/v2/crates/ruv-neural/ruv-neural-mincut/src/dynamic.rs deleted file mode 100644 index af2731c8..00000000 --- a/v2/crates/ruv-neural/ruv-neural-mincut/src/dynamic.rs +++ /dev/null @@ -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, - /// Timestamps corresponding to each result. - timestamps: Vec, - /// Baseline mincut from resting state. - baseline: Option, -} - -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 { - 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 { - 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 { - 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 = - self.history[i - 1].partition_a.iter().copied().collect(); - let curr_a: std::collections::HashSet = - 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 = - 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, b: &std::collections::HashSet) -> 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); - } - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-mincut/src/lib.rs b/v2/crates/ruv-neural/ruv-neural-mincut/src/lib.rs deleted file mode 100644 index 3b91ef9a..00000000 --- a/v2/crates/ruv-neural/ruv-neural-mincut/src/lib.rs +++ /dev/null @@ -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}; diff --git a/v2/crates/ruv-neural/ruv-neural-mincut/src/multiway.rs b/v2/crates/ruv-neural/ruv-neural-mincut/src/multiway.rs deleted file mode 100644 index 30e0407e..00000000 --- a/v2/crates/ruv-neural/ruv-neural-mincut/src/multiway.rs +++ /dev/null @@ -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 { - 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![(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 = sub_result - .partition_a - .iter() - .map(|&i| to_split[i]) - .collect(); - let part_b: Vec = 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 { - 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 = 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 = 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]) -> f64 { - let adj = graph.adjacency_matrix(); - let n = graph.num_nodes; - let m: f64 = graph.edges.iter().map(|e| e.weight).sum::(); - - 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 = (0..n).map(|i| adj[i].iter().sum::()).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]) -> 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-mincut/src/normalized.rs b/v2/crates/ruv-neural/ruv-neural-mincut/src/normalized.rs deleted file mode 100644 index 9e3b4cfb..00000000 --- a/v2/crates/ruv-neural/ruv-neural-mincut/src/normalized.rs +++ /dev/null @@ -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 { - 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 = (0..n) - .map(|i| adj[i].iter().sum::()) - .collect(); - - // Sort node indices by Fiedler value. - let mut sorted_indices: Vec = (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 = sorted_indices[..best_split].to_vec(); - let partition_b: Vec = sorted_indices[best_split..].to_vec(); - - let partition_a_set: std::collections::HashSet = - 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 = set_a.iter().copied().collect(); - let b_set: std::collections::HashSet = 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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-mincut/src/spectral_cut.rs b/v2/crates/ruv-neural/ruv-neural-mincut/src/spectral_cut.rs deleted file mode 100644 index 34f6a84a..00000000 --- a/v2/crates/ruv-neural/ruv-neural-mincut/src/spectral_cut.rs +++ /dev/null @@ -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)> { - 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 = vec![1.0 / (n as f64).sqrt(); n]; - - // Random-ish initial vector, orthogonal to ones. - let mut v: Vec = (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 { - 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 = - 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 { - 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 = (0..n) - .map(|i| adj[i].iter().sum::()) - .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::()) - .sum(); - let vol_b: f64 = result - .partition_b - .iter() - .map(|&i| adj[i].iter().sum::()) - .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], n: usize, max_iter: usize) -> f64 { - let mut v: Vec = (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::().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::()) - .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); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-mincut/src/stoer_wagner.rs b/v2/crates/ruv-neural/ruv-neural-mincut/src/stoer_wagner.rs deleted file mode 100644 index 1427e5f6..00000000 --- a/v2/crates/ruv-neural/ruv-neural-mincut/src/stoer_wagner.rs +++ /dev/null @@ -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 { - 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> = adj; - - // `merged[i]` holds the list of original node indices that have been merged - // into supernode i. - let mut merged: Vec> = (0..n).map(|i| vec![i]).collect(); - - // Which supernodes are still active. - let mut active: Vec = vec![true; n]; - - let mut best_cut_value = f64::INFINITY; - let mut best_partition: Vec = 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 = best_partition.clone(); - partition_a.sort_unstable(); - let partition_a_set: std::collections::HashSet = - partition_a.iter().copied().collect(); - let mut partition_b: Vec = (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, -} - -/// 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], - active: &[bool], - merged: &[Vec], -) -> Result { - let n = w.len(); - - // Find all active nodes. - let active_nodes: Vec = (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 = vec![0.0; n]; - let mut in_a: Vec = 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], - merged: &mut [Vec], - 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 = 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, -) -> 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 - ); - } - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-sensor/Cargo.toml b/v2/crates/ruv-neural/ruv-neural-sensor/Cargo.toml deleted file mode 100644 index e1c50f92..00000000 --- a/v2/crates/ruv-neural/ruv-neural-sensor/Cargo.toml +++ /dev/null @@ -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 } diff --git a/v2/crates/ruv-neural/ruv-neural-sensor/README.md b/v2/crates/ruv-neural/ruv-neural-sensor/README.md deleted file mode 100644 index 2dbdc775..00000000 --- a/v2/crates/ruv-neural/ruv-neural-sensor/README.md +++ /dev/null @@ -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 diff --git a/v2/crates/ruv-neural/ruv-neural-sensor/src/calibration.rs b/v2/crates/ruv-neural/ruv-neural-sensor/src/calibration.rs deleted file mode 100644 index 1adcb7e7..00000000 --- a/v2/crates/ruv-neural/ruv-neural-sensor/src/calibration.rs +++ /dev/null @@ -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, - /// Per-channel DC offsets to subtract. - pub offsets: Vec, - /// Per-channel noise floor estimates (fT RMS). - pub noise_floors: Vec, -} - -/// 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::() / 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::() / n as f64; - let mean_t = target[..n].iter().sum::() / 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) -} diff --git a/v2/crates/ruv-neural/ruv-neural-sensor/src/eeg.rs b/v2/crates/ruv-neural/ruv-neural-sensor/src/eeg.rs deleted file mode 100644 index 464c4606..00000000 --- a/v2/crates/ruv-neural/ruv-neural-sensor/src/eeg.rs +++ /dev/null @@ -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, - /// 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>, -} - -impl Default for EegConfig { - fn default() -> Self { - let labels: Vec = 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::(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 { - 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]) { - 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::() / 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 { - 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 = (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> = (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) - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-sensor/src/lib.rs b/v2/crates/ruv-neural/ruv-neural-sensor/src/lib.rs deleted file mode 100644 index bf7bea68..00000000 --- a/v2/crates/ruv-neural/ruv-neural-sensor/src/lib.rs +++ /dev/null @@ -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::() / 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 = (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 = (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 = (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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-sensor/src/nv_diamond.rs b/v2/crates/ruv-neural/ruv-neural-sensor/src/nv_diamond.rs deleted file mode 100644 index 80c29b0a..00000000 --- a/v2/crates/ruv-neural/ruv-neural-sensor/src/nv_diamond.rs +++ /dev/null @@ -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, - /// Noise floor in fT/sqrt(Hz), per channel. - pub noise_floor_ft: Vec, - /// Zero-field splitting offset per channel in MHz. - pub zfs_offset_mhz: Vec, -} - -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, -} - -/// 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::(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 { - 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 { - 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 { - 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> = (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) - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-sensor/src/opm.rs b/v2/crates/ruv-neural/ruv-neural-sensor/src/opm.rs deleted file mode 100644 index 2d88cc76..00000000 --- a/v2/crates/ruv-neural/ruv-neural-sensor/src/opm.rs +++ /dev/null @@ -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, - /// 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>, - /// Active shielding compensation coefficients per channel. - pub active_shielding_coeffs: Vec, -} - -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>) -> 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 = 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], rhs: &[f64]) -> Option> { - 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> = 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 { - 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> = (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::(&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>) -> 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 = (0..3) - .map(|i| ct[i].iter().zip(&expected_t0).map(|(c, x)| c * x).sum()) - .collect(); - let raw_t1: Vec = (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()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-sensor/src/quality.rs b/v2/crates/ruv-neural/ruv-neural-sensor/src/quality.rs deleted file mode 100644 index c1970889..00000000 --- a/v2/crates/ruv-neural/ruv-neural-sensor/src/quality.rs +++ /dev/null @@ -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 { - 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::() / signal.len() as f64; - let variance = signal.iter().map(|x| (x - mean).powi(2)).sum::() / 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::() - / (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 -} diff --git a/v2/crates/ruv-neural/ruv-neural-sensor/src/simulator.rs b/v2/crates/ruv-neural/ruv-neural-sensor/src/simulator.rs deleted file mode 100644 index 25b30160..00000000 --- a/v2/crates/ruv-neural/ruv-neural-sensor/src/simulator.rs +++ /dev/null @@ -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, - /// Pending events to inject on the next acquisition. - pending_events: Vec, - /// 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 { - 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::() * 2.0 - 1.0; - let noise2: f64 = self.rng.gen::() * 2.0 - 1.0; - // Box-Muller transform for Gaussian noise. - let u1 = self.rng.gen::().max(1e-15); - let u2 = self.rng.gen::(); - 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 { - 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) - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-signal/Cargo.toml b/v2/crates/ruv-neural/ruv-neural-signal/Cargo.toml deleted file mode 100644 index 04cb9c5f..00000000 --- a/v2/crates/ruv-neural/ruv-neural-signal/Cargo.toml +++ /dev/null @@ -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 diff --git a/v2/crates/ruv-neural/ruv-neural-signal/README.md b/v2/crates/ruv-neural/ruv-neural-signal/README.md deleted file mode 100644 index 8a790044..00000000 --- a/v2/crates/ruv-neural/ruv-neural-signal/README.md +++ /dev/null @@ -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 diff --git a/v2/crates/ruv-neural/ruv-neural-signal/benches/benchmarks.rs b/v2/crates/ruv-neural/ruv-neural-signal/benches/benchmarks.rs deleted file mode 100644 index 6953ab18..00000000 --- a/v2/crates/ruv-neural/ruv-neural-signal/benches/benchmarks.rs +++ /dev/null @@ -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 { - (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> = (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); diff --git a/v2/crates/ruv-neural/ruv-neural-signal/src/artifact.rs b/v2/crates/ruv-neural/ruv-neural-signal/src/artifact.rs deleted file mode 100644 index a526aa6c..00000000 --- a/v2/crates/ruv-neural/ruv-neural-signal/src/artifact.rs +++ /dev/null @@ -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 = filtered.iter().map(|x| x.abs()).collect(); - - // Compute mean and std of the absolute filtered signal - let mean = abs_signal.iter().sum::() / abs_signal.len() as f64; - let variance = abs_signal - .iter() - .map(|x| (x - mean).powi(2)) - .sum::() - / 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::() / n as f64; - let variance = rms_signal - .iter() - .map(|x| (x - mean).powi(2)) - .sum::() - / 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 { - 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 = 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::() / n as f64; - let variance = integrated - .iter() - .map(|x| (x - mean).powi(2)) - .sum::() - / 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 = 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() - ); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-signal/src/connectivity.rs b/v2/crates/ruv-neural/ruv-neural-signal/src/connectivity.rs deleted file mode 100644 index 9b89512b..00000000 --- a/v2/crates/ruv-neural/ruv-neural-signal/src/connectivity.rs +++ /dev/null @@ -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> = 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> = (0..window_size) - .map(|i| Complex::new(signal_a[start + i] * window[i], 0.0)) - .collect(); - let mut fb: Vec> = (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> = (0..window_size) - .map(|i| Complex::new(signal_a[start + i] * window[i], 0.0)) - .collect(); - let mut fb: Vec> = (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> { - 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>> = 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> = 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::() / n as f64; - let mean_b = b[..n].iter().sum::() / 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 { - (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 = (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 = (0..n) - .map(|i| { - let t = i as f64 / sr; - (2.0 * PI * 10.0 * t).sin() - }) - .collect(); - let signal_b: Vec = (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 = (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); - } - } - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-signal/src/filter.rs b/v2/crates/ruv-neural/ruv-neural-signal/src/filter.rs deleted file mode 100644 index 3e07d902..00000000 --- a/v2/crates/ruv-neural/ruv-neural-signal/src/filter.rs +++ /dev/null @@ -1,511 +0,0 @@ -//! Digital filters for neural signal processing. -//! -//! Implements Butterworth IIR filters in second-order sections (SOS) form -//! for numerical stability. Supports bandpass, notch (band-reject), -//! highpass, and lowpass configurations. -//! -//! All filters implement the [`SignalProcessor`] trait for uniform usage. - -use serde::{Deserialize, Serialize}; -use std::f64::consts::PI; - -/// Trait for signal processing operations. -pub trait SignalProcessor { - /// Apply the processor to a signal, returning the filtered output. - fn process(&self, signal: &[f64]) -> Vec; -} - -/// A single second-order section (biquad) with coefficients. -/// -/// Transfer function: H(z) = (b0 + b1*z^-1 + b2*z^-2) / (1 + a1*z^-1 + a2*z^-2) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SecondOrderSection { - pub b0: f64, - pub b1: f64, - pub b2: f64, - pub a1: f64, - pub a2: f64, -} - -impl SecondOrderSection { - /// Apply this biquad section to a signal using Direct Form II Transposed. - fn apply(&self, signal: &[f64]) -> Vec { - let n = signal.len(); - let mut output = vec![0.0; n]; - let mut w1 = 0.0; - let mut w2 = 0.0; - - for i in 0..n { - let x = signal[i]; - let y = self.b0 * x + w1; - w1 = self.b1 * x - self.a1 * y + w2; - w2 = self.b2 * x - self.a2 * y; - output[i] = y; - } - - output - } -} - -/// Apply a cascade of second-order sections to a signal (forward-backward -/// for zero-phase filtering). -fn apply_sos_filtfilt(sections: &[SecondOrderSection], signal: &[f64]) -> Vec { - if signal.is_empty() { - return Vec::new(); - } - - // Forward pass through all sections - let mut result = signal.to_vec(); - for sos in sections { - result = sos.apply(&result); - } - - // Reverse - result.reverse(); - - // Backward pass through all sections - for sos in sections { - result = sos.apply(&result); - } - - // Reverse back to original order - result.reverse(); - - result -} - -/// Design Butterworth analog prototype poles for a given order. -/// Returns poles on the unit circle in the left half of the s-plane. -fn butterworth_poles(order: usize) -> Vec<(f64, f64)> { - let mut poles = Vec::new(); - for k in 0..order { - let theta = PI * (2 * k + order + 1) as f64 / (2 * order) as f64; - poles.push((theta.cos(), theta.sin())); - } - poles -} - -/// Prewarp a frequency from digital to analog domain. -fn prewarp(freq_hz: f64, sample_rate: f64) -> f64 { - 2.0 * sample_rate * (PI * freq_hz / sample_rate).tan() -} - -/// Design a lowpass second-order section from analog prototype poles -/// using the bilinear transform. -fn design_lowpass_sos(pole_re: f64, pole_im: f64, wc: f64, fs: f64) -> SecondOrderSection { - let t = 1.0 / (2.0 * fs); - - if pole_im.abs() < 1e-14 { - // Real pole -> embed in SOS with b2=0, a2=0 - let s_re = wc * pole_re; - let d = 1.0 - s_re * t; - let n = -(s_re * t); - SecondOrderSection { - b0: n / d, - b1: n / d, - b2: 0.0, - a1: -(1.0 + s_re * t) / d, - a2: 0.0, - } - } else { - // Complex conjugate pair - let s_re = wc * pole_re; - let s_im = wc * pole_im; - let denom = (1.0 - s_re * t).powi(2) + (s_im * t).powi(2); - let a1 = 2.0 * ((s_re * t).powi(2) + (s_im * t).powi(2) - 1.0) / denom; - let a2 = ((1.0 + s_re * t).powi(2) + (s_im * t).powi(2)) / denom; - let num_gain = (wc * t).powi(2) / denom; - SecondOrderSection { - b0: num_gain, - b1: 2.0 * num_gain, - b2: num_gain, - a1, - a2, - } - } -} - -/// Design a highpass second-order section from analog prototype poles. -fn design_highpass_sos(pole_re: f64, pole_im: f64, wc: f64, fs: f64) -> SecondOrderSection { - let t = 1.0 / (2.0 * fs); - - if pole_im.abs() < 1e-14 { - // Real pole - let alpha = wc / (-pole_re); - let d = 1.0 + alpha * t; - SecondOrderSection { - b0: 1.0 / d, - b1: -1.0 / d, - b2: 0.0, - a1: -(1.0 - alpha * t) / d, - a2: 0.0, - } - } else { - // Complex conjugate pair: HP transform s -> wc/s - let mag_sq = pole_re.powi(2) + pole_im.powi(2); - let hp_re = wc * pole_re / mag_sq; - let hp_im = -wc * pole_im / mag_sq; - - let denom = (1.0 - hp_re * t).powi(2) + (hp_im * t).powi(2); - let a1 = 2.0 * ((hp_re * t).powi(2) + (hp_im * t).powi(2) - 1.0) / denom; - let a2 = ((1.0 + hp_re * t).powi(2) + (hp_im * t).powi(2)) / denom; - let num_gain = 1.0 / denom; - SecondOrderSection { - b0: num_gain, - b1: -2.0 * num_gain, - b2: num_gain, - a1, - a2, - } - } -} - -/// Design Butterworth lowpass filter as cascade of second-order sections. -fn design_butterworth_lowpass(order: usize, cutoff_hz: f64, sample_rate: f64) -> Vec { - let wc = prewarp(cutoff_hz, sample_rate); - let poles = butterworth_poles(order); - let mut sections = Vec::new(); - - let mut i = 0; - while i < poles.len() { - if poles[i].1.abs() < 1e-14 { - sections.push(design_lowpass_sos(poles[i].0, 0.0, wc, sample_rate)); - i += 1; - } else { - sections.push(design_lowpass_sos(poles[i].0, poles[i].1, wc, sample_rate)); - i += 2; - } - } - - sections -} - -/// Design Butterworth highpass filter as cascade of second-order sections. -fn design_butterworth_highpass(order: usize, cutoff_hz: f64, sample_rate: f64) -> Vec { - let wc = prewarp(cutoff_hz, sample_rate); - let poles = butterworth_poles(order); - let mut sections = Vec::new(); - - let mut i = 0; - while i < poles.len() { - if poles[i].1.abs() < 1e-14 { - sections.push(design_highpass_sos(poles[i].0, 0.0, wc, sample_rate)); - i += 1; - } else { - sections.push(design_highpass_sos(poles[i].0, poles[i].1, wc, sample_rate)); - i += 2; - } - } - - sections -} - -/// Butterworth IIR bandpass filter using cascaded second-order sections. -/// -/// Applies a zero-phase (forward-backward) filter for no phase distortion. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BandpassFilter { - /// Filter order (per lowpass/highpass stage). - pub order: usize, - /// Lower cutoff frequency in Hz. - pub low_hz: f64, - /// Upper cutoff frequency in Hz. - pub high_hz: f64, - /// Sampling rate in Hz. - pub sample_rate: f64, - /// Highpass SOS sections (for low_hz cutoff). - hp_sections: Vec, - /// Lowpass SOS sections (for high_hz cutoff). - lp_sections: Vec, -} - -impl BandpassFilter { - /// Create a new Butterworth bandpass filter. - /// - /// # Arguments - /// * `order` - Filter order (typically 2-6) - /// * `low_hz` - Lower cutoff frequency in Hz - /// * `high_hz` - Upper cutoff frequency in Hz - /// * `sample_rate` - Sampling rate in Hz - pub fn new(order: usize, low_hz: f64, high_hz: f64, sample_rate: f64) -> Self { - let hp_sections = design_butterworth_highpass(order, low_hz, sample_rate); - let lp_sections = design_butterworth_lowpass(order, high_hz, sample_rate); - Self { - order, - low_hz, - high_hz, - sample_rate, - hp_sections, - lp_sections, - } - } - - /// Apply the bandpass filter to a signal. - pub fn apply(&self, signal: &[f64]) -> Vec { - let hp_out = apply_sos_filtfilt(&self.hp_sections, signal); - apply_sos_filtfilt(&self.lp_sections, &hp_out) - } -} - -impl SignalProcessor for BandpassFilter { - fn process(&self, signal: &[f64]) -> Vec { - self.apply(signal) - } -} - -/// Notch (band-reject) filter for removing line noise (50/60 Hz). -/// -/// Implements a second-order IIR notch filter. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NotchFilter { - /// Center frequency to reject in Hz. - pub center_hz: f64, - /// Rejection bandwidth in Hz. - pub bandwidth_hz: f64, - /// Sampling rate in Hz. - pub sample_rate: f64, - /// The notch filter section. - section: SecondOrderSection, -} - -impl NotchFilter { - /// Create a new notch filter. - /// - /// # Arguments - /// * `center_hz` - Center frequency to reject (e.g., 50.0 or 60.0) - /// * `bandwidth_hz` - Width of the rejection band in Hz (e.g., 2.0) - /// * `sample_rate` - Sampling rate in Hz - pub fn new(center_hz: f64, bandwidth_hz: f64, sample_rate: f64) -> Self { - let w0 = 2.0 * PI * center_hz / sample_rate; - let bw = 2.0 * PI * bandwidth_hz / sample_rate; - let q = w0.sin() / bw; - let alpha = w0.sin() / (2.0 * q); - - let a0 = 1.0 + alpha; - let section = SecondOrderSection { - b0: 1.0 / a0, - b1: -2.0 * w0.cos() / a0, - b2: 1.0 / a0, - a1: -2.0 * w0.cos() / a0, - a2: (1.0 - alpha) / a0, - }; - - Self { - center_hz, - bandwidth_hz, - sample_rate, - section, - } - } - - /// Apply the notch filter to a signal (zero-phase). - pub fn apply(&self, signal: &[f64]) -> Vec { - apply_sos_filtfilt(&[self.section.clone()], signal) - } -} - -impl SignalProcessor for NotchFilter { - fn process(&self, signal: &[f64]) -> Vec { - self.apply(signal) - } -} - -/// Butterworth highpass filter using second-order sections. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HighpassFilter { - /// Filter order. - pub order: usize, - /// Cutoff frequency in Hz. - pub cutoff_hz: f64, - /// Sampling rate in Hz. - pub sample_rate: f64, - /// SOS sections. - sections: Vec, -} - -impl HighpassFilter { - /// Create a new Butterworth highpass filter. - pub fn new(order: usize, cutoff_hz: f64, sample_rate: f64) -> Self { - let sections = design_butterworth_highpass(order, cutoff_hz, sample_rate); - Self { - order, - cutoff_hz, - sample_rate, - sections, - } - } - - /// Apply the highpass filter to a signal (zero-phase). - pub fn apply(&self, signal: &[f64]) -> Vec { - apply_sos_filtfilt(&self.sections, signal) - } -} - -impl SignalProcessor for HighpassFilter { - fn process(&self, signal: &[f64]) -> Vec { - self.apply(signal) - } -} - -/// Butterworth lowpass filter using second-order sections. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LowpassFilter { - /// Filter order. - pub order: usize, - /// Cutoff frequency in Hz. - pub cutoff_hz: f64, - /// Sampling rate in Hz. - pub sample_rate: f64, - /// SOS sections. - sections: Vec, -} - -impl LowpassFilter { - /// Create a new Butterworth lowpass filter. - pub fn new(order: usize, cutoff_hz: f64, sample_rate: f64) -> Self { - let sections = design_butterworth_lowpass(order, cutoff_hz, sample_rate); - Self { - order, - cutoff_hz, - sample_rate, - sections, - } - } - - /// Apply the lowpass filter to a signal (zero-phase). - pub fn apply(&self, signal: &[f64]) -> Vec { - apply_sos_filtfilt(&self.sections, signal) - } -} - -impl SignalProcessor for LowpassFilter { - fn process(&self, signal: &[f64]) -> Vec { - self.apply(signal) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::f64::consts::PI; - - fn sine_wave(freq_hz: f64, sample_rate: f64, duration_s: f64) -> Vec { - let n = (sample_rate * duration_s) as usize; - (0..n) - .map(|i| { - let t = i as f64 / sample_rate; - (2.0 * PI * freq_hz * t).sin() - }) - .collect() - } - - fn rms(signal: &[f64]) -> f64 { - let sum_sq: f64 = signal.iter().map(|x| x * x).sum(); - (sum_sq / signal.len() as f64).sqrt() - } - - #[test] - fn bandpass_passes_correct_frequency() { - let sr = 1000.0; - let dur = 2.0; - let in_band = sine_wave(20.0, sr, dur); - let out_band = sine_wave(200.0, sr, dur); - let signal: Vec = in_band.iter().zip(&out_band).map(|(a, b)| a + b).collect(); - - let filter = BandpassFilter::new(4, 10.0, 50.0, sr); - let filtered = filter.apply(&signal); - - let in_rms = rms(&in_band); - let filtered_rms = rms(&filtered[200..filtered.len() - 200]); - - assert!( - (filtered_rms - in_rms).abs() / in_rms < 0.3, - "Bandpass should preserve in-band signal: filtered_rms={filtered_rms}, in_rms={in_rms}" - ); - } - - #[test] - fn bandpass_rejects_out_of_band() { - let sr = 1000.0; - let dur = 2.0; - let signal = sine_wave(200.0, sr, dur); - - let filter = BandpassFilter::new(4, 10.0, 50.0, sr); - let filtered = filter.apply(&signal); - - let orig_rms = rms(&signal); - let filtered_rms = rms(&filtered[200..filtered.len() - 200]); - - assert!( - filtered_rms / orig_rms < 0.1, - "Bandpass should reject out-of-band: ratio={}", - filtered_rms / orig_rms - ); - } - - #[test] - fn notch_removes_target_frequency() { - let sr = 1000.0; - let dur = 2.0; - let keep = sine_wave(10.0, sr, dur); - let remove = sine_wave(50.0, sr, dur); - let signal: Vec = keep.iter().zip(&remove).map(|(a, b)| a + b).collect(); - - let filter = NotchFilter::new(50.0, 2.0, sr); - let filtered = filter.apply(&signal); - - let keep_rms = rms(&keep); - let filtered_rms = rms(&filtered[200..filtered.len() - 200]); - - assert!( - (filtered_rms - keep_rms).abs() / keep_rms < 0.3, - "Notch should preserve nearby: filtered_rms={filtered_rms}, keep_rms={keep_rms}" - ); - } - - #[test] - fn lowpass_passes_low_frequency() { - let sr = 1000.0; - let dur = 2.0; - let low = sine_wave(5.0, sr, dur); - let high = sine_wave(100.0, sr, dur); - let signal: Vec = low.iter().zip(&high).map(|(a, b)| a + b).collect(); - - let filter = LowpassFilter::new(4, 20.0, sr); - let filtered = filter.apply(&signal); - - let low_rms = rms(&low); - let filtered_rms = rms(&filtered[200..filtered.len() - 200]); - - assert!( - (filtered_rms - low_rms).abs() / low_rms < 0.3, - "Lowpass should preserve low freq" - ); - } - - #[test] - fn highpass_passes_high_frequency() { - let sr = 1000.0; - let dur = 2.0; - let low = sine_wave(1.0, sr, dur); - let high = sine_wave(50.0, sr, dur); - let signal: Vec = low.iter().zip(&high).map(|(a, b)| a + b).collect(); - - let filter = HighpassFilter::new(4, 10.0, sr); - let filtered = filter.apply(&signal); - - let high_rms = rms(&high); - let filtered_rms = rms(&filtered[200..filtered.len() - 200]); - - assert!( - (filtered_rms - high_rms).abs() / high_rms < 0.3, - "Highpass should preserve high freq" - ); - } - - #[test] - fn empty_signal_returns_empty() { - let filter = BandpassFilter::new(2, 1.0, 50.0, 1000.0); - assert!(filter.apply(&[]).is_empty()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-signal/src/hilbert.rs b/v2/crates/ruv-neural/ruv-neural-signal/src/hilbert.rs deleted file mode 100644 index e5ef902a..00000000 --- a/v2/crates/ruv-neural/ruv-neural-signal/src/hilbert.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! Hilbert transform for instantaneous phase and amplitude extraction. -//! -//! Computes the analytic signal via FFT-based Hilbert transform: -//! 1. FFT the real signal -//! 2. Zero negative frequencies, double positive frequencies -//! 3. IFFT to obtain the analytic signal -//! -//! The instantaneous amplitude is |analytic(t)| and the instantaneous -//! phase is arg(analytic(t)). - -use num_complex::Complex; -use rustfft::FftPlanner; -use std::cell::RefCell; - -thread_local! { - static FFT_PLANNER: RefCell> = RefCell::new(FftPlanner::new()); -} - -/// Compute the analytic signal via FFT-based Hilbert transform. -/// -/// Given a real signal x(t), returns the analytic signal z(t) = x(t) + j * H[x](t), -/// where H[x] is the Hilbert transform of x. -/// -/// Uses a thread-local cached FftPlanner to avoid re-creating plans on every call. -pub fn hilbert_transform(signal: &[f64]) -> Vec> { - let n = signal.len(); - if n == 0 { - return Vec::new(); - } - - let (fft_forward, fft_inverse) = FFT_PLANNER.with(|planner| { - let mut planner = planner.borrow_mut(); - let fwd = planner.plan_fft_forward(n); - let inv = planner.plan_fft_inverse(n); - (fwd, inv) - }); - - // Forward FFT - let mut spectrum: Vec> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect(); - fft_forward.process(&mut spectrum); - - // Build the analytic signal in the frequency domain: - // - DC component (k=0): multiply by 1 - // - Positive frequencies (k=1..n/2-1): multiply by 2 - // - Nyquist (k=n/2, if n is even): multiply by 1 - // - Negative frequencies (k=n/2+1..n-1): multiply by 0 - if n > 1 { - let half = n / 2; - for k in 1..half { - spectrum[k] *= 2.0; - } - // Nyquist bin stays at 1x if n is even (already correct) - for k in (half + 1)..n { - spectrum[k] = Complex::new(0.0, 0.0); - } - } - - // Inverse FFT - fft_inverse.process(&mut spectrum); - - // Normalize by N (rustfft does unnormalized transforms) - let inv_n = 1.0 / n as f64; - for s in &mut spectrum { - *s *= inv_n; - } - - spectrum -} - -/// Compute the instantaneous phase of a signal via the Hilbert transform. -/// -/// Returns phase values in radians in the range (-pi, pi]. -pub fn instantaneous_phase(signal: &[f64]) -> Vec { - hilbert_transform(signal) - .iter() - .map(|z| z.im.atan2(z.re)) - .collect() -} - -/// Compute the instantaneous amplitude (envelope) of a signal via the Hilbert transform. -/// -/// Returns |analytic(t)| for each sample. -pub fn instantaneous_amplitude(signal: &[f64]) -> Vec { - hilbert_transform(signal) - .iter() - .map(|z| z.norm()) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use approx::assert_abs_diff_eq; - use std::f64::consts::PI; - - #[test] - fn hilbert_of_cosine_gives_sine() { - // For cos(2*pi*f*t), the Hilbert transform is sin(2*pi*f*t). - // The analytic signal is cos + j*sin = exp(j*2*pi*f*t). - // So the imaginary part of the analytic signal should be sin. - let n = 256; - let f = 5.0; - let signal: Vec = (0..n) - .map(|i| { - let t = i as f64 / n as f64; - (2.0 * PI * f * t).cos() - }) - .collect(); - - let analytic = hilbert_transform(&signal); - - // Check imaginary part ≈ sin(2*pi*f*t) for interior samples - // (edge effects make first/last few samples less accurate) - for i in 10..(n - 10) { - let t = i as f64 / n as f64; - let expected_sin = (2.0 * PI * f * t).sin(); - assert_abs_diff_eq!(analytic[i].im, expected_sin, epsilon = 0.05); - } - } - - #[test] - fn instantaneous_amplitude_of_constant_frequency() { - // A pure cosine has constant amplitude = 1.0 - let n = 256; - let f = 10.0; - let signal: Vec = (0..n) - .map(|i| { - let t = i as f64 / n as f64; - (2.0 * PI * f * t).cos() - }) - .collect(); - - let amp = instantaneous_amplitude(&signal); - - // Interior samples should have amplitude close to 1.0 - for &a in &[10..(n - 10)] { - assert_abs_diff_eq!(a, 1.0, epsilon = 0.05); - } - } - - #[test] - fn empty_signal() { - let result = hilbert_transform(&[]); - assert!(result.is_empty()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-signal/src/lib.rs b/v2/crates/ruv-neural/ruv-neural-signal/src/lib.rs deleted file mode 100644 index 57f23e98..00000000 --- a/v2/crates/ruv-neural/ruv-neural-signal/src/lib.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! rUv Neural Signal — Digital signal processing for neural magnetic field data. -//! -//! This crate provides filtering, spectral analysis, artifact detection/rejection, -//! cross-channel connectivity metrics, and full preprocessing pipelines for -//! multi-channel neural time series data (MEG, OPM, EEG). -//! -//! # Modules -//! -//! - [`filter`] — Butterworth IIR bandpass, notch, highpass, and lowpass filters (SOS form) -//! - [`spectral`] — PSD (Welch), STFT, band power, spectral entropy, peak frequency -//! - [`hilbert`] — FFT-based Hilbert transform for instantaneous phase and amplitude -//! - [`artifact`] — Eye blink, muscle artifact, and cardiac artifact detection/rejection -//! - [`connectivity`] — PLV, coherence, imaginary coherence, amplitude envelope correlation -//! - [`preprocessing`] — Configurable multi-stage preprocessing pipeline - -pub mod artifact; -pub mod connectivity; -pub mod filter; -pub mod hilbert; -pub mod preprocessing; -pub mod spectral; - -pub use artifact::{detect_cardiac, detect_eye_blinks, detect_muscle_artifact, reject_artifacts}; -pub use connectivity::{ - amplitude_envelope_correlation, coherence, compute_all_pairs, imaginary_coherence, - phase_locking_value, ConnectivityMetric, -}; -pub use filter::{BandpassFilter, HighpassFilter, LowpassFilter, NotchFilter, SignalProcessor}; -pub use hilbert::{hilbert_transform, instantaneous_amplitude, instantaneous_phase}; -pub use preprocessing::PreprocessingPipeline; -pub use spectral::{band_power, compute_psd, compute_stft, peak_frequency, spectral_entropy}; diff --git a/v2/crates/ruv-neural/ruv-neural-signal/src/preprocessing.rs b/v2/crates/ruv-neural/ruv-neural-signal/src/preprocessing.rs deleted file mode 100644 index ab188bc4..00000000 --- a/v2/crates/ruv-neural/ruv-neural-signal/src/preprocessing.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! Configurable multi-stage preprocessing pipeline for neural data. -//! -//! Provides a builder-pattern pipeline that chains filtering and artifact -//! rejection stages. The default pipeline applies: -//! 1. Notch filter at 50 Hz (power line noise removal) -//! 2. Bandpass filter 1-200 Hz -//! 3. Artifact rejection (eye blink + muscle) - -use ruv_neural_core::error::{Result, RuvNeuralError}; -use ruv_neural_core::signal::MultiChannelTimeSeries; - -use crate::artifact::{detect_eye_blinks, detect_muscle_artifact, reject_artifacts}; -use crate::filter::{BandpassFilter, NotchFilter, SignalProcessor}; - -/// A processing stage in the pipeline. -enum PipelineStage { - /// Apply a notch filter to each channel. - Notch(NotchFilter), - /// Apply a bandpass filter to each channel. - Bandpass(BandpassFilter), - /// Run artifact detection and rejection. - ArtifactRejection, -} - -/// Configurable preprocessing pipeline for multi-channel neural data. -/// -/// # Example -/// ```ignore -/// use ruv_neural_signal::PreprocessingPipeline; -/// -/// let pipeline = PreprocessingPipeline::default_pipeline(1000.0); -/// let clean_data = pipeline.process(&raw_data).unwrap(); -/// ``` -pub struct PreprocessingPipeline { - stages: Vec, - sample_rate: f64, -} - -impl PreprocessingPipeline { - /// Create a new empty pipeline. - pub fn new(sample_rate: f64) -> Self { - Self { - stages: Vec::new(), - sample_rate, - } - } - - /// Create the default preprocessing pipeline: - /// 1. Notch at 50 Hz (BW=2 Hz) - /// 2. Bandpass 1-200 Hz (order 4) - /// 3. Artifact rejection - pub fn default_pipeline(sample_rate: f64) -> Self { - let mut pipeline = Self::new(sample_rate); - pipeline.add_notch(50.0, 2.0); - pipeline.add_bandpass(1.0, 200.0, 4); - pipeline.add_artifact_rejection(); - pipeline - } - - /// Add a notch filter stage. - /// - /// # Arguments - /// * `center_hz` - Center frequency to reject - /// * `bandwidth_hz` - Rejection bandwidth - pub fn add_notch(&mut self, center_hz: f64, bandwidth_hz: f64) { - let filter = NotchFilter::new(center_hz, bandwidth_hz, self.sample_rate); - self.stages.push(PipelineStage::Notch(filter)); - } - - /// Add a bandpass filter stage. - /// - /// # Arguments - /// * `low_hz` - Lower cutoff frequency - /// * `high_hz` - Upper cutoff frequency - /// * `order` - Filter order - pub fn add_bandpass(&mut self, low_hz: f64, high_hz: f64, order: usize) { - let filter = BandpassFilter::new(order, low_hz, high_hz, self.sample_rate); - self.stages.push(PipelineStage::Bandpass(filter)); - } - - /// Add an artifact rejection stage. - /// - /// Runs eye blink and muscle artifact detection, then interpolates - /// across detected artifact periods. - pub fn add_artifact_rejection(&mut self) { - self.stages.push(PipelineStage::ArtifactRejection); - } - - /// Process multi-channel data through all pipeline stages. - /// - /// Each stage is applied sequentially. Filter stages process each - /// channel independently. Artifact rejection operates on all channels. - pub fn process(&self, data: &MultiChannelTimeSeries) -> Result { - if data.num_channels == 0 || data.num_samples == 0 { - return Err(RuvNeuralError::Signal( - "Cannot process empty data".into(), - )); - } - - let mut current = data.clone(); - - for stage in &self.stages { - current = match stage { - PipelineStage::Notch(filter) => { - let new_data: Vec> = current - .data - .iter() - .map(|ch| filter.process(ch)) - .collect(); - MultiChannelTimeSeries { - data: new_data, - ..current - } - } - PipelineStage::Bandpass(filter) => { - let new_data: Vec> = current - .data - .iter() - .map(|ch| filter.process(ch)) - .collect(); - MultiChannelTimeSeries { - data: new_data, - ..current - } - } - PipelineStage::ArtifactRejection => { - // Collect artifact ranges from all channels - let mut all_ranges = Vec::new(); - for ch in ¤t.data { - let blinks = detect_eye_blinks(ch, current.sample_rate_hz); - let muscle = detect_muscle_artifact(ch, current.sample_rate_hz); - all_ranges.extend(blinks); - all_ranges.extend(muscle); - } - - // Sort and merge overlapping ranges - all_ranges.sort_by_key(|&(s, _)| s); - let merged = merge_ranges(&all_ranges); - - reject_artifacts(¤t, &merged) - } - }; - } - - Ok(current) - } -} - -/// Merge overlapping or adjacent ranges. -fn merge_ranges(ranges: &[(usize, usize)]) -> Vec<(usize, usize)> { - if ranges.is_empty() { - return Vec::new(); - } - - let mut merged = Vec::new(); - let (mut cur_start, mut cur_end) = ranges[0]; - - for &(s, e) in &ranges[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; - use std::f64::consts::PI; - - #[test] - fn preprocessing_pipeline_processes_without_error() { - let sr = 1000.0; - let n = 2000; - // Create multi-channel test data - let data = MultiChannelTimeSeries { - data: vec![ - (0..n) - .map(|i| { - let t = i as f64 / sr; - (2.0 * PI * 10.0 * t).sin() + 0.1 * (2.0 * PI * 50.0 * t).sin() - }) - .collect(), - (0..n) - .map(|i| { - let t = i as f64 / sr; - (2.0 * PI * 20.0 * t).sin() + 0.05 * (2.0 * PI * 50.0 * t).sin() - }) - .collect(), - ], - sample_rate_hz: sr, - num_channels: 2, - num_samples: n, - timestamp_start: 0.0, - }; - - let pipeline = PreprocessingPipeline::default_pipeline(sr); - let result = pipeline.process(&data); - - assert!(result.is_ok(), "Pipeline should process without error"); - let clean = result.unwrap(); - assert_eq!(clean.num_channels, 2); - assert_eq!(clean.num_samples, n); - } - - #[test] - fn empty_data_returns_error() { - let data = MultiChannelTimeSeries { - data: vec![], - sample_rate_hz: 1000.0, - num_channels: 0, - num_samples: 0, - timestamp_start: 0.0, - }; - - let pipeline = PreprocessingPipeline::default_pipeline(1000.0); - let result = pipeline.process(&data); - assert!(result.is_err()); - } - - #[test] - fn custom_pipeline_builds_and_runs() { - let sr = 500.0; - let n = 1000; - let data = MultiChannelTimeSeries { - data: vec![(0..n) - .map(|i| { - let t = i as f64 / sr; - (2.0 * PI * 10.0 * t).sin() - }) - .collect()], - sample_rate_hz: sr, - num_channels: 1, - num_samples: n, - timestamp_start: 0.0, - }; - - let mut pipeline = PreprocessingPipeline::new(sr); - pipeline.add_notch(60.0, 2.0); // 60 Hz notch for US power line - pipeline.add_bandpass(0.5, 100.0, 2); - - let result = pipeline.process(&data); - assert!(result.is_ok()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-signal/src/spectral.rs b/v2/crates/ruv-neural/ruv-neural-signal/src/spectral.rs deleted file mode 100644 index 16eec420..00000000 --- a/v2/crates/ruv-neural/ruv-neural-signal/src/spectral.rs +++ /dev/null @@ -1,303 +0,0 @@ -//! Spectral analysis for neural time series data. -//! -//! Provides Welch's method for power spectral density estimation, -//! short-time Fourier transform (STFT), band power extraction, -//! spectral entropy, and peak frequency detection. -//! -//! All transforms use a Hann window for spectral leakage reduction. - -use num_complex::Complex; -use ruv_neural_core::signal::{FrequencyBand, TimeFrequencyMap}; -use rustfft::FftPlanner; -use std::cell::RefCell; -use std::f64::consts::PI; - -thread_local! { - static FFT_PLANNER: RefCell> = RefCell::new(FftPlanner::new()); -} - -/// Generate a Hann window of the given length. -fn hann_window(length: usize) -> Vec { - (0..length) - .map(|i| 0.5 * (1.0 - (2.0 * PI * i as f64 / (length - 1).max(1) as f64).cos())) - .collect() -} - -/// Compute the power spectral density using Welch's method. -/// -/// Divides the signal into overlapping segments (50% overlap), applies a Hann -/// window, computes the periodogram for each segment, and averages. -/// -/// # Arguments -/// * `signal` - Input time series -/// * `sample_rate` - Sampling rate in Hz -/// * `window_size` - Length of each segment in samples -/// -/// # Returns -/// (frequencies, power_spectral_density) in Hz and signal_units^2/Hz. -pub fn compute_psd(signal: &[f64], sample_rate: f64, window_size: usize) -> (Vec, Vec) { - let n = signal.len(); - if n == 0 || window_size == 0 { - return (Vec::new(), Vec::new()); - } - - let win_size = window_size.min(n); - let overlap = win_size / 2; - let hop = win_size - overlap; - let window = hann_window(win_size); - - let window_power: f64 = window.iter().map(|w| w * w).sum(); - - let fft = FFT_PLANNER.with(|p| p.borrow_mut().plan_fft_forward(win_size)); - - let num_freqs = win_size / 2 + 1; - let mut psd_accum = vec![0.0; num_freqs]; - let mut num_segments = 0; - - let mut start = 0; - while start + win_size <= n { - let mut windowed: Vec> = (0..win_size) - .map(|i| Complex::new(signal[start + i] * window[i], 0.0)) - .collect(); - - fft.process(&mut windowed); - - for k in 0..num_freqs { - let power = windowed[k].norm_sqr(); - let scale = if k == 0 || k == win_size / 2 { 1.0 } else { 2.0 }; - psd_accum[k] += power * scale; - } - num_segments += 1; - start += hop; - } - - if num_segments == 0 { - return (Vec::new(), Vec::new()); - } - - let norm = num_segments as f64 * sample_rate * window_power; - let psd: Vec = psd_accum.iter().map(|p| p / norm).collect(); - - let freq_resolution = sample_rate / win_size as f64; - let freqs: Vec = (0..num_freqs).map(|k| k as f64 * freq_resolution).collect(); - - (freqs, psd) -} - -/// Compute the short-time Fourier transform (STFT). -/// -/// # Arguments -/// * `signal` - Input time series -/// * `sample_rate` - Sampling rate in Hz -/// * `window_size` - FFT window length in samples -/// * `hop_size` - Hop size between windows in samples -/// -/// # Returns -/// A [`TimeFrequencyMap`] containing the magnitude spectrogram. -pub fn compute_stft( - signal: &[f64], - sample_rate: f64, - window_size: usize, - hop_size: usize, -) -> TimeFrequencyMap { - let n = signal.len(); - if n == 0 || window_size == 0 || hop_size == 0 { - return TimeFrequencyMap { - data: Vec::new(), - time_points: Vec::new(), - frequency_bins: Vec::new(), - }; - } - - let win_size = window_size.min(n); - let window = hann_window(win_size); - - let fft = FFT_PLANNER.with(|p| p.borrow_mut().plan_fft_forward(win_size)); - - let num_freqs = win_size / 2 + 1; - let freq_resolution = sample_rate / win_size as f64; - let frequency_bins: Vec = (0..num_freqs).map(|k| k as f64 * freq_resolution).collect(); - - let mut data = Vec::new(); - let mut time_points = Vec::new(); - - let mut start = 0; - while start + win_size <= n { - let mut windowed: Vec> = (0..win_size) - .map(|i| Complex::new(signal[start + i] * window[i], 0.0)) - .collect(); - - fft.process(&mut windowed); - - let magnitudes: Vec = windowed[..num_freqs] - .iter() - .map(|c| c.norm() / win_size as f64) - .collect(); - - data.push(magnitudes); - time_points.push((start as f64 + win_size as f64 / 2.0) / sample_rate); - start += hop_size; - } - - TimeFrequencyMap { - data, - time_points, - frequency_bins, - } -} - -/// Extract total power within a specific frequency band from a PSD. -/// -/// Integrates (trapezoidal) the PSD values for frequencies within the band range. -pub fn band_power(psd: &[f64], freqs: &[f64], band: FrequencyBand) -> f64 { - let (low, high) = band.range_hz(); - let df = if freqs.len() > 1 { - freqs[1] - freqs[0] - } else { - 1.0 - }; - - psd.iter() - .zip(freqs.iter()) - .filter(|(_, f)| **f >= low && **f <= high) - .map(|(p, _)| p * df) - .sum() -} - -/// Compute the spectral entropy of a power spectral density. -/// -/// Normalizes the PSD to a probability distribution and computes -/// Shannon entropy: H = -sum(p * log2(p)). -/// -/// Higher entropy = more uniform (noise-like) spectrum. -/// Lower entropy = more peaked (tonal) spectrum. -pub fn spectral_entropy(psd: &[f64]) -> f64 { - let total: f64 = psd.iter().sum(); - if total <= 0.0 || psd.is_empty() { - return 0.0; - } - - let mut entropy = 0.0; - for &p in psd { - let prob = p / total; - if prob > 1e-30 { - entropy -= prob * prob.log2(); - } - } - - entropy -} - -/// Find the frequency of the maximum power in the PSD. -pub fn peak_frequency(psd: &[f64], freqs: &[f64]) -> f64 { - if psd.is_empty() || freqs.is_empty() { - return 0.0; - } - - let (max_idx, _) = psd - .iter() - .enumerate() - .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) - .unwrap(); - - freqs[max_idx] -} - -#[cfg(test)] -mod tests { - use super::*; - use approx::assert_abs_diff_eq; - use std::f64::consts::PI; - - #[test] - fn psd_of_sinusoid_peaks_at_correct_frequency() { - let sr = 1000.0; - let freq = 40.0; - let n = 4000; - let signal: Vec = (0..n) - .map(|i| { - let t = i as f64 / sr; - (2.0 * PI * freq * t).sin() - }) - .collect(); - - let (freqs, psd) = compute_psd(&signal, sr, 512); - - let peak = peak_frequency(&psd, &freqs); - let freq_res = sr / 512.0; - assert!( - (peak - freq).abs() < freq_res * 1.5, - "Peak at {peak} Hz, expected {freq} Hz (resolution {freq_res} Hz)" - ); - } - - #[test] - fn spectral_entropy_white_noise_gt_pure_tone() { - let sr = 1000.0; - let n = 4000; - - let tone: Vec = (0..n) - .map(|i| { - let t = i as f64 / sr; - (2.0 * PI * 50.0 * t).sin() - }) - .collect(); - - let noise: Vec = (0..n) - .map(|i| { - let t = i as f64 / sr; - let mut val = 0.0; - for f in (1..200).step_by(3) { - val += (2.0 * PI * f as f64 * t + f as f64 * 0.7).sin(); - } - val - }) - .collect(); - - let (_, psd_tone) = compute_psd(&tone, sr, 512); - let (_, psd_noise) = compute_psd(&noise, sr, 512); - - let ent_tone = spectral_entropy(&psd_tone); - let ent_noise = spectral_entropy(&psd_noise); - - assert!( - ent_noise > ent_tone, - "Noise entropy ({ent_noise}) should be > tone entropy ({ent_tone})" - ); - } - - #[test] - fn stft_produces_correct_dimensions() { - let sr = 1000.0; - let n = 2000; - let signal: Vec = (0..n).map(|i| (i as f64 * 0.01).sin()).collect(); - - let stft = compute_stft(&signal, sr, 256, 128); - - assert_eq!(stft.frequency_bins.len(), 129); - - let expected_frames = (n - 256) / 128 + 1; - assert_eq!(stft.time_points.len(), expected_frames); - assert_eq!(stft.data.len(), expected_frames); - } - - #[test] - fn band_power_extracts_correct_band() { - let freqs: Vec = (0..100).map(|i| i as f64).collect(); - let mut psd = vec![0.0; 100]; - psd[10] = 100.0; - - let alpha_power = band_power(&psd, &freqs, FrequencyBand::Alpha); - let beta_power = band_power(&psd, &freqs, FrequencyBand::Beta); - - assert!(alpha_power > 0.0, "Alpha band should have power"); - assert_abs_diff_eq!(beta_power, 0.0, epsilon = 1e-10); - } - - #[test] - fn empty_signal_psd() { - let (freqs, psd) = compute_psd(&[], 1000.0, 256); - assert!(freqs.is_empty()); - assert!(psd.is_empty()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-viz/Cargo.toml b/v2/crates/ruv-neural/ruv-neural-viz/Cargo.toml deleted file mode 100644 index 15075bcc..00000000 --- a/v2/crates/ruv-neural/ruv-neural-viz/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "ruv-neural-viz" -description = "rUv Neural — Brain topology visualization data structures and ASCII rendering" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true - -[features] -default = ["std"] -std = [] -ascii = [] # ASCII art rendering for terminal - -[dependencies] -ruv-neural-core = { workspace = true } -ruv-neural-graph = { workspace = true } -ruv-neural-mincut = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tracing = { workspace = true } - -[dev-dependencies] -approx = { workspace = true } diff --git a/v2/crates/ruv-neural/ruv-neural-viz/README.md b/v2/crates/ruv-neural/ruv-neural-viz/README.md deleted file mode 100644 index 15f88963..00000000 --- a/v2/crates/ruv-neural/ruv-neural-viz/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# ruv-neural-viz - -Brain topology visualization, ASCII rendering, and export formats. - -## Overview - -`ruv-neural-viz` provides layout algorithms, color mapping, terminal-friendly -ASCII rendering, animation frame generation, and export to standard graph -visualization formats for brain connectivity graphs. It turns `BrainGraph` and -mincut analysis results into visual output suitable for terminal dashboards, -web applications, and graph analysis tools. - -## Features - -- **Layout algorithms** (`layout`): `ForceDirectedLayout` for spring-based node - positioning and `AnatomicalLayout` for MNI-coordinate-based brain region - placement; circular layout variants -- **Color mapping** (`colormap`): `ColorMap` with cool-warm, viridis, and - module-color schemes for mapping scalar values (edge weights, node degrees) - to colors -- **ASCII rendering** (`ascii`): Terminal-friendly renderers for brain graphs, - mincut partitions, sparkline time series, connectivity matrices, and - real-time dashboard views -- **Export formats** (`export`): D3.js JSON (force-directed graph format), - Graphviz DOT, GEXF (Gephi), and CSV timeline export -- **Animation** (`animation`): `AnimationFrames` generator from temporal - `BrainGraphSequence` data with `AnimatedNode`, `AnimatedEdge`, and - `AnimationFrame` types; configurable `LayoutType` per frame - -## Usage - -```rust -use ruv_neural_viz::{ - ForceDirectedLayout, AnatomicalLayout, ColorMap, - AnimationFrames, LayoutType, -}; -use ruv_neural_viz::ascii; -use ruv_neural_viz::export; - -// Force-directed layout for a brain graph -let layout = ForceDirectedLayout::new(); -let positions = layout.compute(&graph); - -// Anatomical layout using MNI coordinates -let anat_layout = AnatomicalLayout::new(); -let positions = anat_layout.compute(&graph, &parcellation); - -// Color mapping -let cmap = ColorMap::cool_warm(); -let color = cmap.map(0.75); // returns (r, g, b) - -// ASCII rendering to terminal -ascii::render_graph(&graph); -ascii::render_mincut(&mincut_result); - -// Export to D3.js JSON -let d3_json = export::to_d3_json(&graph, &positions); - -// Export to Graphviz DOT -let dot = export::to_dot(&graph); - -// Generate animation frames from temporal sequence -let frames = AnimationFrames::from_sequence( - &graph_sequence, - LayoutType::ForceDirected, -); -``` - -## API Reference - -| Module | Key Types / Functions | -|-------------|----------------------------------------------------------------| -| `layout` | `ForceDirectedLayout`, `AnatomicalLayout` | -| `colormap` | `ColorMap` | -| `ascii` | Graph, mincut, sparkline, matrix, and dashboard renderers | -| `export` | `to_d3_json`, `to_dot`, `to_gexf`, `to_csv_timeline` | -| `animation` | `AnimationFrames`, `AnimationFrame`, `AnimatedNode`, `AnimatedEdge`, `LayoutType` | - -## Feature Flags - -| Feature | Default | Description | -|---------|---------|-------------------------------------| -| `std` | Yes | Standard library support | -| `ascii` | No | ASCII art rendering for terminal | - -## Integration - -Depends on `ruv-neural-core` for `BrainGraph` types, `ruv-neural-graph` for -graph metrics used in layout computation, and `ruv-neural-mincut` for partition -visualization. Used by `ruv-neural-cli` for terminal dashboard output and -export commands. - -## License - -MIT OR Apache-2.0 diff --git a/v2/crates/ruv-neural/ruv-neural-viz/src/animation.rs b/v2/crates/ruv-neural/ruv-neural-viz/src/animation.rs deleted file mode 100644 index 88ddd1f3..00000000 --- a/v2/crates/ruv-neural/ruv-neural-viz/src/animation.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! Animation frame generation from temporal brain graph sequences. - -use serde::{Deserialize, Serialize}; - -use ruv_neural_core::graph::BrainGraphSequence; -use ruv_neural_core::topology::TopologyMetrics; - -use crate::colormap::ColorMap; -use crate::layout::{circular_layout, ForceDirectedLayout}; - -/// Layout algorithm selection for animation. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LayoutType { - /// Fruchterman-Reingold force-directed layout. - ForceDirected, - /// MNI anatomical coordinates (requires parcellation data). - Anatomical, - /// Simple circular layout. - Circular, -} - -/// A single node in an animation frame. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AnimatedNode { - /// Node index. - pub id: usize, - /// 3D position. - pub position: [f64; 3], - /// RGB color. - pub color: [u8; 3], - /// Display size (proportional to degree). - pub size: f64, - /// Module assignment. - pub module: usize, -} - -/// A single edge in an animation frame. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AnimatedEdge { - /// Source node index. - pub source: usize, - /// Target node index. - pub target: usize, - /// Edge weight. - pub weight: f64, - /// Whether this edge is part of a minimum cut. - pub is_cut: bool, - /// RGB color. - pub color: [u8; 3], -} - -/// A single animation frame capturing the graph state at one time point. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AnimationFrame { - /// Timestamp of this frame. - pub timestamp: f64, - /// Nodes with positions, colors, and sizes. - pub nodes: Vec, - /// Edges with weights, cut status, and colors. - pub edges: Vec, - /// Topology metrics for this frame. - pub metrics: TopologyMetrics, -} - -/// A sequence of animation frames. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AnimationFrames { - frames: Vec, -} - -impl AnimationFrames { - /// Generate animation frames from a brain graph sequence. - /// - /// Each graph in the sequence becomes one animation frame. Positions are - /// computed independently per frame using the specified layout algorithm. - pub fn from_graph_sequence( - graphs: &BrainGraphSequence, - layout_type: LayoutType, - ) -> Self { - let colormap = ColorMap::cool_warm(); - - let frames = graphs - .graphs - .iter() - .map(|graph| { - let n = graph.num_nodes; - - // Compute layout - let positions_3d: Vec<[f64; 3]> = match layout_type { - LayoutType::ForceDirected => { - let layout = ForceDirectedLayout::new(); - layout.compute(graph) - } - LayoutType::Anatomical => { - // Fallback to circular if no parcellation data available - let pos2d = circular_layout(n); - pos2d.iter().map(|p| [p[0], p[1], 0.0]).collect() - } - LayoutType::Circular => { - let pos2d = circular_layout(n); - pos2d.iter().map(|p| [p[0], p[1], 0.0]).collect() - } - }; - - // Compute node degrees for sizing - let max_degree = (0..n) - .map(|i| graph.node_degree(i)) - .fold(0.0_f64, f64::max) - .max(1.0); - - // Build animated nodes - let nodes: Vec = (0..n) - .map(|i| { - let degree = graph.node_degree(i); - let norm_degree = degree / max_degree; - AnimatedNode { - id: i, - position: if i < positions_3d.len() { - positions_3d[i] - } else { - [0.0, 0.0, 0.0] - }, - color: colormap.map(norm_degree), - size: 1.0 + norm_degree * 4.0, - module: 0, // Default module; updated if partition data available - } - }) - .collect(); - - // Build animated edges - let max_weight = graph - .edges - .iter() - .map(|e| e.weight) - .fold(0.0_f64, f64::max) - .max(1e-12); - - let edges: Vec = graph - .edges - .iter() - .map(|e| { - let norm_weight = e.weight / max_weight; - AnimatedEdge { - source: e.source, - target: e.target, - weight: e.weight, - is_cut: false, - color: colormap.map(norm_weight), - } - }) - .collect(); - - // Compute basic metrics - let metrics = TopologyMetrics { - global_mincut: 0.0, - modularity: 0.0, - global_efficiency: 0.0, - local_efficiency: 0.0, - graph_entropy: 0.0, - fiedler_value: 0.0, - num_modules: 1, - timestamp: graph.timestamp, - }; - - AnimationFrame { - timestamp: graph.timestamp, - nodes, - edges, - metrics, - } - }) - .collect(); - - Self { frames } - } - - /// Serialize all frames to JSON. - pub fn to_json(&self) -> String { - serde_json::to_string_pretty(&self.frames).unwrap_or_else(|_| "[]".to_string()) - } - - /// Number of frames in the animation. - pub fn frame_count(&self) -> usize { - self.frames.len() - } - - /// Get a reference to a specific frame by index. - pub fn get_frame(&self, index: usize) -> Option<&AnimationFrame> { - self.frames.get(index) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ruv_neural_core::brain::Atlas; - use ruv_neural_core::graph::{BrainEdge, BrainGraph, BrainGraphSequence, ConnectivityMetric}; - use ruv_neural_core::signal::FrequencyBand; - - fn make_sequence(count: usize) -> BrainGraphSequence { - let graphs = (0..count) - .map(|i| BrainGraph { - num_nodes: 4, - edges: vec![ - BrainEdge { - source: 0, - target: 1, - weight: 0.8, - metric: ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 2, - target: 3, - weight: 0.5, - metric: ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Alpha, - }, - ], - timestamp: i as f64 * 0.5, - window_duration_s: 0.5, - atlas: Atlas::Custom(4), - }) - .collect(); - - BrainGraphSequence { - graphs, - window_step_s: 0.5, - } - } - - #[test] - fn animation_frame_count_matches() { - let seq = make_sequence(5); - let anim = AnimationFrames::from_graph_sequence(&seq, LayoutType::Circular); - assert_eq!(anim.frame_count(), 5); - } - - #[test] - fn animation_get_frame() { - let seq = make_sequence(3); - let anim = AnimationFrames::from_graph_sequence(&seq, LayoutType::Circular); - assert!(anim.get_frame(0).is_some()); - assert!(anim.get_frame(2).is_some()); - assert!(anim.get_frame(3).is_none()); - } - - #[test] - fn animation_to_json_valid() { - let seq = make_sequence(2); - let anim = AnimationFrames::from_graph_sequence(&seq, LayoutType::Circular); - let json = anim.to_json(); - let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); - let arr = parsed.as_array().expect("should be array"); - assert_eq!(arr.len(), 2); - } - - #[test] - fn animation_force_directed() { - let seq = make_sequence(2); - let anim = AnimationFrames::from_graph_sequence(&seq, LayoutType::ForceDirected); - assert_eq!(anim.frame_count(), 2); - let frame = anim.get_frame(0).unwrap(); - assert_eq!(frame.nodes.len(), 4); - assert_eq!(frame.edges.len(), 2); - } - - #[test] - fn animation_empty_sequence() { - let seq = BrainGraphSequence { - graphs: vec![], - window_step_s: 0.5, - }; - let anim = AnimationFrames::from_graph_sequence(&seq, LayoutType::Circular); - assert_eq!(anim.frame_count(), 0); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-viz/src/ascii.rs b/v2/crates/ruv-neural/ruv-neural-viz/src/ascii.rs deleted file mode 100644 index ed7e73c3..00000000 --- a/v2/crates/ruv-neural/ruv-neural-viz/src/ascii.rs +++ /dev/null @@ -1,356 +0,0 @@ -//! Terminal ASCII rendering for brain topology visualization. - -use ruv_neural_core::graph::BrainGraph; -use ruv_neural_core::topology::{CognitiveState, MincutResult, TopologyMetrics}; - -/// Render a brain graph as ASCII art. -/// -/// Produces a simple text representation with nodes and edges. -pub fn render_ascii_graph(graph: &BrainGraph, width: usize, height: usize) -> String { - let n = graph.num_nodes; - if n == 0 { - return String::from("(empty graph)"); - } - - let mut canvas = vec![vec![' '; width]; height]; - - // Place nodes in a grid - let cols = (n as f64).sqrt().ceil() as usize; - let row_spacing = if cols > 0 { height.saturating_sub(1).max(1) / cols.max(1) } else { 1 }; - let col_spacing = if cols > 0 { width.saturating_sub(1).max(1) / cols.max(1) } else { 1 }; - - let mut node_positions = Vec::new(); - for i in 0..n { - let r = i / cols; - let c = i % cols; - let y = (r * row_spacing).min(height.saturating_sub(1)); - let x = (c * col_spacing).min(width.saturating_sub(1)); - node_positions.push((x, y)); - - // Draw node marker - if y < height && x < width { - canvas[y][x] = 'O'; - // Draw node number if space permits - let label = format!("{}", i); - for (di, ch) in label.chars().enumerate() { - if x + 1 + di < width { - canvas[y][x + 1 + di] = ch; - } - } - } - } - - // Draw edges as simple lines between connected nodes - for edge in &graph.edges { - if edge.source < n && edge.target < n { - let (x1, y1) = node_positions[edge.source]; - let (x2, y2) = node_positions[edge.target]; - draw_line(&mut canvas, x1, y1, x2, y2, width, height); - } - } - - // Redraw nodes on top - for (i, &(x, y)) in node_positions.iter().enumerate() { - if y < height && x < width { - canvas[y][x] = 'O'; - let label = format!("{}", i); - for (di, ch) in label.chars().enumerate() { - if x + 1 + di < width { - canvas[y][x + 1 + di] = ch; - } - } - } - } - - canvas - .iter() - .map(|row| row.iter().collect::().trim_end().to_string()) - .collect::>() - .join("\n") -} - -/// Draw a simple line on the canvas using Bresenham-like stepping. -fn draw_line( - canvas: &mut [Vec], - x1: usize, - y1: usize, - x2: usize, - y2: usize, - width: usize, - height: usize, -) { - let dx = (x2 as isize - x1 as isize).abs(); - let dy = (y2 as isize - y1 as isize).abs(); - let steps = dx.max(dy); - if steps == 0 { - return; - } - - for step in 1..steps { - let t = step as f64 / steps as f64; - let x = (x1 as f64 + t * (x2 as f64 - x1 as f64)).round() as usize; - let y = (y1 as f64 + t * (y2 as f64 - y1 as f64)).round() as usize; - if x < width && y < height && canvas[y][x] == ' ' { - canvas[y][x] = '.'; - } - } -} - -/// Render a mincut result as ASCII showing two partitions. -pub fn render_ascii_mincut(result: &MincutResult, graph: &BrainGraph) -> String { - let _ = graph; // May be used for node labels in the future. - - let mut out = String::new(); - out.push_str(&format!( - "=== Minimum Cut (value: {:.4}) ===\n", - result.cut_value - )); - out.push('\n'); - - // Partition A - out.push_str("Partition A: ["); - out.push_str( - &result - .partition_a - .iter() - .map(|n| n.to_string()) - .collect::>() - .join(", "), - ); - out.push_str("]\n"); - - // Separator - out.push_str(&"-".repeat(40)); - out.push('\n'); - - // Partition B - out.push_str("Partition B: ["); - out.push_str( - &result - .partition_b - .iter() - .map(|n| n.to_string()) - .collect::>() - .join(", "), - ); - out.push_str("]\n"); - - // Cut edges - out.push('\n'); - out.push_str(&format!("Cut edges ({}):\n", result.cut_edges.len())); - for &(s, t, w) in &result.cut_edges { - out.push_str(&format!(" {} --({:.4})--> {}\n", s, w, t)); - } - - out.push_str(&format!( - "\nBalance ratio: {:.4}\n", - result.balance_ratio() - )); - - out -} - -/// Render a sparkline from a slice of values using Unicode block characters. -pub fn render_sparkline(values: &[f64], width: usize) -> String { - if values.is_empty() || width == 0 { - return String::new(); - } - - let blocks = ['\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', - '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}']; - - let min = values.iter().cloned().fold(f64::INFINITY, f64::min); - let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let range = max - min; - - // Resample values to fit width - let resampled: Vec = if values.len() <= width { - values.to_vec() - } else { - (0..width) - .map(|i| { - let idx = (i as f64 / width as f64 * values.len() as f64) as usize; - values[idx.min(values.len() - 1)] - }) - .collect() - }; - - resampled - .iter() - .map(|&v| { - if range < 1e-12 { - blocks[4] // Middle block if all values equal - } else { - let normalized = ((v - min) / range).clamp(0.0, 1.0); - let idx = (normalized * 7.0).round() as usize; - blocks[idx.min(7)] - } - }) - .collect() -} - -/// Render a brain state dashboard showing key metrics. -pub fn render_dashboard(metrics: &TopologyMetrics, state: &CognitiveState) -> String { - let mut out = String::new(); - - let state_label = match state { - CognitiveState::Rest => "Rest", - CognitiveState::Focused => "Focused", - CognitiveState::MotorPlanning => "Motor Planning", - CognitiveState::SpeechProcessing => "Speech Processing", - CognitiveState::MemoryEncoding => "Memory Encoding", - CognitiveState::MemoryRetrieval => "Memory Retrieval", - CognitiveState::Creative => "Creative", - CognitiveState::Stressed => "Stressed", - CognitiveState::Fatigued => "Fatigued", - CognitiveState::Sleep(_) => "Sleep", - CognitiveState::Unknown => "Unknown", - }; - - out.push_str("+--------------------------------------+\n"); - out.push_str(&format!( - "| State: {:<29}|\n", - state_label - )); - out.push_str("|--------------------------------------|\n"); - out.push_str(&format!( - "| Mincut: {:<7.4} {}|\n", - metrics.global_mincut, - bar(metrics.global_mincut, 10.0, 16) - )); - out.push_str(&format!( - "| Modularity: {:<7.4} {}|\n", - metrics.modularity, - bar(metrics.modularity, 1.0, 16) - )); - out.push_str(&format!( - "| Efficiency: {:<7.4} {}|\n", - metrics.global_efficiency, - bar(metrics.global_efficiency, 1.0, 16) - )); - out.push_str(&format!( - "| Modules: {:<25}|\n", - metrics.num_modules - )); - out.push_str("+--------------------------------------+\n"); - - out -} - -/// Render a simple horizontal bar. -fn bar(value: f64, max_val: f64, width: usize) -> String { - let fraction = (value / max_val).clamp(0.0, 1.0); - let filled = (fraction * width as f64).round() as usize; - let empty = width.saturating_sub(filled); - format!("[{}{}]", "#".repeat(filled), " ".repeat(empty)) -} - -#[cfg(test)] -mod tests { - use super::*; - use ruv_neural_core::brain::Atlas; - use ruv_neural_core::graph::{BrainEdge, BrainGraph}; - use ruv_neural_core::signal::FrequencyBand; - - #[test] - fn sparkline_renders_known_values() { - let values = [0.0, 0.25, 0.5, 0.75, 1.0]; - let result = render_sparkline(&values, 5); - assert_eq!(result.chars().count(), 5); - // First char should be lowest block, last should be highest - let chars: Vec = result.chars().collect(); - assert_eq!(chars[0], '\u{2581}'); - assert_eq!(chars[4], '\u{2588}'); - } - - #[test] - fn sparkline_empty() { - assert_eq!(render_sparkline(&[], 10), ""); - } - - #[test] - fn sparkline_zero_width() { - assert_eq!(render_sparkline(&[1.0, 2.0], 0), ""); - } - - #[test] - fn sparkline_constant_values() { - let result = render_sparkline(&[5.0, 5.0, 5.0], 3); - assert_eq!(result.chars().count(), 3); - } - - #[test] - fn dashboard_renders() { - let metrics = TopologyMetrics { - global_mincut: 2.5, - modularity: 0.65, - global_efficiency: 0.42, - local_efficiency: 0.38, - graph_entropy: 3.2, - fiedler_value: 0.15, - num_modules: 4, - timestamp: 0.0, - }; - let state = CognitiveState::Focused; - let output = render_dashboard(&metrics, &state); - assert!(output.contains("Focused")); - assert!(output.contains("Mincut")); - assert!(output.contains("Modularity")); - assert!(output.contains("Modules")); - } - - #[test] - fn mincut_renders() { - let result = MincutResult { - cut_value: 1.5, - partition_a: vec![0, 1, 2], - partition_b: vec![3, 4], - cut_edges: vec![(1, 3, 0.8), (2, 4, 0.7)], - timestamp: 0.0, - }; - let graph = BrainGraph { - num_nodes: 5, - edges: vec![], - timestamp: 0.0, - window_duration_s: 1.0, - atlas: Atlas::Custom(5), - }; - let output = render_ascii_mincut(&result, &graph); - assert!(output.contains("Partition A")); - assert!(output.contains("Partition B")); - assert!(output.contains("1.5000")); - } - - #[test] - fn ascii_graph_renders() { - let graph = BrainGraph { - num_nodes: 4, - edges: vec![BrainEdge { - source: 0, - target: 1, - weight: 1.0, - metric: ruv_neural_core::graph::ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Alpha, - }], - timestamp: 0.0, - window_duration_s: 1.0, - atlas: Atlas::Custom(4), - }; - let output = render_ascii_graph(&graph, 40, 10); - assert!(!output.is_empty()); - assert!(output.contains('O')); - } - - #[test] - fn ascii_graph_empty() { - let graph = BrainGraph { - num_nodes: 0, - edges: vec![], - timestamp: 0.0, - window_duration_s: 1.0, - atlas: Atlas::Custom(0), - }; - let output = render_ascii_graph(&graph, 40, 10); - assert_eq!(output, "(empty graph)"); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-viz/src/colormap.rs b/v2/crates/ruv-neural/ruv-neural-viz/src/colormap.rs deleted file mode 100644 index 28bfb149..00000000 --- a/v2/crates/ruv-neural/ruv-neural-viz/src/colormap.rs +++ /dev/null @@ -1,200 +0,0 @@ -//! Color mapping utilities for brain topology visualization. - -/// Maps scalar values in [0, 1] to RGB colors via piecewise-linear interpolation. -#[derive(Debug, Clone)] -pub struct ColorMap { - /// Sorted color stops: (position, [r, g, b]). - stops: Vec<(f64, [u8; 3])>, -} - -impl ColorMap { - /// Create a colormap from a list of (position, color) stops. - /// - /// Positions must be in ascending order and span at least two values. - /// Values outside the stop range are clamped. - pub fn new(stops: Vec<(f64, [u8; 3])>) -> Self { - assert!(stops.len() >= 2, "ColorMap requires at least two stops"); - Self { stops } - } - - /// Cool-warm diverging colormap (blue -> white -> red). - pub fn cool_warm() -> Self { - Self { - stops: vec![ - (0.0, [59, 76, 192]), // blue - (0.5, [221, 221, 221]), // near-white - (1.0, [180, 4, 38]), // red - ], - } - } - - /// Viridis-like sequential colormap (dark purple -> teal -> yellow). - pub fn viridis() -> Self { - Self { - stops: vec![ - (0.0, [68, 1, 84]), // dark purple - (0.25, [59, 82, 139]), // blue-purple - (0.5, [33, 145, 140]), // teal - (0.75, [94, 201, 98]), // green - (1.0, [253, 231, 37]), // yellow - ], - } - } - - /// Generate distinct colors for brain modules (partitions). - /// - /// Uses evenly-spaced hues on the HSV color wheel. - pub fn module_colors(num_modules: usize) -> Vec<[u8; 3]> { - if num_modules == 0 { - return Vec::new(); - } - (0..num_modules) - .map(|i| { - let hue = (i as f64) / (num_modules as f64) * 360.0; - hsv_to_rgb(hue, 0.7, 0.9) - }) - .collect() - } - - /// Map a value in [0, 1] to an RGB color. - /// - /// Values outside [0, 1] are clamped. - pub fn map(&self, value: f64) -> [u8; 3] { - let v = value.clamp(0.0, 1.0); - - // Before first stop - if v <= self.stops[0].0 { - return self.stops[0].1; - } - // After last stop - if v >= self.stops[self.stops.len() - 1].0 { - return self.stops[self.stops.len() - 1].1; - } - - // Find the two surrounding stops - for w in self.stops.windows(2) { - let (p0, c0) = w[0]; - let (p1, c1) = w[1]; - if v >= p0 && v <= p1 { - let t = if (p1 - p0).abs() < 1e-12 { - 0.0 - } else { - (v - p0) / (p1 - p0) - }; - return [ - lerp_u8(c0[0], c1[0], t), - lerp_u8(c0[1], c1[1], t), - lerp_u8(c0[2], c1[2], t), - ]; - } - } - - // Fallback (should not reach here) - self.stops[self.stops.len() - 1].1 - } - - /// Map a value to a hex color string (e.g., "#3B4CC0"). - pub fn map_hex(&self, value: f64) -> String { - let [r, g, b] = self.map(value); - format!("#{:02X}{:02X}{:02X}", r, g, b) - } -} - -/// Linearly interpolate between two u8 values. -fn lerp_u8(a: u8, b: u8, t: f64) -> u8 { - let result = (a as f64) * (1.0 - t) + (b as f64) * t; - result.round().clamp(0.0, 255.0) as u8 -} - -/// Convert HSV (h in [0,360], s in [0,1], v in [0,1]) to RGB. -fn hsv_to_rgb(h: f64, s: f64, v: f64) -> [u8; 3] { - let c = v * s; - let hp = h / 60.0; - let x = c * (1.0 - ((hp % 2.0) - 1.0).abs()); - let m = v - c; - - let (r1, g1, b1) = if hp < 1.0 { - (c, x, 0.0) - } else if hp < 2.0 { - (x, c, 0.0) - } else if hp < 3.0 { - (0.0, c, x) - } else if hp < 4.0 { - (0.0, x, c) - } else if hp < 5.0 { - (x, 0.0, c) - } else { - (c, 0.0, x) - }; - - [ - ((r1 + m) * 255.0).round() as u8, - ((g1 + m) * 255.0).round() as u8, - ((b1 + m) * 255.0).round() as u8, - ] -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cool_warm_blue_at_zero() { - let cm = ColorMap::cool_warm(); - let c = cm.map(0.0); - assert_eq!(c, [59, 76, 192]); - } - - #[test] - fn cool_warm_white_at_half() { - let cm = ColorMap::cool_warm(); - let c = cm.map(0.5); - assert_eq!(c, [221, 221, 221]); - } - - #[test] - fn cool_warm_red_at_one() { - let cm = ColorMap::cool_warm(); - let c = cm.map(1.0); - assert_eq!(c, [180, 4, 38]); - } - - #[test] - fn map_hex_format() { - let cm = ColorMap::cool_warm(); - let hex = cm.map_hex(0.0); - assert_eq!(hex, "#3B4CC0"); - } - - #[test] - fn module_colors_distinct() { - let colors = ColorMap::module_colors(5); - assert_eq!(colors.len(), 5); - // All colors should be distinct - for i in 0..colors.len() { - for j in (i + 1)..colors.len() { - assert_ne!(colors[i], colors[j], "module colors must be distinct"); - } - } - } - - #[test] - fn module_colors_empty() { - let colors = ColorMap::module_colors(0); - assert!(colors.is_empty()); - } - - #[test] - fn clamp_below_zero() { - let cm = ColorMap::cool_warm(); - let c = cm.map(-0.5); - assert_eq!(c, cm.map(0.0)); - } - - #[test] - fn clamp_above_one() { - let cm = ColorMap::cool_warm(); - let c = cm.map(1.5); - assert_eq!(c, cm.map(1.0)); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-viz/src/export.rs b/v2/crates/ruv-neural/ruv-neural-viz/src/export.rs deleted file mode 100644 index 0293f351..00000000 --- a/v2/crates/ruv-neural/ruv-neural-viz/src/export.rs +++ /dev/null @@ -1,230 +0,0 @@ -//! Export brain graphs to visualization formats (D3.js, DOT, GEXF, CSV). - -use ruv_neural_core::graph::BrainGraph; -use ruv_neural_core::topology::TopologyMetrics; - -/// Export a brain graph to JSON suitable for D3.js force-directed layouts. -/// -/// Output format: -/// ```json -/// { -/// "nodes": [{"id": 0, "x": 1.0, "y": 2.0, "z": 3.0}, ...], -/// "links": [{"source": 0, "target": 1, "weight": 0.5}, ...] -/// } -/// ``` -pub fn to_d3_json(graph: &BrainGraph, layout: &[[f64; 3]]) -> String { - let mut nodes = Vec::new(); - for (i, pos) in layout.iter().enumerate() { - nodes.push(format!( - r#" {{"id": {}, "x": {:.6}, "y": {:.6}, "z": {:.6}}}"#, - i, pos[0], pos[1], pos[2] - )); - } - - let mut links = Vec::new(); - for edge in &graph.edges { - links.push(format!( - r#" {{"source": {}, "target": {}, "weight": {:.6}}}"#, - edge.source, edge.target, edge.weight - )); - } - - format!( - "{{\n \"nodes\": [\n{}\n ],\n \"links\": [\n{}\n ]\n}}", - nodes.join(",\n"), - links.join(",\n") - ) -} - -/// Export a brain graph to Graphviz DOT format. -pub fn to_dot(graph: &BrainGraph) -> String { - let mut out = String::new(); - out.push_str("graph brain {\n"); - out.push_str(" layout=neato;\n"); - out.push_str(" node [shape=circle, style=filled, fillcolor=\"#6699CC\"];\n\n"); - - for i in 0..graph.num_nodes { - out.push_str(&format!(" n{} [label=\"{}\"];\n", i, i)); - } - out.push('\n'); - - for edge in &graph.edges { - out.push_str(&format!( - " n{} -- n{} [penwidth={:.2}, label=\"{:.3}\"];\n", - edge.source, - edge.target, - (edge.weight * 3.0).clamp(0.5, 5.0), - edge.weight - )); - } - - out.push_str("}\n"); - out -} - -/// Export a topology metrics timeline to CSV format. -/// -/// Columns: timestamp, global_mincut, modularity, global_efficiency, -/// local_efficiency, graph_entropy, fiedler_value, num_modules -pub fn timeline_to_csv(timeline: &[(f64, TopologyMetrics)]) -> String { - let mut out = String::new(); - out.push_str( - "timestamp,global_mincut,modularity,global_efficiency,\ - local_efficiency,graph_entropy,fiedler_value,num_modules\n", - ); - for (t, m) in timeline { - out.push_str(&format!( - "{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{}\n", - t, - m.global_mincut, - m.modularity, - m.global_efficiency, - m.local_efficiency, - m.graph_entropy, - m.fiedler_value, - m.num_modules, - )); - } - out -} - -/// Export a brain graph to GEXF format (Gephi). -pub fn to_gexf(graph: &BrainGraph) -> String { - let mut out = String::new(); - out.push_str("\n"); - out.push_str("\n"); - out.push_str(" \n"); - out.push_str(" ruv-neural-viz\n"); - out.push_str(" Brain connectivity graph\n"); - out.push_str(" \n"); - out.push_str(" \n"); - - // Nodes - out.push_str(" \n"); - for i in 0..graph.num_nodes { - out.push_str(&format!( - " \n", - i, i - )); - } - out.push_str(" \n"); - - // Edges - out.push_str(" \n"); - for (idx, edge) in graph.edges.iter().enumerate() { - out.push_str(&format!( - " \n", - idx, edge.source, edge.target, edge.weight - )); - } - out.push_str(" \n"); - - out.push_str(" \n"); - out.push_str("\n"); - out -} - -#[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_graph() -> BrainGraph { - BrainGraph { - num_nodes: 3, - edges: vec![ - BrainEdge { - source: 0, - target: 1, - weight: 0.8, - metric: ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 1, - target: 2, - weight: 0.5, - metric: ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Alpha, - }, - ], - timestamp: 1.0, - window_duration_s: 1.0, - atlas: Atlas::Custom(3), - } - } - - #[test] - fn d3_json_valid() { - let graph = make_graph(); - let layout = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]]; - let json = to_d3_json(&graph, &layout); - - // Parse to verify valid JSON - let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); - let nodes = parsed["nodes"].as_array().expect("nodes array"); - let links = parsed["links"].as_array().expect("links array"); - assert_eq!(nodes.len(), 3); - assert_eq!(links.len(), 2); - } - - #[test] - fn dot_valid_format() { - let graph = make_graph(); - let dot = to_dot(&graph); - assert!(dot.starts_with("graph brain {")); - assert!(dot.contains("n0 -- n1")); - assert!(dot.contains("n1 -- n2")); - assert!(dot.ends_with("}\n")); - } - - #[test] - fn csv_header_and_rows() { - let timeline = vec![ - ( - 0.0, - TopologyMetrics { - global_mincut: 1.0, - modularity: 0.5, - global_efficiency: 0.4, - local_efficiency: 0.3, - graph_entropy: 2.0, - fiedler_value: 0.1, - num_modules: 3, - timestamp: 0.0, - }, - ), - ( - 1.0, - TopologyMetrics { - global_mincut: 1.5, - modularity: 0.6, - global_efficiency: 0.45, - local_efficiency: 0.35, - graph_entropy: 2.1, - fiedler_value: 0.12, - num_modules: 4, - timestamp: 1.0, - }, - ), - ]; - let csv = timeline_to_csv(&timeline); - let lines: Vec<&str> = csv.lines().collect(); - assert_eq!(lines.len(), 3); // header + 2 data rows - assert!(lines[0].contains("timestamp")); - assert!(lines[0].contains("global_mincut")); - } - - #[test] - fn gexf_valid_structure() { - let graph = make_graph(); - let gexf = to_gexf(&graph); - assert!(gexf.contains("")); - assert!(gexf.contains("")); - assert!(gexf.contains("")); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-viz/src/layout.rs b/v2/crates/ruv-neural/ruv-neural-viz/src/layout.rs deleted file mode 100644 index f5d22d2d..00000000 --- a/v2/crates/ruv-neural/ruv-neural-viz/src/layout.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! Graph layout algorithms for brain topology visualization. - -use ruv_neural_core::brain::Parcellation; -use ruv_neural_core::graph::BrainGraph; - -/// Force-directed layout for brain graph visualization. -/// -/// Uses the Fruchterman-Reingold algorithm to position nodes such that -/// connected nodes are attracted and all nodes repel each other. -#[derive(Debug, Clone)] -pub struct ForceDirectedLayout { - /// Number of layout iterations. - pub iterations: usize, - /// Repulsion constant between all node pairs. - pub repulsion: f64, - /// Attraction constant along edges. - pub attraction: f64, - /// Velocity damping factor per iteration. - pub damping: f64, -} - -impl Default for ForceDirectedLayout { - fn default() -> Self { - Self::new() - } -} - -impl ForceDirectedLayout { - /// Create a new layout with default parameters. - pub fn new() -> Self { - Self { - iterations: 100, - repulsion: 1000.0, - attraction: 0.01, - damping: 0.95, - } - } - - /// Compute 3D positions for each node using force-directed placement. - /// - /// 1. Initialize positions deterministically (grid-based). - /// 2. Iterate: compute repulsive forces between all pairs, attractive forces along edges. - /// 3. Apply displacement with damping. - pub fn compute(&self, graph: &BrainGraph) -> Vec<[f64; 3]> { - let n = graph.num_nodes; - if n == 0 { - return Vec::new(); - } - - // Initialize positions on a simple 3D grid - let mut positions: Vec<[f64; 3]> = (0..n) - .map(|i| { - let fi = i as f64; - let cols = (n as f64).sqrt().ceil() as usize; - let cols_f = cols as f64; - let x = (fi % cols_f) * 10.0; - let y = ((fi / cols_f).floor()) * 10.0; - let z = ((fi / (cols_f * cols_f)).floor()) * 10.0; - [x, y, z] - }) - .collect(); - - let mut velocities = vec![[0.0_f64; 3]; n]; - - for _iter in 0..self.iterations { - let mut forces = vec![[0.0_f64; 3]; n]; - - // Repulsive forces between all pairs - for i in 0..n { - for j in (i + 1)..n { - let dx = positions[i][0] - positions[j][0]; - let dy = positions[i][1] - positions[j][1]; - let dz = positions[i][2] - positions[j][2]; - let dist_sq = dx * dx + dy * dy + dz * dz; - let dist = dist_sq.sqrt().max(0.01); - - let force = self.repulsion / dist_sq.max(0.01); - let fx = force * dx / dist; - let fy = force * dy / dist; - let fz = force * dz / dist; - - forces[i][0] += fx; - forces[i][1] += fy; - forces[i][2] += fz; - forces[j][0] -= fx; - forces[j][1] -= fy; - forces[j][2] -= fz; - } - } - - // Attractive forces along edges - for edge in &graph.edges { - if edge.source >= n || edge.target >= n { - continue; - } - let s = edge.source; - let t = edge.target; - let dx = positions[t][0] - positions[s][0]; - let dy = positions[t][1] - positions[s][1]; - let dz = positions[t][2] - positions[s][2]; - let dist = (dx * dx + dy * dy + dz * dz).sqrt().max(0.01); - - let force = self.attraction * edge.weight * dist; - let fx = force * dx / dist; - let fy = force * dy / dist; - let fz = force * dz / dist; - - forces[s][0] += fx; - forces[s][1] += fy; - forces[s][2] += fz; - forces[t][0] -= fx; - forces[t][1] -= fy; - forces[t][2] -= fz; - } - - // Apply forces with damping - for i in 0..n { - for d in 0..3 { - velocities[i][d] = (velocities[i][d] + forces[i][d]) * self.damping; - positions[i][d] += velocities[i][d]; - } - } - } - - positions - } -} - -/// Anatomical layout using MNI coordinates from brain parcellation. -pub struct AnatomicalLayout; - -impl AnatomicalLayout { - /// Compute positions from parcellation MNI centroids. - pub fn compute(parcellation: &Parcellation) -> Vec<[f64; 3]> { - parcellation.regions.iter().map(|r| r.centroid).collect() - } -} - -/// Compute a circular 2D layout for a given number of nodes. -/// -/// Nodes are placed evenly around a unit circle. -pub fn circular_layout(num_nodes: usize) -> Vec<[f64; 2]> { - if num_nodes == 0 { - return Vec::new(); - } - (0..num_nodes) - .map(|i| { - let angle = 2.0 * std::f64::consts::PI * (i as f64) / (num_nodes as f64); - [angle.cos(), angle.sin()] - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use ruv_neural_core::brain::Atlas; - use ruv_neural_core::graph::{BrainEdge, BrainGraph}; - use ruv_neural_core::signal::FrequencyBand; - - fn make_test_graph(num_nodes: usize) -> BrainGraph { - let mut edges = Vec::new(); - for i in 0..num_nodes { - for j in (i + 1)..num_nodes { - if (i + j) % 3 == 0 { - edges.push(BrainEdge { - source: i, - target: j, - weight: 0.5, - metric: ruv_neural_core::graph::ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Alpha, - }); - } - } - } - BrainGraph { - num_nodes, - edges, - timestamp: 0.0, - window_duration_s: 1.0, - atlas: Atlas::Custom(num_nodes), - } - } - - #[test] - fn force_directed_positions_within_bounds() { - let graph = make_test_graph(8); - let layout = ForceDirectedLayout::new(); - let positions = layout.compute(&graph); - - assert_eq!(positions.len(), 8); - for pos in &positions { - for &coord in pos { - assert!(coord.is_finite(), "position coordinate must be finite"); - } - } - } - - #[test] - fn force_directed_empty_graph() { - let graph = BrainGraph { - num_nodes: 0, - edges: Vec::new(), - timestamp: 0.0, - window_duration_s: 1.0, - atlas: Atlas::Custom(0), - }; - let layout = ForceDirectedLayout::new(); - let positions = layout.compute(&graph); - assert!(positions.is_empty()); - } - - #[test] - fn circular_layout_correct_count() { - let positions = circular_layout(10); - assert_eq!(positions.len(), 10); - } - - #[test] - fn circular_layout_on_unit_circle() { - let positions = circular_layout(4); - for pos in &positions { - let r = (pos[0] * pos[0] + pos[1] * pos[1]).sqrt(); - assert!((r - 1.0).abs() < 1e-10, "point should be on unit circle"); - } - } - - #[test] - fn circular_layout_empty() { - let positions = circular_layout(0); - assert!(positions.is_empty()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-viz/src/lib.rs b/v2/crates/ruv-neural/ruv-neural-viz/src/lib.rs deleted file mode 100644 index 4fcdf15b..00000000 --- a/v2/crates/ruv-neural/ruv-neural-viz/src/lib.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! rUv Neural Viz — Brain topology visualization data structures and ASCII rendering. -//! -//! This crate provides: -//! - **Layout algorithms**: Force-directed, anatomical (MNI), and circular layouts -//! - **Color mapping**: Cool-warm, viridis, and module-color schemes -//! - **ASCII rendering**: Terminal-friendly graph, mincut, sparkline, and dashboard views -//! - **Export**: D3.js JSON, Graphviz DOT, GEXF, and CSV timeline formats -//! - **Animation**: Frame generation from temporal brain graph sequences - -pub mod animation; -pub mod ascii; -pub mod colormap; -pub mod export; -pub mod layout; - -pub use animation::{AnimatedEdge, AnimatedNode, AnimationFrame, AnimationFrames, LayoutType}; -pub use colormap::ColorMap; -pub use layout::{AnatomicalLayout, ForceDirectedLayout}; diff --git a/v2/crates/ruv-neural/ruv-neural-wasm/Cargo.toml b/v2/crates/ruv-neural/ruv-neural-wasm/Cargo.toml deleted file mode 100644 index 40bde08e..00000000 --- a/v2/crates/ruv-neural/ruv-neural-wasm/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "ruv-neural-wasm" -description = "rUv Neural — WebAssembly bindings for browser-based brain topology visualization" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true - -[lib] -crate-type = ["cdylib", "rlib"] - -[features] -default = [] -console_error_panic_hook = [] - -[dependencies] -ruv-neural-core = { workspace = true } -wasm-bindgen = { workspace = true } -js-sys = { workspace = true } -web-sys = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -serde-wasm-bindgen = "0.6" - -[dev-dependencies] -wasm-bindgen-test = "0.3" diff --git a/v2/crates/ruv-neural/ruv-neural-wasm/README.md b/v2/crates/ruv-neural/ruv-neural-wasm/README.md deleted file mode 100644 index ec4f8155..00000000 --- a/v2/crates/ruv-neural/ruv-neural-wasm/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# ruv-neural-wasm - -WebAssembly bindings for browser-based brain topology visualization. - -## Overview - -`ruv-neural-wasm` provides JavaScript-callable functions for creating, analyzing, -and visualizing brain connectivity graphs directly in the browser. It wraps -`ruv-neural-core` types with `wasm-bindgen` and implements lightweight -WASM-compatible versions of graph algorithms (Stoer-Wagner mincut, spectral -embedding via power iteration, topology metrics, and cognitive state decoding) -that run without heavy native dependencies. - -**Note:** This crate is excluded from the default workspace build. Build it -separately targeting `wasm32-unknown-unknown`. - -## Features - -- **Graph parsing**: `create_brain_graph` -- parse `BrainGraph` from JSON -- **Minimum cut**: `compute_mincut` -- Stoer-Wagner on graphs up to 500 nodes -- **Topology metrics**: `compute_topology_metrics` -- density, efficiency, - modularity, Fiedler value, entropy, module count -- **Spectral embedding**: `embed_graph` -- power iteration on normalized Laplacian - (no LAPACK dependency) -- **State decoding**: `decode_state` -- threshold-based cognitive state classification - from topology metrics -- **RVF I/O**: `load_rvf` / `export_rvf` -- read and write RuVector binary files -- **Streaming** (`streaming`): WebSocket-compatible streaming data processor -- **Visualization data** (`viz_data`): Data structures for D3.js and Three.js rendering - -## Build - -```bash -# Requires wasm-pack or cargo with wasm32 target -cargo build -p ruv-neural-wasm --target wasm32-unknown-unknown --release - -# Or with wasm-pack for npm-ready output -wasm-pack build ruv-neural-wasm --target web -``` - -## Usage (JavaScript) - -```javascript -import init, { - create_brain_graph, - compute_mincut, - compute_topology_metrics, - embed_graph, - decode_state, - export_rvf, - version, -} from './ruv_neural_wasm.js'; - -await init(); - -const graphJson = JSON.stringify({ - num_nodes: 3, - edges: [ - { source: 0, target: 1, weight: 0.8, metric: "Coherence", frequency_band: "Alpha" }, - { source: 1, target: 2, weight: 0.5, metric: "Coherence", frequency_band: "Beta" }, - ], - timestamp: 0.0, - window_duration_s: 1.0, - atlas: { Custom: 3 }, -}); - -const graph = create_brain_graph(graphJson); -const mincut = compute_mincut(graphJson); -const metrics = compute_topology_metrics(graphJson); -const embedding = embed_graph(graphJson, 2); -const rvfBytes = export_rvf(graphJson); -console.log('Version:', version()); -``` - -## API Reference - -| Function | Description | -|----------------------------|---------------------------------------------------| -| `create_brain_graph(json)` | Parse JSON into a BrainGraph JS object | -| `compute_mincut(json)` | Stoer-Wagner minimum cut, returns MincutResult | -| `compute_topology_metrics(json)` | Compute TopologyMetrics for a graph | -| `embed_graph(json, dim)` | Spectral embedding via power iteration | -| `decode_state(json)` | Classify CognitiveState from TopologyMetrics | -| `load_rvf(bytes)` | Parse RVF binary data into JS object | -| `export_rvf(json)` | Serialize BrainGraph to RVF bytes | -| `version()` | Return crate version string | - -| Module | Key Types | -|-------------|-----------------------------------------------------------| -| `graph_wasm`| `wasm_mincut`, `wasm_embed`, `wasm_topology_metrics`, `wasm_decode` | -| `streaming` | WebSocket streaming data processor | -| `viz_data` | D3.js / Three.js visualization structures | - -## Integration - -Depends on `ruv-neural-core` for `BrainGraph`, `TopologyMetrics`, `RvfFile`, -and `CognitiveState` types. Uses `wasm-bindgen` and `serde-wasm-bindgen` for -JS interop. Designed for browser-based dashboards and real-time visualization -applications. - -## License - -MIT OR Apache-2.0 diff --git a/v2/crates/ruv-neural/ruv-neural-wasm/src/graph_wasm.rs b/v2/crates/ruv-neural/ruv-neural-wasm/src/graph_wasm.rs deleted file mode 100644 index 2c555bd5..00000000 --- a/v2/crates/ruv-neural/ruv-neural-wasm/src/graph_wasm.rs +++ /dev/null @@ -1,738 +0,0 @@ -//! WASM-compatible lightweight graph algorithms. -//! -//! These implementations avoid heavy dependencies (ndarray-linalg, petgraph) and work -//! within the constraints of the wasm32-unknown-unknown target. All algorithms operate -//! on the `BrainGraph` type from `ruv-neural-core`. - -use ruv_neural_core::embedding::{EmbeddingMetadata, NeuralEmbedding}; -use ruv_neural_core::graph::BrainGraph; -use ruv_neural_core::topology::{CognitiveState, MincutResult, TopologyMetrics}; - -/// Error type for WASM graph operations. -#[derive(Debug)] -pub struct WasmGraphError(pub String); - -impl std::fmt::Display for WasmGraphError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "WasmGraphError: {}", self.0) - } -} - -impl std::error::Error for WasmGraphError {} - -/// Simplified Stoer-Wagner minimum cut for small graphs (<500 nodes). -/// -/// This is a direct implementation of the Stoer-Wagner algorithm that finds -/// the global minimum cut in an undirected weighted graph. The algorithm runs -/// in O(V^3) time which is acceptable for brain graphs up to ~500 nodes. -pub fn wasm_mincut(graph: &BrainGraph) -> Result { - let n = graph.num_nodes; - if n == 0 { - return Err(WasmGraphError("Graph has no nodes".into())); - } - if n > 500 { - return Err(WasmGraphError(format!( - "Graph too large for WASM mincut: {} nodes (max 500)", - n - ))); - } - if n == 1 { - return Ok(MincutResult { - cut_value: 0.0, - partition_a: vec![0], - partition_b: vec![], - cut_edges: vec![], - timestamp: graph.timestamp, - }); - } - - let mut adj = graph.adjacency_matrix(); - - // Track which original nodes are merged into each super-node. - let mut merged: Vec> = (0..n).map(|i| vec![i]).collect(); - // Track which super-nodes are still active. - let mut active: Vec = vec![true; n]; - - let mut best_cut = f64::INFINITY; - let mut best_partition_a: Vec = Vec::new(); - - // Stoer-Wagner: perform n-1 minimum cut phases. - for _ in 0..n - 1 { - let active_nodes: Vec = (0..n).filter(|&i| active[i]).collect(); - if active_nodes.len() < 2 { - break; - } - - // Maximum adjacency ordering. - let mut in_set = vec![false; n]; - let mut w = vec![0.0f64; n]; // key values - let mut order: Vec = Vec::with_capacity(active_nodes.len()); - - for _ in 0..active_nodes.len() { - // Find the active node not in set with maximum key. - let next = active_nodes - .iter() - .filter(|&&v| !in_set[v]) - .max_by(|&&a, &&b| w[a].partial_cmp(&w[b]).unwrap_or(std::cmp::Ordering::Equal)) - .copied() - .unwrap(); - - in_set[next] = true; - order.push(next); - - // Update keys for neighbours. - for &v in &active_nodes { - if !in_set[v] { - w[v] += adj[next][v]; - } - } - } - - // The last two nodes in the ordering. - let t = *order.last().unwrap(); - let s = order[order.len() - 2]; - - // Cut of the phase = key of the last added node. - let cut_of_phase = w[t]; - - if cut_of_phase < best_cut { - best_cut = cut_of_phase; - best_partition_a = merged[t].clone(); - } - - // Merge t into s. - let t_nodes = merged[t].clone(); - merged[s].extend(t_nodes); - active[t] = false; - - // Update adjacency: merge t into s. - for i in 0..n { - adj[s][i] += adj[t][i]; - adj[i][s] += adj[i][t]; - } - adj[s][s] = 0.0; - } - - // Build partition B from nodes not in partition A. - let partition_a_set: std::collections::HashSet = - best_partition_a.iter().copied().collect(); - let partition_b: Vec = (0..n).filter(|i| !partition_a_set.contains(i)).collect(); - - // Find cut edges. - let cut_edges: Vec<(usize, usize, f64)> = graph - .edges - .iter() - .filter(|e| { - (partition_a_set.contains(&e.source) && !partition_a_set.contains(&e.target)) - || (!partition_a_set.contains(&e.source) && partition_a_set.contains(&e.target)) - }) - .map(|e| (e.source, e.target, e.weight)) - .collect(); - - Ok(MincutResult { - cut_value: best_cut, - partition_a: best_partition_a, - partition_b, - cut_edges, - timestamp: graph.timestamp, - }) -} - -/// Compute basic topology metrics without heavy linear algebra dependencies. -/// -/// Computes density, degree statistics, clustering coefficient, and graph entropy. -/// Fiedler value and global efficiency use simplified approximations suitable for WASM. -pub fn wasm_topology_metrics(graph: &BrainGraph) -> Result { - let n = graph.num_nodes; - if n == 0 { - return Err(WasmGraphError("Graph has no nodes".into())); - } - - let adj = graph.adjacency_matrix(); - - // Density. - let _density = graph.density(); - - // Degree statistics. - let degrees: Vec = (0..n).map(|i| graph.node_degree(i)).collect(); - let _mean_degree = degrees.iter().sum::() / n as f64; - - // Graph entropy from edge weight distribution. - let total_weight = graph.total_weight(); - let graph_entropy = if total_weight > 0.0 { - graph - .edges - .iter() - .map(|e| { - let p = e.weight / total_weight; - if p > 0.0 { - -p * p.ln() - } else { - 0.0 - } - }) - .sum::() - } else { - 0.0 - }; - - // Approximate global efficiency using shortest paths (Floyd-Warshall for small graphs). - let global_efficiency = compute_global_efficiency(&adj, n); - - // Approximate Fiedler value using power iteration on the Laplacian. - let fiedler_value = approximate_fiedler(&adj, n); - - // Modularity estimate from mincut (simplified). - let mincut_result = wasm_mincut(graph).ok(); - let (modularity, global_mincut) = if let Some(ref mc) = mincut_result { - let q = estimate_modularity(graph, &mc.partition_a, &mc.partition_b); - (q, mc.cut_value) - } else { - (0.0, 0.0) - }; - - // Local efficiency (average local clustering). - let local_efficiency = compute_local_efficiency(&adj, n); - - // Number of modules (using simple threshold-based detection). - let num_modules = if modularity > 0.3 { 2 } else { 1 }; - - Ok(TopologyMetrics { - global_mincut, - modularity, - global_efficiency, - local_efficiency, - graph_entropy, - fiedler_value, - num_modules, - timestamp: graph.timestamp, - }) -} - -/// Spectral embedding using power iteration on the graph Laplacian. -/// -/// Computes the `dimension` smallest non-trivial eigenvectors of the normalized -/// Laplacian using repeated power iteration with deflation. This avoids any -/// dependency on LAPACK/BLAS. -pub fn wasm_embed( - graph: &BrainGraph, - dimension: usize, -) -> Result { - let n = graph.num_nodes; - if n == 0 { - return Err(WasmGraphError("Graph has no nodes".into())); - } - if dimension == 0 { - return Err(WasmGraphError("Embedding dimension must be > 0".into())); - } - if dimension >= n { - return Err(WasmGraphError(format!( - "Embedding dimension {} must be < num_nodes {}", - dimension, n - ))); - } - - let adj = graph.adjacency_matrix(); - - // Build normalized Laplacian: L = D^(-1/2) * (D - A) * D^(-1/2) - let degrees: Vec = (0..n).map(|i| adj[i].iter().sum::()).collect(); - let d_inv_sqrt: Vec = degrees - .iter() - .map(|&d| if d > 0.0 { 1.0 / d.sqrt() } else { 0.0 }) - .collect(); - - let mut laplacian = vec![vec![0.0f64; n]; n]; - for i in 0..n { - for j in 0..n { - if i == j { - laplacian[i][j] = if degrees[i] > 0.0 { 1.0 } else { 0.0 }; - } else { - laplacian[i][j] = -adj[i][j] * d_inv_sqrt[i] * d_inv_sqrt[j]; - } - } - } - - // Power iteration with deflation to find smallest eigenvectors. - // We invert the problem: find largest eigenvectors of (I - L). - let mut inv_l = vec![vec![0.0f64; n]; n]; - for i in 0..n { - for j in 0..n { - inv_l[i][j] = if i == j { - 1.0 - laplacian[i][j] - } else { - -laplacian[i][j] - }; - } - } - - let mut eigenvectors: Vec> = Vec::new(); - let max_iter = 100; - - // Skip the first (trivial) eigenvector, compute `dimension` more. - for _ in 0..dimension + 1 { - let mut v = vec![0.0f64; n]; - // Initialize with pseudo-random values based on index. - for i in 0..n { - v[i] = ((i as f64 + 1.0) * 0.618033988749895).fract() - 0.5; - } - - // Orthogonalize against previously found eigenvectors. - for ev in &eigenvectors { - let dot: f64 = v.iter().zip(ev.iter()).map(|(a, b)| a * b).sum(); - for i in 0..n { - v[i] -= dot * ev[i]; - } - } - - for _ in 0..max_iter { - // Multiply: w = inv_l * v - let mut w = vec![0.0f64; n]; - for i in 0..n { - for j in 0..n { - w[i] += inv_l[i][j] * v[j]; - } - } - - // Orthogonalize against previously found eigenvectors. - for ev in &eigenvectors { - let dot: f64 = w.iter().zip(ev.iter()).map(|(a, b)| a * b).sum(); - for i in 0..n { - w[i] -= dot * ev[i]; - } - } - - // Normalize. - let norm: f64 = w.iter().map(|x| x * x).sum::().sqrt(); - if norm > 1e-12 { - for x in w.iter_mut() { - *x /= norm; - } - } - - v = w; - } - - eigenvectors.push(v); - } - - // Skip the first eigenvector (trivial constant vector), take the next `dimension`. - let embedding_vectors: Vec<&Vec> = eigenvectors.iter().skip(1).take(dimension).collect(); - - // Build embedding: each node gets a `dimension`-dimensional vector. - // We flatten into a single vector of length n * dimension for the NeuralEmbedding. - let mut flat_embedding = Vec::with_capacity(n * dimension); - for node in 0..n { - for ev in &embedding_vectors { - flat_embedding.push(ev[node]); - } - } - - let metadata = EmbeddingMetadata { - subject_id: None, - session_id: None, - cognitive_state: None, - source_atlas: graph.atlas, - embedding_method: "spectral-power-iteration".to_string(), - }; - - NeuralEmbedding::new(flat_embedding, graph.timestamp, metadata) - .map_err(|e| WasmGraphError(e.to_string())) -} - -/// Decode cognitive state from topology metrics using threshold-based rules. -/// -/// This is a simplified heuristic decoder that maps topology metric patterns -/// to cognitive states without requiring a trained ML model. -pub fn wasm_decode(metrics: &TopologyMetrics) -> Result { - // Simple threshold-based classification based on topology patterns. - // In a production system, this would be replaced by the trained decoder - // from ruv-neural-decoder. - - let modularity = metrics.modularity; - let efficiency = metrics.global_efficiency; - let fiedler = metrics.fiedler_value; - let entropy = metrics.graph_entropy; - - // High modularity + low efficiency => segregated processing (rest, sleep). - if modularity > 0.5 && efficiency < 0.3 { - if entropy < 1.0 { - return Ok(CognitiveState::Sleep( - ruv_neural_core::topology::SleepStage::N3, - )); - } - return Ok(CognitiveState::Rest); - } - - // Low modularity + high efficiency => integrated processing (focused, creative). - if modularity < 0.3 && efficiency > 0.6 { - if fiedler > 0.5 { - return Ok(CognitiveState::Focused); - } - return Ok(CognitiveState::Creative); - } - - // High entropy => complex distributed processing. - if entropy > 3.0 { - if efficiency > 0.5 { - return Ok(CognitiveState::MemoryRetrieval); - } - return Ok(CognitiveState::MemoryEncoding); - } - - // Medium modularity => motor or speech. - if modularity > 0.3 && modularity < 0.5 { - if efficiency > 0.5 { - return Ok(CognitiveState::MotorPlanning); - } - return Ok(CognitiveState::SpeechProcessing); - } - - // High fiedler + low entropy => stressed/fatigued. - if fiedler > 0.7 && entropy < 1.5 { - return Ok(CognitiveState::Stressed); - } - if fiedler < 0.2 && entropy < 1.5 { - return Ok(CognitiveState::Fatigued); - } - - Ok(CognitiveState::Unknown) -} - -// --- Internal helper functions --- - -/// Compute global efficiency using Floyd-Warshall shortest paths. -fn compute_global_efficiency(adj: &[Vec], n: usize) -> f64 { - if n < 2 { - return 0.0; - } - - // Initialize distance matrix with inverse weights (higher weight = shorter distance). - 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 via_k = dist[i][k] + dist[k][j]; - if via_k < dist[i][j] { - dist[i][j] = via_k; - } - } - } - } - - // Global efficiency = mean of (1/d_ij) for all i != j. - let mut sum = 0.0; - let mut count = 0; - for i in 0..n { - for j in 0..n { - if i != j && dist[i][j].is_finite() && dist[i][j] > 0.0 { - sum += 1.0 / dist[i][j]; - count += 1; - } - } - } - - if count > 0 { - sum / count as f64 - } else { - 0.0 - } -} - -/// Approximate the Fiedler value (algebraic connectivity) using power iteration -/// on the graph Laplacian. -fn approximate_fiedler(adj: &[Vec], n: usize) -> f64 { - if n < 2 { - return 0.0; - } - - // Build Laplacian: L = D - A - let mut laplacian = vec![vec![0.0f64; 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]; - } - } - } - - // Find second-smallest eigenvalue using inverse power iteration. - // First, find the largest eigenvalue to shift the matrix. - let mut v = vec![0.0f64; n]; - for i in 0..n { - v[i] = ((i as f64 + 1.0) * 0.618033988749895).fract() - 0.5; - } - - // Orthogonalize against the trivial eigenvector (constant vector). - let trivial: Vec = vec![1.0 / (n as f64).sqrt(); n]; - - let max_iter = 50; - for _ in 0..max_iter { - // Multiply: w = L * v - let mut w = vec![0.0f64; n]; - for i in 0..n { - for j in 0..n { - w[i] += laplacian[i][j] * v[j]; - } - } - - // Orthogonalize against trivial eigenvector. - let dot: f64 = w.iter().zip(trivial.iter()).map(|(a, b)| a * b).sum(); - for i in 0..n { - w[i] -= dot * trivial[i]; - } - - // Normalize. - let norm: f64 = w.iter().map(|x| x * x).sum::().sqrt(); - if norm > 1e-12 { - for x in w.iter_mut() { - *x /= norm; - } - } - - v = w; - } - - // Rayleigh quotient: lambda = v^T L v / v^T v - let mut vlv = 0.0; - for i in 0..n { - let mut lv_i = 0.0; - for j in 0..n { - lv_i += laplacian[i][j] * v[j]; - } - vlv += v[i] * lv_i; - } - let vtv: f64 = v.iter().map(|x| x * x).sum(); - - if vtv > 1e-12 { - vlv / vtv - } else { - 0.0 - } -} - -/// Estimate Newman-Girvan modularity for a two-way partition. -fn estimate_modularity( - graph: &BrainGraph, - partition_a: &[usize], - partition_b: &[usize], -) -> f64 { - let total_weight = graph.total_weight(); - if total_weight == 0.0 { - return 0.0; - } - let m = total_weight; // sum of all edge weights - - let _a_set: std::collections::HashSet = partition_a.iter().copied().collect(); - - let mut q = 0.0; - for &i in partition_a { - for &j in partition_a { - if i != j { - let a_ij = graph.edge_weight(i, j).unwrap_or(0.0); - let k_i = graph.node_degree(i); - let k_j = graph.node_degree(j); - q += a_ij - (k_i * k_j) / (2.0 * m); - } - } - } - for &i in partition_b { - for &j in partition_b { - if i != j { - let a_ij = graph.edge_weight(i, j).unwrap_or(0.0); - let k_i = graph.node_degree(i); - let k_j = graph.node_degree(j); - q += a_ij - (k_i * k_j) / (2.0 * m); - } - } - } - - q / (2.0 * m) -} - -/// Compute mean local efficiency (average clustering coefficient approximation). -fn compute_local_efficiency(adj: &[Vec], n: usize) -> f64 { - if n < 3 { - return 0.0; - } - - let mut total_cc = 0.0; - for i in 0..n { - let neighbors: Vec = (0..n).filter(|&j| j != i && adj[i][j] > 0.0).collect(); - let k = neighbors.len(); - if k < 2 { - continue; - } - - // Count weighted triangles. - let mut triangle_weight = 0.0; - for &u in &neighbors { - for &v in &neighbors { - if u < v && adj[u][v] > 0.0 { - // Weighted triangle contribution. - triangle_weight += - (adj[i][u] * adj[i][v] * adj[u][v]).cbrt(); - } - } - } - - let max_triangles = (k * (k - 1)) as f64 / 2.0; - if max_triangles > 0.0 { - // Normalize by the maximum possible strength. - let max_weight = adj[i] - .iter() - .filter(|&&w| w > 0.0) - .cloned() - .fold(0.0f64, f64::max); - let denom = max_triangles * max_weight; - if denom > 0.0 { - total_cc += triangle_weight / denom; - } - } - } - - total_cc / n as f64 -} - -#[cfg(test)] -mod tests { - use super::*; - use ruv_neural_core::brain::Atlas; - use ruv_neural_core::graph::{BrainEdge, BrainGraph}; - use ruv_neural_core::signal::FrequencyBand; - - fn make_test_graph() -> BrainGraph { - // Simple 4-node graph with a clear 2-way cut: - // 0 -- 1 (weight 5.0) - // 2 -- 3 (weight 5.0) - // 1 -- 2 (weight 0.1) <-- this is the cut edge - BrainGraph { - num_nodes: 4, - edges: vec![ - BrainEdge { - source: 0, - target: 1, - weight: 5.0, - metric: ruv_neural_core::graph::ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 2, - target: 3, - weight: 5.0, - metric: ruv_neural_core::graph::ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 1, - target: 2, - weight: 0.1, - metric: ruv_neural_core::graph::ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Alpha, - }, - ], - timestamp: 1000.0, - window_duration_s: 1.0, - atlas: Atlas::Custom(4), - } - } - - #[test] - fn test_wasm_mincut_finds_cut() { - let graph = make_test_graph(); - let result = wasm_mincut(&graph).unwrap(); - // The minimum cut should separate {0,1} from {2,3} with value 0.1. - assert!((result.cut_value - 0.1).abs() < 1e-6); - assert_eq!(result.num_cut_edges(), 1); - } - - #[test] - fn test_wasm_mincut_single_node() { - let graph = BrainGraph { - num_nodes: 1, - edges: vec![], - timestamp: 0.0, - window_duration_s: 1.0, - atlas: Atlas::Custom(1), - }; - let result = wasm_mincut(&graph).unwrap(); - assert_eq!(result.cut_value, 0.0); - } - - #[test] - fn test_wasm_topology_metrics() { - let graph = make_test_graph(); - let metrics = wasm_topology_metrics(&graph).unwrap(); - assert!(metrics.global_mincut >= 0.0); - assert!(metrics.graph_entropy >= 0.0); - assert!(metrics.fiedler_value >= 0.0); - } - - #[test] - fn test_wasm_embed() { - let graph = make_test_graph(); - let embedding = wasm_embed(&graph, 2).unwrap(); - // 4 nodes x 2 dimensions = 8 values. - assert_eq!(embedding.vector.len(), 8); - } - - #[test] - fn test_wasm_decode_sleep() { - let metrics = TopologyMetrics { - global_mincut: 0.1, - modularity: 0.6, - global_efficiency: 0.2, - local_efficiency: 0.3, - graph_entropy: 0.5, - fiedler_value: 0.3, - num_modules: 2, - timestamp: 0.0, - }; - let state = wasm_decode(&metrics).unwrap(); - // High modularity + low efficiency + low entropy => deep sleep. - assert_eq!( - state, - CognitiveState::Sleep(ruv_neural_core::topology::SleepStage::N3) - ); - } - - #[test] - fn test_wasm_decode_rest() { - let metrics = TopologyMetrics { - global_mincut: 0.1, - modularity: 0.6, - global_efficiency: 0.2, - local_efficiency: 0.3, - graph_entropy: 1.5, - fiedler_value: 0.3, - num_modules: 2, - timestamp: 0.0, - }; - let state = wasm_decode(&metrics).unwrap(); - // High modularity + low efficiency + moderate entropy => rest. - assert_eq!(state, CognitiveState::Rest); - } - - #[test] - fn test_wasm_mincut_empty_graph() { - let graph = BrainGraph { - num_nodes: 0, - edges: vec![], - timestamp: 0.0, - window_duration_s: 1.0, - atlas: Atlas::Custom(0), - }; - assert!(wasm_mincut(&graph).is_err()); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-wasm/src/lib.rs b/v2/crates/ruv-neural/ruv-neural-wasm/src/lib.rs deleted file mode 100644 index 96b900ef..00000000 --- a/v2/crates/ruv-neural/ruv-neural-wasm/src/lib.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! rUv Neural WASM — WebAssembly bindings for browser-based brain topology visualization. -//! -//! This crate provides JavaScript-callable functions for creating, analyzing, and -//! visualizing brain connectivity graphs directly in the browser. It wraps the -//! core `ruv-neural-core` types with `wasm-bindgen` bindings and provides -//! lightweight WASM-compatible implementations of graph algorithms. -//! -//! # Features -//! -//! - Parse brain graphs from JSON and return JS-compatible objects -//! - Compute minimum cut (Stoer-Wagner) on graphs up to 500 nodes -//! - Generate topology metrics (density, efficiency, modularity, Fiedler value) -//! - Spectral embedding via power iteration (no LAPACK dependency) -//! - Decode cognitive state from topology metrics -//! - RVF file format load/export -//! - Streaming data processor for WebSocket integration -//! - Visualization data structures for D3.js / Three.js - -pub mod graph_wasm; -pub mod streaming; -pub mod viz_data; - -use ruv_neural_core::graph::BrainGraph; -use ruv_neural_core::rvf::{RvfDataType, RvfFile}; -use ruv_neural_core::topology::TopologyMetrics; -use wasm_bindgen::prelude::*; - -use graph_wasm::{wasm_decode, wasm_embed, wasm_mincut, wasm_topology_metrics}; - -/// Initialize the WASM module. -/// -/// Called automatically when the module is loaded. Sets up panic hooks -/// for better error messages in the browser console. -#[wasm_bindgen(start)] -pub fn init() { - #[cfg(feature = "console_error_panic_hook")] - console_error_panic_hook::set_once(); -} - -/// Create a brain graph from JSON data. -/// -/// Parses a JSON string into a `BrainGraph` and returns it as a JS object. -/// -/// # Arguments -/// * `json_data` - JSON string representing a `BrainGraph`. -/// -/// # Returns -/// A JS object containing the parsed graph data. -#[wasm_bindgen] -pub fn create_brain_graph(json_data: &str) -> Result { - let graph: BrainGraph = - serde_json::from_str(json_data).map_err(|e| JsError::new(&e.to_string()))?; - serde_wasm_bindgen::to_value(&graph).map_err(|e| JsError::new(&e.to_string())) -} - -/// Compute minimum cut on a brain graph. -/// -/// Uses a simplified Stoer-Wagner algorithm suitable for graphs with up to -/// 500 nodes. Returns the cut value, partitions, and cut edges. -/// -/// # Arguments -/// * `json_graph` - JSON string representing a `BrainGraph`. -/// -/// # Returns -/// A JS object containing the `MincutResult`. -#[wasm_bindgen] -pub fn compute_mincut(json_graph: &str) -> Result { - let graph: BrainGraph = - serde_json::from_str(json_graph).map_err(|e| JsError::new(&e.to_string()))?; - let result = wasm_mincut(&graph)?; - serde_wasm_bindgen::to_value(&result).map_err(|e| JsError::new(&e.to_string())) -} - -/// Compute topology metrics for a brain graph. -/// -/// Returns density, efficiency, modularity, Fiedler value, entropy, and -/// module count. All computations use WASM-compatible algorithms without -/// heavy linear algebra dependencies. -/// -/// # Arguments -/// * `json_graph` - JSON string representing a `BrainGraph`. -/// -/// # Returns -/// A JS object containing the `TopologyMetrics`. -#[wasm_bindgen] -pub fn compute_topology_metrics(json_graph: &str) -> Result { - let graph: BrainGraph = - serde_json::from_str(json_graph).map_err(|e| JsError::new(&e.to_string()))?; - let metrics = wasm_topology_metrics(&graph)?; - serde_wasm_bindgen::to_value(&metrics).map_err(|e| JsError::new(&e.to_string())) -} - -/// Generate a spectral embedding from a brain graph. -/// -/// Uses power iteration on the normalized Laplacian to compute spectral -/// coordinates. Returns a flat vector of length `num_nodes * dimension`. -/// -/// # Arguments -/// * `json_graph` - JSON string representing a `BrainGraph`. -/// * `dimension` - Number of embedding dimensions. -/// -/// # Returns -/// A JS object containing the `NeuralEmbedding`. -#[wasm_bindgen] -pub fn embed_graph(json_graph: &str, dimension: usize) -> Result { - let graph: BrainGraph = - serde_json::from_str(json_graph).map_err(|e| JsError::new(&e.to_string()))?; - let embedding = wasm_embed(&graph, dimension)?; - serde_wasm_bindgen::to_value(&embedding).map_err(|e| JsError::new(&e.to_string())) -} - -/// Decode cognitive state from topology metrics. -/// -/// Uses threshold-based heuristics to classify the cognitive state -/// from a set of topology metrics. For production use, the trained -/// decoder from `ruv-neural-decoder` is recommended. -/// -/// # Arguments -/// * `json_metrics` - JSON string representing `TopologyMetrics`. -/// -/// # Returns -/// A JS object containing the decoded `CognitiveState`. -#[wasm_bindgen] -pub fn decode_state(json_metrics: &str) -> Result { - let metrics: TopologyMetrics = - serde_json::from_str(json_metrics).map_err(|e| JsError::new(&e.to_string()))?; - let state = wasm_decode(&metrics)?; - serde_wasm_bindgen::to_value(&state).map_err(|e| JsError::new(&e.to_string())) -} - -/// Load an RVF (RuVector File) from raw bytes. -/// -/// Parses the binary RVF header, JSON metadata, and payload, returning -/// the complete file structure as a JS object. -/// -/// # Arguments -/// * `data` - Raw bytes of the RVF file. -/// -/// # Returns -/// A JS object containing the parsed `RvfFile`. -#[wasm_bindgen] -pub fn load_rvf(data: &[u8]) -> Result { - let mut cursor = std::io::Cursor::new(data); - let rvf = RvfFile::read_from(&mut cursor).map_err(|e| JsError::new(&e.to_string()))?; - serde_wasm_bindgen::to_value(&rvf).map_err(|e| JsError::new(&e.to_string())) -} - -/// Export a brain graph as RVF bytes. -/// -/// Serializes a `BrainGraph` (provided as JSON) into the binary RVF format. -/// -/// # Arguments -/// * `json_graph` - JSON string representing a `BrainGraph`. -/// -/// # Returns -/// A `Vec` containing the RVF binary data. -#[wasm_bindgen] -pub fn export_rvf(json_graph: &str) -> Result, JsError> { - let graph: BrainGraph = - serde_json::from_str(json_graph).map_err(|e| JsError::new(&e.to_string()))?; - - let graph_json = - serde_json::to_vec(&graph).map_err(|e| JsError::new(&e.to_string()))?; - - let mut rvf = RvfFile::new(RvfDataType::BrainGraph); - rvf.header.num_entries = 1; - rvf.metadata = serde_json::json!({ - "num_nodes": graph.num_nodes, - "num_edges": graph.edges.len(), - "timestamp": graph.timestamp, - }); - rvf.data = graph_json; - - let mut buf = Vec::new(); - rvf.write_to(&mut buf) - .map_err(|e| JsError::new(&e.to_string()))?; - - Ok(buf) -} - -/// Get the crate version string. -#[wasm_bindgen] -pub fn version() -> String { - env!("CARGO_PKG_VERSION").to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - use ruv_neural_core::brain::Atlas; - use ruv_neural_core::graph::{BrainEdge, BrainGraph}; - use ruv_neural_core::signal::FrequencyBand; - - fn sample_graph_json() -> String { - let graph = BrainGraph { - num_nodes: 3, - edges: vec![ - BrainEdge { - source: 0, - target: 1, - weight: 0.8, - metric: ruv_neural_core::graph::ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 1, - target: 2, - weight: 0.5, - metric: ruv_neural_core::graph::ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Beta, - }, - ], - timestamp: 1000.0, - window_duration_s: 1.0, - atlas: Atlas::Custom(3), - }; - serde_json::to_string(&graph).unwrap() - } - - #[test] - fn test_create_brain_graph_parses_valid_json() { - let json = sample_graph_json(); - let graph: BrainGraph = serde_json::from_str(&json).unwrap(); - assert_eq!(graph.num_nodes, 3); - assert_eq!(graph.edges.len(), 2); - } - - #[test] - fn test_create_brain_graph_rejects_invalid_json() { - let result: Result = serde_json::from_str("not valid json"); - assert!(result.is_err()); - } - - #[test] - fn test_compute_mincut_returns_valid_result() { - let json = sample_graph_json(); - let graph: BrainGraph = serde_json::from_str(&json).unwrap(); - let result = wasm_mincut(&graph).unwrap(); - assert!(result.cut_value >= 0.0); - assert_eq!(result.num_nodes(), 3); - } - - #[test] - fn test_rvf_round_trip() { - let json = sample_graph_json(); - let graph: BrainGraph = serde_json::from_str(&json).unwrap(); - - // Export to RVF bytes. - let graph_bytes = serde_json::to_vec(&graph).unwrap(); - let mut rvf = RvfFile::new(RvfDataType::BrainGraph); - rvf.header.num_entries = 1; - rvf.metadata = serde_json::json!({"test": true}); - rvf.data = graph_bytes; - - let mut buf = Vec::new(); - rvf.write_to(&mut buf).unwrap(); - - // Read back. - let mut cursor = std::io::Cursor::new(&buf); - let loaded = RvfFile::read_from(&mut cursor).unwrap(); - - assert_eq!(loaded.header.data_type, RvfDataType::BrainGraph); - assert_eq!(loaded.header.num_entries, 1); - - // Deserialize the payload back to a BrainGraph. - let loaded_graph: BrainGraph = serde_json::from_slice(&loaded.data).unwrap(); - assert_eq!(loaded_graph.num_nodes, 3); - assert_eq!(loaded_graph.edges.len(), 2); - } - - #[test] - fn test_version_returns_string() { - let v = version(); - assert!(!v.is_empty()); - assert!(v.contains('.')); - } - - #[test] - fn test_decode_state_from_metrics() { - let metrics = TopologyMetrics { - global_mincut: 0.5, - modularity: 0.6, - global_efficiency: 0.2, - local_efficiency: 0.3, - graph_entropy: 1.5, - fiedler_value: 0.3, - num_modules: 2, - timestamp: 0.0, - }; - let state = wasm_decode(&metrics).unwrap(); - // High modularity + low efficiency + moderate entropy => Rest. - assert_eq!( - state, - ruv_neural_core::topology::CognitiveState::Rest - ); - } - - #[test] - fn test_embed_graph_produces_correct_dimensions() { - let json = sample_graph_json(); - let graph: BrainGraph = serde_json::from_str(&json).unwrap(); - let embedding = wasm_embed(&graph, 2).unwrap(); - assert_eq!(embedding.vector.len(), 6); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-wasm/src/streaming.rs b/v2/crates/ruv-neural/ruv-neural-wasm/src/streaming.rs deleted file mode 100644 index 1c5420ec..00000000 --- a/v2/crates/ruv-neural/ruv-neural-wasm/src/streaming.rs +++ /dev/null @@ -1,217 +0,0 @@ -//! WebSocket streaming support for real-time neural data processing. -//! -//! Provides a `StreamProcessor` that accumulates incoming neural samples, -//! applies a sliding window, and emits updated topology metrics whenever -//! a complete window is available. - -use serde::{Deserialize, Serialize}; -use wasm_bindgen::prelude::*; - -/// Streaming neural data processor with a sliding window. -/// -/// Accumulates incoming samples and produces topology metric updates -/// whenever enough data fills a window. Designed for use with WebSocket -/// connections in the browser. -#[wasm_bindgen] -pub struct StreamProcessor { - /// Internal sample buffer. - buffer: Vec, - /// Number of samples in a complete analysis window. - window_size: usize, - /// Number of samples to advance between windows (hop size). - step_size: usize, - /// Number of windows emitted so far. - windows_emitted: u64, -} - -/// Summary statistics for a single window of streaming data. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct WindowStats { - /// Mean value of samples in the window. - pub mean: f64, - /// Variance of samples in the window. - pub variance: f64, - /// Minimum sample value. - pub min: f64, - /// Maximum sample value. - pub max: f64, - /// Number of samples in the window. - pub window_size: usize, - /// Sequential window index. - pub window_index: u64, -} - -#[wasm_bindgen] -impl StreamProcessor { - /// Create a new `StreamProcessor`. - /// - /// # Arguments - /// * `window_size` - Number of samples in each analysis window. - /// * `step_size` - Number of samples to advance between windows (hop size). - #[wasm_bindgen(constructor)] - pub fn new(window_size: usize, step_size: usize) -> Self { - let step_size = if step_size == 0 { 1 } else { step_size }; - Self { - buffer: Vec::with_capacity(window_size), - window_size, - step_size, - windows_emitted: 0, - } - } - - /// Push new samples into the buffer and return window statistics - /// if a complete window is available. - /// - /// Returns `null` if not enough samples have accumulated yet. - /// When a window is complete, computes statistics and advances - /// the buffer by `step_size` samples. - pub fn push_samples(&mut self, samples: &[f64]) -> Option { - let stats = self.push_samples_native(samples)?; - serde_wasm_bindgen::to_value(&stats).ok() - } - - /// Reset the internal buffer and window counter. - pub fn reset(&mut self) { - self.buffer.clear(); - self.windows_emitted = 0; - } - - /// Get the current number of buffered samples. - pub fn buffered_count(&self) -> usize { - self.buffer.len() - } - - /// Get the number of windows emitted so far. - pub fn windows_emitted(&self) -> u64 { - self.windows_emitted - } - - /// Get the configured window size. - pub fn window_size(&self) -> usize { - self.window_size - } - - /// Get the configured step size. - pub fn step_size(&self) -> usize { - self.step_size - } -} - -impl StreamProcessor { - /// Push samples and return native `WindowStats` (usable without WASM runtime). - pub fn push_samples_native(&mut self, samples: &[f64]) -> Option { - self.buffer.extend_from_slice(samples); - - if self.buffer.len() >= self.window_size { - let window = &self.buffer[..self.window_size]; - let stats = compute_window_stats(window, self.windows_emitted); - self.windows_emitted += 1; - - // Advance buffer by step_size. - let drain_count = self.step_size.min(self.buffer.len()); - self.buffer.drain(..drain_count); - - Some(stats) - } else { - None - } - } -} - -/// Compute basic statistics over a sample window. -fn compute_window_stats(window: &[f64], window_index: u64) -> WindowStats { - let n = window.len() as f64; - let sum: f64 = window.iter().sum(); - let mean = sum / n; - - let variance = window.iter().map(|x| (x - mean).powi(2)).sum::() / n; - - let min = window - .iter() - .cloned() - .fold(f64::INFINITY, f64::min); - let max = window - .iter() - .cloned() - .fold(f64::NEG_INFINITY, f64::max); - - WindowStats { - mean, - variance, - min, - max, - window_size: window.len(), - window_index, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_stream_processor_accumulates() { - let mut proc = StreamProcessor::new(10, 5); - assert_eq!(proc.buffered_count(), 0); - - // Push 5 samples (not enough for a window). - let result = proc.push_samples_native(&[1.0, 2.0, 3.0, 4.0, 5.0]); - assert!(result.is_none()); - assert_eq!(proc.buffered_count(), 5); - } - - #[test] - fn test_stream_processor_emits_on_full_window() { - let mut proc = StreamProcessor::new(4, 2); - - // Push exactly 4 samples. - let result = proc.push_samples_native(&[1.0, 2.0, 3.0, 4.0]); - assert!(result.is_some()); - let stats = result.unwrap(); - assert!((stats.mean - 2.5).abs() < 1e-10); - assert_eq!(proc.windows_emitted(), 1); - // After step of 2, buffer should have 2 remaining. - assert_eq!(proc.buffered_count(), 2); - } - - #[test] - fn test_stream_processor_reset() { - let mut proc = StreamProcessor::new(4, 2); - proc.push_samples_native(&[1.0, 2.0, 3.0, 4.0]); - proc.reset(); - assert_eq!(proc.buffered_count(), 0); - assert_eq!(proc.windows_emitted(), 0); - } - - #[test] - fn test_window_stats_computation() { - let window = [2.0, 4.0, 6.0, 8.0]; - let stats = compute_window_stats(&window, 0); - assert!((stats.mean - 5.0).abs() < 1e-10); - assert!((stats.variance - 5.0).abs() < 1e-10); - assert!((stats.min - 2.0).abs() < 1e-10); - assert!((stats.max - 8.0).abs() < 1e-10); - assert_eq!(stats.window_size, 4); - } - - #[test] - fn test_stream_processor_zero_step_defaults_to_one() { - let proc = StreamProcessor::new(4, 0); - assert_eq!(proc.step_size(), 1); - } - - #[test] - fn test_multiple_windows() { - let mut proc = StreamProcessor::new(3, 1); - - // Push 5 samples: should emit window at sample 3. - let result = proc.push_samples_native(&[1.0, 2.0, 3.0]); - assert!(result.is_some()); - assert_eq!(proc.windows_emitted(), 1); - - // Push 1 more: buffer should be [2,3,X], then with new sample [2,3,4]. - let result = proc.push_samples_native(&[4.0]); - assert!(result.is_some()); - assert_eq!(proc.windows_emitted(), 2); - } -} diff --git a/v2/crates/ruv-neural/ruv-neural-wasm/src/viz_data.rs b/v2/crates/ruv-neural/ruv-neural-wasm/src/viz_data.rs deleted file mode 100644 index aeccc1c5..00000000 --- a/v2/crates/ruv-neural/ruv-neural-wasm/src/viz_data.rs +++ /dev/null @@ -1,247 +0,0 @@ -//! Visualization data structures for JavaScript rendering. -//! -//! Provides types formatted for direct consumption by D3.js and Three.js -//! visualization libraries. Includes force-directed layout positioning -//! and partition coloring. - -use ruv_neural_core::graph::BrainGraph; -use serde::{Deserialize, Serialize}; -use wasm_bindgen::prelude::*; - -use crate::graph_wasm::wasm_mincut; - -/// Graph data formatted for D3.js / Three.js visualization. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VizGraph { - /// Nodes with positions and visual attributes. - pub nodes: Vec, - /// Edges with visual attributes. - pub edges: Vec, - /// Optional partition assignments (list of node-index groups). - pub partitions: Option>>, - /// Optional indices into `edges` that are cut edges. - pub cut_edges: Option>, -} - -/// A single node in the visualization graph. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VizNode { - /// Node index. - pub id: usize, - /// Human-readable label. - pub label: String, - /// X position (layout coordinate). - pub x: f64, - /// Y position (layout coordinate). - pub y: f64, - /// Z position (layout coordinate, for 3D views). - pub z: f64, - /// Module/partition membership group. - pub group: usize, - /// Node importance (e.g., weighted degree). - pub size: f64, - /// Hex color string (e.g., "#ff6600"). - pub color: String, -} - -/// A single edge in the visualization graph. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VizEdge { - /// Source node index. - pub source: usize, - /// Target node index. - pub target: usize, - /// Edge weight. - pub weight: f64, - /// Whether this edge crosses a partition boundary. - pub is_cut: bool, - /// Hex color string. - pub color: String, -} - -/// Default color palette for partition groups. -const GROUP_COLORS: &[&str] = &[ - "#4285f4", // Blue - "#ea4335", // Red - "#fbbc05", // Yellow - "#34a853", // Green - "#ff6d01", // Orange - "#46bdc6", // Teal - "#7b1fa2", // Purple - "#c2185b", // Pink -]; - -/// Convert a `BrainGraph` to a `VizGraph` with force-directed layout positions. -pub fn create_viz_graph(graph: &BrainGraph) -> VizGraph { - let n = graph.num_nodes; - - // Compute partitions via mincut (if graph is small enough). - let mincut_result = if n > 0 && n <= 500 { - wasm_mincut(graph).ok() - } else { - None - }; - - // Build partition membership map. - let mut node_group = vec![0usize; n]; - if let Some(ref mc) = mincut_result { - for &idx in &mc.partition_b { - if idx < n { - node_group[idx] = 1; - } - } - } - - // Compute initial layout using a simple circular arrangement - // (JavaScript side typically re-layouts with D3 force simulation). - let mut nodes = Vec::with_capacity(n); - for i in 0..n { - let angle = 2.0 * std::f64::consts::PI * (i as f64) / (n.max(1) as f64); - let radius = 100.0; - let group = node_group[i]; - let degree = graph.node_degree(i); - - nodes.push(VizNode { - id: i, - label: format!("R{}", i), - x: radius * angle.cos(), - y: radius * angle.sin(), - z: 0.0, - group, - size: (degree + 1.0).ln(), // Log-scaled importance - color: GROUP_COLORS[group % GROUP_COLORS.len()].to_string(), - }); - } - - // Build cut-edge set for coloring. - let cut_edge_set: std::collections::HashSet<(usize, usize)> = mincut_result - .as_ref() - .map(|mc| { - mc.cut_edges - .iter() - .flat_map(|&(s, t, _)| vec![(s, t), (t, s)]) - .collect() - }) - .unwrap_or_default(); - - let mut edges = Vec::with_capacity(graph.edges.len()); - let mut cut_edge_indices = Vec::new(); - - for (idx, edge) in graph.edges.iter().enumerate() { - let is_cut = cut_edge_set.contains(&(edge.source, edge.target)); - if is_cut { - cut_edge_indices.push(idx); - } - edges.push(VizEdge { - source: edge.source, - target: edge.target, - weight: edge.weight, - is_cut, - color: if is_cut { - "#ff0000".to_string() - } else { - "#999999".to_string() - }, - }); - } - - let partitions = mincut_result.map(|mc| vec![mc.partition_a, mc.partition_b]); - - VizGraph { - nodes, - edges, - partitions, - cut_edges: if cut_edge_indices.is_empty() { - None - } else { - Some(cut_edge_indices) - }, - } -} - -/// Convert a `BrainGraph` JSON string to a `VizGraph` for rendering. -#[wasm_bindgen] -pub fn to_viz_graph(json_graph: &str) -> Result { - let graph: BrainGraph = - serde_json::from_str(json_graph).map_err(|e| JsError::new(&e.to_string()))?; - let viz = create_viz_graph(&graph); - serde_wasm_bindgen::to_value(&viz).map_err(|e| JsError::new(&e.to_string())) -} - -#[cfg(test)] -mod tests { - use super::*; - use ruv_neural_core::brain::Atlas; - use ruv_neural_core::graph::{BrainEdge, BrainGraph}; - use ruv_neural_core::signal::FrequencyBand; - - fn make_test_graph() -> BrainGraph { - BrainGraph { - num_nodes: 4, - edges: vec![ - BrainEdge { - source: 0, - target: 1, - weight: 5.0, - metric: ruv_neural_core::graph::ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 2, - target: 3, - weight: 5.0, - metric: ruv_neural_core::graph::ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 1, - target: 2, - weight: 0.1, - metric: ruv_neural_core::graph::ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Alpha, - }, - ], - timestamp: 1000.0, - window_duration_s: 1.0, - atlas: Atlas::Custom(4), - } - } - - #[test] - fn test_viz_graph_creation() { - let graph = make_test_graph(); - let viz = create_viz_graph(&graph); - assert_eq!(viz.nodes.len(), 4); - assert_eq!(viz.edges.len(), 3); - // Should have partitions from mincut. - assert!(viz.partitions.is_some()); - } - - #[test] - fn test_viz_graph_serializes() { - let graph = make_test_graph(); - let viz = create_viz_graph(&graph); - let json = serde_json::to_string(&viz).unwrap(); - assert!(json.contains("\"nodes\"")); - assert!(json.contains("\"edges\"")); - } - - #[test] - fn test_viz_node_has_position() { - let graph = make_test_graph(); - let viz = create_viz_graph(&graph); - for node in &viz.nodes { - // Nodes should have non-zero positions (circular layout). - assert!(node.x != 0.0 || node.y != 0.0 || node.id == 0); - } - } - - #[test] - fn test_cut_edges_marked() { - let graph = make_test_graph(); - let viz = create_viz_graph(&graph); - let cut_count = viz.edges.iter().filter(|e| e.is_cut).count(); - // Should have at least one cut edge. - assert!(cut_count >= 1); - } -} diff --git a/v2/crates/ruv-neural/tests/integration.rs b/v2/crates/ruv-neural/tests/integration.rs deleted file mode 100644 index dded9b27..00000000 --- a/v2/crates/ruv-neural/tests/integration.rs +++ /dev/null @@ -1,558 +0,0 @@ -//! Workspace-level integration tests for the rUv Neural crate ecosystem. -//! -//! These tests verify that all crates compose correctly and that the full -//! pipeline (simulate -> preprocess -> graph -> mincut -> embed -> decode) -//! produces consistent results across crate boundaries. -//! -//! Gate with `cfg(feature = "integration")` so these only run when all crates -//! are built together (they require the full workspace). - -#![cfg(feature = "integration")] - -use ruv_neural_core::error::Result; -use ruv_neural_core::graph::{BrainEdge, BrainGraph, ConnectivityMetric}; -use ruv_neural_core::signal::{FrequencyBand, MultiChannelTimeSeries}; -use ruv_neural_core::topology::MincutResult; -use ruv_neural_core::traits::SensorSource; -use ruv_neural_core::{Atlas, BrainRegion, Hemisphere, Lobe}; - -// --------------------------------------------------------------------------- -// 1. Cross-crate type compatibility -// --------------------------------------------------------------------------- - -#[test] -fn core_types_are_send_and_sync() { - fn assert_send_sync() {} - assert_send_sync::(); - assert_send_sync::(); - assert_send_sync::(); - assert_send_sync::(); - assert_send_sync::(); -} - -#[test] -fn core_enums_roundtrip_serde() { - let atlas = Atlas::DesikanKilliany68; - let json = serde_json::to_string(&atlas).unwrap(); - let back: Atlas = serde_json::from_str(&json).unwrap(); - assert_eq!(atlas, back); - - let metric = ConnectivityMetric::PhaseLockingValue; - let json = serde_json::to_string(&metric).unwrap(); - let back: ConnectivityMetric = serde_json::from_str(&json).unwrap(); - assert_eq!(metric, back); - - let band = FrequencyBand::Alpha; - let json = serde_json::to_string(&band).unwrap(); - let back: FrequencyBand = serde_json::from_str(&json).unwrap(); - assert_eq!(band, back); -} - -// --------------------------------------------------------------------------- -// 2. Sensor -> Signal pipeline -// --------------------------------------------------------------------------- - -#[test] -fn simulator_produces_valid_multichannel_data() { - use ruv_neural_sensor::simulator::SimulatedSensorArray; - - let mut sim = SimulatedSensorArray::new(16, 1000.0); - let data = sim.read_chunk(500).expect("sensor read failed"); - - assert_eq!(data.num_channels, 16); - assert_eq!(data.num_samples, 500); - assert_eq!(data.sample_rate_hz, 1000.0); - assert_eq!(data.data.len(), 16); - for ch in &data.data { - assert_eq!(ch.len(), 500); - } -} - -#[test] -fn simulator_with_alpha_injection() { - use ruv_neural_sensor::simulator::SimulatedSensorArray; - - let mut sim = SimulatedSensorArray::new(8, 1000.0); - sim.inject_alpha(200.0); - let data = sim.read_chunk(2000).expect("sensor read failed"); - - // With alpha injection, signals should have non-trivial variance. - let ch0 = &data.data[0]; - let mean: f64 = ch0.iter().sum::() / ch0.len() as f64; - let variance: f64 = ch0.iter().map(|x| (x - mean).powi(2)).sum::() / ch0.len() as f64; - assert!( - variance > 0.0, - "Expected non-zero variance with alpha injection" - ); -} - -#[test] -fn preprocessing_pipeline_processes_channel_data() { - use ruv_neural_signal::PreprocessingPipeline; - - let pipeline = PreprocessingPipeline::new(); - assert_eq!(pipeline.num_stages(), 0, "Default pipeline has no stages"); - - // Process a simple signal through the empty pipeline (identity). - let signal: Vec = (0..100).map(|i| (i as f64 * 0.1).sin()).collect(); - let result = pipeline.process(&signal); - assert_eq!(result.len(), signal.len()); -} - -// --------------------------------------------------------------------------- -// 3. Signal -> Graph -> Mincut pipeline -// --------------------------------------------------------------------------- - -#[test] -fn connectivity_matrix_from_signals() { - use ruv_neural_signal::{compute_all_pairs, ConnectivityMetric}; - - // Create 4 channels of synthetic sinusoidal data. - let n = 1000; - let channels: Vec> = (0..4) - .map(|ch| { - (0..n) - .map(|t| { - let phase = ch as f64 * 0.5; - (2.0 * std::f64::consts::PI * 10.0 * t as f64 / 1000.0 + phase).sin() - }) - .collect() - }) - .collect(); - - let matrix = compute_all_pairs(&channels, &ConnectivityMetric::PhaseLockingValue); - assert_eq!(matrix.len(), 4); - for row in &matrix { - assert_eq!(row.len(), 4); - } - - // Diagonal should be 1.0 (self-PLV) or at least the highest value. - for i in 0..4 { - assert!( - matrix[i][i] >= 0.99, - "Self-PLV should be ~1.0, got {}", - matrix[i][i] - ); - } -} - -#[test] -fn brain_graph_construction_and_mincut() { - // Build a small BrainGraph manually and run Stoer-Wagner. - let edges = vec![ - BrainEdge { - source: 0, - target: 1, - weight: 0.9, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 1, - target: 2, - weight: 0.8, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 2, - target: 3, - weight: 0.1, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 3, - target: 4, - weight: 0.85, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 0, - target: 2, - weight: 0.7, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }, - ]; - - let graph = BrainGraph { - num_nodes: 5, - edges, - timestamp: 0.0, - window_duration_s: 1.0, - atlas: Atlas::DesikanKilliany68, - }; - - // Verify graph utilities. - assert!(graph.density() > 0.0); - assert!(graph.total_weight() > 0.0); - assert_eq!(graph.adjacency_matrix().len(), 5); - - // Run Stoer-Wagner mincut. - let result = ruv_neural_mincut::stoer_wagner_mincut(&graph).expect("mincut failed"); - assert!(result.cut_value > 0.0, "Cut value must be positive"); - assert!( - !result.partition_a.is_empty() && !result.partition_b.is_empty(), - "Both partitions must be non-empty" - ); - assert_eq!( - result.partition_a.len() + result.partition_b.len(), - 5, - "Partitions must cover all nodes" - ); - - // The weakest link (0.1 between nodes 2-3) should likely be cut. - assert!( - result.cut_value <= 0.2, - "Expected cut near the weak edge (0.1), got {}", - result.cut_value - ); -} - -#[test] -fn normalized_cut_produces_valid_partition() { - let edges = vec![ - BrainEdge { - source: 0, - target: 1, - weight: 0.9, - metric: ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Beta, - }, - BrainEdge { - source: 1, - target: 2, - weight: 0.05, - metric: ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Beta, - }, - BrainEdge { - source: 2, - target: 3, - weight: 0.85, - metric: ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Beta, - }, - ]; - - let graph = BrainGraph { - num_nodes: 4, - edges, - timestamp: 1.0, - window_duration_s: 1.0, - atlas: Atlas::DesikanKilliany68, - }; - - let result = ruv_neural_mincut::normalized_cut(&graph).expect("normalized cut failed"); - assert!(result.cut_value >= 0.0); - assert_eq!(result.partition_a.len() + result.partition_b.len(), 4); -} - -// --------------------------------------------------------------------------- -// 4. Mincut -> Embed pipeline -// --------------------------------------------------------------------------- - -#[test] -fn neural_embedding_creation_and_serialization() { - use ruv_neural_embed::NeuralEmbedding; - - let embedding = NeuralEmbedding::new(vec![1.0, 2.0, 3.0, 4.0], 0.0, "spectral") - .expect("embedding creation failed"); - - assert_eq!(embedding.dimension, 4); - assert_eq!(embedding.values.len(), 4); - assert_eq!(embedding.method, "spectral"); - assert!((embedding.norm() - (1.0_f64 + 4.0 + 9.0 + 16.0).sqrt()).abs() < 1e-10); - - // Serde roundtrip. - let json = serde_json::to_string(&embedding).unwrap(); - let back: NeuralEmbedding = serde_json::from_str(&json).unwrap(); - assert_eq!(back.dimension, 4); - assert_eq!(back.values, embedding.values); -} - -#[test] -fn zero_embedding_has_zero_norm() { - use ruv_neural_embed::NeuralEmbedding; - - let zero = NeuralEmbedding::zeros(16, 0.0, "test"); - assert_eq!(zero.dimension, 16); - assert!((zero.norm() - 0.0).abs() < 1e-15); -} - -#[test] -fn empty_embedding_is_rejected() { - use ruv_neural_embed::NeuralEmbedding; - - let result = NeuralEmbedding::new(vec![], 0.0, "empty"); - assert!(result.is_err(), "Empty embedding should be rejected"); -} - -// --------------------------------------------------------------------------- -// 5. Decoder types -// --------------------------------------------------------------------------- - -#[test] -fn decoder_types_exist_and_are_constructible() { - // Verify that decoder public types can be referenced. - // This is a compile-time check more than a runtime check. - let _: fn() -> &str = || { - let _ = std::any::type_name::(); - let _ = std::any::type_name::(); - let _ = std::any::type_name::(); - let _ = std::any::type_name::(); - let _ = std::any::type_name::(); - "ok" - }; -} - -// --------------------------------------------------------------------------- -// 6. Core traits are object-safe (can be used as trait objects) -// --------------------------------------------------------------------------- - -#[test] -fn core_traits_are_object_safe() { - use ruv_neural_core::traits::*; - - // These lines verify the traits can be used as `dyn Trait`. - // If a trait is not object-safe, this will fail to compile. - fn _accept_sensor(_: &dyn SensorSource) {} - fn _accept_signal(_: &dyn SignalProcessor) {} - fn _accept_graph(_: &dyn GraphConstructor) {} - fn _accept_topology(_: &dyn TopologyAnalyzer) {} - fn _accept_embedding(_: &dyn EmbeddingGenerator) {} - fn _accept_decoder(_: &dyn StateDecoder) {} - fn _accept_memory(_: &mut dyn NeuralMemory) {} -} - -// --------------------------------------------------------------------------- -// 7. Full pipeline: simulate -> preprocess -> connectivity -> graph -> mincut -// --------------------------------------------------------------------------- - -#[test] -fn full_pipeline_simulate_to_mincut() { - use ruv_neural_sensor::simulator::SimulatedSensorArray; - use ruv_neural_signal::{compute_all_pairs, ConnectivityMetric}; - - // Step 1: Simulate sensor data (16 channels, 1s at 1000 Hz). - let mut sim = SimulatedSensorArray::new(16, 1000.0); - sim.inject_alpha(150.0); - let data = sim.read_chunk(1000).expect("sensor read failed"); - assert_eq!(data.data.len(), 16); - - // Step 2: Compute pairwise connectivity matrix (PLV). - let matrix = compute_all_pairs(&data.data, &ConnectivityMetric::PhaseLockingValue); - assert_eq!(matrix.len(), 16); - - // Step 3: Build BrainGraph from connectivity matrix. - let threshold = 0.3; - let mut edges = Vec::new(); - for i in 0..16 { - for j in (i + 1)..16 { - if matrix[i][j] > threshold { - edges.push(BrainEdge { - source: i, - target: j, - weight: matrix[i][j], - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }); - } - } - } - - let graph = BrainGraph { - num_nodes: 16, - edges, - timestamp: data.timestamp_start, - window_duration_s: 1.0, - atlas: Atlas::DesikanKilliany68, - }; - - // Step 4: Run Stoer-Wagner mincut. - if graph.edges.is_empty() { - // If no edges pass threshold, the graph is disconnected — that is valid. - return; - } - let result = ruv_neural_mincut::stoer_wagner_mincut(&graph).expect("mincut failed"); - assert!(result.cut_value >= 0.0); - assert_eq!( - result.partition_a.len() + result.partition_b.len(), - 16, - "Partitions must cover all 16 nodes" - ); - - // Step 5: Create embedding from topology result. - let feature_vec = vec![ - result.cut_value, - result.balance_ratio(), - result.num_cut_edges() as f64, - graph.density(), - graph.total_weight(), - ]; - let embedding = ruv_neural_embed::NeuralEmbedding::new(feature_vec, data.timestamp_start, "topology") - .expect("embedding failed"); - assert_eq!(embedding.dimension, 5); - assert!(embedding.norm() > 0.0); -} - -// --------------------------------------------------------------------------- -// 8. BrainGraph serde roundtrip -// --------------------------------------------------------------------------- - -#[test] -fn brain_graph_serde_roundtrip() { - let graph = BrainGraph { - num_nodes: 3, - edges: vec![ - BrainEdge { - source: 0, - target: 1, - weight: 0.5, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 1, - target: 2, - weight: 0.7, - metric: ConnectivityMetric::Coherence, - frequency_band: FrequencyBand::Gamma, - }, - ], - timestamp: 42.0, - window_duration_s: 2.0, - atlas: Atlas::DesikanKilliany68, - }; - - let json = serde_json::to_string_pretty(&graph).unwrap(); - let back: BrainGraph = serde_json::from_str(&json).unwrap(); - - assert_eq!(back.num_nodes, graph.num_nodes); - assert_eq!(back.edges.len(), graph.edges.len()); - assert!((back.timestamp - graph.timestamp).abs() < 1e-10); - assert_eq!(back.atlas, graph.atlas); -} - -// --------------------------------------------------------------------------- -// 9. Multiway cut (multiple partitions) -// --------------------------------------------------------------------------- - -#[test] -fn multiway_cut_produces_valid_partitions() { - // Build a graph with 3 clear clusters connected by weak edges. - let mut edges = Vec::new(); - - // Cluster A: nodes 0, 1, 2 (strong internal edges). - for &(s, t) in &[(0, 1), (1, 2), (0, 2)] { - edges.push(BrainEdge { - source: s, - target: t, - weight: 0.9, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }); - } - - // Cluster B: nodes 3, 4, 5 (strong internal edges). - for &(s, t) in &[(3, 4), (4, 5), (3, 5)] { - edges.push(BrainEdge { - source: s, - target: t, - weight: 0.85, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }); - } - - // Cluster C: nodes 6, 7, 8 (strong internal edges). - for &(s, t) in &[(6, 7), (7, 8), (6, 8)] { - edges.push(BrainEdge { - source: s, - target: t, - weight: 0.88, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }); - } - - // Weak inter-cluster bridges. - edges.push(BrainEdge { - source: 2, - target: 3, - weight: 0.05, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }); - edges.push(BrainEdge { - source: 5, - target: 6, - weight: 0.04, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }); - - let graph = BrainGraph { - num_nodes: 9, - edges, - timestamp: 0.0, - window_duration_s: 1.0, - atlas: Atlas::DesikanKilliany68, - }; - - let partitions = ruv_neural_mincut::multiway_cut(&graph, 3).expect("multiway cut failed"); - assert!( - partitions.num_partitions() >= 2, - "Expected at least 2 partitions" - ); - assert_eq!( - partitions.num_nodes(), - 9, - "All nodes must be assigned to a partition" - ); -} - -// --------------------------------------------------------------------------- -// 10. Spectral cut analysis -// --------------------------------------------------------------------------- - -#[test] -fn spectral_bisection_produces_valid_split() { - let edges = vec![ - BrainEdge { - source: 0, - target: 1, - weight: 0.9, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 1, - target: 2, - weight: 0.05, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }, - BrainEdge { - source: 2, - target: 3, - weight: 0.85, - metric: ConnectivityMetric::PhaseLockingValue, - frequency_band: FrequencyBand::Alpha, - }, - ]; - - let graph = BrainGraph { - num_nodes: 4, - edges, - timestamp: 0.0, - window_duration_s: 1.0, - atlas: Atlas::DesikanKilliany68, - }; - - let result = ruv_neural_mincut::spectral_bisection(&graph).expect("spectral bisection failed"); - assert!(result.cut_value >= 0.0); - assert_eq!(result.partition_a.len() + result.partition_b.len(), 4); -}