Skip to main content

ruvector_consciousness/
emergence.rs

1//! Causal emergence and effective information computation.
2//!
3//! Implements Erik Hoel's causal emergence framework:
4//! - **Effective Information (EI)**: measures the causal power of a system
5//! - **Determinism**: how precisely causes map to effects
6//! - **Degeneracy**: how many causes lead to the same effect
7//! - **Causal Emergence**: EI_macro - EI_micro > 0 means the macro level
8//!   is more causally informative than the micro level
9//!
10//! # References
11//!
12//! Hoel, E.P. (2017). "When the Map is Better Than the Territory."
13//! Entropy, 19(5), 188.
14
15use crate::error::{ConsciousnessError, ValidationError};
16use crate::simd::{entropy, kl_divergence};
17use crate::traits::EmergenceEngine;
18use crate::types::{ComputeBudget, EmergenceResult, TransitionMatrix};
19
20use std::time::Instant;
21
22// ---------------------------------------------------------------------------
23// Effective Information
24// ---------------------------------------------------------------------------
25
26/// Compute effective information for a TPM.
27///
28/// EI = H_max(cause) - <H(cause | effect)>
29///    = log2(n) - (determinism_deficit + degeneracy)
30///
31/// Concretely:
32///   EI = (1/n) * Σ_state D_KL( P(future|state) || uniform )
33///
34/// This measures how much knowing the current state reduces uncertainty
35/// about the future state, compared to a uniform prior.
36pub fn effective_information(tpm: &TransitionMatrix) -> Result<f64, ConsciousnessError> {
37    let n = tpm.n;
38    if n < 2 {
39        return Err(ValidationError::EmptySystem.into());
40    }
41
42    let uniform: Vec<f64> = vec![1.0 / n as f64; n];
43    let mut ei_sum = 0.0f64;
44
45    for state in 0..n {
46        let row = &tpm.data[state * n..(state + 1) * n];
47        ei_sum += kl_divergence(row, &uniform);
48    }
49
50    Ok(ei_sum / n as f64)
51}
52
53/// Compute determinism: how precisely states map to unique outcomes.
54///
55/// det = (1/n) * Σ_state H_max - H(P(future|state))
56///     = log(n) - (1/n) * Σ_state H(row)
57pub fn determinism(tpm: &TransitionMatrix) -> f64 {
58    let n = tpm.n;
59    let h_max = (n as f64).ln();
60    let mut avg_entropy = 0.0f64;
61
62    for state in 0..n {
63        let row = &tpm.data[state * n..(state + 1) * n];
64        avg_entropy += entropy(row);
65    }
66    avg_entropy /= n as f64;
67
68    h_max - avg_entropy
69}
70
71/// Compute degeneracy: how many states lead to the same outcome.
72///
73/// deg = H_max - H(marginal_output)
74pub fn degeneracy(tpm: &TransitionMatrix) -> f64 {
75    let n = tpm.n;
76    let h_max = (n as f64).ln();
77
78    // Marginal output distribution (average of all rows).
79    let mut marginal = vec![0.0f64; n];
80    for state in 0..n {
81        for j in 0..n {
82            marginal[j] += tpm.get(state, j);
83        }
84    }
85    let inv_n = 1.0 / n as f64;
86    for m in &mut marginal {
87        *m *= inv_n;
88    }
89
90    h_max - entropy(&marginal)
91}
92
93// ---------------------------------------------------------------------------
94// Coarse-graining
95// ---------------------------------------------------------------------------
96
97/// Apply a coarse-graining mapping to a TPM.
98///
99/// `mapping[i]` gives the macro-state index for micro-state i.
100/// The resulting macro-TPM has size max(mapping) + 1.
101pub fn coarse_grain(tpm: &TransitionMatrix, mapping: &[usize]) -> TransitionMatrix {
102    assert_eq!(mapping.len(), tpm.n);
103
104    let n_macro = mapping.iter().copied().max().unwrap_or(0) + 1;
105    let mut macro_tpm = vec![0.0f64; n_macro * n_macro];
106    let mut counts = vec![0usize; n_macro]; // micro-states per macro-state
107
108    for micro_from in 0..tpm.n {
109        let macro_from = mapping[micro_from];
110        counts[macro_from] += 1;
111
112        for micro_to in 0..tpm.n {
113            let macro_to = mapping[micro_to];
114            macro_tpm[macro_from * n_macro + macro_to] += tpm.get(micro_from, micro_to);
115        }
116    }
117
118    // Normalize by the number of micro-states in each macro-state.
119    for macro_from in 0..n_macro {
120        if counts[macro_from] > 0 {
121            let inv = 1.0 / counts[macro_from] as f64;
122            for macro_to in 0..n_macro {
123                macro_tpm[macro_from * n_macro + macro_to] *= inv;
124            }
125        }
126    }
127
128    TransitionMatrix::new(n_macro, macro_tpm)
129}
130
131// ---------------------------------------------------------------------------
132// Causal Emergence Engine
133// ---------------------------------------------------------------------------
134
135/// Causal emergence engine that searches for the coarse-graining
136/// that maximizes effective information.
137pub struct CausalEmergenceEngine {
138    max_macro_states: usize,
139}
140
141impl CausalEmergenceEngine {
142    pub fn new(max_macro_states: usize) -> Self {
143        Self { max_macro_states }
144    }
145}
146
147impl Default for CausalEmergenceEngine {
148    fn default() -> Self {
149        Self {
150            max_macro_states: 16,
151        }
152    }
153}
154
155impl EmergenceEngine for CausalEmergenceEngine {
156    fn compute_emergence(
157        &self,
158        tpm: &TransitionMatrix,
159        budget: &ComputeBudget,
160    ) -> Result<EmergenceResult, ConsciousnessError> {
161        let start = Instant::now();
162        let n = tpm.n;
163
164        if n < 2 {
165            return Err(ValidationError::EmptySystem.into());
166        }
167
168        // Micro-level EI.
169        let ei_micro = effective_information(tpm)?;
170        let det_micro = determinism(tpm);
171        let deg_micro = degeneracy(tpm);
172
173        // Search coarse-grainings: try merging pairs greedily.
174        let mut best_ei_macro = ei_micro;
175        let mut best_mapping: Vec<usize> = (0..n).collect(); // identity = no coarse-graining
176
177        // Greedy merge: try reducing to k macro-states for k = n-1, n-2, ..., 2.
178        let min_k = 2.max(self.max_macro_states.min(n));
179        for target_k in (2..n).rev() {
180            if target_k < min_k && best_ei_macro > ei_micro {
181                break; // Found improvement, stop searching
182            }
183            if start.elapsed() > budget.max_time {
184                break;
185            }
186
187            // Greedy: merge the two states with most similar output distributions.
188            let mapping = greedy_merge(tpm, target_k);
189            let macro_tpm = coarse_grain(tpm, &mapping);
190
191            if let Ok(ei) = effective_information(&macro_tpm) {
192                if ei > best_ei_macro {
193                    best_ei_macro = ei;
194                    best_mapping = mapping;
195                }
196            }
197        }
198
199        Ok(EmergenceResult {
200            ei_micro,
201            ei_macro: best_ei_macro,
202            causal_emergence: best_ei_macro - ei_micro,
203            coarse_graining: best_mapping,
204            determinism: det_micro,
205            degeneracy: deg_micro,
206            elapsed: start.elapsed(),
207        })
208    }
209
210    fn effective_information(
211        &self,
212        tpm: &TransitionMatrix,
213    ) -> Result<f64, ConsciousnessError> {
214        effective_information(tpm)
215    }
216}
217
218/// Greedy merge: iteratively merge the two most similar states until
219/// we reach the target number of macro-states.
220fn greedy_merge(tpm: &TransitionMatrix, target_k: usize) -> Vec<usize> {
221    let n = tpm.n;
222    let mut mapping: Vec<usize> = (0..n).collect();
223    let mut current_k = n;
224
225    while current_k > target_k {
226        // Find the pair of macro-states with minimum distribution distance.
227        let mut best_dist = f64::MAX;
228        let mut best_i = 0;
229        let mut best_j = 1;
230
231        let macro_ids: Vec<usize> = {
232            let mut ids: Vec<usize> = mapping.to_vec();
233            ids.sort_unstable();
234            ids.dedup();
235            ids
236        };
237
238        for (ai, &mi) in macro_ids.iter().enumerate() {
239            for &mj in macro_ids[ai + 1..].iter() {
240                // Average distribution distance.
241                let dist = state_distribution_distance(tpm, &mapping, mi, mj);
242                if dist < best_dist {
243                    best_dist = dist;
244                    best_i = mi;
245                    best_j = mj;
246                }
247            }
248        }
249
250        // Merge: map all occurrences of best_j to best_i.
251        for m in &mut mapping {
252            if *m == best_j {
253                *m = best_i;
254            }
255        }
256
257        // Re-index to be contiguous.
258        let mut unique: Vec<usize> = mapping.to_vec();
259        unique.sort_unstable();
260        unique.dedup();
261        for m in &mut mapping {
262            *m = unique.iter().position(|&u| u == *m).unwrap();
263        }
264
265        current_k = unique.len();
266    }
267
268    mapping
269}
270
271/// L2 distance between average output distributions of two macro-states.
272fn state_distribution_distance(
273    tpm: &TransitionMatrix,
274    mapping: &[usize],
275    macro_a: usize,
276    macro_b: usize,
277) -> f64 {
278    let n = tpm.n;
279    let mut avg_a = vec![0.0f64; n];
280    let mut avg_b = vec![0.0f64; n];
281    let mut count_a = 0usize;
282    let mut count_b = 0usize;
283
284    for micro in 0..n {
285        if mapping[micro] == macro_a {
286            for j in 0..n {
287                avg_a[j] += tpm.get(micro, j);
288            }
289            count_a += 1;
290        } else if mapping[micro] == macro_b {
291            for j in 0..n {
292                avg_b[j] += tpm.get(micro, j);
293            }
294            count_b += 1;
295        }
296    }
297
298    if count_a > 0 {
299        let inv = 1.0 / count_a as f64;
300        for a in &mut avg_a {
301            *a *= inv;
302        }
303    }
304    if count_b > 0 {
305        let inv = 1.0 / count_b as f64;
306        for b in &mut avg_b {
307            *b *= inv;
308        }
309    }
310
311    // L2 distance.
312    let mut dist = 0.0f64;
313    for j in 0..n {
314        let d = avg_a[j] - avg_b[j];
315        dist += d * d;
316    }
317    dist.sqrt()
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    fn identity_tpm(n: usize) -> TransitionMatrix {
325        TransitionMatrix::identity(n)
326    }
327
328    fn uniform_tpm(n: usize) -> TransitionMatrix {
329        let val = 1.0 / n as f64;
330        let data = vec![val; n * n];
331        TransitionMatrix::new(n, data)
332    }
333
334    #[test]
335    fn ei_identity_is_max() {
336        // Identity TPM: each state deterministically maps to itself = max EI.
337        let tpm = identity_tpm(4);
338        let ei = effective_information(&tpm).unwrap();
339        let h_max = (4.0f64).ln();
340        assert!(
341            (ei - h_max).abs() < 1e-6,
342            "identity TPM should have EI = log(n), got {ei}"
343        );
344    }
345
346    #[test]
347    fn ei_uniform_is_zero() {
348        // Uniform TPM: every state maps uniformly = zero EI.
349        let tpm = uniform_tpm(4);
350        let ei = effective_information(&tpm).unwrap();
351        assert!(ei.abs() < 1e-6, "uniform TPM should have EI ≈ 0, got {ei}");
352    }
353
354    #[test]
355    fn determinism_identity_is_max() {
356        let tpm = identity_tpm(4);
357        let det = determinism(&tpm);
358        let h_max = (4.0f64).ln();
359        assert!((det - h_max).abs() < 1e-6);
360    }
361
362    #[test]
363    fn degeneracy_identity_is_zero() {
364        // Identity: marginal is uniform, so degeneracy = 0.
365        let tpm = identity_tpm(4);
366        let deg = degeneracy(&tpm);
367        assert!(deg.abs() < 1e-6, "identity TPM degeneracy should be 0, got {deg}");
368    }
369
370    #[test]
371    fn coarse_grain_identity() {
372        let tpm = identity_tpm(4);
373        let mapping = vec![0, 0, 1, 1]; // merge 0+1 and 2+3
374        let macro_tpm = coarse_grain(&tpm, &mapping);
375        assert_eq!(macro_tpm.n, 2);
376        // Identity: each micro-state maps to itself. After merging 0+1 into macro 0,
377        // both micro-states transition within macro 0, so P(macro 0 -> macro 0) = 1.0.
378        assert!((macro_tpm.get(0, 0) - 1.0).abs() < 1e-6);
379    }
380
381    #[test]
382    fn causal_emergence_runs() {
383        let tpm = identity_tpm(4);
384        let budget = ComputeBudget::fast();
385        let engine = CausalEmergenceEngine::default();
386        let result = engine.compute_emergence(&tpm, &budget).unwrap();
387        assert!(result.ei_micro >= 0.0);
388        assert!(result.causal_emergence.is_finite());
389    }
390}