diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/SECURITY_REVIEW.md b/rust-port/wifi-densepose-rs/crates/ruv-neural/SECURITY_REVIEW.md new file mode 100644 index 00000000..bc5a44db --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/SECURITY_REVIEW.md @@ -0,0 +1,570 @@ +# 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/Cargo.toml b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/Cargo.toml index 32aac55a..8d23b837 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/Cargo.toml +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/Cargo.toml @@ -1,9 +1,28 @@ [package] name = "ruv-neural-cli" -description = "rUv Neural — ruv-neural-cli (stub)" +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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/analyze.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/analyze.rs new file mode 100644 index 00000000..be05bfb5 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/analyze.rs @@ -0,0 +1,237 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/export.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/export.rs new file mode 100644 index 00000000..70ce84ff --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/export.rs @@ -0,0 +1,280 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/info.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/info.rs new file mode 100644 index 00000000..08aa1383 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/info.rs @@ -0,0 +1,66 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/mincut.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/mincut.rs new file mode 100644 index 00000000..bda78ec2 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/mincut.rs @@ -0,0 +1,184 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/mod.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/mod.rs new file mode 100644 index 00000000..fd788f94 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/mod.rs @@ -0,0 +1,8 @@ +//! CLI command implementations. + +pub mod analyze; +pub mod export; +pub mod info; +pub mod mincut; +pub mod pipeline; +pub mod simulate; diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/pipeline.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/pipeline.rs new file mode 100644 index 00000000..2f18a4c5 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/pipeline.rs @@ -0,0 +1,377 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/simulate.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/simulate.rs new file mode 100644 index 00000000..3ba9f788 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/commands/simulate.rs @@ -0,0 +1,156 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/lib.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/lib.rs deleted file mode 100644 index 6afb469e..00000000 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -//! Stub crate. diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/main.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/main.rs new file mode 100644 index 00000000..1d6b75a9 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-cli/src/main.rs @@ -0,0 +1,286 @@ +//! 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, +} + +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(()) + } + }; + + 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-core/README.md b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-core/README.md new file mode 100644 index 00000000..e1e688e3 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-core/README.md @@ -0,0 +1,73 @@ +# rUv Neural Core + +**Core types, traits, and error types for the ruv-neural brain topology analysis system.** + +`ruv-neural-core` is the foundational crate of the ruv-neural workspace. It defines all shared types, error variants, and trait interfaces that downstream crates implement. It has **zero internal dependencies** -- every other ruv-neural crate depends on this one. + +## Feature Flags + +| Feature | Default | Description | +|----------|---------|------------------------------------------| +| `std` | Yes | Standard library support | +| `no_std` | No | Embedded/ESP32 target compatibility | +| `wasm` | No | WebAssembly target support | +| `rvf` | No | RuVector RVF file format extensions | + +## Type Overview + +| Module | Key Types | +|-------------|------------------------------------------------------------------| +| `error` | `RuvNeuralError`, `Result` | +| `sensor` | `SensorType`, `SensorChannel`, `SensorArray` | +| `signal` | `MultiChannelTimeSeries`, `FrequencyBand`, `SpectralFeatures` | +| `brain` | `Atlas`, `BrainRegion`, `Hemisphere`, `Lobe`, `Parcellation` | +| `graph` | `BrainGraph`, `BrainEdge`, `ConnectivityMetric` | +| `topology` | `MincutResult`, `MultiPartition`, `CognitiveState`, `TopologyMetrics` | +| `embedding` | `NeuralEmbedding`, `EmbeddingMetadata`, `EmbeddingTrajectory` | +| `rvf` | `RvfFile`, `RvfHeader`, `RvfDataType` | + +## Trait Overview + +| Trait | Purpose | +|----------------------|------------------------------------------------| +| `SensorSource` | Read chunks from hardware or simulated sensors | +| `SignalProcessor` | Transform time series (filter, artifact removal)| +| `GraphConstructor` | Build connectivity graphs from signals | +| `TopologyAnalyzer` | Compute mincut, modularity, efficiency | +| `EmbeddingGenerator` | Map brain graphs to vector space | +| `StateDecoder` | Classify cognitive state from embeddings | +| `NeuralMemory` | Store and query embedding history | +| `RvfSerializable` | Serialize/deserialize to RVF file format | + +## Usage + +```rust +use ruv_neural_core::{ + Atlas, BrainGraph, BrainEdge, ConnectivityMetric, FrequencyBand, + NeuralEmbedding, EmbeddingMetadata, CognitiveState, + RvfFile, RvfDataType, + Result, +}; + +// Build a connectivity graph +let graph = BrainGraph { + num_nodes: 68, + edges: vec![BrainEdge { + source: 0, + target: 1, + weight: 0.85, + metric: ConnectivityMetric::PhaseLockingValue, + frequency_band: FrequencyBand::Alpha, + }], + timestamp: 1000.0, + window_duration_s: 2.0, + atlas: Atlas::DesikanKilliany68, +}; + +let adj = graph.adjacency_matrix(); +let density = graph.density(); +``` + +## License + +MIT OR Apache-2.0 diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-core/src/lib.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-core/src/lib.rs index ebf0c027..deea6ced 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-core/src/lib.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-core/src/lib.rs @@ -1,4 +1,24 @@ -//! rUv Neural Core — types, traits, and error types for brain topology analysis. +//! # 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; @@ -10,9 +30,616 @@ pub mod signal; pub mod topology; pub mod traits; +// 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 traits::SensorSource; +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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/Cargo.toml b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/Cargo.toml index e3d159ee..8fa00ce7 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/Cargo.toml +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/Cargo.toml @@ -13,8 +13,8 @@ wasm = [] [dependencies] ruv-neural-core = { workspace = true } -ruv-neural-embed = { workspace = true } -ruv-neural-memory = { 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 } diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/README.md b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/README.md new file mode 100644 index 00000000..efd3732f --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/README.md @@ -0,0 +1,86 @@ +# rUv Neural Decoder + +Cognitive state classification and BCI decoding from neural topology embeddings. + +Part of the **rUv Neural** brain-computer interface platform. + +## Decoders + +| Decoder | Description | +|---------|-------------| +| **KnnDecoder** | K-nearest neighbor classification using stored labeled embeddings with inverse-distance weighting | +| **ThresholdDecoder** | Rule-based classification from topology metric ranges (mincut, modularity, efficiency, entropy) | +| **TransitionDecoder** | Detects cognitive state transitions by matching topology delta patterns against a sliding window | +| **ClinicalScorer** | Biomarker detection via z-score deviation from a learned healthy baseline population | +| **DecoderPipeline** | End-to-end ensemble combining all decoders with configurable weights and clinical scoring | + +## Pipeline Architecture + +``` +NeuralEmbedding ──> KnnDecoder ─────────┐ + │ +TopologyMetrics ──> ThresholdDecoder ────┤── Weighted Vote ──> DecoderOutput + │ │ state, confidence + ├─> TransitionDecoder ──┘ transition + │ brain_health_index + └─> ClinicalScorer ─────────> clinical_flags +``` + +## Usage + +```rust +use ruv_neural_decoder::{DecoderPipeline, TopologyThreshold}; +use ruv_neural_core::topology::{CognitiveState, TopologyMetrics}; + +// Build a pipeline with all decoders +let mut pipeline = DecoderPipeline::new() + .with_knn(5) + .with_thresholds() + .with_transitions(10) + .with_clinical(baseline_metrics, baseline_std); + +// Train the KNN decoder +pipeline.knn_mut().unwrap().train(labeled_embeddings); + +// Configure threshold ranges +pipeline.threshold_mut().unwrap().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), + }, +); + +// Decode +let output = pipeline.decode(&embedding, &metrics); +println!("State: {:?} (confidence: {:.2})", output.state, output.confidence); + +if let Some(health) = output.brain_health_index { + println!("Brain health: {:.2}", health); +} +for flag in &output.clinical_flags { + println!("WARNING: {}", flag); +} +``` + +## Clinical Applications + +The `ClinicalScorer` provides research-grade biomarker detection for: + +- **Alzheimer's disease**: Detects network fragmentation (reduced efficiency, increased modularity, reduced mincut) +- **Epilepsy**: Detects hypersynchrony (increased mincut, decreased modularity, increased local efficiency) +- **Depression**: Detects connectivity weakening (reduced efficiency, reduced Fiedler value, altered entropy) +- **Brain Health Index**: Composite score from 0 (severe abnormality) to 1 (healthy baseline) + +**Note**: These scores are intended for research use only. Clinical diagnosis requires professional medical evaluation. + +## Features + +- `std` (default) — Standard library support +- `wasm` — WebAssembly target support + +## License + +MIT OR Apache-2.0 diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/src/clinical.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/src/clinical.rs new file mode 100644 index 00000000..c844c6c5 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/src/clinical.rs @@ -0,0 +1,357 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/src/pipeline.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/src/pipeline.rs new file mode 100644 index 00000000..31779b31 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/src/pipeline.rs @@ -0,0 +1,369 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/src/transition_decoder.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/src/transition_decoder.rs index 352f6882..9d4cf2b8 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/src/transition_decoder.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-decoder/src/transition_decoder.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use ruv_neural_core::topology::{CognitiveState, TopologyMetrics}; +use serde::{Deserialize, Serialize}; /// Detect cognitive state transitions from topology change patterns. /// @@ -27,7 +28,7 @@ pub struct TransitionPattern { } /// A detected state transition. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct StateTransition { /// State before the transition. pub from: CognitiveState, @@ -235,14 +236,24 @@ mod tests { }, ); - // Feed metrics that match the pattern. - let _ = decoder.update(make_metrics(5.0, 0.4, 0.0)); - let _ = decoder.update(make_metrics(6.0, 0.45, 0.5)); - let _ = decoder.update(make_metrics(7.0, 0.5, 1.0)); - let result = decoder.update(make_metrics(8.0, 0.6, 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), + ]; - assert!(result.is_some(), "Expected a transition to be detected"); - let transition = result.unwrap(); + 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); diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/README.md b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/README.md new file mode 100644 index 00000000..f3211477 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/README.md @@ -0,0 +1,75 @@ +# 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. +Multiple embedding strategies are provided, each capturing different aspects of graph structure. + +## Embedding Methods + +| Method | Module | Description | Output Dimension | +|--------|--------|-------------|-----------------| +| **Spectral** | `spectral_embed` | Laplacian eigenvector positional encoding | `k * 4` (mean/std/min/max per eigenvector) | +| **Topology** | `topology_embed` | Hand-crafted topological feature vector | 13 (with all features enabled) | +| **Node2Vec** | `node2vec` | Random-walk co-occurrence SVD embedding | `dim * 2` (mean/std per component) | +| **Combined** | `combined` | Weighted concatenation of multiple methods | Sum of sub-embedder dimensions | +| **Temporal** | `temporal` | Sliding-window context-enriched embedding | `base_dim * 2` (current + context) | + +## Distance Metrics + +| Metric | Function | Description | +|--------|----------|-------------| +| Cosine Similarity | `cosine_similarity` | Direction similarity in [-1, 1] | +| Euclidean Distance | `euclidean_distance` | L2 norm of difference | +| Manhattan Distance | `manhattan_distance` | L1 norm of difference | +| k-Nearest Neighbors | `k_nearest` | Find k closest embeddings | +| Trajectory Distance | `trajectory_distance` | DTW alignment cost for sequences | + +## Usage + +```rust +use ruv_neural_embed::spectral_embed::SpectralEmbedder; +use ruv_neural_embed::topology_embed::TopologyEmbedder; +use ruv_neural_embed::combined::CombinedEmbedder; +use ruv_neural_embed::distance::{cosine_similarity, k_nearest}; +use ruv_neural_core::traits::EmbeddingGenerator; + +// Single-method embedding +let spectral = SpectralEmbedder::new(4); +let embedding = spectral.embed(&brain_graph).unwrap(); + +// Combined multi-method embedding +let combined = CombinedEmbedder::new() + .add(Box::new(SpectralEmbedder::new(4)), 1.0) + .add(Box::new(TopologyEmbedder::new()), 0.5); +let combined_emb = combined.embed(&brain_graph).unwrap(); + +// Compare embeddings +let sim = cosine_similarity(&emb_a, &emb_b); +let neighbors = k_nearest(&query, &candidates, 5); +``` + +## RVF Export + +```rust +use ruv_neural_embed::rvf_export::{export_rvf, import_rvf}; + +// Save embeddings +export_rvf(&embeddings, "brain_states.rvf").unwrap(); + +// Load embeddings +let restored = import_rvf("brain_states.rvf").unwrap(); +``` + +## Features + +- `std` (default) -- Standard library support +- `wasm` -- WebAssembly compatibility +- `rvf` -- Extended RVF format support + +## License + +MIT OR Apache-2.0 diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/combined.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/combined.rs index 3fc2259b..09e15f33 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/combined.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/combined.rs @@ -3,10 +3,12 @@ //! 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::{EmbeddingGenerator, NeuralEmbedding}; +use crate::default_metadata; /// Combines multiple embedding methods into a single embedding vector. pub struct CombinedEmbedder { @@ -39,11 +41,11 @@ impl CombinedEmbedder { /// Total embedding dimension (sum of all sub-embedder dimensions). pub fn total_dimension(&self) -> usize { - self.embedders.iter().map(|e| e.dimension()).sum() + self.embedders.iter().map(|e| e.embedding_dim()).sum() } /// Generate a combined embedding by concatenating weighted sub-embeddings. - pub fn embed(&self, graph: &BrainGraph) -> Result { + pub fn embed_graph(&self, graph: &BrainGraph) -> Result { if self.embedders.is_empty() { return Err(RuvNeuralError::Embedding( "CombinedEmbedder has no sub-embedders".into(), @@ -54,12 +56,13 @@ impl CombinedEmbedder { for (embedder, &weight) in self.embedders.iter().zip(self.weights.iter()) { let sub_emb = embedder.embed(graph)?; - for v in &sub_emb.values { + for v in &sub_emb.vector { values.push(v * weight); } } - NeuralEmbedding::new(values, graph.timestamp, "combined") + let meta = default_metadata("combined", graph.atlas); + NeuralEmbedding::new(values, graph.timestamp, meta) } } @@ -70,16 +73,12 @@ impl Default for CombinedEmbedder { } impl EmbeddingGenerator for CombinedEmbedder { - fn dimension(&self) -> usize { + fn embedding_dim(&self) -> usize { self.total_dimension() } - fn method_name(&self) -> &str { - "combined" - } - fn embed(&self, graph: &BrainGraph) -> Result { - CombinedEmbedder::embed(self, graph) + self.embed_graph(graph) } } @@ -137,8 +136,8 @@ mod tests { let spectral = SpectralEmbedder::new(2); let topo = TopologyEmbedder::new(); - let spectral_dim = spectral.dimension(); - let topo_dim = topo.dimension(); + let spectral_dim = spectral.embedding_dim(); + let topo_dim = topo.embedding_dim(); let combined = CombinedEmbedder::new() .add(Box::new(spectral), 1.0) @@ -148,7 +147,7 @@ mod tests { let emb = combined.embed(&graph).unwrap(); assert_eq!(emb.dimension, spectral_dim + topo_dim); - assert_eq!(emb.method, "combined"); + assert_eq!(emb.metadata.embedding_method, "combined"); } #[test] @@ -156,15 +155,13 @@ mod tests { let graph = make_test_graph(); let topo = TopologyEmbedder::new(); - // Weight = 2.0 let combined = CombinedEmbedder::new().add(Box::new(topo), 2.0); let emb = combined.embed(&graph).unwrap(); - // Compare with direct topology embedding let topo2 = TopologyEmbedder::new(); let direct = topo2.embed(&graph).unwrap(); - for (c, d) in emb.values.iter().zip(direct.values.iter()) { + 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*{}", diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/distance.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/distance.rs index 4a602af8..b0644487 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/distance.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/distance.rs @@ -1,9 +1,9 @@ -//! Embedding distance and similarity metrics. +//! 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 crate::{EmbeddingTrajectory, NeuralEmbedding}; +use ruv_neural_core::embedding::{EmbeddingTrajectory, NeuralEmbedding}; /// Cosine similarity between two embeddings. /// @@ -12,7 +12,7 @@ use crate::{EmbeddingTrajectory, NeuralEmbedding}; /// /// Returns 0.0 if either embedding has zero norm. pub fn cosine_similarity(a: &NeuralEmbedding, b: &NeuralEmbedding) -> f64 { - let len = a.values.len().min(b.values.len()); + let len = a.vector.len().min(b.vector.len()); if len == 0 { return 0.0; } @@ -22,9 +22,9 @@ pub fn cosine_similarity(a: &NeuralEmbedding, b: &NeuralEmbedding) -> f64 { let mut norm_b = 0.0; for i in 0..len { - dot += a.values[i] * b.values[i]; - norm_a += a.values[i] * a.values[i]; - norm_b += b.values[i] * b.values[i]; + 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(); @@ -40,14 +40,14 @@ pub fn cosine_similarity(a: &NeuralEmbedding, b: &NeuralEmbedding) -> f64 { /// If the embeddings have different dimensions, only the overlapping /// portion is compared. pub fn euclidean_distance(a: &NeuralEmbedding, b: &NeuralEmbedding) -> f64 { - let len = a.values.len().min(b.values.len()); + 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.values[i] - b.values[i]; + let diff = a.vector[i] - b.vector[i]; sum_sq += diff * diff; } @@ -56,10 +56,10 @@ pub fn euclidean_distance(a: &NeuralEmbedding, b: &NeuralEmbedding) -> f64 { /// Manhattan (L1) distance between two embeddings. pub fn manhattan_distance(a: &NeuralEmbedding, b: &NeuralEmbedding) -> f64 { - let len = a.values.len().min(b.values.len()); + let len = a.vector.len().min(b.vector.len()); let mut sum = 0.0; for i in 0..len { - sum += (a.values[i] - b.values[i]).abs(); + sum += (a.vector[i] - b.vector[i]).abs(); } sum } @@ -97,7 +97,6 @@ pub fn trajectory_distance(a: &EmbeddingTrajectory, b: &EmbeddingTrajectory) -> return f64::INFINITY; } - // DTW cost matrix let mut dtw = vec![vec![f64::INFINITY; m + 1]; n + 1]; dtw[0][0] = 0.0; @@ -117,10 +116,13 @@ pub fn trajectory_distance(a: &EmbeddingTrajectory, b: &EmbeddingTrajectory) -> #[cfg(test)] mod tests { use super::*; - use crate::NeuralEmbedding; + use crate::default_metadata; + use ruv_neural_core::brain::Atlas; + use ruv_neural_core::embedding::NeuralEmbedding; fn emb(values: Vec) -> NeuralEmbedding { - NeuralEmbedding::new(values, 0.0, "test").unwrap() + let meta = default_metadata("test", Atlas::Custom(1)); + NeuralEmbedding::new(values, 0.0, meta).unwrap() } #[test] @@ -128,7 +130,10 @@ mod tests { 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"); + assert!( + (sim - 1.0).abs() < 1e-10, + "Identical embeddings: cos sim should be 1.0" + ); } #[test] @@ -136,7 +141,10 @@ mod tests { 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"); + assert!( + sim.abs() < 1e-10, + "Orthogonal embeddings: cos sim should be 0.0" + ); } #[test] @@ -144,7 +152,10 @@ mod tests { 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"); + assert!( + (sim + 1.0).abs() < 1e-10, + "Opposite embeddings: cos sim should be -1.0" + ); } #[test] @@ -152,7 +163,10 @@ mod tests { 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"); + assert!( + dist.abs() < 1e-10, + "Identical embeddings: distance should be 0.0" + ); } #[test] @@ -175,7 +189,6 @@ mod tests { let nearest = k_nearest(&query, &candidates, 2); assert_eq!(nearest.len(), 2); - // Closest should be index 3 (0.5, 0.5), then index 1 (1.0, 0.0) assert_eq!(nearest[0].0, 3); assert_eq!(nearest[1].0, 1); } @@ -192,35 +205,41 @@ mod tests { fn test_trajectory_distance_identical() { let traj = EmbeddingTrajectory { embeddings: vec![emb(vec![1.0, 2.0]), emb(vec![3.0, 4.0])], - step_s: 0.5, + 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"); + 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])], - step_s: 0.5, + timestamps: vec![0.0, 0.5], }; let b = EmbeddingTrajectory { embeddings: vec![emb(vec![0.0, 0.0]), emb(vec![0.0, 1.0])], - step_s: 0.5, + timestamps: vec![0.0, 0.5], }; let dist = trajectory_distance(&a, &b); - assert!(dist > 0.0, "Different trajectories should have non-zero DTW distance"); + assert!( + dist > 0.0, + "Different trajectories should have non-zero DTW distance" + ); } #[test] fn test_trajectory_distance_empty() { let a = EmbeddingTrajectory { embeddings: vec![], - step_s: 0.5, + timestamps: vec![], }; let b = EmbeddingTrajectory { embeddings: vec![emb(vec![1.0])], - step_s: 0.5, + timestamps: vec![0.0], }; let dist = trajectory_distance(&a, &b); assert!(dist.is_infinite()); diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/lib.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/lib.rs index 8a5fb52c..ebfde321 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/lib.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/lib.rs @@ -25,152 +25,76 @@ pub mod spectral_embed; pub mod temporal; pub mod topology_embed; -use ruv_neural_core::error::{Result, RuvNeuralError}; -use ruv_neural_core::graph::{BrainGraph, BrainGraphSequence}; -use serde::{Deserialize, Serialize}; +// 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; -/// A fixed-dimensional embedding of a brain graph. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NeuralEmbedding { - /// The embedding vector. - pub values: Vec, - /// Dimensionality of the embedding. - pub dimension: usize, - /// Timestamp of the source graph (Unix seconds). - pub timestamp: f64, - /// Name of the method that produced this embedding. - pub method: String, - /// Optional metadata (e.g., parameters used). - pub metadata: Option, -} - -impl NeuralEmbedding { - /// Create a new embedding, validating dimension consistency. - pub fn new(values: Vec, timestamp: f64, method: &str) -> Result { - let dimension = values.len(); - if dimension == 0 { - return Err(RuvNeuralError::Embedding( - "Embedding must have at least one dimension".into(), - )); - } - Ok(Self { - values, - dimension, - timestamp, - method: method.to_string(), - metadata: None, - }) +/// 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(), } - - /// Create a zero embedding of a given dimension. - pub fn zeros(dimension: usize, timestamp: f64, method: &str) -> Self { - Self { - values: vec![0.0; dimension], - dimension, - timestamp, - method: method.to_string(), - metadata: None, - } - } - - /// L2 norm of the embedding vector. - pub fn norm(&self) -> f64 { - self.values.iter().map(|v| v * v).sum::().sqrt() - } - - /// Normalize the embedding to unit length (in-place). - pub fn normalize(&mut self) { - let n = self.norm(); - if n > 1e-12 { - for v in &mut self.values { - *v /= n; - } - } - } - - /// Return a normalized copy. - pub fn normalized(&self) -> Self { - let mut copy = self.clone(); - copy.normalize(); - copy - } -} - -/// A temporal sequence of embeddings (one per graph in a sequence). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmbeddingTrajectory { - /// Ordered embeddings. - pub embeddings: Vec, - /// Time step between successive embeddings in seconds. - pub step_s: f64, -} - -impl EmbeddingTrajectory { - /// Number of time points in the trajectory. - pub fn len(&self) -> usize { - self.embeddings.len() - } - - /// Whether the trajectory is empty. - pub fn is_empty(&self) -> bool { - self.embeddings.is_empty() - } - - /// Total duration of the trajectory in seconds. - pub fn duration_s(&self) -> f64 { - if self.embeddings.len() < 2 { - return 0.0; - } - (self.embeddings.len() - 1) as f64 * self.step_s - } -} - -/// Trait for types that generate embeddings from brain graphs. -pub trait EmbeddingGenerator: Send + Sync { - /// Embedding dimensionality produced by this generator. - fn dimension(&self) -> usize; - - /// Name of the embedding method. - fn method_name(&self) -> &str; - - /// Generate an embedding from a brain graph. - fn embed(&self, graph: &BrainGraph) -> Result; } #[cfg(test)] mod tests { use super::*; + use ruv_neural_core::brain::Atlas; #[test] fn test_neural_embedding_new() { - let emb = NeuralEmbedding::new(vec![1.0, 2.0, 3.0], 0.0, "test").unwrap(); + 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.values.len(), 3); + assert_eq!(emb.vector.len(), 3); } #[test] fn test_neural_embedding_empty_fails() { - let result = NeuralEmbedding::new(vec![], 0.0, "test"); + let meta = default_metadata("test", Atlas::Custom(1)); + let result = NeuralEmbedding::new(vec![], 0.0, meta); assert!(result.is_err()); } #[test] - fn test_normalize() { - let mut emb = NeuralEmbedding::new(vec![3.0, 4.0], 0.0, "test").unwrap(); - emb.normalize(); - let norm = emb.norm(); - assert!((norm - 1.0).abs() < 1e-10); + 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::zeros(4, 0.0, "test"), - NeuralEmbedding::zeros(4, 0.5, "test"), - NeuralEmbedding::zeros(4, 1.0, "test"), + 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(), ], - step_s: 0.5, + 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/node2vec.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/node2vec.rs index 8b0ca1f0..5eb97dcd 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/node2vec.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/node2vec.rs @@ -4,11 +4,14 @@ //! matrix. The graph-level embedding is obtained via SVD of the co-occurrence //! matrix (a simplified skip-gram approximation). -use rand::Rng; +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::{EmbeddingGenerator, NeuralEmbedding}; +use crate::default_metadata; /// Node2Vec-style graph embedder using biased random walks. pub struct Node2VecEmbedder { @@ -40,11 +43,13 @@ impl Node2VecEmbedder { } /// Perform a single biased random walk starting from `start`. - fn random_walk(&self, graph: &BrainGraph, adj: &[Vec], start: usize) -> Vec { - let n = graph.num_nodes; - let mut rng = rand::rngs::StdRng::seed_from_u64( - self.seed.wrapping_add(start as u64), - ); + 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); @@ -52,7 +57,7 @@ impl Node2VecEmbedder { return walk; } - // First step: uniform over neighbors + // 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])) @@ -89,14 +94,13 @@ impl Node2VecEmbedder { break; } - // Compute biased weights 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 // neighbor of previous node + 1.0 } else { 1.0 / self.q }; @@ -125,12 +129,12 @@ impl Node2VecEmbedder { } /// Generate all random walks from all nodes. - fn generate_walks(&self, graph: &BrainGraph, adj: &[Vec]) -> Vec> { - let n = graph.num_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(graph, adj, node)); + all_walks.push(self.random_walk(adj, n, node, &mut rng)); } } all_walks @@ -153,18 +157,16 @@ impl Node2VecEmbedder { cooc } - /// Simplified SVD via power iteration: extract top-k singular vectors. - /// Returns left singular vectors scaled by singular values. + /// 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::with_capacity(k); + let mut result: Vec> = Vec::with_capacity(k); for col in 0..k { - // Initialize deterministically 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 { @@ -173,28 +175,26 @@ impl Node2VecEmbedder { } } - // Deflate previously found components + // Deflate for prev in &result { - let dot: f64 = v.iter().zip(prev.iter()).map(|(a, b)| a * b).sum(); let prev_norm: f64 = prev.iter().map(|x| x * x).sum::().sqrt(); if prev_norm > 1e-12 { - let prev_normalized: Vec = prev.iter().map(|x| x / prev_norm).collect(); + 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_norm * prev_normalized[i]; + v[i] -= dot * prev_unit[i]; } } } // Power iteration on M^T M for _ in 0..100 { - // u = M * v let mut u = vec![0.0; n]; for i in 0..n { for j in 0..n { u[i] += matrix[i][j] * v[j]; } } - // v = M^T * u let mut new_v = vec![0.0; n]; for j in 0..n { for i in 0..n { @@ -206,15 +206,14 @@ impl Node2VecEmbedder { for prev in &result { let prev_norm: f64 = prev.iter().map(|x| x * x).sum::().sqrt(); if prev_norm > 1e-12 { - let prev_normalized: Vec = - prev.iter().map(|x| x / prev_norm).collect(); + let prev_unit: Vec = prev.iter().map(|x| x / prev_norm).collect(); let dot: f64 = new_v .iter() - .zip(prev_normalized.iter()) + .zip(prev_unit.iter()) .map(|(a, b)| a * b) .sum(); for i in 0..n { - new_v[i] -= dot * prev_normalized[i]; + new_v[i] -= dot * prev_unit[i]; } } } @@ -229,29 +228,22 @@ impl Node2VecEmbedder { v = new_v; } - // Compute the singular value: sigma = ||M * 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]; } } - let sigma = mv.iter().map(|x| x * x).sum::().sqrt(); - // Store u * sigma (the left singular vector scaled by singular value) - if sigma > 1e-12 { - let scaled: Vec = mv.iter().map(|x| *x).collect(); - result.push(scaled); - } else { - result.push(vec![0.0; n]); - } + result.push(mv); } result } /// Generate the Node2Vec embedding for a brain graph. - pub fn embed(&self, graph: &BrainGraph) -> Result { + pub fn embed_graph(&self, graph: &BrainGraph) -> Result { let n = graph.num_nodes; if n < 2 { return Err(RuvNeuralError::Embedding( @@ -260,21 +252,19 @@ impl Node2VecEmbedder { } let adj = graph.adjacency_matrix(); - let walks = self.generate_walks(graph, &adj); + let walks = self.generate_walks(&adj, n); let cooc = Self::build_cooccurrence(&walks, n, 5); - // Apply log transform (PPMI-like): log(1 + cooc) + // Log transform (PPMI-like) let log_cooc: Vec> = cooc .iter() .map(|row| row.iter().map(|&v| (1.0 + v).ln()).collect()) .collect(); - // SVD to get node embeddings let dim = self.embedding_dim.min(n); let node_embeddings = Self::truncated_svd(&log_cooc, n, dim); - // Aggregate node embeddings into a graph-level embedding. - // For each SVD component: [mean, std] over nodes. + // 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; @@ -283,32 +273,25 @@ impl Node2VecEmbedder { values.push(var.sqrt()); } - // Pad if needed while values.len() < self.embedding_dim * 2 { values.push(0.0); } - NeuralEmbedding::new(values, graph.timestamp, "node2vec") + let meta = default_metadata("node2vec", graph.atlas); + NeuralEmbedding::new(values, graph.timestamp, meta) } } impl EmbeddingGenerator for Node2VecEmbedder { - fn dimension(&self) -> usize { + fn embedding_dim(&self) -> usize { self.embedding_dim * 2 } - fn method_name(&self) -> &str { - "node2vec" - } - fn embed(&self, graph: &BrainGraph) -> Result { - Node2VecEmbedder::embed(self, graph) + self.embed_graph(graph) } } -// We need the StdRng import -use rand::SeedableRng; - #[cfg(test)] mod tests { use super::*; @@ -317,7 +300,6 @@ mod tests { use ruv_neural_core::signal::FrequencyBand; fn make_connected_graph() -> BrainGraph { - // A connected path graph: 0-1-2-3-4 let edges: Vec = (0..4) .map(|i| BrainEdge { source: i, @@ -349,9 +331,8 @@ mod tests { }; let adj = graph.adjacency_matrix(); - let walks = embedder.generate_walks(&graph, &adj); + let walks = embedder.generate_walks(&adj, graph.num_nodes); - // Collect all visited nodes across all walks let mut visited = std::collections::HashSet::new(); for walk in &walks { for &node in walk { @@ -359,7 +340,6 @@ mod tests { } } - // All 5 nodes should be visited (since each node starts a walk) assert_eq!(visited.len(), 5, "All nodes should be visited"); } @@ -368,8 +348,8 @@ mod tests { let graph = make_connected_graph(); let embedder = Node2VecEmbedder::new(3); let emb = embedder.embed(&graph).unwrap(); - assert_eq!(emb.dimension, 3 * 2); // mean + std per component - assert_eq!(emb.method, "node2vec"); + assert_eq!(emb.dimension, 3 * 2); + assert_eq!(emb.metadata.embedding_method, "node2vec"); } #[test] diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/rvf_export.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/rvf_export.rs new file mode 100644 index 00000000..7eafd023 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/rvf_export.rs @@ -0,0 +1,210 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/spectral_embed.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/spectral_embed.rs index d4612fea..2b9cf9e8 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/spectral_embed.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/spectral_embed.rs @@ -4,10 +4,12 @@ //! 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::{EmbeddingGenerator, NeuralEmbedding}; +use crate::default_metadata; /// Spectral embedding via Laplacian eigenvectors. pub struct SpectralEmbedder { @@ -30,12 +32,8 @@ impl SpectralEmbedder { /// Compute the normalized Laplacian matrix: L_norm = I - D^{-1/2} A D^{-1/2}. fn normalized_laplacian(adj: &[Vec], n: usize) -> Vec> { - // Degree vector - let degrees: Vec = (0..n) - .map(|i| adj[i].iter().sum::()) - .collect(); + let degrees: Vec = (0..n).map(|i| adj[i].iter().sum::()).collect(); - // D^{-1/2} let inv_sqrt_deg: Vec = degrees .iter() .map(|d| if *d > 1e-12 { 1.0 / d.sqrt() } else { 0.0 }) @@ -69,7 +67,7 @@ impl SpectralEmbedder { } let k = k.min(n); - // Estimate max eigenvalue via Gershgorin bound (for shift) + // Gershgorin bound for max eigenvalue let max_eig: f64 = (0..n) .map(|i| { let diag = laplacian[i][i]; @@ -82,7 +80,6 @@ impl SpectralEmbedder { .fold(0.0_f64, f64::max); // Shifted matrix: M = max_eig * I - L - // Largest eigenvectors of M correspond to smallest eigenvectors of L let shifted: Vec> = (0..n) .map(|i| { (0..n) @@ -100,7 +97,6 @@ impl SpectralEmbedder { let mut eigenvectors: Vec> = Vec::with_capacity(k); for _ev in 0..k { - // Initialize with a deterministic vector 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 { @@ -109,7 +105,7 @@ impl SpectralEmbedder { } } - // Deflate: remove components along already-found eigenvectors + // 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 { @@ -117,9 +113,7 @@ impl SpectralEmbedder { } } - // Power iteration for _ in 0..iterations { - // Matrix-vector multiply: w = M * v let mut w = vec![0.0; n]; for i in 0..n { for j in 0..n { @@ -127,7 +121,6 @@ impl SpectralEmbedder { } } - // Deflate for prev in &eigenvectors { let dot: f64 = w.iter().zip(prev.iter()).map(|(a, b)| a * b).sum(); for i in 0..n { @@ -135,7 +128,6 @@ impl SpectralEmbedder { } } - // Normalize let norm = w.iter().map(|x| x * x).sum::().sqrt(); if norm < 1e-12 { break; @@ -153,7 +145,7 @@ impl SpectralEmbedder { } /// Embed a brain graph using spectral decomposition. - pub fn embed(&self, graph: &BrainGraph) -> Result { + pub fn embed_graph(&self, graph: &BrainGraph) -> Result { let n = graph.num_nodes; if n < 2 { return Err(RuvNeuralError::Embedding( @@ -164,16 +156,14 @@ impl SpectralEmbedder { let adj = graph.adjacency_matrix(); let laplacian = Self::normalized_laplacian(&adj, n); - // We skip the first eigenvector (trivial constant) and take the next `dimension` + // 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); - // Skip the first (trivial) eigenvector and take up to `dimension` let useful: Vec<&Vec> = eigvecs.iter().skip(1).take(self.dimension).collect(); - // Build graph-level embedding from per-node spectral coordinates. - // For each eigenvector: [mean, std, min, max] -> 4 features per eigenvector. + // 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; @@ -187,26 +177,23 @@ impl SpectralEmbedder { values.push(max); } - // Pad if we got fewer eigenvectors than requested + // Pad if fewer eigenvectors than requested while values.len() < self.dimension * 4 { values.push(0.0); } - NeuralEmbedding::new(values, graph.timestamp, "spectral") + let meta = default_metadata("spectral", graph.atlas); + NeuralEmbedding::new(values, graph.timestamp, meta) } } impl EmbeddingGenerator for SpectralEmbedder { - fn dimension(&self) -> usize { + fn embedding_dim(&self) -> usize { self.dimension * 4 } - fn method_name(&self) -> &str { - "spectral" - } - fn embed(&self, graph: &BrainGraph) -> Result { - SpectralEmbedder::embed(self, graph) + self.embed_graph(graph) } } @@ -240,9 +227,8 @@ mod tests { } fn make_two_cluster_graph() -> BrainGraph { - // Two dense clusters of 4 nodes each, with a weak link between them let mut edges = Vec::new(); - // Cluster A: nodes 0-3 + // Cluster A: nodes 0-3 (fully connected) for i in 0..4 { for j in (i + 1)..4 { edges.push(BrainEdge { @@ -254,7 +240,7 @@ mod tests { }); } } - // Cluster B: nodes 4-7 + // Cluster B: nodes 4-7 (fully connected) for i in 4..8 { for j in (i + 1)..8 { edges.push(BrainEdge { @@ -288,9 +274,6 @@ mod tests { let graph = make_complete_graph(6); let embedder = SpectralEmbedder::new(3); let emb = embedder.embed(&graph).unwrap(); - // Complete graph: all eigenvectors beyond the first are degenerate - // All node positions should be similar -> low std deviation - // Check that the embedding has the expected dimension assert_eq!(emb.dimension, 3 * 4); } @@ -299,9 +282,8 @@ mod tests { let graph = make_two_cluster_graph(); let embedder = SpectralEmbedder::new(2); let emb = embedder.embed(&graph).unwrap(); - // The Fiedler vector (first non-trivial eigenvector) should separate the two clusters. - // This means the std of the first eigenvector component should be non-negligible. - let fiedler_std = emb.values[1]; // index 1 is std of first eigenvector + // 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={}", diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/temporal.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/temporal.rs index 781d8d17..e22dd985 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/temporal.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/temporal.rs @@ -1,12 +1,14 @@ -//! Temporal embedding of brain graph sequences. +//! 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::{EmbeddingGenerator, EmbeddingTrajectory, NeuralEmbedding}; +use crate::default_metadata; /// Temporal embedder that enriches each graph embedding with historical context. pub struct TemporalEmbedder { @@ -47,16 +49,18 @@ impl TemporalEmbedder { 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, - step_s: sequence.window_step_s, + timestamps, }) } @@ -75,12 +79,12 @@ impl TemporalEmbedder { let context = self.compute_context(history, base_dim); - // Concatenate current embedding with context let mut values = Vec::with_capacity(base_dim * 2); - values.extend_from_slice(¤t.values); + values.extend_from_slice(¤t.vector); values.extend_from_slice(&context); - NeuralEmbedding::new(values, graph.timestamp, "temporal") + let meta = default_metadata("temporal", graph.atlas); + NeuralEmbedding::new(values, graph.timestamp, meta) } /// Compute the exponentially-weighted context vector from history. @@ -104,7 +108,7 @@ impl TemporalEmbedder { total_weight += w; let usable_dim = dim.min(emb.dimension); for j in 0..usable_dim { - context[j] += w * emb.values[j]; + context[j] += w * emb.vector[j]; } } @@ -119,7 +123,7 @@ impl TemporalEmbedder { /// Output dimension: base dimension * 2 (current + context). pub fn output_dimension(&self) -> usize { - self.base_embedder.dimension() * 2 + self.base_embedder.embedding_dim() * 2 } } @@ -162,13 +166,12 @@ mod tests { let graph = make_graph(0.0); let emb = embedder.embed_with_context(&graph, &[]).unwrap(); - let base_dim = TopologyEmbedder::new().dimension(); + let base_dim = TopologyEmbedder::new().embedding_dim(); assert_eq!(emb.dimension, base_dim * 2); - // Context part should be all zeros for i in base_dim..emb.dimension { assert!( - emb.values[i].abs() < 1e-12, + emb.vector[i].abs() < 1e-12, "Context should be zero with no history" ); } @@ -186,17 +189,20 @@ mod tests { let trajectory = embedder.embed_sequence(&sequence).unwrap(); assert_eq!(trajectory.len(), 3); - assert!((trajectory.step_s - 0.5).abs() < 1e-10); + assert_eq!(trajectory.timestamps.len(), 3); - // First embedding should have zero context - let base_dim = TopologyEmbedder::new().dimension(); + let base_dim = TopologyEmbedder::new().embedding_dim(); for i in base_dim..trajectory.embeddings[0].dimension { - assert!(trajectory.embeddings[0].values[i].abs() < 1e-12); + assert!(trajectory.embeddings[0].vector[i].abs() < 1e-12); } - // Later embeddings should have non-zero context (since graphs are non-trivial) - let has_nonzero = trajectory.embeddings[2].values[base_dim..].iter().any(|v| v.abs() > 1e-12); - assert!(has_nonzero, "Third embedding should have non-zero temporal context"); + 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] diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/topology_embed.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/topology_embed.rs index 037e7bc4..c620f4a3 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/topology_embed.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-embed/src/topology_embed.rs @@ -3,10 +3,12 @@ //! 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::{EmbeddingGenerator, NeuralEmbedding}; +use crate::default_metadata; /// Topology-based embedder: converts a brain graph into a vector of topological features. pub struct TopologyEmbedder { @@ -31,7 +33,7 @@ impl TopologyEmbedder { } } - /// Estimate global minimum cut via the minimum node degree (Stoer-Wagner lower bound). + /// Estimate global minimum cut via the minimum node degree. fn estimate_mincut(graph: &BrainGraph) -> f64 { if graph.num_nodes < 2 { return 0.0; @@ -42,7 +44,6 @@ impl TopologyEmbedder { } /// Estimate modularity using a simple greedy two-partition. - /// This is a simplified Newman modularity approximation. fn estimate_modularity(graph: &BrainGraph) -> f64 { let n = graph.num_nodes; if n < 2 { @@ -56,7 +57,6 @@ impl TopologyEmbedder { let adj = graph.adjacency_matrix(); let degrees: Vec = (0..n).map(|i| graph.node_degree(i)).collect(); - // Simple partition: split by median degree 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()); @@ -67,7 +67,6 @@ impl TopologyEmbedder { partition[node] = if rank < mid { 1 } else { -1 }; } - // Q = (1/2m) * sum_ij [ A_ij - k_i*k_j/(2m) ] * delta(c_i, c_j) let two_m = 2.0 * total_weight; let mut q = 0.0; for i in 0..n { @@ -81,7 +80,6 @@ impl TopologyEmbedder { } /// Compute global efficiency: average of 1/shortest_path for all node pairs. - /// Uses BFS on the unweighted adjacency structure. fn global_efficiency(graph: &BrainGraph) -> f64 { let n = graph.num_nodes; if n < 2 { @@ -92,7 +90,6 @@ impl TopologyEmbedder { let mut sum_inv_dist = 0.0; for source in 0..n { - // BFS from source let mut dist = vec![usize::MAX; n]; dist[source] = 0; let mut queue = std::collections::VecDeque::new(); @@ -117,7 +114,7 @@ impl TopologyEmbedder { sum_inv_dist / (n * (n - 1)) as f64 } - /// Compute mean local efficiency (average efficiency of each node's neighborhood). + /// Compute mean local efficiency. fn local_efficiency(graph: &BrainGraph) -> f64 { let n = graph.num_nodes; if n == 0 { @@ -136,12 +133,11 @@ impl TopologyEmbedder { continue; } - // Efficiency within the subgraph of neighbors 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; // direct connection -> distance 1 + sub_sum += 1.0; } } } @@ -172,7 +168,6 @@ impl TopologyEmbedder { } /// Estimate the Fiedler value (algebraic connectivity). - /// Uses simplified power iteration on the Laplacian. fn estimate_fiedler(graph: &BrainGraph) -> f64 { let n = graph.num_nodes; if n < 2 { @@ -182,7 +177,6 @@ impl TopologyEmbedder { let adj = graph.adjacency_matrix(); let degrees: Vec = (0..n).map(|i| adj[i].iter().sum::()).collect(); - // Laplacian: L = D - A let mut laplacian = vec![vec![0.0; n]; n]; for i in 0..n { for j in 0..n { @@ -194,8 +188,6 @@ impl TopologyEmbedder { } } - // Use inverse iteration with deflation to find second smallest eigenvalue - // First, find the largest eigenvalue for shifting let max_eig: f64 = (0..n) .map(|i| { let diag = laplacian[i][i]; @@ -207,15 +199,9 @@ impl TopologyEmbedder { }) .fold(0.0_f64, f64::max); - // Shifted matrix M = max_eig * I - L: largest eigvec of M = smallest eigvec of L - // We need the second largest of M (first is trivial constant vector) - - // First eigenvector of M: the constant vector (corresponds to lambda_0 = 0 of L) let e0: Vec = vec![1.0 / (n as f64).sqrt(); n]; - // Power iteration for second eigenvector let mut v: Vec = (0..n).map(|i| ((i + 1) as f64).sin()).collect(); - // Deflate e0 let dot0: f64 = v.iter().zip(e0.iter()).map(|(a, b)| a * b).sum(); for i in 0..n { v[i] -= dot0 * e0[i]; @@ -241,7 +227,6 @@ impl TopologyEmbedder { } } - // Deflate let dot: f64 = w.iter().zip(e0.iter()).map(|(a, b)| a * b).sum(); for i in 0..n { w[i] -= dot * e0[i]; @@ -251,18 +236,17 @@ impl TopologyEmbedder { if norm < 1e-12 { break; } - eigenvalue = norm; // This is the eigenvalue of the shifted matrix + eigenvalue = norm; for x in &mut w { *x /= norm; } v = w; } - // Fiedler value = max_eig - eigenvalue_of_shifted (max_eig - eigenvalue).max(0.0) } - /// Compute clustering coefficient (average over nodes). + /// Compute average clustering coefficient. fn clustering_coefficient(graph: &BrainGraph) -> f64 { let n = graph.num_nodes; if n == 0 { @@ -328,7 +312,7 @@ impl TopologyEmbedder { } /// Generate the topology embedding. - pub fn embed(&self, graph: &BrainGraph) -> Result { + pub fn embed_graph(&self, graph: &BrainGraph) -> Result { let mut values = Vec::new(); if self.include_mincut { @@ -344,7 +328,6 @@ impl TopologyEmbedder { values.push(Self::local_efficiency(graph)); } - // Always include these core features values.push(Self::graph_entropy(graph)); values.push(Self::estimate_fiedler(graph)); @@ -358,7 +341,8 @@ impl TopologyEmbedder { 0.0 }; let std_deg = if n > 0 { - let var = degrees.iter().map(|d| (d - mean_deg).powi(2)).sum::() / n as f64; + let var = + degrees.iter().map(|d| (d - mean_deg).powi(2)).sum::() / n as f64; var.sqrt() } else { 0.0 @@ -377,7 +361,8 @@ impl TopologyEmbedder { values.push(Self::clustering_coefficient(graph)); values.push(Self::num_components(graph) as f64); - NeuralEmbedding::new(values, graph.timestamp, "topology") + let meta = default_metadata("topology", graph.atlas); + NeuralEmbedding::new(values, graph.timestamp, meta) } /// Number of features produced with current settings. @@ -390,13 +375,13 @@ impl TopologyEmbedder { count += 1; } if self.include_efficiency { - count += 2; // global + local + count += 2; } count += 2; // entropy + fiedler if self.include_degree_stats { - count += 4; // mean, std, max, min + count += 4; } - count += 3; // density, clustering_coefficient, num_components + count += 3; // density, clustering, components count } } @@ -408,16 +393,12 @@ impl Default for TopologyEmbedder { } impl EmbeddingGenerator for TopologyEmbedder { - fn dimension(&self) -> usize { + fn embedding_dim(&self) -> usize { self.feature_count() } - fn method_name(&self) -> &str { - "topology" - } - fn embed(&self, graph: &BrainGraph) -> Result { - TopologyEmbedder::embed(self, graph) + self.embed_graph(graph) } } @@ -467,14 +448,13 @@ mod tests { let emb = embedder.embed(&graph).unwrap(); assert_eq!(emb.dimension, embedder.feature_count()); - assert_eq!(emb.method, "topology"); + assert_eq!(emb.metadata.embedding_method, "topology"); - // Triangle is complete K3: density = 1.0, clustering = 1.0, 1 component let dim = emb.dimension; // Last three values: density, clustering, components - assert!((emb.values[dim - 3] - 1.0).abs() < 1e-10, "density should be 1.0"); - assert!((emb.values[dim - 2] - 1.0).abs() < 1e-10, "clustering should be 1.0"); - assert!((emb.values[dim - 1] - 1.0).abs() < 1e-10, "should be 1 component"); + 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] @@ -486,9 +466,9 @@ mod tests { // 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.values[2] - 1.0).abs() < 1e-10, + (emb.vector[2] - 1.0).abs() < 1e-10, "global efficiency of K3 should be 1.0, got {}", - emb.values[2] + emb.vector[2] ); } @@ -503,10 +483,9 @@ mod tests { }; let embedder = TopologyEmbedder::new(); let emb = embedder.embed(&graph).unwrap(); - // density = 0, clustering = 0, components = 4 let dim = emb.dimension; - assert!((emb.values[dim - 3]).abs() < 1e-10); - assert!((emb.values[dim - 2]).abs() < 1e-10); - assert!((emb.values[dim - 1] - 4.0).abs() < 1e-10); + 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-esp32/README.md b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-esp32/README.md new file mode 100644 index 00000000..ee10c614 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-esp32/README.md @@ -0,0 +1,119 @@ +# rUv Neural ESP32 + +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 before sending to the main RuVector backend. + +## Hardware Requirements + +| Component | Specification | +|-----------|--------------| +| MCU | ESP32-S3 (dual-core Xtensa LX7, 240 MHz) | +| Flash | 8 MB minimum | +| PSRAM | 2 MB recommended for multi-channel buffering | +| ADC | 12-bit SAR ADC (built-in), or external 16-bit via SPI | +| WiFi | 802.11 b/g/n (built-in) | +| Battery | 3.7V LiPo, 2000+ mAh recommended | + +## Pin Configuration + +| GPIO | Function | Module | Notes | +|------|----------|--------|-------| +| 36 | ADC1_CH0 | `adc` | NV diamond sensor input (default) | +| 37 | ADC1_CH1 | `adc` | OPM sensor input | +| 38 | ADC1_CH2 | `adc` | EEG sensor input | +| 39 | ADC1_CH3 | `adc` | Auxiliary sensor input | +| 4 | ADC2_CH0 | `adc` | Battery voltage monitor | +| 16 | UART TX | `protocol` | Backend communication (if wired) | +| 17 | UART RX | `protocol` | Backend communication (if wired) | +| 2 | LED | `power` | Status indicator | + +## Modules + +### ADC (`adc.rs`) + +Configurable multi-channel ADC reader with support for 12-bit and 16-bit resolution. Converts raw ADC values to physical units (femtotesla) using per-channel gain and offset calibration. + +### Edge Preprocessing (`preprocessing.rs`) + +Lightweight signal conditioning that runs on-device before data transmission: + +- 50/60 Hz mains notch filters (IIR biquad) +- Configurable high-pass filter (default 0.5 Hz) for DC removal +- Configurable low-pass filter (default 200 Hz) for anti-aliasing +- Block-averaging downsampler +- Fixed-point IIR path for integer-only ESP32 math + +### Communication Protocol (`protocol.rs`) + +Binary packet format for ESP32-to-backend data transfer: + +``` ++--------+-----+--------+----------+------+---------+------+------+----------+ +| Magic | Ver | PktID | Timestamp| NCh | Samples | Data | Qual | Checksum | +| 4B | 1B | 4B | 8B | 1B | 2B | var | var | 4B | ++--------+-----+--------+----------+------+---------+------+------+----------+ + "rUvN" 1 u32 u64 us u8 u16 i16[] u8[] CRC32 +``` + +- Magic bytes: `rUvN` (0x72 0x55 0x76 0x4E) +- Fixed-point samples (i16) with per-channel scale factor for bandwidth efficiency +- CRC32 checksum (IEEE polynomial) for integrity verification +- JSON serialization in std mode; compact binary on embedded targets + +### TDM Scheduler (`tdm.rs`) + +Time-Division Multiplexing for collision-free multi-node operation: + +``` +| Node 0 | Node 1 | Node 2 | Node 3 | Node 0 | ... +|<-slot_d->|<-slot_d->|<-slot_d->|<-slot_d->| +|<-------------- frame_duration ------------>| +``` + +Supported sync methods: +- **GPS PPS** -- sub-microsecond accuracy +- **NTP Sync** -- millisecond accuracy over WiFi +- **WiFi Beacon** -- timestamp alignment from AP beacons +- **Leader-Follower** -- leader broadcasts sync pulses (default) + +### Power Management (`power.rs`) + +Battery life optimization through duty-cycle control: + +| Mode | Current Draw | Estimated Runtime (2000 mAh) | +|------|-------------|------------------------------| +| Active | 240 mA | ~6.25 hours | +| LowPower | 80 mA | ~15 hours | +| UltraLowPower | 20 mA | ~60 hours | +| Sleep | 10 uA | ~22 years | + +Automatic duty-cycle optimization targets a user-specified runtime by adjusting sample and WiFi duty cycles via binary search. + +### Node Aggregator (`aggregator.rs`) + +Collects packets from multiple ESP32 nodes and assembles them into a unified `MultiChannelTimeSeries`. Timestamp-based packet matching with configurable sync tolerance (default 1 ms). + +## Build Instructions + +```bash +# Build for host (std mode, simulation) +cd rust-port/wifi-densepose-rs/crates/ruv-neural +cargo build -p ruv-neural-esp32 + +# Run tests +cargo test -p ruv-neural-esp32 + +# Build with simulator feature +cargo build -p ruv-neural-esp32 --features simulator +``` + +## Features + +| Feature | Description | +|---------|-------------| +| `std` (default) | Standard library support, simulated ADC | +| `no_std` | Bare-metal ESP32 deployment (no heap allocator required for core types) | +| `simulator` | ESP32 simulation mode for desktop development | + +## License + +MIT OR Apache-2.0 diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-esp32/src/lib.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-esp32/src/lib.rs index 6afb469e..56e97985 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-esp32/src/lib.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-esp32/src/lib.rs @@ -1 +1,28 @@ -//! Stub crate. +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/Cargo.toml b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/Cargo.toml index e3adec54..97747878 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/Cargo.toml +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/Cargo.toml @@ -1,9 +1,21 @@ [package] name = "ruv-neural-graph" -description = "rUv Neural — ruv-neural-graph (stub)" +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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/README.md b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/README.md new file mode 100644 index 00000000..cc14d1a8 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/README.md @@ -0,0 +1,98 @@ +# ruv-neural-graph + +**rUv Neural** -- Brain connectivity graph construction from neural signals. + +Part of the [rUv Neural](https://github.com/ruvnet/RuView) workspace for brain topology analysis. + +## Overview + +`ruv-neural-graph` transforms multi-channel neural time series data into brain connectivity graphs and computes graph-theoretic metrics used in network neuroscience. It supports built-in brain atlases, sliding-window graph construction, spectral analysis, and temporal dynamics tracking. + +## Dependency Diagram + +``` +ruv-neural-core + | + v +ruv-neural-signal + | + v +ruv-neural-graph <-- petgraph + | + v +ruv-neural-mincut / ruv-neural-embed / ruv-neural-decoder +``` + +## Modules + +| Module | Description | +|-------------------|--------------------------------------------------------------| +| `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 | + +## Graph Metrics + +| Metric | Function | Description | +|-------------------------|----------------------------|--------------------------------------------------| +| Global efficiency | `global_efficiency` | Average inverse shortest path length | +| Local efficiency | `local_efficiency` | Average node-level subgraph efficiency | +| Clustering coefficient | `clustering_coefficient` | Weighted triangle ratio | +| Node degree | `node_degree` | Weighted degree of a single node | +| Degree distribution | `degree_distribution` | All node degrees | +| Betweenness centrality | `betweenness_centrality` | Fraction of shortest paths through each node | +| Graph density | `graph_density` | Fraction of possible edges present | +| Small-world index | `small_world_index` | sigma = (C/C_rand) / (L/L_rand) | +| Modularity | `modularity` | Newman modularity Q for a given partition | +| Graph Laplacian | `graph_laplacian` | L = D - A | +| Normalized Laplacian | `normalized_laplacian` | L_norm = D^{-1/2} L D^{-1/2} | +| Fiedler value | `fiedler_value` | Algebraic connectivity (second smallest eigenvalue)| +| Spectral gap | `spectral_gap` | lambda_2 - lambda_1 | + +## Usage + +```rust +use ruv_neural_graph::{ + AtlasType, BrainGraphConstructor, load_atlas, + global_efficiency, clustering_coefficient, fiedler_value, + to_petgraph, TopologyTracker, +}; +use ruv_neural_core::graph::ConnectivityMetric; +use ruv_neural_core::signal::FrequencyBand; + +// Load the Desikan-Killiany atlas (68 cortical regions) +let parcellation = load_atlas(AtlasType::DesikanKilliany); +assert_eq!(parcellation.num_regions(), 68); + +// Build a graph constructor +let constructor = BrainGraphConstructor::new( + AtlasType::DesikanKilliany, + ConnectivityMetric::PhaseLockingValue, + FrequencyBand::Alpha, +) +.with_threshold(0.1); + +// Construct a graph from a connectivity matrix +let connectivity = vec![vec![1.0; 68]; 68]; // example: fully connected +let graph = constructor.construct_from_matrix(&connectivity, 0.0); + +// Compute metrics +let eff = global_efficiency(&graph); +let cc = clustering_coefficient(&graph); +let fv = fiedler_value(&graph); + +// Convert to petgraph for advanced algorithms +let pg = to_petgraph(&graph); + +// Track topology over time +let mut tracker = TopologyTracker::new(); +tracker.track(&graph); +let transitions = tracker.detect_transitions(0.1); +``` + +## License + +MIT OR Apache-2.0 diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/constructor.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/constructor.rs index 89e9f948..9fdb1ea0 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/constructor.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/constructor.rs @@ -5,7 +5,7 @@ //! It also supports sliding-window construction from raw time series via the //! signal crate's connectivity metrics. -use ruv_neural_core::brain::{Atlas, Parcellation}; +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}; @@ -102,7 +102,6 @@ impl BrainGraphConstructor { &self, data: &MultiChannelTimeSeries, ) -> BrainGraphSequence { - let n_channels = data.num_channels; let n_samples = data.num_samples; let sr = data.sample_rate_hz; diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/dynamics.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/dynamics.rs new file mode 100644 index 00000000..0ce529fb --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/dynamics.rs @@ -0,0 +1,262 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/lib.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/lib.rs index 6afb469e..9a143f2f 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/lib.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/lib.rs @@ -1 +1,31 @@ -//! Stub crate. +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/metrics.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/metrics.rs new file mode 100644 index 00000000..7caca7d2 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/metrics.rs @@ -0,0 +1,517 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs index 82bc2280..21224e22 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/petgraph_bridge.rs @@ -4,7 +4,7 @@ //! (shortest paths, connected components, etc.) on brain connectivity graphs. use petgraph::graph::{Graph, NodeIndex, UnGraph}; -use petgraph::Undirected; +use petgraph::visit::EdgeRef; use ruv_neural_core::brain::Atlas; use ruv_neural_core::graph::{BrainEdge, BrainGraph, ConnectivityMetric}; @@ -78,8 +78,6 @@ pub fn node_index(region_id: usize) -> NodeIndex { NodeIndex::new(region_id) } -use petgraph::visit::EdgeRef; - #[cfg(test)] mod tests { use super::*; diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/spectral.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/spectral.rs new file mode 100644 index 00000000..126fd0ba --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-graph/src/spectral.rs @@ -0,0 +1,317 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-memory/src/persistence.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-memory/src/persistence.rs index 02f8a263..4879884e 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-memory/src/persistence.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-memory/src/persistence.rs @@ -123,6 +123,7 @@ pub fn load_rvf(path: &str) -> Result { 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 { diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/benchmark.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/benchmark.rs new file mode 100644 index 00000000..c76e13ef --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/benchmark.rs @@ -0,0 +1,186 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/coherence.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/coherence.rs new file mode 100644 index 00000000..f6b85c4b --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/coherence.rs @@ -0,0 +1,315 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/dynamic.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/dynamic.rs index 5fc359c9..af2731c8 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/dynamic.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/dynamic.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use ruv_neural_core::graph::BrainGraph; use ruv_neural_core::topology::MincutResult; -use ruv_neural_core::{Result, RuvNeuralError}; +use ruv_neural_core::Result; use crate::stoer_wagner::stoer_wagner_mincut; @@ -161,7 +161,9 @@ impl DynamicMincutTracker { /// /// The integration index is defined as: /// - /// I(t) = 1.0 - mincut(t) / max_mincut + /// ```text + /// I(t) = 1.0 - mincut(t) / max_mincut + /// ``` /// /// High values (close to 1) indicate integrated states; low values indicate /// segregated states. @@ -352,7 +354,7 @@ mod tests { 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)); + let g = make_graph(i as f64, i as f64 + 1.0); tracker.update(&g).unwrap(); } diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/multiway.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/multiway.rs index e18c987c..30e0407e 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/multiway.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/multiway.rs @@ -5,7 +5,7 @@ //! optimization. use ruv_neural_core::graph::{BrainEdge, BrainGraph}; -use ruv_neural_core::topology::{MincutResult, MultiPartition}; +use ruv_neural_core::topology::MultiPartition; use ruv_neural_core::{Result, RuvNeuralError}; use crate::normalized::normalized_cut; diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/normalized.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/normalized.rs index 49cc129f..9e3b4cfb 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/normalized.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/normalized.rs @@ -2,7 +2,9 @@ //! //! The normalized cut objective is: //! -//! Ncut(A, B) = cut(A,B) / vol(A) + cut(A,B) / vol(B) +//! ```text +//! Ncut(A, B) = cut(A,B) / vol(A) + cut(A,B) / vol(B) +//! ``` //! //! where vol(S) = sum of degrees of nodes in S. //! diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/spectral_cut.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/spectral_cut.rs index 4db97fe5..d1f81576 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/spectral_cut.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-mincut/src/spectral_cut.rs @@ -234,10 +234,24 @@ pub fn cheeger_constant(graph: &BrainGraph) -> Result { } } -/// Cheeger inequality bounds relating the Fiedler value lambda_2 to the -/// Cheeger constant h(G): +/// Cheeger inequality bounds relating the Fiedler value lambda_2 of the +/// **unnormalized** Laplacian to the conductance h(G). /// -/// lambda_2 / 2 <= h(G) <= sqrt(2 * lambda_2) +/// 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) { @@ -331,7 +345,11 @@ mod tests { ); } - /// Cheeger bounds: lambda_2/2 <= h(G) <= sqrt(2*lambda_2). + /// 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 { @@ -349,21 +367,31 @@ mod tests { let (fiedler_value, _) = fiedler_decomposition(&graph).unwrap(); let h = cheeger_constant(&graph).unwrap(); - let (lower, upper) = cheeger_bound(fiedler_value); + + // 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={})", + "Cheeger h={} should be >= lower bound {} (lambda2_norm={})", h, lower, - fiedler_value + lambda_2_norm ); assert!( h <= upper + 1e-6, - "Cheeger h={} should be <= upper bound {} (lambda2={})", + "Cheeger h={} should be <= upper bound {} (lambda2_norm={})", h, upper, - fiedler_value + lambda_2_norm ); } diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/README.md b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/README.md new file mode 100644 index 00000000..ab88804a --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/README.md @@ -0,0 +1,130 @@ +# ruv-neural-sensor + +**rUv Neural** -- Sensor data acquisition for NV diamond, OPM, EEG, and simulated sources. + +Part of the [rUv Neural](https://github.com/ruvnet/RuView) brain topology analysis pipeline. + +## Overview + +`ruv-neural-sensor` provides a uniform `SensorSource` trait interface for acquiring multi-channel neural signal data from multiple sensor modalities. Each sensor backend is feature-gated so you only compile what you need. + +## Supported Sensor Types + +| Sensor | Feature Flag | Sensitivity | Description | +|--------|-------------|-------------|-------------| +| Simulated | `simulator` (default) | Configurable | Synthetic data for testing and development | +| NV Diamond | `nv_diamond` | ~10 fT/sqrt(Hz) | Nitrogen-vacancy diamond magnetometer | +| OPM | `opm` | ~7 fT/sqrt(Hz) | Optically pumped magnetometer (SERF mode) | +| EEG | `eeg` | ~1000 fT/sqrt(Hz) | Electroencephalography (10-20 system) | + +## Feature Flags + +| Feature | Default | Description | +|---------|---------|-------------| +| `simulator` | Yes | Simulated sensor array with configurable noise, oscillations, and events | +| `nv_diamond` | No | NV diamond magnetometer with ODMR signal processing stub | +| `opm` | No | OPM array with SERF mode, cross-talk matrix, active shielding | +| `eeg` | No | EEG with 10-20 electrode system, impedance tracking | + +## Usage + +### Basic Simulator + +```rust +use ruv_neural_sensor::simulator::SimulatedSensorArray; +use ruv_neural_sensor::SensorSource; + +// Create a 16-channel simulator at 1000 Hz with 10 fT/sqrt(Hz) noise. +let mut sim = SimulatedSensorArray::new(16, 1000.0); + +// Inject alpha rhythm (10 Hz, 100 fT amplitude). +sim.inject_alpha(100.0); + +// Acquire 1 second of data. +let data = sim.read_chunk(1000).unwrap(); +assert_eq!(data.num_channels, 16); +assert_eq!(data.num_samples, 1000); +``` + +### Custom Noise Floor + +```rust +use ruv_neural_sensor::simulator::SimulatedSensorArray; + +let mut sim = SimulatedSensorArray::new(8, 500.0) + .with_noise(5.0); // 5 fT/sqrt(Hz) noise density +``` + +### Injecting Events + +```rust +use ruv_neural_sensor::simulator::{SimulatedSensorArray, SensorEvent}; +use ruv_neural_sensor::SensorSource; + +let mut sim = SimulatedSensorArray::new(4, 1000.0); +sim.inject_event(SensorEvent::Spike { + channel: 0, + amplitude_ft: 500.0, + sample_offset: 100, +}); +let data = sim.read_chunk(200).unwrap(); +``` + +## Calibration + +The `calibration` module provides tools for sensor gain/offset correction and cross-sensor alignment. + +```rust +use ruv_neural_sensor::calibration::{CalibrationData, calibrate_channel, estimate_noise_floor, cross_calibrate}; + +// Define calibration data. +let cal = CalibrationData { + gains: vec![2.0, 1.5], + offsets: vec![10.0, 5.0], + noise_floors: vec![1.0, 2.0], +}; + +// Apply correction: corrected = (raw - offset) * gain +let corrected = calibrate_channel(100.0, 0, &cal); + +// Estimate noise floor from a quiet recording. +let quiet_data = vec![0.1, -0.2, 0.15, -0.1]; +let noise = estimate_noise_floor(&quiet_data); + +// Cross-calibrate two sensors. +let reference = vec![10.0, 20.0, 30.0]; +let target = vec![5.0, 10.0, 15.0]; +let (gain, offset) = cross_calibrate(&reference, &target); +``` + +## Quality Monitoring + +The `quality` module tracks real-time signal quality across channels. + +```rust +use ruv_neural_sensor::quality::{QualityMonitor, SignalQuality}; + +let mut monitor = QualityMonitor::new(4); + +// Check quality of 4 channels. +let ch0 = vec![/* ... */]; +let ch1 = vec![/* ... */]; +let ch2 = vec![/* ... */]; +let ch3 = vec![/* ... */]; +let qualities = monitor.check_quality(&[&ch0, &ch1, &ch2, &ch3]); + +for (i, q) in qualities.iter().enumerate() { + if q.below_threshold() { + println!("Channel {i}: quality below threshold (SNR={:.1} dB)", q.snr_db); + } +} +``` + +**Alert thresholds:** +- SNR < 3 dB +- Artifact probability > 0.5 +- Saturation detected + +## License + +MIT OR Apache-2.0 diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/calibration.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/calibration.rs new file mode 100644 index 00000000..1adcb7e7 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/calibration.rs @@ -0,0 +1,60 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/eeg.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/eeg.rs new file mode 100644 index 00000000..5677ed9e --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/eeg.rs @@ -0,0 +1,214 @@ +//! EEG (Electroencephalography) interface. +//! +//! Provides a sensor interface for standard EEG systems using the 10-20 +//! international electrode placement system. 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. +/// Currently operates as a simulated backend. +#[derive(Debug)] +pub struct EegArray { + config: EegConfig, + array: SensorArray, + sample_counter: u64, +} + +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, + } + } + + /// 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; + } + } + } +} + +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; + + // Generate simulated EEG: microvolts scale (converted to fT-equivalent units). + let mut rng = rand::thread_rng(); + let data: Vec> = (0..self.config.num_channels) + .map(|_ch| { + // EEG noise ~50 uV RMS, simulated as white noise. + let sigma = 50.0; // uV + (0..num_samples) + .map(|_| { + let u1: f64 = rand::Rng::gen::(&mut rng).max(1e-15); + let u2: f64 = rand::Rng::gen(&mut rng); + sigma * (-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos() + }) + .collect() + }) + .collect(); + + self.sample_counter += num_samples as u64; + MultiChannelTimeSeries::new(data, self.config.sample_rate_hz, timestamp) + } +} diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/lib.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/lib.rs index b76c1393..bf7bea68 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/lib.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/lib.rs @@ -171,12 +171,20 @@ mod tests { .map(|i| 100.0 * (2.0 * std::f64::consts::PI * 10.0 * i as f64 / 1000.0).sin()) .collect(); - // Channel 1: mostly noise. - let bad_signal: Vec = (0..1000).map(|i| (i as f64 * 0.001).sin() * 0.01).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); - assert!(qualities[0].snr_db > qualities[1].snr_db); + // 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] diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/nv_diamond.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/nv_diamond.rs index 0382c408..509b508e 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/nv_diamond.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/nv_diamond.rs @@ -117,6 +117,11 @@ impl NvDiamondArray { } } + /// 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 { diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/opm.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/opm.rs index 3f16f31b..14fd3e3c 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/opm.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/opm.rs @@ -116,6 +116,11 @@ impl OpmArray { } } + /// Returns the sensor array metadata. + pub fn sensor_array(&self) -> &SensorArray { + &self.array + } + /// Apply cross-talk compensation to raw channel data. /// /// Multiplies the raw data vector by the inverse cross-talk matrix. diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/quality.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/quality.rs new file mode 100644 index 00000000..c1970889 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/quality.rs @@ -0,0 +1,95 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/simulator.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/simulator.rs index a939875e..25b30160 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/simulator.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-sensor/src/simulator.rs @@ -148,6 +148,11 @@ impl SimulatedSensorArray { 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 { diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/README.md b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/README.md new file mode 100644 index 00000000..71bf4a70 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/README.md @@ -0,0 +1,140 @@ +# rUv Neural Signal + +Digital signal processing for neural magnetic field data. + +Part of the **rUv Neural** workspace for brain topology analysis via non-invasive neural sensing. + +## Capabilities + +| Module | Description | +|--------|-------------| +| `filter` | Butterworth IIR bandpass, notch, highpass, lowpass filters (SOS form, zero-phase) | +| `spectral` | Power spectral density (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 (QRS) detection and rejection | +| `connectivity` | Phase Locking Value, coherence, imaginary coherence, amplitude envelope correlation | +| `preprocessing` | Configurable multi-stage pipeline (notch + bandpass + artifact rejection) | + +## Feature Flags + +| Flag | Description | +|------|-------------| +| `std` (default) | Standard library support | +| `simd` | SIMD-accelerated processing (future) | + +## Usage + +### Preprocessing Pipeline + +```rust +use ruv_neural_core::signal::MultiChannelTimeSeries; +use ruv_neural_signal::PreprocessingPipeline; + +// Load your multi-channel neural recording +let raw_data = MultiChannelTimeSeries::new(channels, 1000.0, 0.0).unwrap(); + +// Default pipeline: 50 Hz notch -> 1-200 Hz bandpass -> artifact rejection +let pipeline = PreprocessingPipeline::default_pipeline(1000.0); +let clean_data = pipeline.process(&raw_data).unwrap(); + +// Or build a custom pipeline +let mut custom = PreprocessingPipeline::new(1000.0); +custom.add_notch(60.0, 2.0); // 60 Hz for US power grid +custom.add_bandpass(0.5, 100.0, 4); // Wider passband +custom.add_artifact_rejection(); +let result = custom.process(&raw_data).unwrap(); +``` + +### Spectral Analysis + +```rust +use ruv_neural_signal::{compute_psd, band_power, spectral_entropy, peak_frequency}; +use ruv_neural_core::signal::FrequencyBand; + +let (freqs, psd) = compute_psd(&signal, 1000.0, 512); +let alpha_power = band_power(&psd, &freqs, FrequencyBand::Alpha); +let entropy = spectral_entropy(&psd); +let peak = peak_frequency(&psd, &freqs); +``` + +### Connectivity + +```rust +use ruv_neural_signal::{phase_locking_value, coherence, compute_all_pairs, ConnectivityMetric}; +use ruv_neural_core::signal::FrequencyBand; + +// Pairwise PLV in the alpha band +let plv = phase_locking_value(&ch_a, &ch_b, 1000.0, FrequencyBand::Alpha); + +// Full connectivity matrix +let matrix = compute_all_pairs(&data, ConnectivityMetric::Plv, FrequencyBand::Alpha); +``` + +### Hilbert Transform + +```rust +use ruv_neural_signal::{hilbert_transform, instantaneous_phase, instantaneous_amplitude}; + +let analytic = hilbert_transform(&signal); +let phase = instantaneous_phase(&signal); +let envelope = instantaneous_amplitude(&signal); +``` + +## Mathematical Formulations + +### Butterworth Filter + +The Butterworth filter maximizes flatness in the passband. The magnitude response of an Nth-order Butterworth lowpass filter is: + +``` +|H(jw)|^2 = 1 / (1 + (w/wc)^(2N)) +``` + +Implemented as cascaded second-order sections (biquads) via bilinear transform for numerical stability. Zero-phase filtering is achieved by forward-backward (filtfilt) application. + +### Welch's Method (PSD) + +The signal is divided into overlapping segments (50% overlap), each windowed with a Hann window, and the averaged periodogram is computed: + +``` +PSD(f) = (1 / (M * fs * W)) * sum_m |X_m(f)|^2 +``` + +where M is the number of segments, fs is the sample rate, and W is the window power. + +### Phase Locking Value + +``` +PLV = || +``` + +Instantaneous phases are extracted via the Hilbert transform after bandpass filtering. + +### Hilbert Transform + +The analytic signal is computed via the FFT: +1. Compute X(f) = FFT(x(t)) +2. Zero negative frequencies, double positive frequencies +3. z(t) = IFFT(X_analytic(f)) + +Instantaneous amplitude = |z(t)|, instantaneous phase = arg(z(t)). + +### Spectral Entropy + +``` +H = -sum(p_k * log2(p_k)) +``` + +where p_k = PSD(f_k) / sum(PSD) is the normalized power distribution. + +## Performance Notes + +- All filters use SOS (second-order sections) cascade for numerical stability with high filter orders +- Zero-phase filtering (forward-backward) eliminates phase distortion at the cost of 2x computation +- FFT operations use the `rustfft` crate (pure Rust, no external dependencies) +- Connectivity matrix computation is O(N^2) in the number of channels; each pair requires bandpass filtering + Hilbert transform +- The `simd` feature flag is reserved for future SIMD-accelerated inner loops + +## License + +MIT OR Apache-2.0 diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/artifact.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/artifact.rs index 1748ba15..a526aa6c 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/artifact.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/artifact.rs @@ -1,67 +1,391 @@ -//! Artifact detection and rejection for neural signals. +//! 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. -/// Detect eye blink artifacts by amplitude threshold. -pub fn detect_eye_blinks(signal: &[f64], threshold: f64) -> Vec<(usize, usize)> { - let mut artifacts = Vec::new(); +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 signal.iter().enumerate() { - if val.abs() > threshold && !in_artifact { + + for (i, &val) in abs_signal.iter().enumerate() { + if val > threshold && !in_artifact { in_artifact = true; start = i; - } else if val.abs() <= threshold && in_artifact { + } else if val <= threshold && in_artifact { in_artifact = false; - artifacts.push((start, i)); + ranges.push((start, i)); } } if in_artifact { - artifacts.push((start, signal.len())); + ranges.push((start, abs_signal.len())); } - artifacts + + // 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 artifacts via high-frequency power. -pub fn detect_muscle_artifact(signal: &[f64], threshold: f64) -> Vec<(usize, usize)> { - // Simplified: detect rapid changes - let mut artifacts = Vec::new(); - if signal.len() < 2 { return artifacts; } +/// 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 in 1..signal.len() { - let diff = (signal[i] - signal[i - 1]).abs(); - if diff > threshold && !in_artifact { + + for (i, &val) in rms_signal.iter().enumerate() { + if val > threshold && !in_artifact { in_artifact = true; start = i; - } else if diff <= threshold && in_artifact { + } else if val <= threshold && in_artifact { in_artifact = false; - artifacts.push((start, i)); + ranges.push((start, i)); } } if in_artifact { - artifacts.push((start, signal.len())); + ranges.push((start, n)); } - artifacts + + let pad = (sample_rate * 0.025) as usize; + merge_ranges_with_padding(&ranges, pad, signal.len()) } -/// Detect cardiac artifacts. -pub fn detect_cardiac(signal: &[f64], sample_rate_hz: f64) -> Vec<(usize, usize)> { - let _ = sample_rate_hz; - detect_eye_blinks(signal, 3.0 * std_dev(signal)) -} +/// 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(); + } -/// Reject artifacts by zeroing detected intervals. -pub fn reject_artifacts(signal: &mut [f64], artifacts: &[(usize, usize)]) { - for &(start, end) in artifacts { - for sample in signal[start..end.min(signal.len())].iter_mut() { - *sample = 0.0; + // 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 } -fn std_dev(signal: &[f64]) -> f64 { - let n = signal.len() as f64; - if n <= 1.0 { return 0.0; } - let mean = signal.iter().sum::() / n; - let var = signal.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0); - var.sqrt() +/// 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/connectivity.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/connectivity.rs index bc6c0d26..6770f4ec 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/connectivity.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/connectivity.rs @@ -1,111 +1,282 @@ -//! Connectivity metrics between neural signal channels. +//! Cross-channel coupling and connectivity metrics. //! -//! Provides PLV, coherence, imaginary coherence, and amplitude envelope correlation. +//! 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 crate::hilbert::{hilbert_transform, instantaneous_amplitude}; use num_complex::Complex; +use ruv_neural_core::signal::{FrequencyBand, MultiChannelTimeSeries}; +use rustfft::FftPlanner; +use serde::{Deserialize, Serialize}; use std::f64::consts::PI; -/// Compute the Phase Locking Value between two signals. +use crate::filter::BandpassFilter; +use crate::hilbert::hilbert_transform; + +/// 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, +} + +/// Compute the Phase Locking Value (PLV) between two signals. /// -/// PLV measures the consistency of phase difference between two signals. -/// Returns a value in [0, 1] where 1 = perfectly phase-locked. -pub fn phase_locking_value(signal_a: &[f64], signal_b: &[f64]) -> f64 { +/// 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 == 0 { + if n < 4 { return 0.0; } - let analytic_a = hilbert_transform(signal_a); - let analytic_b = hilbert_transform(signal_b); - let sum: Complex = analytic_a[..n] - .iter() - .zip(analytic_b[..n].iter()) - .map(|(a, b)| { - let phase_diff = (a / a.norm()) * (b / b.norm()).conj(); - if phase_diff.norm() > 0.0 { - phase_diff / phase_diff.norm() - } else { - Complex::new(0.0, 0.0) - } - }) - .fold(Complex::new(0.0, 0.0), |acc, x| acc + x); + 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. -pub fn coherence(signal_a: &[f64], signal_b: &[f64]) -> f64 { +/// +/// 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 0.0; + return Vec::new(); } - // Simplified: correlation of instantaneous amplitudes - let amp_a = instantaneous_amplitude(signal_a); - let amp_b = instantaneous_amplitude(signal_b); - pearson_correlation(&_a[..n], &_b[..n]).abs() + + 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 mut planner = FftPlanner::new(); + let fft = planner.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 (volume-conduction robust). -pub fn imaginary_coherence(signal_a: &[f64], signal_b: &[f64]) -> f64 { +/// 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 0.0; + return Vec::new(); } - let analytic_a = hilbert_transform(signal_a); - let analytic_b = hilbert_transform(signal_b); - let cross_spec: Complex = analytic_a[..n] - .iter() - .zip(analytic_b[..n].iter()) - .map(|(a, b)| a * b.conj()) - .fold(Complex::new(0.0, 0.0), |acc, x| acc + x); + let window_size = 256.min(n); + let overlap = window_size / 2; + let hop = window_size - overlap; - let psd_a: f64 = analytic_a[..n].iter().map(|a| a.norm_sqr()).sum(); - let psd_b: f64 = analytic_b[..n].iter().map(|b| b.norm_sqr()).sum(); - let denom = (psd_a * psd_b).sqrt(); - if denom == 0.0 { - return 0.0; + let window = hann_window(window_size); + let num_freqs = window_size / 2 + 1; + + let mut planner = FftPlanner::new(); + let fft = planner.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; } - (cross_spec.im / denom).abs() + + 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. -pub fn amplitude_envelope_correlation(signal_a: &[f64], signal_b: &[f64]) -> f64 { +/// +/// 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 == 0 { + if n < 4 { return 0.0; } - let env_a = instantaneous_amplitude(signal_a); - let env_b = instantaneous_amplitude(signal_b); - pearson_correlation(&env_a[..n], &env_b[..n]) + + 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 all-pairs connectivity for a set of channels. +/// Compute a full connectivity matrix for all channel pairs. /// -/// Returns a symmetric matrix `result[i][j]` with connectivity between channel i and j. +/// # 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( - channels: &[Vec], - metric: &crate::ConnectivityMetric, + data: &MultiChannelTimeSeries, + metric: ConnectivityMetric, + band: FrequencyBand, ) -> Vec> { - let n = channels.len(); - let mut matrix = vec![vec![0.0; n]; n]; + let nc = data.num_channels; + let sr = data.sample_rate_hz; + let mut matrix = vec![vec![0.0; nc]; nc]; - for i in 0..n { - matrix[i][i] = 1.0; // Self-connectivity = 1 - for j in (i + 1)..n { + for i in 0..nc { + matrix[i][i] = 1.0; // Self-connectivity is 1.0 + for j in (i + 1)..nc { let val = match metric { - crate::ConnectivityMetric::Plv => { - phase_locking_value(&channels[i], &channels[j]) + ConnectivityMetric::Plv => { + phase_locking_value(&data.data[i], &data.data[j], sr, band) } - crate::ConnectivityMetric::Coherence => { - coherence(&channels[i], &channels[j]) - } - crate::ConnectivityMetric::ImaginaryCoherence => { - imaginary_coherence(&channels[i], &channels[j]) - } - crate::ConnectivityMetric::AmplitudeEnvelopeCorrelation => { - amplitude_envelope_correlation(&channels[i], &channels[j]) + ConnectivityMetric::Aec => { + amplitude_envelope_correlation(&data.data[i], &data.data[j], sr, band) } }; matrix[i][j] = val; @@ -116,28 +287,156 @@ pub fn compute_all_pairs( matrix } -/// Pearson correlation coefficient between two slices. +/// Pearson correlation coefficient between two vectors. fn pearson_correlation(a: &[f64], b: &[f64]) -> f64 { - let n = a.len().min(b.len()) as f64; - if n <= 1.0 { + let n = a.len().min(b.len()); + if n == 0 { return 0.0; } - let mean_a = a.iter().sum::() / n; - let mean_b = b.iter().sum::() / n; + + 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 as usize { + + 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 == 0.0 { + 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/filter.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/filter.rs index 88dee989..3e07d902 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/filter.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/filter.rs @@ -1,134 +1,511 @@ //! Digital filters for neural signal processing. //! -//! Provides Butterworth IIR filters: bandpass, highpass, lowpass, and notch. +//! 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 crate::SignalProcessor; +use serde::{Deserialize, Serialize}; +use std::f64::consts::PI; -/// Butterworth bandpass filter. -#[derive(Debug, Clone)] +/// 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 { - low_hz: f64, - high_hz: f64, - order: usize, - sample_rate_hz: f64, + /// 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 bandpass filter. - pub fn new(low_hz: f64, high_hz: f64, order: usize, sample_rate_hz: f64) -> Self { - Self { low_hz, high_hz, order, sample_rate_hz } + /// 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 apply(&self, signal: &[f64]) -> Vec { - // Simple moving-average approximation for compilation. - // A production implementation would use proper Butterworth coefficients. - let n = signal.len(); - if n < 3 { - return signal.to_vec(); - } - let mut out = vec![0.0; n]; - // Remove DC (highpass effect) then smooth (lowpass effect) - let mean: f64 = signal.iter().sum::() / n as f64; - let dc_removed: Vec = signal.iter().map(|x| x - mean).collect(); - // Simple smoothing kernel - let kernel_size = (self.sample_rate_hz / self.high_hz).max(1.0).min(n as f64 / 2.0) as usize; - let kernel_size = kernel_size.max(1); - for i in 0..n { - let start = i.saturating_sub(kernel_size / 2); - let end = (i + kernel_size / 2 + 1).min(n); - let sum: f64 = dc_removed[start..end].iter().sum(); - out[i] = sum / (end - start) as f64; - } - out - } - - fn name(&self) -> &str { - "BandpassFilter" + fn process(&self, signal: &[f64]) -> Vec { + self.apply(signal) } } -/// Butterworth highpass filter. -#[derive(Debug, Clone)] -pub struct HighpassFilter { - cutoff_hz: f64, - order: usize, - sample_rate_hz: f64, -} - -impl HighpassFilter { - pub fn new(cutoff_hz: f64, order: usize, sample_rate_hz: f64) -> Self { - Self { cutoff_hz, order, sample_rate_hz } - } -} - -impl SignalProcessor for HighpassFilter { - fn apply(&self, signal: &[f64]) -> Vec { - let n = signal.len(); - if n == 0 { return Vec::new(); } - let mean: f64 = signal.iter().sum::() / n as f64; - signal.iter().map(|x| x - mean).collect() - } - - fn name(&self) -> &str { - "HighpassFilter" - } -} - -/// Butterworth lowpass filter. -#[derive(Debug, Clone)] -pub struct LowpassFilter { - cutoff_hz: f64, - order: usize, - sample_rate_hz: f64, -} - -impl LowpassFilter { - pub fn new(cutoff_hz: f64, order: usize, sample_rate_hz: f64) -> Self { - Self { cutoff_hz, order, sample_rate_hz } - } -} - -impl SignalProcessor for LowpassFilter { - fn apply(&self, signal: &[f64]) -> Vec { - let n = signal.len(); - if n < 2 { return signal.to_vec(); } - let alpha = 0.5_f64.min(self.cutoff_hz / self.sample_rate_hz); - let mut out = vec![0.0; n]; - out[0] = signal[0]; - for i in 1..n { - out[i] = alpha * signal[i] + (1.0 - alpha) * out[i - 1]; - } - out - } - - fn name(&self) -> &str { - "LowpassFilter" - } -} - -/// Notch filter to remove a specific frequency (e.g., 50/60 Hz line noise). -#[derive(Debug, Clone)] +/// 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_hz: f64, - bandwidth_hz: f64, - sample_rate_hz: f64, + /// 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 { - pub fn new(center_hz: f64, bandwidth_hz: f64, sample_rate_hz: f64) -> Self { - Self { center_hz, bandwidth_hz, sample_rate_hz } + /// 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 apply(&self, signal: &[f64]) -> Vec { - // Simplified: just return signal minus estimated tone at center_hz - signal.to_vec() - } - - fn name(&self) -> &str { - "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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/hilbert.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/hilbert.rs index 6f186bce..a2acee7c 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/hilbert.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/hilbert.rs @@ -10,7 +10,6 @@ use num_complex::Complex; use rustfft::FftPlanner; -use std::f64::consts::PI; /// Compute the analytic signal via FFT-based Hilbert transform. /// @@ -82,6 +81,7 @@ pub fn instantaneous_amplitude(signal: &[f64]) -> Vec { mod tests { use super::*; use approx::assert_abs_diff_eq; + use std::f64::consts::PI; #[test] fn hilbert_of_cosine_gives_sine() { diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/preprocessing.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/preprocessing.rs index 348fb5ca..ab188bc4 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/preprocessing.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/preprocessing.rs @@ -1,41 +1,252 @@ -//! Configurable multi-stage preprocessing pipeline. +//! 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 crate::SignalProcessor; +use ruv_neural_core::error::{Result, RuvNeuralError}; +use ruv_neural_core::signal::MultiChannelTimeSeries; -/// A pipeline of sequential signal processing stages. +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>, + stages: Vec, + sample_rate: f64, } impl PreprocessingPipeline { - /// Create an empty pipeline. - pub fn new() -> Self { - Self { stages: Vec::new() } - } - - /// Add a processing stage. - pub fn add_stage(mut self, processor: Box) -> Self { - self.stages.push(processor); - self - } - - /// Apply all stages in sequence. - pub fn process(&self, signal: &[f64]) -> Vec { - let mut result = signal.to_vec(); - for stage in &self.stages { - result = stage.apply(&result); + /// Create a new empty pipeline. + pub fn new(sample_rate: f64) -> Self { + Self { + stages: Vec::new(), + sample_rate, } - result } - /// Number of stages in the pipeline. - pub fn num_stages(&self) -> usize { - self.stages.len() + /// 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) } } -impl Default for PreprocessingPipeline { - fn default() -> Self { - Self::new() +/// 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/spectral.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/spectral.rs index 9ef325df..023f7e9f 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/spectral.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-signal/src/spectral.rs @@ -1,88 +1,300 @@ -//! Spectral analysis: PSD, STFT, band power, spectral entropy. +//! 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::f64::consts::PI; -/// Compute power spectral density using Welch's method (simplified). -pub fn compute_psd(signal: &[f64], sample_rate_hz: f64) -> (Vec, Vec) { +/// 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 { + if n == 0 || window_size == 0 { return (Vec::new(), Vec::new()); } - let mut planner = FftPlanner::::new(); - let fft = planner.plan_fft_forward(n); - let mut spectrum: Vec> = signal.iter().map(|&x| Complex::new(x, 0.0)).collect(); - fft.process(&mut spectrum); - let num_freqs = n / 2 + 1; - let df = sample_rate_hz / n as f64; - let freqs: Vec = (0..num_freqs).map(|i| i as f64 * df).collect(); - let psd: Vec = spectrum[..num_freqs] - .iter() - .map(|c| (c.norm_sqr()) / (n as f64 * sample_rate_hz)) - .collect(); + 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 mut planner = FftPlanner::new(); + let fft = planner.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 short-time Fourier transform. +/// 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, - sample_rate_hz: f64, -) -> (Vec, Vec, Vec>) { - let mut times = Vec::new(); - let mut magnitudes = Vec::new(); - let num_freqs = window_size / 2 + 1; - let df = sample_rate_hz / window_size as f64; - let freqs: Vec = (0..num_freqs).map(|i| i as f64 * df).collect(); - - let mut planner = FftPlanner::::new(); - let fft = planner.plan_fft_forward(window_size); - - let mut pos = 0; - while pos + window_size <= signal.len() { - let window = &signal[pos..pos + window_size]; - let mut spectrum: Vec> = window.iter().map(|&x| Complex::new(x, 0.0)).collect(); - fft.process(&mut spectrum); - let mags: Vec = spectrum[..num_freqs].iter().map(|c| c.norm()).collect(); - magnitudes.push(mags); - times.push(pos as f64 / sample_rate_hz); - pos += hop_size; +) -> 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(), + }; } - (times, freqs, magnitudes) + let win_size = window_size.min(n); + let window = hann_window(win_size); + + let mut planner = FftPlanner::new(); + let fft = planner.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, + } } -/// Compute band power in a given frequency range. -pub fn band_power(signal: &[f64], sample_rate_hz: f64, low_hz: f64, high_hz: f64) -> f64 { - let (freqs, psd) = compute_psd(signal, sample_rate_hz); - let df = if freqs.len() > 1 { freqs[1] - freqs[0] } else { 1.0 }; - freqs.iter().zip(psd.iter()) - .filter(|(&f, _)| f >= low_hz && f <= high_hz) - .map(|(_, &p)| p * df) +/// 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() } -/// Find the peak frequency in the PSD. -pub fn peak_frequency(signal: &[f64], sample_rate_hz: f64) -> f64 { - let (freqs, psd) = compute_psd(signal, sample_rate_hz); - if psd.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)) - .map(|(i, _)| i) - .unwrap_or(0); - freqs.get(max_idx).copied().unwrap_or(0.0) +/// 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 } -/// Compute spectral entropy. -pub fn spectral_entropy(signal: &[f64], sample_rate_hz: f64) -> f64 { - let (_, psd) = compute_psd(signal, sample_rate_hz); - let total: f64 = psd.iter().sum(); - if total <= 0.0 { return 0.0; } - let probs: Vec = psd.iter().map(|&p| p / total).collect(); - -probs.iter() - .filter(|&&p| p > 0.0) - .map(|&p| p * p.ln()) - .sum::() +/// 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-viz/src/animation.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-viz/src/animation.rs new file mode 100644 index 00000000..88ddd1f3 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-viz/src/animation.rs @@ -0,0 +1,277 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/Cargo.toml b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/Cargo.toml index aaec3ae4..40bde08e 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/Cargo.toml +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/Cargo.toml @@ -1,9 +1,26 @@ [package] name = "ruv-neural-wasm" -description = "rUv Neural — WASM bindings (stub)" +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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/README.md b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/README.md new file mode 100644 index 00000000..0a5584c3 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/README.md @@ -0,0 +1,208 @@ +# rUv Neural WASM + +WebAssembly bindings for browser-based brain topology visualization. Part of the **rUv Neural** suite. + +## Overview + +`ruv-neural-wasm` exposes the core brain graph analysis pipeline to JavaScript via `wasm-bindgen`. It provides lightweight, WASM-compatible implementations of graph algorithms (Stoer-Wagner mincut, spectral embedding, topology metrics) that run entirely in the browser without server round-trips. + +## Build + +Requires [wasm-pack](https://rustwasm.github.io/wasm-pack/installer/): + +```bash +# Build for browser (ES modules) +wasm-pack build --target web + +# Build for bundler (webpack, vite, etc.) +wasm-pack build --target bundler + +# Build for Node.js +wasm-pack build --target nodejs + +# Native check (no WASM target required) +cargo check -p ruv-neural-wasm +``` + +## JavaScript Usage + +### Basic Graph Analysis + +```javascript +import init, { + create_brain_graph, + compute_mincut, + compute_topology_metrics, + embed_graph, + decode_state, + to_viz_graph, + version, +} from "./pkg/ruv_neural_wasm.js"; + +await init(); + +console.log("rUv Neural WASM v" + version()); + +// Define a brain connectivity graph +const graphJson = JSON.stringify({ + num_nodes: 4, + edges: [ + { source: 0, target: 1, weight: 0.9, metric: "Coherence", frequency_band: "Alpha" }, + { source: 1, target: 2, weight: 0.3, metric: "Coherence", frequency_band: "Alpha" }, + { source: 2, target: 3, weight: 0.8, metric: "Coherence", frequency_band: "Alpha" }, + { source: 0, target: 3, weight: 0.7, metric: "Coherence", frequency_band: "Alpha" }, + ], + timestamp: Date.now() / 1000, + window_duration_s: 1.0, + atlas: { Custom: 4 }, +}); + +// Parse and validate +const graph = create_brain_graph(graphJson); + +// Compute minimum cut +const mincut = compute_mincut(graphJson); +console.log("Min-cut value:", mincut.cut_value); +console.log("Partition A:", mincut.partition_a); +console.log("Partition B:", mincut.partition_b); + +// Compute topology metrics +const metrics = compute_topology_metrics(graphJson); +console.log("Modularity:", metrics.modularity); +console.log("Fiedler value:", metrics.fiedler_value); +console.log("Global efficiency:", metrics.global_efficiency); + +// Decode cognitive state +const metricsJson = JSON.stringify(metrics); +const state = decode_state(metricsJson); +console.log("Cognitive state:", state); + +// Generate spectral embedding (2D) +const embedding = embed_graph(graphJson, 2); +console.log("Embedding dimension:", embedding.dimension); +``` + +### D3.js Visualization + +```javascript +import { to_viz_graph } from "./pkg/ruv_neural_wasm.js"; + +const vizGraph = to_viz_graph(graphJson); + +// vizGraph.nodes: [{ id, label, x, y, z, group, size, color }, ...] +// vizGraph.edges: [{ source, target, weight, is_cut, color }, ...] +// vizGraph.partitions: [[nodeIds...], [nodeIds...]] or null +// vizGraph.cut_edges: [edgeIndices...] or null + +// Use with D3 force simulation +const simulation = d3 + .forceSimulation(vizGraph.nodes) + .force("link", d3.forceLink(vizGraph.edges).id((d) => d.id)) + .force("charge", d3.forceManyBody().strength(-100)) + .force("center", d3.forceCenter(width / 2, height / 2)); + +// Color nodes by partition group +svg + .selectAll("circle") + .data(vizGraph.nodes) + .enter() + .append("circle") + .attr("r", (d) => d.size * 5) + .attr("fill", (d) => d.color); + +// Highlight cut edges in red +svg + .selectAll("line") + .data(vizGraph.edges) + .enter() + .append("line") + .attr("stroke", (d) => d.color) + .attr("stroke-width", (d) => (d.is_cut ? 3 : 1)); +``` + +### WebSocket Streaming + +```javascript +import { StreamProcessor } from "./pkg/ruv_neural_wasm.js"; + +// Create processor: 256-sample window, 64-sample hop +const processor = new StreamProcessor(256, 64); + +const ws = new WebSocket("ws://localhost:8080/neural-stream"); + +ws.onmessage = (event) => { + const samples = new Float64Array(event.data); + const stats = processor.push_samples(samples); + + if (stats) { + console.log(`Window ${stats.window_index}: mean=${stats.mean.toFixed(3)}`); + updateVisualization(stats); + } +}; + +// Reset when switching sessions +function resetStream() { + processor.reset(); +} +``` + +### RVF File I/O + +```javascript +import { load_rvf, export_rvf } from "./pkg/ruv_neural_wasm.js"; + +// Export graph to RVF binary +const rvfBytes = export_rvf(graphJson); + +// Save as file download +const blob = new Blob([rvfBytes], { type: "application/octet-stream" }); +const url = URL.createObjectURL(blob); + +// Load RVF from file input +const fileInput = document.getElementById("rvf-file"); +fileInput.onchange = async (e) => { + const buffer = await e.target.files[0].arrayBuffer(); + const rvf = load_rvf(new Uint8Array(buffer)); + console.log("Loaded RVF:", rvf.header.data_type); +}; +``` + +## API Reference + +| Function | Description | +|----------|-------------| +| `create_brain_graph(json)` | Parse JSON into a BrainGraph | +| `compute_mincut(json)` | Stoer-Wagner minimum cut (max 500 nodes) | +| `compute_topology_metrics(json)` | Density, efficiency, modularity, Fiedler, entropy | +| `embed_graph(json, dim)` | Spectral embedding via power iteration | +| `decode_state(json)` | Classify cognitive state from metrics | +| `to_viz_graph(json)` | Convert to D3.js/Three.js-ready visualization data | +| `load_rvf(bytes)` | Parse RVF binary file | +| `export_rvf(json)` | Serialize graph to RVF binary | +| `version()` | Get crate version string | +| `StreamProcessor` | Sliding-window streaming data processor | + +## Browser Compatibility + +- Chrome 57+ / Edge 79+ +- Firefox 52+ +- Safari 11+ +- All modern browsers with WebAssembly support + +## Graph Size Limits + +The Stoer-Wagner minimum cut algorithm runs in O(V^3) time. For browser performance: + +| Nodes | Approximate Time | +|-------|-----------------| +| 68 (DK atlas) | < 10ms | +| 100 (Schaefer) | < 50ms | +| 200 (Schaefer) | < 500ms | +| 400 (Schaefer) | ~2-5s | +| 500 (max) | ~5-10s | + +For larger graphs, use the native `ruv-neural-mincut` crate with server-side computation. + +## License + +MIT OR Apache-2.0 diff --git a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/src/graph_wasm.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/src/graph_wasm.rs new file mode 100644 index 00000000..2c555bd5 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/src/graph_wasm.rs @@ -0,0 +1,738 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/src/lib.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/src/lib.rs index 6afb469e..96b900ef 100644 --- a/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/src/lib.rs +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/src/lib.rs @@ -1 +1,305 @@ -//! Stub crate. +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/src/streaming.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/src/streaming.rs new file mode 100644 index 00000000..1c5420ec --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/src/streaming.rs @@ -0,0 +1,217 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/src/viz_data.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/src/viz_data.rs new file mode 100644 index 00000000..aeccc1c5 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/ruv-neural-wasm/src/viz_data.rs @@ -0,0 +1,247 @@ +//! 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/rust-port/wifi-densepose-rs/crates/ruv-neural/tests/integration.rs b/rust-port/wifi-densepose-rs/crates/ruv-neural/tests/integration.rs new file mode 100644 index 00000000..ab76421a --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/ruv-neural/tests/integration.rs @@ -0,0 +1,558 @@ +//! 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 (from non-stub decoder crate) +// --------------------------------------------------------------------------- + +#[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); +}