clawft_kernel/causal_predict.rs
1//! Causal collapse prediction via O(1) analytical perturbation + EML correction.
2//!
3//! Predicts how adding a new edge will change the causal graph's algebraic
4//! connectivity (lambda_2) **without** recomputing the full O(k*m) Lanczos
5//! eigenvalue iteration. The core insight comes from first-order eigenvalue
6//! perturbation theory:
7//!
8//! ```text
9//! delta_lambda_2 = w * (phi[u] - phi[v])^2
10//! ```
11//!
12//! where phi is the Fiedler vector and w is the edge weight. Edges that
13//! bridge the spectral partition (phi[u] and phi[v] have opposite signs)
14//! produce the largest coherence gains.
15//!
16//! # Components
17//!
18//! - [`predict_delta_lambda2`] -- O(1) analytical perturbation formula
19//! - [`rank_evidence_by_impact`] -- rank candidate edges by predicted impact
20//! - [`detect_conversation_cycle`] -- detect stuck/oscillating conversations
21//! - [`CausalCollapseModel`] -- EML-enhanced prediction with learned corrections
22//! - [`CausalGraph::rank_candidates`] -- convenience method on the causal graph
23//!
24//! # References
25//!
26//! See `.planning/development_notes/eml-causal-collapse-research.md` for the
27//! full mathematical derivation and design rationale.
28
29use serde::{Deserialize, Serialize};
30
31use crate::causal::CausalGraph;
32
33// ---------------------------------------------------------------------------
34// Analytical delta-lambda_2 prediction
35// ---------------------------------------------------------------------------
36
37/// First-order perturbation prediction: delta_lambda_2 = w * (phi[u] - phi[v])^2
38///
39/// O(1) per candidate -- just a multiplication and squared difference.
40///
41/// # Arguments
42///
43/// - `fiedler_u`: Fiedler vector component for node u.
44/// - `fiedler_v`: Fiedler vector component for node v.
45/// - `edge_weight`: Weight of the candidate edge.
46///
47/// # Returns
48///
49/// Predicted change in algebraic connectivity. Always non-negative because
50/// adding edges can only increase lambda_2.
51pub fn predict_delta_lambda2(fiedler_u: f64, fiedler_v: f64, edge_weight: f64) -> f64 {
52 edge_weight * (fiedler_u - fiedler_v).powi(2)
53}
54
55// ---------------------------------------------------------------------------
56// Evidence ranking
57// ---------------------------------------------------------------------------
58
59/// Result of ranking a candidate evidence addition.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct EvidenceRanking {
62 /// Source node ID.
63 pub source: u64,
64 /// Target node ID.
65 pub target: u64,
66 /// Edge weight.
67 pub weight: f32,
68 /// Predicted change in algebraic connectivity.
69 pub predicted_delta: f64,
70 /// Human-readable explanation of why this edge matters.
71 pub explanation: String,
72}
73
74/// Rank candidate evidence by predicted coherence impact.
75///
76/// Uses the Fiedler vector from the most recent spectral analysis to predict
77/// how each candidate edge would change lambda_2, without actually adding any
78/// edges.
79///
80/// Returns sorted by `predicted_delta` descending (biggest impact first).
81///
82/// # Arguments
83///
84/// - `fiedler`: The current Fiedler vector (indexed by node position).
85/// - `current_lambda2`: Current algebraic connectivity (unused in first-order,
86/// but available for logging and higher-order corrections).
87/// - `candidates`: Slice of `(source_node_id, target_node_id, weight)` tuples.
88pub fn rank_evidence_by_impact(
89 fiedler: &[f64],
90 _current_lambda2: f64,
91 candidates: &[(u64, u64, f32)],
92) -> Vec<EvidenceRanking> {
93 let mut rankings: Vec<EvidenceRanking> = candidates
94 .iter()
95 .map(|&(src, tgt, w)| {
96 let phi_u = fiedler.get(src as usize).copied().unwrap_or(0.0);
97 let phi_v = fiedler.get(tgt as usize).copied().unwrap_or(0.0);
98 let delta = predict_delta_lambda2(phi_u, phi_v, w as f64);
99
100 let explanation =
101 if phi_u.signum() != phi_v.signum() && phi_u.abs() > 1e-12 && phi_v.abs() > 1e-12 {
102 format!(
103 "Bridges the graph partition (phi[{}]={:.3}, phi[{}]={:.3})",
104 src, phi_u, tgt, phi_v
105 )
106 } else if delta > 0.1 {
107 format!("Strengthens weak connection (delta_lambda2={:.4})", delta)
108 } else {
109 format!("Reinforces existing cluster (delta_lambda2={:.4})", delta)
110 };
111
112 EvidenceRanking {
113 source: src,
114 target: tgt,
115 weight: w,
116 predicted_delta: delta,
117 explanation,
118 }
119 })
120 .collect();
121
122 rankings.sort_by(|a, b| {
123 b.predicted_delta
124 .partial_cmp(&a.predicted_delta)
125 .unwrap_or(std::cmp::Ordering::Equal)
126 });
127 rankings
128}
129
130// ---------------------------------------------------------------------------
131// Conversation cycle detection
132// ---------------------------------------------------------------------------
133
134/// State of a conversation's coherence trajectory.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub enum ConversationState {
137 /// Not enough data to determine state.
138 TooEarly,
139 /// Coherence is increasing at a measurable rate.
140 Converging {
141 /// Rate of convergence (delta per tick).
142 rate: f64,
143 },
144 /// Coherence is decreasing at a measurable rate.
145 Diverging {
146 /// Rate of divergence (delta per tick).
147 rate: f64,
148 },
149 /// Coherence is not changing meaningfully.
150 Stuck {
151 /// Net change over the window.
152 net_change: f64,
153 },
154 /// Coherence is bouncing up and down without net progress.
155 Oscillating {
156 /// Net change over the window.
157 net_change: f64,
158 /// Maximum swing between consecutive ticks.
159 max_swing: f64,
160 },
161}
162
163/// Detect if coherence changes indicate a circular/stuck conversation.
164///
165/// Examines the last `window` entries of the coherence history and classifies
166/// the trajectory as converging, diverging, stuck, or oscillating.
167///
168/// # Arguments
169///
170/// - `coherence_history`: Full history of coherence values (one per tick).
171/// - `window`: Number of recent entries to examine.
172/// - `threshold`: Minimum meaningful change (below this = stuck).
173///
174/// # Implementation
175///
176/// Dispatches through [`clawft_treecalc::triage_trajectory`] first
177/// (Finding #8 in `docs/eml-treecalc-swap-sites.md`): the trajectory
178/// is classified as `Atom` (flat), `Sequence` (monotone), or
179/// `Branch` (oscillating) by its sign-of-difference pattern. The
180/// treecalc form then drives which `ConversationState` is returned,
181/// and the per-state metrics (rate, net_change, max_swing) are
182/// computed from the same recent window. This replaces the nested
183/// threshold arithmetic with a structural dispatch that's easier to
184/// extend (e.g. a future `Atom` sub-classifier distinguishing
185/// "never-started" from "plateaued").
186pub fn detect_conversation_cycle(
187 coherence_history: &[f64],
188 window: usize,
189 threshold: f64,
190) -> ConversationState {
191 if coherence_history.len() < window || window < 2 {
192 return ConversationState::TooEarly;
193 }
194
195 let recent = &coherence_history[coherence_history.len() - window..];
196 let first = recent[0];
197 let last = recent[recent.len() - 1];
198 let total_change = (last - first).abs();
199 let max_swing = recent
200 .windows(2)
201 .map(|w| (w[1] - w[0]).abs())
202 .fold(0.0f64, f64::max);
203
204 // Structural dispatch on the trajectory's form.
205 match clawft_treecalc::triage_trajectory(recent, threshold) {
206 clawft_treecalc::Form::Atom => ConversationState::Stuck {
207 net_change: total_change,
208 },
209 clawft_treecalc::Form::Sequence => {
210 // Monotone. Sign of `last - first` tells us up vs. down.
211 let rate = total_change / window as f64;
212 if last > first {
213 ConversationState::Converging { rate }
214 } else {
215 ConversationState::Diverging { rate }
216 }
217 }
218 clawft_treecalc::Form::Branch => {
219 // Oscillating means the sign-of-difference flipped at
220 // least once. If the net change is still large we call
221 // it Converging/Diverging with noise; if small, we call
222 // it Oscillating (net flat with visible swings).
223 if total_change >= threshold {
224 let rate = total_change / window as f64;
225 if last > first {
226 ConversationState::Converging { rate }
227 } else {
228 ConversationState::Diverging { rate }
229 }
230 } else {
231 ConversationState::Oscillating {
232 net_change: total_change,
233 max_swing,
234 }
235 }
236 }
237 }
238}
239
240// ---------------------------------------------------------------------------
241// Information Gain Pruning (KG-005)
242// ---------------------------------------------------------------------------
243
244/// Check if a candidate edge is redundant given recent additions.
245///
246/// An edge is considered redundant (low marginal information gain) when
247/// **all three** of the following hold:
248///
249/// 1. Both endpoints are on the same side of the Fiedler partition
250/// (same sign in the Fiedler vector), meaning they are already in the
251/// same connected region.
252/// 2. Its predicted `delta_lambda_2` is below `threshold`, indicating
253/// the area is already well-connected and the edge adds negligible
254/// algebraic connectivity.
255/// 3. A recent addition already bridged the same Fiedler partition --
256/// i.e., at least one edge in `recent_additions` has endpoints with
257/// opposite Fiedler signs and a non-trivial predicted delta.
258///
259/// This is intended for use in the DEMOCRITUS update loop: before
260/// processing each candidate edge, call `is_redundant` to decide
261/// whether to skip it, achieving 50-70% token reduction in dense graphs.
262///
263/// # Arguments
264///
265/// - `candidate`: The `(source, target, weight)` edge being considered.
266/// - `recent_additions`: Edges that have already been accepted in this
267/// DEMOCRITUS cycle.
268/// - `fiedler`: The current Fiedler vector (indexed by node position).
269/// - `threshold`: Minimum delta_lambda_2 for an edge to be considered
270/// non-redundant (e.g., 0.01).
271///
272/// # Returns
273///
274/// `true` if the candidate should be skipped (redundant).
275pub fn is_redundant(
276 candidate: &(u64, u64, f32),
277 recent_additions: &[(u64, u64, f32)],
278 fiedler: &[f64],
279 threshold: f64,
280) -> bool {
281 let (src, tgt, w) = *candidate;
282
283 let phi_src = fiedler.get(src as usize).copied().unwrap_or(0.0);
284 let phi_tgt = fiedler.get(tgt as usize).copied().unwrap_or(0.0);
285
286 // Condition 1: same side of the partition.
287 // If they bridge the partition (opposite signs), never redundant.
288 let same_side =
289 phi_src.signum() == phi_tgt.signum() || phi_src.abs() < 1e-12 || phi_tgt.abs() < 1e-12;
290 if !same_side {
291 return false;
292 }
293
294 // Condition 2: low predicted delta.
295 let delta = predict_delta_lambda2(phi_src, phi_tgt, w as f64);
296 if delta >= threshold {
297 return false;
298 }
299
300 // Condition 3: a recent addition already bridged the partition.
301
302 recent_additions.iter().any(|&(rs, rt, rw)| {
303 let phi_rs = fiedler.get(rs as usize).copied().unwrap_or(0.0);
304 let phi_rt = fiedler.get(rt as usize).copied().unwrap_or(0.0);
305 let opposite_signs =
306 phi_rs.signum() != phi_rt.signum() && phi_rs.abs() > 1e-12 && phi_rt.abs() > 1e-12;
307 let recent_delta = predict_delta_lambda2(phi_rs, phi_rt, rw as f64);
308 opposite_signs && recent_delta > threshold
309 })
310}
311
312// ---------------------------------------------------------------------------
313// EML correction model for multi-edge batching
314// ---------------------------------------------------------------------------
315
316/// Feature vector for the causal collapse correction model.
317#[derive(Debug, Clone)]
318pub struct CollapseFeatures {
319 /// Fiedler vector component for the source node.
320 pub fiedler_u: f64,
321 /// Fiedler vector component for the target node.
322 pub fiedler_v: f64,
323 /// Edge weight.
324 pub edge_weight: f64,
325 /// Current algebraic connectivity.
326 pub current_lambda2: f64,
327 /// Spectral gap (lambda_3 - lambda_2).
328 pub spectral_gap: f64,
329 /// Graph edge density.
330 pub graph_density: f64,
331 /// Number of nodes in the graph.
332 pub node_count: f64,
333 /// Degree of the source node.
334 pub degree_u: f64,
335 /// Degree of the target node.
336 pub degree_v: f64,
337}
338
339impl CollapseFeatures {
340 /// Convert to a normalized feature vector for the EML model.
341 fn to_vec(&self) -> Vec<f64> {
342 vec![
343 self.fiedler_u.clamp(-1.0, 1.0),
344 self.fiedler_v.clamp(-1.0, 1.0),
345 self.edge_weight / 10.0,
346 self.current_lambda2 / 100.0,
347 self.spectral_gap / 100.0,
348 self.graph_density,
349 self.node_count / 10000.0,
350 self.degree_u / 1000.0,
351 self.degree_v / 1000.0,
352 ]
353 }
354}
355
356/// Internal training record storing both features and the residual ground truth.
357#[derive(Debug, Clone)]
358#[allow(dead_code)] // populated by training flow that lands later in the EML-collapse work
359struct CollapseTrainingPoint {
360 features: Vec<f64>,
361 residual: f64,
362}
363
364/// EML-enhanced delta prediction for when the analytical formula is insufficient.
365///
366/// Learns corrections for: multi-edge batching, Fiedler staleness, phase transitions.
367/// The model trains on the **residual** (actual - analytical) so the analytical
368/// formula provides the baseline and the EML tree only needs to learn the error.
369///
370/// # Architecture
371///
372/// - Depth 3, 9 inputs, 1 head
373/// - Inputs: fiedler_u, fiedler_v, edge_weight, current_lambda2, spectral_gap,
374/// graph_density, node_count, degree_u, degree_v
375/// - Output: correction to add to the analytical prediction
376#[derive(Debug, Clone, Serialize, Deserialize)]
377pub struct CausalCollapseModel {
378 /// First-order correction model.
379 model: eml_core::EmlModel,
380 /// Training data (not serialized -- transient buffer).
381 #[serde(skip)]
382 training_data: Vec<CollapseTrainingPoint>,
383}
384
385impl Default for CausalCollapseModel {
386 fn default() -> Self {
387 Self::new()
388 }
389}
390
391impl CausalCollapseModel {
392 /// Create a new untrained collapse prediction model.
393 pub fn new() -> Self {
394 let mut model = eml_core::EmlModel::new(3, 9, 1);
395 model.set_model_name("causal_collapse");
396 Self {
397 model,
398 training_data: Vec::new(),
399 }
400 }
401
402 /// Whether the underlying EML model has been trained.
403 pub fn is_trained(&self) -> bool {
404 self.model.is_trained()
405 }
406
407 /// Number of training samples collected.
408 pub fn training_sample_count(&self) -> usize {
409 self.training_data.len()
410 }
411
412 /// Predict with EML correction applied to analytical formula.
413 ///
414 /// When the model is untrained, returns the pure analytical prediction.
415 /// When trained, adds a learned correction that accounts for higher-order
416 /// effects like Fiedler staleness and multi-edge batching.
417 pub fn predict(&self, features: &CollapseFeatures) -> f64 {
418 let analytical =
419 predict_delta_lambda2(features.fiedler_u, features.fiedler_v, features.edge_weight);
420
421 if !self.model.is_trained() {
422 return analytical;
423 }
424
425 // EML learns the CORRECTION to the analytical formula
426 let input_vec = features.to_vec();
427 let correction = self.model.predict_primary(&input_vec);
428 analytical + correction
429 }
430
431 /// Record a ground-truth observation for future training.
432 ///
433 /// `actual_delta` is the real change in lambda_2 measured after adding
434 /// the edge. The model trains on the residual (actual - analytical).
435 pub fn record(&mut self, features: CollapseFeatures, actual_delta: f64) {
436 let analytical =
437 predict_delta_lambda2(features.fiedler_u, features.fiedler_v, features.edge_weight);
438 let residual = actual_delta - analytical;
439 let input_vec = features.to_vec();
440
441 // Also record in the inner EML model's training buffer
442 self.model.record(&input_vec, &[Some(residual)]);
443
444 self.training_data.push(CollapseTrainingPoint {
445 features: input_vec,
446 residual,
447 });
448 }
449
450 /// Train the EML correction model.
451 ///
452 /// Requires at least 50 training samples. Returns `true` if the model
453 /// converged (MSE < 0.01).
454 pub fn train(&mut self) -> bool {
455 self.model.train()
456 }
457
458 /// Drain accumulated EML lifecycle events for ExoChain forwarding.
459 pub fn drain_events(&mut self) -> Vec<eml_core::EmlEvent> {
460 self.model.drain_events()
461 }
462}
463
464// ---------------------------------------------------------------------------
465// CausalGraph integration
466// ---------------------------------------------------------------------------
467
468impl CausalGraph {
469 /// Rank candidate edges by predicted coherence impact.
470 ///
471 /// Uses the Fiedler vector from a recent spectral analysis to predict
472 /// how each candidate edge would affect algebraic connectivity, without
473 /// actually modifying the graph.
474 ///
475 /// # Arguments
476 ///
477 /// - `candidates`: Slice of `(source_node_id, target_node_id, weight)`.
478 /// - `fiedler`: Fiedler vector (indexed by node position, not node ID).
479 /// - `lambda2`: Current algebraic connectivity.
480 ///
481 /// # Returns
482 ///
483 /// Rankings sorted by predicted delta descending (biggest impact first).
484 pub fn rank_candidates(
485 &self,
486 candidates: &[(u64, u64, f32)],
487 fiedler: &[f64],
488 lambda2: f64,
489 ) -> Vec<EvidenceRanking> {
490 rank_evidence_by_impact(fiedler, lambda2, candidates)
491 }
492}
493
494// ---------------------------------------------------------------------------
495// RPC types for daemon integration
496// ---------------------------------------------------------------------------
497
498/// Request payload for the `causal.rank` RPC endpoint.
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct CausalRankRequest {
501 /// Candidate edges to rank: `[(source_id, target_id, weight), ...]`
502 pub candidates: Vec<(u64, u64, f32)>,
503}
504
505/// Response payload for the `causal.rank` RPC endpoint.
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct CausalRankResponse {
508 /// Current algebraic connectivity before any additions.
509 pub current_lambda2: f64,
510 /// Ranked evidence by predicted coherence impact (descending).
511 pub rankings: Vec<EvidenceRanking>,
512}
513
514// ---------------------------------------------------------------------------
515// Coherence history tracker (for DEMOCRITUS integration)
516// ---------------------------------------------------------------------------
517
518/// Tracks coherence history for conversation cycle detection.
519///
520/// Intended to be held alongside the DEMOCRITUS loop state. Call
521/// [`push`] after each coherence measurement and [`check`] periodically
522/// to detect stuck or oscillating conversations.
523#[derive(Debug, Clone, Default)]
524pub struct CoherenceTracker {
525 history: Vec<f64>,
526}
527
528impl CoherenceTracker {
529 /// Create a new empty tracker.
530 pub fn new() -> Self {
531 Self {
532 history: Vec::new(),
533 }
534 }
535
536 /// Record a coherence measurement.
537 pub fn push(&mut self, coherence: f64) {
538 self.history.push(coherence);
539 }
540
541 /// Number of measurements recorded.
542 pub fn len(&self) -> usize {
543 self.history.len()
544 }
545
546 /// Whether no measurements have been recorded.
547 pub fn is_empty(&self) -> bool {
548 self.history.is_empty()
549 }
550
551 /// Check for conversation cycle.
552 ///
553 /// Returns `None` if there are fewer than `window` measurements.
554 pub fn check(&self, window: usize, threshold: f64) -> ConversationState {
555 detect_conversation_cycle(&self.history, window, threshold)
556 }
557
558 /// Access the raw history.
559 pub fn history(&self) -> &[f64] {
560 &self.history
561 }
562}
563
564// ---------------------------------------------------------------------------
565// Tests
566// ---------------------------------------------------------------------------
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use crate::causal::{CausalEdgeType, CausalGraph};
572
573 // -- predict_delta_lambda2 on known graphs ----------------------------------
574
575 #[test]
576 fn delta_lambda2_same_side_is_zero() {
577 // Two nodes with the same Fiedler component: delta should be 0.
578 let delta = predict_delta_lambda2(0.5, 0.5, 1.0);
579 assert!(delta.abs() < 1e-12, "same Fiedler components => delta=0");
580 }
581
582 #[test]
583 fn delta_lambda2_opposite_sides() {
584 // Nodes on opposite sides of the partition.
585 let delta = predict_delta_lambda2(0.5, -0.5, 1.0);
586 assert!(
587 (delta - 1.0).abs() < 1e-12,
588 "delta should be 1.0 * (0.5 - (-0.5))^2 = 1.0, got {delta}"
589 );
590 }
591
592 #[test]
593 fn delta_lambda2_weight_scaling() {
594 let delta = predict_delta_lambda2(1.0, 0.0, 2.0);
595 assert!(
596 (delta - 2.0).abs() < 1e-12,
597 "delta should scale with weight: 2.0 * 1.0^2 = 2.0, got {delta}"
598 );
599 }
600
601 #[test]
602 fn delta_lambda2_zero_weight() {
603 let delta = predict_delta_lambda2(1.0, -1.0, 0.0);
604 assert!(delta.abs() < 1e-12, "zero weight => zero delta");
605 }
606
607 #[test]
608 fn delta_lambda2_path_graph_fiedler() {
609 // Path graph P_4: Fiedler vector components are proportional to
610 // cos(pi * i / 4) for i = 0..3, normalized. For the perturbation
611 // formula test, use unnormalized values.
612 let phi = [0.653, 0.271, -0.271, -0.653]; // approximate P_4 Fiedler
613 // Edge bridging the cut (1->2) should have largest delta.
614 let delta_bridge = predict_delta_lambda2(phi[1], phi[2], 1.0);
615 // Edge within same side (0->1) should have smaller delta.
616 let delta_same = predict_delta_lambda2(phi[0], phi[1], 1.0);
617 assert!(
618 delta_bridge > delta_same,
619 "bridge edge should have larger delta: bridge={delta_bridge}, same={delta_same}"
620 );
621 }
622
623 #[test]
624 fn delta_lambda2_star_graph() {
625 // Star graph S_5: center node has Fiedler component 0, leaves are
626 // symmetric. Adding any edge to center has same delta.
627 let center = 0.0;
628 let leaf = 0.5;
629 let delta = predict_delta_lambda2(center, leaf, 1.0);
630 assert!(
631 (delta - 0.25).abs() < 1e-12,
632 "center-leaf delta = 1.0 * 0.5^2 = 0.25, got {delta}"
633 );
634 }
635
636 #[test]
637 fn delta_lambda2_complete_graph() {
638 // Complete graph K_n: all Fiedler components are equal (proportional to
639 // constant vector for the fully-connected case). Adding an edge has
640 // delta = 0 because all components are the same.
641 let phi_val = 1.0 / 5.0_f64.sqrt();
642 let delta = predict_delta_lambda2(phi_val, phi_val, 1.0);
643 assert!(
644 delta.abs() < 1e-12,
645 "K_n: all components equal => delta=0, got {delta}"
646 );
647 }
648
649 // -- rank_evidence_by_impact ------------------------------------------------
650
651 #[test]
652 fn ranking_order_descending() {
653 let fiedler = vec![0.5, -0.5, 0.1, -0.1];
654 let candidates = vec![(0, 1, 1.0f32), (2, 3, 1.0f32), (0, 2, 1.0f32)];
655 let rankings = rank_evidence_by_impact(&fiedler, 1.0, &candidates);
656
657 // (0, 1) bridges the cut with components 0.5 and -0.5 => delta = 1.0
658 // (2, 3) bridges a small gap: 0.1 and -0.1 => delta = 0.04
659 // (0, 2) same side-ish: 0.5 and 0.1 => delta = 0.16
660 assert_eq!(rankings.len(), 3);
661 assert!(
662 rankings[0].predicted_delta >= rankings[1].predicted_delta,
663 "rankings should be descending"
664 );
665 assert!(
666 rankings[1].predicted_delta >= rankings[2].predicted_delta,
667 "rankings should be descending"
668 );
669 }
670
671 #[test]
672 fn ranking_bridge_explanation() {
673 let fiedler = vec![0.5, -0.5];
674 let candidates = vec![(0, 1, 1.0f32)];
675 let rankings = rank_evidence_by_impact(&fiedler, 1.0, &candidates);
676 assert_eq!(rankings.len(), 1);
677 assert!(
678 rankings[0].explanation.contains("Bridges"),
679 "opposite-sign nodes should get bridge explanation: {}",
680 rankings[0].explanation
681 );
682 }
683
684 #[test]
685 fn ranking_empty_candidates() {
686 let fiedler = vec![0.5, -0.5];
687 let rankings = rank_evidence_by_impact(&fiedler, 1.0, &[]);
688 assert!(rankings.is_empty());
689 }
690
691 #[test]
692 fn ranking_out_of_bounds_node_ids() {
693 // Node IDs beyond Fiedler vector length default to phi=0.0.
694 let fiedler = vec![0.5, -0.5];
695 let candidates = vec![(0, 99, 1.0f32)];
696 let rankings = rank_evidence_by_impact(&fiedler, 1.0, &candidates);
697 assert_eq!(rankings.len(), 1);
698 // phi[0]=0.5, phi[99]=0.0 => delta = 1.0 * 0.25 = 0.25
699 assert!(
700 (rankings[0].predicted_delta - 0.25).abs() < 1e-12,
701 "out-of-bounds defaults to 0.0"
702 );
703 }
704
705 // -- detect_conversation_cycle all 4 states ---------------------------------
706
707 #[test]
708 fn cycle_too_early() {
709 let history = vec![1.0, 2.0];
710 let state = detect_conversation_cycle(&history, 5, 0.01);
711 assert!(matches!(state, ConversationState::TooEarly));
712 }
713
714 #[test]
715 fn cycle_converging() {
716 // Steadily increasing coherence.
717 let history: Vec<f64> = (0..20).map(|i| i as f64 * 0.1).collect();
718 let state = detect_conversation_cycle(&history, 10, 0.01);
719 match state {
720 ConversationState::Converging { rate } => {
721 assert!(rate > 0.0, "converging rate should be positive");
722 }
723 other => panic!("expected Converging, got {:?}", other),
724 }
725 }
726
727 #[test]
728 fn cycle_diverging() {
729 // Steadily decreasing coherence.
730 let history: Vec<f64> = (0..20).map(|i| 2.0 - i as f64 * 0.1).collect();
731 let state = detect_conversation_cycle(&history, 10, 0.01);
732 match state {
733 ConversationState::Diverging { rate } => {
734 assert!(rate > 0.0, "diverging rate should be positive");
735 }
736 other => panic!("expected Diverging, got {:?}", other),
737 }
738 }
739
740 #[test]
741 fn cycle_stuck() {
742 // Flat coherence.
743 let history = vec![1.0; 20];
744 let state = detect_conversation_cycle(&history, 10, 0.01);
745 match state {
746 ConversationState::Stuck { net_change } => {
747 assert!(
748 net_change < 0.01,
749 "stuck net_change should be below threshold"
750 );
751 }
752 other => panic!("expected Stuck, got {:?}", other),
753 }
754 }
755
756 #[test]
757 fn cycle_oscillating() {
758 // Alternating high/low values with near-zero net change.
759 // Window of 10 starts and ends on the same value (even indices = 1.0)
760 // so net_change is 0.0, but max_swing is 0.1 which exceeds 2*threshold.
761 let mut history = Vec::new();
762 for i in 0..21 {
763 if i % 2 == 0 {
764 history.push(1.0);
765 } else {
766 history.push(1.1);
767 }
768 }
769 // Window = 11 entries: starts at 1.0, ends at 1.0 => net_change = 0
770 let state = detect_conversation_cycle(&history, 11, 0.01);
771 match state {
772 ConversationState::Oscillating {
773 net_change,
774 max_swing,
775 } => {
776 assert!(net_change < 0.01, "net change should be below threshold");
777 assert!(max_swing > 0.02, "max swing should exceed 2*threshold");
778 }
779 other => panic!("expected Oscillating, got {:?}", other),
780 }
781 }
782
783 // -- CausalCollapseModel untrained fallback ---------------------------------
784
785 #[test]
786 fn collapse_model_untrained_fallback() {
787 let model = CausalCollapseModel::new();
788 assert!(!model.is_trained());
789
790 let features = CollapseFeatures {
791 fiedler_u: 0.5,
792 fiedler_v: -0.5,
793 edge_weight: 1.0,
794 current_lambda2: 2.0,
795 spectral_gap: 1.0,
796 graph_density: 0.3,
797 node_count: 10.0,
798 degree_u: 3.0,
799 degree_v: 4.0,
800 };
801
802 let predicted = model.predict(&features);
803 let analytical = predict_delta_lambda2(0.5, -0.5, 1.0);
804 assert!(
805 (predicted - analytical).abs() < 1e-12,
806 "untrained model should return pure analytical: expected {analytical}, got {predicted}"
807 );
808 }
809
810 #[test]
811 fn collapse_model_record_and_count() {
812 let mut model = CausalCollapseModel::new();
813 assert_eq!(model.training_sample_count(), 0);
814
815 let features = CollapseFeatures {
816 fiedler_u: 0.3,
817 fiedler_v: -0.2,
818 edge_weight: 0.8,
819 current_lambda2: 1.5,
820 spectral_gap: 0.5,
821 graph_density: 0.4,
822 node_count: 20.0,
823 degree_u: 5.0,
824 degree_v: 3.0,
825 };
826
827 model.record(features, 0.3);
828 assert_eq!(model.training_sample_count(), 1);
829 }
830
831 #[test]
832 fn collapse_model_trained_produces_finite() {
833 let mut model = CausalCollapseModel::new();
834
835 // Generate enough training data
836 for i in 0..80 {
837 let u = (i as f64 / 80.0) - 0.5;
838 let v = -u;
839 let w = 0.5 + (i as f64 / 160.0);
840 let analytical = predict_delta_lambda2(u, v, w);
841 // Simulate a small correction (10% error)
842 let actual = analytical * 1.1;
843
844 let features = CollapseFeatures {
845 fiedler_u: u,
846 fiedler_v: v,
847 edge_weight: w,
848 current_lambda2: 2.0,
849 spectral_gap: 1.0,
850 graph_density: 0.3,
851 node_count: 50.0,
852 degree_u: 5.0,
853 degree_v: 5.0,
854 };
855 model.record(features, actual);
856 }
857
858 // Training should not panic
859 let _ = model.train();
860
861 // Prediction should be finite
862 let features = CollapseFeatures {
863 fiedler_u: 0.3,
864 fiedler_v: -0.3,
865 edge_weight: 1.0,
866 current_lambda2: 2.0,
867 spectral_gap: 1.0,
868 graph_density: 0.3,
869 node_count: 50.0,
870 degree_u: 5.0,
871 degree_v: 5.0,
872 };
873 let predicted = model.predict(&features);
874 assert!(predicted.is_finite(), "trained prediction should be finite");
875 }
876
877 // -- CausalGraph::rank_candidates convenience method -----------------------
878
879 #[test]
880 fn causal_graph_rank_candidates() {
881 let g = CausalGraph::new();
882 let a = g.add_node("A".into(), serde_json::json!({}));
883 let b = g.add_node("B".into(), serde_json::json!({}));
884 let c = g.add_node("C".into(), serde_json::json!({}));
885 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
886
887 // Synthetic Fiedler vector: A and B on same side, C on other.
888 let fiedler = vec![0.0; a as usize]
889 .into_iter()
890 .chain(std::iter::once(0.5)) // a
891 .chain(std::iter::once(0.3)) // b
892 .chain(std::iter::once(-0.6)) // c
893 .collect::<Vec<f64>>();
894
895 let candidates = vec![(a, c, 1.0f32), (b, c, 1.0f32), (a, b, 1.0f32)];
896 let rankings = g.rank_candidates(&candidates, &fiedler, 1.0);
897
898 assert_eq!(rankings.len(), 3);
899 // (a,c) delta = (0.5-(-0.6))^2 = 1.21
900 // (b,c) delta = (0.3-(-0.6))^2 = 0.81
901 // (a,b) delta = (0.5-0.3)^2 = 0.04
902 assert_eq!(rankings[0].source, a);
903 assert_eq!(rankings[0].target, c);
904 }
905
906 // -- CoherenceTracker -------------------------------------------------------
907
908 #[test]
909 fn coherence_tracker_basic() {
910 let mut tracker = CoherenceTracker::new();
911 assert!(tracker.is_empty());
912 assert_eq!(tracker.len(), 0);
913
914 tracker.push(1.0);
915 tracker.push(1.1);
916 tracker.push(1.2);
917 assert_eq!(tracker.len(), 3);
918
919 let state = tracker.check(3, 0.01);
920 assert!(matches!(state, ConversationState::Converging { .. }));
921 }
922
923 // -- Serialization roundtrip ------------------------------------------------
924
925 #[test]
926 fn evidence_ranking_serde() {
927 let r = EvidenceRanking {
928 source: 1,
929 target: 2,
930 weight: 0.5,
931 predicted_delta: 0.42,
932 explanation: "test".into(),
933 };
934 let json = serde_json::to_string(&r).unwrap();
935 let restored: EvidenceRanking = serde_json::from_str(&json).unwrap();
936 assert_eq!(restored.source, 1);
937 assert_eq!(restored.target, 2);
938 assert!((restored.predicted_delta - 0.42).abs() < 1e-12);
939 }
940
941 #[test]
942 fn conversation_state_serde() {
943 let state = ConversationState::Oscillating {
944 net_change: 0.01,
945 max_swing: 0.15,
946 };
947 let json = serde_json::to_string(&state).unwrap();
948 let _restored: ConversationState = serde_json::from_str(&json).unwrap();
949 }
950
951 #[test]
952 fn causal_rank_request_serde() {
953 let req = CausalRankRequest {
954 candidates: vec![(1, 2, 0.5), (3, 4, 0.8)],
955 };
956 let json = serde_json::to_string(&req).unwrap();
957 let restored: CausalRankRequest = serde_json::from_str(&json).unwrap();
958 assert_eq!(restored.candidates.len(), 2);
959 }
960
961 #[test]
962 fn collapse_model_serde() {
963 let model = CausalCollapseModel::new();
964 let json = serde_json::to_string(&model).unwrap();
965 let restored: CausalCollapseModel = serde_json::from_str(&json).unwrap();
966 assert!(!restored.is_trained());
967 }
968
969 // ===================================================================
970 // KG-005: Information Gain Pruning — is_redundant
971 // ===================================================================
972
973 #[test]
974 fn redundant_same_side_low_delta_bridge_exists() {
975 // Fiedler: nodes 0,1 on positive side; nodes 2,3 on negative side.
976 let fiedler = vec![0.5, 0.3, -0.4, -0.6];
977 // Recent addition bridged the partition: (1, 2) with opposite signs.
978 let recent = vec![(1u64, 2u64, 1.0f32)];
979 // Candidate: (0, 1) — same side, low delta.
980 let candidate = (0u64, 1u64, 0.1f32);
981 assert!(
982 is_redundant(&candidate, &recent, &fiedler, 0.05),
983 "same-side, low-delta, bridge exists => redundant"
984 );
985 }
986
987 #[test]
988 fn not_redundant_bridges_partition() {
989 let fiedler = vec![0.5, 0.3, -0.4, -0.6];
990 let recent: Vec<(u64, u64, f32)> = vec![];
991 // Candidate: (0, 2) — opposite sides.
992 let candidate = (0u64, 2u64, 1.0f32);
993 assert!(
994 !is_redundant(&candidate, &recent, &fiedler, 0.01),
995 "bridge edge should never be redundant"
996 );
997 }
998
999 #[test]
1000 fn not_redundant_high_delta() {
1001 let fiedler = vec![0.5, 0.3, -0.4, -0.6];
1002 let recent = vec![(1u64, 2u64, 1.0f32)];
1003 // Candidate: (0, 1) same side but with high weight => high delta.
1004 let candidate = (0u64, 1u64, 10.0f32);
1005 let phi_u = fiedler[0];
1006 let phi_v = fiedler[1];
1007 let delta = predict_delta_lambda2(phi_u, phi_v, 10.0);
1008 // With threshold lower than delta, delta >= threshold so not redundant.
1009 assert!(
1010 !is_redundant(&candidate, &recent, &fiedler, delta - 0.001),
1011 "high-delta edge should not be redundant when delta >= threshold"
1012 );
1013 }
1014
1015 #[test]
1016 fn not_redundant_no_recent_bridge() {
1017 let fiedler = vec![0.5, 0.3, -0.4, -0.6];
1018 // No recent additions bridged the partition.
1019 let recent = vec![(0u64, 1u64, 1.0f32)]; // same side
1020 let candidate = (0u64, 1u64, 0.01f32);
1021 assert!(
1022 !is_redundant(&candidate, &recent, &fiedler, 0.05),
1023 "no bridge in recent => not redundant even if same side + low delta"
1024 );
1025 }
1026
1027 #[test]
1028 fn not_redundant_empty_recent() {
1029 let fiedler = vec![0.5, 0.3, -0.4, -0.6];
1030 let recent: Vec<(u64, u64, f32)> = vec![];
1031 let candidate = (0u64, 1u64, 0.01f32);
1032 assert!(
1033 !is_redundant(&candidate, &recent, &fiedler, 0.05),
1034 "empty recent => not redundant"
1035 );
1036 }
1037
1038 #[test]
1039 fn redundant_with_zero_fiedler_components() {
1040 // phi=0 treated as "same side" — candidates connecting to unknown
1041 // nodes are conservative (potentially redundant).
1042 let fiedler = vec![0.5, 0.0];
1043 let recent = vec![(0u64, 99u64, 2.0f32)]; // won't bridge since phi[99]=0
1044 let candidate = (0u64, 1u64, 0.001f32);
1045 // phi[0]=0.5, phi[1]=0.0 => same side (zero treated as no info).
1046 // delta = 0.001 * 0.25 = 0.00025 < threshold.
1047 // But recent (0,99) has phi[99]=0 => not opposite signs => no bridge.
1048 assert!(
1049 !is_redundant(&candidate, &recent, &fiedler, 0.01),
1050 "zero-component recent doesn't count as bridge"
1051 );
1052 }
1053
1054 #[test]
1055 fn redundant_dense_graph_scenario() {
1056 // Simulate a dense region where many edges are redundant.
1057 // Positive cluster: 0,1,2,3. Negative: 4,5.
1058 let fiedler = vec![0.4, 0.3, 0.2, 0.1, -0.5, -0.6];
1059 // One edge already bridged the partition.
1060 let recent = vec![(3u64, 4u64, 1.0f32)];
1061 // Many within-cluster candidates should be redundant.
1062 let c1 = (0u64, 1u64, 0.01f32);
1063 let c2 = (1u64, 2u64, 0.01f32);
1064 let c3 = (2u64, 3u64, 0.01f32);
1065 assert!(is_redundant(&c1, &recent, &fiedler, 0.01));
1066 assert!(is_redundant(&c2, &recent, &fiedler, 0.01));
1067 assert!(is_redundant(&c3, &recent, &fiedler, 0.01));
1068 }
1069}