Skip to main content

ruvector_consciousness/
phi.rs

1//! Integrated Information Theory (IIT) Φ computation.
2//!
3//! Implements exact and approximate algorithms for computing integrated
4//! information Φ — the core metric of consciousness in IIT 3.0/4.0.
5//!
6//! # Algorithms
7//!
8//! | Algorithm | Complexity | Use case |
9//! |-----------|-----------|----------|
10//! | Exact | O(2^n · n²) | n ≤ 16 elements |
11//! | Spectral | O(n² log n) | n ≤ 1000, good approximation |
12//! | Stochastic | O(k · n²) | Any n, configurable samples |
13//! | GreedyBisection | O(n³) | Fast lower bound |
14//!
15//! # Algorithm
16//!
17//! Φ = min over all bipartitions { D_KL(P(whole) || P(part_A) ⊗ P(part_B)) }
18//!
19//! The minimum information partition (MIP) is the bipartition that causes
20//! the least information loss when the system is "cut".
21
22use crate::arena::PhiArena;
23use crate::error::{ConsciousnessError, ValidationError};
24use crate::simd::kl_divergence;
25use crate::traits::PhiEngine;
26use crate::types::{
27    Bipartition, BipartitionIter, ComputeBudget, PhiAlgorithm, PhiResult, TransitionMatrix,
28};
29
30use std::time::Instant;
31
32// ---------------------------------------------------------------------------
33// Validation
34// ---------------------------------------------------------------------------
35
36pub(crate) fn validate_tpm(tpm: &TransitionMatrix) -> Result<(), ConsciousnessError> {
37    if tpm.n < 2 {
38        return Err(ValidationError::EmptySystem.into());
39    }
40    for i in 0..tpm.n {
41        let mut row_sum = 0.0;
42        for j in 0..tpm.n {
43            let val = tpm.get(i, j);
44            if !val.is_finite() {
45                return Err(ValidationError::NonFiniteValue { row: i, col: j }.into());
46            }
47            if val < -1e-10 {
48                return Err(ValidationError::ParameterOutOfRange {
49                    name: format!("tpm[{i}][{j}]"),
50                    value: format!("{val:.6}"),
51                    expected: ">= 0.0".into(),
52                }
53                .into());
54            }
55            row_sum += val;
56        }
57        if (row_sum - 1.0).abs() > 1e-6 {
58            return Err(ValidationError::InvalidTPM { row: i, sum: row_sum }.into());
59        }
60    }
61    Ok(())
62}
63
64// ---------------------------------------------------------------------------
65// Core: information loss for a bipartition
66// ---------------------------------------------------------------------------
67
68/// Public wrapper for cross-module access.
69pub(crate) fn partition_information_loss_pub(
70    tpm: &TransitionMatrix,
71    state: usize,
72    partition: &Bipartition,
73    arena: &PhiArena,
74) -> f64 {
75    partition_information_loss(tpm, state, partition, arena)
76}
77
78/// Compute information loss for a given bipartition at a given state.
79///
80/// This is the core hot path: for each partition, we compute the KL divergence
81/// between the whole-system conditional distribution and the product of the
82/// marginalized sub-system distributions.
83///
84/// Optimized: uses stack-allocated index arrays (no heap alloc for n ≤ 64),
85/// and fused product-distribution + KL computation to minimize cache misses.
86fn partition_information_loss(
87    tpm: &TransitionMatrix,
88    state: usize,
89    partition: &Bipartition,
90    arena: &PhiArena,
91) -> f64 {
92    let n = tpm.n;
93    let mask = partition.mask;
94
95    // Extract set indices via bit manipulation — no Vec allocation.
96    let mut indices_a = [0usize; 64];
97    let mut indices_b = [0usize; 64];
98    let mut ka = 0usize;
99    let mut kb = 0usize;
100    for i in 0..n {
101        if mask & (1 << i) != 0 {
102            indices_a[ka] = i;
103            ka += 1;
104        } else {
105            indices_b[kb] = i;
106            kb += 1;
107        }
108    }
109    let set_a = &indices_a[..ka];
110    let set_b = &indices_b[..kb];
111
112    // Whole-system distribution P(future | state)
113    let whole_dist = &tpm.data[state * n..(state + 1) * n];
114
115    // Marginalize to get sub-TPMs
116    let tpm_a = tpm.marginalize(set_a);
117    let tpm_b = tpm.marginalize(set_b);
118
119    // Map current state to sub-system states.
120    let state_a = map_state_to_subsystem_inline(state, set_a);
121    let state_b = map_state_to_subsystem_inline(state, set_b);
122
123    let dist_a = &tpm_a.data[state_a * tpm_a.n..(state_a + 1) * tpm_a.n];
124    let dist_b = &tpm_b.data[state_b * tpm_b.n..(state_b + 1) * tpm_b.n];
125
126    // Reconstruct product distribution P(A) ⊗ P(B) in full state space.
127    let product = arena.alloc_slice::<f64>(n);
128    compute_product_distribution_fast(dist_a, set_a, dist_b, set_b, product, n);
129
130    // Normalize product distribution.
131    let sum: f64 = product.iter().sum();
132    if sum > 1e-15 {
133        let inv_sum = 1.0 / sum;
134        for p in product.iter_mut() {
135            *p *= inv_sum;
136        }
137    }
138
139    let loss = kl_divergence(whole_dist, product).max(0.0);
140    arena.reset();
141    loss
142}
143
144/// Map a global state index to a sub-system state index.
145/// Inline version using slice instead of Vec.
146#[inline(always)]
147fn map_state_to_subsystem_inline(state: usize, indices: &[usize]) -> usize {
148    let mut sub_state = 0usize;
149    for (bit, &idx) in indices.iter().enumerate() {
150        // Branchless: extract bit and shift into position.
151        sub_state |= ((state >> idx) & 1) << bit;
152    }
153    sub_state % indices.len().max(1)
154}
155
156/// Legacy wrapper for cross-module compat.
157fn map_state_to_subsystem(state: usize, indices: &[usize], _n: usize) -> usize {
158    map_state_to_subsystem_inline(state, indices)
159}
160
161/// Compute product distribution P(A) ⊗ P(B) expanded to full state space.
162/// Optimized: precompute bit extraction tables to avoid per-state inner loops.
163fn compute_product_distribution_fast(
164    dist_a: &[f64],
165    set_a: &[usize],
166    dist_b: &[f64],
167    set_b: &[usize],
168    output: &mut [f64],
169    n: usize,
170) {
171    let ka = set_a.len();
172    let kb = set_b.len();
173
174    // For small n (≤ 16), precompute lookup tables for state → sub-state mapping.
175    // This avoids the inner bit-extraction loop per global state.
176    if n <= 16 {
177        // Precompute: for each global state, what's its set_a and set_b sub-state?
178        for global_state in 0..n {
179            let sa = extract_substate(global_state, set_a);
180            let sb = extract_substate(global_state, set_b);
181            let pa = if sa < ka { unsafe { *dist_a.get_unchecked(sa) } } else { 0.0 };
182            let pb = if sb < kb { unsafe { *dist_b.get_unchecked(sb) } } else { 0.0 };
183            unsafe { *output.get_unchecked_mut(global_state) = pa * pb; }
184        }
185    } else {
186        for global_state in 0..n {
187            let sa = extract_substate(global_state, set_a);
188            let sb = extract_substate(global_state, set_b);
189            let pa = if sa < ka { dist_a[sa] } else { 0.0 };
190            let pb = if sb < kb { dist_b[sb] } else { 0.0 };
191            output[global_state] = pa * pb;
192        }
193    }
194}
195
196#[inline(always)]
197fn extract_substate(global_state: usize, indices: &[usize]) -> usize {
198    let mut sub = 0usize;
199    for (bit, &idx) in indices.iter().enumerate() {
200        sub |= ((global_state >> idx) & 1) << bit;
201    }
202    sub
203}
204
205// ---------------------------------------------------------------------------
206// Exact Φ engine
207// ---------------------------------------------------------------------------
208
209/// Exact Φ computation via exhaustive bipartition enumeration.
210///
211/// Evaluates all 2^(n-1) - 1 bipartitions. Practical for n ≤ 16.
212pub struct ExactPhiEngine;
213
214impl PhiEngine for ExactPhiEngine {
215    fn compute_phi(
216        &self,
217        tpm: &TransitionMatrix,
218        state: Option<usize>,
219        budget: &ComputeBudget,
220    ) -> Result<PhiResult, ConsciousnessError> {
221        validate_tpm(tpm)?;
222        let n = tpm.n;
223
224        if n > 20 {
225            return Err(ConsciousnessError::SystemTooLarge { n, max: 20 });
226        }
227
228        let state_idx = state.unwrap_or(0);
229        let start = Instant::now();
230        let arena = PhiArena::with_capacity(n * n * 16);
231
232        let total_partitions = (1u64 << n) - 2;
233        let mut min_phi = f64::MAX;
234        let mut best_partition = Bipartition { mask: 1, n };
235        let mut evaluated = 0u64;
236        let mut convergence = Vec::new();
237
238        for partition in BipartitionIter::new(n) {
239            if budget.max_partitions > 0 && evaluated >= budget.max_partitions {
240                break;
241            }
242            if start.elapsed() > budget.max_time {
243                break;
244            }
245
246            let loss = partition_information_loss(tpm, state_idx, &partition, &arena);
247
248            if loss < min_phi {
249                min_phi = loss;
250                best_partition = partition;
251            }
252
253            evaluated += 1;
254            if evaluated % 1000 == 0 {
255                convergence.push(min_phi);
256            }
257        }
258
259        Ok(PhiResult {
260            phi: if min_phi == f64::MAX { 0.0 } else { min_phi },
261            mip: best_partition,
262            partitions_evaluated: evaluated,
263            total_partitions,
264            algorithm: PhiAlgorithm::Exact,
265            elapsed: start.elapsed(),
266            convergence,
267        })
268    }
269
270    fn algorithm(&self) -> PhiAlgorithm {
271        PhiAlgorithm::Exact
272    }
273
274    fn estimate_cost(&self, n: usize) -> u64 {
275        (1u64 << n).saturating_sub(2)
276    }
277}
278
279// ---------------------------------------------------------------------------
280// Spectral approximation
281// ---------------------------------------------------------------------------
282
283/// Spectral Φ approximation using the Fiedler vector.
284///
285/// Computes the second-smallest eigenvalue of the Laplacian of the TPM's
286/// mutual information graph. The Fiedler vector gives an approximately
287/// optimal bipartition in O(n² log n) time.
288pub struct SpectralPhiEngine {
289    power_iterations: usize,
290}
291
292impl SpectralPhiEngine {
293    pub fn new(power_iterations: usize) -> Self {
294        Self { power_iterations }
295    }
296}
297
298impl Default for SpectralPhiEngine {
299    fn default() -> Self {
300        Self {
301            power_iterations: 100,
302        }
303    }
304}
305
306impl PhiEngine for SpectralPhiEngine {
307    fn compute_phi(
308        &self,
309        tpm: &TransitionMatrix,
310        state: Option<usize>,
311        _budget: &ComputeBudget,
312    ) -> Result<PhiResult, ConsciousnessError> {
313        validate_tpm(tpm)?;
314        let n = tpm.n;
315        let state_idx = state.unwrap_or(0);
316        let start = Instant::now();
317
318        // Build mutual information adjacency matrix using shared MI function.
319        let mi_matrix = crate::simd::build_mi_matrix(tpm.as_slice(), n);
320
321        // Build Laplacian L = D - W.
322        let mut laplacian = vec![0.0f64; n * n];
323        for i in 0..n {
324            let mut degree = 0.0;
325            for j in 0..n {
326                degree += mi_matrix[i * n + j];
327            }
328            laplacian[i * n + i] = degree;
329            for j in 0..n {
330                laplacian[i * n + j] -= mi_matrix[i * n + j];
331            }
332        }
333
334        // Power iteration for second-smallest eigenvector (Fiedler vector).
335        let fiedler = fiedler_vector(&laplacian, n, self.power_iterations);
336
337        // Partition by sign of Fiedler vector.
338        let mut mask = 0u64;
339        for i in 0..n {
340            if fiedler[i] >= 0.0 {
341                mask |= 1 << i;
342            }
343        }
344
345        // Ensure valid bipartition.
346        let full = (1u64 << n) - 1;
347        if mask == 0 {
348            mask = 1;
349        }
350        if mask == full {
351            mask = full - 1;
352        }
353
354        let partition = Bipartition { mask, n };
355        let arena = PhiArena::with_capacity(n * 16);
356        let phi = partition_information_loss(tpm, state_idx, &partition, &arena);
357
358        Ok(PhiResult {
359            phi,
360            mip: partition,
361            partitions_evaluated: 1,
362            total_partitions: (1u64 << n) - 2,
363            algorithm: PhiAlgorithm::Spectral,
364            elapsed: start.elapsed(),
365            convergence: vec![phi],
366        })
367    }
368
369    fn algorithm(&self) -> PhiAlgorithm {
370        PhiAlgorithm::Spectral
371    }
372
373    fn estimate_cost(&self, n: usize) -> u64 {
374        (n * n * self.power_iterations) as u64
375    }
376}
377
378/// Compute Fiedler vector (second-smallest eigenvector of Laplacian).
379/// Uses inverse power iteration with deflation.
380fn fiedler_vector(laplacian: &[f64], n: usize, max_iter: usize) -> Vec<f64> {
381    use rand::rngs::StdRng;
382    use rand::{Rng, SeedableRng};
383
384    let mut rng = StdRng::seed_from_u64(42);
385
386    // Random initial vector, orthogonal to the constant vector.
387    let mut v: Vec<f64> = (0..n).map(|_| rng.gen::<f64>() - 0.5).collect();
388
389    // Orthogonalize against the constant eigenvector.
390    let inv_n = 1.0 / n as f64;
391    let mean: f64 = v.iter().sum::<f64>() * inv_n;
392    for vi in &mut v {
393        *vi -= mean;
394    }
395
396    // Normalize.
397    let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
398    if norm > 1e-15 {
399        for vi in &mut v {
400            *vi /= norm;
401        }
402    }
403
404    // Power iteration on (mu*I - L) to find second-smallest eigenvalue.
405    // Use shifted inverse iteration with convergence-based early exit.
406    let mut w = vec![0.0f64; n];
407
408    // Estimate largest eigenvalue for shift.
409    let mu = estimate_largest_eigenvalue(laplacian, n);
410    let mut prev_eigenvalue = 0.0f64;
411
412    for _ in 0..max_iter {
413        // w = (mu*I - L) * v
414        for i in 0..n {
415            let row = &laplacian[i * n..(i + 1) * n];
416            let mut sum = mu * v[i];
417            for j in 0..n {
418                sum -= row[j] * v[j];
419            }
420            w[i] = sum;
421        }
422
423        // Deflate: remove component along constant vector.
424        let mean: f64 = w.iter().sum::<f64>() * inv_n;
425        for wi in &mut w {
426            *wi -= mean;
427        }
428
429        // Normalize.
430        let norm: f64 = w.iter().map(|x| x * x).sum::<f64>().sqrt();
431        if norm < 1e-15 {
432            break;
433        }
434        let inv_norm = 1.0 / norm;
435        for i in 0..n {
436            v[i] = w[i] * inv_norm;
437        }
438
439        // Convergence check: Rayleigh quotient v^T L v.
440        let mut eigenvalue = 0.0f64;
441        for i in 0..n {
442            let row = &laplacian[i * n..(i + 1) * n];
443            let mut lv_i = 0.0;
444            for j in 0..n {
445                lv_i += row[j] * v[j];
446            }
447            eigenvalue += v[i] * lv_i;
448        }
449        if (eigenvalue - prev_eigenvalue).abs() < 1e-10 {
450            break;
451        }
452        prev_eigenvalue = eigenvalue;
453    }
454
455    v
456}
457
458fn estimate_largest_eigenvalue(matrix: &[f64], n: usize) -> f64 {
459    // Gershgorin circle theorem: max eigenvalue ≤ max row sum of abs values.
460    let mut max_row_sum = 0.0f64;
461    for i in 0..n {
462        let mut row_sum = 0.0;
463        for j in 0..n {
464            row_sum += matrix[i * n + j].abs();
465        }
466        max_row_sum = max_row_sum.max(row_sum);
467    }
468    max_row_sum
469}
470
471// ---------------------------------------------------------------------------
472// Stochastic sampling
473// ---------------------------------------------------------------------------
474
475/// Stochastic Φ approximation via random partition sampling.
476///
477/// Samples random bipartitions and tracks the minimum information loss.
478/// Runs in O(k · n²) where k is the sample count.
479pub struct StochasticPhiEngine {
480    samples: u64,
481    seed: u64,
482}
483
484impl StochasticPhiEngine {
485    pub fn new(samples: u64, seed: u64) -> Self {
486        Self { samples, seed }
487    }
488}
489
490impl PhiEngine for StochasticPhiEngine {
491    fn compute_phi(
492        &self,
493        tpm: &TransitionMatrix,
494        state: Option<usize>,
495        budget: &ComputeBudget,
496    ) -> Result<PhiResult, ConsciousnessError> {
497        validate_tpm(tpm)?;
498        let n = tpm.n;
499        let state_idx = state.unwrap_or(0);
500        let start = Instant::now();
501        let arena = PhiArena::with_capacity(n * n * 16);
502
503        use rand::rngs::StdRng;
504        use rand::{Rng, SeedableRng};
505        let mut rng = StdRng::seed_from_u64(self.seed);
506
507        let total_partitions = (1u64 << n) - 2;
508        let effective_samples = self.samples.min(total_partitions);
509        let mut min_phi = f64::MAX;
510        let mut best_partition = Bipartition { mask: 1, n };
511        let mut convergence = Vec::new();
512
513        for i in 0..effective_samples {
514            if start.elapsed() > budget.max_time {
515                break;
516            }
517
518            // Random valid bipartition.
519            let mask = loop {
520                let m = rng.gen::<u64>() & ((1u64 << n) - 1);
521                if m != 0 && m != (1u64 << n) - 1 {
522                    break m;
523                }
524            };
525
526            let partition = Bipartition { mask, n };
527            let loss = partition_information_loss(tpm, state_idx, &partition, &arena);
528
529            if loss < min_phi {
530                min_phi = loss;
531                best_partition = partition;
532            }
533
534            if i % 100 == 0 {
535                convergence.push(min_phi);
536            }
537        }
538
539        Ok(PhiResult {
540            phi: if min_phi == f64::MAX { 0.0 } else { min_phi },
541            mip: best_partition,
542            partitions_evaluated: effective_samples,
543            total_partitions,
544            algorithm: PhiAlgorithm::Stochastic,
545            elapsed: start.elapsed(),
546            convergence,
547        })
548    }
549
550    fn algorithm(&self) -> PhiAlgorithm {
551        PhiAlgorithm::Stochastic
552    }
553
554    fn estimate_cost(&self, n: usize) -> u64 {
555        self.samples * (n * n) as u64
556    }
557}
558
559// ---------------------------------------------------------------------------
560// Greedy bisection
561// ---------------------------------------------------------------------------
562
563/// Greedy bisection Φ approximation.
564///
565/// Starts from the Fiedler-based spectral partition and greedily swaps
566/// elements between sets A and B to minimize information loss. Each swap
567/// is accepted only if it reduces Φ. Converges to a local minimum.
568///
569/// Complexity: O(n³) — at most n passes of n element swaps.
570pub struct GreedyBisectionPhiEngine {
571    max_passes: usize,
572}
573
574impl GreedyBisectionPhiEngine {
575    pub fn new(max_passes: usize) -> Self {
576        Self { max_passes }
577    }
578}
579
580impl Default for GreedyBisectionPhiEngine {
581    fn default() -> Self {
582        Self { max_passes: 50 }
583    }
584}
585
586impl PhiEngine for GreedyBisectionPhiEngine {
587    fn compute_phi(
588        &self,
589        tpm: &TransitionMatrix,
590        state: Option<usize>,
591        budget: &ComputeBudget,
592    ) -> Result<PhiResult, ConsciousnessError> {
593        validate_tpm(tpm)?;
594        let n = tpm.n;
595        let state_idx = state.unwrap_or(0);
596        let start = Instant::now();
597        let arena = PhiArena::with_capacity(n * n * 16);
598
599        let total_partitions = (1u64 << n) - 2;
600        let mut evaluated = 0u64;
601        let mut convergence = Vec::new();
602
603        // Start from spectral partition as seed.
604        let spectral = SpectralPhiEngine::default();
605        let seed_result = spectral.compute_phi(tpm, state, budget)?;
606        let mut best_mask = seed_result.mip.mask;
607        let mut best_phi = seed_result.phi;
608        evaluated += 1;
609        convergence.push(best_phi);
610
611        // Greedy swap: try moving each element between sets.
612        for _pass in 0..self.max_passes {
613            if start.elapsed() > budget.max_time {
614                break;
615            }
616
617            let mut improved = false;
618
619            for elem in 0..n {
620                if start.elapsed() > budget.max_time {
621                    break;
622                }
623
624                // Flip element's membership.
625                let new_mask = best_mask ^ (1 << elem);
626                let full = (1u64 << n) - 1;
627                if new_mask == 0 || new_mask == full {
628                    continue; // Invalid partition.
629                }
630
631                let partition = Bipartition { mask: new_mask, n };
632                let loss = partition_information_loss(tpm, state_idx, &partition, &arena);
633                evaluated += 1;
634
635                if loss < best_phi {
636                    best_phi = loss;
637                    best_mask = new_mask;
638                    improved = true;
639                }
640
641                if evaluated % 100 == 0 {
642                    convergence.push(best_phi);
643                }
644            }
645
646            if !improved {
647                break; // Local minimum reached.
648            }
649        }
650
651        convergence.push(best_phi);
652
653        Ok(PhiResult {
654            phi: best_phi,
655            mip: Bipartition { mask: best_mask, n },
656            partitions_evaluated: evaluated,
657            total_partitions,
658            algorithm: PhiAlgorithm::GreedyBisection,
659            elapsed: start.elapsed(),
660            convergence,
661        })
662    }
663
664    fn algorithm(&self) -> PhiAlgorithm {
665        PhiAlgorithm::GreedyBisection
666    }
667
668    fn estimate_cost(&self, n: usize) -> u64 {
669        (n * n * self.max_passes) as u64
670    }
671}
672
673// ---------------------------------------------------------------------------
674// Hierarchical approximation
675// ---------------------------------------------------------------------------
676
677/// Hierarchical Φ approximation for large systems.
678///
679/// Recursively bisects the system into subsystems, computes Φ for each,
680/// then estimates global Φ as the minimum sub-system Φ. This is a
681/// conservative lower bound (global Φ ≤ min(sub-Φ)).
682///
683/// Works for arbitrarily large systems by recursively halving until
684/// subsystems are small enough for exact computation.
685///
686/// Complexity: O(n² log n) — log(n) levels × n² per level.
687pub struct HierarchicalPhiEngine {
688    /// Maximum subsystem size for exact computation.
689    pub exact_threshold: usize,
690}
691
692impl HierarchicalPhiEngine {
693    pub fn new(exact_threshold: usize) -> Self {
694        Self { exact_threshold }
695    }
696}
697
698impl Default for HierarchicalPhiEngine {
699    fn default() -> Self {
700        Self { exact_threshold: 12 }
701    }
702}
703
704impl PhiEngine for HierarchicalPhiEngine {
705    fn compute_phi(
706        &self,
707        tpm: &TransitionMatrix,
708        state: Option<usize>,
709        budget: &ComputeBudget,
710    ) -> Result<PhiResult, ConsciousnessError> {
711        validate_tpm(tpm)?;
712        let n = tpm.n;
713        let state_idx = state.unwrap_or(0);
714        let start = Instant::now();
715
716        let total_partitions = (1u64 << n) - 2;
717        let mut total_evaluated = 0u64;
718        let mut convergence = Vec::new();
719
720        // If small enough, delegate to exact.
721        if n <= self.exact_threshold {
722            return ExactPhiEngine.compute_phi(tpm, state, budget);
723        }
724
725        // Spectral bisection to split the system.
726        let mi_graph = build_mi_graph(tpm);
727        let fiedler = fiedler_vector(&mi_graph, n, 100);
728
729        let mut group_a: Vec<usize> = Vec::new();
730        let mut group_b: Vec<usize> = Vec::new();
731        for i in 0..n {
732            if fiedler[i] >= 0.0 {
733                group_a.push(i);
734            } else {
735                group_b.push(i);
736            }
737        }
738
739        // Ensure both groups are non-empty.
740        if group_a.is_empty() {
741            group_a.push(group_b.pop().unwrap());
742        }
743        if group_b.is_empty() {
744            group_b.push(group_a.pop().unwrap());
745        }
746
747        // Compute information loss for this top-level split.
748        let top_mask: u64 = group_a.iter().fold(0u64, |acc, &i| acc | (1 << i));
749        let top_partition = Bipartition { mask: top_mask, n };
750        let arena = PhiArena::with_capacity(n * n * 16);
751        let top_loss = partition_information_loss(tpm, state_idx, &top_partition, &arena);
752        total_evaluated += 1;
753        convergence.push(top_loss);
754
755        // Recursively compute Φ for sub-systems if they're large enough.
756        let mut min_phi = top_loss;
757        let mut best_partition = top_partition;
758
759        for group in [&group_a, &group_b] {
760            if group.len() >= 2 && start.elapsed() < budget.max_time {
761                let sub_tpm = tpm.marginalize(group);
762                let sub_state = map_state_to_subsystem(state_idx, group, n);
763
764                let sub_budget = ComputeBudget {
765                    max_time: budget.max_time.saturating_sub(start.elapsed()),
766                    max_partitions: budget.max_partitions.saturating_sub(total_evaluated),
767                    ..*budget
768                };
769
770                let sub_result = if sub_tpm.n <= self.exact_threshold {
771                    ExactPhiEngine.compute_phi(&sub_tpm, Some(sub_state), &sub_budget)
772                } else {
773                    self.compute_phi(&sub_tpm, Some(sub_state), &sub_budget)
774                };
775
776                if let Ok(result) = sub_result {
777                    total_evaluated += result.partitions_evaluated;
778                    if result.phi < min_phi {
779                        min_phi = result.phi;
780                        // Map sub-partition back to global indices.
781                        let sub_mask = result.mip.mask;
782                        let mut global_mask = 0u64;
783                        for (bit, &global_idx) in group.iter().enumerate() {
784                            if sub_mask & (1 << bit) != 0 {
785                                global_mask |= 1 << global_idx;
786                            }
787                        }
788                        // Fill in the other group's elements.
789                        let other_group = if std::ptr::eq(group, &group_a) { &group_b } else { &group_a };
790                        for &idx in other_group {
791                            global_mask |= 1 << idx;
792                        }
793                        let full = (1u64 << n) - 1;
794                        if global_mask != 0 && global_mask != full {
795                            best_partition = Bipartition { mask: global_mask, n };
796                        }
797                    }
798                    convergence.push(min_phi);
799                }
800            }
801        }
802
803        Ok(PhiResult {
804            phi: min_phi,
805            mip: best_partition,
806            partitions_evaluated: total_evaluated,
807            total_partitions,
808            algorithm: PhiAlgorithm::Hierarchical,
809            elapsed: start.elapsed(),
810            convergence,
811        })
812    }
813
814    fn algorithm(&self) -> PhiAlgorithm {
815        PhiAlgorithm::Hierarchical
816    }
817
818    fn estimate_cost(&self, n: usize) -> u64 {
819        // Roughly O(n² log n).
820        let log_n = (n as f64).log2().ceil() as u64;
821        (n * n) as u64 * log_n
822    }
823}
824
825/// Build MI Laplacian from TPM using shared MI computation.
826fn build_mi_graph(tpm: &TransitionMatrix) -> Vec<f64> {
827    let n = tpm.n;
828    let mi_matrix = crate::simd::build_mi_matrix(tpm.as_slice(), n);
829
830    // Convert to Laplacian: L = D - W.
831    let mut laplacian = vec![0.0f64; n * n];
832    for i in 0..n {
833        let mut degree = 0.0;
834        for j in 0..n {
835            degree += mi_matrix[i * n + j];
836        }
837        laplacian[i * n + i] = degree;
838        for j in 0..n {
839            laplacian[i * n + j] -= mi_matrix[i * n + j];
840        }
841    }
842
843    laplacian
844}
845
846// ---------------------------------------------------------------------------
847// Auto-selecting engine
848// ---------------------------------------------------------------------------
849
850/// Automatically selects the best algorithm based on system size.
851///
852/// Algorithm selection tiers:
853/// - n ≤ 16 (exact): ExactPhiEngine (exhaustive, guaranteed optimal)
854/// - 16 < n ≤ 25 (near-exact): GeoMIP (pruned exhaustive, 100-300x faster)
855/// - 25 < n ≤ 100 (fast approx): GreedyBisection (spectral seed + local search)
856/// - 100 < n ≤ 1000 (spectral): SpectralPhiEngine (Fiedler vector)
857/// - n > 1000 (large-scale): HierarchicalPhiEngine (recursive decomposition)
858pub fn auto_compute_phi(
859    tpm: &TransitionMatrix,
860    state: Option<usize>,
861    budget: &ComputeBudget,
862) -> Result<PhiResult, ConsciousnessError> {
863    let n = tpm.n;
864    if n <= 16 && budget.approximation_ratio >= 0.99 {
865        ExactPhiEngine.compute_phi(tpm, state, budget)
866    } else if n <= 25 && budget.approximation_ratio >= 0.95 {
867        // GeoMIP is near-exact and handles up to n=25 efficiently.
868        crate::geomip::GeoMipPhiEngine::default().compute_phi(tpm, state, budget)
869    } else if n <= 100 {
870        GreedyBisectionPhiEngine::default().compute_phi(tpm, state, budget)
871    } else if n <= 1000 {
872        SpectralPhiEngine::default().compute_phi(tpm, state, budget)
873    } else {
874        HierarchicalPhiEngine::default().compute_phi(tpm, state, budget)
875    }
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881
882    /// Create a simple 2-element AND gate TPM.
883    fn and_gate_tpm() -> TransitionMatrix {
884        // 2 elements, 4 states (00, 01, 10, 11)
885        // AND gate: output is 1 only when both inputs are 1
886        #[rustfmt::skip]
887        let data = vec![
888            0.5, 0.25, 0.25, 0.0,   // from 00
889            0.5, 0.25, 0.25, 0.0,   // from 01
890            0.5, 0.25, 0.25, 0.0,   // from 10
891            0.0, 0.0,  0.0,  1.0,   // from 11
892        ];
893        TransitionMatrix::new(4, data)
894    }
895
896    /// Create a disconnected system (Φ should be 0).
897    fn disconnected_tpm() -> TransitionMatrix {
898        #[rustfmt::skip]
899        let data = vec![
900            0.5, 0.5, 0.0, 0.0,
901            0.5, 0.5, 0.0, 0.0,
902            0.0, 0.0, 0.5, 0.5,
903            0.0, 0.0, 0.5, 0.5,
904        ];
905        TransitionMatrix::new(4, data)
906    }
907
908    #[test]
909    fn exact_phi_disconnected_is_zero() {
910        let tpm = disconnected_tpm();
911        let budget = ComputeBudget::exact();
912        let result = ExactPhiEngine.compute_phi(&tpm, Some(0), &budget).unwrap();
913        assert!(
914            result.phi < 1e-6,
915            "disconnected system should have Φ ≈ 0, got {}",
916            result.phi
917        );
918    }
919
920    #[test]
921    fn exact_phi_and_gate_positive() {
922        let tpm = and_gate_tpm();
923        let budget = ComputeBudget::exact();
924        let result = ExactPhiEngine.compute_phi(&tpm, Some(3), &budget).unwrap();
925        assert!(
926            result.phi >= 0.0,
927            "AND gate at state 11 should have Φ ≥ 0, got {}",
928            result.phi
929        );
930    }
931
932    #[test]
933    fn spectral_phi_runs() {
934        let tpm = and_gate_tpm();
935        let budget = ComputeBudget::fast();
936        let result = SpectralPhiEngine::default()
937            .compute_phi(&tpm, Some(0), &budget)
938            .unwrap();
939        assert!(result.phi >= 0.0);
940        assert_eq!(result.algorithm, PhiAlgorithm::Spectral);
941    }
942
943    #[test]
944    fn stochastic_phi_runs() {
945        let tpm = and_gate_tpm();
946        let budget = ComputeBudget::fast();
947        let result = StochasticPhiEngine::new(100, 42)
948            .compute_phi(&tpm, Some(0), &budget)
949            .unwrap();
950        assert!(result.phi >= 0.0);
951        assert_eq!(result.algorithm, PhiAlgorithm::Stochastic);
952    }
953
954    #[test]
955    fn auto_selects_exact_for_small() {
956        let tpm = and_gate_tpm();
957        let budget = ComputeBudget::exact();
958        let result = auto_compute_phi(&tpm, Some(0), &budget).unwrap();
959        assert_eq!(result.algorithm, PhiAlgorithm::Exact);
960    }
961
962    #[test]
963    fn validation_rejects_bad_tpm() {
964        let data = vec![0.5, 0.5, 0.3, 0.3]; // row 1 doesn't sum to 1
965        let tpm = TransitionMatrix::new(2, data);
966        let budget = ComputeBudget::exact();
967        let result = ExactPhiEngine.compute_phi(&tpm, Some(0), &budget);
968        assert!(result.is_err());
969    }
970
971    #[test]
972    fn validation_rejects_single_element() {
973        let tpm = TransitionMatrix::new(1, vec![1.0]);
974        let budget = ComputeBudget::exact();
975        let result = ExactPhiEngine.compute_phi(&tpm, Some(0), &budget);
976        assert!(result.is_err());
977    }
978
979    #[test]
980    fn greedy_bisection_runs() {
981        let tpm = and_gate_tpm();
982        let budget = ComputeBudget::fast();
983        let result = GreedyBisectionPhiEngine::default()
984            .compute_phi(&tpm, Some(0), &budget)
985            .unwrap();
986        assert!(result.phi >= 0.0);
987        assert_eq!(result.algorithm, PhiAlgorithm::GreedyBisection);
988    }
989
990    #[test]
991    fn greedy_bisection_disconnected_near_zero() {
992        let tpm = disconnected_tpm();
993        let budget = ComputeBudget::exact();
994        let result = GreedyBisectionPhiEngine::default()
995            .compute_phi(&tpm, Some(0), &budget)
996            .unwrap();
997        assert!(
998            result.phi < 1e-4,
999            "greedy bisection on disconnected should be ≈ 0, got {}",
1000            result.phi
1001        );
1002    }
1003
1004    #[test]
1005    fn hierarchical_runs() {
1006        let tpm = and_gate_tpm();
1007        let budget = ComputeBudget::fast();
1008        // Use low threshold to force hierarchical path even for small system.
1009        let engine = HierarchicalPhiEngine::new(2);
1010        let result = engine.compute_phi(&tpm, Some(0), &budget).unwrap();
1011        assert!(result.phi >= 0.0);
1012        assert_eq!(result.algorithm, PhiAlgorithm::Hierarchical);
1013    }
1014
1015    #[test]
1016    fn hierarchical_falls_through_to_exact() {
1017        let tpm = and_gate_tpm();
1018        let budget = ComputeBudget::exact();
1019        // Default threshold (12) > n=4, so it should use exact.
1020        let result = HierarchicalPhiEngine::default()
1021            .compute_phi(&tpm, Some(0), &budget)
1022            .unwrap();
1023        // Falls through to exact, so algorithm should be Exact.
1024        assert_eq!(result.algorithm, PhiAlgorithm::Exact);
1025    }
1026
1027    #[test]
1028    fn auto_selects_geomip_for_medium() {
1029        // Create an 8x8 TPM (n > 16 doesn't apply, but we can test the tiers).
1030        // For n=4 with exact budget, should still pick exact.
1031        let tpm = and_gate_tpm();
1032        let budget = ComputeBudget {
1033            approximation_ratio: 0.95,
1034            ..ComputeBudget::fast()
1035        };
1036        let result = auto_compute_phi(&tpm, Some(0), &budget).unwrap();
1037        // n=4 with ratio >= 0.99 in fast budget (0.9), so won't hit exact.
1038        // ratio = 0.95 >= 0.95 and n=4 <= 25 → GeoMIP.
1039        assert_eq!(result.algorithm, PhiAlgorithm::GeoMIP);
1040    }
1041}