Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
682
vendor/ruvector/crates/ruvector-mincut/src/snn/attractor.rs
vendored
Normal file
682
vendor/ruvector/crates/ruvector-mincut/src/snn/attractor.rs
vendored
Normal file
@@ -0,0 +1,682 @@
|
||||
//! # Layer 1: Temporal Attractors as SNN Energy Landscapes
|
||||
//!
|
||||
//! Implements unified attractor dynamics across graph and neural representations.
|
||||
//!
|
||||
//! ## Theory
|
||||
//!
|
||||
//! The Lyapunov function `V(x) = -mincut - synchrony` unifies graph connectivity
|
||||
//! and neural coherence into a single energy landscape. Gradient descent simultaneously:
|
||||
//!
|
||||
//! - Strengthens critical graph edges
|
||||
//! - Synchronizes correlated neural populations
|
||||
//! - Discovers natural cluster boundaries
|
||||
//!
|
||||
//! ## Key Insight
|
||||
//!
|
||||
//! High-synchrony neuron pairs indicate strong connectivity - skip in Karger contraction
|
||||
//! for subpolynomial mincut computation.
|
||||
|
||||
use super::{
|
||||
compute_energy, compute_synchrony,
|
||||
network::{LayerConfig, NetworkConfig, SpikingNetwork},
|
||||
synapse::SynapseMatrix,
|
||||
SimTime, Spike,
|
||||
};
|
||||
use crate::graph::{DynamicGraph, VertexId, Weight};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Configuration for attractor dynamics
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AttractorConfig {
|
||||
/// Time step for integration
|
||||
pub dt: f64,
|
||||
/// Convergence threshold for energy gradient
|
||||
pub epsilon: f64,
|
||||
/// Maximum steps before timeout
|
||||
pub max_steps: usize,
|
||||
/// STDP time window for synchrony computation
|
||||
pub stdp_window: f64,
|
||||
/// Weight modulation factor from spikes
|
||||
pub weight_factor: f64,
|
||||
/// Synchrony threshold for edge skip mask
|
||||
pub sync_threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for AttractorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dt: 1.0,
|
||||
epsilon: 0.001,
|
||||
max_steps: 10000,
|
||||
stdp_window: 20.0,
|
||||
weight_factor: 0.01,
|
||||
sync_threshold: 0.8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Energy landscape representation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnergyLandscape {
|
||||
/// Current energy value
|
||||
pub energy: f64,
|
||||
/// Energy gradient (rate of change)
|
||||
pub gradient: f64,
|
||||
/// MinCut contribution
|
||||
pub mincut_component: f64,
|
||||
/// Synchrony contribution
|
||||
pub synchrony_component: f64,
|
||||
/// History of energy values
|
||||
history: Vec<f64>,
|
||||
}
|
||||
|
||||
impl EnergyLandscape {
|
||||
/// Create new energy landscape
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
energy: 0.0,
|
||||
gradient: f64::MAX,
|
||||
mincut_component: 0.0,
|
||||
synchrony_component: 0.0,
|
||||
history: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update with new measurements
|
||||
pub fn update(&mut self, mincut: f64, synchrony: f64) {
|
||||
self.mincut_component = mincut;
|
||||
self.synchrony_component = synchrony;
|
||||
|
||||
let new_energy = compute_energy(mincut, synchrony);
|
||||
|
||||
if !self.history.is_empty() {
|
||||
self.gradient = new_energy - self.energy;
|
||||
}
|
||||
|
||||
self.energy = new_energy;
|
||||
self.history.push(new_energy);
|
||||
|
||||
// Keep bounded history
|
||||
if self.history.len() > 1000 {
|
||||
self.history.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if at attractor (gradient near zero)
|
||||
pub fn at_attractor(&self, epsilon: f64) -> bool {
|
||||
self.gradient.abs() < epsilon
|
||||
}
|
||||
|
||||
/// Get energy variance over recent history
|
||||
pub fn variance(&self) -> f64 {
|
||||
if self.history.len() < 2 {
|
||||
return f64::MAX;
|
||||
}
|
||||
|
||||
let mean = self.history.iter().sum::<f64>() / self.history.len() as f64;
|
||||
let var: f64 = self.history.iter().map(|&e| (e - mean).powi(2)).sum();
|
||||
|
||||
var / self.history.len() as f64
|
||||
}
|
||||
|
||||
/// Check for oscillation (sign changes in gradient)
|
||||
pub fn is_oscillating(&self, window: usize) -> bool {
|
||||
if self.history.len() < window + 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let recent: Vec<_> = self.history.iter().rev().take(window + 1).collect();
|
||||
let mut sign_changes = 0;
|
||||
|
||||
for i in 0..recent.len() - 1 {
|
||||
let diff1 = recent[i] - recent[i + 1];
|
||||
if i + 2 < recent.len() {
|
||||
let diff2 = recent[i + 1] - recent[i + 2];
|
||||
if diff1.signum() != diff2.signum() {
|
||||
sign_changes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sign_changes > window / 2
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EnergyLandscape {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified attractor dynamics across graph and neural representations
|
||||
pub struct AttractorDynamics {
|
||||
/// Graph state (mincut tracks stability)
|
||||
graph: DynamicGraph,
|
||||
/// Neural state (membrane potentials encode graph weights)
|
||||
snn: SpikingNetwork,
|
||||
/// Current energy landscape
|
||||
energy: EnergyLandscape,
|
||||
/// Configuration
|
||||
config: AttractorConfig,
|
||||
/// Current simulation time
|
||||
time: SimTime,
|
||||
/// Whether attractor has been reached
|
||||
at_attractor: bool,
|
||||
/// Spike history for STDP-based weight updates
|
||||
spike_history: Vec<Spike>,
|
||||
}
|
||||
|
||||
impl AttractorDynamics {
|
||||
/// Create new attractor dynamics from existing graph
|
||||
pub fn new(graph: DynamicGraph, config: AttractorConfig) -> Self {
|
||||
// Create SNN matching graph topology
|
||||
let n = graph.num_vertices();
|
||||
let network_config = NetworkConfig {
|
||||
layers: vec![LayerConfig::new(n).with_recurrence()],
|
||||
..NetworkConfig::default()
|
||||
};
|
||||
|
||||
let snn = SpikingNetwork::from_graph(&graph, network_config);
|
||||
|
||||
Self {
|
||||
graph,
|
||||
snn,
|
||||
energy: EnergyLandscape::new(),
|
||||
config,
|
||||
time: 0.0,
|
||||
at_attractor: false,
|
||||
spike_history: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one integration step
|
||||
pub fn step(&mut self) -> Vec<Spike> {
|
||||
// 1. SNN dynamics update membrane potentials
|
||||
let spikes = self.snn.step();
|
||||
|
||||
// Record spikes for STDP
|
||||
self.spike_history.extend(spikes.iter().cloned());
|
||||
|
||||
// 2. Spikes modulate edge weights via STDP
|
||||
self.apply_stdp_weight_updates(&spikes);
|
||||
|
||||
// 3. MinCut computation (with sync-guided skip)
|
||||
let mincut = self.compute_mincut_subpoly();
|
||||
|
||||
// 4. Update energy landscape
|
||||
let synchrony = self.snn.global_synchrony();
|
||||
self.energy.update(mincut, synchrony);
|
||||
|
||||
// 5. Check for attractor
|
||||
if self.energy.at_attractor(self.config.epsilon) {
|
||||
self.at_attractor = true;
|
||||
}
|
||||
|
||||
self.time += self.config.dt;
|
||||
|
||||
// Prune old spike history
|
||||
let cutoff = self.time - self.config.stdp_window;
|
||||
self.spike_history.retain(|s| s.time >= cutoff);
|
||||
|
||||
spikes
|
||||
}
|
||||
|
||||
/// Apply STDP-based weight updates from spikes
|
||||
fn apply_stdp_weight_updates(&mut self, spikes: &[Spike]) {
|
||||
// Get graph vertices
|
||||
let vertices: Vec<_> = self.graph.vertices();
|
||||
|
||||
// Group spikes by time window
|
||||
let mut spike_counts: std::collections::HashMap<usize, usize> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for spike in spikes {
|
||||
*spike_counts.entry(spike.neuron_id).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
// High spike correlation → strengthen edge
|
||||
for edge in self.graph.edges() {
|
||||
let src_idx = vertices.iter().position(|&v| v == edge.source).unwrap_or(0);
|
||||
let tgt_idx = vertices.iter().position(|&v| v == edge.target).unwrap_or(0);
|
||||
|
||||
let src_spikes = spike_counts.get(&src_idx).copied().unwrap_or(0);
|
||||
let tgt_spikes = spike_counts.get(&tgt_idx).copied().unwrap_or(0);
|
||||
|
||||
// Hebbian-like weight update
|
||||
let correlation = (src_spikes * tgt_spikes) as f64;
|
||||
let delta_w = self.config.weight_factor * correlation;
|
||||
|
||||
if delta_w > 0.0 {
|
||||
let new_weight = edge.weight + delta_w;
|
||||
let _ = self
|
||||
.graph
|
||||
.update_edge_weight(edge.source, edge.target, new_weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute mincut with SNN-guided edge selection (subpolynomial)
|
||||
fn compute_mincut_subpoly(&self) -> f64 {
|
||||
// Get synchrony matrix
|
||||
let sync_matrix = self.snn.synchrony_matrix();
|
||||
|
||||
// Create skip mask for edges with high synchrony
|
||||
let vertices: Vec<_> = self.graph.vertices();
|
||||
let mut skip_edges = std::collections::HashSet::new();
|
||||
|
||||
for i in 0..sync_matrix.len() {
|
||||
for j in (i + 1)..sync_matrix[i].len() {
|
||||
if sync_matrix[i][j] >= self.config.sync_threshold {
|
||||
if i < vertices.len() && j < vertices.len() {
|
||||
skip_edges.insert((vertices[i], vertices[j]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified Karger-Stein with skip (actual implementation would use full algorithm)
|
||||
self.karger_stein_with_skip(&skip_edges)
|
||||
}
|
||||
|
||||
/// Fast approximate mincut with skip edges
|
||||
///
|
||||
/// Uses optimized Karger-Stein with early termination and reduced iterations.
|
||||
/// For SNN context, we need relative accuracy not exact values.
|
||||
/// Time complexity: O(n log n) amortized with early termination.
|
||||
fn karger_stein_with_skip(
|
||||
&self,
|
||||
skip_edges: &std::collections::HashSet<(VertexId, VertexId)>,
|
||||
) -> f64 {
|
||||
let vertices: Vec<_> = self.graph.vertices();
|
||||
let n = vertices.len();
|
||||
|
||||
if n <= 1 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// For small graphs, use exact algorithm
|
||||
if n <= 10 {
|
||||
return self.exact_mincut_small(skip_edges, &vertices);
|
||||
}
|
||||
|
||||
// Build compact adjacency representation (Vec-based for speed)
|
||||
let mut vertex_to_idx: std::collections::HashMap<VertexId, usize> =
|
||||
vertices.iter().enumerate().map(|(i, &v)| (v, i)).collect();
|
||||
|
||||
let mut adj_weights: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
|
||||
let mut total_weight = 0.0;
|
||||
|
||||
for edge in self.graph.edges() {
|
||||
let key1 = (edge.source, edge.target);
|
||||
let key2 = (edge.target, edge.source);
|
||||
|
||||
if !skip_edges.contains(&key1) && !skip_edges.contains(&key2) {
|
||||
if let (Some(&i), Some(&j)) = (
|
||||
vertex_to_idx.get(&edge.source),
|
||||
vertex_to_idx.get(&edge.target),
|
||||
) {
|
||||
adj_weights[i].push((j, edge.weight));
|
||||
adj_weights[j].push((i, edge.weight));
|
||||
total_weight += edge.weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fewer iterations with early termination - O(log n) typically sufficient
|
||||
let max_iterations = ((n as f64).ln().ceil() as usize).max(3).min(10);
|
||||
let mut best_cut = f64::INFINITY;
|
||||
|
||||
// Early termination threshold: if we find a cut < avg_edge_weight, stop
|
||||
let avg_edge = total_weight / (self.graph.num_edges().max(1) as f64);
|
||||
let early_threshold = avg_edge * 2.0;
|
||||
|
||||
for iter in 0..max_iterations {
|
||||
let cut = self.karger_contract_fast(&adj_weights, n, iter as u64);
|
||||
if cut < best_cut {
|
||||
best_cut = cut;
|
||||
// Early termination for good cuts
|
||||
if best_cut <= early_threshold {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if best_cut == f64::INFINITY {
|
||||
0.0
|
||||
} else {
|
||||
best_cut
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact mincut for small graphs (brute force is fine for n <= 10)
|
||||
fn exact_mincut_small(
|
||||
&self,
|
||||
skip_edges: &std::collections::HashSet<(VertexId, VertexId)>,
|
||||
vertices: &[VertexId],
|
||||
) -> f64 {
|
||||
let n = vertices.len();
|
||||
if n <= 1 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Build edge weights excluding skipped
|
||||
let mut edge_weights: Vec<(VertexId, VertexId, f64)> = Vec::new();
|
||||
for edge in self.graph.edges() {
|
||||
let key1 = (edge.source, edge.target);
|
||||
let key2 = (edge.target, edge.source);
|
||||
if !skip_edges.contains(&key1) && !skip_edges.contains(&key2) {
|
||||
edge_weights.push((edge.source, edge.target, edge.weight));
|
||||
}
|
||||
}
|
||||
|
||||
// Try all 2^(n-1) - 1 partitions (fixing first vertex)
|
||||
let mut best_cut = f64::INFINITY;
|
||||
let first = vertices[0];
|
||||
|
||||
for mask in 1..(1u64 << (n - 1)) {
|
||||
let mut cut_weight = 0.0;
|
||||
|
||||
// Check each edge
|
||||
for &(u, v, w) in &edge_weights {
|
||||
let u_idx = vertices.iter().position(|&x| x == u);
|
||||
let v_idx = vertices.iter().position(|&x| x == v);
|
||||
|
||||
if let (Some(ui), Some(vi)) = (u_idx, v_idx) {
|
||||
// First vertex always in set 0
|
||||
let u_in_set = if ui == 0 {
|
||||
false
|
||||
} else {
|
||||
(mask >> (ui - 1)) & 1 == 1
|
||||
};
|
||||
let v_in_set = if vi == 0 {
|
||||
false
|
||||
} else {
|
||||
(mask >> (vi - 1)) & 1 == 1
|
||||
};
|
||||
|
||||
if u_in_set != v_in_set {
|
||||
cut_weight += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cut_weight < best_cut {
|
||||
best_cut = cut_weight;
|
||||
}
|
||||
}
|
||||
|
||||
if best_cut == f64::INFINITY {
|
||||
0.0
|
||||
} else {
|
||||
best_cut
|
||||
}
|
||||
}
|
||||
|
||||
/// Fast Karger contraction using Vec-based adjacency
|
||||
fn karger_contract_fast(&self, adj_weights: &[Vec<(usize, f64)>], n: usize, seed: u64) -> f64 {
|
||||
// Union-find with path compression and union by rank
|
||||
let mut parent: Vec<usize> = (0..n).collect();
|
||||
let mut rank: Vec<usize> = vec![0; n];
|
||||
let mut component_count = n;
|
||||
|
||||
// Pre-compute edge list with total weight
|
||||
let mut edges: Vec<(usize, usize, f64)> = Vec::new();
|
||||
let mut total_weight = 0.0;
|
||||
|
||||
for (u, neighbors) in adj_weights.iter().enumerate() {
|
||||
for &(v, w) in neighbors {
|
||||
if u < v {
|
||||
edges.push((u, v, w));
|
||||
total_weight += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if edges.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Seeded PRNG
|
||||
let mut rng_state = seed.wrapping_add(0x9e3779b97f4a7c15);
|
||||
let mut rand = || {
|
||||
rng_state = rng_state.wrapping_mul(0x5851f42d4c957f2d).wrapping_add(1);
|
||||
rng_state
|
||||
};
|
||||
|
||||
// Find with path compression
|
||||
fn find(parent: &mut [usize], mut x: usize) -> usize {
|
||||
let mut root = x;
|
||||
while parent[root] != root {
|
||||
root = parent[root];
|
||||
}
|
||||
// Path compression
|
||||
while parent[x] != root {
|
||||
let next = parent[x];
|
||||
parent[x] = root;
|
||||
x = next;
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
// Union by rank
|
||||
fn union(parent: &mut [usize], rank: &mut [usize], x: usize, y: usize) -> bool {
|
||||
let rx = find(parent, x);
|
||||
let ry = find(parent, y);
|
||||
if rx == ry {
|
||||
return false;
|
||||
}
|
||||
if rank[rx] < rank[ry] {
|
||||
parent[rx] = ry;
|
||||
} else if rank[rx] > rank[ry] {
|
||||
parent[ry] = rx;
|
||||
} else {
|
||||
parent[ry] = rx;
|
||||
rank[rx] += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// Contract until 2 components remain
|
||||
while component_count > 2 && total_weight > 0.0 {
|
||||
// Weighted random edge selection
|
||||
let threshold = (rand() as f64 / u64::MAX as f64) * total_weight;
|
||||
let mut cumulative = 0.0;
|
||||
let mut selected = edges[0];
|
||||
|
||||
for &edge in &edges {
|
||||
let (eu, ev, ew) = edge;
|
||||
let ru = find(&mut parent, eu);
|
||||
let rv = find(&mut parent, ev);
|
||||
// Only count edges between different components
|
||||
if ru != rv {
|
||||
cumulative += ew;
|
||||
if cumulative >= threshold {
|
||||
selected = edge;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (u, v, w) = selected;
|
||||
let root_u = find(&mut parent, u);
|
||||
let root_v = find(&mut parent, v);
|
||||
|
||||
if root_u == root_v {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Contract by union
|
||||
if union(&mut parent, &mut rank, root_u, root_v) {
|
||||
component_count -= 1;
|
||||
// Update total_weight (edges between these components are now internal)
|
||||
// This is approximate but fast
|
||||
total_weight -= w;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate cut value: sum of edges crossing the two remaining components
|
||||
let mut cut_value = 0.0;
|
||||
for &(u, v, w) in &edges {
|
||||
let ru = find(&mut parent, u);
|
||||
let rv = find(&mut parent, v);
|
||||
if ru != rv {
|
||||
cut_value += w;
|
||||
}
|
||||
}
|
||||
|
||||
cut_value
|
||||
}
|
||||
|
||||
/// Check if attractor has been reached
|
||||
pub fn reached_attractor(&self) -> bool {
|
||||
self.at_attractor
|
||||
}
|
||||
|
||||
/// Get current energy
|
||||
pub fn energy(&self) -> f64 {
|
||||
self.energy.energy
|
||||
}
|
||||
|
||||
/// Get energy landscape
|
||||
pub fn energy_landscape(&self) -> &EnergyLandscape {
|
||||
&self.energy
|
||||
}
|
||||
|
||||
/// Get underlying graph
|
||||
pub fn graph(&self) -> &DynamicGraph {
|
||||
&self.graph
|
||||
}
|
||||
|
||||
/// Get configuration
|
||||
pub fn config(&self) -> &AttractorConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Get mutable graph reference
|
||||
pub fn graph_mut(&mut self) -> &mut DynamicGraph {
|
||||
&mut self.graph
|
||||
}
|
||||
|
||||
/// Get underlying SNN
|
||||
pub fn snn(&self) -> &SpikingNetwork {
|
||||
&self.snn
|
||||
}
|
||||
|
||||
/// Run until attractor is reached or max steps
|
||||
pub fn evolve_to_attractor(&mut self) -> (Vec<Spike>, usize) {
|
||||
let mut all_spikes = Vec::new();
|
||||
let mut steps = 0;
|
||||
|
||||
for _ in 0..self.config.max_steps {
|
||||
let spikes = self.step();
|
||||
all_spikes.extend(spikes);
|
||||
steps += 1;
|
||||
|
||||
if self.at_attractor {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(all_spikes, steps)
|
||||
}
|
||||
|
||||
/// Get synchrony-based edge mask for search optimization
|
||||
pub fn get_skip_mask(&self) -> Vec<(VertexId, VertexId)> {
|
||||
let sync_matrix = self.snn.synchrony_matrix();
|
||||
let vertices: Vec<_> = self.graph.vertices();
|
||||
|
||||
let mut skip = Vec::new();
|
||||
|
||||
for i in 0..sync_matrix.len() {
|
||||
for j in (i + 1)..sync_matrix[i].len() {
|
||||
if sync_matrix[i][j] >= self.config.sync_threshold {
|
||||
if i < vertices.len() && j < vertices.len() {
|
||||
skip.push((vertices[i], vertices[j]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
skip
|
||||
}
|
||||
|
||||
/// Inject external current to perturb system
|
||||
pub fn perturb(&mut self, currents: &[f64]) {
|
||||
self.snn.inject_current(currents);
|
||||
self.at_attractor = false;
|
||||
}
|
||||
|
||||
/// Reset to initial state
|
||||
pub fn reset(&mut self) {
|
||||
self.snn.reset();
|
||||
self.energy = EnergyLandscape::new();
|
||||
self.time = 0.0;
|
||||
self.at_attractor = false;
|
||||
self.spike_history.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_energy_landscape() {
|
||||
let mut landscape = EnergyLandscape::new();
|
||||
|
||||
landscape.update(10.0, 0.5);
|
||||
assert!(landscape.energy < 0.0);
|
||||
|
||||
landscape.update(15.0, 0.7);
|
||||
assert!(landscape.gradient != 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_attractor_dynamics_creation() {
|
||||
let graph = DynamicGraph::new();
|
||||
graph.insert_edge(0, 1, 1.0).unwrap();
|
||||
graph.insert_edge(1, 2, 1.0).unwrap();
|
||||
graph.insert_edge(2, 0, 1.0).unwrap();
|
||||
|
||||
let config = AttractorConfig::default();
|
||||
let dynamics = AttractorDynamics::new(graph, config);
|
||||
|
||||
assert!(!dynamics.reached_attractor());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_attractor_step() {
|
||||
let graph = DynamicGraph::new();
|
||||
for i in 0..5 {
|
||||
graph.insert_edge(i, (i + 1) % 5, 1.0).unwrap();
|
||||
}
|
||||
|
||||
let config = AttractorConfig::default();
|
||||
let mut dynamics = AttractorDynamics::new(graph, config);
|
||||
|
||||
// Run a few steps
|
||||
for _ in 0..10 {
|
||||
dynamics.step();
|
||||
}
|
||||
|
||||
// Energy should be computed
|
||||
assert!(dynamics.energy().is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skip_mask() {
|
||||
let graph = DynamicGraph::new();
|
||||
for i in 0..10 {
|
||||
for j in (i + 1)..10 {
|
||||
graph.insert_edge(i, j, 1.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let config = AttractorConfig::default();
|
||||
let dynamics = AttractorDynamics::new(graph, config);
|
||||
|
||||
let mask = dynamics.get_skip_mask();
|
||||
// Should produce some skip mask (may be empty initially)
|
||||
assert!(mask.len() >= 0);
|
||||
}
|
||||
}
|
||||
637
vendor/ruvector/crates/ruvector-mincut/src/snn/causal.rs
vendored
Normal file
637
vendor/ruvector/crates/ruvector-mincut/src/snn/causal.rs
vendored
Normal file
@@ -0,0 +1,637 @@
|
||||
//! # Layer 3: Causal Discovery via Spike Timing
|
||||
//!
|
||||
//! Uses spike-timing cross-correlation to infer causal relationships in graph events.
|
||||
//!
|
||||
//! ## Key Insight
|
||||
//!
|
||||
//! Spike train cross-correlation with asymmetric temporal windows naturally encodes
|
||||
//! Granger-like causality:
|
||||
//!
|
||||
//! ```text
|
||||
//! Neuron A: ──●────────●────────●────────
|
||||
//! Neuron B: ────────●────────●────────●──
|
||||
//! │←─Δt─→│
|
||||
//!
|
||||
//! If Δt consistently positive → A causes B
|
||||
//! STDP learning rule naturally encodes this!
|
||||
//! ```
|
||||
//!
|
||||
//! After learning, W_AB reflects causal strength A→B
|
||||
//!
|
||||
//! ## MinCut Application
|
||||
//!
|
||||
//! MinCut on the causal graph reveals optimal intervention points -
|
||||
//! minimum changes needed to affect outcomes.
|
||||
|
||||
use super::{
|
||||
neuron::{LIFNeuron, NeuronConfig, SpikeTrain},
|
||||
synapse::{AsymmetricSTDP, STDPConfig, Synapse, SynapseMatrix},
|
||||
SimTime, Spike,
|
||||
};
|
||||
use crate::graph::{DynamicGraph, EdgeId, VertexId};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
/// Configuration for causal discovery
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CausalConfig {
|
||||
/// Number of event types (neurons)
|
||||
pub num_event_types: usize,
|
||||
/// Threshold for causal relationship detection
|
||||
pub causal_threshold: f64,
|
||||
/// Time window for causality (ms)
|
||||
pub time_window: f64,
|
||||
/// Asymmetric STDP configuration
|
||||
pub stdp: AsymmetricSTDP,
|
||||
/// Learning rate for causal weight updates
|
||||
pub learning_rate: f64,
|
||||
/// Decay rate for causal weights
|
||||
pub decay_rate: f64,
|
||||
}
|
||||
|
||||
impl Default for CausalConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
num_event_types: 100,
|
||||
causal_threshold: 0.1,
|
||||
time_window: 50.0,
|
||||
stdp: AsymmetricSTDP::default(),
|
||||
learning_rate: 0.01,
|
||||
decay_rate: 0.001,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type of causal relationship
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum CausalRelation {
|
||||
/// A causes B (positive influence)
|
||||
Causes,
|
||||
/// A prevents B (negative influence)
|
||||
Prevents,
|
||||
/// No significant causal relationship
|
||||
None,
|
||||
}
|
||||
|
||||
/// A directed causal relationship
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CausalEdge {
|
||||
/// Source event type
|
||||
pub source: usize,
|
||||
/// Target event type
|
||||
pub target: usize,
|
||||
/// Causal strength (absolute value)
|
||||
pub strength: f64,
|
||||
/// Type of relationship
|
||||
pub relation: CausalRelation,
|
||||
}
|
||||
|
||||
/// Directed graph representing causal relationships
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CausalGraph {
|
||||
/// Number of nodes (event types)
|
||||
pub num_nodes: usize,
|
||||
/// Causal edges
|
||||
edges: Vec<CausalEdge>,
|
||||
/// Adjacency list (source → targets)
|
||||
adjacency: HashMap<usize, Vec<(usize, f64, CausalRelation)>>,
|
||||
}
|
||||
|
||||
impl CausalGraph {
|
||||
/// Create a new empty causal graph
|
||||
pub fn new(num_nodes: usize) -> Self {
|
||||
Self {
|
||||
num_nodes,
|
||||
edges: Vec::new(),
|
||||
adjacency: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a causal edge
|
||||
pub fn add_edge(
|
||||
&mut self,
|
||||
source: usize,
|
||||
target: usize,
|
||||
strength: f64,
|
||||
relation: CausalRelation,
|
||||
) {
|
||||
self.edges.push(CausalEdge {
|
||||
source,
|
||||
target,
|
||||
strength,
|
||||
relation,
|
||||
});
|
||||
|
||||
self.adjacency
|
||||
.entry(source)
|
||||
.or_insert_with(Vec::new)
|
||||
.push((target, strength, relation));
|
||||
}
|
||||
|
||||
/// Get edges from a node
|
||||
pub fn edges_from(&self, source: usize) -> &[(usize, f64, CausalRelation)] {
|
||||
self.adjacency
|
||||
.get(&source)
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Get all edges
|
||||
pub fn edges(&self) -> &[CausalEdge] {
|
||||
&self.edges
|
||||
}
|
||||
|
||||
/// Maximum nodes for transitive closure (O(n³) algorithm)
|
||||
const MAX_CLOSURE_NODES: usize = 500;
|
||||
|
||||
/// Compute transitive closure (indirect causation)
|
||||
///
|
||||
/// Uses Floyd-Warshall algorithm with O(n³) complexity.
|
||||
/// Limited to MAX_CLOSURE_NODES to prevent DoS.
|
||||
pub fn transitive_closure(&self) -> Self {
|
||||
let mut closed = Self::new(self.num_nodes);
|
||||
|
||||
// Resource limit: skip if too many nodes (O(n³) would be too slow)
|
||||
if self.num_nodes > Self::MAX_CLOSURE_NODES {
|
||||
// Just copy direct edges without transitive closure
|
||||
for edge in &self.edges {
|
||||
closed.add_edge(edge.source, edge.target, edge.strength, edge.relation);
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
// Copy direct edges
|
||||
for edge in &self.edges {
|
||||
closed.add_edge(edge.source, edge.target, edge.strength, edge.relation);
|
||||
}
|
||||
|
||||
// Floyd-Warshall-like algorithm for transitive closure
|
||||
for k in 0..self.num_nodes {
|
||||
for i in 0..self.num_nodes {
|
||||
for j in 0..self.num_nodes {
|
||||
if i == j || i == k || j == k {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if path i→k→j exists
|
||||
let ik_strength = self
|
||||
.adjacency
|
||||
.get(&i)
|
||||
.and_then(|edges| edges.iter().find(|(t, _, _)| *t == k))
|
||||
.map(|(_, s, _)| *s);
|
||||
|
||||
let kj_strength = self
|
||||
.adjacency
|
||||
.get(&k)
|
||||
.and_then(|edges| edges.iter().find(|(t, _, _)| *t == j))
|
||||
.map(|(_, s, _)| *s);
|
||||
|
||||
if let (Some(s1), Some(s2)) = (ik_strength, kj_strength) {
|
||||
let indirect_strength = s1 * s2;
|
||||
|
||||
// Only add if stronger than existing direct path
|
||||
let existing = closed
|
||||
.adjacency
|
||||
.get(&i)
|
||||
.and_then(|edges| edges.iter().find(|(t, _, _)| *t == j))
|
||||
.map(|(_, s, _)| *s)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
if indirect_strength > existing {
|
||||
closed.add_edge(i, j, indirect_strength, CausalRelation::Causes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closed
|
||||
}
|
||||
|
||||
/// Find nodes reachable from a source
|
||||
pub fn reachable_from(&self, source: usize) -> HashSet<usize> {
|
||||
let mut visited = HashSet::new();
|
||||
let mut queue = VecDeque::new();
|
||||
|
||||
queue.push_back(source);
|
||||
visited.insert(source);
|
||||
|
||||
while let Some(node) = queue.pop_front() {
|
||||
for (target, _, _) in self.edges_from(node) {
|
||||
if visited.insert(*target) {
|
||||
queue.push_back(*target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visited
|
||||
}
|
||||
|
||||
/// Convert to undirected graph for mincut analysis
|
||||
pub fn to_undirected(&self) -> DynamicGraph {
|
||||
let graph = DynamicGraph::new();
|
||||
|
||||
for edge in &self.edges {
|
||||
if !graph.has_edge(edge.source as u64, edge.target as u64) {
|
||||
let _ = graph.insert_edge(edge.source as u64, edge.target as u64, edge.strength);
|
||||
}
|
||||
}
|
||||
|
||||
graph
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph event that can be observed
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GraphEvent {
|
||||
/// Type of event
|
||||
pub event_type: GraphEventType,
|
||||
/// Associated vertex (if applicable)
|
||||
pub vertex: Option<VertexId>,
|
||||
/// Associated edge (if applicable)
|
||||
pub edge: Option<(VertexId, VertexId)>,
|
||||
/// Event metadata
|
||||
pub data: f64,
|
||||
}
|
||||
|
||||
/// Types of graph events
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum GraphEventType {
|
||||
/// Edge was added
|
||||
EdgeInsert,
|
||||
/// Edge was removed
|
||||
EdgeDelete,
|
||||
/// Edge weight changed
|
||||
WeightChange,
|
||||
/// MinCut value changed
|
||||
MinCutChange,
|
||||
/// Component split
|
||||
ComponentSplit,
|
||||
/// Component merged
|
||||
ComponentMerge,
|
||||
}
|
||||
|
||||
/// Causal discovery using spiking neural network
|
||||
pub struct CausalDiscoverySNN {
|
||||
/// One neuron per graph event type
|
||||
event_neurons: Vec<LIFNeuron>,
|
||||
/// Spike trains for each neuron
|
||||
spike_trains: Vec<SpikeTrain>,
|
||||
/// Synaptic weights encode discovered causal strength
|
||||
synapses: SynapseMatrix,
|
||||
/// Asymmetric STDP for causality detection
|
||||
stdp: AsymmetricSTDP,
|
||||
/// Configuration
|
||||
config: CausalConfig,
|
||||
/// Current simulation time
|
||||
time: SimTime,
|
||||
/// Event type mapping
|
||||
event_type_map: HashMap<GraphEventType, usize>,
|
||||
/// Reverse mapping
|
||||
index_to_event: HashMap<usize, GraphEventType>,
|
||||
}
|
||||
|
||||
impl CausalDiscoverySNN {
|
||||
/// Create a new causal discovery SNN
|
||||
pub fn new(config: CausalConfig) -> Self {
|
||||
let n = config.num_event_types;
|
||||
|
||||
// Create event neurons
|
||||
let neuron_config = NeuronConfig {
|
||||
tau_membrane: 10.0, // Fast response
|
||||
threshold: 0.5,
|
||||
..NeuronConfig::default()
|
||||
};
|
||||
|
||||
let event_neurons: Vec<_> = (0..n)
|
||||
.map(|i| LIFNeuron::with_config(i, neuron_config.clone()))
|
||||
.collect();
|
||||
|
||||
let spike_trains: Vec<_> = (0..n)
|
||||
.map(|i| SpikeTrain::with_window(i, config.time_window * 10.0))
|
||||
.collect();
|
||||
|
||||
// Fully connected synapses
|
||||
let mut synapses = SynapseMatrix::new(n, n);
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if i != j {
|
||||
synapses.add_synapse(i, j, 0.0); // Start with zero weights
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize event type mapping
|
||||
let event_type_map: HashMap<_, _> = [
|
||||
(GraphEventType::EdgeInsert, 0),
|
||||
(GraphEventType::EdgeDelete, 1),
|
||||
(GraphEventType::WeightChange, 2),
|
||||
(GraphEventType::MinCutChange, 3),
|
||||
(GraphEventType::ComponentSplit, 4),
|
||||
(GraphEventType::ComponentMerge, 5),
|
||||
]
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let index_to_event: HashMap<_, _> = event_type_map.iter().map(|(k, v)| (*v, *k)).collect();
|
||||
|
||||
Self {
|
||||
event_neurons,
|
||||
spike_trains,
|
||||
synapses,
|
||||
stdp: config.stdp.clone(),
|
||||
config,
|
||||
time: 0.0,
|
||||
event_type_map,
|
||||
index_to_event,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert graph event to neuron index
|
||||
fn event_to_neuron(&self, event: &GraphEvent) -> usize {
|
||||
self.event_type_map
|
||||
.get(&event.event_type)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Observe a graph event
|
||||
pub fn observe_event(&mut self, event: GraphEvent, timestamp: SimTime) {
|
||||
self.time = timestamp;
|
||||
|
||||
// Convert graph event to spike
|
||||
let neuron_id = self.event_to_neuron(&event);
|
||||
|
||||
if neuron_id < self.event_neurons.len() {
|
||||
// Record spike
|
||||
self.event_neurons[neuron_id].inject_spike(timestamp);
|
||||
self.spike_trains[neuron_id].record_spike(timestamp);
|
||||
|
||||
// STDP update: causal relationships emerge in weights
|
||||
self.stdp
|
||||
.update_weights(&mut self.synapses, neuron_id, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a batch of events
|
||||
pub fn observe_events(&mut self, events: &[GraphEvent], timestamps: &[SimTime]) {
|
||||
for (event, &ts) in events.iter().zip(timestamps.iter()) {
|
||||
self.observe_event(event.clone(), ts);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decay all synaptic weights toward baseline
|
||||
///
|
||||
/// Applies exponential decay: w' = w * (1 - decay_rate) + baseline * decay_rate
|
||||
pub fn decay_weights(&mut self) {
|
||||
let decay = self.config.decay_rate;
|
||||
let baseline = 0.5; // Neutral weight
|
||||
let n = self.config.num_event_types;
|
||||
|
||||
// Iterate through all possible synapse pairs
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if let Some(synapse) = self.synapses.get_synapse_mut(i, j) {
|
||||
// Exponential decay toward baseline
|
||||
synapse.weight = synapse.weight * (1.0 - decay) + baseline * decay;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract causal graph from learned weights
|
||||
pub fn extract_causal_graph(&self) -> CausalGraph {
|
||||
let n = self.config.num_event_types;
|
||||
let mut graph = CausalGraph::new(n);
|
||||
|
||||
for ((i, j), synapse) in self.synapses.iter() {
|
||||
let w = synapse.weight;
|
||||
|
||||
if w.abs() > self.config.causal_threshold {
|
||||
let strength = w.abs();
|
||||
let relation = if w > 0.0 {
|
||||
CausalRelation::Causes
|
||||
} else {
|
||||
CausalRelation::Prevents
|
||||
};
|
||||
|
||||
graph.add_edge(*i, *j, strength, relation);
|
||||
}
|
||||
}
|
||||
|
||||
graph
|
||||
}
|
||||
|
||||
/// Find optimal intervention points using MinCut on causal graph
|
||||
pub fn optimal_intervention_points(
|
||||
&self,
|
||||
controllable: &[usize],
|
||||
targets: &[usize],
|
||||
) -> Vec<usize> {
|
||||
let causal = self.extract_causal_graph();
|
||||
let undirected = causal.to_undirected();
|
||||
|
||||
// Simple heuristic: find nodes on paths from controllable to targets
|
||||
let mut intervention_points = Vec::new();
|
||||
let controllable_set: HashSet<_> = controllable.iter().cloned().collect();
|
||||
let target_set: HashSet<_> = targets.iter().cloned().collect();
|
||||
|
||||
for edge in causal.edges() {
|
||||
// If edge connects controllable region to target region
|
||||
if controllable_set.contains(&edge.source) || target_set.contains(&edge.target) {
|
||||
intervention_points.push(edge.source);
|
||||
}
|
||||
}
|
||||
|
||||
intervention_points.sort();
|
||||
intervention_points.dedup();
|
||||
intervention_points
|
||||
}
|
||||
|
||||
/// Get causal strength between two event types
|
||||
pub fn causal_strength(&self, from: GraphEventType, to: GraphEventType) -> f64 {
|
||||
let i = self.event_type_map.get(&from).copied().unwrap_or(0);
|
||||
let j = self.event_type_map.get(&to).copied().unwrap_or(0);
|
||||
|
||||
self.synapses.weight(i, j)
|
||||
}
|
||||
|
||||
/// Get all direct causes of an event type
|
||||
pub fn direct_causes(&self, event_type: GraphEventType) -> Vec<(GraphEventType, f64)> {
|
||||
let j = self.event_type_map.get(&event_type).copied().unwrap_or(0);
|
||||
let mut causes = Vec::new();
|
||||
|
||||
for i in 0..self.config.num_event_types {
|
||||
if i != j {
|
||||
let w = self.synapses.weight(i, j);
|
||||
if w > self.config.causal_threshold {
|
||||
if let Some(&event) = self.index_to_event.get(&i) {
|
||||
causes.push((event, w));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
causes.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
causes
|
||||
}
|
||||
|
||||
/// Get all direct effects of an event type
|
||||
pub fn direct_effects(&self, event_type: GraphEventType) -> Vec<(GraphEventType, f64)> {
|
||||
let i = self.event_type_map.get(&event_type).copied().unwrap_or(0);
|
||||
let mut effects = Vec::new();
|
||||
|
||||
for j in 0..self.config.num_event_types {
|
||||
if i != j {
|
||||
let w = self.synapses.weight(i, j);
|
||||
if w > self.config.causal_threshold {
|
||||
if let Some(&event) = self.index_to_event.get(&j) {
|
||||
effects.push((event, w));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
effects.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
effects
|
||||
}
|
||||
|
||||
/// Reset the SNN
|
||||
pub fn reset(&mut self) {
|
||||
self.time = 0.0;
|
||||
|
||||
for neuron in &mut self.event_neurons {
|
||||
neuron.reset();
|
||||
}
|
||||
|
||||
for train in &mut self.spike_trains {
|
||||
train.clear();
|
||||
}
|
||||
|
||||
// Reset weights to zero
|
||||
for i in 0..self.config.num_event_types {
|
||||
for j in 0..self.config.num_event_types {
|
||||
if i != j {
|
||||
self.synapses.set_weight(i, j, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get summary statistics
|
||||
pub fn summary(&self) -> CausalSummary {
|
||||
let causal = self.extract_causal_graph();
|
||||
|
||||
let mut total_strength = 0.0;
|
||||
let mut causes_count = 0;
|
||||
let mut prevents_count = 0;
|
||||
|
||||
for edge in causal.edges() {
|
||||
total_strength += edge.strength;
|
||||
match edge.relation {
|
||||
CausalRelation::Causes => causes_count += 1,
|
||||
CausalRelation::Prevents => prevents_count += 1,
|
||||
CausalRelation::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
CausalSummary {
|
||||
num_relationships: causal.edges().len(),
|
||||
causes_count,
|
||||
prevents_count,
|
||||
avg_strength: total_strength / causal.edges().len().max(1) as f64,
|
||||
time_elapsed: self.time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of causal discovery
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CausalSummary {
|
||||
/// Total number of discovered relationships
|
||||
pub num_relationships: usize,
|
||||
/// Number of positive causal relationships
|
||||
pub causes_count: usize,
|
||||
/// Number of preventive relationships
|
||||
pub prevents_count: usize,
|
||||
/// Average causal strength
|
||||
pub avg_strength: f64,
|
||||
/// Time elapsed in observation
|
||||
pub time_elapsed: SimTime,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_causal_graph() {
|
||||
let mut graph = CausalGraph::new(5);
|
||||
graph.add_edge(0, 1, 0.8, CausalRelation::Causes);
|
||||
graph.add_edge(1, 2, 0.6, CausalRelation::Causes);
|
||||
|
||||
assert_eq!(graph.edges().len(), 2);
|
||||
|
||||
let reachable = graph.reachable_from(0);
|
||||
assert!(reachable.contains(&1));
|
||||
assert!(reachable.contains(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_causal_discovery_snn() {
|
||||
let config = CausalConfig::default();
|
||||
let mut snn = CausalDiscoverySNN::new(config);
|
||||
|
||||
// Observe events with consistent temporal ordering
|
||||
for i in 0..10 {
|
||||
let t = i as f64 * 10.0;
|
||||
|
||||
// Edge insert always followed by mincut change
|
||||
snn.observe_event(
|
||||
GraphEvent {
|
||||
event_type: GraphEventType::EdgeInsert,
|
||||
vertex: None,
|
||||
edge: Some((0, 1)),
|
||||
data: 1.0,
|
||||
},
|
||||
t,
|
||||
);
|
||||
|
||||
snn.observe_event(
|
||||
GraphEvent {
|
||||
event_type: GraphEventType::MinCutChange,
|
||||
vertex: None,
|
||||
edge: None,
|
||||
data: 0.5,
|
||||
},
|
||||
t + 5.0,
|
||||
);
|
||||
}
|
||||
|
||||
let summary = snn.summary();
|
||||
assert!(summary.time_elapsed > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transitive_closure() {
|
||||
let mut graph = CausalGraph::new(4);
|
||||
graph.add_edge(0, 1, 0.8, CausalRelation::Causes);
|
||||
graph.add_edge(1, 2, 0.6, CausalRelation::Causes);
|
||||
graph.add_edge(2, 3, 0.5, CausalRelation::Causes);
|
||||
|
||||
let closed = graph.transitive_closure();
|
||||
|
||||
// Should have indirect edges
|
||||
assert!(closed.edges().len() >= 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intervention_points() {
|
||||
let config = CausalConfig::default();
|
||||
let snn = CausalDiscoverySNN::new(config);
|
||||
|
||||
let interventions = snn.optimal_intervention_points(&[0, 1], &[3, 4]);
|
||||
// Should return some intervention points (may be empty if no learned causality)
|
||||
assert!(interventions.len() >= 0);
|
||||
}
|
||||
}
|
||||
739
vendor/ruvector/crates/ruvector-mincut/src/snn/cognitive_engine.rs
vendored
Normal file
739
vendor/ruvector/crates/ruvector-mincut/src/snn/cognitive_engine.rs
vendored
Normal file
@@ -0,0 +1,739 @@
|
||||
//! # Unified Cognitive MinCut Engine
|
||||
//!
|
||||
//! Combines all six integration layers into a unified system.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
//! │ COGNITIVE MINCUT ENGINE │
|
||||
//! ├─────────────────────────────────────────────────────────────────────────────┤
|
||||
//! │ │
|
||||
//! │ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
//! │ │ META-COGNITIVE LAYER │ │
|
||||
//! │ │ Strange Loop + Neural Optimizer + Causal Discovery │ │
|
||||
//! │ └───────────────────────────┬─────────────────────────────────────────┘ │
|
||||
//! │ │ │
|
||||
//! │ ┌───────────────────────────▼─────────────────────────────────────────┐ │
|
||||
//! │ │ DYNAMICAL SYSTEMS LAYER │ │
|
||||
//! │ │ Temporal Attractors + Time Crystals + Morphogenesis │ │
|
||||
//! │ └───────────────────────────┬─────────────────────────────────────────┘ │
|
||||
//! │ │ │
|
||||
//! │ ┌───────────────────────────▼─────────────────────────────────────────┐ │
|
||||
//! │ │ GRAPH ALGORITHM LAYER │ │
|
||||
//! │ │ Karger-Stein MinCut + Subpolynomial Search + HNSW │ │
|
||||
//! │ └───────────────────────────┬─────────────────────────────────────────┘ │
|
||||
//! │ │ │
|
||||
//! │ ┌───────────────────────────▼─────────────────────────────────────────┐ │
|
||||
//! │ │ NEUROMORPHIC SUBSTRATE │ │
|
||||
//! │ │ SNN + STDP + Meta-Neuron + CPG │ │
|
||||
//! │ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
//! │ │
|
||||
//! └─────────────────────────────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! ## Performance Targets
|
||||
//!
|
||||
//! | Metric | Unified | Improvement |
|
||||
//! |--------|---------|-------------|
|
||||
//! | MinCut (1K nodes) | ~5 μs | 10x |
|
||||
//! | Energy per query | ~10 μJ | 1000x |
|
||||
|
||||
use super::{
|
||||
attractor::{AttractorConfig, AttractorDynamics, EnergyLandscape},
|
||||
causal::{CausalConfig, CausalDiscoverySNN, CausalGraph, GraphEvent, GraphEventType},
|
||||
morphogenetic::{MorphConfig, MorphogeneticSNN, TuringPattern},
|
||||
optimizer::{GraphAction, NeuralGraphOptimizer, OptimizationResult, OptimizerConfig},
|
||||
strange_loop::{MetaAction, MetaCognitiveMinCut, StrangeLoopConfig},
|
||||
time_crystal::{CPGConfig, TimeCrystalCPG},
|
||||
SimTime, Spike,
|
||||
};
|
||||
use crate::graph::{DynamicGraph, VertexId, Weight};
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Configuration for the Cognitive MinCut Engine
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EngineConfig {
|
||||
/// Enable attractor dynamics layer
|
||||
pub enable_attractors: bool,
|
||||
/// Enable strange loop self-modification
|
||||
pub enable_strange_loop: bool,
|
||||
/// Enable causal discovery
|
||||
pub enable_causal_discovery: bool,
|
||||
/// Enable time crystal coordination
|
||||
pub enable_time_crystal: bool,
|
||||
/// Enable morphogenetic growth
|
||||
pub enable_morphogenetic: bool,
|
||||
/// Enable neural optimizer
|
||||
pub enable_optimizer: bool,
|
||||
/// Attractor configuration
|
||||
pub attractor_config: AttractorConfig,
|
||||
/// Strange loop configuration
|
||||
pub strange_loop_config: StrangeLoopConfig,
|
||||
/// Causal discovery configuration
|
||||
pub causal_config: CausalConfig,
|
||||
/// Time crystal configuration
|
||||
pub cpg_config: CPGConfig,
|
||||
/// Morphogenetic configuration
|
||||
pub morph_config: MorphConfig,
|
||||
/// Optimizer configuration
|
||||
pub optimizer_config: OptimizerConfig,
|
||||
/// Time step for unified simulation
|
||||
pub dt: f64,
|
||||
/// Maximum steps per operation
|
||||
pub max_steps: usize,
|
||||
}
|
||||
|
||||
impl Default for EngineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable_attractors: true,
|
||||
enable_strange_loop: true,
|
||||
enable_causal_discovery: true,
|
||||
enable_time_crystal: true,
|
||||
enable_morphogenetic: false, // Expensive, off by default
|
||||
enable_optimizer: true,
|
||||
attractor_config: AttractorConfig::default(),
|
||||
strange_loop_config: StrangeLoopConfig::default(),
|
||||
causal_config: CausalConfig::default(),
|
||||
cpg_config: CPGConfig::default(),
|
||||
morph_config: MorphConfig::default(),
|
||||
optimizer_config: OptimizerConfig::default(),
|
||||
dt: 1.0,
|
||||
max_steps: 1000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metrics from engine operation
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EngineMetrics {
|
||||
/// Total time spent in computation
|
||||
pub total_time: Duration,
|
||||
/// Time in attractor dynamics
|
||||
pub attractor_time: Duration,
|
||||
/// Time in strange loop
|
||||
pub strange_loop_time: Duration,
|
||||
/// Time in causal discovery
|
||||
pub causal_time: Duration,
|
||||
/// Time in time crystal
|
||||
pub cpg_time: Duration,
|
||||
/// Time in morphogenesis
|
||||
pub morph_time: Duration,
|
||||
/// Time in optimization
|
||||
pub optimizer_time: Duration,
|
||||
/// Number of spikes generated
|
||||
pub total_spikes: usize,
|
||||
/// Energy estimate (arbitrary units)
|
||||
pub energy_estimate: f64,
|
||||
/// Current mincut value
|
||||
pub mincut_value: f64,
|
||||
/// Global synchrony
|
||||
pub synchrony: f64,
|
||||
/// Steps taken
|
||||
pub steps: usize,
|
||||
}
|
||||
|
||||
/// Operation mode for the engine
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum OperationMode {
|
||||
/// Optimize graph structure
|
||||
Optimize,
|
||||
/// Discover causal relationships
|
||||
CausalDiscovery,
|
||||
/// Evolve to attractor
|
||||
Attractor,
|
||||
/// Coordinated phase operation
|
||||
Crystal,
|
||||
/// Self-modifying operation
|
||||
MetaCognitive,
|
||||
/// Grow structure
|
||||
Morphogenetic,
|
||||
/// All systems active
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Maximum event history size to prevent memory exhaustion
|
||||
const MAX_EVENT_HISTORY: usize = 10_000;
|
||||
|
||||
/// The unified Cognitive MinCut Engine
|
||||
pub struct CognitiveMinCutEngine {
|
||||
/// Primary graph being optimized
|
||||
graph: DynamicGraph,
|
||||
/// Configuration
|
||||
config: EngineConfig,
|
||||
/// Attractor dynamics subsystem
|
||||
attractor: Option<AttractorDynamics>,
|
||||
/// Strange loop subsystem
|
||||
strange_loop: Option<MetaCognitiveMinCut>,
|
||||
/// Causal discovery subsystem
|
||||
causal: Option<CausalDiscoverySNN>,
|
||||
/// Time crystal CPG subsystem
|
||||
time_crystal: Option<TimeCrystalCPG>,
|
||||
/// Morphogenetic subsystem
|
||||
morphogenetic: Option<MorphogeneticSNN>,
|
||||
/// Neural optimizer subsystem
|
||||
optimizer: Option<NeuralGraphOptimizer>,
|
||||
/// Current simulation time
|
||||
time: SimTime,
|
||||
/// Accumulated metrics
|
||||
metrics: EngineMetrics,
|
||||
/// Event history for causal analysis
|
||||
event_history: Vec<(GraphEvent, SimTime)>,
|
||||
/// Current operation mode
|
||||
mode: OperationMode,
|
||||
}
|
||||
|
||||
impl CognitiveMinCutEngine {
|
||||
/// Create a new Cognitive MinCut Engine
|
||||
pub fn new(graph: DynamicGraph, config: EngineConfig) -> Self {
|
||||
let attractor = if config.enable_attractors {
|
||||
Some(AttractorDynamics::new(
|
||||
graph.clone(),
|
||||
config.attractor_config.clone(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let strange_loop = if config.enable_strange_loop {
|
||||
let mut sl_config = config.strange_loop_config.clone();
|
||||
sl_config.level0_size = graph.num_vertices();
|
||||
Some(MetaCognitiveMinCut::new(graph.clone(), sl_config))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let causal = if config.enable_causal_discovery {
|
||||
Some(CausalDiscoverySNN::new(config.causal_config.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let time_crystal = if config.enable_time_crystal {
|
||||
Some(TimeCrystalCPG::new(
|
||||
graph.clone(),
|
||||
config.cpg_config.clone(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let morphogenetic = if config.enable_morphogenetic {
|
||||
Some(MorphogeneticSNN::new(config.morph_config.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let optimizer = if config.enable_optimizer {
|
||||
Some(NeuralGraphOptimizer::new(
|
||||
graph.clone(),
|
||||
config.optimizer_config.clone(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
graph,
|
||||
config,
|
||||
attractor,
|
||||
strange_loop,
|
||||
causal,
|
||||
time_crystal,
|
||||
morphogenetic,
|
||||
optimizer,
|
||||
time: 0.0,
|
||||
metrics: EngineMetrics::default(),
|
||||
event_history: Vec::new(),
|
||||
mode: OperationMode::Full,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set operation mode
|
||||
pub fn set_mode(&mut self, mode: OperationMode) {
|
||||
self.mode = mode;
|
||||
}
|
||||
|
||||
/// Run one integration step
|
||||
pub fn step(&mut self) -> Vec<Spike> {
|
||||
let start = Instant::now();
|
||||
let mut all_spikes = Vec::new();
|
||||
|
||||
match self.mode {
|
||||
OperationMode::Optimize => {
|
||||
self.step_optimizer(&mut all_spikes);
|
||||
}
|
||||
OperationMode::CausalDiscovery => {
|
||||
self.step_causal();
|
||||
}
|
||||
OperationMode::Attractor => {
|
||||
self.step_attractor(&mut all_spikes);
|
||||
}
|
||||
OperationMode::Crystal => {
|
||||
self.step_time_crystal();
|
||||
}
|
||||
OperationMode::MetaCognitive => {
|
||||
self.step_strange_loop();
|
||||
}
|
||||
OperationMode::Morphogenetic => {
|
||||
self.step_morphogenetic();
|
||||
}
|
||||
OperationMode::Full => {
|
||||
self.step_full(&mut all_spikes);
|
||||
}
|
||||
}
|
||||
|
||||
self.time += self.config.dt;
|
||||
self.metrics.steps += 1;
|
||||
self.metrics.total_time += start.elapsed();
|
||||
self.metrics.total_spikes += all_spikes.len();
|
||||
|
||||
all_spikes
|
||||
}
|
||||
|
||||
/// Full integration step (all subsystems)
|
||||
fn step_full(&mut self, all_spikes: &mut Vec<Spike>) {
|
||||
// 1. Attractor dynamics (energy landscape)
|
||||
self.step_attractor(all_spikes);
|
||||
|
||||
// 2. Strange loop (meta-cognition)
|
||||
self.step_strange_loop();
|
||||
|
||||
// 3. Time crystal (coordination)
|
||||
self.step_time_crystal();
|
||||
|
||||
// 4. Neural optimizer (learning)
|
||||
self.step_optimizer(all_spikes);
|
||||
|
||||
// 5. Causal discovery (from accumulated events)
|
||||
self.step_causal();
|
||||
|
||||
// 6. Synchronize graph states
|
||||
self.synchronize_graphs();
|
||||
|
||||
// Update energy estimate
|
||||
self.metrics.energy_estimate += all_spikes.len() as f64 * 0.001;
|
||||
}
|
||||
|
||||
/// Step attractor dynamics
|
||||
fn step_attractor(&mut self, spikes: &mut Vec<Spike>) {
|
||||
if let Some(ref mut attractor) = self.attractor {
|
||||
let start = Instant::now();
|
||||
let new_spikes = attractor.step();
|
||||
spikes.extend(new_spikes);
|
||||
self.metrics.attractor_time += start.elapsed();
|
||||
self.metrics.synchrony = attractor.snn().global_synchrony();
|
||||
}
|
||||
}
|
||||
|
||||
/// Step strange loop
|
||||
fn step_strange_loop(&mut self) {
|
||||
if let Some(ref mut sl) = self.strange_loop {
|
||||
let start = Instant::now();
|
||||
let action = sl.strange_loop_step();
|
||||
|
||||
// Record meta-action as event
|
||||
let event = match action {
|
||||
MetaAction::Strengthen(_) => GraphEvent {
|
||||
event_type: GraphEventType::WeightChange,
|
||||
vertex: None,
|
||||
edge: None,
|
||||
data: 1.0,
|
||||
},
|
||||
MetaAction::Prune(_) => GraphEvent {
|
||||
event_type: GraphEventType::EdgeDelete,
|
||||
vertex: None,
|
||||
edge: None,
|
||||
data: -1.0,
|
||||
},
|
||||
MetaAction::Restructure => GraphEvent {
|
||||
event_type: GraphEventType::ComponentMerge,
|
||||
vertex: None,
|
||||
edge: None,
|
||||
data: 0.0,
|
||||
},
|
||||
MetaAction::NoOp => GraphEvent {
|
||||
event_type: GraphEventType::MinCutChange,
|
||||
vertex: None,
|
||||
edge: None,
|
||||
data: 0.0,
|
||||
},
|
||||
};
|
||||
|
||||
self.event_history.push((event, self.time));
|
||||
self.metrics.strange_loop_time += start.elapsed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Step causal discovery
|
||||
fn step_causal(&mut self) {
|
||||
if let Some(ref mut causal) = self.causal {
|
||||
let start = Instant::now();
|
||||
|
||||
// Process accumulated events
|
||||
for (event, ts) in &self.event_history {
|
||||
causal.observe_event(event.clone(), *ts);
|
||||
}
|
||||
self.event_history.clear();
|
||||
|
||||
self.metrics.causal_time += start.elapsed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Step time crystal CPG
|
||||
fn step_time_crystal(&mut self) {
|
||||
if let Some(ref mut cpg) = self.time_crystal {
|
||||
let start = Instant::now();
|
||||
let _ = cpg.tick();
|
||||
self.metrics.cpg_time += start.elapsed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Step morphogenetic development
|
||||
fn step_morphogenetic(&mut self) {
|
||||
if let Some(ref mut morph) = self.morphogenetic {
|
||||
let start = Instant::now();
|
||||
morph.develop_step();
|
||||
self.metrics.morph_time += start.elapsed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Step neural optimizer
|
||||
fn step_optimizer(&mut self, _spikes: &mut Vec<Spike>) {
|
||||
if let Some(ref mut opt) = self.optimizer {
|
||||
let start = Instant::now();
|
||||
let result = opt.optimize_step();
|
||||
|
||||
// Record optimization action as event
|
||||
let event = match result.action {
|
||||
GraphAction::AddEdge(u, v, _) => GraphEvent {
|
||||
event_type: GraphEventType::EdgeInsert,
|
||||
vertex: None,
|
||||
edge: Some((u, v)),
|
||||
data: result.reward,
|
||||
},
|
||||
GraphAction::RemoveEdge(u, v) => GraphEvent {
|
||||
event_type: GraphEventType::EdgeDelete,
|
||||
vertex: None,
|
||||
edge: Some((u, v)),
|
||||
data: result.reward,
|
||||
},
|
||||
_ => GraphEvent {
|
||||
event_type: GraphEventType::WeightChange,
|
||||
vertex: None,
|
||||
edge: None,
|
||||
data: result.reward,
|
||||
},
|
||||
};
|
||||
|
||||
self.event_history.push((event, self.time));
|
||||
self.metrics.mincut_value = result.new_mincut;
|
||||
self.metrics.optimizer_time += start.elapsed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronize graph states across subsystems
|
||||
fn synchronize_graphs(&mut self) {
|
||||
// Use optimizer's graph as primary
|
||||
if let Some(ref opt) = self.optimizer {
|
||||
self.graph = opt.graph().clone();
|
||||
}
|
||||
|
||||
// Update attractor subsystem with current graph state
|
||||
if let Some(ref mut attractor) = self.attractor {
|
||||
// Create new attractor dynamics with updated graph
|
||||
// This preserves configuration while syncing graph
|
||||
*attractor = AttractorDynamics::new(self.graph.clone(), attractor.config().clone());
|
||||
}
|
||||
|
||||
// Limit event history size to prevent memory exhaustion
|
||||
if self.event_history.len() > MAX_EVENT_HISTORY {
|
||||
// Keep only the most recent events
|
||||
let drain_count = self.event_history.len() - MAX_EVENT_HISTORY;
|
||||
self.event_history.drain(..drain_count);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run for specified number of steps
|
||||
pub fn run(&mut self, steps: usize) -> Vec<Spike> {
|
||||
let mut all_spikes = Vec::new();
|
||||
|
||||
for _ in 0..steps {
|
||||
let spikes = self.step();
|
||||
all_spikes.extend(spikes);
|
||||
}
|
||||
|
||||
all_spikes
|
||||
}
|
||||
|
||||
/// Run until convergence or max steps
|
||||
pub fn run_until_converged(&mut self) -> (Vec<Spike>, bool) {
|
||||
let mut all_spikes = Vec::new();
|
||||
let mut prev_energy = f64::MAX;
|
||||
let mut stable_count = 0;
|
||||
|
||||
for _ in 0..self.config.max_steps {
|
||||
let spikes = self.step();
|
||||
all_spikes.extend(spikes);
|
||||
|
||||
let energy = self.current_energy();
|
||||
if (energy - prev_energy).abs() < 0.001 {
|
||||
stable_count += 1;
|
||||
if stable_count > 10 {
|
||||
return (all_spikes, true);
|
||||
}
|
||||
} else {
|
||||
stable_count = 0;
|
||||
}
|
||||
prev_energy = energy;
|
||||
}
|
||||
|
||||
(all_spikes, false)
|
||||
}
|
||||
|
||||
/// Get current energy
|
||||
pub fn current_energy(&self) -> f64 {
|
||||
if let Some(ref attractor) = self.attractor {
|
||||
attractor.energy()
|
||||
} else {
|
||||
-self.metrics.mincut_value - self.metrics.synchrony
|
||||
}
|
||||
}
|
||||
|
||||
/// Get primary graph
|
||||
pub fn graph(&self) -> &DynamicGraph {
|
||||
&self.graph
|
||||
}
|
||||
|
||||
/// Get mutable graph
|
||||
pub fn graph_mut(&mut self) -> &mut DynamicGraph {
|
||||
&mut self.graph
|
||||
}
|
||||
|
||||
/// Get metrics
|
||||
pub fn metrics(&self) -> &EngineMetrics {
|
||||
&self.metrics
|
||||
}
|
||||
|
||||
/// Get causal graph (if available)
|
||||
pub fn causal_graph(&self) -> Option<CausalGraph> {
|
||||
self.causal.as_ref().map(|c| c.extract_causal_graph())
|
||||
}
|
||||
|
||||
/// Get current phase (from time crystal)
|
||||
pub fn current_phase(&self) -> Option<usize> {
|
||||
self.time_crystal.as_ref().map(|tc| tc.current_phase())
|
||||
}
|
||||
|
||||
/// Get attractor status
|
||||
pub fn at_attractor(&self) -> bool {
|
||||
self.attractor
|
||||
.as_ref()
|
||||
.map(|a| a.reached_attractor())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get morphogenetic pattern
|
||||
pub fn pattern(&self) -> Option<TuringPattern> {
|
||||
self.morphogenetic.as_ref().map(|m| m.detect_pattern())
|
||||
}
|
||||
|
||||
/// Get energy landscape
|
||||
pub fn energy_landscape(&self) -> Option<&EnergyLandscape> {
|
||||
self.attractor.as_ref().map(|a| a.energy_landscape())
|
||||
}
|
||||
|
||||
/// Subpolynomial search exploiting all learned structures
|
||||
pub fn search(&self, query: &[f64], k: usize) -> Vec<VertexId> {
|
||||
// Combine skip regions from all subsystems
|
||||
let mut skip_regions = Vec::new();
|
||||
|
||||
// From attractor dynamics
|
||||
if let Some(ref attractor) = self.attractor {
|
||||
let skip = attractor.get_skip_mask();
|
||||
skip_regions.extend(skip.iter().map(|(u, v)| (*u, *v)));
|
||||
}
|
||||
|
||||
// From neural optimizer
|
||||
if let Some(ref opt) = self.optimizer {
|
||||
return opt.search(query, k);
|
||||
}
|
||||
|
||||
// Fallback: return all vertices
|
||||
self.graph.vertices().into_iter().take(k).collect()
|
||||
}
|
||||
|
||||
/// Record external event for causal analysis
|
||||
pub fn record_event(&mut self, event: GraphEvent) {
|
||||
self.event_history.push((event, self.time));
|
||||
}
|
||||
|
||||
/// Reset all subsystems
|
||||
pub fn reset(&mut self) {
|
||||
self.time = 0.0;
|
||||
self.metrics = EngineMetrics::default();
|
||||
self.event_history.clear();
|
||||
|
||||
if let Some(ref mut attractor) = self.attractor {
|
||||
attractor.reset();
|
||||
}
|
||||
if let Some(ref mut sl) = self.strange_loop {
|
||||
sl.reset();
|
||||
}
|
||||
if let Some(ref mut causal) = self.causal {
|
||||
causal.reset();
|
||||
}
|
||||
if let Some(ref mut cpg) = self.time_crystal {
|
||||
cpg.reset();
|
||||
}
|
||||
if let Some(ref mut morph) = self.morphogenetic {
|
||||
morph.reset();
|
||||
}
|
||||
if let Some(ref mut opt) = self.optimizer {
|
||||
opt.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get summary of engine state
|
||||
pub fn summary(&self) -> EngineSummary {
|
||||
EngineSummary {
|
||||
mode: self.mode,
|
||||
time: self.time,
|
||||
graph_vertices: self.graph.num_vertices(),
|
||||
graph_edges: self.graph.num_edges(),
|
||||
mincut: self.metrics.mincut_value,
|
||||
synchrony: self.metrics.synchrony,
|
||||
at_attractor: self.at_attractor(),
|
||||
current_phase: self.current_phase(),
|
||||
pattern: self.pattern(),
|
||||
total_spikes: self.metrics.total_spikes,
|
||||
energy: self.metrics.energy_estimate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of engine state
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EngineSummary {
|
||||
/// Current operation mode
|
||||
pub mode: OperationMode,
|
||||
/// Simulation time
|
||||
pub time: SimTime,
|
||||
/// Number of graph vertices
|
||||
pub graph_vertices: usize,
|
||||
/// Number of graph edges
|
||||
pub graph_edges: usize,
|
||||
/// Current mincut value
|
||||
pub mincut: f64,
|
||||
/// Global synchrony
|
||||
pub synchrony: f64,
|
||||
/// At attractor?
|
||||
pub at_attractor: bool,
|
||||
/// Current time crystal phase
|
||||
pub current_phase: Option<usize>,
|
||||
/// Morphogenetic pattern
|
||||
pub pattern: Option<TuringPattern>,
|
||||
/// Total spikes generated
|
||||
pub total_spikes: usize,
|
||||
/// Energy estimate
|
||||
pub energy: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_graph() -> DynamicGraph {
|
||||
let graph = DynamicGraph::new();
|
||||
for i in 0..10 {
|
||||
graph.insert_edge(i, (i + 1) % 10, 1.0).unwrap();
|
||||
}
|
||||
graph
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_creation() {
|
||||
let graph = create_test_graph();
|
||||
let config = EngineConfig::default();
|
||||
|
||||
let engine = CognitiveMinCutEngine::new(graph, config);
|
||||
|
||||
assert_eq!(engine.graph().num_vertices(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_step() {
|
||||
let graph = create_test_graph();
|
||||
let config = EngineConfig::default();
|
||||
|
||||
let mut engine = CognitiveMinCutEngine::new(graph, config);
|
||||
engine.set_mode(OperationMode::Optimize);
|
||||
|
||||
let spikes = engine.step();
|
||||
assert!(engine.metrics().steps == 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_run() {
|
||||
let graph = create_test_graph();
|
||||
let mut config = EngineConfig::default();
|
||||
config.enable_morphogenetic = false; // Expensive
|
||||
|
||||
let mut engine = CognitiveMinCutEngine::new(graph, config);
|
||||
|
||||
let spikes = engine.run(10);
|
||||
assert_eq!(engine.metrics().steps, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_modes() {
|
||||
let graph = create_test_graph();
|
||||
let config = EngineConfig::default();
|
||||
|
||||
let mut engine = CognitiveMinCutEngine::new(graph, config);
|
||||
|
||||
// Test each mode
|
||||
for mode in [
|
||||
OperationMode::Optimize,
|
||||
OperationMode::CausalDiscovery,
|
||||
OperationMode::Attractor,
|
||||
OperationMode::Crystal,
|
||||
OperationMode::MetaCognitive,
|
||||
] {
|
||||
engine.set_mode(mode);
|
||||
engine.step();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_summary() {
|
||||
let graph = create_test_graph();
|
||||
let config = EngineConfig::default();
|
||||
|
||||
let mut engine = CognitiveMinCutEngine::new(graph, config);
|
||||
engine.run(5);
|
||||
|
||||
let summary = engine.summary();
|
||||
assert_eq!(summary.graph_vertices, 10);
|
||||
assert!(summary.time > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_event() {
|
||||
let graph = create_test_graph();
|
||||
let config = EngineConfig::default();
|
||||
|
||||
let mut engine = CognitiveMinCutEngine::new(graph, config);
|
||||
|
||||
engine.record_event(GraphEvent {
|
||||
event_type: GraphEventType::EdgeInsert,
|
||||
vertex: None,
|
||||
edge: Some((0, 5)),
|
||||
data: 1.0,
|
||||
});
|
||||
|
||||
engine.step();
|
||||
}
|
||||
}
|
||||
366
vendor/ruvector/crates/ruvector-mincut/src/snn/mod.rs
vendored
Normal file
366
vendor/ruvector/crates/ruvector-mincut/src/snn/mod.rs
vendored
Normal file
@@ -0,0 +1,366 @@
|
||||
//! # SNN Integration for Dynamic MinCut
|
||||
//!
|
||||
//! Deep integration of Spiking Neural Networks with subpolynomial-time
|
||||
//! dynamic minimum cut algorithms.
|
||||
//!
|
||||
//! ## Architecture Overview
|
||||
//!
|
||||
//! This module implements a six-layer integration architecture:
|
||||
//!
|
||||
//! 1. **Temporal Attractors**: SNN energy landscapes for graph optimization
|
||||
//! 2. **Strange Loop**: Self-modifying meta-cognitive protocols
|
||||
//! 3. **Causal Discovery**: Spike-timing based causal inference
|
||||
//! 4. **Time Crystal CPG**: Central pattern generators for coordination
|
||||
//! 5. **Morphogenetic Networks**: Bio-inspired self-organizing growth
|
||||
//! 6. **Neural Optimizer**: Reinforcement learning on graph structures
|
||||
//!
|
||||
//! ## Triple Isomorphism
|
||||
//!
|
||||
//! The integration exploits the deep structural correspondence:
|
||||
//!
|
||||
//! | Graph Theory | Dynamical Systems | Neuromorphic |
|
||||
//! |--------------|-------------------|--------------|
|
||||
//! | MinCut value | Lyapunov exponent | Spike synchrony |
|
||||
//! | Edge contraction | Phase space flow | Synaptic plasticity |
|
||||
//! | Attractor basin | Stable manifold | Memory consolidation |
|
||||
//!
|
||||
//! ## Performance Targets
|
||||
//!
|
||||
//! | Metric | Current (CPU) | Unified (Neuromorphic) | Improvement |
|
||||
//! |--------|---------------|------------------------|-------------|
|
||||
//! | MinCut (1K nodes) | 50 μs | ~5 μs | 10x |
|
||||
//! | Search (1M vectors) | 400 μs | ~40 μs | 10x |
|
||||
//! | Energy per query | ~10 mJ | ~10 μJ | 1000x |
|
||||
|
||||
pub mod attractor;
|
||||
pub mod causal;
|
||||
pub mod cognitive_engine;
|
||||
pub mod morphogenetic;
|
||||
pub mod network;
|
||||
pub mod neuron;
|
||||
pub mod optimizer;
|
||||
pub mod strange_loop;
|
||||
pub mod synapse;
|
||||
pub mod time_crystal;
|
||||
|
||||
// Re-exports
|
||||
pub use attractor::{AttractorConfig, AttractorDynamics, EnergyLandscape};
|
||||
pub use causal::{CausalConfig, CausalDiscoverySNN, CausalGraph, CausalRelation};
|
||||
pub use cognitive_engine::{CognitiveMinCutEngine, EngineConfig, EngineMetrics, OperationMode};
|
||||
pub use morphogenetic::{GrowthRules, MorphConfig, MorphogeneticSNN, TuringPattern};
|
||||
pub use network::{LayerConfig, NetworkConfig, SpikingNetwork};
|
||||
pub use neuron::{LIFNeuron, NeuronConfig, NeuronState, SpikeTrain};
|
||||
pub use optimizer::{
|
||||
NeuralGraphOptimizer, OptimizationResult, OptimizerConfig, PolicySNN, ValueNetwork,
|
||||
};
|
||||
pub use strange_loop::{MetaAction, MetaCognitiveMinCut, MetaLevel, StrangeLoopConfig};
|
||||
pub use synapse::{STDPConfig, Synapse, SynapseMatrix};
|
||||
pub use time_crystal::{CPGConfig, OscillatorNeuron, PhaseTopology, TimeCrystalCPG};
|
||||
|
||||
use crate::graph::{DynamicGraph, EdgeId, VertexId, Weight};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Simulation time in milliseconds
|
||||
pub type SimTime = f64;
|
||||
|
||||
/// Spike event with timestamp
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Spike {
|
||||
/// Neuron ID that fired
|
||||
pub neuron_id: usize,
|
||||
/// Time of spike in simulation time
|
||||
pub time: SimTime,
|
||||
}
|
||||
|
||||
/// Vector type for neural computations
|
||||
pub type Vector = Vec<f64>;
|
||||
|
||||
/// Configuration for the unified SNN-MinCut system
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SNNMinCutConfig {
|
||||
/// Time step for simulation (ms)
|
||||
pub dt: f64,
|
||||
/// Number of neurons (typically matches graph vertices)
|
||||
pub num_neurons: usize,
|
||||
/// Enable attractor dynamics
|
||||
pub enable_attractors: bool,
|
||||
/// Enable strange loop self-modification
|
||||
pub enable_strange_loop: bool,
|
||||
/// Enable causal discovery
|
||||
pub enable_causal_discovery: bool,
|
||||
/// Enable time crystal coordination
|
||||
pub enable_time_crystal: bool,
|
||||
/// Enable morphogenetic growth
|
||||
pub enable_morphogenetic: bool,
|
||||
/// Enable neural optimization
|
||||
pub enable_optimizer: bool,
|
||||
}
|
||||
|
||||
impl Default for SNNMinCutConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dt: 1.0, // 1ms timestep
|
||||
num_neurons: 1000,
|
||||
enable_attractors: true,
|
||||
enable_strange_loop: true,
|
||||
enable_causal_discovery: true,
|
||||
enable_time_crystal: true,
|
||||
enable_morphogenetic: true,
|
||||
enable_optimizer: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a spike-driven computation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpikeComputeResult {
|
||||
/// Spikes generated during computation
|
||||
pub spikes: Vec<Spike>,
|
||||
/// Energy consumed (in arbitrary units)
|
||||
pub energy: f64,
|
||||
/// Duration of computation
|
||||
pub duration: Duration,
|
||||
/// MinCut value discovered/optimized
|
||||
pub mincut_value: Option<f64>,
|
||||
}
|
||||
|
||||
/// Trait for spike-to-graph transduction
|
||||
pub trait SpikeToGraph {
|
||||
/// Convert spike train to edge weight modulation
|
||||
fn spikes_to_weights(&self, spikes: &[Spike], graph: &mut DynamicGraph);
|
||||
|
||||
/// Encode graph state as spike rates
|
||||
fn graph_to_spike_rates(&self, graph: &DynamicGraph) -> Vec<f64>;
|
||||
}
|
||||
|
||||
/// Trait for graph-to-spike transduction
|
||||
pub trait GraphToSpike {
|
||||
/// Convert edge weight to spike input current
|
||||
fn weight_to_current(&self, weight: Weight) -> f64;
|
||||
|
||||
/// Convert vertex degree to spike threshold
|
||||
fn degree_to_threshold(&self, degree: usize) -> f64;
|
||||
}
|
||||
|
||||
/// Default implementation of spike-to-graph transduction
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DefaultSpikeGraphTransducer {
|
||||
/// Weight modulation factor
|
||||
pub weight_factor: f64,
|
||||
/// Current conversion factor
|
||||
pub current_factor: f64,
|
||||
/// Threshold scaling
|
||||
pub threshold_scale: f64,
|
||||
}
|
||||
|
||||
impl DefaultSpikeGraphTransducer {
|
||||
/// Create a new transducer with default parameters
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
weight_factor: 0.01,
|
||||
current_factor: 10.0,
|
||||
threshold_scale: 0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SpikeToGraph for DefaultSpikeGraphTransducer {
|
||||
fn spikes_to_weights(&self, spikes: &[Spike], graph: &mut DynamicGraph) {
|
||||
// Group spikes by time window
|
||||
let mut spike_counts: std::collections::HashMap<usize, usize> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for spike in spikes {
|
||||
*spike_counts.entry(spike.neuron_id).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
// High spike correlation → strengthen edge
|
||||
for edge in graph.edges() {
|
||||
let src_spikes = spike_counts
|
||||
.get(&(edge.source as usize))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let tgt_spikes = spike_counts
|
||||
.get(&(edge.target as usize))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
|
||||
// Hebbian-like weight update
|
||||
let correlation = (src_spikes * tgt_spikes) as f64;
|
||||
let delta_w = self.weight_factor * correlation;
|
||||
|
||||
if delta_w > 0.0 {
|
||||
let new_weight = edge.weight + delta_w;
|
||||
let _ = graph.update_edge_weight(edge.source, edge.target, new_weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn graph_to_spike_rates(&self, graph: &DynamicGraph) -> Vec<f64> {
|
||||
let vertices = graph.vertices();
|
||||
let mut rates = vec![0.0; vertices.len()];
|
||||
|
||||
for (i, v) in vertices.iter().enumerate() {
|
||||
// Higher degree → higher rate
|
||||
let degree = graph.degree(*v);
|
||||
// Total incident weight → rate modulation
|
||||
let weight_sum: f64 = graph
|
||||
.neighbors(*v)
|
||||
.iter()
|
||||
.filter_map(|(_, eid)| {
|
||||
graph
|
||||
.edges()
|
||||
.iter()
|
||||
.find(|e| e.id == *eid)
|
||||
.map(|e| e.weight)
|
||||
})
|
||||
.sum();
|
||||
|
||||
rates[i] = (degree as f64 + weight_sum) * 0.01;
|
||||
}
|
||||
|
||||
rates
|
||||
}
|
||||
}
|
||||
|
||||
impl GraphToSpike for DefaultSpikeGraphTransducer {
|
||||
fn weight_to_current(&self, weight: Weight) -> f64 {
|
||||
self.current_factor * weight
|
||||
}
|
||||
|
||||
fn degree_to_threshold(&self, degree: usize) -> f64 {
|
||||
// Handle degree=0 to avoid ln(0) = -inf
|
||||
if degree == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
1.0 + self.threshold_scale * (degree as f64).ln()
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum spikes to process for synchrony (DoS protection)
|
||||
const MAX_SYNCHRONY_SPIKES: usize = 10_000;
|
||||
|
||||
/// Synchrony measurement for spike trains using efficient O(n log n) algorithm
|
||||
///
|
||||
/// Uses time-binning approach instead of O(n²) pairwise comparison.
|
||||
/// For large spike trains, uses sampling to maintain O(n log n) complexity.
|
||||
pub fn compute_synchrony(spikes: &[Spike], window_ms: f64) -> f64 {
|
||||
if spikes.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Limit input size to prevent DoS
|
||||
let spikes = if spikes.len() > MAX_SYNCHRONY_SPIKES {
|
||||
&spikes[..MAX_SYNCHRONY_SPIKES]
|
||||
} else {
|
||||
spikes
|
||||
};
|
||||
|
||||
// Sort by time for efficient windowed counting
|
||||
let mut sorted: Vec<_> = spikes.to_vec();
|
||||
sorted.sort_by(|a, b| {
|
||||
a.time
|
||||
.partial_cmp(&b.time)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
// Use sliding window approach: O(n log n) due to sort
|
||||
let mut coincidences = 0usize;
|
||||
let mut window_start = 0;
|
||||
|
||||
for i in 0..sorted.len() {
|
||||
// Move window start forward
|
||||
while window_start < i && sorted[i].time - sorted[window_start].time > window_ms {
|
||||
window_start += 1;
|
||||
}
|
||||
|
||||
// Count coincident pairs in window (from window_start to i-1)
|
||||
for j in window_start..i {
|
||||
if sorted[i].neuron_id != sorted[j].neuron_id {
|
||||
coincidences += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Total inter-neuron pairs (excluding same-neuron pairs)
|
||||
let n = sorted.len();
|
||||
let mut neuron_counts: std::collections::HashMap<usize, usize> =
|
||||
std::collections::HashMap::new();
|
||||
for spike in &sorted {
|
||||
*neuron_counts.entry(spike.neuron_id).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
// Total inter-neuron pairs = all pairs - same-neuron pairs
|
||||
let total_inter_pairs: usize = {
|
||||
let total = n * (n - 1) / 2;
|
||||
let intra: usize = neuron_counts.values().map(|&c| c * (c - 1) / 2).sum();
|
||||
total - intra
|
||||
};
|
||||
|
||||
if total_inter_pairs == 0 {
|
||||
0.0
|
||||
} else {
|
||||
coincidences as f64 / total_inter_pairs as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Lyapunov-like energy function combining mincut and synchrony
|
||||
pub fn compute_energy(mincut: f64, synchrony: f64) -> f64 {
|
||||
-mincut - synchrony
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = SNNMinCutConfig::default();
|
||||
assert_eq!(config.dt, 1.0);
|
||||
assert!(config.enable_attractors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_synchrony_computation() {
|
||||
let spikes = vec![
|
||||
Spike {
|
||||
neuron_id: 0,
|
||||
time: 0.0,
|
||||
},
|
||||
Spike {
|
||||
neuron_id: 1,
|
||||
time: 0.5,
|
||||
},
|
||||
Spike {
|
||||
neuron_id: 2,
|
||||
time: 10.0,
|
||||
},
|
||||
];
|
||||
|
||||
let sync_narrow = compute_synchrony(&spikes, 1.0);
|
||||
let sync_wide = compute_synchrony(&spikes, 20.0);
|
||||
|
||||
// Wider window should capture more coincidences
|
||||
assert!(sync_wide >= sync_narrow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_energy_function() {
|
||||
let energy = compute_energy(10.0, 0.5);
|
||||
assert!(energy < 0.0);
|
||||
|
||||
// Higher mincut and synchrony → lower (more negative) energy
|
||||
let energy2 = compute_energy(20.0, 0.8);
|
||||
assert!(energy2 < energy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spike_train() {
|
||||
let spike = Spike {
|
||||
neuron_id: 42,
|
||||
time: 100.5,
|
||||
};
|
||||
assert_eq!(spike.neuron_id, 42);
|
||||
assert!((spike.time - 100.5).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
632
vendor/ruvector/crates/ruvector-mincut/src/snn/morphogenetic.rs
vendored
Normal file
632
vendor/ruvector/crates/ruvector-mincut/src/snn/morphogenetic.rs
vendored
Normal file
@@ -0,0 +1,632 @@
|
||||
//! # Layer 5: Morphogenetic Networks as Developmental SNN
|
||||
//!
|
||||
//! Implements Turing-pattern-like self-organizing growth for graph structures.
|
||||
//!
|
||||
//! ## Theory
|
||||
//!
|
||||
//! ```text
|
||||
//! Turing Pattern SNN Equivalent Graph Manifestation
|
||||
//! ────────────── ────────────── ────────────────────
|
||||
//! Activator Excitatory neurons Edge addition
|
||||
//! Inhibitor Inhibitory neurons Edge removal
|
||||
//! Diffusion Spike propagation Weight spreading
|
||||
//! Reaction Local computation Node-local mincut
|
||||
//! Pattern formation Self-organization Cluster emergence
|
||||
//! ```
|
||||
//!
|
||||
//! ## Growth Rules
|
||||
//!
|
||||
//! - High neural activation → new edges
|
||||
//! - Low activation → edge pruning
|
||||
//! - Maturity detected via mincut stability
|
||||
|
||||
use super::{
|
||||
network::{LayerConfig, NetworkConfig, SpikingNetwork},
|
||||
neuron::{LIFNeuron, NeuronConfig, NeuronPopulation},
|
||||
SimTime, Spike,
|
||||
};
|
||||
use crate::graph::{DynamicGraph, VertexId};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Configuration for morphogenetic development
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MorphConfig {
|
||||
/// Grid size (neurons arranged spatially)
|
||||
pub grid_size: usize,
|
||||
/// Growth activation threshold
|
||||
pub growth_threshold: f64,
|
||||
/// Prune activation threshold
|
||||
pub prune_threshold: f64,
|
||||
/// Minimum edge weight to keep
|
||||
pub prune_weight: f64,
|
||||
/// Target connectivity for maturity
|
||||
pub target_connectivity: f64,
|
||||
/// Stability epsilon for maturity detection
|
||||
pub stability_epsilon: f64,
|
||||
/// Diffusion kernel sigma
|
||||
pub diffusion_sigma: f64,
|
||||
/// Maximum development steps
|
||||
pub max_steps: usize,
|
||||
/// Time step
|
||||
pub dt: f64,
|
||||
}
|
||||
|
||||
impl Default for MorphConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
grid_size: 10,
|
||||
growth_threshold: 0.6,
|
||||
prune_threshold: 0.2,
|
||||
prune_weight: 0.1,
|
||||
target_connectivity: 0.5,
|
||||
stability_epsilon: 0.01,
|
||||
diffusion_sigma: 2.0,
|
||||
max_steps: 1000,
|
||||
dt: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turing pattern type for different growth modes
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum TuringPattern {
|
||||
/// Spots pattern (local clusters)
|
||||
Spots,
|
||||
/// Stripes pattern (elongated structures)
|
||||
Stripes,
|
||||
/// Labyrinth pattern (complex connectivity)
|
||||
Labyrinth,
|
||||
/// Uniform (no pattern)
|
||||
Uniform,
|
||||
}
|
||||
|
||||
/// Growth rules derived from neural activity
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GrowthRules {
|
||||
/// Threshold for creating new edges
|
||||
pub growth_threshold: f64,
|
||||
/// Threshold for pruning edges
|
||||
pub prune_threshold: f64,
|
||||
/// Minimum weight to survive pruning
|
||||
pub prune_weight: f64,
|
||||
/// Target connectivity (edge density)
|
||||
pub target_connectivity: f64,
|
||||
/// Pattern type to develop
|
||||
pub pattern: TuringPattern,
|
||||
}
|
||||
|
||||
impl Default for GrowthRules {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
growth_threshold: 0.6,
|
||||
prune_threshold: 0.2,
|
||||
prune_weight: 0.1,
|
||||
target_connectivity: 0.5,
|
||||
pattern: TuringPattern::Spots,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A spatial position on the grid
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct GridPosition {
|
||||
/// X coordinate
|
||||
pub x: usize,
|
||||
/// Y coordinate
|
||||
pub y: usize,
|
||||
}
|
||||
|
||||
impl GridPosition {
|
||||
/// Create a new grid position
|
||||
pub fn new(x: usize, y: usize) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
|
||||
/// Convert to linear index
|
||||
pub fn to_index(&self, grid_size: usize) -> usize {
|
||||
self.y * grid_size + self.x
|
||||
}
|
||||
|
||||
/// Create from linear index
|
||||
pub fn from_index(idx: usize, grid_size: usize) -> Self {
|
||||
Self {
|
||||
x: idx % grid_size,
|
||||
y: idx / grid_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Euclidean distance to another position
|
||||
pub fn distance(&self, other: &GridPosition) -> f64 {
|
||||
let dx = self.x as f64 - other.x as f64;
|
||||
let dy = self.y as f64 - other.y as f64;
|
||||
(dx * dx + dy * dy).sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
/// Diffusion kernel for spatial coupling
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffusionKernel {
|
||||
/// Kernel sigma (spread)
|
||||
pub sigma: f64,
|
||||
/// Excitatory range
|
||||
pub excite_range: f64,
|
||||
/// Inhibitory range
|
||||
pub inhibit_range: f64,
|
||||
}
|
||||
|
||||
impl DiffusionKernel {
|
||||
/// Create a new diffusion kernel
|
||||
pub fn new(sigma: f64) -> Self {
|
||||
Self {
|
||||
sigma,
|
||||
excite_range: sigma,
|
||||
inhibit_range: sigma * 3.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute kernel weight between two positions
|
||||
pub fn weight(&self, pos1: &GridPosition, pos2: &GridPosition) -> (f64, f64) {
|
||||
let d = pos1.distance(pos2);
|
||||
|
||||
// Mexican hat profile: excitation close, inhibition far
|
||||
let excite = (-d * d / (2.0 * self.excite_range * self.excite_range)).exp();
|
||||
let inhibit = (-d * d / (2.0 * self.inhibit_range * self.inhibit_range)).exp() * 0.5;
|
||||
|
||||
(excite, inhibit)
|
||||
}
|
||||
}
|
||||
|
||||
/// A morphogenetic neuron pair (excitatory + inhibitory)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MorphNeuronPair {
|
||||
/// Excitatory neuron
|
||||
pub excitatory: LIFNeuron,
|
||||
/// Inhibitory neuron
|
||||
pub inhibitory: LIFNeuron,
|
||||
/// Spatial position
|
||||
pub position: GridPosition,
|
||||
}
|
||||
|
||||
impl MorphNeuronPair {
|
||||
/// Create a new neuron pair at position
|
||||
pub fn new(id: usize, position: GridPosition) -> Self {
|
||||
let e_config = NeuronConfig {
|
||||
tau_membrane: 15.0,
|
||||
threshold: 0.8,
|
||||
..NeuronConfig::default()
|
||||
};
|
||||
|
||||
let i_config = NeuronConfig {
|
||||
tau_membrane: 25.0,
|
||||
threshold: 0.6,
|
||||
..NeuronConfig::default()
|
||||
};
|
||||
|
||||
Self {
|
||||
excitatory: LIFNeuron::with_config(id * 2, e_config),
|
||||
inhibitory: LIFNeuron::with_config(id * 2 + 1, i_config),
|
||||
position,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get net activation (excitation - inhibition)
|
||||
pub fn net_activation(&self) -> f64 {
|
||||
self.excitatory.membrane_potential() - self.inhibitory.membrane_potential()
|
||||
}
|
||||
}
|
||||
|
||||
/// Morphogenetic SNN with Turing-pattern dynamics
|
||||
pub struct MorphogeneticSNN {
|
||||
/// Spatial grid of excitatory/inhibitory neuron pairs
|
||||
neurons: Vec<MorphNeuronPair>,
|
||||
/// Grid size
|
||||
grid_size: usize,
|
||||
/// Diffusion kernel
|
||||
diffusion_kernel: DiffusionKernel,
|
||||
/// Growing graph structure
|
||||
graph: DynamicGraph,
|
||||
/// Growth rules
|
||||
growth_rules: GrowthRules,
|
||||
/// Configuration
|
||||
config: MorphConfig,
|
||||
/// Current simulation time
|
||||
time: SimTime,
|
||||
/// Mincut history for maturity detection
|
||||
mincut_history: Vec<f64>,
|
||||
/// Is development complete
|
||||
is_mature: bool,
|
||||
}
|
||||
|
||||
/// Maximum grid size to prevent memory exhaustion (256x256 = 65536 neurons)
|
||||
const MAX_GRID_SIZE: usize = 256;
|
||||
|
||||
impl MorphogeneticSNN {
|
||||
/// Create a new morphogenetic SNN
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if grid_size exceeds MAX_GRID_SIZE (256) to prevent memory exhaustion.
|
||||
pub fn new(config: MorphConfig) -> Self {
|
||||
// Resource limit: prevent grid_size² memory explosion
|
||||
let grid_size = config.grid_size.min(MAX_GRID_SIZE);
|
||||
let n = grid_size * grid_size;
|
||||
|
||||
// Create neuron pairs on grid
|
||||
let neurons: Vec<_> = (0..n)
|
||||
.map(|i| {
|
||||
let pos = GridPosition::from_index(i, grid_size);
|
||||
MorphNeuronPair::new(i, pos)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Initialize graph with nodes
|
||||
let graph = DynamicGraph::new();
|
||||
for i in 0..n {
|
||||
graph.add_vertex(i as u64);
|
||||
}
|
||||
|
||||
// Add initial local connectivity (4-neighborhood)
|
||||
for y in 0..grid_size {
|
||||
for x in 0..grid_size {
|
||||
let i = y * grid_size + x;
|
||||
|
||||
if x + 1 < grid_size {
|
||||
let j = y * grid_size + (x + 1);
|
||||
let _ = graph.insert_edge(i as u64, j as u64, 0.5);
|
||||
}
|
||||
|
||||
if y + 1 < grid_size {
|
||||
let j = (y + 1) * grid_size + x;
|
||||
let _ = graph.insert_edge(i as u64, j as u64, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let growth_rules = GrowthRules {
|
||||
growth_threshold: config.growth_threshold,
|
||||
prune_threshold: config.prune_threshold,
|
||||
prune_weight: config.prune_weight,
|
||||
target_connectivity: config.target_connectivity,
|
||||
..GrowthRules::default()
|
||||
};
|
||||
|
||||
Self {
|
||||
neurons,
|
||||
grid_size,
|
||||
diffusion_kernel: DiffusionKernel::new(config.diffusion_sigma),
|
||||
graph,
|
||||
growth_rules,
|
||||
config,
|
||||
time: 0.0,
|
||||
mincut_history: Vec::new(),
|
||||
is_mature: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one development step
|
||||
pub fn develop_step(&mut self) {
|
||||
let dt = self.config.dt;
|
||||
self.time += dt;
|
||||
|
||||
let n = self.neurons.len();
|
||||
|
||||
// 1. Neural dynamics with spatial diffusion
|
||||
let mut activities = vec![0.0; n];
|
||||
|
||||
for i in 0..n {
|
||||
// Compute diffusion input
|
||||
let pos_i = self.neurons[i].position;
|
||||
let mut e_input = 0.0;
|
||||
let mut i_input = 0.0;
|
||||
|
||||
for j in 0..n {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pos_j = &self.neurons[j].position;
|
||||
let (e_weight, i_weight) = self.diffusion_kernel.weight(&pos_i, pos_j);
|
||||
|
||||
e_input += e_weight * self.neurons[j].excitatory.membrane_potential();
|
||||
i_input += i_weight * self.neurons[j].inhibitory.membrane_potential();
|
||||
}
|
||||
|
||||
// Update neurons
|
||||
let e_current = e_input - self.neurons[i].inhibitory.membrane_potential();
|
||||
let i_current = i_input + self.neurons[i].excitatory.membrane_potential();
|
||||
|
||||
self.neurons[i].excitatory.step(e_current, dt, self.time);
|
||||
self.neurons[i].inhibitory.step(i_current, dt, self.time);
|
||||
|
||||
activities[i] = self.neurons[i].net_activation();
|
||||
}
|
||||
|
||||
// 2. Growth rules: modify graph based on activation
|
||||
self.apply_growth_rules(&activities);
|
||||
|
||||
// 3. Update mincut history for maturity detection
|
||||
let mincut = self.estimate_mincut();
|
||||
self.mincut_history.push(mincut);
|
||||
|
||||
if self.mincut_history.len() > 100 {
|
||||
self.mincut_history.remove(0);
|
||||
}
|
||||
|
||||
// 4. Check maturity
|
||||
self.is_mature = self.check_maturity(mincut);
|
||||
}
|
||||
|
||||
/// Apply growth rules based on neural activities
|
||||
fn apply_growth_rules(&mut self, activities: &[f64]) {
|
||||
let n = self.neurons.len();
|
||||
|
||||
// Collect growth/prune decisions
|
||||
let mut to_add: Vec<(VertexId, VertexId)> = Vec::new();
|
||||
let mut to_remove: Vec<(VertexId, VertexId)> = Vec::new();
|
||||
|
||||
for i in 0..n {
|
||||
let pos_i = &self.neurons[i].position;
|
||||
|
||||
// High activation → sprout new connections
|
||||
if activities[i] > self.growth_rules.growth_threshold {
|
||||
// Find growth targets (nearby nodes without existing edges)
|
||||
for j in 0..n {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pos_j = &self.neurons[j].position;
|
||||
let dist = pos_i.distance(pos_j);
|
||||
|
||||
// Only connect to nearby nodes
|
||||
if dist <= self.diffusion_kernel.excite_range * 2.0 {
|
||||
let u = i as u64;
|
||||
let v = j as u64;
|
||||
|
||||
if !self.graph.has_edge(u, v) {
|
||||
to_add.push((u, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Low activation → retract connections
|
||||
if activities[i] < self.growth_rules.prune_threshold {
|
||||
let u = i as u64;
|
||||
|
||||
for (v, _) in self.graph.neighbors(u) {
|
||||
if let Some(edge) = self.graph.get_edge(u, v) {
|
||||
if edge.weight < self.growth_rules.prune_weight {
|
||||
to_remove.push((u, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
for (u, v) in to_add {
|
||||
if !self.graph.has_edge(u, v) {
|
||||
let _ = self.graph.insert_edge(u, v, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
for (u, v) in to_remove {
|
||||
let _ = self.graph.delete_edge(u, v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate mincut (simplified)
|
||||
fn estimate_mincut(&self) -> f64 {
|
||||
if self.graph.num_vertices() == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Approximate: minimum degree
|
||||
self.graph
|
||||
.vertices()
|
||||
.iter()
|
||||
.map(|&v| self.graph.degree(v) as f64)
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// Check if development is mature
|
||||
fn check_maturity(&self, current_mincut: f64) -> bool {
|
||||
// Mature when connectivity target reached AND mincut is stable
|
||||
let connectivity = self.graph.num_edges() as f64
|
||||
/ (self.graph.num_vertices() * (self.graph.num_vertices() - 1) / 2).max(1) as f64;
|
||||
|
||||
if connectivity < self.growth_rules.target_connectivity {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check mincut stability
|
||||
if self.mincut_history.len() < 20 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let recent: Vec<_> = self.mincut_history.iter().rev().take(20).cloned().collect();
|
||||
let mean = recent.iter().sum::<f64>() / recent.len() as f64;
|
||||
let variance =
|
||||
recent.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / recent.len() as f64;
|
||||
|
||||
variance.sqrt() < self.config.stability_epsilon
|
||||
}
|
||||
|
||||
/// Run development until mature or max steps
|
||||
pub fn develop(&mut self) -> usize {
|
||||
let mut steps = 0;
|
||||
|
||||
while steps < self.config.max_steps && !self.is_mature {
|
||||
self.develop_step();
|
||||
steps += 1;
|
||||
}
|
||||
|
||||
steps
|
||||
}
|
||||
|
||||
/// Get developed graph
|
||||
pub fn graph(&self) -> &DynamicGraph {
|
||||
&self.graph
|
||||
}
|
||||
|
||||
/// Get grid size
|
||||
pub fn grid_size(&self) -> usize {
|
||||
self.grid_size
|
||||
}
|
||||
|
||||
/// Check if mature
|
||||
pub fn is_mature(&self) -> bool {
|
||||
self.is_mature
|
||||
}
|
||||
|
||||
/// Get current activation pattern
|
||||
pub fn activation_pattern(&self) -> Vec<f64> {
|
||||
self.neurons.iter().map(|n| n.net_activation()).collect()
|
||||
}
|
||||
|
||||
/// Detect pattern type from activation
|
||||
pub fn detect_pattern(&self) -> TuringPattern {
|
||||
let activations = self.activation_pattern();
|
||||
|
||||
// Compute spatial autocorrelation
|
||||
let mean = activations.iter().sum::<f64>() / activations.len() as f64;
|
||||
|
||||
let mut local_corr = 0.0;
|
||||
let mut global_corr = 0.0;
|
||||
let mut count = 0;
|
||||
|
||||
for i in 0..self.neurons.len() {
|
||||
for j in (i + 1)..self.neurons.len() {
|
||||
let dist = self.neurons[i].position.distance(&self.neurons[j].position);
|
||||
let prod = (activations[i] - mean) * (activations[j] - mean);
|
||||
|
||||
if dist <= 2.0 {
|
||||
local_corr += prod;
|
||||
}
|
||||
global_corr += prod / dist.max(0.1);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
local_corr /= count.max(1) as f64;
|
||||
global_corr /= count.max(1) as f64;
|
||||
|
||||
// Classify pattern
|
||||
if local_corr > 0.3 && global_corr < 0.1 {
|
||||
TuringPattern::Spots
|
||||
} else if local_corr > 0.2 && global_corr > 0.2 {
|
||||
TuringPattern::Stripes
|
||||
} else if local_corr > 0.1 {
|
||||
TuringPattern::Labyrinth
|
||||
} else {
|
||||
TuringPattern::Uniform
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset development
|
||||
pub fn reset(&mut self) {
|
||||
for pair in &mut self.neurons {
|
||||
pair.excitatory.reset();
|
||||
pair.inhibitory.reset();
|
||||
}
|
||||
|
||||
self.graph.clear();
|
||||
for i in 0..self.neurons.len() {
|
||||
self.graph.add_vertex(i as u64);
|
||||
}
|
||||
|
||||
// Re-add initial connectivity
|
||||
for y in 0..self.grid_size {
|
||||
for x in 0..self.grid_size {
|
||||
let i = y * self.grid_size + x;
|
||||
|
||||
if x + 1 < self.grid_size {
|
||||
let j = y * self.grid_size + (x + 1);
|
||||
let _ = self.graph.insert_edge(i as u64, j as u64, 0.5);
|
||||
}
|
||||
|
||||
if y + 1 < self.grid_size {
|
||||
let j = (y + 1) * self.grid_size + x;
|
||||
let _ = self.graph.insert_edge(i as u64, j as u64, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.time = 0.0;
|
||||
self.mincut_history.clear();
|
||||
self.is_mature = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_grid_position() {
|
||||
let pos1 = GridPosition::new(0, 0);
|
||||
let pos2 = GridPosition::new(3, 4);
|
||||
|
||||
assert_eq!(pos1.distance(&pos2), 5.0);
|
||||
assert_eq!(pos1.to_index(10), 0);
|
||||
assert_eq!(pos2.to_index(10), 43);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diffusion_kernel() {
|
||||
let kernel = DiffusionKernel::new(2.0);
|
||||
|
||||
let pos1 = GridPosition::new(0, 0);
|
||||
let pos2 = GridPosition::new(1, 0);
|
||||
let pos3 = GridPosition::new(5, 0);
|
||||
|
||||
let (e1, i1) = kernel.weight(&pos1, &pos2);
|
||||
let (e2, i2) = kernel.weight(&pos1, &pos3);
|
||||
|
||||
// Closer should have higher excitation
|
||||
assert!(e1 > e2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_morphogenetic_snn_creation() {
|
||||
let mut config = MorphConfig::default();
|
||||
config.grid_size = 5;
|
||||
|
||||
let snn = MorphogeneticSNN::new(config);
|
||||
|
||||
assert_eq!(snn.neurons.len(), 25);
|
||||
assert!(!snn.is_mature());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_morphogenetic_development() {
|
||||
let mut config = MorphConfig::default();
|
||||
config.grid_size = 5;
|
||||
config.max_steps = 100;
|
||||
|
||||
let mut snn = MorphogeneticSNN::new(config);
|
||||
|
||||
let steps = snn.develop();
|
||||
assert!(steps <= 100);
|
||||
|
||||
// Graph should have evolved
|
||||
assert!(snn.graph().num_edges() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pattern_detection() {
|
||||
let mut config = MorphConfig::default();
|
||||
config.grid_size = 5;
|
||||
|
||||
let snn = MorphogeneticSNN::new(config);
|
||||
let pattern = snn.detect_pattern();
|
||||
|
||||
// Initial state should be some pattern
|
||||
assert!(
|
||||
pattern == TuringPattern::Uniform
|
||||
|| pattern == TuringPattern::Spots
|
||||
|| pattern == TuringPattern::Stripes
|
||||
|| pattern == TuringPattern::Labyrinth
|
||||
);
|
||||
}
|
||||
}
|
||||
542
vendor/ruvector/crates/ruvector-mincut/src/snn/network.rs
vendored
Normal file
542
vendor/ruvector/crates/ruvector-mincut/src/snn/network.rs
vendored
Normal file
@@ -0,0 +1,542 @@
|
||||
//! # Spiking Neural Network Architecture
|
||||
//!
|
||||
//! Provides layered spiking network architecture for integration with MinCut algorithms.
|
||||
//!
|
||||
//! ## Network Types
|
||||
//!
|
||||
//! - **Feedforward**: Input → Hidden → Output
|
||||
//! - **Recurrent**: With lateral connections
|
||||
//! - **Graph-coupled**: Topology mirrors graph structure
|
||||
|
||||
use super::{
|
||||
neuron::{LIFNeuron, NeuronConfig, NeuronPopulation, SpikeTrain},
|
||||
synapse::{STDPConfig, Synapse, SynapseMatrix},
|
||||
SimTime, Spike, Vector,
|
||||
};
|
||||
use crate::graph::DynamicGraph;
|
||||
use rayon::prelude::*;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Configuration for a single network layer
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LayerConfig {
|
||||
/// Number of neurons in this layer
|
||||
pub size: usize,
|
||||
/// Neuron configuration
|
||||
pub neuron_config: NeuronConfig,
|
||||
/// Whether this layer has recurrent (lateral) connections
|
||||
pub recurrent: bool,
|
||||
}
|
||||
|
||||
impl LayerConfig {
|
||||
/// Create a new layer config
|
||||
pub fn new(size: usize) -> Self {
|
||||
Self {
|
||||
size,
|
||||
neuron_config: NeuronConfig::default(),
|
||||
recurrent: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable recurrent connections
|
||||
pub fn with_recurrence(mut self) -> Self {
|
||||
self.recurrent = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set custom neuron configuration
|
||||
pub fn with_neuron_config(mut self, config: NeuronConfig) -> Self {
|
||||
self.neuron_config = config;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the full network
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkConfig {
|
||||
/// Layer configurations (input to output)
|
||||
pub layers: Vec<LayerConfig>,
|
||||
/// STDP configuration for all synapses
|
||||
pub stdp_config: STDPConfig,
|
||||
/// Time step for simulation
|
||||
pub dt: f64,
|
||||
/// Enable winner-take-all lateral inhibition
|
||||
pub winner_take_all: bool,
|
||||
/// WTA inhibition strength
|
||||
pub wta_strength: f64,
|
||||
}
|
||||
|
||||
impl Default for NetworkConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
layers: vec![
|
||||
LayerConfig::new(100), // Input
|
||||
LayerConfig::new(50), // Hidden
|
||||
LayerConfig::new(10), // Output
|
||||
],
|
||||
stdp_config: STDPConfig::default(),
|
||||
dt: 1.0,
|
||||
winner_take_all: false,
|
||||
wta_strength: 0.8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A spiking neural network
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpikingNetwork {
|
||||
/// Configuration
|
||||
pub config: NetworkConfig,
|
||||
/// Neurons organized by layer
|
||||
layers: Vec<NeuronPopulation>,
|
||||
/// Feedforward weight matrices (layer i → layer i+1)
|
||||
feedforward_weights: Vec<SynapseMatrix>,
|
||||
/// Recurrent weight matrices (within layer)
|
||||
recurrent_weights: Vec<Option<SynapseMatrix>>,
|
||||
/// Current simulation time
|
||||
time: SimTime,
|
||||
/// Spike buffer for delayed transmission
|
||||
spike_buffer: VecDeque<(Spike, usize, SimTime)>, // (spike, target_layer, arrival_time)
|
||||
/// Global inhibition state (for WTA)
|
||||
global_inhibition: f64,
|
||||
}
|
||||
|
||||
impl SpikingNetwork {
|
||||
/// Create a new spiking network from configuration
|
||||
pub fn new(config: NetworkConfig) -> Self {
|
||||
let mut layers = Vec::new();
|
||||
let mut feedforward_weights = Vec::new();
|
||||
let mut recurrent_weights = Vec::new();
|
||||
|
||||
for (i, layer_config) in config.layers.iter().enumerate() {
|
||||
// Create neuron population
|
||||
let population = NeuronPopulation::with_config(
|
||||
layer_config.size,
|
||||
layer_config.neuron_config.clone(),
|
||||
);
|
||||
layers.push(population);
|
||||
|
||||
// Create feedforward weights to next layer
|
||||
if i + 1 < config.layers.len() {
|
||||
let next_size = config.layers[i + 1].size;
|
||||
let mut weights = SynapseMatrix::with_config(
|
||||
layer_config.size,
|
||||
next_size,
|
||||
config.stdp_config.clone(),
|
||||
);
|
||||
|
||||
// Initialize with random weights
|
||||
for pre in 0..layer_config.size {
|
||||
for post in 0..next_size {
|
||||
let weight = rand_weight();
|
||||
weights.add_synapse(pre, post, weight);
|
||||
}
|
||||
}
|
||||
|
||||
feedforward_weights.push(weights);
|
||||
}
|
||||
|
||||
// Create recurrent weights if enabled
|
||||
if layer_config.recurrent {
|
||||
let mut weights = SynapseMatrix::with_config(
|
||||
layer_config.size,
|
||||
layer_config.size,
|
||||
config.stdp_config.clone(),
|
||||
);
|
||||
|
||||
// Sparse random recurrent connections
|
||||
for pre in 0..layer_config.size {
|
||||
for post in 0..layer_config.size {
|
||||
if pre != post && rand_bool(0.1) {
|
||||
weights.add_synapse(pre, post, rand_weight() * 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recurrent_weights.push(Some(weights));
|
||||
} else {
|
||||
recurrent_weights.push(None);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
config,
|
||||
layers,
|
||||
feedforward_weights,
|
||||
recurrent_weights,
|
||||
time: 0.0,
|
||||
spike_buffer: VecDeque::new(),
|
||||
global_inhibition: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create network with topology matching a graph
|
||||
pub fn from_graph(graph: &DynamicGraph, config: NetworkConfig) -> Self {
|
||||
let n = graph.num_vertices();
|
||||
|
||||
// Single layer matching graph topology
|
||||
let mut network_config = config.clone();
|
||||
network_config.layers = vec![LayerConfig::new(n).with_recurrence()];
|
||||
|
||||
let mut network = Self::new(network_config);
|
||||
|
||||
// Copy graph edges as recurrent connections
|
||||
if let Some(ref mut recurrent) = network.recurrent_weights[0] {
|
||||
let vertices: Vec<_> = graph.vertices();
|
||||
let vertex_to_idx: std::collections::HashMap<_, _> =
|
||||
vertices.iter().enumerate().map(|(i, &v)| (v, i)).collect();
|
||||
|
||||
for edge in graph.edges() {
|
||||
if let (Some(&pre), Some(&post)) = (
|
||||
vertex_to_idx.get(&edge.source),
|
||||
vertex_to_idx.get(&edge.target),
|
||||
) {
|
||||
recurrent.set_weight(pre, post, edge.weight);
|
||||
recurrent.set_weight(post, pre, edge.weight); // Undirected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
network
|
||||
}
|
||||
|
||||
/// Reset network state
|
||||
pub fn reset(&mut self) {
|
||||
self.time = 0.0;
|
||||
self.spike_buffer.clear();
|
||||
self.global_inhibition = 0.0;
|
||||
|
||||
for layer in &mut self.layers {
|
||||
layer.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get number of layers
|
||||
pub fn num_layers(&self) -> usize {
|
||||
self.layers.len()
|
||||
}
|
||||
|
||||
/// Get layer size
|
||||
pub fn layer_size(&self, layer: usize) -> usize {
|
||||
self.layers.get(layer).map(|l| l.size()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Get current simulation time
|
||||
pub fn current_time(&self) -> SimTime {
|
||||
self.time
|
||||
}
|
||||
|
||||
/// Inject current to input layer
|
||||
pub fn inject_current(&mut self, currents: &[f64]) {
|
||||
if !self.layers.is_empty() {
|
||||
let input_layer = &mut self.layers[0];
|
||||
let n = currents.len().min(input_layer.size());
|
||||
|
||||
for (i, neuron) in input_layer.neurons.iter_mut().take(n).enumerate() {
|
||||
neuron.set_membrane_potential(neuron.membrane_potential() + currents[i] * 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one integration step
|
||||
/// Returns spikes from output layer
|
||||
pub fn step(&mut self) -> Vec<Spike> {
|
||||
let dt = self.config.dt;
|
||||
self.time += dt;
|
||||
|
||||
// Collect all spikes from this timestep
|
||||
let mut all_spikes: Vec<Vec<Spike>> = Vec::new();
|
||||
|
||||
// Process each layer
|
||||
for layer_idx in 0..self.layers.len() {
|
||||
// Calculate input currents for this layer
|
||||
let mut currents = vec![0.0; self.layers[layer_idx].size()];
|
||||
|
||||
// Add feedforward input from previous layer (sparse iteration)
|
||||
if layer_idx > 0 {
|
||||
let weights = &self.feedforward_weights[layer_idx - 1];
|
||||
// Collect pre-activations once
|
||||
let pre_activations: Vec<f64> = self.layers[layer_idx - 1]
|
||||
.neurons
|
||||
.iter()
|
||||
.map(|n| n.membrane_potential().max(0.0))
|
||||
.collect();
|
||||
// Use sparse weighted sum computation
|
||||
let ff_currents = weights.compute_weighted_sums(&pre_activations);
|
||||
for (j, &c) in ff_currents.iter().enumerate() {
|
||||
currents[j] += c;
|
||||
}
|
||||
}
|
||||
|
||||
// Add recurrent input (sparse iteration)
|
||||
if let Some(ref weights) = self.recurrent_weights[layer_idx] {
|
||||
// Collect activations
|
||||
let activations: Vec<f64> = self.layers[layer_idx]
|
||||
.neurons
|
||||
.iter()
|
||||
.map(|n| n.membrane_potential().max(0.0))
|
||||
.collect();
|
||||
// Use sparse weighted sum computation
|
||||
let rec_currents = weights.compute_weighted_sums(&activations);
|
||||
for (j, &c) in rec_currents.iter().enumerate() {
|
||||
currents[j] += c;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply winner-take-all inhibition
|
||||
if self.config.winner_take_all && layer_idx == self.layers.len() - 1 {
|
||||
let max_v = self.layers[layer_idx]
|
||||
.neurons
|
||||
.iter()
|
||||
.map(|n| n.membrane_potential())
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
|
||||
for (i, neuron) in self.layers[layer_idx].neurons.iter().enumerate() {
|
||||
if neuron.membrane_potential() < max_v {
|
||||
currents[i] -= self.config.wta_strength * self.global_inhibition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update neurons
|
||||
let spikes = self.layers[layer_idx].step(¤ts, dt);
|
||||
all_spikes.push(spikes.clone());
|
||||
|
||||
// Update global inhibition
|
||||
if !spikes.is_empty() {
|
||||
self.global_inhibition = (self.global_inhibition + 0.1).min(1.0);
|
||||
} else {
|
||||
self.global_inhibition *= 0.95;
|
||||
}
|
||||
|
||||
// STDP updates for feedforward weights
|
||||
if layer_idx > 0 {
|
||||
for spike in &spikes {
|
||||
self.feedforward_weights[layer_idx - 1]
|
||||
.on_post_spike(spike.neuron_id, self.time);
|
||||
}
|
||||
}
|
||||
|
||||
if layer_idx + 1 < self.layers.len() {
|
||||
for spike in &spikes {
|
||||
self.feedforward_weights[layer_idx].on_pre_spike(spike.neuron_id, self.time);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return output layer spikes
|
||||
all_spikes.last().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Run until a decision is made (output neuron spikes)
|
||||
pub fn run_until_decision(&mut self, max_steps: usize) -> Vec<Spike> {
|
||||
for _ in 0..max_steps {
|
||||
let spikes = self.step();
|
||||
if !spikes.is_empty() {
|
||||
return spikes;
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Get population firing rate for a layer
|
||||
pub fn layer_rate(&self, layer: usize, window: f64) -> f64 {
|
||||
self.layers
|
||||
.get(layer)
|
||||
.map(|l| l.population_rate(window))
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Get global synchrony
|
||||
pub fn global_synchrony(&self) -> f64 {
|
||||
let mut total_sync = 0.0;
|
||||
let mut count = 0;
|
||||
|
||||
for layer in &self.layers {
|
||||
total_sync += layer.synchrony(10.0);
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
total_sync / count as f64
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Get synchrony matrix (pairwise correlation)
|
||||
pub fn synchrony_matrix(&self) -> Vec<Vec<f64>> {
|
||||
// Single layer synchrony for simplicity
|
||||
let layer = &self.layers[0];
|
||||
let n = layer.size();
|
||||
let mut matrix = vec![vec![0.0; n]; n];
|
||||
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
let corr =
|
||||
layer.spike_trains[i].cross_correlation(&layer.spike_trains[j], 50.0, 5.0);
|
||||
let sync = corr.iter().sum::<f64>() / corr.len() as f64;
|
||||
matrix[i][j] = sync;
|
||||
matrix[j][i] = sync;
|
||||
}
|
||||
matrix[i][i] = 1.0;
|
||||
}
|
||||
|
||||
matrix
|
||||
}
|
||||
|
||||
/// Get output layer activities
|
||||
pub fn get_output(&self) -> Vec<f64> {
|
||||
self.layers
|
||||
.last()
|
||||
.map(|l| l.neurons.iter().map(|n| n.membrane_potential()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Apply reward signal for R-STDP
|
||||
pub fn apply_reward(&mut self, reward: f64) {
|
||||
for weights in &mut self.feedforward_weights {
|
||||
weights.apply_reward(reward);
|
||||
}
|
||||
for weights in &mut self.recurrent_weights {
|
||||
if let Some(w) = weights {
|
||||
w.apply_reward(reward);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get low-activity regions (for search skip optimization)
|
||||
pub fn low_activity_regions(&self) -> Vec<usize> {
|
||||
let mut low_activity = Vec::new();
|
||||
let threshold = 0.001;
|
||||
|
||||
for (layer_idx, layer) in self.layers.iter().enumerate() {
|
||||
for (neuron_idx, train) in layer.spike_trains.iter().enumerate() {
|
||||
if train.spike_rate(100.0) < threshold {
|
||||
low_activity.push(layer_idx * 1000 + neuron_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
low_activity
|
||||
}
|
||||
|
||||
/// Sync first layer weights back to graph
|
||||
pub fn sync_to_graph(&self, graph: &mut DynamicGraph) {
|
||||
if let Some(ref recurrent) = self.recurrent_weights.first().and_then(|r| r.as_ref()) {
|
||||
let vertices: Vec<_> = graph.vertices();
|
||||
|
||||
for ((pre, post), synapse) in recurrent.iter() {
|
||||
if *pre < vertices.len() && *post < vertices.len() {
|
||||
let u = vertices[*pre];
|
||||
let v = vertices[*post];
|
||||
if graph.has_edge(u, v) {
|
||||
let _ = graph.update_edge_weight(u, v, synapse.weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thread-safe PRNG for weight initialization using atomic CAS
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static RNG_STATE: AtomicU64 = AtomicU64::new(0x853c49e6748fea9b);
|
||||
|
||||
fn rand_u64() -> u64 {
|
||||
// Use compare_exchange loop to ensure atomicity
|
||||
loop {
|
||||
let current = RNG_STATE.load(Ordering::Relaxed);
|
||||
let next = current
|
||||
.wrapping_mul(0x5851f42d4c957f2d)
|
||||
.wrapping_add(0x14057b7ef767814f);
|
||||
match RNG_STATE.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => return next,
|
||||
Err(_) => continue, // Retry on contention
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rand_weight() -> f64 {
|
||||
(rand_u64() as f64) / (u64::MAX as f64) * 0.5 + 0.25
|
||||
}
|
||||
|
||||
fn rand_bool(p: f64) -> bool {
|
||||
(rand_u64() as f64) / (u64::MAX as f64) < p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_network_creation() {
|
||||
let config = NetworkConfig::default();
|
||||
let network = SpikingNetwork::new(config);
|
||||
|
||||
assert_eq!(network.num_layers(), 3);
|
||||
assert_eq!(network.layer_size(0), 100);
|
||||
assert_eq!(network.layer_size(1), 50);
|
||||
assert_eq!(network.layer_size(2), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_step() {
|
||||
let config = NetworkConfig::default();
|
||||
let mut network = SpikingNetwork::new(config);
|
||||
|
||||
// Inject strong current
|
||||
let currents = vec![5.0; 100];
|
||||
network.inject_current(¤ts);
|
||||
|
||||
// Run several steps
|
||||
let mut total_spikes = 0;
|
||||
for _ in 0..100 {
|
||||
let spikes = network.step();
|
||||
total_spikes += spikes.len();
|
||||
}
|
||||
|
||||
// Should produce some output
|
||||
assert!(network.current_time() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graph_network() {
|
||||
use crate::graph::DynamicGraph;
|
||||
|
||||
let graph = DynamicGraph::new();
|
||||
graph.insert_edge(0, 1, 1.0).unwrap();
|
||||
graph.insert_edge(1, 2, 1.0).unwrap();
|
||||
graph.insert_edge(2, 0, 1.0).unwrap();
|
||||
|
||||
let config = NetworkConfig::default();
|
||||
let network = SpikingNetwork::from_graph(&graph, config);
|
||||
|
||||
assert_eq!(network.num_layers(), 1);
|
||||
assert_eq!(network.layer_size(0), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_synchrony_matrix() {
|
||||
let mut config = NetworkConfig::default();
|
||||
config.layers = vec![LayerConfig::new(5)];
|
||||
|
||||
let mut network = SpikingNetwork::new(config);
|
||||
|
||||
// Run a bit
|
||||
let currents = vec![2.0; 5];
|
||||
for _ in 0..50 {
|
||||
network.inject_current(¤ts);
|
||||
network.step();
|
||||
}
|
||||
|
||||
let sync = network.synchrony_matrix();
|
||||
assert_eq!(sync.len(), 5);
|
||||
assert_eq!(sync[0].len(), 5);
|
||||
|
||||
// Diagonal should be 1
|
||||
for i in 0..5 {
|
||||
assert!((sync[i][i] - 1.0).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
}
|
||||
645
vendor/ruvector/crates/ruvector-mincut/src/snn/neuron.rs
vendored
Normal file
645
vendor/ruvector/crates/ruvector-mincut/src/snn/neuron.rs
vendored
Normal file
@@ -0,0 +1,645 @@
|
||||
//! # Leaky Integrate-and-Fire Neuron Model
|
||||
//!
|
||||
//! Implements LIF neurons with adaptive thresholds and refractory periods.
|
||||
//!
|
||||
//! ## Membrane Dynamics
|
||||
//!
|
||||
//! ```text
|
||||
//! τ_m * dV/dt = -(V - V_rest) + R * I(t)
|
||||
//! ```
|
||||
//!
|
||||
//! When V >= θ: emit spike, V → V_reset, enter refractory period
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - Exponential leak with configurable time constant
|
||||
//! - Adaptive threshold (increases after spike, decays back)
|
||||
//! - Absolute refractory period
|
||||
//! - Homeostatic plasticity for stable firing rates
|
||||
|
||||
use super::{SimTime, Spike};
|
||||
use rayon::prelude::*;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Threshold for using parallel neuron updates (overhead not worth it for small populations)
|
||||
/// Set high because neuron.step() is very fast, parallel overhead dominates for smaller sizes.
|
||||
const PARALLEL_THRESHOLD: usize = 2000;
|
||||
|
||||
/// Configuration for LIF neuron
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NeuronConfig {
|
||||
/// Membrane time constant (ms)
|
||||
pub tau_membrane: f64,
|
||||
/// Resting potential (mV)
|
||||
pub v_rest: f64,
|
||||
/// Reset potential after spike (mV)
|
||||
pub v_reset: f64,
|
||||
/// Initial threshold (mV)
|
||||
pub threshold: f64,
|
||||
/// Absolute refractory period (ms)
|
||||
pub t_refrac: f64,
|
||||
/// Membrane resistance (MΩ)
|
||||
pub resistance: f64,
|
||||
/// Threshold adaptation increment
|
||||
pub threshold_adapt: f64,
|
||||
/// Threshold adaptation time constant (ms)
|
||||
pub tau_threshold: f64,
|
||||
/// Enable homeostatic plasticity
|
||||
pub homeostatic: bool,
|
||||
/// Target spike rate (spikes/ms) for homeostasis
|
||||
pub target_rate: f64,
|
||||
/// Homeostatic time constant (ms)
|
||||
pub tau_homeostatic: f64,
|
||||
}
|
||||
|
||||
impl Default for NeuronConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tau_membrane: 20.0,
|
||||
v_rest: 0.0,
|
||||
v_reset: 0.0,
|
||||
threshold: 1.0,
|
||||
t_refrac: 2.0,
|
||||
resistance: 1.0,
|
||||
threshold_adapt: 0.1,
|
||||
tau_threshold: 100.0,
|
||||
homeostatic: true,
|
||||
target_rate: 0.01,
|
||||
tau_homeostatic: 1000.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State of a single LIF neuron
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NeuronState {
|
||||
/// Membrane potential (mV)
|
||||
pub v: f64,
|
||||
/// Current threshold (may be adapted)
|
||||
pub threshold: f64,
|
||||
/// Time remaining in refractory period (ms)
|
||||
pub refrac_remaining: f64,
|
||||
/// Last spike time (-∞ if never spiked)
|
||||
pub last_spike_time: f64,
|
||||
/// Running average spike rate (for homeostasis)
|
||||
pub spike_rate: f64,
|
||||
}
|
||||
|
||||
impl Default for NeuronState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
v: 0.0,
|
||||
threshold: 1.0,
|
||||
refrac_remaining: 0.0,
|
||||
last_spike_time: f64::NEG_INFINITY,
|
||||
spike_rate: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaky Integrate-and-Fire neuron
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LIFNeuron {
|
||||
/// Neuron ID
|
||||
pub id: usize,
|
||||
/// Configuration parameters
|
||||
pub config: NeuronConfig,
|
||||
/// Current state
|
||||
pub state: NeuronState,
|
||||
}
|
||||
|
||||
impl LIFNeuron {
|
||||
/// Create a new LIF neuron with given ID
|
||||
pub fn new(id: usize) -> Self {
|
||||
Self {
|
||||
id,
|
||||
config: NeuronConfig::default(),
|
||||
state: NeuronState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new LIF neuron with custom configuration
|
||||
pub fn with_config(id: usize, config: NeuronConfig) -> Self {
|
||||
let mut state = NeuronState::default();
|
||||
state.threshold = config.threshold;
|
||||
Self { id, config, state }
|
||||
}
|
||||
|
||||
/// Reset neuron state to initial conditions
|
||||
pub fn reset(&mut self) {
|
||||
self.state = NeuronState {
|
||||
threshold: self.config.threshold,
|
||||
..NeuronState::default()
|
||||
};
|
||||
}
|
||||
|
||||
/// Integrate input current for one timestep
|
||||
/// Returns true if neuron spiked
|
||||
pub fn step(&mut self, current: f64, dt: f64, time: SimTime) -> bool {
|
||||
// Handle refractory period
|
||||
if self.state.refrac_remaining > 0.0 {
|
||||
self.state.refrac_remaining -= dt;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Membrane dynamics: τ dV/dt = -(V - V_rest) + R*I
|
||||
let dv = (-self.state.v + self.config.v_rest + self.config.resistance * current)
|
||||
/ self.config.tau_membrane
|
||||
* dt;
|
||||
self.state.v += dv;
|
||||
|
||||
// Threshold adaptation decay
|
||||
if self.state.threshold > self.config.threshold {
|
||||
let d_thresh =
|
||||
-(self.state.threshold - self.config.threshold) / self.config.tau_threshold * dt;
|
||||
self.state.threshold += d_thresh;
|
||||
}
|
||||
|
||||
// Check for spike
|
||||
if self.state.v >= self.state.threshold {
|
||||
// Fire!
|
||||
self.state.v = self.config.v_reset;
|
||||
self.state.refrac_remaining = self.config.t_refrac;
|
||||
self.state.last_spike_time = time;
|
||||
|
||||
// Threshold adaptation
|
||||
self.state.threshold += self.config.threshold_adapt;
|
||||
|
||||
// Update running spike rate for homeostasis using proper exponential
|
||||
// decay based on tau_homeostatic: rate += (1 - rate) * dt / tau
|
||||
let alpha = (dt / self.config.tau_homeostatic).min(1.0);
|
||||
self.state.spike_rate = self.state.spike_rate * (1.0 - alpha) + alpha;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Update spike rate (decay toward 0)
|
||||
self.state.spike_rate *= 1.0 - dt / self.config.tau_homeostatic;
|
||||
|
||||
// Homeostatic plasticity: adjust threshold based on firing rate
|
||||
if self.config.homeostatic {
|
||||
let rate_error = self.state.spike_rate - self.config.target_rate;
|
||||
let d_base_thresh = rate_error * dt / self.config.tau_homeostatic;
|
||||
// Only apply to base threshold, not adapted part
|
||||
// This is a simplification - full implementation would track separately
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Inject a direct spike (for input neurons)
|
||||
pub fn inject_spike(&mut self, time: SimTime) {
|
||||
self.state.last_spike_time = time;
|
||||
// Use same homeostatic update as regular spikes
|
||||
let alpha = (1.0 / self.config.tau_homeostatic).min(1.0);
|
||||
self.state.spike_rate = self.state.spike_rate * (1.0 - alpha) + alpha;
|
||||
}
|
||||
|
||||
/// Get time since last spike
|
||||
pub fn time_since_spike(&self, current_time: SimTime) -> f64 {
|
||||
current_time - self.state.last_spike_time
|
||||
}
|
||||
|
||||
/// Check if neuron is in refractory period
|
||||
pub fn is_refractory(&self) -> bool {
|
||||
self.state.refrac_remaining > 0.0
|
||||
}
|
||||
|
||||
/// Get membrane potential
|
||||
pub fn membrane_potential(&self) -> f64 {
|
||||
self.state.v
|
||||
}
|
||||
|
||||
/// Set membrane potential directly
|
||||
pub fn set_membrane_potential(&mut self, v: f64) {
|
||||
self.state.v = v;
|
||||
}
|
||||
|
||||
/// Get current threshold
|
||||
pub fn threshold(&self) -> f64 {
|
||||
self.state.threshold
|
||||
}
|
||||
}
|
||||
|
||||
/// A collection of spikes over time for one neuron
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpikeTrain {
|
||||
/// Neuron ID
|
||||
pub neuron_id: usize,
|
||||
/// Spike times (sorted)
|
||||
pub spike_times: Vec<SimTime>,
|
||||
/// Maximum time window to keep
|
||||
pub max_window: f64,
|
||||
}
|
||||
|
||||
impl SpikeTrain {
|
||||
/// Create a new empty spike train
|
||||
pub fn new(neuron_id: usize) -> Self {
|
||||
Self {
|
||||
neuron_id,
|
||||
spike_times: Vec::new(),
|
||||
max_window: 1000.0, // 1 second default
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom window size
|
||||
pub fn with_window(neuron_id: usize, max_window: f64) -> Self {
|
||||
Self {
|
||||
neuron_id,
|
||||
spike_times: Vec::new(),
|
||||
max_window,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a spike at given time
|
||||
pub fn record_spike(&mut self, time: SimTime) {
|
||||
self.spike_times.push(time);
|
||||
|
||||
// Prune old spikes
|
||||
let cutoff = time - self.max_window;
|
||||
self.spike_times.retain(|&t| t >= cutoff);
|
||||
}
|
||||
|
||||
/// Clear all recorded spikes
|
||||
pub fn clear(&mut self) {
|
||||
self.spike_times.clear();
|
||||
}
|
||||
|
||||
/// Get number of spikes in the train
|
||||
pub fn count(&self) -> usize {
|
||||
self.spike_times.len()
|
||||
}
|
||||
|
||||
/// Compute instantaneous spike rate (spikes/ms)
|
||||
pub fn spike_rate(&self, window: f64) -> f64 {
|
||||
if self.spike_times.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let latest = self.spike_times.last().copied().unwrap_or(0.0);
|
||||
let count = self
|
||||
.spike_times
|
||||
.iter()
|
||||
.filter(|&&t| t >= latest - window)
|
||||
.count();
|
||||
|
||||
count as f64 / window
|
||||
}
|
||||
|
||||
/// Compute inter-spike interval statistics
|
||||
pub fn mean_isi(&self) -> Option<f64> {
|
||||
if self.spike_times.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut total_isi = 0.0;
|
||||
for i in 1..self.spike_times.len() {
|
||||
total_isi += self.spike_times[i] - self.spike_times[i - 1];
|
||||
}
|
||||
|
||||
Some(total_isi / (self.spike_times.len() - 1) as f64)
|
||||
}
|
||||
|
||||
/// Get coefficient of variation of ISI
|
||||
pub fn cv_isi(&self) -> Option<f64> {
|
||||
let mean = self.mean_isi()?;
|
||||
if mean == 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut variance = 0.0;
|
||||
for i in 1..self.spike_times.len() {
|
||||
let isi = self.spike_times[i] - self.spike_times[i - 1];
|
||||
variance += (isi - mean).powi(2);
|
||||
}
|
||||
variance /= (self.spike_times.len() - 1) as f64;
|
||||
|
||||
Some(variance.sqrt() / mean)
|
||||
}
|
||||
|
||||
/// Convert spike train to binary pattern (temporal encoding)
|
||||
///
|
||||
/// Safely handles potential overflow in bin calculation.
|
||||
pub fn to_pattern(&self, start: SimTime, bin_size: f64, num_bins: usize) -> Vec<bool> {
|
||||
let mut pattern = vec![false; num_bins];
|
||||
|
||||
// Guard against zero/negative bin_size
|
||||
if bin_size <= 0.0 || num_bins == 0 {
|
||||
return pattern;
|
||||
}
|
||||
|
||||
let end_time = start + bin_size * num_bins as f64;
|
||||
|
||||
for &spike_time in &self.spike_times {
|
||||
if spike_time >= start && spike_time < end_time {
|
||||
// Safe bin calculation with overflow protection
|
||||
let offset = spike_time - start;
|
||||
let bin_f64 = offset / bin_size;
|
||||
|
||||
// Check for overflow before casting
|
||||
if bin_f64 >= 0.0 && bin_f64 < num_bins as f64 {
|
||||
let bin = bin_f64 as usize;
|
||||
if bin < num_bins {
|
||||
pattern[bin] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
/// Check if spike times are sorted (for optimization)
|
||||
#[inline]
|
||||
fn is_sorted(times: &[f64]) -> bool {
|
||||
times.windows(2).all(|w| w[0] <= w[1])
|
||||
}
|
||||
|
||||
/// Compute cross-correlation with another spike train
|
||||
///
|
||||
/// Uses O(n log n) sliding window algorithm instead of O(n²) pairwise comparison.
|
||||
/// Optimized to skip sorting when spike trains are already sorted (typical case).
|
||||
/// Uses binary search for initial window position.
|
||||
pub fn cross_correlation(&self, other: &SpikeTrain, max_lag: f64, bin_size: f64) -> Vec<f64> {
|
||||
// Guard against invalid parameters
|
||||
if bin_size <= 0.0 || max_lag <= 0.0 {
|
||||
return vec![0.0];
|
||||
}
|
||||
|
||||
// Safe num_bins calculation with overflow protection
|
||||
let num_bins_f64 = 2.0 * max_lag / bin_size + 1.0;
|
||||
let num_bins = if num_bins_f64 > 0.0 && num_bins_f64 < usize::MAX as f64 {
|
||||
(num_bins_f64 as usize).min(100_000) // Cap at 100K bins to prevent DoS
|
||||
} else {
|
||||
return vec![0.0];
|
||||
};
|
||||
|
||||
let mut correlation = vec![0.0; num_bins];
|
||||
|
||||
// Empty train optimization
|
||||
if self.spike_times.is_empty() || other.spike_times.is_empty() {
|
||||
return correlation;
|
||||
}
|
||||
|
||||
// Avoid cloning and sorting if already sorted (typical case for spike trains)
|
||||
let t1_owned: Vec<f64>;
|
||||
let t2_owned: Vec<f64>;
|
||||
|
||||
let t1: &[f64] = if Self::is_sorted(&self.spike_times) {
|
||||
&self.spike_times
|
||||
} else {
|
||||
t1_owned = {
|
||||
let mut v = self.spike_times.clone();
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
v
|
||||
};
|
||||
&t1_owned
|
||||
};
|
||||
|
||||
let t2: &[f64] = if Self::is_sorted(&other.spike_times) {
|
||||
&other.spike_times
|
||||
} else {
|
||||
t2_owned = {
|
||||
let mut v = other.spike_times.clone();
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
v
|
||||
};
|
||||
&t2_owned
|
||||
};
|
||||
|
||||
// Use binary search for first spike's window start
|
||||
let first_lower = t1[0] - max_lag;
|
||||
let mut window_start = t2.partition_point(|&x| x < first_lower);
|
||||
|
||||
for &t1_spike in t1 {
|
||||
let lower_bound = t1_spike - max_lag;
|
||||
let upper_bound = t1_spike + max_lag;
|
||||
|
||||
// Advance window_start past spikes too early
|
||||
while window_start < t2.len() && t2[window_start] < lower_bound {
|
||||
window_start += 1;
|
||||
}
|
||||
|
||||
// Count spikes within window
|
||||
let mut j = window_start;
|
||||
while j < t2.len() && t2[j] <= upper_bound {
|
||||
let lag = t1_spike - t2[j];
|
||||
|
||||
// Safe bin calculation (inlined for performance)
|
||||
let bin = ((lag + max_lag) / bin_size) as usize;
|
||||
if bin < num_bins {
|
||||
correlation[bin] += 1.0;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize by geometric mean of spike counts
|
||||
let norm = ((self.count() * other.count()) as f64).sqrt();
|
||||
if norm > 0.0 {
|
||||
let inv_norm = 1.0 / norm;
|
||||
for c in &mut correlation {
|
||||
*c *= inv_norm;
|
||||
}
|
||||
}
|
||||
|
||||
correlation
|
||||
}
|
||||
}
|
||||
|
||||
/// Population of LIF neurons
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NeuronPopulation {
|
||||
/// All neurons in the population
|
||||
pub neurons: Vec<LIFNeuron>,
|
||||
/// Spike trains for each neuron
|
||||
pub spike_trains: Vec<SpikeTrain>,
|
||||
/// Current simulation time
|
||||
pub time: SimTime,
|
||||
}
|
||||
|
||||
impl NeuronPopulation {
|
||||
/// Create a new population with n neurons
|
||||
pub fn new(n: usize) -> Self {
|
||||
let neurons: Vec<_> = (0..n).map(|i| LIFNeuron::new(i)).collect();
|
||||
let spike_trains: Vec<_> = (0..n).map(|i| SpikeTrain::new(i)).collect();
|
||||
|
||||
Self {
|
||||
neurons,
|
||||
spike_trains,
|
||||
time: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create population with custom configuration
|
||||
pub fn with_config(n: usize, config: NeuronConfig) -> Self {
|
||||
let neurons: Vec<_> = (0..n)
|
||||
.map(|i| LIFNeuron::with_config(i, config.clone()))
|
||||
.collect();
|
||||
let spike_trains: Vec<_> = (0..n).map(|i| SpikeTrain::new(i)).collect();
|
||||
|
||||
Self {
|
||||
neurons,
|
||||
spike_trains,
|
||||
time: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get number of neurons
|
||||
pub fn size(&self) -> usize {
|
||||
self.neurons.len()
|
||||
}
|
||||
|
||||
/// Step all neurons with given currents
|
||||
///
|
||||
/// Uses parallel processing for large populations (>200 neurons).
|
||||
pub fn step(&mut self, currents: &[f64], dt: f64) -> Vec<Spike> {
|
||||
self.time += dt;
|
||||
let time = self.time;
|
||||
|
||||
if self.neurons.len() >= PARALLEL_THRESHOLD {
|
||||
// Parallel path: compute neuron updates in parallel
|
||||
let spike_flags: Vec<bool> = self
|
||||
.neurons
|
||||
.par_iter_mut()
|
||||
.enumerate()
|
||||
.map(|(i, neuron)| {
|
||||
let current = currents.get(i).copied().unwrap_or(0.0);
|
||||
neuron.step(current, dt, time)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sequential: collect spikes and record to trains
|
||||
let mut spikes = Vec::new();
|
||||
for (i, &spiked) in spike_flags.iter().enumerate() {
|
||||
if spiked {
|
||||
spikes.push(Spike { neuron_id: i, time });
|
||||
self.spike_trains[i].record_spike(time);
|
||||
}
|
||||
}
|
||||
spikes
|
||||
} else {
|
||||
// Sequential path for small populations (avoid parallel overhead)
|
||||
let mut spikes = Vec::new();
|
||||
for (i, neuron) in self.neurons.iter_mut().enumerate() {
|
||||
let current = currents.get(i).copied().unwrap_or(0.0);
|
||||
if neuron.step(current, dt, time) {
|
||||
spikes.push(Spike { neuron_id: i, time });
|
||||
self.spike_trains[i].record_spike(time);
|
||||
}
|
||||
}
|
||||
spikes
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset all neurons
|
||||
pub fn reset(&mut self) {
|
||||
self.time = 0.0;
|
||||
for neuron in &mut self.neurons {
|
||||
neuron.reset();
|
||||
}
|
||||
for train in &mut self.spike_trains {
|
||||
train.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get population spike rate
|
||||
pub fn population_rate(&self, window: f64) -> f64 {
|
||||
let total: f64 = self.spike_trains.iter().map(|t| t.spike_rate(window)).sum();
|
||||
total / self.neurons.len() as f64
|
||||
}
|
||||
|
||||
/// Compute population synchrony
|
||||
pub fn synchrony(&self, window: f64) -> f64 {
|
||||
// Collect recent spikes
|
||||
let mut all_spikes = Vec::new();
|
||||
let cutoff = self.time - window;
|
||||
|
||||
for train in &self.spike_trains {
|
||||
for &t in &train.spike_times {
|
||||
if t >= cutoff {
|
||||
all_spikes.push(Spike {
|
||||
neuron_id: train.neuron_id,
|
||||
time: t,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super::compute_synchrony(&all_spikes, window / 10.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_lif_neuron_creation() {
|
||||
let neuron = LIFNeuron::new(0);
|
||||
assert_eq!(neuron.id, 0);
|
||||
assert_eq!(neuron.state.v, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lif_neuron_spike() {
|
||||
let mut neuron = LIFNeuron::new(0);
|
||||
|
||||
// Apply strong current until it spikes
|
||||
let mut spiked = false;
|
||||
for i in 0..100 {
|
||||
if neuron.step(2.0, 1.0, i as f64) {
|
||||
spiked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(spiked);
|
||||
assert!(neuron.is_refractory());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spike_train() {
|
||||
let mut train = SpikeTrain::new(0);
|
||||
train.record_spike(10.0);
|
||||
train.record_spike(20.0);
|
||||
train.record_spike(30.0);
|
||||
|
||||
assert_eq!(train.count(), 3);
|
||||
|
||||
let mean_isi = train.mean_isi().unwrap();
|
||||
assert!((mean_isi - 10.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_neuron_population() {
|
||||
let mut pop = NeuronPopulation::new(100);
|
||||
|
||||
// Apply uniform current
|
||||
let currents = vec![1.5; 100];
|
||||
|
||||
let mut total_spikes = 0;
|
||||
for _ in 0..100 {
|
||||
let spikes = pop.step(¤ts, 1.0);
|
||||
total_spikes += spikes.len();
|
||||
}
|
||||
|
||||
// Should have some spikes after 100ms with current of 1.5
|
||||
assert!(total_spikes > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spike_train_pattern() {
|
||||
let mut train = SpikeTrain::new(0);
|
||||
train.record_spike(1.0);
|
||||
train.record_spike(3.0);
|
||||
train.record_spike(7.0);
|
||||
|
||||
let pattern = train.to_pattern(0.0, 1.0, 10);
|
||||
assert_eq!(pattern.len(), 10);
|
||||
assert!(pattern[1]); // Spike at t=1
|
||||
assert!(pattern[3]); // Spike at t=3
|
||||
assert!(pattern[7]); // Spike at t=7
|
||||
assert!(!pattern[0]); // No spike at t=0
|
||||
}
|
||||
}
|
||||
840
vendor/ruvector/crates/ruvector-mincut/src/snn/optimizer.rs
vendored
Normal file
840
vendor/ruvector/crates/ruvector-mincut/src/snn/optimizer.rs
vendored
Normal file
@@ -0,0 +1,840 @@
|
||||
//! # Layer 6: Neural Optimizer as Reinforcement Learning on Graphs
|
||||
//!
|
||||
//! Implements neural network-based graph optimization using policy gradients.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! - **Policy SNN**: Outputs graph modification actions via spike rates
|
||||
//! - **Value Network**: Estimates mincut improvement
|
||||
//! - **Experience Replay**: Prioritized sampling for stable learning
|
||||
//!
|
||||
//! ## Key Features
|
||||
//!
|
||||
//! - STDP-based policy gradient for spike-driven learning
|
||||
//! - TD learning via spike-timing for value estimation
|
||||
//! - Subpolynomial search exploiting learned graph structure
|
||||
|
||||
use super::{
|
||||
network::{LayerConfig, NetworkConfig, SpikingNetwork},
|
||||
neuron::{LIFNeuron, NeuronConfig, NeuronPopulation},
|
||||
synapse::{STDPConfig, Synapse, SynapseMatrix},
|
||||
SimTime, Spike,
|
||||
};
|
||||
use crate::graph::{DynamicGraph, EdgeId, VertexId, Weight};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Configuration for neural graph optimizer
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OptimizerConfig {
|
||||
/// Number of input features
|
||||
pub input_size: usize,
|
||||
/// Hidden layer size
|
||||
pub hidden_size: usize,
|
||||
/// Number of possible actions
|
||||
pub num_actions: usize,
|
||||
/// Learning rate
|
||||
pub learning_rate: f64,
|
||||
/// Discount factor (gamma)
|
||||
pub gamma: f64,
|
||||
/// Reward weight for search efficiency
|
||||
pub search_weight: f64,
|
||||
/// Experience replay buffer size
|
||||
pub replay_buffer_size: usize,
|
||||
/// Batch size for training
|
||||
pub batch_size: usize,
|
||||
/// Time step
|
||||
pub dt: f64,
|
||||
}
|
||||
|
||||
impl Default for OptimizerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
input_size: 10,
|
||||
hidden_size: 32,
|
||||
num_actions: 5,
|
||||
learning_rate: 0.01,
|
||||
gamma: 0.99,
|
||||
search_weight: 0.1,
|
||||
replay_buffer_size: 10000,
|
||||
batch_size: 32,
|
||||
dt: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph modification action
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum GraphAction {
|
||||
/// Add an edge between vertices
|
||||
AddEdge(VertexId, VertexId, Weight),
|
||||
/// Remove an edge
|
||||
RemoveEdge(VertexId, VertexId),
|
||||
/// Strengthen an edge (increase weight)
|
||||
Strengthen(VertexId, VertexId, f64),
|
||||
/// Weaken an edge (decrease weight)
|
||||
Weaken(VertexId, VertexId, f64),
|
||||
/// No action
|
||||
NoOp,
|
||||
}
|
||||
|
||||
impl GraphAction {
|
||||
/// Get action index
|
||||
pub fn to_index(&self) -> usize {
|
||||
match self {
|
||||
GraphAction::AddEdge(..) => 0,
|
||||
GraphAction::RemoveEdge(..) => 1,
|
||||
GraphAction::Strengthen(..) => 2,
|
||||
GraphAction::Weaken(..) => 3,
|
||||
GraphAction::NoOp => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of an optimization step
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OptimizationResult {
|
||||
/// Action taken
|
||||
pub action: GraphAction,
|
||||
/// Reward received
|
||||
pub reward: f64,
|
||||
/// New mincut value
|
||||
pub new_mincut: f64,
|
||||
/// Average search latency
|
||||
pub search_latency: f64,
|
||||
}
|
||||
|
||||
/// Experience for replay buffer
|
||||
#[derive(Debug, Clone)]
|
||||
struct Experience {
|
||||
/// State features
|
||||
state: Vec<f64>,
|
||||
/// Action taken
|
||||
action_idx: usize,
|
||||
/// Reward received
|
||||
reward: f64,
|
||||
/// Next state features
|
||||
next_state: Vec<f64>,
|
||||
/// Is terminal state
|
||||
done: bool,
|
||||
/// TD error for prioritization
|
||||
td_error: f64,
|
||||
}
|
||||
|
||||
/// Prioritized experience replay buffer
|
||||
struct PrioritizedReplayBuffer {
|
||||
/// Stored experiences
|
||||
buffer: VecDeque<Experience>,
|
||||
/// Maximum capacity
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl PrioritizedReplayBuffer {
|
||||
fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
buffer: VecDeque::with_capacity(capacity),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, exp: Experience) {
|
||||
if self.buffer.len() >= self.capacity {
|
||||
self.buffer.pop_front();
|
||||
}
|
||||
self.buffer.push_back(exp);
|
||||
}
|
||||
|
||||
fn sample(&self, batch_size: usize) -> Vec<&Experience> {
|
||||
// Prioritized by TD error (simplified: just take recent high-error samples)
|
||||
let mut sorted: Vec<_> = self.buffer.iter().collect();
|
||||
sorted.sort_by(|a, b| {
|
||||
b.td_error
|
||||
.abs()
|
||||
.partial_cmp(&a.td_error.abs())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
sorted.into_iter().take(batch_size).collect()
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.buffer.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple value network for state value estimation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ValueNetwork {
|
||||
/// Weights from input to hidden
|
||||
w_hidden: Vec<Vec<f64>>,
|
||||
/// Hidden biases
|
||||
b_hidden: Vec<f64>,
|
||||
/// Weights from hidden to output
|
||||
w_output: Vec<f64>,
|
||||
/// Output bias
|
||||
b_output: f64,
|
||||
/// Last estimate (for TD error)
|
||||
last_estimate: f64,
|
||||
}
|
||||
|
||||
impl ValueNetwork {
|
||||
/// Create a new value network
|
||||
pub fn new(input_size: usize, hidden_size: usize) -> Self {
|
||||
// Initialize with small random weights (Xavier initialization)
|
||||
let scale = (2.0 / (input_size + hidden_size) as f64).sqrt();
|
||||
let w_hidden: Vec<Vec<f64>> = (0..hidden_size)
|
||||
.map(|_| (0..input_size).map(|_| rand_small() * scale).collect())
|
||||
.collect();
|
||||
|
||||
let b_hidden = vec![0.0; hidden_size];
|
||||
|
||||
let output_scale = (1.0 / hidden_size as f64).sqrt();
|
||||
let w_output: Vec<f64> = (0..hidden_size)
|
||||
.map(|_| rand_small() * output_scale)
|
||||
.collect();
|
||||
let b_output = 0.0;
|
||||
|
||||
Self {
|
||||
w_hidden,
|
||||
b_hidden,
|
||||
w_output,
|
||||
b_output,
|
||||
last_estimate: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate value of a state
|
||||
pub fn estimate(&mut self, state: &[f64]) -> f64 {
|
||||
// Hidden layer
|
||||
let mut hidden = vec![0.0; self.w_hidden.len()];
|
||||
for (j, weights) in self.w_hidden.iter().enumerate() {
|
||||
let mut sum = self.b_hidden[j];
|
||||
for (i, &w) in weights.iter().enumerate() {
|
||||
if i < state.len() {
|
||||
sum += w * state[i];
|
||||
}
|
||||
}
|
||||
hidden[j] = relu(sum);
|
||||
}
|
||||
|
||||
// Output layer
|
||||
let mut output = self.b_output;
|
||||
for (j, &w) in self.w_output.iter().enumerate() {
|
||||
output += w * hidden[j];
|
||||
}
|
||||
|
||||
self.last_estimate = output;
|
||||
output
|
||||
}
|
||||
|
||||
/// Get previous estimate
|
||||
pub fn estimate_previous(&self) -> f64 {
|
||||
self.last_estimate
|
||||
}
|
||||
|
||||
/// Update weights with TD error using proper backpropagation
|
||||
///
|
||||
/// Implements gradient descent with:
|
||||
/// - Forward pass to compute activations
|
||||
/// - Backward pass to compute ∂V/∂w
|
||||
/// - Weight update: w += lr * td_error * ∂V/∂w
|
||||
pub fn update(&mut self, state: &[f64], td_error: f64, lr: f64) {
|
||||
let hidden_size = self.w_hidden.len();
|
||||
let input_size = if self.w_hidden.is_empty() {
|
||||
0
|
||||
} else {
|
||||
self.w_hidden[0].len()
|
||||
};
|
||||
|
||||
// Forward pass: compute hidden activations and pre-activations
|
||||
let mut hidden_pre = vec![0.0; hidden_size]; // Before ReLU
|
||||
let mut hidden_post = vec![0.0; hidden_size]; // After ReLU
|
||||
|
||||
for (j, weights) in self.w_hidden.iter().enumerate() {
|
||||
let mut sum = self.b_hidden[j];
|
||||
for (i, &w) in weights.iter().enumerate() {
|
||||
if i < state.len() {
|
||||
sum += w * state[i];
|
||||
}
|
||||
}
|
||||
hidden_pre[j] = sum;
|
||||
hidden_post[j] = relu(sum);
|
||||
}
|
||||
|
||||
// Backward pass: compute gradients
|
||||
// Output layer gradient: ∂L/∂w_output = td_error * hidden_post
|
||||
// (since L = 0.5 * td_error², ∂L/∂V = td_error)
|
||||
|
||||
// Update output weights: ∂V/∂w_output[j] = hidden_post[j]
|
||||
for (j, w) in self.w_output.iter_mut().enumerate() {
|
||||
*w += lr * td_error * hidden_post[j];
|
||||
}
|
||||
self.b_output += lr * td_error;
|
||||
|
||||
// Backpropagate to hidden layer
|
||||
// ∂V/∂hidden_post[j] = w_output[j]
|
||||
// ∂hidden_post/∂hidden_pre = relu'(hidden_pre) = 1 if hidden_pre > 0 else 0
|
||||
// ∂V/∂w_hidden[j][i] = ∂V/∂hidden_post[j] * relu'(hidden_pre[j]) * state[i]
|
||||
|
||||
for (j, weights) in self.w_hidden.iter_mut().enumerate() {
|
||||
// ReLU derivative: 1 if pre-activation > 0, else 0
|
||||
let relu_grad = if hidden_pre[j] > 0.0 { 1.0 } else { 0.0 };
|
||||
let delta = td_error * self.w_output[j] * relu_grad;
|
||||
|
||||
for (i, w) in weights.iter_mut().enumerate() {
|
||||
if i < state.len() {
|
||||
*w += lr * delta * state[i];
|
||||
}
|
||||
}
|
||||
self.b_hidden[j] += lr * delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Policy SNN for action selection
|
||||
pub struct PolicySNN {
|
||||
/// Input layer
|
||||
input_layer: NeuronPopulation,
|
||||
/// Hidden recurrent layer
|
||||
hidden_layer: NeuronPopulation,
|
||||
/// Output layer (one neuron per action)
|
||||
output_layer: NeuronPopulation,
|
||||
/// Input → Hidden weights
|
||||
w_ih: SynapseMatrix,
|
||||
/// Hidden → Output weights
|
||||
w_ho: SynapseMatrix,
|
||||
/// Reward-modulated STDP configuration
|
||||
stdp_config: STDPConfig,
|
||||
/// Configuration
|
||||
config: OptimizerConfig,
|
||||
}
|
||||
|
||||
impl PolicySNN {
|
||||
/// Create a new policy SNN
|
||||
pub fn new(config: OptimizerConfig) -> Self {
|
||||
let input_config = NeuronConfig {
|
||||
tau_membrane: 10.0,
|
||||
threshold: 0.8,
|
||||
..NeuronConfig::default()
|
||||
};
|
||||
|
||||
let hidden_config = NeuronConfig {
|
||||
tau_membrane: 20.0,
|
||||
threshold: 1.0,
|
||||
..NeuronConfig::default()
|
||||
};
|
||||
|
||||
let output_config = NeuronConfig {
|
||||
tau_membrane: 15.0,
|
||||
threshold: 0.6,
|
||||
..NeuronConfig::default()
|
||||
};
|
||||
|
||||
let input_layer = NeuronPopulation::with_config(config.input_size, input_config);
|
||||
let hidden_layer = NeuronPopulation::with_config(config.hidden_size, hidden_config);
|
||||
let output_layer = NeuronPopulation::with_config(config.num_actions, output_config);
|
||||
|
||||
// Initialize weights
|
||||
let mut w_ih = SynapseMatrix::new(config.input_size, config.hidden_size);
|
||||
let mut w_ho = SynapseMatrix::new(config.hidden_size, config.num_actions);
|
||||
|
||||
// Random initialization
|
||||
for i in 0..config.input_size {
|
||||
for j in 0..config.hidden_size {
|
||||
w_ih.add_synapse(i, j, rand_small() + 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
for i in 0..config.hidden_size {
|
||||
for j in 0..config.num_actions {
|
||||
w_ho.add_synapse(i, j, rand_small() + 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
input_layer,
|
||||
hidden_layer,
|
||||
output_layer,
|
||||
w_ih,
|
||||
w_ho,
|
||||
stdp_config: STDPConfig::default(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject state as current to input layer
|
||||
pub fn inject(&mut self, state: &[f64]) {
|
||||
for (i, neuron) in self.input_layer.neurons.iter_mut().enumerate() {
|
||||
if i < state.len() {
|
||||
neuron.set_membrane_potential(state[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run until decision (output spike)
|
||||
pub fn run_until_decision(&mut self, max_steps: usize) -> Vec<Spike> {
|
||||
for step in 0..max_steps {
|
||||
let time = step as f64 * self.config.dt;
|
||||
|
||||
// Compute hidden currents from input
|
||||
let mut hidden_currents = vec![0.0; self.config.hidden_size];
|
||||
for j in 0..self.config.hidden_size {
|
||||
for i in 0..self.config.input_size {
|
||||
hidden_currents[j] += self.w_ih.weight(i, j)
|
||||
* self.input_layer.neurons[i].membrane_potential().max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Update hidden layer
|
||||
let hidden_spikes = self.hidden_layer.step(&hidden_currents, self.config.dt);
|
||||
|
||||
// Compute output currents from hidden
|
||||
let mut output_currents = vec![0.0; self.config.num_actions];
|
||||
for j in 0..self.config.num_actions {
|
||||
for i in 0..self.config.hidden_size {
|
||||
output_currents[j] += self.w_ho.weight(i, j)
|
||||
* self.hidden_layer.neurons[i].membrane_potential().max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Update output layer
|
||||
let output_spikes = self.output_layer.step(&output_currents, self.config.dt);
|
||||
|
||||
// STDP updates
|
||||
for spike in &hidden_spikes {
|
||||
self.w_ih.on_post_spike(spike.neuron_id, time);
|
||||
}
|
||||
for spike in &output_spikes {
|
||||
self.w_ho.on_post_spike(spike.neuron_id, time);
|
||||
}
|
||||
|
||||
// Return if we have output spikes
|
||||
if !output_spikes.is_empty() {
|
||||
return output_spikes;
|
||||
}
|
||||
}
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Apply reward-modulated STDP
|
||||
pub fn apply_reward_modulated_stdp(&mut self, td_error: f64) {
|
||||
self.w_ih.apply_reward(td_error);
|
||||
self.w_ho.apply_reward(td_error);
|
||||
}
|
||||
|
||||
/// Get regions with low activity (for search skip)
|
||||
pub fn low_activity_regions(&self) -> Vec<usize> {
|
||||
self.hidden_layer
|
||||
.spike_trains
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| t.spike_rate(100.0) < 0.001)
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reset SNN state
|
||||
pub fn reset(&mut self) {
|
||||
self.input_layer.reset();
|
||||
self.hidden_layer.reset();
|
||||
self.output_layer.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// Neural Graph Optimizer combining policy and value networks
|
||||
pub struct NeuralGraphOptimizer {
|
||||
/// Policy network: SNN that outputs graph modification actions
|
||||
policy_snn: PolicySNN,
|
||||
/// Value network: estimates mincut improvement
|
||||
value_network: ValueNetwork,
|
||||
/// Experience replay buffer
|
||||
replay_buffer: PrioritizedReplayBuffer,
|
||||
/// Current graph state
|
||||
graph: DynamicGraph,
|
||||
/// Configuration
|
||||
config: OptimizerConfig,
|
||||
/// Current simulation time
|
||||
time: SimTime,
|
||||
/// Previous mincut for reward computation
|
||||
prev_mincut: f64,
|
||||
/// Previous state for experience storage
|
||||
prev_state: Vec<f64>,
|
||||
/// Search statistics
|
||||
search_latencies: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl NeuralGraphOptimizer {
|
||||
/// Create a new neural graph optimizer
|
||||
pub fn new(graph: DynamicGraph, config: OptimizerConfig) -> Self {
|
||||
let prev_state = extract_features(&graph, config.input_size);
|
||||
let prev_mincut = estimate_mincut(&graph);
|
||||
|
||||
Self {
|
||||
policy_snn: PolicySNN::new(config.clone()),
|
||||
value_network: ValueNetwork::new(config.input_size, config.hidden_size),
|
||||
replay_buffer: PrioritizedReplayBuffer::new(config.replay_buffer_size),
|
||||
graph,
|
||||
config,
|
||||
time: 0.0,
|
||||
prev_mincut,
|
||||
prev_state,
|
||||
search_latencies: VecDeque::with_capacity(100),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one optimization step
|
||||
pub fn optimize_step(&mut self) -> OptimizationResult {
|
||||
// 1. Encode current state as spike pattern
|
||||
let state = extract_features(&self.graph, self.config.input_size);
|
||||
|
||||
// 2. Policy SNN outputs action distribution via spike rates
|
||||
self.policy_snn.inject(&state);
|
||||
let action_spikes = self.policy_snn.run_until_decision(50);
|
||||
let action = self.decode_action(&action_spikes);
|
||||
|
||||
// 3. Execute action on graph
|
||||
let old_mincut = estimate_mincut(&self.graph);
|
||||
self.apply_action(&action);
|
||||
let new_mincut = estimate_mincut(&self.graph);
|
||||
|
||||
// 4. Compute reward: mincut improvement + search efficiency
|
||||
let mincut_reward = if old_mincut > 0.0 {
|
||||
(new_mincut - old_mincut) / old_mincut
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let search_reward = self.measure_search_efficiency();
|
||||
let reward = mincut_reward + self.config.search_weight * search_reward;
|
||||
|
||||
// 5. TD learning update via spike-timing
|
||||
let new_state = extract_features(&self.graph, self.config.input_size);
|
||||
let current_value = self.value_network.estimate(&state);
|
||||
let next_value = self.value_network.estimate(&new_state);
|
||||
|
||||
let td_error = reward + self.config.gamma * next_value - current_value;
|
||||
|
||||
// 6. STDP-based policy gradient
|
||||
self.policy_snn.apply_reward_modulated_stdp(td_error);
|
||||
|
||||
// 7. Update value network
|
||||
self.value_network
|
||||
.update(&state, td_error, self.config.learning_rate);
|
||||
|
||||
// 8. Store experience
|
||||
let exp = Experience {
|
||||
state: self.prev_state.clone(),
|
||||
action_idx: action.to_index(),
|
||||
reward,
|
||||
next_state: new_state.clone(),
|
||||
done: false,
|
||||
td_error,
|
||||
};
|
||||
self.replay_buffer.push(exp);
|
||||
|
||||
// Update state
|
||||
self.prev_state = new_state;
|
||||
self.prev_mincut = new_mincut;
|
||||
self.time += self.config.dt;
|
||||
|
||||
OptimizationResult {
|
||||
action,
|
||||
reward,
|
||||
new_mincut,
|
||||
search_latency: search_reward,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode action from spikes
|
||||
fn decode_action(&self, spikes: &[Spike]) -> GraphAction {
|
||||
if spikes.is_empty() {
|
||||
return GraphAction::NoOp;
|
||||
}
|
||||
|
||||
// Use first spike's neuron as action
|
||||
let action_idx = spikes[0].neuron_id;
|
||||
|
||||
// Get random vertices for action
|
||||
let vertices: Vec<_> = self.graph.vertices();
|
||||
|
||||
if vertices.len() < 2 {
|
||||
return GraphAction::NoOp;
|
||||
}
|
||||
|
||||
let v1 = vertices[action_idx % vertices.len()];
|
||||
let v2 = vertices[(action_idx + 1) % vertices.len()];
|
||||
|
||||
match action_idx % 5 {
|
||||
0 => {
|
||||
if !self.graph.has_edge(v1, v2) {
|
||||
GraphAction::AddEdge(v1, v2, 1.0)
|
||||
} else {
|
||||
GraphAction::NoOp
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
if self.graph.has_edge(v1, v2) {
|
||||
GraphAction::RemoveEdge(v1, v2)
|
||||
} else {
|
||||
GraphAction::NoOp
|
||||
}
|
||||
}
|
||||
2 => GraphAction::Strengthen(v1, v2, 0.1),
|
||||
3 => GraphAction::Weaken(v1, v2, 0.1),
|
||||
_ => GraphAction::NoOp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply action to graph
|
||||
fn apply_action(&mut self, action: &GraphAction) {
|
||||
match action {
|
||||
GraphAction::AddEdge(u, v, w) => {
|
||||
if !self.graph.has_edge(*u, *v) {
|
||||
let _ = self.graph.insert_edge(*u, *v, *w);
|
||||
}
|
||||
}
|
||||
GraphAction::RemoveEdge(u, v) => {
|
||||
let _ = self.graph.delete_edge(*u, *v);
|
||||
}
|
||||
GraphAction::Strengthen(u, v, delta) => {
|
||||
if let Some(edge) = self.graph.get_edge(*u, *v) {
|
||||
let _ = self.graph.update_edge_weight(*u, *v, edge.weight + delta);
|
||||
}
|
||||
}
|
||||
GraphAction::Weaken(u, v, delta) => {
|
||||
if let Some(edge) = self.graph.get_edge(*u, *v) {
|
||||
let new_weight = (edge.weight - delta).max(0.01);
|
||||
let _ = self.graph.update_edge_weight(*u, *v, new_weight);
|
||||
}
|
||||
}
|
||||
GraphAction::NoOp => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure search efficiency
|
||||
fn measure_search_efficiency(&mut self) -> f64 {
|
||||
// Simplified: based on graph connectivity
|
||||
let n = self.graph.num_vertices() as f64;
|
||||
let m = self.graph.num_edges() as f64;
|
||||
|
||||
if n < 2.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Higher connectivity relative to vertices = better search
|
||||
let efficiency = m / (n * (n - 1.0) / 2.0);
|
||||
|
||||
self.search_latencies.push_back(efficiency);
|
||||
if self.search_latencies.len() > 100 {
|
||||
self.search_latencies.pop_front();
|
||||
}
|
||||
|
||||
efficiency
|
||||
}
|
||||
|
||||
/// Get learned skip regions for subpolynomial search
|
||||
pub fn search_skip_regions(&self) -> Vec<usize> {
|
||||
self.policy_snn.low_activity_regions()
|
||||
}
|
||||
|
||||
/// Search with learned structure
|
||||
pub fn search(&self, query: &[f64], k: usize) -> Vec<VertexId> {
|
||||
// Use skip regions to guide search
|
||||
let skip_regions = self.search_skip_regions();
|
||||
|
||||
// Simple nearest neighbor in graph space
|
||||
let vertices: Vec<_> = self.graph.vertices();
|
||||
|
||||
let mut scores: Vec<(VertexId, f64)> = vertices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !skip_regions.contains(i))
|
||||
.map(|(i, &v)| {
|
||||
// Score based on degree (proxy for centrality)
|
||||
let score = self.graph.degree(v) as f64;
|
||||
(v, score)
|
||||
})
|
||||
.collect();
|
||||
|
||||
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
scores.into_iter().take(k).map(|(v, _)| v).collect()
|
||||
}
|
||||
|
||||
/// Get underlying graph
|
||||
pub fn graph(&self) -> &DynamicGraph {
|
||||
&self.graph
|
||||
}
|
||||
|
||||
/// Get mutable graph
|
||||
pub fn graph_mut(&mut self) -> &mut DynamicGraph {
|
||||
&mut self.graph
|
||||
}
|
||||
|
||||
/// Run multiple optimization steps
|
||||
pub fn optimize(&mut self, steps: usize) -> Vec<OptimizationResult> {
|
||||
(0..steps).map(|_| self.optimize_step()).collect()
|
||||
}
|
||||
|
||||
/// Reset optimizer state
|
||||
pub fn reset(&mut self) {
|
||||
self.policy_snn.reset();
|
||||
self.prev_mincut = estimate_mincut(&self.graph);
|
||||
self.prev_state = extract_features(&self.graph, self.config.input_size);
|
||||
self.time = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract features from graph
|
||||
fn extract_features(graph: &DynamicGraph, num_features: usize) -> Vec<f64> {
|
||||
let n = graph.num_vertices() as f64;
|
||||
let m = graph.num_edges() as f64;
|
||||
|
||||
let mut features = vec![0.0; num_features];
|
||||
|
||||
if num_features > 0 {
|
||||
features[0] = n / 1000.0; // Normalized vertex count
|
||||
}
|
||||
if num_features > 1 {
|
||||
features[1] = m / 5000.0; // Normalized edge count
|
||||
}
|
||||
if num_features > 2 {
|
||||
features[2] = if n > 1.0 {
|
||||
m / (n * (n - 1.0) / 2.0)
|
||||
} else {
|
||||
0.0
|
||||
}; // Density
|
||||
}
|
||||
if num_features > 3 {
|
||||
// Average degree
|
||||
let avg_deg: f64 = graph
|
||||
.vertices()
|
||||
.iter()
|
||||
.map(|&v| graph.degree(v) as f64)
|
||||
.sum::<f64>()
|
||||
/ n.max(1.0);
|
||||
features[3] = avg_deg / 10.0;
|
||||
}
|
||||
if num_features > 4 {
|
||||
features[4] = estimate_mincut(graph) / m.max(1.0); // Normalized mincut
|
||||
}
|
||||
|
||||
// Fill rest with zeros or derived features
|
||||
for i in 5..num_features {
|
||||
features[i] = features[i % 5] * 0.1;
|
||||
}
|
||||
|
||||
features
|
||||
}
|
||||
|
||||
/// Estimate mincut (simplified)
|
||||
fn estimate_mincut(graph: &DynamicGraph) -> f64 {
|
||||
if graph.num_vertices() == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
graph
|
||||
.vertices()
|
||||
.iter()
|
||||
.map(|&v| graph.degree(v) as f64)
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
// Thread-safe PRNG helpers using atomic CAS
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static OPTIMIZER_RNG: AtomicU64 = AtomicU64::new(0xdeadbeef12345678);
|
||||
|
||||
fn rand_small() -> f64 {
|
||||
// Use compare_exchange loop to ensure atomicity
|
||||
let state = loop {
|
||||
let current = OPTIMIZER_RNG.load(Ordering::Relaxed);
|
||||
let next = current.wrapping_mul(0x5851f42d4c957f2d).wrapping_add(1);
|
||||
match OPTIMIZER_RNG.compare_exchange_weak(
|
||||
current,
|
||||
next,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => break next,
|
||||
Err(_) => continue,
|
||||
}
|
||||
};
|
||||
(state as f64) / (u64::MAX as f64) * 0.4 - 0.2
|
||||
}
|
||||
|
||||
fn relu(x: f64) -> f64 {
|
||||
x.max(0.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_value_network() {
|
||||
let mut network = ValueNetwork::new(5, 10);
|
||||
|
||||
let state = vec![0.5, 0.3, 0.8, 0.2, 0.9];
|
||||
let value = network.estimate(&state);
|
||||
|
||||
assert!(value.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_snn() {
|
||||
let config = OptimizerConfig::default();
|
||||
let mut policy = PolicySNN::new(config);
|
||||
|
||||
let state = vec![1.0; 10];
|
||||
policy.inject(&state);
|
||||
|
||||
let spikes = policy.run_until_decision(100);
|
||||
// May or may not spike
|
||||
assert!(spikes.len() >= 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_neural_optimizer() {
|
||||
let graph = DynamicGraph::new();
|
||||
for i in 0..10 {
|
||||
graph.insert_edge(i, (i + 1) % 10, 1.0).unwrap();
|
||||
}
|
||||
|
||||
let config = OptimizerConfig::default();
|
||||
let mut optimizer = NeuralGraphOptimizer::new(graph, config);
|
||||
|
||||
let result = optimizer.optimize_step();
|
||||
|
||||
assert!(result.new_mincut.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optimize_multiple() {
|
||||
let graph = DynamicGraph::new();
|
||||
for i in 0..5 {
|
||||
for j in (i + 1)..5 {
|
||||
graph.insert_edge(i, j, 1.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let config = OptimizerConfig::default();
|
||||
let mut optimizer = NeuralGraphOptimizer::new(graph, config);
|
||||
|
||||
let results = optimizer.optimize(10);
|
||||
assert_eq!(results.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search() {
|
||||
let graph = DynamicGraph::new();
|
||||
for i in 0..20 {
|
||||
graph.insert_edge(i, (i + 1) % 20, 1.0).unwrap();
|
||||
}
|
||||
|
||||
let config = OptimizerConfig::default();
|
||||
let optimizer = NeuralGraphOptimizer::new(graph, config);
|
||||
|
||||
let results = optimizer.search(&[0.5; 10], 5);
|
||||
assert!(results.len() <= 5);
|
||||
}
|
||||
}
|
||||
458
vendor/ruvector/crates/ruvector-mincut/src/snn/strange_loop.rs
vendored
Normal file
458
vendor/ruvector/crates/ruvector-mincut/src/snn/strange_loop.rs
vendored
Normal file
@@ -0,0 +1,458 @@
|
||||
//! # Layer 2: Strange Loop Self-Modification Protocol
|
||||
//!
|
||||
//! Implements recursive self-observation for meta-cognitive graph optimization.
|
||||
//!
|
||||
//! ## Hierarchical Levels
|
||||
//!
|
||||
//! - **Level 0**: Object Graph - computational units and data flow
|
||||
//! - **Level 1**: Meta-Graph - observes Level 0 statistics
|
||||
//! - **Level 2**: Meta-Meta-Graph - observes learning dynamics
|
||||
//!
|
||||
//! The "strange loop" closes when Level 2 actions modify Level 0 structure,
|
||||
//! which changes Level 1 observations, which triggers Level 2 re-evaluation.
|
||||
|
||||
use super::{
|
||||
network::{LayerConfig, NetworkConfig, SpikingNetwork},
|
||||
neuron::{LIFNeuron, NeuronConfig, NeuronPopulation},
|
||||
SimTime, Spike,
|
||||
};
|
||||
use crate::graph::{DynamicGraph, VertexId};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Configuration for strange loop system
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StrangeLoopConfig {
|
||||
/// Number of Level 0 neurons (matches graph vertices)
|
||||
pub level0_size: usize,
|
||||
/// Number of Level 1 observer neurons
|
||||
pub level1_size: usize,
|
||||
/// Number of Level 2 meta-neurons
|
||||
pub level2_size: usize,
|
||||
/// Time step for simulation
|
||||
pub dt: f64,
|
||||
/// Threshold for strengthen action
|
||||
pub strengthen_threshold: f64,
|
||||
/// Threshold for prune action
|
||||
pub prune_threshold: f64,
|
||||
/// Minimum mincut contribution to keep edge
|
||||
pub prune_weight_threshold: f64,
|
||||
/// History window for observations
|
||||
pub observation_window: usize,
|
||||
}
|
||||
|
||||
impl Default for StrangeLoopConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
level0_size: 100,
|
||||
level1_size: 20,
|
||||
level2_size: 5,
|
||||
dt: 1.0,
|
||||
strengthen_threshold: 0.7,
|
||||
prune_threshold: 0.3,
|
||||
prune_weight_threshold: 0.1,
|
||||
observation_window: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Meta-level in the hierarchy
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum MetaLevel {
|
||||
/// Level 0: Object graph being optimized
|
||||
Object,
|
||||
/// Level 1: Observer SNN watching Level 0
|
||||
Observer,
|
||||
/// Level 2: Meta-neuron modulating observer
|
||||
Meta,
|
||||
}
|
||||
|
||||
/// Actions that Level 2 can take to modify Level 0
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MetaAction {
|
||||
/// Strengthen edges where observer activity is high
|
||||
Strengthen(f64),
|
||||
/// Remove edges below mincut contribution threshold
|
||||
Prune(f64),
|
||||
/// Radical reorganization using current mincut as seed
|
||||
Restructure,
|
||||
/// No action needed
|
||||
NoOp,
|
||||
}
|
||||
|
||||
/// Cross-level influence matrix
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CrossLevelInfluence {
|
||||
/// Level 0 → Level 1 influence weights
|
||||
pub l0_to_l1: Vec<Vec<f64>>,
|
||||
/// Level 1 → Level 2 influence weights
|
||||
pub l1_to_l2: Vec<Vec<f64>>,
|
||||
/// Level 2 → Level 0 influence (the strange part)
|
||||
pub l2_to_l0: Vec<Vec<f64>>,
|
||||
}
|
||||
|
||||
/// Meta-neuron for Level 2 decisions
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MetaNeuron {
|
||||
/// ID of this meta-neuron
|
||||
pub id: usize,
|
||||
/// Internal state
|
||||
pub state: f64,
|
||||
/// Decision threshold
|
||||
pub threshold: f64,
|
||||
/// History of observer summaries
|
||||
history: VecDeque<f64>,
|
||||
/// Window size for decisions
|
||||
window: usize,
|
||||
}
|
||||
|
||||
impl MetaNeuron {
|
||||
/// Create a new meta-neuron
|
||||
pub fn new(id: usize, window: usize) -> Self {
|
||||
Self {
|
||||
id,
|
||||
state: 0.0,
|
||||
threshold: 0.5,
|
||||
history: VecDeque::with_capacity(window),
|
||||
window,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process observer summary and produce modulation signal
|
||||
pub fn modulate(&mut self, observer_summary: f64) -> MetaAction {
|
||||
// Update history
|
||||
self.history.push_back(observer_summary);
|
||||
if self.history.len() > self.window {
|
||||
self.history.pop_front();
|
||||
}
|
||||
|
||||
// Compute trend
|
||||
let mean: f64 = self.history.iter().sum::<f64>() / self.history.len() as f64;
|
||||
let recent_mean: f64 = self.history.iter().rev().take(10).sum::<f64>()
|
||||
/ 10.0f64.min(self.history.len() as f64);
|
||||
|
||||
self.state = recent_mean - mean;
|
||||
|
||||
// Decide action based on state
|
||||
if self.state > self.threshold {
|
||||
MetaAction::Strengthen(observer_summary)
|
||||
} else if self.state < -self.threshold {
|
||||
MetaAction::Prune(observer_summary.abs())
|
||||
} else if observer_summary.abs() > 2.0 * self.threshold {
|
||||
MetaAction::Restructure
|
||||
} else {
|
||||
MetaAction::NoOp
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset meta-neuron state
|
||||
pub fn reset(&mut self) {
|
||||
self.state = 0.0;
|
||||
self.history.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Meta-Cognitive MinCut with Strange Loop
|
||||
pub struct MetaCognitiveMinCut {
|
||||
/// Level 0: Object graph being optimized
|
||||
object_graph: DynamicGraph,
|
||||
/// Level 1: SNN observing object graph statistics
|
||||
observer_snn: SpikingNetwork,
|
||||
/// Level 2: Meta-neurons modulating observer behavior
|
||||
meta_neurons: Vec<MetaNeuron>,
|
||||
/// Cross-level influence matrix
|
||||
influence: CrossLevelInfluence,
|
||||
/// Configuration
|
||||
config: StrangeLoopConfig,
|
||||
/// Current simulation time
|
||||
time: SimTime,
|
||||
/// History of mincut values
|
||||
mincut_history: VecDeque<f64>,
|
||||
/// History of actions taken
|
||||
action_history: Vec<MetaAction>,
|
||||
}
|
||||
|
||||
impl MetaCognitiveMinCut {
|
||||
/// Create a new meta-cognitive mincut system
|
||||
pub fn new(graph: DynamicGraph, config: StrangeLoopConfig) -> Self {
|
||||
let n = graph.num_vertices();
|
||||
|
||||
// Level 1: Observer SNN
|
||||
let observer_config = NetworkConfig {
|
||||
layers: vec![LayerConfig::new(config.level1_size)],
|
||||
..NetworkConfig::default()
|
||||
};
|
||||
let observer_snn = SpikingNetwork::new(observer_config);
|
||||
|
||||
// Level 2: Meta-neurons
|
||||
let meta_neurons: Vec<_> = (0..config.level2_size)
|
||||
.map(|i| MetaNeuron::new(i, config.observation_window))
|
||||
.collect();
|
||||
|
||||
// Initialize cross-level influence
|
||||
let influence = CrossLevelInfluence {
|
||||
l0_to_l1: vec![vec![0.1; config.level1_size]; n],
|
||||
l1_to_l2: vec![vec![0.1; config.level2_size]; config.level1_size],
|
||||
l2_to_l0: vec![vec![0.1; n]; config.level2_size],
|
||||
};
|
||||
|
||||
let observation_window = config.observation_window;
|
||||
|
||||
Self {
|
||||
object_graph: graph,
|
||||
observer_snn,
|
||||
meta_neurons,
|
||||
influence,
|
||||
config,
|
||||
time: 0.0,
|
||||
mincut_history: VecDeque::with_capacity(observation_window),
|
||||
action_history: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode graph state as spike pattern for Level 1
|
||||
fn encode_graph_state(&self) -> Vec<f64> {
|
||||
let vertices = self.object_graph.vertices();
|
||||
let mut encoding = vec![0.0; self.config.level1_size];
|
||||
|
||||
for (i, v) in vertices.iter().enumerate() {
|
||||
let degree = self.object_graph.degree(*v) as f64;
|
||||
let weight_sum: f64 = self
|
||||
.object_graph
|
||||
.neighbors(*v)
|
||||
.iter()
|
||||
.filter_map(|(_, _)| Some(1.0))
|
||||
.sum();
|
||||
|
||||
// Project to observer neurons
|
||||
for j in 0..encoding.len() {
|
||||
if i < self.influence.l0_to_l1.len() && j < self.influence.l0_to_l1[i].len() {
|
||||
encoding[j] += self.influence.l0_to_l1[i][j] * (degree + weight_sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
encoding
|
||||
}
|
||||
|
||||
/// Get population rate as observer summary
|
||||
fn observer_summary(&self) -> f64 {
|
||||
self.observer_snn.layer_rate(0, 100.0)
|
||||
}
|
||||
|
||||
/// Find high-correlation pairs in observer SNN
|
||||
fn high_correlation_pairs(&self, threshold: f64) -> Vec<(VertexId, VertexId)> {
|
||||
let sync_matrix = self.observer_snn.synchrony_matrix();
|
||||
let vertices = self.object_graph.vertices();
|
||||
let mut pairs = Vec::new();
|
||||
|
||||
for i in 0..sync_matrix.len().min(vertices.len()) {
|
||||
for j in (i + 1)..sync_matrix[i].len().min(vertices.len()) {
|
||||
if sync_matrix[i][j] > threshold {
|
||||
pairs.push((vertices[i], vertices[j]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Compute mincut contribution for each edge (simplified)
|
||||
fn mincut_contribution(&self, edge: &crate::graph::Edge) -> f64 {
|
||||
// Simplified: degree-based contribution
|
||||
let src_degree = self.object_graph.degree(edge.source) as f64;
|
||||
let tgt_degree = self.object_graph.degree(edge.target) as f64;
|
||||
|
||||
edge.weight / (src_degree + tgt_degree).max(1.0)
|
||||
}
|
||||
|
||||
/// Rebuild graph from partition (simplified)
|
||||
fn rebuild_from_partition(&mut self, vertices: &[VertexId]) {
|
||||
// Keep only edges within the partition
|
||||
let vertex_set: std::collections::HashSet<_> = vertices.iter().collect();
|
||||
|
||||
let edges_to_remove: Vec<_> = self
|
||||
.object_graph
|
||||
.edges()
|
||||
.iter()
|
||||
.filter(|e| !vertex_set.contains(&e.source) || !vertex_set.contains(&e.target))
|
||||
.map(|e| (e.source, e.target))
|
||||
.collect();
|
||||
|
||||
for (u, v) in edges_to_remove {
|
||||
let _ = self.object_graph.delete_edge(u, v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute one strange loop iteration
|
||||
pub fn strange_loop_step(&mut self) -> MetaAction {
|
||||
// Level 0 → Level 1: Encode graph state as spike patterns
|
||||
let graph_state = self.encode_graph_state();
|
||||
self.observer_snn.inject_current(&graph_state);
|
||||
|
||||
// Level 1 dynamics: Observer SNN processes graph state
|
||||
let _observer_spikes = self.observer_snn.step();
|
||||
|
||||
// Level 1 → Level 2: Meta-neuron receives observer output
|
||||
let observer_summary = self.observer_summary();
|
||||
|
||||
// Level 2 decision: Aggregate meta-neuron decisions
|
||||
let mut actions = Vec::new();
|
||||
for meta_neuron in &mut self.meta_neurons {
|
||||
actions.push(meta_neuron.modulate(observer_summary));
|
||||
}
|
||||
|
||||
// Select dominant action (simplified: first non-NoOp)
|
||||
let action = actions
|
||||
.into_iter()
|
||||
.find(|a| !matches!(a, MetaAction::NoOp))
|
||||
.unwrap_or(MetaAction::NoOp);
|
||||
|
||||
// Level 2 → Level 0: Close the strange loop
|
||||
match &action {
|
||||
MetaAction::Strengthen(threshold) => {
|
||||
// Add edges where observer activity is high
|
||||
let hot_pairs = self.high_correlation_pairs(*threshold);
|
||||
for (u, v) in hot_pairs {
|
||||
if !self.object_graph.has_edge(u, v) {
|
||||
let _ = self.object_graph.insert_edge(u, v, 1.0);
|
||||
} else {
|
||||
// Strengthen existing edge
|
||||
if let Some(edge) = self.object_graph.get_edge(u, v) {
|
||||
let _ = self
|
||||
.object_graph
|
||||
.update_edge_weight(u, v, edge.weight * 1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MetaAction::Prune(threshold) => {
|
||||
// Remove edges below mincut contribution threshold
|
||||
let weak_edges: Vec<_> = self
|
||||
.object_graph
|
||||
.edges()
|
||||
.iter()
|
||||
.filter(|e| self.mincut_contribution(e) < *threshold)
|
||||
.map(|e| (e.source, e.target))
|
||||
.collect();
|
||||
|
||||
for (u, v) in weak_edges {
|
||||
let _ = self.object_graph.delete_edge(u, v);
|
||||
}
|
||||
}
|
||||
MetaAction::Restructure => {
|
||||
// Use largest connected component
|
||||
let components = self.object_graph.connected_components();
|
||||
if let Some(largest) = components.iter().max_by_key(|c| c.len()) {
|
||||
if largest.len() < self.object_graph.num_vertices() {
|
||||
self.rebuild_from_partition(largest);
|
||||
}
|
||||
}
|
||||
}
|
||||
MetaAction::NoOp => {}
|
||||
}
|
||||
|
||||
self.time += self.config.dt;
|
||||
self.action_history.push(action.clone());
|
||||
|
||||
action
|
||||
}
|
||||
|
||||
/// Get object graph
|
||||
pub fn graph(&self) -> &DynamicGraph {
|
||||
&self.object_graph
|
||||
}
|
||||
|
||||
/// Get mutable object graph
|
||||
pub fn graph_mut(&mut self) -> &mut DynamicGraph {
|
||||
&mut self.object_graph
|
||||
}
|
||||
|
||||
/// Get observer SNN
|
||||
pub fn observer(&self) -> &SpikingNetwork {
|
||||
&self.observer_snn
|
||||
}
|
||||
|
||||
/// Get action history
|
||||
pub fn action_history(&self) -> &[MetaAction] {
|
||||
&self.action_history
|
||||
}
|
||||
|
||||
/// Get meta-level state summary
|
||||
pub fn level_summary(&self) -> (f64, f64, f64) {
|
||||
let l0 = self.object_graph.num_edges() as f64;
|
||||
let l1 = self.observer_summary();
|
||||
let l2 =
|
||||
self.meta_neurons.iter().map(|m| m.state).sum::<f64>() / self.meta_neurons.len() as f64;
|
||||
|
||||
(l0, l1, l2)
|
||||
}
|
||||
|
||||
/// Reset the system
|
||||
pub fn reset(&mut self) {
|
||||
self.observer_snn.reset();
|
||||
for meta in &mut self.meta_neurons {
|
||||
meta.reset();
|
||||
}
|
||||
self.time = 0.0;
|
||||
self.mincut_history.clear();
|
||||
self.action_history.clear();
|
||||
}
|
||||
|
||||
/// Run multiple strange loop iterations
|
||||
pub fn run(&mut self, steps: usize) -> Vec<MetaAction> {
|
||||
let mut actions = Vec::new();
|
||||
for _ in 0..steps {
|
||||
actions.push(self.strange_loop_step());
|
||||
}
|
||||
actions
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_meta_neuron() {
|
||||
let mut neuron = MetaNeuron::new(0, 10);
|
||||
|
||||
// Feed increasing summaries
|
||||
for i in 0..15 {
|
||||
let _ = neuron.modulate(0.1 * i as f64);
|
||||
}
|
||||
|
||||
// Should have accumulated state
|
||||
assert!(neuron.history.len() == 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strange_loop_creation() {
|
||||
let graph = DynamicGraph::new();
|
||||
for i in 0..10 {
|
||||
graph.insert_edge(i, (i + 1) % 10, 1.0).unwrap();
|
||||
}
|
||||
|
||||
let config = StrangeLoopConfig::default();
|
||||
let system = MetaCognitiveMinCut::new(graph, config);
|
||||
|
||||
let (l0, l1, l2) = system.level_summary();
|
||||
assert!(l0 > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strange_loop_step() {
|
||||
let graph = DynamicGraph::new();
|
||||
for i in 0..10 {
|
||||
for j in (i + 1)..10 {
|
||||
graph.insert_edge(i, j, 1.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let config = StrangeLoopConfig::default();
|
||||
let mut system = MetaCognitiveMinCut::new(graph, config);
|
||||
|
||||
// Run a few steps
|
||||
let actions = system.run(5);
|
||||
assert_eq!(actions.len(), 5);
|
||||
}
|
||||
}
|
||||
581
vendor/ruvector/crates/ruvector-mincut/src/snn/synapse.rs
vendored
Normal file
581
vendor/ruvector/crates/ruvector-mincut/src/snn/synapse.rs
vendored
Normal file
@@ -0,0 +1,581 @@
|
||||
//! # Synaptic Connections with STDP Learning
|
||||
//!
|
||||
//! Implements spike-timing dependent plasticity (STDP) for synaptic weight updates.
|
||||
//!
|
||||
//! ## STDP Learning Rule
|
||||
//!
|
||||
//! ```text
|
||||
//! ΔW = A+ * exp(-Δt/τ+) if Δt > 0 (pre before post → LTP)
|
||||
//! ΔW = A- * exp(Δt/τ-) if Δt < 0 (post before pre → LTD)
|
||||
//! ```
|
||||
//!
|
||||
//! Where Δt = t_post - t_pre
|
||||
//!
|
||||
//! ## Integration with MinCut
|
||||
//!
|
||||
//! Synaptic weights directly map to graph edge weights:
|
||||
//! - Strong synapse → strong edge → less likely in mincut
|
||||
//! - STDP learning → edge weight evolution → dynamic mincut
|
||||
|
||||
use super::{SimTime, Spike};
|
||||
use crate::graph::{DynamicGraph, VertexId, Weight};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Configuration for STDP learning
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct STDPConfig {
|
||||
/// LTP amplitude (potentiation)
|
||||
pub a_plus: f64,
|
||||
/// LTD amplitude (depression)
|
||||
pub a_minus: f64,
|
||||
/// LTP time constant (ms)
|
||||
pub tau_plus: f64,
|
||||
/// LTD time constant (ms)
|
||||
pub tau_minus: f64,
|
||||
/// Minimum weight
|
||||
pub w_min: f64,
|
||||
/// Maximum weight
|
||||
pub w_max: f64,
|
||||
/// Learning rate
|
||||
pub learning_rate: f64,
|
||||
/// Eligibility trace time constant
|
||||
pub tau_eligibility: f64,
|
||||
}
|
||||
|
||||
impl Default for STDPConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
a_plus: 0.01,
|
||||
a_minus: 0.012,
|
||||
tau_plus: 20.0,
|
||||
tau_minus: 20.0,
|
||||
w_min: 0.0,
|
||||
w_max: 1.0,
|
||||
learning_rate: 1.0,
|
||||
tau_eligibility: 1000.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single synapse between two neurons
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Synapse {
|
||||
/// Pre-synaptic neuron ID
|
||||
pub pre: usize,
|
||||
/// Post-synaptic neuron ID
|
||||
pub post: usize,
|
||||
/// Synaptic weight
|
||||
pub weight: f64,
|
||||
/// Transmission delay (ms)
|
||||
pub delay: f64,
|
||||
/// Eligibility trace for reward-modulated STDP
|
||||
pub eligibility: f64,
|
||||
/// Last update time
|
||||
pub last_update: SimTime,
|
||||
}
|
||||
|
||||
impl Synapse {
|
||||
/// Create a new synapse
|
||||
pub fn new(pre: usize, post: usize, weight: f64) -> Self {
|
||||
Self {
|
||||
pre,
|
||||
post,
|
||||
weight,
|
||||
delay: 1.0,
|
||||
eligibility: 0.0,
|
||||
last_update: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create synapse with delay
|
||||
pub fn with_delay(pre: usize, post: usize, weight: f64, delay: f64) -> Self {
|
||||
Self {
|
||||
pre,
|
||||
post,
|
||||
weight,
|
||||
delay,
|
||||
eligibility: 0.0,
|
||||
last_update: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute STDP weight change
|
||||
pub fn stdp_update(&mut self, t_pre: SimTime, t_post: SimTime, config: &STDPConfig) -> f64 {
|
||||
let dt = t_post - t_pre;
|
||||
|
||||
let dw = if dt > 0.0 {
|
||||
// Pre before post → LTP
|
||||
config.a_plus * (-dt / config.tau_plus).exp()
|
||||
} else {
|
||||
// Post before pre → LTD
|
||||
-config.a_minus * (dt / config.tau_minus).exp()
|
||||
};
|
||||
|
||||
// Apply learning rate and clip
|
||||
let delta = config.learning_rate * dw;
|
||||
self.weight = (self.weight + delta).clamp(config.w_min, config.w_max);
|
||||
|
||||
// Update eligibility trace
|
||||
self.eligibility += dw;
|
||||
|
||||
delta
|
||||
}
|
||||
|
||||
/// Decay eligibility trace
|
||||
pub fn decay_eligibility(&mut self, dt: f64, tau: f64) {
|
||||
self.eligibility *= (-dt / tau).exp();
|
||||
}
|
||||
|
||||
/// Apply reward-modulated update (R-STDP)
|
||||
pub fn reward_modulated_update(&mut self, reward: f64, config: &STDPConfig) {
|
||||
let delta = reward * self.eligibility * config.learning_rate;
|
||||
self.weight = (self.weight + delta).clamp(config.w_min, config.w_max);
|
||||
// Reset eligibility after reward
|
||||
self.eligibility *= 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/// Matrix of synaptic connections
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SynapseMatrix {
|
||||
/// Number of pre-synaptic neurons
|
||||
pub n_pre: usize,
|
||||
/// Number of post-synaptic neurons
|
||||
pub n_post: usize,
|
||||
/// Synapses indexed by (pre, post)
|
||||
synapses: HashMap<(usize, usize), Synapse>,
|
||||
/// STDP configuration
|
||||
pub config: STDPConfig,
|
||||
/// Track last spike times for pre-synaptic neurons
|
||||
pre_spike_times: Vec<SimTime>,
|
||||
/// Track last spike times for post-synaptic neurons
|
||||
post_spike_times: Vec<SimTime>,
|
||||
}
|
||||
|
||||
impl SynapseMatrix {
|
||||
/// Create a new synapse matrix
|
||||
pub fn new(n_pre: usize, n_post: usize) -> Self {
|
||||
Self {
|
||||
n_pre,
|
||||
n_post,
|
||||
synapses: HashMap::new(),
|
||||
config: STDPConfig::default(),
|
||||
pre_spike_times: vec![f64::NEG_INFINITY; n_pre],
|
||||
post_spike_times: vec![f64::NEG_INFINITY; n_post],
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom STDP config
|
||||
pub fn with_config(n_pre: usize, n_post: usize, config: STDPConfig) -> Self {
|
||||
Self {
|
||||
n_pre,
|
||||
n_post,
|
||||
synapses: HashMap::new(),
|
||||
config,
|
||||
pre_spike_times: vec![f64::NEG_INFINITY; n_pre],
|
||||
post_spike_times: vec![f64::NEG_INFINITY; n_post],
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a synapse
|
||||
pub fn add_synapse(&mut self, pre: usize, post: usize, weight: f64) {
|
||||
if pre < self.n_pre && post < self.n_post {
|
||||
self.synapses
|
||||
.insert((pre, post), Synapse::new(pre, post, weight));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get synapse if it exists
|
||||
pub fn get_synapse(&self, pre: usize, post: usize) -> Option<&Synapse> {
|
||||
self.synapses.get(&(pre, post))
|
||||
}
|
||||
|
||||
/// Get mutable synapse if it exists
|
||||
pub fn get_synapse_mut(&mut self, pre: usize, post: usize) -> Option<&mut Synapse> {
|
||||
self.synapses.get_mut(&(pre, post))
|
||||
}
|
||||
|
||||
/// Get weight of a synapse (0 if doesn't exist)
|
||||
pub fn weight(&self, pre: usize, post: usize) -> f64 {
|
||||
self.get_synapse(pre, post).map(|s| s.weight).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Compute weighted sum for all post-synaptic neurons given pre-synaptic activations
|
||||
///
|
||||
/// This is optimized to iterate only over existing synapses, avoiding O(n²) lookups.
|
||||
/// pre_activations[i] is the activation of pre-synaptic neuron i.
|
||||
/// Returns vector of weighted sums for each post-synaptic neuron.
|
||||
#[inline]
|
||||
pub fn compute_weighted_sums(&self, pre_activations: &[f64]) -> Vec<f64> {
|
||||
let mut sums = vec![0.0; self.n_post];
|
||||
|
||||
// Iterate only over existing synapses (sparse operation)
|
||||
for (&(pre, post), synapse) in &self.synapses {
|
||||
if pre < pre_activations.len() {
|
||||
sums[post] += synapse.weight * pre_activations[pre];
|
||||
}
|
||||
}
|
||||
|
||||
sums
|
||||
}
|
||||
|
||||
/// Compute weighted sum for a single post-synaptic neuron
|
||||
#[inline]
|
||||
pub fn weighted_sum_for_post(&self, post: usize, pre_activations: &[f64]) -> f64 {
|
||||
let mut sum = 0.0;
|
||||
for pre in 0..self.n_pre.min(pre_activations.len()) {
|
||||
if let Some(synapse) = self.synapses.get(&(pre, post)) {
|
||||
sum += synapse.weight * pre_activations[pre];
|
||||
}
|
||||
}
|
||||
sum
|
||||
}
|
||||
|
||||
/// Set weight of a synapse (creates if doesn't exist)
|
||||
pub fn set_weight(&mut self, pre: usize, post: usize, weight: f64) {
|
||||
if let Some(synapse) = self.get_synapse_mut(pre, post) {
|
||||
synapse.weight = weight;
|
||||
} else {
|
||||
self.add_synapse(pre, post, weight);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a pre-synaptic spike and perform STDP updates
|
||||
pub fn on_pre_spike(&mut self, pre: usize, time: SimTime) {
|
||||
if pre >= self.n_pre {
|
||||
return;
|
||||
}
|
||||
|
||||
self.pre_spike_times[pre] = time;
|
||||
|
||||
// LTD: pre spike after recent post spikes
|
||||
for post in 0..self.n_post {
|
||||
if let Some(synapse) = self.synapses.get_mut(&(pre, post)) {
|
||||
let t_post = self.post_spike_times[post];
|
||||
if t_post > f64::NEG_INFINITY {
|
||||
synapse.stdp_update(time, t_post, &self.config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a post-synaptic spike and perform STDP updates
|
||||
pub fn on_post_spike(&mut self, post: usize, time: SimTime) {
|
||||
if post >= self.n_post {
|
||||
return;
|
||||
}
|
||||
|
||||
self.post_spike_times[post] = time;
|
||||
|
||||
// LTP: post spike after recent pre spikes
|
||||
for pre in 0..self.n_pre {
|
||||
if let Some(synapse) = self.synapses.get_mut(&(pre, post)) {
|
||||
let t_pre = self.pre_spike_times[pre];
|
||||
if t_pre > f64::NEG_INFINITY {
|
||||
synapse.stdp_update(t_pre, time, &self.config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process multiple spikes with STDP
|
||||
pub fn process_spikes(&mut self, spikes: &[Spike]) {
|
||||
for spike in spikes {
|
||||
// Assume neuron IDs map directly
|
||||
// Pre-synaptic: lower half, Post-synaptic: upper half (example mapping)
|
||||
if spike.neuron_id < self.n_pre {
|
||||
self.on_pre_spike(spike.neuron_id, spike.time);
|
||||
}
|
||||
if spike.neuron_id < self.n_post {
|
||||
self.on_post_spike(spike.neuron_id, spike.time);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decay all eligibility traces
|
||||
pub fn decay_eligibility(&mut self, dt: f64) {
|
||||
for synapse in self.synapses.values_mut() {
|
||||
synapse.decay_eligibility(dt, self.config.tau_eligibility);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply reward-modulated learning to all synapses
|
||||
pub fn apply_reward(&mut self, reward: f64) {
|
||||
for synapse in self.synapses.values_mut() {
|
||||
synapse.reward_modulated_update(reward, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all synapses as an iterator
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&(usize, usize), &Synapse)> {
|
||||
self.synapses.iter()
|
||||
}
|
||||
|
||||
/// Get number of synapses
|
||||
pub fn num_synapses(&self) -> usize {
|
||||
self.synapses.len()
|
||||
}
|
||||
|
||||
/// Compute total synaptic input to a post-synaptic neuron
|
||||
pub fn input_to(&self, post: usize, pre_activities: &[f64]) -> f64 {
|
||||
let mut total = 0.0;
|
||||
for pre in 0..self.n_pre.min(pre_activities.len()) {
|
||||
total += self.weight(pre, post) * pre_activities[pre];
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Create dense weight matrix
|
||||
pub fn to_dense(&self) -> Vec<Vec<f64>> {
|
||||
let mut matrix = vec![vec![0.0; self.n_post]; self.n_pre];
|
||||
for ((pre, post), synapse) in &self.synapses {
|
||||
matrix[*pre][*post] = synapse.weight;
|
||||
}
|
||||
matrix
|
||||
}
|
||||
|
||||
/// Initialize from dense matrix
|
||||
pub fn from_dense(matrix: &[Vec<f64>]) -> Self {
|
||||
let n_pre = matrix.len();
|
||||
let n_post = matrix.first().map(|r| r.len()).unwrap_or(0);
|
||||
|
||||
let mut sm = Self::new(n_pre, n_post);
|
||||
|
||||
for (pre, row) in matrix.iter().enumerate() {
|
||||
for (post, &weight) in row.iter().enumerate() {
|
||||
if weight != 0.0 {
|
||||
sm.add_synapse(pre, post, weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sm
|
||||
}
|
||||
|
||||
/// Synchronize weights with a DynamicGraph
|
||||
/// Maps neurons to vertices via a mapping function
|
||||
pub fn sync_to_graph<F>(&self, graph: &mut DynamicGraph, neuron_to_vertex: F)
|
||||
where
|
||||
F: Fn(usize) -> VertexId,
|
||||
{
|
||||
for ((pre, post), synapse) in &self.synapses {
|
||||
let u = neuron_to_vertex(*pre);
|
||||
let v = neuron_to_vertex(*post);
|
||||
|
||||
if graph.has_edge(u, v) {
|
||||
let _ = graph.update_edge_weight(u, v, synapse.weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load weights from a DynamicGraph
|
||||
pub fn sync_from_graph<F>(&mut self, graph: &DynamicGraph, vertex_to_neuron: F)
|
||||
where
|
||||
F: Fn(VertexId) -> usize,
|
||||
{
|
||||
for edge in graph.edges() {
|
||||
let pre = vertex_to_neuron(edge.source);
|
||||
let post = vertex_to_neuron(edge.target);
|
||||
|
||||
if pre < self.n_pre && post < self.n_post {
|
||||
self.set_weight(pre, post, edge.weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get high-correlation pairs (synapses with weight above threshold)
|
||||
pub fn high_correlation_pairs(&self, threshold: f64) -> Vec<(usize, usize)> {
|
||||
self.synapses
|
||||
.iter()
|
||||
.filter(|(_, s)| s.weight >= threshold)
|
||||
.map(|((pre, post), _)| (*pre, *post))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Asymmetric STDP for causal relationship encoding
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AsymmetricSTDP {
|
||||
/// Forward (causal) time constant
|
||||
pub tau_forward: f64,
|
||||
/// Backward time constant
|
||||
pub tau_backward: f64,
|
||||
/// Forward amplitude (typically larger for causality)
|
||||
pub a_forward: f64,
|
||||
/// Backward amplitude
|
||||
pub a_backward: f64,
|
||||
}
|
||||
|
||||
impl Default for AsymmetricSTDP {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tau_forward: 15.0,
|
||||
tau_backward: 30.0, // Longer backward window
|
||||
a_forward: 0.015, // Stronger forward (causal)
|
||||
a_backward: 0.008, // Weaker backward
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsymmetricSTDP {
|
||||
/// Compute weight change for causal relationship encoding
|
||||
/// Positive Δt (pre→post) is weighted more heavily
|
||||
pub fn compute_dw(&self, dt: f64) -> f64 {
|
||||
if dt > 0.0 {
|
||||
// Pre before post → causal relationship
|
||||
self.a_forward * (-dt / self.tau_forward).exp()
|
||||
} else {
|
||||
// Post before pre → anti-causal
|
||||
-self.a_backward * (dt / self.tau_backward).exp()
|
||||
}
|
||||
}
|
||||
|
||||
/// Update weight matrix for causal discovery
|
||||
pub fn update_weights(&self, matrix: &mut SynapseMatrix, neuron_id: usize, time: SimTime) {
|
||||
let w_min = matrix.config.w_min;
|
||||
let w_max = matrix.config.w_max;
|
||||
let n_pre = matrix.n_pre;
|
||||
let n_post = matrix.n_post;
|
||||
|
||||
// Collect pre-spike times first to avoid borrow conflicts
|
||||
let pre_times: Vec<_> = (0..n_pre)
|
||||
.map(|pre| {
|
||||
matrix
|
||||
.pre_spike_times
|
||||
.get(pre)
|
||||
.copied()
|
||||
.unwrap_or(f64::NEG_INFINITY)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// This neuron just spiked - update all synapses involving it (incoming)
|
||||
for pre in 0..n_pre {
|
||||
let t_pre = pre_times[pre];
|
||||
if t_pre > f64::NEG_INFINITY {
|
||||
let dt = time - t_pre;
|
||||
let dw = self.compute_dw(dt);
|
||||
if let Some(synapse) = matrix.get_synapse_mut(pre, neuron_id) {
|
||||
synapse.weight = (synapse.weight + dw).clamp(w_min, w_max);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect post-spike times
|
||||
let post_times: Vec<_> = (0..n_post)
|
||||
.map(|post| {
|
||||
matrix
|
||||
.post_spike_times
|
||||
.get(post)
|
||||
.copied()
|
||||
.unwrap_or(f64::NEG_INFINITY)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for post in 0..n_post {
|
||||
let t_post = post_times[post];
|
||||
if t_post > f64::NEG_INFINITY {
|
||||
let dt = t_post - time; // Reversed for outgoing
|
||||
let dw = self.compute_dw(dt);
|
||||
if let Some(synapse) = matrix.get_synapse_mut(neuron_id, post) {
|
||||
synapse.weight = (synapse.weight + dw).clamp(w_min, w_max);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_synapse_creation() {
|
||||
let synapse = Synapse::new(0, 1, 0.5);
|
||||
assert_eq!(synapse.pre, 0);
|
||||
assert_eq!(synapse.post, 1);
|
||||
assert_eq!(synapse.weight, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stdp_ltp() {
|
||||
let mut synapse = Synapse::new(0, 1, 0.5);
|
||||
let config = STDPConfig::default();
|
||||
|
||||
// Pre before post → LTP
|
||||
let dw = synapse.stdp_update(10.0, 15.0, &config);
|
||||
assert!(dw > 0.0);
|
||||
assert!(synapse.weight > 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stdp_ltd() {
|
||||
let mut synapse = Synapse::new(0, 1, 0.5);
|
||||
let config = STDPConfig::default();
|
||||
|
||||
// Post before pre → LTD
|
||||
let dw = synapse.stdp_update(15.0, 10.0, &config);
|
||||
assert!(dw < 0.0);
|
||||
assert!(synapse.weight < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_synapse_matrix() {
|
||||
let mut matrix = SynapseMatrix::new(10, 10);
|
||||
matrix.add_synapse(0, 1, 0.5);
|
||||
matrix.add_synapse(1, 2, 0.3);
|
||||
|
||||
assert_eq!(matrix.num_synapses(), 2);
|
||||
assert!((matrix.weight(0, 1) - 0.5).abs() < 0.001);
|
||||
assert!((matrix.weight(1, 2) - 0.3).abs() < 0.001);
|
||||
assert_eq!(matrix.weight(2, 3), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spike_processing() {
|
||||
let mut matrix = SynapseMatrix::new(5, 5);
|
||||
|
||||
// Fully connected
|
||||
for i in 0..5 {
|
||||
for j in 0..5 {
|
||||
if i != j {
|
||||
matrix.add_synapse(i, j, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pre spike then post spike → LTP
|
||||
matrix.on_pre_spike(0, 10.0);
|
||||
matrix.on_post_spike(1, 15.0);
|
||||
|
||||
// Should have strengthened 0→1 connection
|
||||
assert!(matrix.weight(0, 1) > 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asymmetric_stdp() {
|
||||
let stdp = AsymmetricSTDP::default();
|
||||
|
||||
// Causal (dt > 0) should have larger effect
|
||||
let dw_causal = stdp.compute_dw(5.0);
|
||||
let dw_anticausal = stdp.compute_dw(-5.0);
|
||||
|
||||
assert!(dw_causal > 0.0);
|
||||
assert!(dw_anticausal < 0.0);
|
||||
assert!(dw_causal.abs() > dw_anticausal.abs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dense_conversion() {
|
||||
let mut matrix = SynapseMatrix::new(3, 3);
|
||||
matrix.add_synapse(0, 1, 0.5);
|
||||
matrix.add_synapse(1, 2, 0.7);
|
||||
|
||||
let dense = matrix.to_dense();
|
||||
assert_eq!(dense.len(), 3);
|
||||
assert!((dense[0][1] - 0.5).abs() < 0.001);
|
||||
assert!((dense[1][2] - 0.7).abs() < 0.001);
|
||||
|
||||
let recovered = SynapseMatrix::from_dense(&dense);
|
||||
assert_eq!(recovered.num_synapses(), 2);
|
||||
}
|
||||
}
|
||||
521
vendor/ruvector/crates/ruvector-mincut/src/snn/time_crystal.rs
vendored
Normal file
521
vendor/ruvector/crates/ruvector-mincut/src/snn/time_crystal.rs
vendored
Normal file
@@ -0,0 +1,521 @@
|
||||
//! # Layer 4: Time Crystal as Central Pattern Generator
|
||||
//!
|
||||
//! Implements discrete time-translation symmetry breaking for coordination patterns.
|
||||
//!
|
||||
//! ## Theory
|
||||
//!
|
||||
//! Time crystals exhibit periodic self-sustaining patterns. The SNN equivalent is a
|
||||
//! Central Pattern Generator (CPG) - coupled oscillators that produce rhythmic output.
|
||||
//!
|
||||
//! ## Application
|
||||
//!
|
||||
//! Different phases correspond to different graph topologies. Phase transitions
|
||||
//! trigger topology changes, and MinCut verification ensures stability within each phase.
|
||||
|
||||
use super::{
|
||||
network::{LayerConfig, NetworkConfig, SpikingNetwork},
|
||||
neuron::{LIFNeuron, NeuronConfig},
|
||||
SimTime, Spike, Vector,
|
||||
};
|
||||
use crate::graph::{DynamicGraph, VertexId};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Configuration for Time Crystal CPG
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CPGConfig {
|
||||
/// Number of phases in the crystal
|
||||
pub num_phases: usize,
|
||||
/// Oscillation frequency (Hz)
|
||||
pub frequency: f64,
|
||||
/// Coupling strength between oscillators
|
||||
pub coupling: f64,
|
||||
/// Stability threshold for mincut verification
|
||||
pub stability_threshold: f64,
|
||||
/// Time step for simulation
|
||||
pub dt: f64,
|
||||
/// Phase transition threshold
|
||||
pub transition_threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for CPGConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
num_phases: 4,
|
||||
frequency: 10.0, // 10 Hz default
|
||||
coupling: 0.3,
|
||||
stability_threshold: 0.1,
|
||||
dt: 1.0,
|
||||
transition_threshold: 0.8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An oscillator neuron for CPG
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OscillatorNeuron {
|
||||
/// ID of this oscillator
|
||||
pub id: usize,
|
||||
/// Current phase (0 to 2π)
|
||||
pub phase: f64,
|
||||
/// Natural frequency (rad/ms)
|
||||
pub omega: f64,
|
||||
/// Amplitude
|
||||
pub amplitude: f64,
|
||||
/// Activity level (derived from phase)
|
||||
activity: f64,
|
||||
}
|
||||
|
||||
impl OscillatorNeuron {
|
||||
/// Create a new oscillator
|
||||
pub fn new(id: usize, frequency_hz: f64, phase_offset: f64) -> Self {
|
||||
let omega = 2.0 * PI * frequency_hz / 1000.0; // Convert to rad/ms
|
||||
|
||||
Self {
|
||||
id,
|
||||
phase: phase_offset,
|
||||
omega,
|
||||
amplitude: 1.0,
|
||||
activity: (phase_offset).cos(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Integrate oscillator dynamics
|
||||
pub fn integrate(&mut self, dt: f64, coupling_input: f64) {
|
||||
// Kuramoto-like dynamics: dφ/dt = ω + K*sin(φ_coupled - φ)
|
||||
let d_phase = self.omega + coupling_input;
|
||||
self.phase += d_phase * dt;
|
||||
|
||||
// Keep phase in [0, 2π]
|
||||
while self.phase >= 2.0 * PI {
|
||||
self.phase -= 2.0 * PI;
|
||||
}
|
||||
while self.phase < 0.0 {
|
||||
self.phase += 2.0 * PI;
|
||||
}
|
||||
|
||||
// Update activity
|
||||
self.activity = self.amplitude * self.phase.cos();
|
||||
}
|
||||
|
||||
/// Get current activity
|
||||
pub fn activity(&self) -> f64 {
|
||||
self.activity
|
||||
}
|
||||
|
||||
/// Reset oscillator
|
||||
pub fn reset(&mut self, phase: f64) {
|
||||
self.phase = phase;
|
||||
self.activity = (phase).cos();
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph topology for a specific phase
|
||||
#[derive(Clone)]
|
||||
pub struct PhaseTopology {
|
||||
/// Phase index
|
||||
pub phase_id: usize,
|
||||
/// Graph structure for this phase
|
||||
pub graph: DynamicGraph,
|
||||
/// Expected mincut value for stability
|
||||
pub expected_mincut: f64,
|
||||
/// Entry points for search (mincut boundary nodes)
|
||||
entry_points: Vec<VertexId>,
|
||||
}
|
||||
|
||||
impl PhaseTopology {
|
||||
/// Create a new phase topology
|
||||
pub fn new(phase_id: usize) -> Self {
|
||||
Self {
|
||||
phase_id,
|
||||
graph: DynamicGraph::new(),
|
||||
expected_mincut: 0.0,
|
||||
entry_points: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build topology from base graph with phase-specific modifications
|
||||
pub fn from_graph(phase_id: usize, base: &DynamicGraph, modulation: f64) -> Self {
|
||||
let graph = base.clone();
|
||||
|
||||
// Modulate edge weights based on phase
|
||||
let phase_factor = (phase_id as f64 * PI / 2.0).sin().abs() + 0.5;
|
||||
|
||||
for edge in graph.edges() {
|
||||
let new_weight = edge.weight * phase_factor * (1.0 + modulation);
|
||||
let _ = graph.update_edge_weight(edge.source, edge.target, new_weight);
|
||||
}
|
||||
|
||||
// Estimate expected mincut
|
||||
let expected_mincut = graph.edges().iter().map(|e| e.weight).sum::<f64>()
|
||||
/ graph.num_vertices().max(1) as f64;
|
||||
|
||||
Self {
|
||||
phase_id,
|
||||
graph,
|
||||
expected_mincut,
|
||||
entry_points: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get entry points for search
|
||||
pub fn entry_points(&self) -> &[VertexId] {
|
||||
&self.entry_points
|
||||
}
|
||||
|
||||
/// Update entry points based on mincut analysis
|
||||
pub fn update_entry_points(&mut self) {
|
||||
// Use vertices with highest degree as entry points
|
||||
let mut degrees: Vec<_> = self
|
||||
.graph
|
||||
.vertices()
|
||||
.iter()
|
||||
.map(|&v| (v, self.graph.degree(v)))
|
||||
.collect();
|
||||
|
||||
degrees.sort_by_key(|(_, d)| std::cmp::Reverse(*d));
|
||||
|
||||
self.entry_points = degrees.iter().take(5).map(|(v, _)| *v).collect();
|
||||
}
|
||||
|
||||
/// Get expected mincut
|
||||
pub fn expected_mincut(&self) -> f64 {
|
||||
self.expected_mincut
|
||||
}
|
||||
}
|
||||
|
||||
/// Time Crystal Central Pattern Generator
|
||||
pub struct TimeCrystalCPG {
|
||||
/// Oscillator neurons (one per phase)
|
||||
oscillators: Vec<OscillatorNeuron>,
|
||||
/// Phase coupling determines crystal structure
|
||||
coupling: Vec<Vec<f64>>,
|
||||
/// Graph topology per phase
|
||||
phase_topologies: Vec<PhaseTopology>,
|
||||
/// Current phase (discrete time crystal state)
|
||||
current_phase: usize,
|
||||
/// Configuration
|
||||
config: CPGConfig,
|
||||
/// Current simulation time
|
||||
time: SimTime,
|
||||
/// Phase history for stability analysis
|
||||
phase_history: Vec<usize>,
|
||||
/// Active graph reference
|
||||
active_graph: DynamicGraph,
|
||||
}
|
||||
|
||||
impl TimeCrystalCPG {
|
||||
/// Create a new Time Crystal CPG
|
||||
pub fn new(base_graph: DynamicGraph, config: CPGConfig) -> Self {
|
||||
let n = config.num_phases;
|
||||
|
||||
// Create oscillators with phase offsets
|
||||
let oscillators: Vec<_> = (0..n)
|
||||
.map(|i| {
|
||||
let phase_offset = 2.0 * PI * i as f64 / n as f64;
|
||||
OscillatorNeuron::new(i, config.frequency, phase_offset)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create coupling matrix (nearest-neighbor coupling)
|
||||
let mut coupling = vec![vec![0.0; n]; n];
|
||||
for i in 0..n {
|
||||
let prev = (i + n - 1) % n;
|
||||
let next = (i + 1) % n;
|
||||
coupling[i][prev] = config.coupling;
|
||||
coupling[i][next] = config.coupling;
|
||||
}
|
||||
|
||||
// Create phase topologies
|
||||
let phase_topologies: Vec<_> = (0..n)
|
||||
.map(|i| {
|
||||
let modulation = 0.1 * i as f64;
|
||||
PhaseTopology::from_graph(i, &base_graph, modulation)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
oscillators,
|
||||
coupling,
|
||||
phase_topologies,
|
||||
current_phase: 0,
|
||||
config,
|
||||
time: 0.0,
|
||||
phase_history: Vec::new(),
|
||||
active_graph: base_graph,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one integration tick
|
||||
pub fn tick(&mut self) -> Option<usize> {
|
||||
let dt = self.config.dt;
|
||||
self.time += dt;
|
||||
|
||||
// 1. Compute coupling inputs
|
||||
let n = self.oscillators.len();
|
||||
let mut coupling_inputs = vec![0.0; n];
|
||||
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if i != j && self.coupling[i][j] != 0.0 {
|
||||
// Kuramoto coupling: K * sin(φ_j - φ_i)
|
||||
let phase_diff = self.oscillators[j].phase - self.oscillators[i].phase;
|
||||
coupling_inputs[i] += self.coupling[i][j] * phase_diff.sin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Oscillator dynamics
|
||||
for (i, osc) in self.oscillators.iter_mut().enumerate() {
|
||||
osc.integrate(dt, coupling_inputs[i]);
|
||||
}
|
||||
|
||||
// 3. Winner-take-all: highest activity determines phase
|
||||
let winner = self
|
||||
.oscillators
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|(_, a), (_, b)| {
|
||||
a.activity()
|
||||
.partial_cmp(&b.activity())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
|
||||
// 4. Phase transition if winner changed
|
||||
let transition = if winner != self.current_phase {
|
||||
let old_phase = self.current_phase;
|
||||
self.transition_topology(old_phase, winner);
|
||||
self.current_phase = winner;
|
||||
self.phase_history.push(winner);
|
||||
|
||||
// Prune history
|
||||
if self.phase_history.len() > 1000 {
|
||||
self.phase_history.remove(0);
|
||||
}
|
||||
|
||||
Some(winner)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 5. Verify time crystal stability via mincut
|
||||
if let Some(topology) = self.phase_topologies.get(self.current_phase) {
|
||||
let actual_mincut = self.estimate_mincut();
|
||||
|
||||
if (topology.expected_mincut - actual_mincut).abs()
|
||||
> self.config.stability_threshold * topology.expected_mincut
|
||||
{
|
||||
self.repair_crystal();
|
||||
}
|
||||
}
|
||||
|
||||
transition
|
||||
}
|
||||
|
||||
/// Transition between topologies
|
||||
fn transition_topology(&mut self, from: usize, to: usize) {
|
||||
// Blend topologies during transition
|
||||
if let Some(to_topo) = self.phase_topologies.get(to) {
|
||||
self.active_graph = to_topo.graph.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate mincut (simplified)
|
||||
fn estimate_mincut(&self) -> f64 {
|
||||
let n = self.active_graph.num_vertices();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Approximate: minimum degree
|
||||
self.active_graph
|
||||
.vertices()
|
||||
.iter()
|
||||
.map(|&v| self.active_graph.degree(v) as f64)
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// Repair crystal structure
|
||||
fn repair_crystal(&mut self) {
|
||||
// Re-synchronize oscillators
|
||||
let n = self.oscillators.len();
|
||||
for (i, osc) in self.oscillators.iter_mut().enumerate() {
|
||||
let target_phase = 2.0 * PI * i as f64 / n as f64;
|
||||
osc.reset(target_phase);
|
||||
}
|
||||
|
||||
// Restore topology from template
|
||||
if let Some(topology) = self.phase_topologies.get(self.current_phase) {
|
||||
self.active_graph = topology.graph.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current phase
|
||||
pub fn current_phase(&self) -> usize {
|
||||
self.current_phase
|
||||
}
|
||||
|
||||
/// Get oscillator phases
|
||||
pub fn phases(&self) -> Vec<f64> {
|
||||
self.oscillators.iter().map(|o| o.phase).collect()
|
||||
}
|
||||
|
||||
/// Get oscillator activities
|
||||
pub fn activities(&self) -> Vec<f64> {
|
||||
self.oscillators.iter().map(|o| o.activity()).collect()
|
||||
}
|
||||
|
||||
/// Get active graph
|
||||
pub fn active_graph(&self) -> &DynamicGraph {
|
||||
&self.active_graph
|
||||
}
|
||||
|
||||
/// Phase-aware search exploiting crystal structure
|
||||
pub fn phase_aware_entry_points(&self) -> Vec<VertexId> {
|
||||
self.phase_topologies
|
||||
.get(self.current_phase)
|
||||
.map(|t| t.entry_points().to_vec())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Check if crystal is stable (periodic behavior)
|
||||
pub fn is_stable(&self) -> bool {
|
||||
if self.phase_history.len() < self.config.num_phases * 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for periodic pattern
|
||||
let period = self.config.num_phases;
|
||||
let recent: Vec<_> = self.phase_history.iter().rev().take(period * 2).collect();
|
||||
|
||||
for i in 0..period {
|
||||
if recent.get(i) != recent.get(i + period) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Get crystal periodicity (0 if not periodic)
|
||||
pub fn periodicity(&self) -> usize {
|
||||
if self.phase_history.len() < 10 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find shortest repeating subsequence
|
||||
for period in 1..=self.config.num_phases {
|
||||
let mut is_periodic = true;
|
||||
|
||||
for i in 0..(self.phase_history.len() - period) {
|
||||
if self.phase_history[i] != self.phase_history[i + period] {
|
||||
is_periodic = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if is_periodic {
|
||||
return period;
|
||||
}
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// Run for specified duration
|
||||
pub fn run(&mut self, duration: f64) -> Vec<usize> {
|
||||
let steps = (duration / self.config.dt) as usize;
|
||||
let mut transitions = Vec::new();
|
||||
|
||||
for _ in 0..steps {
|
||||
if let Some(new_phase) = self.tick() {
|
||||
transitions.push(new_phase);
|
||||
}
|
||||
}
|
||||
|
||||
transitions
|
||||
}
|
||||
|
||||
/// Reset CPG
|
||||
pub fn reset(&mut self) {
|
||||
let n = self.oscillators.len();
|
||||
for (i, osc) in self.oscillators.iter_mut().enumerate() {
|
||||
let phase_offset = 2.0 * PI * i as f64 / n as f64;
|
||||
osc.reset(phase_offset);
|
||||
}
|
||||
|
||||
self.current_phase = 0;
|
||||
self.time = 0.0;
|
||||
self.phase_history.clear();
|
||||
|
||||
if let Some(topology) = self.phase_topologies.get(0) {
|
||||
self.active_graph = topology.graph.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_oscillator_neuron() {
|
||||
let mut osc = OscillatorNeuron::new(0, 10.0, 0.0);
|
||||
|
||||
// Should oscillate
|
||||
let initial_activity = osc.activity();
|
||||
|
||||
for _ in 0..100 {
|
||||
osc.integrate(1.0, 0.0);
|
||||
}
|
||||
|
||||
// Activity should have changed
|
||||
assert!(osc.activity() != initial_activity || osc.phase != 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_phase_topology() {
|
||||
let graph = DynamicGraph::new();
|
||||
graph.insert_edge(0, 1, 1.0).unwrap();
|
||||
graph.insert_edge(1, 2, 1.0).unwrap();
|
||||
|
||||
let topology = PhaseTopology::from_graph(0, &graph, 0.1);
|
||||
|
||||
assert_eq!(topology.phase_id, 0);
|
||||
assert!(topology.expected_mincut >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_crystal_cpg() {
|
||||
let graph = DynamicGraph::new();
|
||||
for i in 0..10 {
|
||||
graph.insert_edge(i, (i + 1) % 10, 1.0).unwrap();
|
||||
}
|
||||
|
||||
let config = CPGConfig::default();
|
||||
let mut cpg = TimeCrystalCPG::new(graph, config);
|
||||
|
||||
// Run for a while
|
||||
let transitions = cpg.run(1000.0);
|
||||
|
||||
// Should have some phase transitions
|
||||
assert!(cpg.time > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_phase_aware_entry() {
|
||||
let graph = DynamicGraph::new();
|
||||
for i in 0..5 {
|
||||
for j in (i + 1)..5 {
|
||||
graph.insert_edge(i, j, 1.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let mut config = CPGConfig::default();
|
||||
config.num_phases = 2;
|
||||
|
||||
let cpg = TimeCrystalCPG::new(graph, config);
|
||||
let _entry_points = cpg.phase_aware_entry_points();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user