1use 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
32pub(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
64pub(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
78fn 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 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 let whole_dist = &tpm.data[state * n..(state + 1) * n];
114
115 let tpm_a = tpm.marginalize(set_a);
117 let tpm_b = tpm.marginalize(set_b);
118
119 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 let product = arena.alloc_slice::<f64>(n);
128 compute_product_distribution_fast(dist_a, set_a, dist_b, set_b, product, n);
129
130 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#[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 sub_state |= ((state >> idx) & 1) << bit;
152 }
153 sub_state % indices.len().max(1)
154}
155
156fn map_state_to_subsystem(state: usize, indices: &[usize], _n: usize) -> usize {
158 map_state_to_subsystem_inline(state, indices)
159}
160
161fn 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 if n <= 16 {
177 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
205pub 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
279pub 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 let mi_matrix = crate::simd::build_mi_matrix(tpm.as_slice(), n);
320
321 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 let fiedler = fiedler_vector(&laplacian, n, self.power_iterations);
336
337 let mut mask = 0u64;
339 for i in 0..n {
340 if fiedler[i] >= 0.0 {
341 mask |= 1 << i;
342 }
343 }
344
345 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
378fn 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 let mut v: Vec<f64> = (0..n).map(|_| rng.gen::<f64>() - 0.5).collect();
388
389 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 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 let mut w = vec![0.0f64; n];
407
408 let mu = estimate_largest_eigenvalue(laplacian, n);
410 let mut prev_eigenvalue = 0.0f64;
411
412 for _ in 0..max_iter {
413 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 let mean: f64 = w.iter().sum::<f64>() * inv_n;
425 for wi in &mut w {
426 *wi -= mean;
427 }
428
429 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 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 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
471pub 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 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
559pub 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 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 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 let new_mask = best_mask ^ (1 << elem);
626 let full = (1u64 << n) - 1;
627 if new_mask == 0 || new_mask == full {
628 continue; }
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; }
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
673pub struct HierarchicalPhiEngine {
688 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 n <= self.exact_threshold {
722 return ExactPhiEngine.compute_phi(tpm, state, budget);
723 }
724
725 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 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 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 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 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 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 let log_n = (n as f64).log2().ceil() as u64;
821 (n * n) as u64 * log_n
822 }
823}
824
825fn 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 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
846pub 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 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 fn and_gate_tpm() -> TransitionMatrix {
884 #[rustfmt::skip]
887 let data = vec![
888 0.5, 0.25, 0.25, 0.0, 0.5, 0.25, 0.25, 0.0, 0.5, 0.25, 0.25, 0.0, 0.0, 0.0, 0.0, 1.0, ];
893 TransitionMatrix::new(4, data)
894 }
895
896 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]; 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 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 let result = HierarchicalPhiEngine::default()
1021 .compute_phi(&tpm, Some(0), &budget)
1022 .unwrap();
1023 assert_eq!(result.algorithm, PhiAlgorithm::Exact);
1025 }
1026
1027 #[test]
1028 fn auto_selects_geomip_for_medium() {
1029 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 assert_eq!(result.algorithm, PhiAlgorithm::GeoMIP);
1040 }
1041}