Skip to main content

sc_neurocore_engine/
phi.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Phi* integrated information estimation in Rust
8
9//! Barrett & Seth 2011 geometric Phi under Gaussian assumption.
10//! Phi* = MI(past; future) - max_partition sum MI(past_k; future_k)
11
12use nalgebra::DMatrix;
13
14/// Compute Phi* for a multi-channel time series.
15/// data: [n_channels][n_timesteps], row-major.
16pub fn phi_star(data: &[Vec<f64>], tau: usize) -> f64 {
17    let n = data.len();
18    if n < 2 || data[0].len() <= 2 * tau {
19        return 0.0;
20    }
21    let t = data[0].len();
22
23    // Split into past and future
24    let past: Vec<Vec<f64>> = data.iter().map(|ch| ch[..t - tau].to_vec()).collect();
25    let future: Vec<Vec<f64>> = data.iter().map(|ch| ch[tau..].to_vec()).collect();
26
27    let mi_whole = gaussian_mi(&past, &future);
28
29    // Minimum information partition: contiguous splits only
30    let mut mi_parts_min = f64::INFINITY;
31    for k in 1..n {
32        let past_a: Vec<Vec<f64>> = past[..k].to_vec();
33        let fut_a: Vec<Vec<f64>> = future[..k].to_vec();
34        let past_b: Vec<Vec<f64>> = past[k..].to_vec();
35        let fut_b: Vec<Vec<f64>> = future[k..].to_vec();
36        let mi_parts = gaussian_mi(&past_a, &fut_a) + gaussian_mi(&past_b, &fut_b);
37        if mi_parts < mi_parts_min {
38            mi_parts_min = mi_parts;
39        }
40    }
41
42    (mi_whole - mi_parts_min).max(0.0)
43}
44
45/// Gaussian mutual information `MI(X;Y) = 0.5 (log|Cov_X| + log|Cov_Y| - log|Cov_XY|)`.
46///
47/// The covariances are symmetric positive-definite (regularised with `eps` on the
48/// diagonal), so each log-determinant is taken from a Cholesky factor
49/// (`2 Σ log Lᵢᵢ`). Summing log-determinants is numerically stable where the former
50/// product/ratio of raw determinants would underflow for larger channel counts.
51fn gaussian_mi(x: &[Vec<f64>], y: &[Vec<f64>]) -> f64 {
52    let eps = 1e-10;
53    let cov_x = covariance_matrix(x, eps);
54    let cov_y = covariance_matrix(y, eps);
55
56    let mut xy: Vec<Vec<f64>> = x.to_vec();
57    xy.extend_from_slice(y);
58    let cov_xy = covariance_matrix(&xy, eps);
59
60    let mi = 0.5 * (logdet_spd(&cov_x) + logdet_spd(&cov_y) - logdet_spd(&cov_xy));
61    mi.max(0.0)
62}
63
64/// Compute covariance matrix with regularization.
65fn covariance_matrix(data: &[Vec<f64>], eps: f64) -> Vec<Vec<f64>> {
66    let n = data.len();
67    if n == 0 {
68        return vec![];
69    }
70    let t = data[0].len();
71    if t == 0 {
72        return vec![vec![eps; n]; n];
73    }
74
75    // Compute means
76    let means: Vec<f64> = data
77        .iter()
78        .map(|ch| ch.iter().sum::<f64>() / t as f64)
79        .collect();
80
81    // Compute covariance
82    let mut cov = vec![vec![0.0; n]; n];
83    let t_f = t as f64;
84    let denom = (t_f - 1.0).max(1.0);
85
86    for i in 0..n {
87        for j in 0..=i {
88            let dot = crate::simd::dot_f64_dispatch(&data[i], &data[j]);
89            let val = (dot - t_f * means[i] * means[j]) / denom;
90            cov[i][j] = val;
91            cov[j][i] = val;
92        }
93        cov[i][i] += eps; // regularize diagonal
94    }
95    cov
96}
97
98/// Natural log-determinant of a symmetric positive-definite matrix via Cholesky.
99///
100/// `log|A| = 2 Σ log Lᵢᵢ` where `A = L Lᵀ`. The phi covariances are SPD by
101/// construction (`eps`-regularised), so the Cholesky cannot fail in practice.
102fn logdet_spd(matrix: &[Vec<f64>]) -> f64 {
103    let n = matrix.len();
104    if n == 0 {
105        return 0.0;
106    }
107    let flat: Vec<f64> = matrix.iter().flatten().copied().collect();
108    let chol = DMatrix::<f64>::from_row_slice(n, n, &flat)
109        .cholesky()
110        .expect("phi covariance must be symmetric positive-definite");
111    let l = chol.l();
112    2.0 * (0..n).map(|i| l[(i, i)].ln()).sum::<f64>()
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn test_independent_channels_low_phi() {
121        // Independent random channels: Phi should be near zero
122        let data = vec![
123            (0..200).map(|i| (i as f64 * 0.1).sin()).collect(),
124            (0..200).map(|i| (i as f64 * 0.3).cos()).collect(),
125            (0..200).map(|i| (i as f64 * 0.7).sin()).collect(),
126        ];
127        let phi = phi_star(&data, 1);
128        assert!(phi < 0.5, "Expected low Phi for independent, got {phi}");
129    }
130
131    #[test]
132    fn test_correlated_channels_positive_phi() {
133        // Cross-correlated but distinct: Phi should be positive
134        let data = vec![
135            (0..200)
136                .map(|i| (i as f64 * 0.1).sin() + (i as f64 * 0.05).cos() * 0.3)
137                .collect(),
138            (0..200)
139                .map(|i| (i as f64 * 0.1).sin() * 0.8 + (i as f64 * 0.2).sin() * 0.5)
140                .collect(),
141            (0..200)
142                .map(|i| (i as f64 * 0.1).sin() * 0.6 + (i as f64 * 0.15).cos() * 0.7)
143                .collect(),
144        ];
145        let phi = phi_star(&data, 1);
146        assert!(phi >= 0.0, "Expected non-negative Phi, got {phi}");
147    }
148
149    #[test]
150    fn test_single_channel_zero() {
151        let data = vec![vec![1.0, 2.0, 3.0, 4.0, 5.0]];
152        assert_eq!(phi_star(&data, 1), 0.0);
153    }
154
155    #[test]
156    fn test_short_data_zero() {
157        let data = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
158        assert_eq!(phi_star(&data, 1), 0.0);
159    }
160
161    #[test]
162    fn test_logdet_spd_diagonal() {
163        // log|diag(2, 8)| = log 16
164        let m = vec![vec![2.0, 0.0], vec![0.0, 8.0]];
165        assert!((logdet_spd(&m) - 16.0_f64.ln()).abs() < 1e-10);
166    }
167
168    #[test]
169    fn test_logdet_spd_matches_dense() {
170        // SPD matrix; compare against a direct ln(det) reference.
171        let m = vec![
172            vec![4.0, 1.0, 0.5],
173            vec![1.0, 3.0, 0.2],
174            vec![0.5, 0.2, 2.0],
175        ];
176        let det: f64 = 4.0 * (3.0 * 2.0 - 0.2 * 0.2) - 1.0 * (1.0 * 2.0 - 0.2 * 0.5)
177            + 0.5 * (1.0 * 0.2 - 3.0 * 0.5);
178        assert!((logdet_spd(&m) - det.ln()).abs() < 1e-10);
179    }
180}