Skip to main content

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}