Skip to main content

m1nd_core/
lib.rs

1#![allow(unused)]
2
3pub mod activation;
4pub mod builder;
5pub mod counterfactual;
6pub mod domain;
7pub mod error;
8pub mod graph;
9pub mod plasticity;
10pub mod query;
11pub mod resonance;
12pub mod seed;
13pub mod semantic;
14pub mod snapshot;
15pub mod temporal;
16pub mod topology;
17pub mod types;
18pub mod xlr;
19
20#[cfg(test)]
21mod tests {
22    use crate::activation::*;
23    use crate::counterfactual::*;
24    use crate::error::*;
25    use crate::graph::*;
26    use crate::plasticity::*;
27    use crate::query::*;
28    use crate::resonance::*;
29    use crate::seed::*;
30    use crate::temporal::*;
31    use crate::topology::*;
32    use crate::types::*;
33    use crate::xlr::*;
34
35    // ===== STEP-001: types.rs tests =====
36
37    #[test]
38    #[cfg(debug_assertions)]
39    #[should_panic(expected = "non-finite")]
40    fn finite_f32_rejects_nan() {
41        // debug_assert! fires in test (debug) builds only
42        let _f = FiniteF32::new(f32::NAN);
43    }
44
45    #[test]
46    #[cfg(debug_assertions)]
47    #[should_panic(expected = "non-finite")]
48    fn finite_f32_rejects_inf() {
49        // debug_assert! fires in test (debug) builds only
50        let _f = FiniteF32::new(f32::INFINITY);
51    }
52
53    #[test]
54    fn finite_f32_accepts_normal() {
55        let f = FiniteF32::new(1.0);
56        assert_eq!(f.get(), 1.0);
57    }
58
59    #[test]
60    fn finite_f32_total_order() {
61        let a = FiniteF32::new(0.5);
62        let b = FiniteF32::new(0.7);
63        assert!(a < b);
64        assert_eq!(a.cmp(&a), std::cmp::Ordering::Equal);
65    }
66
67    #[test]
68    fn pos_f32_rejects_zero() {
69        assert!(PosF32::new(0.0).is_none());
70    }
71
72    #[test]
73    fn pos_f32_rejects_negative() {
74        assert!(PosF32::new(-1.0).is_none());
75    }
76
77    #[test]
78    fn pos_f32_accepts_positive() {
79        assert!(PosF32::new(0.001).is_some());
80    }
81
82    #[test]
83    fn learning_rate_range() {
84        assert!(LearningRate::new(0.0).is_none());
85        assert!(LearningRate::new(1.1).is_none());
86        assert!(LearningRate::new(0.5).is_some());
87        assert!(LearningRate::new(1.0).is_some());
88    }
89
90    #[test]
91    fn decay_factor_range() {
92        assert!(DecayFactor::new(0.0).is_none());
93        assert!(DecayFactor::new(1.1).is_none());
94        assert!(DecayFactor::new(0.55).is_some());
95    }
96
97    // ===== STEP-002: error.rs tests =====
98
99    #[test]
100    fn error_display_empty_graph() {
101        let e = M1ndError::EmptyGraph;
102        let msg = format!("{e}");
103        assert!(msg.contains("empty"), "Expected 'empty' in: {msg}");
104    }
105
106    #[test]
107    fn error_display_dangling_edge() {
108        let e = M1ndError::DanglingEdge {
109            edge: EdgeIdx::new(0),
110            node: NodeId::new(999),
111        };
112        let msg = format!("{e}");
113        assert!(msg.contains("dangling"));
114    }
115
116    // ===== STEP-003: graph.rs tests =====
117
118    fn build_test_graph() -> Graph {
119        let mut g = Graph::new();
120        g.add_node(
121            "mat_pe",
122            "Polietileno",
123            NodeType::Material,
124            &["plastico", "polimero"],
125            1000.0,
126            0.5,
127        )
128        .unwrap();
129        g.add_node(
130            "mat_pp",
131            "Polipropileno",
132            NodeType::Material,
133            &["plastico", "polimero"],
134            900.0,
135            0.3,
136        )
137        .unwrap();
138        g.add_node(
139            "mat_abs",
140            "ABS",
141            NodeType::Material,
142            &["plastico"],
143            800.0,
144            0.2,
145        )
146        .unwrap();
147        g.add_node(
148            "proc_inj",
149            "Injecao",
150            NodeType::Process,
151            &["processo"],
152            700.0,
153            0.4,
154        )
155        .unwrap();
156        g.add_node(
157            "proc_ext",
158            "Extrusao",
159            NodeType::Process,
160            &["processo"],
161            600.0,
162            0.1,
163        )
164        .unwrap();
165        g.add_node(
166            "prod_garrafa",
167            "Garrafa",
168            NodeType::Product,
169            &["produto"],
170            500.0,
171            0.6,
172        )
173        .unwrap();
174
175        g.add_edge(
176            NodeId::new(0),
177            NodeId::new(3),
178            "feeds_into",
179            FiniteF32::new(0.8),
180            EdgeDirection::Forward,
181            false,
182            FiniteF32::new(0.5),
183        )
184        .unwrap();
185        g.add_edge(
186            NodeId::new(1),
187            NodeId::new(3),
188            "feeds_into",
189            FiniteF32::new(0.7),
190            EdgeDirection::Forward,
191            false,
192            FiniteF32::new(0.3),
193        )
194        .unwrap();
195        g.add_edge(
196            NodeId::new(2),
197            NodeId::new(4),
198            "feeds_into",
199            FiniteF32::new(0.6),
200            EdgeDirection::Forward,
201            false,
202            FiniteF32::new(0.2),
203        )
204        .unwrap();
205        g.add_edge(
206            NodeId::new(3),
207            NodeId::new(5),
208            "produces",
209            FiniteF32::new(0.9),
210            EdgeDirection::Forward,
211            false,
212            FiniteF32::new(0.8),
213        )
214        .unwrap();
215        g.add_edge(
216            NodeId::new(0),
217            NodeId::new(1),
218            "similar_to",
219            FiniteF32::new(0.5),
220            EdgeDirection::Bidirectional,
221            false,
222            FiniteF32::ZERO,
223        )
224        .unwrap();
225
226        g.finalize().unwrap();
227        g
228    }
229
230    #[test]
231    fn graph_add_node_and_resolve() {
232        let mut g = Graph::new();
233        let n1 = g
234            .add_node("ext1", "Label1", NodeType::Module, &[], 0.0, 0.0)
235            .unwrap();
236        assert_eq!(n1, NodeId::new(0));
237        assert_eq!(g.num_nodes(), 1);
238        assert_eq!(g.resolve_id("ext1"), Some(NodeId::new(0)));
239    }
240
241    #[test]
242    fn graph_add_node_duplicate() {
243        let mut g = Graph::new();
244        g.add_node("ext1", "label1", NodeType::Module, &[], 0.0, 0.0)
245            .unwrap();
246        let n2 = g.add_node("ext1", "label2", NodeType::Module, &[], 0.0, 0.0);
247        assert!(matches!(n2, Err(M1ndError::DuplicateNode(_))));
248    }
249
250    #[test]
251    fn graph_add_edge_dangling() {
252        let mut g = Graph::new();
253        let n1 = g
254            .add_node("a", "A", NodeType::Module, &[], 0.0, 0.0)
255            .unwrap();
256        let bad = NodeId::new(999);
257        let e = g.add_edge(
258            n1,
259            bad,
260            "calls",
261            FiniteF32::ONE,
262            EdgeDirection::Forward,
263            false,
264            FiniteF32::ZERO,
265        );
266        assert!(matches!(e, Err(M1ndError::DanglingEdge { .. })));
267    }
268
269    #[test]
270    fn graph_finalize_builds_csr() {
271        let g = build_test_graph();
272        assert!(g.finalized);
273        assert_eq!(g.num_nodes(), 6);
274        assert!(g.num_edges() > 0);
275        assert!(g.pagerank_computed);
276    }
277
278    #[test]
279    fn graph_pagerank_computed() {
280        let g = build_test_graph();
281        // At least one node should have non-zero pagerank
282        let max_pr = (0..g.num_nodes() as usize)
283            .map(|i| g.nodes.pagerank[i].get())
284            .fold(0.0f32, f32::max);
285        assert!(max_pr > 0.0, "PageRank should have non-zero values");
286    }
287
288    // ===== STEP-004: seed.rs tests =====
289
290    #[test]
291    fn seed_finder_exact_match() {
292        let g = build_test_graph();
293        let seeds = SeedFinder::find_seeds(&g, "Polietileno", 200).unwrap();
294        assert!(!seeds.is_empty(), "Should find at least one seed");
295        assert_eq!(
296            seeds[0].1.get(),
297            1.0,
298            "Exact match should have relevance 1.0"
299        );
300    }
301
302    #[test]
303    fn seed_finder_tag_match() {
304        let g = build_test_graph();
305        let seeds = SeedFinder::find_seeds(&g, "plastico", 200).unwrap();
306        assert!(!seeds.is_empty(), "Should find seeds by tag");
307    }
308
309    #[test]
310    fn seed_finder_caps_at_max() {
311        let g = build_test_graph();
312        let seeds = SeedFinder::find_seeds(&g, "a", 2).unwrap();
313        assert!(seeds.len() <= 2);
314    }
315
316    // ===== STEP-005: activation.rs tests =====
317
318    #[test]
319    fn bloom_filter_basic() {
320        let mut bf = BloomFilter::with_capacity(1000, 0.01);
321        bf.insert(NodeId::new(42));
322        assert!(bf.probably_contains(NodeId::new(42)));
323        // 99 is likely not in the filter (1% FPR)
324        // Not asserting false because Bloom can have false positives
325    }
326
327    #[test]
328    fn wavefront_single_seed() {
329        let g = build_test_graph();
330        let engine = WavefrontEngine::new();
331        let config = PropagationConfig::default();
332        let seeds = vec![(NodeId::new(0), FiniteF32::ONE)];
333        let result = engine.propagate(&g, &seeds, &config).unwrap();
334        assert!(
335            !result.scores.is_empty(),
336            "Wavefront should activate at least one node"
337        );
338        assert!(result.scores[0].1.get() > 0.0);
339    }
340
341    #[test]
342    fn heap_single_seed() {
343        let g = build_test_graph();
344        let engine = HeapEngine::new();
345        let config = PropagationConfig::default();
346        let seeds = vec![(NodeId::new(0), FiniteF32::ONE)];
347        let result = engine.propagate(&g, &seeds, &config).unwrap();
348        assert!(
349            !result.scores.is_empty(),
350            "Heap should activate at least one node"
351        );
352    }
353
354    #[test]
355    fn hybrid_delegates_correctly() {
356        let g = build_test_graph();
357        let engine = HybridEngine::new();
358        let config = PropagationConfig::default();
359        let seeds = vec![(NodeId::new(0), FiniteF32::ONE)];
360        let result = engine.propagate(&g, &seeds, &config).unwrap();
361        assert!(!result.scores.is_empty());
362    }
363
364    #[test]
365    fn activation_empty_seeds_returns_empty() {
366        let g = build_test_graph();
367        let engine = WavefrontEngine::new();
368        let config = PropagationConfig::default();
369        let result = engine.propagate(&g, &[], &config).unwrap();
370        assert!(result.scores.is_empty());
371    }
372
373    #[test]
374    fn merge_dimensions_resonance_bonus() {
375        // FM-ACT-001 FIX: 4-dim check BEFORE 3-dim
376        let make_dim = |dim: Dimension, scores: Vec<(NodeId, FiniteF32)>| DimensionResult {
377            scores,
378            dimension: dim,
379            elapsed_ns: 0,
380        };
381
382        let node = NodeId::new(0);
383        let score = FiniteF32::new(0.5);
384        let results = [
385            make_dim(Dimension::Structural, vec![(node, score)]),
386            make_dim(Dimension::Semantic, vec![(node, score)]),
387            make_dim(Dimension::Temporal, vec![(node, score)]),
388            make_dim(Dimension::Causal, vec![(node, score)]),
389        ];
390
391        let merged = merge_dimensions(&results, 10).unwrap();
392        assert!(!merged.activated.is_empty());
393        let activated = &merged.activated[0];
394        assert_eq!(activated.active_dimension_count, 4);
395        // With 4-dim resonance bonus of 1.5x, score should be boosted
396        let base = 0.5 * 0.35 + 0.5 * 0.25 + 0.5 * 0.15 + 0.5 * 0.25;
397        let expected = base * RESONANCE_BONUS_4DIM;
398        assert!(
399            (activated.activation.get() - expected).abs() < 0.01,
400            "Expected ~{expected}, got {}",
401            activated.activation.get()
402        );
403    }
404
405    // ===== STEP-006: xlr.rs tests =====
406
407    #[test]
408    fn xlr_sigmoid_gate() {
409        let zero = AdaptiveXlrEngine::sigmoid_gate(FiniteF32::ZERO);
410        assert!((zero.get() - 0.5).abs() < 0.01, "sigmoid(0) should be ~0.5");
411
412        let positive = AdaptiveXlrEngine::sigmoid_gate(FiniteF32::new(1.0));
413        assert!(positive.get() > 0.5, "sigmoid(+) should be > 0.5");
414
415        let negative = AdaptiveXlrEngine::sigmoid_gate(FiniteF32::new(-1.0));
416        assert!(negative.get() < 0.5, "sigmoid(-) should be < 0.5");
417    }
418
419    #[test]
420    fn xlr_pick_anti_seeds() {
421        let g = build_test_graph();
422        let xlr = AdaptiveXlrEngine::with_defaults();
423        let seeds = vec![NodeId::new(0)];
424        let anti = xlr.pick_anti_seeds(&g, &seeds).unwrap();
425        // Anti-seeds should not include the seed itself
426        assert!(!anti.contains(&NodeId::new(0)));
427    }
428
429    #[test]
430    fn xlr_immunity_bfs() {
431        let g = build_test_graph();
432        let xlr = AdaptiveXlrEngine::with_defaults();
433        let seeds = vec![NodeId::new(0)];
434        let immunity = xlr.compute_immunity(&g, &seeds).unwrap();
435        assert!(immunity[0], "Seed itself should be immune");
436    }
437
438    // ===== STEP-008: temporal.rs tests =====
439
440    #[test]
441    fn temporal_decay_clamps_negative_age() {
442        let scorer = TemporalDecayScorer::new(PosF32::new(168.0).unwrap());
443        let result = scorer.score_one(-10.0, FiniteF32::ZERO, None);
444        // FM-TMP-009: negative age clamped to 0 -> raw_decay should be 1.0
445        assert!(
446            (result.raw_decay.get() - 1.0).abs() < 0.01,
447            "Negative age should clamp to decay=1.0, got {}",
448            result.raw_decay.get()
449        );
450    }
451
452    #[test]
453    fn temporal_decay_exponential() {
454        let scorer = TemporalDecayScorer::new(PosF32::new(168.0).unwrap());
455        let result = scorer.score_one(168.0, FiniteF32::ZERO, None);
456        // After one half-life, decay should be ~0.5
457        assert!(
458            (result.raw_decay.get() - 0.5).abs() < 0.05,
459            "After one half-life, decay ~0.5, got {}",
460            result.raw_decay.get()
461        );
462    }
463
464    #[test]
465    fn causal_chain_budget_limits() {
466        let g = build_test_graph();
467        let detector = CausalChainDetector::new(6, FiniteF32::new(0.01), 100);
468        let chains = detector.detect(&g, NodeId::new(0)).unwrap();
469        // Should find at least one chain (mat_pe -> proc_inj -> prod_garrafa)
470        // Budget of 100 should be sufficient for this small graph
471        assert!(!chains.is_empty() || g.num_edges() == 0);
472    }
473
474    // ===== STEP-009: topology.rs tests =====
475
476    #[test]
477    fn louvain_detects_communities() {
478        let g = build_test_graph();
479        let detector = CommunityDetector::with_defaults();
480        let result = detector.detect(&g).unwrap();
481        assert!(result.num_communities >= 1);
482        assert_eq!(result.assignments.len(), g.num_nodes() as usize);
483    }
484
485    #[test]
486    fn louvain_empty_graph_error() {
487        let g = Graph::new();
488        let detector = CommunityDetector::with_defaults();
489        let result = detector.detect(&g);
490        assert!(matches!(result, Err(M1ndError::EmptyGraph)));
491    }
492
493    #[test]
494    fn bridge_detection() {
495        let g = build_test_graph();
496        let detector = CommunityDetector::with_defaults();
497        let communities = detector.detect(&g).unwrap();
498        let bridges = BridgeDetector::detect(&g, &communities).unwrap();
499        // May or may not have bridges depending on community structure
500        // Just verify it doesn't crash
501        let _ = bridges;
502    }
503
504    #[test]
505    fn spectral_gap_empty_graph() {
506        let g = Graph::new();
507        let analyzer = SpectralGapAnalyzer::with_defaults();
508        let result = analyzer.analyze(&g);
509        assert!(matches!(result, Err(M1ndError::EmptyGraph)));
510    }
511
512    // ===== STEP-010: resonance.rs tests =====
513
514    #[test]
515    fn wave_accumulator_complex_interference() {
516        let mut acc = WaveAccumulator::default();
517        let pulse1 = WavePulse {
518            node: NodeId::new(0),
519            amplitude: FiniteF32::ONE,
520            phase: FiniteF32::ZERO,
521            frequency: PosF32::new(1.0).unwrap(),
522            wavelength: PosF32::new(4.0).unwrap(),
523            hops: 0,
524            prev_node: NodeId::new(0),
525        };
526        acc.accumulate(&pulse1);
527        let amp = acc.amplitude().get();
528        assert!(
529            (amp - 1.0).abs() < 0.01,
530            "Single pulse amplitude should be ~1.0"
531        );
532    }
533
534    #[test]
535    fn standing_wave_propagation() {
536        let g = build_test_graph();
537        let propagator = StandingWavePropagator::new(5, FiniteF32::new(0.01), 10_000);
538        let seeds = vec![(NodeId::new(0), FiniteF32::ONE)];
539        let result = propagator
540            .propagate(
541                &g,
542                &seeds,
543                PosF32::new(1.0).unwrap(),
544                PosF32::new(4.0).unwrap(),
545            )
546            .unwrap();
547        assert!(result.pulses_processed > 0);
548        assert!(!result.antinodes.is_empty());
549    }
550
551    // ===== STEP-011: plasticity.rs tests =====
552
553    #[test]
554    fn query_memory_ring_buffer() {
555        let mut mem = QueryMemory::new(3, 10);
556        assert!(mem.is_empty());
557
558        for i in 0..5 {
559            mem.record(QueryRecord {
560                query_text: format!("query_{i}"),
561                seeds: vec![NodeId::new(i)],
562                activated_nodes: vec![NodeId::new(i), NodeId::new(i + 1)],
563                timestamp: i as f64,
564            });
565        }
566        // Capacity is 3, so only 3 records should be present
567        assert_eq!(mem.len(), 3);
568    }
569
570    #[test]
571    fn plasticity_generation_check() {
572        let mut g = build_test_graph();
573        let engine = PlasticityEngine::new(&g, PlasticityConfig::default());
574        // After adding a node, generation changes
575        // PlasticityEngine's generation should no longer match
576        // (we relax this in update, but check_generation would catch it)
577        let _ = engine;
578    }
579
580    // ===== STEP-012: counterfactual.rs tests =====
581
582    #[test]
583    fn removal_mask_basic() {
584        let g = build_test_graph();
585        let mut mask = RemovalMask::new(g.num_nodes(), g.num_edges());
586        assert!(!mask.is_node_removed(NodeId::new(0)));
587        mask.remove_node(&g, NodeId::new(0));
588        assert!(mask.is_node_removed(NodeId::new(0)));
589        mask.reset();
590        assert!(!mask.is_node_removed(NodeId::new(0)));
591    }
592
593    // ===== Integration: full query orchestration =====
594
595    #[test]
596    fn query_orchestrator_builds() {
597        let g = build_test_graph();
598        let orch = QueryOrchestrator::build(&g);
599        assert!(orch.is_ok(), "Orchestrator should build from test graph");
600    }
601
602    #[test]
603    fn full_query_execution() {
604        let mut g = build_test_graph();
605        let mut orch = QueryOrchestrator::build(&g).unwrap();
606        let config = QueryConfig {
607            query: "plastico polimero".to_string(),
608            agent_id: "test".to_string(),
609            top_k: 10,
610            xlr_enabled: false, // Disable XLR for simpler test
611            include_ghost_edges: true,
612            include_structural_holes: false,
613            ..QueryConfig::default()
614        };
615        let result = orch.query(&mut g, &config).unwrap();
616        assert!(result.elapsed_ms >= 0.0);
617        // Should activate at least the seed nodes
618        // (exact count depends on seed finding + propagation)
619    }
620
621    // =================================================================
622    // NEW TESTS — Agent C0: Core Test Suite expansion
623    // =================================================================
624
625    /// Helper: build a richer test graph with ~10 nodes and ~15 edges for edge-case coverage.
626    fn make_test_graph() -> Graph {
627        let mut g = Graph::new();
628        // 10 nodes: mix of types
629        g.add_node(
630            "n0",
631            "Alpha",
632            NodeType::Material,
633            &["group_a", "core"],
634            1000.0,
635            0.9,
636        )
637        .unwrap();
638        g.add_node(
639            "n1",
640            "Beta",
641            NodeType::Material,
642            &["group_a", "core"],
643            900.0,
644            0.8,
645        )
646        .unwrap();
647        g.add_node("n2", "Gamma", NodeType::Process, &["group_b"], 800.0, 0.7)
648            .unwrap();
649        g.add_node("n3", "Delta", NodeType::Process, &["group_b"], 700.0, 0.6)
650            .unwrap();
651        g.add_node("n4", "Epsilon", NodeType::Product, &["group_c"], 600.0, 0.5)
652            .unwrap();
653        g.add_node("n5", "Zeta", NodeType::Product, &["group_c"], 500.0, 0.4)
654            .unwrap();
655        g.add_node("n6", "Eta", NodeType::Module, &["group_d"], 400.0, 0.3)
656            .unwrap();
657        g.add_node("n7", "Theta", NodeType::Module, &["group_d"], 300.0, 0.2)
658            .unwrap();
659        g.add_node("n8", "Iota", NodeType::Concept, &["group_e"], 200.0, 0.1)
660            .unwrap();
661        g.add_node("n9", "Kappa", NodeType::Concept, &["group_e"], 100.0, 0.05)
662            .unwrap();
663
664        // 15 edges: hub at n0, chain n0->n2->n4->n6->n8, cross-links
665        g.add_edge(
666            NodeId::new(0),
667            NodeId::new(1),
668            "similar",
669            FiniteF32::new(0.9),
670            EdgeDirection::Bidirectional,
671            false,
672            FiniteF32::ZERO,
673        )
674        .unwrap();
675        g.add_edge(
676            NodeId::new(0),
677            NodeId::new(2),
678            "feeds",
679            FiniteF32::new(0.8),
680            EdgeDirection::Forward,
681            false,
682            FiniteF32::new(0.7),
683        )
684        .unwrap();
685        g.add_edge(
686            NodeId::new(0),
687            NodeId::new(3),
688            "feeds",
689            FiniteF32::new(0.7),
690            EdgeDirection::Forward,
691            false,
692            FiniteF32::new(0.6),
693        )
694        .unwrap();
695        g.add_edge(
696            NodeId::new(0),
697            NodeId::new(4),
698            "feeds",
699            FiniteF32::new(0.6),
700            EdgeDirection::Forward,
701            false,
702            FiniteF32::new(0.5),
703        )
704        .unwrap();
705        g.add_edge(
706            NodeId::new(0),
707            NodeId::new(5),
708            "feeds",
709            FiniteF32::new(0.5),
710            EdgeDirection::Forward,
711            false,
712            FiniteF32::new(0.4),
713        )
714        .unwrap();
715        g.add_edge(
716            NodeId::new(2),
717            NodeId::new(4),
718            "produces",
719            FiniteF32::new(0.8),
720            EdgeDirection::Forward,
721            false,
722            FiniteF32::new(0.8),
723        )
724        .unwrap();
725        g.add_edge(
726            NodeId::new(3),
727            NodeId::new(5),
728            "produces",
729            FiniteF32::new(0.7),
730            EdgeDirection::Forward,
731            false,
732            FiniteF32::new(0.7),
733        )
734        .unwrap();
735        g.add_edge(
736            NodeId::new(4),
737            NodeId::new(6),
738            "uses",
739            FiniteF32::new(0.6),
740            EdgeDirection::Forward,
741            false,
742            FiniteF32::new(0.3),
743        )
744        .unwrap();
745        g.add_edge(
746            NodeId::new(5),
747            NodeId::new(7),
748            "uses",
749            FiniteF32::new(0.5),
750            EdgeDirection::Forward,
751            false,
752            FiniteF32::new(0.2),
753        )
754        .unwrap();
755        g.add_edge(
756            NodeId::new(6),
757            NodeId::new(8),
758            "refs",
759            FiniteF32::new(0.4),
760            EdgeDirection::Forward,
761            false,
762            FiniteF32::new(0.1),
763        )
764        .unwrap();
765        g.add_edge(
766            NodeId::new(7),
767            NodeId::new(9),
768            "refs",
769            FiniteF32::new(0.3),
770            EdgeDirection::Forward,
771            false,
772            FiniteF32::ZERO,
773        )
774        .unwrap();
775        g.add_edge(
776            NodeId::new(1),
777            NodeId::new(3),
778            "feeds",
779            FiniteF32::new(0.6),
780            EdgeDirection::Forward,
781            false,
782            FiniteF32::new(0.5),
783        )
784        .unwrap();
785        g.add_edge(
786            NodeId::new(8),
787            NodeId::new(9),
788            "related",
789            FiniteF32::new(0.5),
790            EdgeDirection::Bidirectional,
791            false,
792            FiniteF32::ZERO,
793        )
794        .unwrap();
795        g.add_edge(
796            NodeId::new(6),
797            NodeId::new(7),
798            "related",
799            FiniteF32::new(0.4),
800            EdgeDirection::Bidirectional,
801            false,
802            FiniteF32::ZERO,
803        )
804        .unwrap();
805        g.add_edge(
806            NodeId::new(2),
807            NodeId::new(3),
808            "related",
809            FiniteF32::new(0.3),
810            EdgeDirection::Bidirectional,
811            false,
812            FiniteF32::ZERO,
813        )
814        .unwrap();
815
816        g.finalize().unwrap();
817        g
818    }
819
820    // ===== Semantic tests =====
821
822    #[test]
823    fn test_semantic_cooccurrence_builds_from_graph() {
824        use crate::semantic::CoOccurrenceIndex;
825        let g = make_test_graph();
826        let idx = CoOccurrenceIndex::build(&g, 10, 20, 4).unwrap();
827        // Should have vectors for each node
828        let _ = idx; // build succeeded without panic
829    }
830
831    #[test]
832    fn test_semantic_ppmi_positive_values() {
833        use crate::semantic::CoOccurrenceIndex;
834        let g = make_test_graph();
835        let idx = CoOccurrenceIndex::build(&g, 10, 20, 4).unwrap();
836        // Query top-k for node 0 (hub node) — should find similar nodes
837        let results = idx.query_top_k(NodeId::new(0), 5);
838        // All returned PPMI similarity scores must be positive
839        for &(_, score) in &results {
840            assert!(
841                score.get() >= 0.0,
842                "PPMI score must be non-negative, got {}",
843                score.get()
844            );
845        }
846    }
847
848    #[test]
849    fn test_semantic_engine_finds_similar_nodes() {
850        use crate::semantic::SemanticEngine;
851        let g = make_test_graph();
852        let engine = SemanticEngine::build(&g, SemanticWeights::default()).unwrap();
853        let results = engine.query(&g, "Alpha", 5).unwrap();
854        // Searching for "Alpha" should return at least the node with that label
855        assert!(
856            !results.is_empty(),
857            "Semantic query for exact label should return results"
858        );
859    }
860
861    #[test]
862    fn test_semantic_search_exact_label_match() {
863        use crate::semantic::CharNgramIndex;
864        let g = make_test_graph();
865        let idx = CharNgramIndex::build(&g, 3).unwrap();
866        let results = idx.query_top_k("Alpha", 10);
867        // The node labeled "Alpha" should appear in results
868        assert!(
869            !results.is_empty(),
870            "N-gram search for exact label should find matches"
871        );
872        // Top result should be node 0 (Alpha)
873        assert_eq!(
874            results[0].0,
875            NodeId::new(0),
876            "Top result should be node 0 (Alpha)"
877        );
878    }
879
880    #[test]
881    fn test_semantic_search_empty_query_returns_empty() {
882        use crate::semantic::CharNgramIndex;
883        let g = make_test_graph();
884        let idx = CharNgramIndex::build(&g, 3).unwrap();
885        let results = idx.query_top_k("", 10);
886        // Empty query should still not panic; may return empty or very low scores
887        let _ = results;
888    }
889
890    #[test]
891    fn test_semantic_cosine_similarity_identical_vectors() {
892        use crate::semantic::CharNgramIndex;
893        let g = make_test_graph();
894        let idx = CharNgramIndex::build(&g, 3).unwrap();
895        let qvec = idx.query_vector("Alpha");
896        let sim = CharNgramIndex::cosine_similarity(&qvec, &qvec);
897        assert!(
898            (sim.get() - 1.0).abs() < 0.01,
899            "Self-similarity should be ~1.0, got {}",
900            sim.get()
901        );
902    }
903
904    #[test]
905    fn test_semantic_cosine_similarity_empty_vectors() {
906        use crate::semantic::{CharNgramIndex, NgramVector};
907        let empty: NgramVector = std::collections::HashMap::new();
908        let sim = CharNgramIndex::cosine_similarity(&empty, &empty);
909        assert_eq!(
910            sim.get(),
911            0.0,
912            "Cosine similarity of empty vectors should be 0"
913        );
914    }
915
916    #[test]
917    fn test_semantic_synonym_expansion() {
918        use crate::semantic::SynonymExpander;
919        let expander = SynonymExpander::build_default().unwrap();
920        let expanded = expander.expand("plastico");
921        assert!(expanded.contains(&"plastico".to_string()));
922        assert!(expanded.contains(&"polimero".to_string()));
923        assert!(expanded.contains(&"resina".to_string()));
924    }
925
926    #[test]
927    fn test_semantic_synonym_are_synonyms() {
928        use crate::semantic::SynonymExpander;
929        let expander = SynonymExpander::build_default().unwrap();
930        assert!(expander.are_synonyms("plastico", "polimero"));
931        assert!(expander.are_synonyms("Plastico", "POLIMERO")); // case-insensitive
932        assert!(!expander.are_synonyms("plastico", "metal"));
933    }
934
935    // ===== Snapshot tests =====
936
937    #[test]
938    fn test_snapshot_roundtrip_graph() {
939        use crate::snapshot::{load_graph, save_graph};
940        let g = make_test_graph();
941        let path = std::path::PathBuf::from("/tmp/m1nd_test_snapshot_graph.json");
942        save_graph(&g, &path).unwrap();
943        let loaded = load_graph(&path).unwrap();
944        assert_eq!(
945            loaded.num_nodes(),
946            g.num_nodes(),
947            "Node count mismatch after roundtrip"
948        );
949        // Edges may differ slightly due to bidirectional expansion, but should be non-zero
950        assert!(loaded.num_edges() > 0, "Loaded graph should have edges");
951        assert!(loaded.finalized, "Loaded graph should be finalized");
952        // Clean up
953        let _ = std::fs::remove_file(&path);
954    }
955
956    #[test]
957    fn test_snapshot_roundtrip_preserves_labels() {
958        use crate::snapshot::{load_graph, save_graph};
959        let g = make_test_graph();
960        let path = std::path::PathBuf::from("/tmp/m1nd_test_snapshot_labels.json");
961        save_graph(&g, &path).unwrap();
962        let loaded = load_graph(&path).unwrap();
963        // Check that node n0 ("Alpha") can be resolved
964        assert!(
965            loaded.resolve_id("n0").is_some(),
966            "Should resolve 'n0' after roundtrip"
967        );
968        assert!(
969            loaded.resolve_id("n9").is_some(),
970            "Should resolve 'n9' after roundtrip"
971        );
972        let _ = std::fs::remove_file(&path);
973    }
974
975    #[test]
976    fn test_snapshot_roundtrip_preserves_provenance() {
977        use crate::graph::NodeProvenanceInput;
978        use crate::snapshot::{load_graph, save_graph};
979
980        let mut g = make_test_graph();
981        let node = g.resolve_id("n0").unwrap();
982        g.set_node_provenance(
983            node,
984            NodeProvenanceInput {
985                source_path: Some("memory/2026-03-13.md"),
986                line_start: Some(12),
987                line_end: Some(14),
988                excerpt: Some("Batman mode means peak build window."),
989                namespace: Some("memory"),
990                canonical: true,
991            },
992        );
993
994        let path = std::path::PathBuf::from("/tmp/m1nd_test_snapshot_provenance.json");
995        save_graph(&g, &path).unwrap();
996        let loaded = load_graph(&path).unwrap();
997        let provenance = loaded.resolve_node_provenance(loaded.resolve_id("n0").unwrap());
998
999        assert_eq!(
1000            provenance.source_path.as_deref(),
1001            Some("memory/2026-03-13.md")
1002        );
1003        assert_eq!(provenance.line_start, Some(12));
1004        assert_eq!(provenance.line_end, Some(14));
1005        assert_eq!(
1006            provenance.excerpt.as_deref(),
1007            Some("Batman mode means peak build window.")
1008        );
1009        assert_eq!(provenance.namespace.as_deref(), Some("memory"));
1010        assert!(provenance.canonical);
1011
1012        let _ = std::fs::remove_file(&path);
1013    }
1014
1015    #[test]
1016    fn test_snapshot_roundtrip_plasticity_state() {
1017        use crate::plasticity::SynapticState;
1018        use crate::snapshot::{load_plasticity_state, save_plasticity_state};
1019        let states = vec![
1020            SynapticState {
1021                source_label: "n0".to_string(),
1022                target_label: "n2".to_string(),
1023                relation: "feeds".to_string(),
1024                original_weight: 0.8,
1025                current_weight: 0.85,
1026                strengthen_count: 3,
1027                weaken_count: 0,
1028                ltp_applied: false,
1029                ltd_applied: false,
1030            },
1031            SynapticState {
1032                source_label: "n2".to_string(),
1033                target_label: "n4".to_string(),
1034                relation: "produces".to_string(),
1035                original_weight: 0.7,
1036                current_weight: 0.65,
1037                strengthen_count: 0,
1038                weaken_count: 2,
1039                ltp_applied: false,
1040                ltd_applied: false,
1041            },
1042        ];
1043        let path = std::path::PathBuf::from("/tmp/m1nd_test_plasticity_state.json");
1044        save_plasticity_state(&states, &path).unwrap();
1045        let loaded = load_plasticity_state(&path).unwrap();
1046        assert_eq!(loaded.len(), 2);
1047        assert_eq!(loaded[0].source_label, "n0");
1048        assert!((loaded[0].current_weight - 0.85).abs() < 1e-5);
1049        assert_eq!(loaded[1].weaken_count, 2);
1050        let _ = std::fs::remove_file(&path);
1051    }
1052
1053    #[test]
1054    fn test_snapshot_load_nonexistent_file_returns_error() {
1055        use crate::snapshot::load_graph;
1056        let path = std::path::Path::new("/tmp/m1nd_test_nonexistent_42.json");
1057        let result = load_graph(path);
1058        assert!(
1059            result.is_err(),
1060            "Loading nonexistent file should return error"
1061        );
1062    }
1063
1064    #[test]
1065    fn test_snapshot_load_corrupt_json_returns_error() {
1066        use crate::snapshot::load_graph;
1067        let path = std::path::PathBuf::from("/tmp/m1nd_test_corrupt.json");
1068        std::fs::write(&path, "{ this is not valid json !!!").unwrap();
1069        let result = load_graph(&path);
1070        assert!(result.is_err(), "Loading corrupt JSON should return error");
1071        let _ = std::fs::remove_file(&path);
1072    }
1073
1074    #[test]
1075    fn test_snapshot_saved_graph_is_valid_json() {
1076        use crate::snapshot::save_graph;
1077        let g = make_test_graph();
1078        let path = std::path::PathBuf::from("/tmp/m1nd_test_valid_json.json");
1079        save_graph(&g, &path).unwrap();
1080        let data = std::fs::read_to_string(&path).unwrap();
1081        let parsed: serde_json::Value = serde_json::from_str(&data).unwrap();
1082        assert!(parsed.is_object(), "Saved snapshot should be a JSON object");
1083        assert!(
1084            parsed.get("version").is_some(),
1085            "Snapshot should have version field"
1086        );
1087        assert!(
1088            parsed.get("nodes").is_some(),
1089            "Snapshot should have nodes field"
1090        );
1091        assert!(
1092            parsed.get("edges").is_some(),
1093            "Snapshot should have edges field"
1094        );
1095        let _ = std::fs::remove_file(&path);
1096    }
1097
1098    // ===== Query tests =====
1099
1100    #[test]
1101    fn test_query_orchestrator_builds_from_finalized_graph() {
1102        let g = make_test_graph();
1103        let orch = QueryOrchestrator::build(&g);
1104        assert!(
1105            orch.is_ok(),
1106            "Orchestrator should build from finalized graph"
1107        );
1108    }
1109
1110    #[test]
1111    fn test_query_execution_returns_results_for_existing_node() {
1112        let mut g = make_test_graph();
1113        let mut orch = QueryOrchestrator::build(&g).unwrap();
1114        let config = QueryConfig {
1115            query: "Alpha".to_string(),
1116            agent_id: "test_agent".to_string(),
1117            top_k: 10,
1118            xlr_enabled: false,
1119            include_ghost_edges: false,
1120            include_structural_holes: false,
1121            ..QueryConfig::default()
1122        };
1123        let result = orch.query(&mut g, &config).unwrap();
1124        assert!(result.elapsed_ms >= 0.0);
1125    }
1126
1127    #[test]
1128    fn test_query_with_nonexistent_term_returns_gracefully() {
1129        let mut g = make_test_graph();
1130        let mut orch = QueryOrchestrator::build(&g).unwrap();
1131        let config = QueryConfig {
1132            query: "zzzznonexistent_xyz_987".to_string(),
1133            agent_id: "test".to_string(),
1134            top_k: 5,
1135            xlr_enabled: false,
1136            include_ghost_edges: false,
1137            include_structural_holes: false,
1138            ..QueryConfig::default()
1139        };
1140        let result = orch.query(&mut g, &config).unwrap();
1141        // Should not crash; may return empty or minimal results
1142        assert!(result.elapsed_ms >= 0.0);
1143    }
1144
1145    #[test]
1146    fn test_query_memory_deduplication() {
1147        let mut mem = QueryMemory::new(10, 20);
1148        let record1 = QueryRecord {
1149            query_text: "Alpha".to_string(),
1150            seeds: vec![NodeId::new(0)],
1151            activated_nodes: vec![NodeId::new(0), NodeId::new(1)],
1152            timestamp: 1.0,
1153        };
1154        let record2 = QueryRecord {
1155            query_text: "Alpha".to_string(),
1156            seeds: vec![NodeId::new(0)],
1157            activated_nodes: vec![NodeId::new(0), NodeId::new(1)],
1158            timestamp: 2.0,
1159        };
1160        mem.record(record1);
1161        mem.record(record2);
1162        assert_eq!(mem.len(), 2, "Both records should be stored");
1163        // Priming signal should reflect accumulated frequency
1164        let priming = mem.get_priming_signal(&[NodeId::new(0)], FiniteF32::new(0.5));
1165        // Node 1 should appear in priming (activated twice with seed 0)
1166        let has_node1 = priming.iter().any(|(n, _)| *n == NodeId::new(1));
1167        assert!(has_node1, "Node 1 should appear in priming signal");
1168    }
1169
1170    // ===== Counterfactual tests =====
1171
1172    #[test]
1173    fn test_counterfactual_removal_of_leaf_node() {
1174        let g = make_test_graph();
1175        let engine = HybridEngine::new();
1176        let config = PropagationConfig::default();
1177        let cf = CounterfactualEngine::with_defaults();
1178        // Node 9 (Kappa) is a leaf — removing it should have low/zero impact
1179        let result = cf
1180            .simulate_removal(&g, &engine, &config, &[NodeId::new(9)])
1181            .unwrap();
1182        assert!(
1183            result.total_impact.get() >= 0.0,
1184            "Impact should be non-negative"
1185        );
1186        // Leaf removal typically has very low impact
1187    }
1188
1189    #[test]
1190    fn test_counterfactual_removal_of_hub_node_has_positive_impact() {
1191        let g = make_test_graph();
1192        let engine = HybridEngine::new();
1193        let config = PropagationConfig::default();
1194        let cf = CounterfactualEngine::with_defaults();
1195        // Node 0 (Alpha) is the hub — removing it should have significant impact
1196        let result = cf
1197            .simulate_removal(&g, &engine, &config, &[NodeId::new(0)])
1198            .unwrap();
1199        assert!(
1200            result.total_impact.get() >= 0.0,
1201            "Hub removal should have non-negative impact"
1202        );
1203    }
1204
1205    #[test]
1206    fn test_counterfactual_empty_removal_returns_zero_impact() {
1207        let g = make_test_graph();
1208        let engine = HybridEngine::new();
1209        let config = PropagationConfig::default();
1210        let cf = CounterfactualEngine::with_defaults();
1211        let result = cf.simulate_removal(&g, &engine, &config, &[]).unwrap();
1212        assert!(
1213            (result.total_impact.get() - 0.0).abs() < 0.01,
1214            "Empty removal should have ~0% impact, got {}",
1215            result.total_impact.get()
1216        );
1217    }
1218
1219    #[test]
1220    fn test_counterfactual_multi_node_combined_ge_individual() {
1221        let g = make_test_graph();
1222        let engine = HybridEngine::new();
1223        let config = PropagationConfig::default();
1224        let cf = CounterfactualEngine::with_defaults();
1225        let synergy = cf
1226            .synergy_analysis(&g, &engine, &config, &[NodeId::new(0), NodeId::new(2)])
1227            .unwrap();
1228        // Combined impact should be >= max individual (synergy or at least additive)
1229        let max_individual = synergy
1230            .individual_impacts
1231            .iter()
1232            .map(|(_, s)| s.get())
1233            .fold(0.0f32, f32::max);
1234        assert!(
1235            synergy.combined_impact.get() >= max_individual * 0.5,
1236            "Combined impact ({}) should be significant relative to individual ({})",
1237            synergy.combined_impact.get(),
1238            max_individual
1239        );
1240    }
1241
1242    #[test]
1243    fn test_counterfactual_cascade_analysis() {
1244        let g = make_test_graph();
1245        let engine = HybridEngine::new();
1246        let config = PropagationConfig::default();
1247        let cf = CounterfactualEngine::with_defaults();
1248        let cascade = cf
1249            .cascade_analysis(&g, &engine, &config, NodeId::new(0))
1250            .unwrap();
1251        // Hub node should have downstream cascade
1252        assert!(
1253            cascade.total_affected > 0,
1254            "Hub removal should have downstream cascade"
1255        );
1256        assert!(
1257            cascade.cascade_depth > 0,
1258            "Cascade depth from hub should be > 0"
1259        );
1260    }
1261
1262    #[test]
1263    fn test_counterfactual_removal_mask_edge_removal() {
1264        let g = make_test_graph();
1265        let mut mask = RemovalMask::new(g.num_nodes(), g.num_edges());
1266        // Remove node 0 and check that its edges are also marked removed
1267        mask.remove_node(&g, NodeId::new(0));
1268        let out_range = g.csr.out_range(NodeId::new(0));
1269        for j in out_range {
1270            assert!(
1271                mask.is_edge_removed(EdgeIdx::new(j as u32)),
1272                "Edge {} from removed node should be marked removed",
1273                j
1274            );
1275        }
1276    }
1277
1278    // ===== Plasticity tests =====
1279
1280    #[test]
1281    fn test_plasticity_engine_creates_correct_entries() {
1282        let g = make_test_graph();
1283        let engine = PlasticityEngine::new(&g, PlasticityConfig::default());
1284        // Engine should be created without error; generation matches graph
1285        let _ = engine;
1286    }
1287
1288    #[test]
1289    fn test_plasticity_strengthen_edge_increases_weight() {
1290        let mut g = make_test_graph();
1291        let mut engine = PlasticityEngine::new(&g, PlasticityConfig::default());
1292        // Read weight of first edge before strengthening
1293        let edge0_before = g.csr.read_weight(EdgeIdx::new(0)).get();
1294        // Activate nodes 0 and 1 with high activation to trigger Hebbian strengthening
1295        let activated = vec![
1296            (NodeId::new(0), FiniteF32::ONE),
1297            (NodeId::new(1), FiniteF32::ONE),
1298            (NodeId::new(2), FiniteF32::ONE),
1299            (NodeId::new(3), FiniteF32::ONE),
1300            (NodeId::new(4), FiniteF32::ONE),
1301        ];
1302        let seeds = vec![(NodeId::new(0), FiniteF32::ONE)];
1303        let result = engine
1304            .update(&mut g, &activated, &seeds, "test query")
1305            .unwrap();
1306        assert!(
1307            result.edges_strengthened > 0,
1308            "At least one edge should be strengthened"
1309        );
1310    }
1311
1312    #[test]
1313    fn test_plasticity_export_state_produces_real_labels() {
1314        let g = make_test_graph();
1315        let engine = PlasticityEngine::new(&g, PlasticityConfig::default());
1316        let states = engine.export_state(&g).unwrap();
1317        assert!(!states.is_empty(), "Export should produce states");
1318        // Verify labels are real node IDs, not placeholders
1319        for state in &states {
1320            assert!(
1321                !state.source_label.is_empty(),
1322                "Source label should not be empty"
1323            );
1324            assert!(
1325                !state.target_label.is_empty(),
1326                "Target label should not be empty"
1327            );
1328            assert!(
1329                !state.source_label.starts_with("node_"),
1330                "Source should use real label, not placeholder"
1331            );
1332        }
1333    }
1334
1335    #[test]
1336    fn test_plasticity_import_state_roundtrip() {
1337        let mut g = make_test_graph();
1338        let engine = PlasticityEngine::new(&g, PlasticityConfig::default());
1339        let states = engine.export_state(&g).unwrap();
1340        // Import the exported state back
1341        let mut engine2 = PlasticityEngine::new(&g, PlasticityConfig::default());
1342        let applied = engine2.import_state(&mut g, &states).unwrap();
1343        assert!(applied > 0, "Import should apply at least some states");
1344    }
1345
1346    #[test]
1347    fn test_plasticity_decay_reduces_weights() {
1348        let mut g = make_test_graph();
1349        let mut engine = PlasticityEngine::new(&g, PlasticityConfig::default());
1350        // Read a weight before decay — use an edge from an unactivated node
1351        let edge_idx = EdgeIdx::new(
1352            g.csr.offsets[8] as u32, // first edge from node 8
1353        );
1354        let before = g.csr.read_weight(edge_idx).get();
1355        // Run update with NO activated nodes except node 0 — node 8's edges should decay
1356        let activated = vec![(NodeId::new(0), FiniteF32::ONE)];
1357        let seeds = vec![(NodeId::new(0), FiniteF32::ONE)];
1358        let result = engine
1359            .update(&mut g, &activated, &seeds, "decay test")
1360            .unwrap();
1361        let after = g.csr.read_weight(edge_idx).get();
1362        // Decay should reduce weight (or hit floor)
1363        assert!(
1364            after <= before,
1365            "Weight should decrease after decay: before={before}, after={after}"
1366        );
1367        assert!(
1368            result.edges_decayed > 0,
1369            "At least one edge should be decayed"
1370        );
1371    }
1372
1373    #[test]
1374    fn test_plasticity_ltp_threshold_triggering() {
1375        let mut g = make_test_graph();
1376        let ltp_thresh = 3u16; // lower threshold for easier triggering
1377        let config = PlasticityConfig {
1378            ltp_threshold: ltp_thresh,
1379            ..PlasticityConfig::default()
1380        };
1381        let mut engine = PlasticityEngine::new(&g, config);
1382        let activated = vec![
1383            (NodeId::new(0), FiniteF32::ONE),
1384            (NodeId::new(1), FiniteF32::ONE),
1385            (NodeId::new(2), FiniteF32::ONE),
1386            (NodeId::new(3), FiniteF32::ONE),
1387        ];
1388        let seeds = vec![(NodeId::new(0), FiniteF32::ONE)];
1389        // Run multiple updates to accumulate strengthen_count past threshold
1390        let mut total_ltp = 0u32;
1391        for i in 0..6 {
1392            let result = engine
1393                .update(&mut g, &activated, &seeds, &format!("ltp_test_{i}"))
1394                .unwrap();
1395            total_ltp += result.ltp_events;
1396        }
1397        assert!(
1398            total_ltp > 0,
1399            "LTP should trigger after repeated strengthening"
1400        );
1401    }
1402
1403    #[test]
1404    fn test_plasticity_query_memory_priming_signal() {
1405        let mut mem = QueryMemory::new(100, 20);
1406        // Record multiple queries with overlapping seeds
1407        for i in 0..5 {
1408            mem.record(QueryRecord {
1409                query_text: format!("query_{i}"),
1410                seeds: vec![NodeId::new(0), NodeId::new(1)],
1411                activated_nodes: vec![
1412                    NodeId::new(0),
1413                    NodeId::new(1),
1414                    NodeId::new(2),
1415                    NodeId::new(3),
1416                ],
1417                timestamp: i as f64,
1418            });
1419        }
1420        // Get priming for seed [0] — should find nodes that co-occur
1421        let priming = mem.get_priming_signal(&[NodeId::new(0)], FiniteF32::new(0.5));
1422        // Nodes 2 and 3 should appear (frequently activated alongside seed 0)
1423        assert!(
1424            !priming.is_empty(),
1425            "Priming signal should be non-empty after recording queries"
1426        );
1427    }
1428
1429    #[test]
1430    fn test_plasticity_homeostatic_normalization() {
1431        let mut g = make_test_graph();
1432        // Artificially inflate a weight to exceed homeostatic ceiling
1433        let edge_idx = EdgeIdx::new(0);
1434        let _ = g
1435            .csr
1436            .atomic_write_weight(edge_idx, FiniteF32::new(10.0), 64);
1437        let engine = PlasticityEngine::new(&g, PlasticityConfig::default());
1438        // Calling export_state should still produce finite weights
1439        let states = engine.export_state(&g).unwrap();
1440        for state in &states {
1441            assert!(
1442                state.current_weight.is_finite(),
1443                "Exported weight should be finite"
1444            );
1445        }
1446    }
1447
1448    // ===== Resonance tests =====
1449
1450    #[test]
1451    fn test_resonance_standing_wave_produces_waves_from_seeds() {
1452        let g = make_test_graph();
1453        let propagator = StandingWavePropagator::new(10, FiniteF32::new(0.001), 50_000);
1454        let seeds = vec![(NodeId::new(0), FiniteF32::ONE)];
1455        let result = propagator
1456            .propagate(
1457                &g,
1458                &seeds,
1459                PosF32::new(1.0).unwrap(),
1460                PosF32::new(4.0).unwrap(),
1461            )
1462            .unwrap();
1463        assert!(
1464            result.pulses_processed > 0,
1465            "Should process at least one pulse"
1466        );
1467        assert!(!result.antinodes.is_empty(), "Should produce antinodes");
1468        assert!(
1469            result.total_energy.get() > 0.0,
1470            "Total energy should be positive"
1471        );
1472    }
1473
1474    #[test]
1475    fn test_resonance_harmonic_analyzer_detects_fundamental() {
1476        let g = make_test_graph();
1477        let propagator = StandingWavePropagator::new(5, FiniteF32::new(0.01), 10_000);
1478        let analyzer = HarmonicAnalyzer::new(propagator, 3);
1479        let seeds = vec![(NodeId::new(0), FiniteF32::ONE)];
1480        let result = analyzer
1481            .analyze(
1482                &g,
1483                &seeds,
1484                PosF32::new(1.0).unwrap(),
1485                PosF32::new(4.0).unwrap(),
1486            )
1487            .unwrap();
1488        assert!(
1489            !result.harmonics.is_empty(),
1490            "Should detect at least one harmonic"
1491        );
1492        // Harmonic 1 is the fundamental
1493        assert_eq!(
1494            result.harmonics[0].harmonic, 1,
1495            "First harmonic should be the fundamental"
1496        );
1497        assert!(
1498            result.harmonics[0].total_energy.get() > 0.0,
1499            "Fundamental should have energy"
1500        );
1501    }
1502
1503    #[test]
1504    fn test_resonance_sympathetic_detector_finds_pairs() {
1505        let g = make_test_graph();
1506        let propagator = StandingWavePropagator::new(10, FiniteF32::new(0.001), 50_000);
1507        let detector = SympatheticResonanceDetector::new(propagator, FiniteF32::new(0.01));
1508        let seeds = vec![(NodeId::new(0), FiniteF32::ONE)];
1509        let result = detector
1510            .detect(
1511                &g,
1512                &seeds,
1513                PosF32::new(1.0).unwrap(),
1514                PosF32::new(4.0).unwrap(),
1515            )
1516            .unwrap();
1517        assert!(
1518            result.checked_disconnected,
1519            "Should check disconnected components"
1520        );
1521        // Sympathetic nodes may or may not exist depending on graph topology
1522        let _ = result.sympathetic_nodes;
1523    }
1524
1525    #[test]
1526    fn test_resonance_engine_analyze_returns_report() {
1527        let g = make_test_graph();
1528        let engine = ResonanceEngine::with_defaults();
1529        let seeds = vec![(NodeId::new(0), FiniteF32::ONE)];
1530        let report = engine.analyze(&g, &seeds).unwrap();
1531        assert!(report.standing_wave.pulses_processed > 0);
1532        assert!(!report.harmonics.harmonics.is_empty());
1533    }
1534
1535    #[test]
1536    fn test_resonance_wave_accumulator_destructive_interference() {
1537        let mut acc = WaveAccumulator::default();
1538        // Two pulses with opposite phases should cancel
1539        let pulse1 = WavePulse {
1540            node: NodeId::new(0),
1541            amplitude: FiniteF32::ONE,
1542            phase: FiniteF32::ZERO,
1543            frequency: PosF32::new(1.0).unwrap(),
1544            wavelength: PosF32::new(4.0).unwrap(),
1545            hops: 0,
1546            prev_node: NodeId::new(0),
1547        };
1548        let pulse2 = WavePulse {
1549            node: NodeId::new(0),
1550            amplitude: FiniteF32::ONE,
1551            phase: FiniteF32::new(std::f32::consts::PI),
1552            frequency: PosF32::new(1.0).unwrap(),
1553            wavelength: PosF32::new(4.0).unwrap(),
1554            hops: 0,
1555            prev_node: NodeId::new(0),
1556        };
1557        acc.accumulate(&pulse1);
1558        acc.accumulate(&pulse2);
1559        let amp = acc.amplitude().get();
1560        assert!(
1561            amp < 0.1,
1562            "Opposite-phase pulses should destructively interfere, got amp={amp}"
1563        );
1564    }
1565
1566    // ===== Graph edge case tests =====
1567
1568    #[test]
1569    fn test_graph_finalize_empty_graph_succeeds() {
1570        let mut g = Graph::new();
1571        // Finalizing an empty graph should not panic or error
1572        let result = g.finalize();
1573        assert!(result.is_ok(), "Finalizing empty graph should succeed");
1574        assert!(g.finalized);
1575    }
1576
1577    #[test]
1578    fn test_graph_finalize_computes_pagerank_top_node() {
1579        let g = make_test_graph();
1580        // Node 0 (hub) should have the highest or near-highest PageRank
1581        let max_idx = (0..g.num_nodes() as usize)
1582            .max_by(|&a, &b| g.nodes.pagerank[a].cmp(&g.nodes.pagerank[b]))
1583            .unwrap();
1584        let max_pr = g.nodes.pagerank[max_idx].get();
1585        assert!(
1586            (max_pr - 1.0).abs() < 0.01,
1587            "Highest PageRank should be normalized to ~1.0, got {max_pr}"
1588        );
1589    }
1590
1591    #[test]
1592    fn test_graph_csr_offsets_monotonically_increasing() {
1593        let g = make_test_graph();
1594        for i in 1..g.csr.offsets.len() {
1595            assert!(
1596                g.csr.offsets[i] >= g.csr.offsets[i - 1],
1597                "CSR offsets must be monotonically increasing: offsets[{}]={} < offsets[{}]={}",
1598                i,
1599                g.csr.offsets[i],
1600                i - 1,
1601                g.csr.offsets[i - 1]
1602            );
1603        }
1604    }
1605
1606    #[test]
1607    fn test_graph_bidirectional_edge_creates_two_csr_entries() {
1608        // Build a minimal graph with one bidirectional edge
1609        let mut g = Graph::new();
1610        g.add_node("a", "A", NodeType::Module, &[], 0.0, 0.0)
1611            .unwrap();
1612        g.add_node("b", "B", NodeType::Module, &[], 0.0, 0.0)
1613            .unwrap();
1614        g.add_edge(
1615            NodeId::new(0),
1616            NodeId::new(1),
1617            "bidir",
1618            FiniteF32::ONE,
1619            EdgeDirection::Bidirectional,
1620            false,
1621            FiniteF32::ZERO,
1622        )
1623        .unwrap();
1624        g.finalize().unwrap();
1625        // Bidirectional edge should create 2 CSR entries (one in each direction)
1626        assert_eq!(
1627            g.num_edges(),
1628            2,
1629            "Bidirectional edge should produce 2 CSR entries"
1630        );
1631        // Node 0 should have outgoing edge to 1
1632        let out_0 = g.csr.out_range(NodeId::new(0));
1633        assert_eq!(
1634            out_0.end - out_0.start,
1635            1,
1636            "Node 0 should have 1 outgoing edge"
1637        );
1638        // Node 1 should have outgoing edge to 0
1639        let out_1 = g.csr.out_range(NodeId::new(1));
1640        assert_eq!(
1641            out_1.end - out_1.start,
1642            1,
1643            "Node 1 should have 1 outgoing edge"
1644        );
1645    }
1646
1647    #[test]
1648    fn test_graph_edge_plasticity_matches_csr_count() {
1649        let g = make_test_graph();
1650        let num_csr = g.num_edges();
1651        assert_eq!(
1652            g.edge_plasticity.original_weight.len(),
1653            num_csr,
1654            "Plasticity original_weight length should match CSR edge count"
1655        );
1656        assert_eq!(
1657            g.edge_plasticity.current_weight.len(),
1658            num_csr,
1659            "Plasticity current_weight length should match CSR edge count"
1660        );
1661        assert_eq!(
1662            g.edge_plasticity.strengthen_count.len(),
1663            num_csr,
1664            "Plasticity strengthen_count length should match CSR edge count"
1665        );
1666        assert_eq!(
1667            g.edge_plasticity.ltp_applied.len(),
1668            num_csr,
1669            "Plasticity ltp_applied length should match CSR edge count"
1670        );
1671    }
1672
1673    #[test]
1674    fn test_graph_resolve_nonexistent_id_returns_none() {
1675        let g = make_test_graph();
1676        assert!(g.resolve_id("nonexistent_node_xyz").is_none());
1677    }
1678
1679    #[test]
1680    fn test_graph_avg_degree_positive_for_nonempty_graph() {
1681        let g = make_test_graph();
1682        assert!(
1683            g.avg_degree() > 0.0,
1684            "Average degree should be positive for non-empty graph"
1685        );
1686    }
1687
1688    #[test]
1689    fn test_graph_avg_degree_zero_for_empty_graph() {
1690        let g = Graph::new();
1691        assert_eq!(
1692            g.avg_degree(),
1693            0.0,
1694            "Average degree of empty graph should be 0"
1695        );
1696    }
1697
1698    // ===== Additional counterfactual tests =====
1699
1700    #[test]
1701    fn test_counterfactual_keystone_analysis() {
1702        let g = make_test_graph();
1703        let engine = HybridEngine::new();
1704        let config = PropagationConfig::default();
1705        let cf = CounterfactualEngine::new(4, 5);
1706        let result = cf.find_keystones(&g, &engine, &config).unwrap();
1707        assert!(
1708            !result.keystones.is_empty(),
1709            "Should identify at least one keystone"
1710        );
1711        // Keystones should be sorted by impact descending
1712        for i in 1..result.keystones.len() {
1713            assert!(
1714                result.keystones[i - 1].avg_impact.get() >= result.keystones[i].avg_impact.get(),
1715                "Keystones should be sorted by impact descending"
1716            );
1717        }
1718    }
1719
1720    #[test]
1721    fn test_counterfactual_redundancy_check() {
1722        let g = make_test_graph();
1723        let engine = HybridEngine::new();
1724        let config = PropagationConfig::default();
1725        let cf = CounterfactualEngine::with_defaults();
1726        let result = cf
1727            .check_redundancy(&g, &engine, &config, NodeId::new(9))
1728            .unwrap();
1729        // Leaf node should be fairly redundant (high score)
1730        assert!(
1731            result.redundancy_score.get() >= 0.0 && result.redundancy_score.get() <= 1.0,
1732            "Redundancy score should be in [0,1], got {}",
1733            result.redundancy_score.get()
1734        );
1735    }
1736
1737    #[test]
1738    fn test_counterfactual_reachability_tracked() {
1739        let g = make_test_graph();
1740        let engine = HybridEngine::new();
1741        let config = PropagationConfig::default();
1742        let cf = CounterfactualEngine::with_defaults();
1743        let result = cf
1744            .simulate_removal(&g, &engine, &config, &[NodeId::new(0)])
1745            .unwrap();
1746        assert!(
1747            result.reachability_before > 0,
1748            "Reachability before should be > 0"
1749        );
1750        // After removing hub, reachability should decrease
1751        assert!(
1752            result.reachability_after <= result.reachability_before,
1753            "Reachability should not increase after removal"
1754        );
1755    }
1756
1757    // ===== Additional query / orchestration tests =====
1758
1759    #[test]
1760    fn test_query_config_default_has_four_dimensions() {
1761        let config = QueryConfig::default();
1762        assert_eq!(config.dimensions.len(), 4);
1763        assert!(config.xlr_enabled);
1764        assert_eq!(config.top_k, 20);
1765    }
1766
1767    #[test]
1768    fn test_query_with_structural_holes_enabled() {
1769        let mut g = make_test_graph();
1770        let mut orch = QueryOrchestrator::build(&g).unwrap();
1771        let config = QueryConfig {
1772            query: "Alpha Beta".to_string(),
1773            agent_id: "test".to_string(),
1774            top_k: 10,
1775            xlr_enabled: false,
1776            include_ghost_edges: true,
1777            include_structural_holes: true,
1778            ..QueryConfig::default()
1779        };
1780        let result = orch.query(&mut g, &config).unwrap();
1781        // Should not crash with structural holes enabled
1782        assert!(result.elapsed_ms >= 0.0);
1783    }
1784
1785    // ===== Additional activation / merge tests =====
1786
1787    #[test]
1788    fn test_merge_dimensions_three_dim_resonance_bonus() {
1789        let make_dim = |dim: Dimension, scores: Vec<(NodeId, FiniteF32)>| DimensionResult {
1790            scores,
1791            dimension: dim,
1792            elapsed_ns: 0,
1793        };
1794        let node = NodeId::new(0);
1795        let score = FiniteF32::new(0.5);
1796        let results = [
1797            make_dim(Dimension::Structural, vec![(node, score)]),
1798            make_dim(Dimension::Semantic, vec![(node, score)]),
1799            make_dim(Dimension::Temporal, vec![(node, score)]),
1800            make_dim(Dimension::Causal, vec![]), // no causal
1801        ];
1802        let merged = merge_dimensions(&results, 10).unwrap();
1803        assert!(!merged.activated.is_empty());
1804        let activated = &merged.activated[0];
1805        assert_eq!(activated.active_dimension_count, 3);
1806        // 3-dim bonus = 1.3x
1807        let w_sum = 0.35 + 0.25 + 0.15; // adaptive weights redistribute
1808        let base = 0.5 * (0.35 / w_sum) + 0.5 * (0.25 / w_sum) + 0.5 * (0.15 / w_sum);
1809        let expected = base * RESONANCE_BONUS_3DIM;
1810        assert!(
1811            (activated.activation.get() - expected).abs() < 0.02,
1812            "Expected ~{expected}, got {}",
1813            activated.activation.get()
1814        );
1815    }
1816
1817    #[test]
1818    fn test_merge_dimensions_single_dim_no_bonus() {
1819        let make_dim = |dim: Dimension, scores: Vec<(NodeId, FiniteF32)>| DimensionResult {
1820            scores,
1821            dimension: dim,
1822            elapsed_ns: 0,
1823        };
1824        let node = NodeId::new(0);
1825        let score = FiniteF32::new(0.5);
1826        let results = [
1827            make_dim(Dimension::Structural, vec![(node, score)]),
1828            make_dim(Dimension::Semantic, vec![]),
1829            make_dim(Dimension::Temporal, vec![]),
1830            make_dim(Dimension::Causal, vec![]),
1831        ];
1832        let merged = merge_dimensions(&results, 10).unwrap();
1833        assert!(!merged.activated.is_empty());
1834        let activated = &merged.activated[0];
1835        assert_eq!(activated.active_dimension_count, 1);
1836        // No resonance bonus for single dimension
1837        // Adaptive weight: 0.35 redistributed to 1.0 (only active dim)
1838        let expected = 0.5 * 1.0; // weight normalized to 1.0 since only one dim active
1839        assert!(
1840            (activated.activation.get() - expected).abs() < 0.02,
1841            "Expected ~{expected} (no bonus), got {}",
1842            activated.activation.get()
1843        );
1844    }
1845
1846    // ===== Bloom filter edge cases =====
1847
1848    #[test]
1849    fn test_bloom_filter_clear_resets_all() {
1850        let mut bf = BloomFilter::with_capacity(100, 0.01);
1851        bf.insert(NodeId::new(10));
1852        bf.insert(NodeId::new(20));
1853        assert!(bf.probably_contains(NodeId::new(10)));
1854        bf.clear();
1855        // After clear, nothing should be found (assuming no false positives)
1856        // We can't guarantee no FP, but 10 should likely not be found
1857        // Just verify clear doesn't crash
1858    }
1859
1860    // ===== String interner edge case =====
1861
1862    #[test]
1863    fn test_string_interner_idempotent() {
1864        let mut interner = StringInterner::new();
1865        let h1 = interner.get_or_intern("hello");
1866        let h2 = interner.get_or_intern("hello");
1867        assert_eq!(h1, h2, "Re-interning same string should return same handle");
1868        assert_eq!(interner.len(), 1, "Should only have one entry");
1869    }
1870
1871    #[test]
1872    fn test_string_interner_resolve() {
1873        let mut interner = StringInterner::new();
1874        let h = interner.get_or_intern("world");
1875        assert_eq!(interner.resolve(h), "world");
1876    }
1877
1878    // ===== Temporal additional tests =====
1879
1880    #[test]
1881    fn test_temporal_activation_dimension() {
1882        let g = make_test_graph();
1883        let seeds = vec![(NodeId::new(0), FiniteF32::ONE)];
1884        let weights = TemporalWeights::default();
1885        let result = activate_temporal(&g, &seeds, &weights).unwrap();
1886        assert_eq!(result.dimension, Dimension::Temporal);
1887        // Should produce at least one score for the seed node
1888        assert!(
1889            !result.scores.is_empty(),
1890            "Temporal activation should produce scores for seeds"
1891        );
1892    }
1893
1894    #[test]
1895    fn test_causal_activation_dimension() {
1896        let g = make_test_graph();
1897        let seeds = vec![(NodeId::new(0), FiniteF32::ONE)];
1898        let config = PropagationConfig::default();
1899        let result = activate_causal(&g, &seeds, &config).unwrap();
1900        assert_eq!(result.dimension, Dimension::Causal);
1901    }
1902
1903    #[test]
1904    fn test_causal_activation_empty_graph() {
1905        let g = Graph::new();
1906        let seeds = vec![];
1907        let config = PropagationConfig::default();
1908        let result = activate_causal(&g, &seeds, &config).unwrap();
1909        assert!(result.scores.is_empty());
1910    }
1911
1912    // ===== Removal mask additional tests =====
1913
1914    #[test]
1915    fn test_removal_mask_remove_edge_directly() {
1916        let g = make_test_graph();
1917        let mut mask = RemovalMask::new(g.num_nodes(), g.num_edges());
1918        mask.remove_edge(EdgeIdx::new(0));
1919        assert!(mask.is_edge_removed(EdgeIdx::new(0)));
1920        assert!(!mask.is_edge_removed(EdgeIdx::new(1)));
1921    }
1922
1923    #[test]
1924    fn test_removal_mask_out_of_range_node_noop() {
1925        let g = make_test_graph();
1926        let mut mask = RemovalMask::new(g.num_nodes(), g.num_edges());
1927        // Removing a node beyond range should be a no-op
1928        mask.remove_node(&g, NodeId::new(999));
1929        // Should not panic, mask remains unchanged
1930        for i in 0..g.num_nodes() {
1931            assert!(!mask.is_node_removed(NodeId::new(i)));
1932        }
1933    }
1934
1935    // ===== Co-occurrence cosine similarity tests =====
1936
1937    #[test]
1938    fn test_cooccurrence_cosine_empty_vectors() {
1939        use crate::semantic::CoOccurrenceIndex;
1940        let a: Vec<(NodeId, FiniteF32)> = vec![];
1941        let b: Vec<(NodeId, FiniteF32)> = vec![];
1942        let sim = CoOccurrenceIndex::cosine_similarity(&a, &b);
1943        assert_eq!(sim.get(), 0.0);
1944    }
1945
1946    #[test]
1947    fn test_cooccurrence_cosine_identical_vectors() {
1948        use crate::semantic::CoOccurrenceIndex;
1949        let a = vec![
1950            (NodeId::new(0), FiniteF32::new(0.5)),
1951            (NodeId::new(1), FiniteF32::new(0.3)),
1952        ];
1953        let sim = CoOccurrenceIndex::cosine_similarity(&a, &a);
1954        assert!(
1955            (sim.get() - 1.0).abs() < 0.01,
1956            "Cosine similarity of identical vectors should be ~1.0, got {}",
1957            sim.get()
1958        );
1959    }
1960
1961    // =================================================================
1962    // NEW TESTS — Agent C1: Git Co-Change + Temporal Engine
1963    // =================================================================
1964
1965    // ===== CoChangeMatrix tests =====
1966
1967    #[test]
1968    fn test_co_change_record_increments_count() {
1969        let g = build_test_graph();
1970        let mut matrix = CoChangeMatrix::bootstrap(&g, 500_000).unwrap();
1971        let a = NodeId::new(0);
1972        let b = NodeId::new(1);
1973
1974        // Record a co-change
1975        matrix.record_co_change(a, b, 1000.0).unwrap();
1976
1977        // Predict should include b for a
1978        let predictions = matrix.predict(a, 10);
1979        assert!(
1980            predictions.iter().any(|e| e.target == b),
1981            "After recording co-change A->B, predict(A) should include B"
1982        );
1983    }
1984
1985    #[test]
1986    fn test_co_change_multiple_records_accumulate() {
1987        let g = build_test_graph();
1988        let mut matrix = CoChangeMatrix::bootstrap(&g, 500_000).unwrap();
1989        let a = NodeId::new(0);
1990        let b = NodeId::new(2);
1991
1992        // Get initial strength for A->B (from bootstrap)
1993        let initial_strength = matrix
1994            .predict(a, 100)
1995            .iter()
1996            .find(|e| e.target == b)
1997            .map(|e| e.strength.get())
1998            .unwrap_or(0.0);
1999
2000        // Record multiple co-changes
2001        matrix.record_co_change(a, b, 1000.0).unwrap();
2002        matrix.record_co_change(a, b, 2000.0).unwrap();
2003        matrix.record_co_change(a, b, 3000.0).unwrap();
2004
2005        let final_strength = matrix
2006            .predict(a, 100)
2007            .iter()
2008            .find(|e| e.target == b)
2009            .map(|e| e.strength.get())
2010            .unwrap_or(0.0);
2011
2012        assert!(
2013            final_strength > initial_strength,
2014            "Multiple co-change records should accumulate: initial={}, final={}",
2015            initial_strength,
2016            final_strength
2017        );
2018    }
2019
2020    #[test]
2021    fn test_co_change_predict_returns_most_frequent() {
2022        let g = build_test_graph();
2023        let mut matrix = CoChangeMatrix::bootstrap(&g, 500_000).unwrap();
2024        let a = NodeId::new(0);
2025        let b = NodeId::new(1);
2026        let c = NodeId::new(2);
2027
2028        // Record B more frequently than C
2029        for _ in 0..5 {
2030            matrix.record_co_change(a, b, 1000.0).unwrap();
2031        }
2032        matrix.record_co_change(a, c, 1000.0).unwrap();
2033
2034        let predictions = matrix.predict(a, 10);
2035        let b_strength = predictions
2036            .iter()
2037            .find(|e| e.target == b)
2038            .map(|e| e.strength.get())
2039            .unwrap_or(0.0);
2040        let c_strength = predictions
2041            .iter()
2042            .find(|e| e.target == c)
2043            .map(|e| e.strength.get())
2044            .unwrap_or(0.0);
2045
2046        assert!(
2047            b_strength > c_strength,
2048            "More frequently co-changed node should have higher strength: B={}, C={}",
2049            b_strength,
2050            c_strength
2051        );
2052    }
2053
2054    #[test]
2055    fn test_co_change_empty_matrix_returns_empty() {
2056        // Build a graph with no edges -> bootstrap produces an empty matrix
2057        let mut g = Graph::new();
2058        g.add_node("iso_a", "IsoA", NodeType::File, &[], 0.0, 0.1)
2059            .unwrap();
2060        g.add_node("iso_b", "IsoB", NodeType::File, &[], 0.0, 0.1)
2061            .unwrap();
2062        g.finalize().unwrap();
2063
2064        let matrix = CoChangeMatrix::bootstrap(&g, 500_000).unwrap();
2065
2066        // No co-changes recorded, no edges -> empty predictions
2067        let predictions = matrix.predict(NodeId::new(0), 10);
2068        assert!(
2069            predictions.is_empty(),
2070            "Empty matrix should return empty predictions"
2071        );
2072    }
2073
2074    #[test]
2075    fn test_co_change_predict_returns_both_partners() {
2076        let g = build_test_graph();
2077        let mut matrix = CoChangeMatrix::bootstrap(&g, 500_000).unwrap();
2078        let a = NodeId::new(0);
2079        let b = NodeId::new(3);
2080        let c = NodeId::new(4);
2081
2082        // Record A<->B and A<->C
2083        matrix.record_co_change(a, b, 1000.0).unwrap();
2084        matrix.record_co_change(a, c, 1000.0).unwrap();
2085
2086        let predictions = matrix.predict(a, 10);
2087        let has_b = predictions.iter().any(|e| e.target == b);
2088        let has_c = predictions.iter().any(|e| e.target == c);
2089
2090        assert!(has_b, "predict(A) should include B after recording A<->B");
2091        assert!(has_c, "predict(A) should include C after recording A<->C");
2092    }
2093
2094    // ===== VelocityScorer tests =====
2095
2096    #[test]
2097    fn test_velocity_zscore_zero_for_average() {
2098        // Build a graph where all nodes have the same frequency
2099        let mut g = Graph::new();
2100        g.add_node("v0", "V0", NodeType::File, &[], 1000.0, 0.5)
2101            .unwrap();
2102        g.add_node("v1", "V1", NodeType::File, &[], 1000.0, 0.5)
2103            .unwrap();
2104        g.add_node("v2", "V2", NodeType::File, &[], 1000.0, 0.5)
2105            .unwrap();
2106        g.finalize().unwrap();
2107
2108        let score = VelocityScorer::score_one(&g, NodeId::new(0), 2000.0).unwrap();
2109        assert!(
2110            score.velocity.get().abs() < 0.01,
2111            "Node with average frequency should have z-score ~0, got {}",
2112            score.velocity.get()
2113        );
2114        assert_eq!(score.trend, VelocityTrend::Stable);
2115    }
2116
2117    #[test]
2118    fn test_velocity_high_frequency_positive_zscore() {
2119        // Build a graph with one high-frequency node and several low ones
2120        let mut g = Graph::new();
2121        g.add_node("high", "High", NodeType::File, &[], 1000.0, 1.0)
2122            .unwrap();
2123        g.add_node("low1", "Low1", NodeType::File, &[], 1000.0, 0.1)
2124            .unwrap();
2125        g.add_node("low2", "Low2", NodeType::File, &[], 1000.0, 0.1)
2126            .unwrap();
2127        g.add_node("low3", "Low3", NodeType::File, &[], 1000.0, 0.1)
2128            .unwrap();
2129        g.finalize().unwrap();
2130
2131        let score = VelocityScorer::score_one(&g, NodeId::new(0), 2000.0).unwrap();
2132        assert!(
2133            score.velocity.get() > 0.0,
2134            "High-frequency node should have positive z-score, got {}",
2135            score.velocity.get()
2136        );
2137    }
2138
2139    #[test]
2140    fn test_velocity_low_frequency_negative_zscore() {
2141        // Build a graph with one low-frequency node and several high ones
2142        let mut g = Graph::new();
2143        g.add_node("low", "Low", NodeType::File, &[], 1000.0, 0.05)
2144            .unwrap();
2145        g.add_node("high1", "High1", NodeType::File, &[], 1000.0, 0.9)
2146            .unwrap();
2147        g.add_node("high2", "High2", NodeType::File, &[], 1000.0, 0.9)
2148            .unwrap();
2149        g.add_node("high3", "High3", NodeType::File, &[], 1000.0, 0.9)
2150            .unwrap();
2151        g.finalize().unwrap();
2152
2153        let score = VelocityScorer::score_one(&g, NodeId::new(0), 2000.0).unwrap();
2154        assert!(
2155            score.velocity.get() < 0.0,
2156            "Low-frequency node should have negative z-score, got {}",
2157            score.velocity.get()
2158        );
2159    }
2160
2161    #[test]
2162    fn test_velocity_score_all_empty_graph() {
2163        let g = Graph::new();
2164        let scores = VelocityScorer::score_all(&g, 0.0).unwrap();
2165        assert!(
2166            scores.is_empty(),
2167            "score_all on empty graph should return empty"
2168        );
2169    }
2170
2171    // ===== TemporalDecayScorer tests =====
2172
2173    #[test]
2174    fn test_decay_recent_file_near_one() {
2175        let scorer = TemporalDecayScorer::new(PosF32::new(168.0).unwrap());
2176        // age_hours = 0 -> decay should be very close to 1.0
2177        let result = scorer.score_one(0.0, FiniteF32::ZERO, None);
2178        assert!(
2179            (result.raw_decay.get() - 1.0).abs() < 0.01,
2180            "Recent file (age=0) should have decay ~1.0, got {}",
2181            result.raw_decay.get()
2182        );
2183        assert!(
2184            (result.final_score.get() - 1.0).abs() < 0.01,
2185            "Recent file final_score should be ~1.0, got {}",
2186            result.final_score.get()
2187        );
2188    }
2189
2190    #[test]
2191    fn test_decay_old_file_below_half() {
2192        let scorer = TemporalDecayScorer::new(PosF32::new(168.0).unwrap());
2193        // age = 3 half-lives = 504 hours -> decay ~ 0.125
2194        let result = scorer.score_one(504.0, FiniteF32::ZERO, None);
2195        assert!(
2196            result.raw_decay.get() < 0.5,
2197            "Old file (3 half-lives) should have decay < 0.5, got {}",
2198            result.raw_decay.get()
2199        );
2200    }
2201
2202    #[test]
2203    fn test_decay_per_nodetype_function_faster_than_module() {
2204        let scorer = TemporalDecayScorer::new(PosF32::new(168.0).unwrap());
2205        let age_hours = 336.0; // 14 days
2206
2207        // Function has half-life 336h (14d), Module has half-life 720h (30d)
2208        let func_decay =
2209            scorer.score_one_typed(age_hours, FiniteF32::ZERO, None, Some(NodeType::Function));
2210        let mod_decay =
2211            scorer.score_one_typed(age_hours, FiniteF32::ZERO, None, Some(NodeType::Module));
2212
2213        assert!(
2214            func_decay.raw_decay.get() < mod_decay.raw_decay.get(),
2215            "Function should decay faster than Module at same age: func={}, mod={}",
2216            func_decay.raw_decay.get(),
2217            mod_decay.raw_decay.get()
2218        );
2219    }
2220
2221    #[test]
2222    fn test_decay_score_all_in_unit_range() {
2223        let g = build_test_graph();
2224        let scorer = TemporalDecayScorer::new(PosF32::new(168.0).unwrap());
2225        // Use a "now" that is after all node timestamps
2226        let now_unix = 2000.0;
2227        let scores = scorer.score_all(&g, now_unix).unwrap();
2228
2229        assert_eq!(scores.len(), g.num_nodes() as usize);
2230        for ds in &scores {
2231            assert!(
2232                ds.final_score.get() >= 0.0 && ds.final_score.get() <= 1.0,
2233                "Decay score should be in [0,1], got {} for node {:?}",
2234                ds.final_score.get(),
2235                ds.node
2236            );
2237            assert!(
2238                ds.raw_decay.get() >= 0.0 && ds.raw_decay.get() <= 1.0,
2239                "Raw decay should be in [0,1], got {} for node {:?}",
2240                ds.raw_decay.get(),
2241                ds.node
2242            );
2243        }
2244    }
2245
2246    // ===== ImpactRadiusAnalyzer tests =====
2247
2248    #[test]
2249    fn test_impact_isolated_node_limited_to_self() {
2250        // Build a graph with an isolated node
2251        let mut g = Graph::new();
2252        g.add_node("iso", "Isolated", NodeType::File, &[], 1000.0, 0.5)
2253            .unwrap();
2254        g.add_node("other", "Other", NodeType::File, &[], 1000.0, 0.5)
2255            .unwrap();
2256        // No edges between them
2257        g.finalize().unwrap();
2258
2259        let calc = ImpactRadiusCalculator::new(5, FiniteF32::new(0.01));
2260        let result = calc
2261            .compute(&g, NodeId::new(0), ImpactDirection::Both)
2262            .unwrap();
2263
2264        assert!(
2265            result.blast_radius.is_empty(),
2266            "Isolated node should have empty blast radius, got {} entries",
2267            result.blast_radius.len()
2268        );
2269        assert_eq!(result.source, NodeId::new(0));
2270    }
2271
2272    #[test]
2273    fn test_impact_propagates_through_edges() {
2274        let g = build_test_graph();
2275        let calc = ImpactRadiusCalculator::new(5, FiniteF32::new(0.01));
2276
2277        // Node 0 (mat_pe) has edges to node 3 (proc_inj) and node 1 (mat_pp)
2278        let result = calc
2279            .compute(&g, NodeId::new(0), ImpactDirection::Forward)
2280            .unwrap();
2281
2282        assert!(
2283            !result.blast_radius.is_empty(),
2284            "Connected node should have non-empty blast radius"
2285        );
2286
2287        // At least the direct neighbor should be impacted
2288        let impacted_nodes: Vec<NodeId> = result.blast_radius.iter().map(|e| e.node).collect();
2289        assert!(
2290            impacted_nodes.contains(&NodeId::new(3)),
2291            "Direct forward neighbor (proc_inj, node 3) should be in blast radius. Got: {:?}",
2292            impacted_nodes
2293        );
2294
2295        // All signal strengths should be in (0, 1]
2296        for entry in &result.blast_radius {
2297            assert!(
2298                entry.signal_strength.get() > 0.0 && entry.signal_strength.get() <= 1.0,
2299                "Impact signal should be in (0,1], got {}",
2300                entry.signal_strength.get()
2301            );
2302        }
2303    }
2304
2305    #[test]
2306    fn test_impact_multi_hop_propagation() {
2307        let g = build_test_graph();
2308        let calc = ImpactRadiusCalculator::new(5, FiniteF32::new(0.001));
2309
2310        // Node 0 -> Node 3 -> Node 5: should reach node 5 at hop distance 2
2311        let result = calc
2312            .compute(&g, NodeId::new(0), ImpactDirection::Forward)
2313            .unwrap();
2314
2315        let node5_entry = result
2316            .blast_radius
2317            .iter()
2318            .find(|e| e.node == NodeId::new(5));
2319        assert!(
2320            node5_entry.is_some(),
2321            "Impact should propagate to 2-hop neighbor (node 5). Blast radius: {:?}",
2322            result
2323                .blast_radius
2324                .iter()
2325                .map(|e| e.node)
2326                .collect::<Vec<_>>()
2327        );
2328
2329        if let Some(entry) = node5_entry {
2330            assert!(
2331                entry.hop_distance >= 2,
2332                "Node 5 should be at hop distance >= 2, got {}",
2333                entry.hop_distance
2334            );
2335        }
2336    }
2337
2338    // ===== VelocityScorer cache tests =====
2339
2340    #[test]
2341    fn test_velocity_scorer_cache_works() {
2342        let mut g = Graph::new();
2343        g.add_node("c0", "C0", NodeType::File, &[], 1000.0, 0.9)
2344            .unwrap();
2345        g.add_node("c1", "C1", NodeType::File, &[], 1000.0, 0.1)
2346            .unwrap();
2347        g.add_node("c2", "C2", NodeType::File, &[], 1000.0, 0.5)
2348            .unwrap();
2349        g.finalize().unwrap();
2350
2351        let mut scorer = VelocityScorer::new();
2352
2353        // First call computes stats
2354        let scores1 = scorer.score_all_cached(&g, 2000.0).unwrap();
2355        // Second call should use cache (same graph, same result)
2356        let scores2 = scorer.score_all_cached(&g, 2000.0).unwrap();
2357
2358        assert_eq!(scores1.len(), scores2.len(), "Cached results should match");
2359        for (s1, s2) in scores1.iter().zip(scores2.iter()) {
2360            assert_eq!(
2361                s1.velocity.get(),
2362                s2.velocity.get(),
2363                "Cached velocity scores should be identical"
2364            );
2365        }
2366
2367        // After invalidation, recompute
2368        scorer.invalidate_cache();
2369        let scores3 = scorer.score_all_cached(&g, 2000.0).unwrap();
2370        assert_eq!(
2371            scores1.len(),
2372            scores3.len(),
2373            "After invalidation, results should still match"
2374        );
2375    }
2376
2377    // ===== CoChangeMatrix.populate_from_commit_groups test =====
2378
2379    #[test]
2380    fn test_co_change_populate_from_commit_groups() {
2381        // Build a graph with file nodes that can be resolved
2382        let mut g = Graph::new();
2383        g.add_node(
2384            "file::alpha.rs",
2385            "alpha.rs",
2386            NodeType::File,
2387            &[],
2388            1000.0,
2389            0.5,
2390        )
2391        .unwrap();
2392        g.add_node("file::beta.rs", "beta.rs", NodeType::File, &[], 1000.0, 0.3)
2393            .unwrap();
2394        g.add_node(
2395            "file::gamma.rs",
2396            "gamma.rs",
2397            NodeType::File,
2398            &[],
2399            1000.0,
2400            0.2,
2401        )
2402        .unwrap();
2403        g.finalize().unwrap();
2404
2405        let mut matrix = CoChangeMatrix::bootstrap(&g, 500_000).unwrap();
2406
2407        // Commit groups: alpha + beta changed together, alpha + gamma changed together
2408        let groups = vec![
2409            vec!["alpha.rs".to_string(), "beta.rs".to_string()],
2410            vec!["alpha.rs".to_string(), "gamma.rs".to_string()],
2411        ];
2412
2413        matrix.populate_from_commit_groups(&g, &groups).unwrap();
2414
2415        // alpha should predict both beta and gamma
2416        let alpha_id = g.resolve_id("file::alpha.rs").unwrap();
2417        let beta_id = g.resolve_id("file::beta.rs").unwrap();
2418        let gamma_id = g.resolve_id("file::gamma.rs").unwrap();
2419
2420        let predictions = matrix.predict(alpha_id, 10);
2421        let has_beta = predictions.iter().any(|e| e.target == beta_id);
2422        let has_gamma = predictions.iter().any(|e| e.target == gamma_id);
2423
2424        assert!(
2425            has_beta,
2426            "After commit group [alpha, beta], alpha should predict beta"
2427        );
2428        assert!(
2429            has_gamma,
2430            "After commit group [alpha, gamma], alpha should predict gamma"
2431        );
2432    }
2433}