Skip to main content

m1nd_core/
lib.rs

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