#![cfg(feature = "integration")]
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::decoder::MessagePassingResult;
use crate::types::GraphEdgeType;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FactorKind {
Semantic,
Temporal,
Causal,
Entity,
}
impl FactorKind {
pub fn from_edge_type(edge_type: &GraphEdgeType) -> Self {
match edge_type {
GraphEdgeType::Semantic { .. } => FactorKind::Semantic,
GraphEdgeType::Temporal { .. } => FactorKind::Temporal,
GraphEdgeType::Causal { .. } => FactorKind::Causal,
GraphEdgeType::Entity { .. } => FactorKind::Entity,
}
}
pub fn default_weight(&self) -> f64 {
match self {
FactorKind::Semantic => 0.35,
FactorKind::Temporal => 0.20,
FactorKind::Causal => 0.30,
FactorKind::Entity => 0.15,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Factor {
pub source: String,
pub target: String,
pub kind: FactorKind,
pub edge_weight: f64,
pub metadata: Option<FactorMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FactorMetadata {
Semantic { cosine_similarity: Option<f64> },
Temporal { delta_secs: i64 },
Causal { confidence: f64 },
Entity { relation: String },
}
impl Factor {
pub fn compatibility(&self, source_belief: f64, target_belief: f64) -> f64 {
let base = match &self.metadata {
Some(FactorMetadata::Semantic { cosine_similarity }) => {
let cos = cosine_similarity.unwrap_or(1.0).abs();
let agreement = 1.0 - (source_belief - target_belief).abs();
cos * agreement
}
Some(FactorMetadata::Temporal { delta_secs }) => {
let half_life_secs = 2_592_000.0_f64;
let decay = (-delta_secs.abs() as f64 / half_life_secs).exp();
let agreement = 1.0 - (source_belief - target_belief).abs();
decay * agreement
}
Some(FactorMetadata::Causal { confidence }) => {
let agreement = 1.0 - (source_belief - target_belief).abs();
confidence * agreement
}
Some(FactorMetadata::Entity { relation: _ }) => {
let agreement = 1.0 - (source_belief - target_belief).abs();
self.edge_weight * agreement
}
None => {
let agreement = 1.0 - (source_belief - target_belief).abs();
self.edge_weight * agreement
}
};
base.clamp(0.0, 1.0)
}
}
#[derive(Debug, Clone)]
pub struct FactorGraphNode {
pub item_id: String,
pub initial_belief: f64,
pub factors: Vec<Factor>,
}
#[derive(Debug, Clone)]
pub struct FactorGraph {
pub nodes: HashMap<String, FactorGraphNode>,
pub config: FactorGraphConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FactorGraphConfig {
pub semantic_weight: f64,
pub temporal_weight: f64,
pub causal_weight: f64,
pub entity_weight: f64,
pub self_influence: f64,
pub max_iterations: usize,
pub convergence_threshold: f64,
}
impl Default for FactorGraphConfig {
fn default() -> Self {
Self {
semantic_weight: FactorKind::Semantic.default_weight(),
temporal_weight: FactorKind::Temporal.default_weight(),
causal_weight: FactorKind::Causal.default_weight(),
entity_weight: FactorKind::Entity.default_weight(),
self_influence: 0.6,
max_iterations: 50,
convergence_threshold: 0.001,
}
}
}
impl FactorGraphConfig {
pub fn weight_for(&self, kind: FactorKind) -> f64 {
match kind {
FactorKind::Semantic => self.semantic_weight,
FactorKind::Temporal => self.temporal_weight,
FactorKind::Causal => self.causal_weight,
FactorKind::Entity => self.entity_weight,
}
}
pub fn normalized_weights(&self) -> (f64, f64, f64, f64) {
let total = self.semantic_weight
+ self.temporal_weight
+ self.causal_weight
+ self.entity_weight;
if total <= 0.0 {
return (0.25, 0.25, 0.25, 0.25);
}
(
self.semantic_weight / total,
self.temporal_weight / total,
self.causal_weight / total,
self.entity_weight / total,
)
}
}
impl FactorGraph {
pub fn new(
nodes: &[(String, f64)],
factors: Vec<Factor>,
config: FactorGraphConfig,
) -> Self {
let mut node_map: HashMap<String, FactorGraphNode> = nodes
.iter()
.map(|(id, belief)| {
(
id.clone(),
FactorGraphNode {
item_id: id.clone(),
initial_belief: *belief,
factors: Vec::new(),
},
)
})
.collect();
for factor in &factors {
if !node_map.contains_key(&factor.source) {
node_map.insert(
factor.source.clone(),
FactorGraphNode {
item_id: factor.source.clone(),
initial_belief: 0.5,
factors: Vec::new(),
},
);
}
if !node_map.contains_key(&factor.target) {
node_map.insert(
factor.target.clone(),
FactorGraphNode {
item_id: factor.target.clone(),
initial_belief: 0.5,
factors: Vec::new(),
},
);
}
node_map
.get_mut(&factor.source)
.unwrap()
.factors
.push(factor.clone());
node_map
.get_mut(&factor.target)
.unwrap()
.factors
.push(factor.clone());
}
Self {
nodes: node_map,
config,
}
}
pub fn propagate(&self) -> FactorGraphResult {
let start = std::time::Instant::now();
let mut current: HashMap<String, f64> = self
.nodes
.iter()
.map(|(id, node)| (id.clone(), node.initial_belief))
.collect();
let (sem_w, temp_w, causal_w, ent_w) = self.config.normalized_weights();
let self_inf = self.config.self_influence;
let neighbor_inf = 1.0 - self_inf;
let mut iterations = 0;
let mut converged = false;
for iter in 0..self.config.max_iterations {
iterations = iter + 1;
let mut max_delta: f64 = 0.0;
let mut next: HashMap<String, f64> = HashMap::new();
for (id, node) in &self.nodes {
let current_belief = *current.get(id).unwrap_or(&node.initial_belief);
if node.factors.is_empty() {
next.insert(id.clone(), node.initial_belief);
continue;
}
let mut weighted_sum: f64 = 0.0;
let mut weight_total: f64 = 0.0;
for factor in &node.factors {
let neighbor_id = if factor.source == *id {
&factor.target
} else {
&factor.source
};
let neighbor_belief = *current.get(neighbor_id).unwrap_or(&0.5);
let compatibility = factor.compatibility(current_belief, neighbor_belief);
let kind_weight = match factor.kind {
FactorKind::Semantic => sem_w,
FactorKind::Temporal => temp_w,
FactorKind::Causal => causal_w,
FactorKind::Entity => ent_w,
};
let combined_weight = kind_weight * factor.edge_weight;
let message = if compatibility > 0.5 {
neighbor_belief * (2.0 * compatibility - 1.0)
+ current_belief * (2.0 - 2.0 * compatibility)
} else {
0.5 * (1.0 - 2.0 * compatibility) + current_belief * 2.0 * compatibility
};
weighted_sum += message * combined_weight;
weight_total += combined_weight;
}
let new_belief = if weight_total > 0.0 {
let neighbor_avg = weighted_sum / weight_total;
self_inf * node.initial_belief + neighbor_inf * neighbor_avg
} else {
node.initial_belief
};
let new_belief = new_belief.clamp(0.0, 1.0);
let delta = (new_belief - current_belief).abs();
if delta > max_delta {
max_delta = delta;
}
next.insert(id.clone(), new_belief);
}
current = next;
if max_delta < self.config.convergence_threshold {
converged = true;
break;
}
}
let mut kind_counts: HashMap<FactorKind, usize> = HashMap::new();
for node in self.nodes.values() {
for factor in &node.factors {
if factor.source == node.item_id {
*kind_counts.entry(factor.kind).or_default() += 1;
}
}
}
FactorGraphResult {
node_beliefs: current,
iterations,
converged,
elapsed_ms: start.elapsed().as_millis() as u64,
factor_counts: FactorCounts {
semantic: *kind_counts.get(&FactorKind::Semantic).unwrap_or(&0),
temporal: *kind_counts.get(&FactorKind::Temporal).unwrap_or(&0),
causal: *kind_counts.get(&FactorKind::Causal).unwrap_or(&0),
entity: *kind_counts.get(&FactorKind::Entity).unwrap_or(&0),
},
config: self.config.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FactorGraphResult {
pub node_beliefs: HashMap<String, f64>,
pub iterations: usize,
pub converged: bool,
pub elapsed_ms: u64,
pub factor_counts: FactorCounts,
pub config: FactorGraphConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FactorCounts {
pub semantic: usize,
pub temporal: usize,
pub causal: usize,
pub entity: usize,
}
impl FactorCounts {
pub fn total(&self) -> usize {
self.semantic + self.temporal + self.causal + self.entity
}
}
impl FactorGraphResult {
pub fn to_message_passing_result(&self) -> MessagePassingResult {
MessagePassingResult {
node_confidences: self.node_beliefs.clone(),
iterations: self.iterations,
converged: self.converged,
elapsed_ms: self.elapsed_ms,
}
}
pub fn top_k(&self, k: usize) -> Vec<(String, f64)> {
let mut items: Vec<(String, f64)> = self
.node_beliefs
.iter()
.map(|(id, belief)| (id.clone(), *belief))
.collect();
items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
items.truncate(k);
items
}
}
pub fn factors_from_edges(
edges: &[(String, String, GraphEdgeType, f64, Option<String>)],
) -> Vec<Factor> {
edges
.iter()
.map(|(source, target, edge_type, weight, metadata_json)| {
let kind = FactorKind::from_edge_type(edge_type);
let mut metadata = match edge_type {
GraphEdgeType::Semantic { cosine_similarity } => {
Some(FactorMetadata::Semantic {
cosine_similarity: Some(*cosine_similarity as f64),
})
}
GraphEdgeType::Temporal { delta_secs } => {
Some(FactorMetadata::Temporal {
delta_secs: *delta_secs as i64,
})
}
GraphEdgeType::Causal {
confidence,
..
} => Some(FactorMetadata::Causal {
confidence: *confidence as f64,
}),
GraphEdgeType::Entity { relation } => {
Some(FactorMetadata::Entity {
relation: relation.clone(),
})
}
};
if let Some(json) = metadata_json.as_ref() {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(json) {
if let Some(obj) = value.as_object() {
match &mut metadata {
Some(FactorMetadata::Semantic { cosine_similarity }) => {
if let Some(override_cosine) = obj
.get("cosine_similarity")
.and_then(|v| v.as_f64())
{
*cosine_similarity = Some(override_cosine);
}
}
Some(FactorMetadata::Temporal { delta_secs }) => {
if let Some(override_delta) = obj
.get("delta_secs")
.and_then(|v| v.as_i64().or_else(|| v.as_u64().map(|v| v as i64)))
{
*delta_secs = override_delta;
}
}
Some(FactorMetadata::Causal { confidence }) => {
if let Some(override_confidence) = obj
.get("confidence")
.and_then(|v| v.as_f64())
{
*confidence = override_confidence;
}
}
Some(FactorMetadata::Entity { relation }) => {
if let Some(override_relation) = obj
.get("relation")
.and_then(|v| v.as_str())
{
*relation = override_relation.to_string();
}
}
None => {}
}
}
}
}
Factor {
source: source.clone(),
target: target.clone(),
kind,
edge_weight: *weight,
metadata,
}
})
.collect()
}
pub fn factor_graph_quantization(
result: &FactorGraphResult,
) -> Vec<crate::integration::ConfidenceQuantizationRecommendation> {
crate::integration::confidence_aware_quantization(&result.to_message_passing_result())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_nodes() -> Vec<(String, f64)> {
vec![
("fact:a".to_string(), 0.9),
("fact:b".to_string(), 0.8),
("fact:c".to_string(), 0.5),
("fact:d".to_string(), 0.3),
]
}
#[test]
fn factor_graph_propagates_semantic_reinforcement() {
let nodes = vec![
("fact:a".to_string(), 0.7),
("fact:b".to_string(), 0.7),
];
let factors = vec![Factor {
source: "fact:a".to_string(),
target: "fact:b".to_string(),
kind: FactorKind::Semantic,
edge_weight: 1.0,
metadata: Some(FactorMetadata::Semantic { cosine_similarity: Some(0.9) }),
}];
let graph = FactorGraph::new(&nodes, factors, FactorGraphConfig::default());
let result = graph.propagate();
let a_belief = result.node_beliefs["fact:a"];
let b_belief = result.node_beliefs["fact:b"];
assert!(a_belief > 0.65, "fact:a belief should be reinforced: {}", a_belief);
assert!(b_belief > 0.65, "fact:b belief should be reinforced: {}", b_belief);
}
#[test]
fn factor_graph_propagates_contradiction_pull() {
let nodes = vec![
("fact:high".to_string(), 0.9),
("fact:low".to_string(), 0.2),
];
let factors = vec![Factor {
source: "fact:high".to_string(),
target: "fact:low".to_string(),
kind: FactorKind::Semantic,
edge_weight: 1.0,
metadata: Some(FactorMetadata::Semantic { cosine_similarity: Some(0.8) }),
}];
let graph = FactorGraph::new(&nodes, factors, FactorGraphConfig::default());
let result = graph.propagate();
let high_belief = result.node_beliefs["fact:high"];
assert!(
high_belief < 0.9,
"fact:high should be pulled down from 0.9: {}",
high_belief
);
}
#[test]
fn factor_graph_combines_all_edge_types() {
let nodes = vec![
("fact:center".to_string(), 0.5),
("fact:sem".to_string(), 0.9),
("fact:temp".to_string(), 0.9),
("fact:caus".to_string(), 0.9),
("fact:ent".to_string(), 0.9),
];
let factors = vec![
Factor {
source: "fact:center".to_string(),
target: "fact:sem".to_string(),
kind: FactorKind::Semantic,
edge_weight: 1.0,
metadata: Some(FactorMetadata::Semantic { cosine_similarity: Some(0.9) }),
},
Factor {
source: "fact:center".to_string(),
target: "fact:temp".to_string(),
kind: FactorKind::Temporal,
edge_weight: 1.0,
metadata: Some(FactorMetadata::Temporal { delta_secs: 3600 }),
},
Factor {
source: "fact:center".to_string(),
target: "fact:caus".to_string(),
kind: FactorKind::Causal,
edge_weight: 1.0,
metadata: Some(FactorMetadata::Causal { confidence: 0.8 }),
},
Factor {
source: "fact:center".to_string(),
target: "fact:ent".to_string(),
kind: FactorKind::Entity,
edge_weight: 1.0,
metadata: Some(FactorMetadata::Entity { relation: "depends_on".to_string() }),
},
];
let graph = FactorGraph::new(&nodes, factors, FactorGraphConfig::default());
let result = graph.propagate();
let center_belief = result.node_beliefs["fact:center"];
assert!(
center_belief > 0.5,
"fact:center should be boosted above 0.5 by 4 reinforcing factors: {}",
center_belief
);
assert!(result.factor_counts.total() >= 4);
assert_eq!(result.factor_counts.semantic, 1);
assert_eq!(result.factor_counts.temporal, 1);
assert_eq!(result.factor_counts.causal, 1);
assert_eq!(result.factor_counts.entity, 1);
}
#[test]
fn factor_graph_converges() {
let nodes = sample_nodes();
let factors = vec![
Factor {
source: "fact:a".to_string(),
target: "fact:b".to_string(),
kind: FactorKind::Entity,
edge_weight: 1.0,
metadata: Some(FactorMetadata::Entity { relation: "sibling".to_string() }),
},
Factor {
source: "fact:b".to_string(),
target: "fact:c".to_string(),
kind: FactorKind::Causal,
edge_weight: 0.8,
metadata: Some(FactorMetadata::Causal { confidence: 0.7 }),
},
];
let graph = FactorGraph::new(&nodes, factors, FactorGraphConfig::default());
let result = graph.propagate();
assert!(result.converged, "should converge within 50 iterations");
assert!(result.iterations <= 50);
}
#[test]
fn factor_graph_no_factors_keeps_initial() {
let nodes = vec![("fact:lonely".to_string(), 0.7)];
let graph = FactorGraph::new(&nodes, vec![], FactorGraphConfig::default());
let result = graph.propagate();
let belief = result.node_beliefs["fact:lonely"];
assert!(
(belief - 0.7).abs() < 0.001,
"node with no factors should keep initial belief: {}",
belief
);
}
#[test]
fn factor_graph_temporal_decay() {
let recent_nodes = vec![
("fact:a".to_string(), 0.5),
("fact:b_recent".to_string(), 0.9),
];
let recent_factors = vec![Factor {
source: "fact:a".to_string(),
target: "fact:b_recent".to_string(),
kind: FactorKind::Temporal,
edge_weight: 1.0,
metadata: Some(FactorMetadata::Temporal { delta_secs: 100 }), }];
let recent_graph = FactorGraph::new(&recent_nodes, recent_factors, FactorGraphConfig::default());
let recent_result = recent_graph.propagate();
let old_nodes = vec![
("fact:a".to_string(), 0.5),
("fact:b_old".to_string(), 0.9),
];
let old_factors = vec![Factor {
source: "fact:a".to_string(),
target: "fact:b_old".to_string(),
kind: FactorKind::Temporal,
edge_weight: 1.0,
metadata: Some(FactorMetadata::Temporal { delta_secs: 31_536_000 }), }];
let old_graph = FactorGraph::new(&old_nodes, old_factors, FactorGraphConfig::default());
let old_result = old_graph.propagate();
let recent_boost = recent_result.node_beliefs["fact:a"];
let old_boost = old_result.node_beliefs["fact:a"];
assert!(
recent_boost > old_boost,
"recent temporal edge should boost more than old: recent={}, old={}",
recent_boost,
old_boost
);
}
#[test]
fn factor_graph_causal_confidence_weighting() {
let high_conf_factors = vec![Factor {
source: "fact:cause".to_string(),
target: "fact:effect".to_string(),
kind: FactorKind::Causal,
edge_weight: 1.0,
metadata: Some(FactorMetadata::Causal { confidence: 0.9 }),
}];
let low_conf_factors = vec![Factor {
source: "fact:cause".to_string(),
target: "fact:effect".to_string(),
kind: FactorKind::Causal,
edge_weight: 1.0,
metadata: Some(FactorMetadata::Causal { confidence: 0.3 }),
}];
let nodes = vec![
("fact:cause".to_string(), 0.9),
("fact:effect".to_string(), 0.5),
];
let high_graph = FactorGraph::new(&nodes, high_conf_factors, FactorGraphConfig::default());
let low_graph = FactorGraph::new(&nodes, low_conf_factors, FactorGraphConfig::default());
let high_result = high_graph.propagate();
let low_result = low_graph.propagate();
let high_effect = high_result.node_beliefs["fact:effect"];
let low_effect = low_result.node_beliefs["fact:effect"];
assert!(
high_effect > low_effect,
"high-confidence causal should pull effect toward cause more: high={}, low={}",
high_effect,
low_effect
);
}
#[test]
fn factors_from_edges_parses_metadata() {
use crate::types::GraphEdgeType;
let edges = vec![
(
"fact:a".to_string(),
"fact:b".to_string(),
GraphEdgeType::Temporal { delta_secs: 0 },
1.0,
Some(r#"{"delta_secs": 86400}"#.to_string()),
),
(
"fact:c".to_string(),
"fact:d".to_string(),
GraphEdgeType::Causal { confidence: 0.0, evidence_ids: vec![] },
1.0,
Some(r#"{"confidence": 0.8}"#.to_string()),
),
(
"fact:e".to_string(),
"fact:f".to_string(),
GraphEdgeType::Entity { relation: "depends_on".to_string() },
1.0,
Some(r#"{"relation": "depends_on"}"#.to_string()),
),
];
let factors = factors_from_edges(&edges);
assert_eq!(factors.len(), 3);
assert!(matches!(factors[0].metadata, Some(FactorMetadata::Temporal { delta_secs: 86400 })));
assert!(matches!(factors[1].metadata, Some(FactorMetadata::Causal { confidence: 0.8 })));
assert!(matches!(
factors[2].metadata,
Some(FactorMetadata::Entity { ref relation }) if relation == "depends_on"
));
}
#[test]
fn factor_graph_top_k_returns_highest_beliefs() {
let nodes = vec![
("fact:low".to_string(), 0.2),
("fact:high".to_string(), 0.9),
("fact:mid".to_string(), 0.5),
];
let graph = FactorGraph::new(&nodes, vec![], FactorGraphConfig::default());
let result = graph.propagate();
let top2 = result.top_k(2);
assert_eq!(top2.len(), 2);
assert_eq!(top2[0].0, "fact:high");
assert_eq!(top2[1].0, "fact:mid");
}
#[test]
fn factor_graph_to_message_passing_result() {
let nodes = vec![("fact:a".to_string(), 0.7)];
let graph = FactorGraph::new(&nodes, vec![], FactorGraphConfig::default());
let result = graph.propagate();
let mp = result.to_message_passing_result();
assert_eq!(mp.node_confidences["fact:a"], result.node_beliefs["fact:a"]);
assert_eq!(mp.iterations, result.iterations);
assert_eq!(mp.converged, result.converged);
}
#[test]
fn factor_graph_quantization_integration() {
let nodes = vec![
("fact:high".to_string(), 0.9),
("fact:low".to_string(), 0.1),
];
let graph = FactorGraph::new(&nodes, vec![], FactorGraphConfig::default());
let result = graph.propagate();
let recs = factor_graph_quantization(&result);
assert_eq!(recs.len(), 2);
let high_rec = recs.iter().find(|r| r.item_id == "fact:high").unwrap();
assert_eq!(high_rec.recommended_level, "F32");
let low_rec = recs.iter().find(|r| r.item_id == "fact:low").unwrap();
assert_eq!(low_rec.recommended_level, "SQ4Marked");
}
#[test]
fn factor_graph_normalized_weights_sum_to_one() {
let config = FactorGraphConfig::default();
let (s, t, c, e) = config.normalized_weights();
assert!((s + t + c + e - 1.0).abs() < 0.001, "weights should sum to 1.0");
}
#[test]
fn factor_graph_empty_graph_propagates_without_error() {
let graph = FactorGraph::new(&[], vec![], FactorGraphConfig::default());
let result = graph.propagate();
assert!(result.node_beliefs.is_empty());
assert!(result.converged);
}
}