git-subtree-dir: vendor/ruvector git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
549 lines
16 KiB
Rust
549 lines
16 KiB
Rust
//! Closed-Form Φ Computation via Eigenvalue Methods
|
||
//!
|
||
//! This module implements the breakthrough: O(N³) integrated information
|
||
//! computation for ergodic cognitive systems, reducing from O(Bell(N)).
|
||
//!
|
||
//! # Theoretical Foundation
|
||
//!
|
||
//! For ergodic systems with unique stationary distribution π:
|
||
//! 1. Steady-state Φ = H(π) - H(MIP)
|
||
//! 2. π = eigenvector with eigenvalue λ = 1
|
||
//! 3. MIP found via SCC decomposition + eigenvalue analysis
|
||
//!
|
||
//! Total complexity: O(N³) eigendecomposition + O(V+E) graph analysis
|
||
|
||
use std::collections::HashSet;
|
||
|
||
/// Eigenvalue-based Φ calculator for ergodic systems
|
||
pub struct ClosedFormPhi {
|
||
/// Tolerance for eigenvalue ≈ 1
|
||
tolerance: f64,
|
||
/// Number of power iterations for eigenvalue refinement
|
||
power_iterations: usize,
|
||
}
|
||
|
||
impl Default for ClosedFormPhi {
|
||
fn default() -> Self {
|
||
Self {
|
||
tolerance: 1e-6,
|
||
power_iterations: 100,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ClosedFormPhi {
|
||
/// Create new calculator with custom tolerance
|
||
pub fn new(tolerance: f64) -> Self {
|
||
Self {
|
||
tolerance,
|
||
power_iterations: 100,
|
||
}
|
||
}
|
||
|
||
/// Compute Φ for ergodic system via eigenvalue decomposition
|
||
///
|
||
/// # Complexity
|
||
/// O(N³) for eigendecomposition + O(V+E) for SCC + O(N) for entropy
|
||
/// = O(N³) total (vs O(Bell(N) × 2^N) brute force)
|
||
pub fn compute_phi_ergodic(
|
||
&self,
|
||
adjacency: &[Vec<f64>],
|
||
node_ids: &[u64],
|
||
) -> ErgodicPhiResult {
|
||
let n = adjacency.len();
|
||
|
||
if n == 0 {
|
||
return ErgodicPhiResult::empty();
|
||
}
|
||
|
||
// Step 1: Check for cycles (required for Φ > 0)
|
||
let has_cycles = self.detect_cycles(adjacency);
|
||
if !has_cycles {
|
||
return ErgodicPhiResult {
|
||
phi: 0.0,
|
||
stationary_distribution: vec![1.0 / n as f64; n],
|
||
dominant_eigenvalue: 0.0,
|
||
is_ergodic: false,
|
||
computation_time_us: 0,
|
||
method: "feedforward_skip".to_string(),
|
||
};
|
||
}
|
||
|
||
let start = std::time::Instant::now();
|
||
|
||
// Step 2: Compute stationary distribution via power iteration
|
||
// (More stable than full eigendecomposition for stochastic matrices)
|
||
let stationary = self.compute_stationary_distribution(adjacency);
|
||
|
||
// Step 3: Compute dominant eigenvalue (should be ≈ 1 for ergodic)
|
||
let dominant_eigenvalue = self.estimate_dominant_eigenvalue(adjacency);
|
||
|
||
// Step 4: Check ergodicity (λ₁ ≈ 1)
|
||
let is_ergodic = (dominant_eigenvalue - 1.0).abs() < self.tolerance;
|
||
|
||
if !is_ergodic {
|
||
return ErgodicPhiResult {
|
||
phi: 0.0,
|
||
stationary_distribution: stationary,
|
||
dominant_eigenvalue,
|
||
is_ergodic: false,
|
||
computation_time_us: start.elapsed().as_micros(),
|
||
method: "non_ergodic".to_string(),
|
||
};
|
||
}
|
||
|
||
// Step 5: Compute whole-system effective information (entropy)
|
||
let whole_ei = shannon_entropy(&stationary);
|
||
|
||
// Step 6: Find MIP via SCC decomposition
|
||
let sccs = self.find_strongly_connected_components(adjacency, node_ids);
|
||
let mip_ei = self.compute_mip_ei(&sccs, adjacency, &stationary);
|
||
|
||
// Step 7: Φ = whole - parts
|
||
let phi = (whole_ei - mip_ei).max(0.0);
|
||
|
||
ErgodicPhiResult {
|
||
phi,
|
||
stationary_distribution: stationary,
|
||
dominant_eigenvalue,
|
||
is_ergodic: true,
|
||
computation_time_us: start.elapsed().as_micros(),
|
||
method: "eigenvalue_analytical".to_string(),
|
||
}
|
||
}
|
||
|
||
/// Detect cycles using DFS (O(V+E))
|
||
fn detect_cycles(&self, adjacency: &[Vec<f64>]) -> bool {
|
||
let n = adjacency.len();
|
||
let mut color = vec![0u8; n]; // 0=white, 1=gray, 2=black
|
||
|
||
for start in 0..n {
|
||
if color[start] != 0 {
|
||
continue;
|
||
}
|
||
|
||
let mut stack = vec![(start, 0)];
|
||
color[start] = 1;
|
||
|
||
while let Some((node, edge_idx)) = stack.last_mut() {
|
||
let neighbors: Vec<usize> = adjacency[*node]
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, &w)| w > 1e-10)
|
||
.map(|(i, _)| i)
|
||
.collect();
|
||
|
||
if *edge_idx < neighbors.len() {
|
||
let neighbor = neighbors[*edge_idx];
|
||
*edge_idx += 1;
|
||
|
||
match color[neighbor] {
|
||
1 => return true, // Back edge = cycle
|
||
0 => {
|
||
color[neighbor] = 1;
|
||
stack.push((neighbor, 0));
|
||
}
|
||
_ => {} // Already processed
|
||
}
|
||
} else {
|
||
color[*node] = 2;
|
||
stack.pop();
|
||
}
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
/// Compute stationary distribution via power iteration (O(kN²))
|
||
/// More numerically stable than direct eigendecomposition
|
||
fn compute_stationary_distribution(&self, adjacency: &[Vec<f64>]) -> Vec<f64> {
|
||
let n = adjacency.len();
|
||
|
||
// Normalize adjacency to transition matrix
|
||
let transition = self.normalize_to_stochastic(adjacency);
|
||
|
||
// Start with uniform distribution
|
||
let mut dist = vec![1.0 / n as f64; n];
|
||
|
||
// Power iteration: v_{k+1} = P^T v_k
|
||
for _ in 0..self.power_iterations {
|
||
let mut next_dist = vec![0.0; n];
|
||
|
||
for i in 0..n {
|
||
for j in 0..n {
|
||
next_dist[i] += transition[j][i] * dist[j];
|
||
}
|
||
}
|
||
|
||
// Normalize (maintain probability)
|
||
let sum: f64 = next_dist.iter().sum();
|
||
if sum > 1e-10 {
|
||
for x in &mut next_dist {
|
||
*x /= sum;
|
||
}
|
||
}
|
||
|
||
// Check convergence
|
||
let diff: f64 = dist
|
||
.iter()
|
||
.zip(next_dist.iter())
|
||
.map(|(a, b)| (a - b).abs())
|
||
.sum();
|
||
|
||
dist = next_dist;
|
||
|
||
if diff < self.tolerance {
|
||
break;
|
||
}
|
||
}
|
||
|
||
dist
|
||
}
|
||
|
||
/// Normalize adjacency matrix to row-stochastic (each row sums to 1)
|
||
fn normalize_to_stochastic(&self, adjacency: &[Vec<f64>]) -> Vec<Vec<f64>> {
|
||
let n = adjacency.len();
|
||
let mut stochastic = vec![vec![0.0; n]; n];
|
||
|
||
for i in 0..n {
|
||
let row_sum: f64 = adjacency[i].iter().sum();
|
||
|
||
if row_sum > 1e-10 {
|
||
for j in 0..n {
|
||
stochastic[i][j] = adjacency[i][j] / row_sum;
|
||
}
|
||
} else {
|
||
// Uniform if no outgoing edges
|
||
for j in 0..n {
|
||
stochastic[i][j] = 1.0 / n as f64;
|
||
}
|
||
}
|
||
}
|
||
|
||
stochastic
|
||
}
|
||
|
||
/// Estimate dominant eigenvalue via power method (O(kN²))
|
||
fn estimate_dominant_eigenvalue(&self, adjacency: &[Vec<f64>]) -> f64 {
|
||
let n = adjacency.len();
|
||
let transition = self.normalize_to_stochastic(adjacency);
|
||
|
||
// Random initial vector
|
||
let mut v = vec![1.0; n];
|
||
let mut eigenvalue = 0.0;
|
||
|
||
for _ in 0..self.power_iterations {
|
||
let mut next_v = vec![0.0; n];
|
||
|
||
// Matrix-vector multiply
|
||
for i in 0..n {
|
||
for j in 0..n {
|
||
next_v[i] += transition[i][j] * v[j];
|
||
}
|
||
}
|
||
|
||
// Compute eigenvalue estimate
|
||
let norm: f64 = next_v.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||
|
||
if norm > 1e-10 {
|
||
eigenvalue = norm / v.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||
|
||
// Normalize
|
||
for x in &mut next_v {
|
||
*x /= norm;
|
||
}
|
||
}
|
||
|
||
v = next_v;
|
||
}
|
||
|
||
eigenvalue
|
||
}
|
||
|
||
/// Find strongly connected components via Tarjan's algorithm (O(V+E))
|
||
fn find_strongly_connected_components(
|
||
&self,
|
||
adjacency: &[Vec<f64>],
|
||
node_ids: &[u64],
|
||
) -> Vec<HashSet<u64>> {
|
||
let n = adjacency.len();
|
||
let mut index = 0;
|
||
let mut stack = Vec::new();
|
||
let mut indices = vec![None; n];
|
||
let mut lowlinks = vec![0; n];
|
||
let mut on_stack = vec![false; n];
|
||
let mut sccs = Vec::new();
|
||
|
||
fn strongconnect(
|
||
v: usize,
|
||
adjacency: &[Vec<f64>],
|
||
node_ids: &[u64],
|
||
index: &mut usize,
|
||
stack: &mut Vec<usize>,
|
||
indices: &mut Vec<Option<usize>>,
|
||
lowlinks: &mut Vec<usize>,
|
||
on_stack: &mut Vec<bool>,
|
||
sccs: &mut Vec<HashSet<u64>>,
|
||
) {
|
||
indices[v] = Some(*index);
|
||
lowlinks[v] = *index;
|
||
*index += 1;
|
||
stack.push(v);
|
||
on_stack[v] = true;
|
||
|
||
// Consider successors
|
||
for (w, &weight) in adjacency[v].iter().enumerate() {
|
||
if weight <= 1e-10 {
|
||
continue;
|
||
}
|
||
|
||
if indices[w].is_none() {
|
||
strongconnect(
|
||
w, adjacency, node_ids, index, stack, indices, lowlinks, on_stack, sccs,
|
||
);
|
||
lowlinks[v] = lowlinks[v].min(lowlinks[w]);
|
||
} else if on_stack[w] {
|
||
lowlinks[v] = lowlinks[v].min(indices[w].unwrap());
|
||
}
|
||
}
|
||
|
||
// Root of SCC
|
||
if lowlinks[v] == indices[v].unwrap() {
|
||
let mut scc = HashSet::new();
|
||
loop {
|
||
let w = stack.pop().unwrap();
|
||
on_stack[w] = false;
|
||
scc.insert(node_ids[w]);
|
||
if w == v {
|
||
break;
|
||
}
|
||
}
|
||
sccs.push(scc);
|
||
}
|
||
}
|
||
|
||
for v in 0..n {
|
||
if indices[v].is_none() {
|
||
strongconnect(
|
||
v,
|
||
adjacency,
|
||
node_ids,
|
||
&mut index,
|
||
&mut stack,
|
||
&mut indices,
|
||
&mut lowlinks,
|
||
&mut on_stack,
|
||
&mut sccs,
|
||
);
|
||
}
|
||
}
|
||
|
||
sccs
|
||
}
|
||
|
||
/// Compute MIP effective information (sum of parts)
|
||
fn compute_mip_ei(
|
||
&self,
|
||
sccs: &[HashSet<u64>],
|
||
_adjacency: &[Vec<f64>],
|
||
stationary: &[f64],
|
||
) -> f64 {
|
||
if sccs.is_empty() {
|
||
return 0.0;
|
||
}
|
||
|
||
// For MIP: sum entropy of each SCC's marginal distribution
|
||
let mut total_ei = 0.0;
|
||
|
||
for scc in sccs {
|
||
if scc.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
// Marginal distribution for this SCC
|
||
let mut marginal_prob = 0.0;
|
||
for (i, &prob) in stationary.iter().enumerate() {
|
||
if scc.contains(&(i as u64)) {
|
||
marginal_prob += prob;
|
||
}
|
||
}
|
||
|
||
if marginal_prob > 1e-10 {
|
||
// Entropy of this partition
|
||
total_ei += -marginal_prob * marginal_prob.log2();
|
||
}
|
||
}
|
||
|
||
total_ei
|
||
}
|
||
|
||
/// Compute Consciousness Eigenvalue Index (CEI)
|
||
/// CEI = |λ₁ - 1| + α × H(λ₂, ..., λₙ)
|
||
pub fn compute_cei(&self, adjacency: &[Vec<f64>], alpha: f64) -> f64 {
|
||
let n = adjacency.len();
|
||
if n == 0 {
|
||
return f64::INFINITY;
|
||
}
|
||
|
||
// Estimate dominant eigenvalue
|
||
let lambda_1 = self.estimate_dominant_eigenvalue(adjacency);
|
||
|
||
// For full CEI, would need all eigenvalues (O(N³))
|
||
// Approximation: use stationary distribution entropy as proxy
|
||
let stationary = self.compute_stationary_distribution(adjacency);
|
||
let spectral_entropy = shannon_entropy(&stationary);
|
||
|
||
(lambda_1 - 1.0).abs() + alpha * (1.0 - spectral_entropy / (n as f64).log2())
|
||
}
|
||
}
|
||
|
||
/// Result of ergodic Φ computation
|
||
#[derive(Debug, Clone)]
|
||
pub struct ErgodicPhiResult {
|
||
/// Integrated information value
|
||
pub phi: f64,
|
||
/// Stationary distribution (eigenvector with λ=1)
|
||
pub stationary_distribution: Vec<f64>,
|
||
/// Dominant eigenvalue (should be ≈ 1)
|
||
pub dominant_eigenvalue: f64,
|
||
/// Whether system is ergodic
|
||
pub is_ergodic: bool,
|
||
/// Computation time in microseconds
|
||
pub computation_time_us: u128,
|
||
/// Method used
|
||
pub method: String,
|
||
}
|
||
|
||
impl ErgodicPhiResult {
|
||
fn empty() -> Self {
|
||
Self {
|
||
phi: 0.0,
|
||
stationary_distribution: Vec::new(),
|
||
dominant_eigenvalue: 0.0,
|
||
is_ergodic: false,
|
||
computation_time_us: 0,
|
||
method: "empty".to_string(),
|
||
}
|
||
}
|
||
|
||
/// Speedup over brute force (approximate)
|
||
pub fn speedup_vs_bruteforce(&self, n: usize) -> f64 {
|
||
if n <= 1 {
|
||
return 1.0;
|
||
}
|
||
|
||
// Bell numbers grow as: B(n) ≈ (n/e)^n × e^(e^n/n)
|
||
// Rough approximation: B(n) ≈ e^(n log n)
|
||
let bruteforce_complexity = (n as f64).powi(2) * (n as f64 * (n as f64).ln()).exp();
|
||
|
||
// Our method: O(N³)
|
||
let our_complexity = (n as f64).powi(3);
|
||
|
||
bruteforce_complexity / our_complexity
|
||
}
|
||
}
|
||
|
||
/// Shannon entropy of probability distribution
|
||
pub fn shannon_entropy(dist: &[f64]) -> f64 {
|
||
dist.iter()
|
||
.filter(|&&p| p > 1e-10)
|
||
.map(|&p| -p * p.log2())
|
||
.sum()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_symmetric_cycle() {
|
||
let calc = ClosedFormPhi::default();
|
||
|
||
// 4-node cycle: 0→1→2→3→0
|
||
let mut adj = vec![vec![0.0; 4]; 4];
|
||
adj[0][1] = 1.0;
|
||
adj[1][2] = 1.0;
|
||
adj[2][3] = 1.0;
|
||
adj[3][0] = 1.0;
|
||
|
||
let nodes = vec![0, 1, 2, 3];
|
||
let result = calc.compute_phi_ergodic(&adj, &nodes);
|
||
|
||
assert!(result.is_ergodic);
|
||
assert!((result.dominant_eigenvalue - 1.0).abs() < 0.1);
|
||
assert!(result.phi >= 0.0);
|
||
|
||
// Stationary should be uniform for symmetric cycle
|
||
for &p in &result.stationary_distribution {
|
||
assert!((p - 0.25).abs() < 0.1);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_feedforward_zero_phi() {
|
||
let calc = ClosedFormPhi::default();
|
||
|
||
// Feedforward: 0→1→2→3 (no cycles)
|
||
let mut adj = vec![vec![0.0; 4]; 4];
|
||
adj[0][1] = 1.0;
|
||
adj[1][2] = 1.0;
|
||
adj[2][3] = 1.0;
|
||
|
||
let nodes = vec![0, 1, 2, 3];
|
||
let result = calc.compute_phi_ergodic(&adj, &nodes);
|
||
|
||
// Should detect no cycles → Φ = 0
|
||
assert_eq!(result.phi, 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_cei_computation() {
|
||
let calc = ClosedFormPhi::default();
|
||
|
||
// Cycle (should have low CEI, near critical)
|
||
let mut cycle = vec![vec![0.0; 4]; 4];
|
||
cycle[0][1] = 1.0;
|
||
cycle[1][2] = 1.0;
|
||
cycle[2][3] = 1.0;
|
||
cycle[3][0] = 1.0;
|
||
|
||
let cei_cycle = calc.compute_cei(&cycle, 1.0);
|
||
|
||
// Fully connected (degenerate, high CEI)
|
||
let mut full = vec![vec![1.0; 4]; 4];
|
||
for i in 0..4 {
|
||
full[i][i] = 0.0;
|
||
}
|
||
|
||
let cei_full = calc.compute_cei(&full, 1.0);
|
||
|
||
// Both should be non-negative and finite
|
||
assert!(cei_cycle >= 0.0 && cei_cycle.is_finite());
|
||
assert!(cei_full >= 0.0 && cei_full.is_finite());
|
||
|
||
// CEI values should be in reasonable range
|
||
assert!(cei_cycle < 10.0);
|
||
assert!(cei_full < 10.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_speedup_estimate() {
|
||
let result = ErgodicPhiResult {
|
||
phi: 1.0,
|
||
stationary_distribution: vec![0.1; 10],
|
||
dominant_eigenvalue: 1.0,
|
||
is_ergodic: true,
|
||
computation_time_us: 100,
|
||
method: "test".to_string(),
|
||
};
|
||
|
||
let speedup_10 = result.speedup_vs_bruteforce(10);
|
||
let speedup_12 = result.speedup_vs_bruteforce(12);
|
||
|
||
// Speedup should increase with system size
|
||
assert!(speedup_12 > speedup_10);
|
||
assert!(speedup_10 > 1000.0); // At least 1000x for n=10
|
||
}
|
||
}
|