use serde::{Deserialize, Serialize};
use crate::causal::CausalGraph;
pub fn predict_delta_lambda2(fiedler_u: f64, fiedler_v: f64, edge_weight: f64) -> f64 {
edge_weight * (fiedler_u - fiedler_v).powi(2)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceRanking {
pub source: u64,
pub target: u64,
pub weight: f32,
pub predicted_delta: f64,
pub explanation: String,
}
pub fn rank_evidence_by_impact(
fiedler: &[f64],
_current_lambda2: f64,
candidates: &[(u64, u64, f32)],
) -> Vec<EvidenceRanking> {
let mut rankings: Vec<EvidenceRanking> = candidates
.iter()
.map(|&(src, tgt, w)| {
let phi_u = fiedler.get(src as usize).copied().unwrap_or(0.0);
let phi_v = fiedler.get(tgt as usize).copied().unwrap_or(0.0);
let delta = predict_delta_lambda2(phi_u, phi_v, w as f64);
let explanation = if phi_u.signum() != phi_v.signum()
&& phi_u.abs() > 1e-12
&& phi_v.abs() > 1e-12
{
format!(
"Bridges the graph partition (phi[{}]={:.3}, phi[{}]={:.3})",
src, phi_u, tgt, phi_v
)
} else if delta > 0.1 {
format!("Strengthens weak connection (delta_lambda2={:.4})", delta)
} else {
format!("Reinforces existing cluster (delta_lambda2={:.4})", delta)
};
EvidenceRanking {
source: src,
target: tgt,
weight: w,
predicted_delta: delta,
explanation,
}
})
.collect();
rankings.sort_by(|a, b| {
b.predicted_delta
.partial_cmp(&a.predicted_delta)
.unwrap_or(std::cmp::Ordering::Equal)
});
rankings
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ConversationState {
TooEarly,
Converging {
rate: f64,
},
Diverging {
rate: f64,
},
Stuck {
net_change: f64,
},
Oscillating {
net_change: f64,
max_swing: f64,
},
}
pub fn detect_conversation_cycle(
coherence_history: &[f64],
window: usize,
threshold: f64,
) -> ConversationState {
if coherence_history.len() < window || window < 2 {
return ConversationState::TooEarly;
}
let recent = &coherence_history[coherence_history.len() - window..];
let first = recent[0];
let last = recent[recent.len() - 1];
let total_change = (last - first).abs();
let max_swing = recent
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0.0f64, f64::max);
if total_change < threshold {
if max_swing > threshold * 2.0 {
ConversationState::Oscillating {
net_change: total_change,
max_swing,
}
} else {
ConversationState::Stuck {
net_change: total_change,
}
}
} else if last > first {
ConversationState::Converging {
rate: total_change / window as f64,
}
} else {
ConversationState::Diverging {
rate: total_change / window as f64,
}
}
}
pub fn is_redundant(
candidate: &(u64, u64, f32),
recent_additions: &[(u64, u64, f32)],
fiedler: &[f64],
threshold: f64,
) -> bool {
let (src, tgt, w) = *candidate;
let phi_src = fiedler.get(src as usize).copied().unwrap_or(0.0);
let phi_tgt = fiedler.get(tgt as usize).copied().unwrap_or(0.0);
let same_side = phi_src.signum() == phi_tgt.signum()
|| phi_src.abs() < 1e-12
|| phi_tgt.abs() < 1e-12;
if !same_side {
return false;
}
let delta = predict_delta_lambda2(phi_src, phi_tgt, w as f64);
if delta >= threshold {
return false;
}
let partition_already_bridged = recent_additions.iter().any(|&(rs, rt, rw)| {
let phi_rs = fiedler.get(rs as usize).copied().unwrap_or(0.0);
let phi_rt = fiedler.get(rt as usize).copied().unwrap_or(0.0);
let opposite_signs = phi_rs.signum() != phi_rt.signum()
&& phi_rs.abs() > 1e-12
&& phi_rt.abs() > 1e-12;
let recent_delta = predict_delta_lambda2(phi_rs, phi_rt, rw as f64);
opposite_signs && recent_delta > threshold
});
partition_already_bridged
}
#[derive(Debug, Clone)]
pub struct CollapseFeatures {
pub fiedler_u: f64,
pub fiedler_v: f64,
pub edge_weight: f64,
pub current_lambda2: f64,
pub spectral_gap: f64,
pub graph_density: f64,
pub node_count: f64,
pub degree_u: f64,
pub degree_v: f64,
}
impl CollapseFeatures {
fn to_vec(&self) -> Vec<f64> {
vec![
self.fiedler_u.clamp(-1.0, 1.0),
self.fiedler_v.clamp(-1.0, 1.0),
self.edge_weight / 10.0,
self.current_lambda2 / 100.0,
self.spectral_gap / 100.0,
self.graph_density,
self.node_count / 10000.0,
self.degree_u / 1000.0,
self.degree_v / 1000.0,
]
}
}
#[derive(Debug, Clone)]
struct CollapseTrainingPoint {
features: Vec<f64>,
residual: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CausalCollapseModel {
model: eml_core::EmlModel,
#[serde(skip)]
training_data: Vec<CollapseTrainingPoint>,
}
impl Default for CausalCollapseModel {
fn default() -> Self {
Self::new()
}
}
impl CausalCollapseModel {
pub fn new() -> Self {
let mut model = eml_core::EmlModel::new(3, 9, 1);
model.set_model_name("causal_collapse");
Self {
model,
training_data: Vec::new(),
}
}
pub fn is_trained(&self) -> bool {
self.model.is_trained()
}
pub fn training_sample_count(&self) -> usize {
self.training_data.len()
}
pub fn predict(&self, features: &CollapseFeatures) -> f64 {
let analytical =
predict_delta_lambda2(features.fiedler_u, features.fiedler_v, features.edge_weight);
if !self.model.is_trained() {
return analytical;
}
let input_vec = features.to_vec();
let correction = self.model.predict_primary(&input_vec);
analytical + correction
}
pub fn record(&mut self, features: CollapseFeatures, actual_delta: f64) {
let analytical =
predict_delta_lambda2(features.fiedler_u, features.fiedler_v, features.edge_weight);
let residual = actual_delta - analytical;
let input_vec = features.to_vec();
self.model.record(&input_vec, &[Some(residual)]);
self.training_data.push(CollapseTrainingPoint {
features: input_vec,
residual,
});
}
pub fn train(&mut self) -> bool {
self.model.train()
}
pub fn drain_events(&mut self) -> Vec<eml_core::EmlEvent> {
self.model.drain_events()
}
}
impl CausalGraph {
pub fn rank_candidates(
&self,
candidates: &[(u64, u64, f32)],
fiedler: &[f64],
lambda2: f64,
) -> Vec<EvidenceRanking> {
rank_evidence_by_impact(fiedler, lambda2, candidates)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CausalRankRequest {
pub candidates: Vec<(u64, u64, f32)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CausalRankResponse {
pub current_lambda2: f64,
pub rankings: Vec<EvidenceRanking>,
}
#[derive(Debug, Clone, Default)]
pub struct CoherenceTracker {
history: Vec<f64>,
}
impl CoherenceTracker {
pub fn new() -> Self {
Self {
history: Vec::new(),
}
}
pub fn push(&mut self, coherence: f64) {
self.history.push(coherence);
}
pub fn len(&self) -> usize {
self.history.len()
}
pub fn is_empty(&self) -> bool {
self.history.is_empty()
}
pub fn check(&self, window: usize, threshold: f64) -> ConversationState {
detect_conversation_cycle(&self.history, window, threshold)
}
pub fn history(&self) -> &[f64] {
&self.history
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::causal::{CausalEdgeType, CausalGraph};
#[test]
fn delta_lambda2_same_side_is_zero() {
let delta = predict_delta_lambda2(0.5, 0.5, 1.0);
assert!(delta.abs() < 1e-12, "same Fiedler components => delta=0");
}
#[test]
fn delta_lambda2_opposite_sides() {
let delta = predict_delta_lambda2(0.5, -0.5, 1.0);
assert!(
(delta - 1.0).abs() < 1e-12,
"delta should be 1.0 * (0.5 - (-0.5))^2 = 1.0, got {delta}"
);
}
#[test]
fn delta_lambda2_weight_scaling() {
let delta = predict_delta_lambda2(1.0, 0.0, 2.0);
assert!(
(delta - 2.0).abs() < 1e-12,
"delta should scale with weight: 2.0 * 1.0^2 = 2.0, got {delta}"
);
}
#[test]
fn delta_lambda2_zero_weight() {
let delta = predict_delta_lambda2(1.0, -1.0, 0.0);
assert!(delta.abs() < 1e-12, "zero weight => zero delta");
}
#[test]
fn delta_lambda2_path_graph_fiedler() {
let phi = [0.653, 0.271, -0.271, -0.653]; let delta_bridge = predict_delta_lambda2(phi[1], phi[2], 1.0);
let delta_same = predict_delta_lambda2(phi[0], phi[1], 1.0);
assert!(
delta_bridge > delta_same,
"bridge edge should have larger delta: bridge={delta_bridge}, same={delta_same}"
);
}
#[test]
fn delta_lambda2_star_graph() {
let center = 0.0;
let leaf = 0.5;
let delta = predict_delta_lambda2(center, leaf, 1.0);
assert!(
(delta - 0.25).abs() < 1e-12,
"center-leaf delta = 1.0 * 0.5^2 = 0.25, got {delta}"
);
}
#[test]
fn delta_lambda2_complete_graph() {
let phi_val = 1.0 / 5.0_f64.sqrt();
let delta = predict_delta_lambda2(phi_val, phi_val, 1.0);
assert!(
delta.abs() < 1e-12,
"K_n: all components equal => delta=0, got {delta}"
);
}
#[test]
fn ranking_order_descending() {
let fiedler = vec![0.5, -0.5, 0.1, -0.1];
let candidates = vec![(0, 1, 1.0f32), (2, 3, 1.0f32), (0, 2, 1.0f32)];
let rankings = rank_evidence_by_impact(&fiedler, 1.0, &candidates);
assert_eq!(rankings.len(), 3);
assert!(
rankings[0].predicted_delta >= rankings[1].predicted_delta,
"rankings should be descending"
);
assert!(
rankings[1].predicted_delta >= rankings[2].predicted_delta,
"rankings should be descending"
);
}
#[test]
fn ranking_bridge_explanation() {
let fiedler = vec![0.5, -0.5];
let candidates = vec![(0, 1, 1.0f32)];
let rankings = rank_evidence_by_impact(&fiedler, 1.0, &candidates);
assert_eq!(rankings.len(), 1);
assert!(
rankings[0].explanation.contains("Bridges"),
"opposite-sign nodes should get bridge explanation: {}",
rankings[0].explanation
);
}
#[test]
fn ranking_empty_candidates() {
let fiedler = vec![0.5, -0.5];
let rankings = rank_evidence_by_impact(&fiedler, 1.0, &[]);
assert!(rankings.is_empty());
}
#[test]
fn ranking_out_of_bounds_node_ids() {
let fiedler = vec![0.5, -0.5];
let candidates = vec![(0, 99, 1.0f32)];
let rankings = rank_evidence_by_impact(&fiedler, 1.0, &candidates);
assert_eq!(rankings.len(), 1);
assert!(
(rankings[0].predicted_delta - 0.25).abs() < 1e-12,
"out-of-bounds defaults to 0.0"
);
}
#[test]
fn cycle_too_early() {
let history = vec![1.0, 2.0];
let state = detect_conversation_cycle(&history, 5, 0.01);
assert!(matches!(state, ConversationState::TooEarly));
}
#[test]
fn cycle_converging() {
let history: Vec<f64> = (0..20).map(|i| i as f64 * 0.1).collect();
let state = detect_conversation_cycle(&history, 10, 0.01);
match state {
ConversationState::Converging { rate } => {
assert!(rate > 0.0, "converging rate should be positive");
}
other => panic!("expected Converging, got {:?}", other),
}
}
#[test]
fn cycle_diverging() {
let history: Vec<f64> = (0..20).map(|i| 2.0 - i as f64 * 0.1).collect();
let state = detect_conversation_cycle(&history, 10, 0.01);
match state {
ConversationState::Diverging { rate } => {
assert!(rate > 0.0, "diverging rate should be positive");
}
other => panic!("expected Diverging, got {:?}", other),
}
}
#[test]
fn cycle_stuck() {
let history = vec![1.0; 20];
let state = detect_conversation_cycle(&history, 10, 0.01);
match state {
ConversationState::Stuck { net_change } => {
assert!(
net_change < 0.01,
"stuck net_change should be below threshold"
);
}
other => panic!("expected Stuck, got {:?}", other),
}
}
#[test]
fn cycle_oscillating() {
let mut history = Vec::new();
for i in 0..21 {
if i % 2 == 0 {
history.push(1.0);
} else {
history.push(1.1);
}
}
let state = detect_conversation_cycle(&history, 11, 0.01);
match state {
ConversationState::Oscillating {
net_change,
max_swing,
} => {
assert!(net_change < 0.01, "net change should be below threshold");
assert!(max_swing > 0.02, "max swing should exceed 2*threshold");
}
other => panic!("expected Oscillating, got {:?}", other),
}
}
#[test]
fn collapse_model_untrained_fallback() {
let model = CausalCollapseModel::new();
assert!(!model.is_trained());
let features = CollapseFeatures {
fiedler_u: 0.5,
fiedler_v: -0.5,
edge_weight: 1.0,
current_lambda2: 2.0,
spectral_gap: 1.0,
graph_density: 0.3,
node_count: 10.0,
degree_u: 3.0,
degree_v: 4.0,
};
let predicted = model.predict(&features);
let analytical = predict_delta_lambda2(0.5, -0.5, 1.0);
assert!(
(predicted - analytical).abs() < 1e-12,
"untrained model should return pure analytical: expected {analytical}, got {predicted}"
);
}
#[test]
fn collapse_model_record_and_count() {
let mut model = CausalCollapseModel::new();
assert_eq!(model.training_sample_count(), 0);
let features = CollapseFeatures {
fiedler_u: 0.3,
fiedler_v: -0.2,
edge_weight: 0.8,
current_lambda2: 1.5,
spectral_gap: 0.5,
graph_density: 0.4,
node_count: 20.0,
degree_u: 5.0,
degree_v: 3.0,
};
model.record(features, 0.3);
assert_eq!(model.training_sample_count(), 1);
}
#[test]
fn collapse_model_trained_produces_finite() {
let mut model = CausalCollapseModel::new();
for i in 0..80 {
let u = (i as f64 / 80.0) - 0.5;
let v = -u;
let w = 0.5 + (i as f64 / 160.0);
let analytical = predict_delta_lambda2(u, v, w);
let actual = analytical * 1.1;
let features = CollapseFeatures {
fiedler_u: u,
fiedler_v: v,
edge_weight: w,
current_lambda2: 2.0,
spectral_gap: 1.0,
graph_density: 0.3,
node_count: 50.0,
degree_u: 5.0,
degree_v: 5.0,
};
model.record(features, actual);
}
let _ = model.train();
let features = CollapseFeatures {
fiedler_u: 0.3,
fiedler_v: -0.3,
edge_weight: 1.0,
current_lambda2: 2.0,
spectral_gap: 1.0,
graph_density: 0.3,
node_count: 50.0,
degree_u: 5.0,
degree_v: 5.0,
};
let predicted = model.predict(&features);
assert!(predicted.is_finite(), "trained prediction should be finite");
}
#[test]
fn causal_graph_rank_candidates() {
let g = CausalGraph::new();
let a = g.add_node("A".into(), serde_json::json!({}));
let b = g.add_node("B".into(), serde_json::json!({}));
let c = g.add_node("C".into(), serde_json::json!({}));
g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
let fiedler = vec![0.0; a as usize]
.into_iter()
.chain(std::iter::once(0.5)) .chain(std::iter::once(0.3)) .chain(std::iter::once(-0.6)) .collect::<Vec<f64>>();
let candidates = vec![(a, c, 1.0f32), (b, c, 1.0f32), (a, b, 1.0f32)];
let rankings = g.rank_candidates(&candidates, &fiedler, 1.0);
assert_eq!(rankings.len(), 3);
assert_eq!(rankings[0].source, a);
assert_eq!(rankings[0].target, c);
}
#[test]
fn coherence_tracker_basic() {
let mut tracker = CoherenceTracker::new();
assert!(tracker.is_empty());
assert_eq!(tracker.len(), 0);
tracker.push(1.0);
tracker.push(1.1);
tracker.push(1.2);
assert_eq!(tracker.len(), 3);
let state = tracker.check(3, 0.01);
assert!(matches!(state, ConversationState::Converging { .. }));
}
#[test]
fn evidence_ranking_serde() {
let r = EvidenceRanking {
source: 1,
target: 2,
weight: 0.5,
predicted_delta: 0.42,
explanation: "test".into(),
};
let json = serde_json::to_string(&r).unwrap();
let restored: EvidenceRanking = serde_json::from_str(&json).unwrap();
assert_eq!(restored.source, 1);
assert_eq!(restored.target, 2);
assert!((restored.predicted_delta - 0.42).abs() < 1e-12);
}
#[test]
fn conversation_state_serde() {
let state = ConversationState::Oscillating {
net_change: 0.01,
max_swing: 0.15,
};
let json = serde_json::to_string(&state).unwrap();
let _restored: ConversationState = serde_json::from_str(&json).unwrap();
}
#[test]
fn causal_rank_request_serde() {
let req = CausalRankRequest {
candidates: vec![(1, 2, 0.5), (3, 4, 0.8)],
};
let json = serde_json::to_string(&req).unwrap();
let restored: CausalRankRequest = serde_json::from_str(&json).unwrap();
assert_eq!(restored.candidates.len(), 2);
}
#[test]
fn collapse_model_serde() {
let model = CausalCollapseModel::new();
let json = serde_json::to_string(&model).unwrap();
let restored: CausalCollapseModel = serde_json::from_str(&json).unwrap();
assert!(!restored.is_trained());
}
#[test]
fn redundant_same_side_low_delta_bridge_exists() {
let fiedler = vec![0.5, 0.3, -0.4, -0.6];
let recent = vec![(1u64, 2u64, 1.0f32)];
let candidate = (0u64, 1u64, 0.1f32);
assert!(
is_redundant(&candidate, &recent, &fiedler, 0.05),
"same-side, low-delta, bridge exists => redundant"
);
}
#[test]
fn not_redundant_bridges_partition() {
let fiedler = vec![0.5, 0.3, -0.4, -0.6];
let recent: Vec<(u64, u64, f32)> = vec![];
let candidate = (0u64, 2u64, 1.0f32);
assert!(
!is_redundant(&candidate, &recent, &fiedler, 0.01),
"bridge edge should never be redundant"
);
}
#[test]
fn not_redundant_high_delta() {
let fiedler = vec![0.5, 0.3, -0.4, -0.6];
let recent = vec![(1u64, 2u64, 1.0f32)];
let candidate = (0u64, 1u64, 10.0f32);
let phi_u = fiedler[0];
let phi_v = fiedler[1];
let delta = predict_delta_lambda2(phi_u, phi_v, 10.0);
assert!(
!is_redundant(&candidate, &recent, &fiedler, delta - 0.001),
"high-delta edge should not be redundant when delta >= threshold"
);
}
#[test]
fn not_redundant_no_recent_bridge() {
let fiedler = vec![0.5, 0.3, -0.4, -0.6];
let recent = vec![(0u64, 1u64, 1.0f32)]; let candidate = (0u64, 1u64, 0.01f32);
assert!(
!is_redundant(&candidate, &recent, &fiedler, 0.05),
"no bridge in recent => not redundant even if same side + low delta"
);
}
#[test]
fn not_redundant_empty_recent() {
let fiedler = vec![0.5, 0.3, -0.4, -0.6];
let recent: Vec<(u64, u64, f32)> = vec![];
let candidate = (0u64, 1u64, 0.01f32);
assert!(
!is_redundant(&candidate, &recent, &fiedler, 0.05),
"empty recent => not redundant"
);
}
#[test]
fn redundant_with_zero_fiedler_components() {
let fiedler = vec![0.5, 0.0];
let recent = vec![(0u64, 99u64, 2.0f32)]; let candidate = (0u64, 1u64, 0.001f32);
assert!(
!is_redundant(&candidate, &recent, &fiedler, 0.01),
"zero-component recent doesn't count as bridge"
);
}
#[test]
fn redundant_dense_graph_scenario() {
let fiedler = vec![0.4, 0.3, 0.2, 0.1, -0.5, -0.6];
let recent = vec![(3u64, 4u64, 1.0f32)];
let c1 = (0u64, 1u64, 0.01f32);
let c2 = (1u64, 2u64, 0.01f32);
let c3 = (2u64, 3u64, 0.01f32);
assert!(is_redundant(&c1, &recent, &fiedler, 0.01));
assert!(is_redundant(&c2, &recent, &fiedler, 0.01));
assert!(is_redundant(&c3, &recent, &fiedler, 0.01));
}
}