/// ReasoningEngine — graph-native inference over the Engram knowledge graph. /// /// # The Core Insight /// /// Language models conflate reasoning and generation: transformer weights encode /// both the inference logic and the ability to verbalize conclusions. You cannot /// separate them — the same matrix multiplication does both. /// /// This engine separates them deliberately: /// /// 1. **Reasoning** — `ReasoningEngine::reason()` traverses the knowledge graph /// via spreading activation, classifies activated nodes as evidence, builds /// typed inference chains, and computes a confidence-weighted verdict. /// No language model is involved. The reasoning IS the graph traversal. /// /// 2. **Generation** — A separate codec (not in this crate) converts the /// `ReasoningResult` into natural language. It renders the evidence chains /// and verdict into prose; it does not alter the logical content. /// /// The verdict is determined by the graph structure, not by which tokens were /// sampled. This is what makes it "not an LLM." /// /// # Algorithm /// /// 1. Embed the hypothesis text (caller provides embedding) /// 2. Find seed nodes via vector similarity search /// 3. Run spreading activation from seeds (EngramDb::activate) /// 4. Classify each activated node as evidence (support/refute/context) /// 5. Build evidence chains by following typed edges through activated subgraph /// 6. Propagate confidence through chains /// 7. Compute verdict from support vs. refutation mass use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use engram_core::{EngramDb, EngramResult}; use engram_core::types::{Node, NodeType, RelationType}; use uuid::Uuid; use crate::types::{ CausalDirection, ChainType, Conclusion, EvidenceChain, EvidenceNode, EvidenceType, Hypothesis, HypothesisType, InferenceEdge, InferenceEdgeType, ReasoningConfig, ReasoningResult, Verdict, }; // ── Cosine similarity (inlined — no dep on private engram_core::vector) ─────── /// Cosine similarity between two embedding vectors, clamped to [0.0, 1.0]. fn cosine_sim(a: &[f32], b: &[f32]) -> f32 { if a.is_empty() || b.is_empty() || a.len() != b.len() { return 0.0; } let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); let norm_a: f32 = a.iter().map(|x| x * x).sum::().sqrt(); let norm_b: f32 = b.iter().map(|x| x * x).sum::().sqrt(); if norm_a == 0.0 || norm_b == 0.0 { return 0.0; } (dot / (norm_a * norm_b)).clamp(0.0, 1.0) } /// Simple negation detection: does the content contain negation markers /// near keywords from the hypothesis? fn has_negation_signals(content: &str) -> bool { let lower = content.to_lowercase(); let negation_words = ["not", "never", "no ", "false", "incorrect", "wrong", "cannot", "can't", "doesn't", "isn't", "aren't", "wasn't", "weren't", "won't", "wouldn't", "shouldn't", "couldn't", "invalid", "disproves", "refutes", "contra"]; negation_words.iter().any(|w| lower.contains(w)) } // ── ReasoningEngine ─────────────────────────────────────────────────────────── pub struct ReasoningEngine { db: Arc>, pub config: ReasoningConfig, } impl ReasoningEngine { pub fn new(db: Arc>, config: ReasoningConfig) -> Self { Self { db, config } } pub fn with_default_config(db: Arc>) -> Self { Self::new(db, ReasoningConfig::default()) } // ── Core reasoning pass ─────────────────────────────────────────────────── /// Evaluate a hypothesis against the knowledge graph. /// /// Returns a full `ReasoningResult` including verdict, evidence chains, /// and confidence scores. This is the primary entry point. pub fn reason(&mut self, hypothesis: &Hypothesis) -> EngramResult { let mut reasoning_steps = 0u32; // Step 1: Find seed nodes via vector search let seeds: Vec = { let db = self.db.lock().map_err(|_| { engram_core::EngramError::InvalidParam("db lock poisoned".into()) })?; let scored = db.search_embedding( &hypothesis.embedding, 10.min(self.config.max_evidence_nodes as usize), )?; reasoning_steps += 1; scored.into_iter().map(|s| s.node.id).collect() }; if seeds.is_empty() { // Graph is empty — cannot reason return Ok(ReasoningResult { hypothesis: hypothesis.clone(), conclusion: Conclusion { verdict: Verdict::Insufficient, summary: "No relevant nodes found in the knowledge graph.".into(), confidence: 0.0, primary_evidence: vec![], }, evidence_chains: vec![], confidence: 0.0, reasoning_steps, nodes_visited: 0, }); } // Step 2: Spreading activation from seeds let activated: Vec = { let db = self.db.lock().map_err(|_| { engram_core::EngramError::InvalidParam("db lock poisoned".into()) })?; db.activate( &seeds, &hypothesis.embedding, self.config.max_depth.min(u8::MAX as u32) as u8, self.config.max_evidence_nodes as usize, )? }; reasoning_steps += 1; let nodes_visited = activated.len() as u32 + seeds.len() as u32; // Step 3: Classify each activated node as evidence let mut evidence_nodes: Vec = activated .iter() .filter(|a| a.activation_strength >= self.config.min_confidence) .map(|a| self.classify_evidence(&a.node, hypothesis, a.activation_strength, a.hops)) .collect(); // Also include the seed nodes themselves as evidence { let db = self.db.lock().map_err(|_| { engram_core::EngramError::InvalidParam("db lock poisoned".into()) })?; for seed_id in &seeds { if let Some(node) = db.get_node(*seed_id)? { let ev = self.classify_evidence(&node, hypothesis, 1.0, 0); evidence_nodes.push(ev); } } } reasoning_steps += evidence_nodes.len() as u32; // Step 4: Build inference edges from the activated subgraph let activated_ids: HashSet = evidence_nodes .iter() .map(|e| e.engram_node_id) .collect(); let inference_edges = self.build_inference_edges(&activated_ids, hypothesis)?; reasoning_steps += 1; // Step 5: Propagate confidence through nodes self.propagate_confidence(&mut evidence_nodes, &inference_edges); reasoning_steps += 1; // Step 6: Build evidence chains let evidence_chains = self.build_chains(&evidence_nodes, &inference_edges, hypothesis); reasoning_steps += 1; // Step 7: Compute verdict let (verdict, confidence) = self.compute_verdict(&evidence_nodes, hypothesis); reasoning_steps += 1; // Collect primary evidence (strongest items for/against) let mut primary_evidence: Vec = evidence_nodes .iter() .filter(|e| { matches!( e.evidence_type, EvidenceType::DirectSupport | EvidenceType::DirectRefutation | EvidenceType::IndirectSupport | EvidenceType::IndirectRefutation ) }) .cloned() .collect(); primary_evidence.sort_by(|a, b| { b.confidence .partial_cmp(&a.confidence) .unwrap_or(std::cmp::Ordering::Equal) }); primary_evidence.truncate(5); let summary = self.build_summary(&verdict, &primary_evidence, hypothesis); Ok(ReasoningResult { hypothesis: hypothesis.clone(), conclusion: Conclusion { verdict, summary, confidence, primary_evidence, }, evidence_chains, confidence, reasoning_steps, nodes_visited, }) } // ── Causal chain ────────────────────────────────────────────────────────── /// Find causal chains: what causes a concept, or what a concept causes. /// /// Traverses the graph following `RelationType::Causes` edges in the /// requested direction. pub fn causal_chain( &mut self, concept_embedding: &[f32], direction: CausalDirection, ) -> EngramResult> { // Find seed nodes close to the concept let seeds: Vec = { let db = self.db.lock().map_err(|_| { engram_core::EngramError::InvalidParam("db lock poisoned".into()) })?; db.search_embedding(concept_embedding, 5)? .into_iter() .map(|s| s.node) .collect() }; if seeds.is_empty() { return Ok(vec![]); } let mut chains: Vec = Vec::new(); for seed in &seeds { let traversal_nodes: Vec = { let db = self.db.lock().map_err(|_| { engram_core::EngramError::InvalidParam("db lock poisoned".into()) })?; db.traverse(seed.id, Some(RelationType::Causes), self.config.max_depth as u8)? }; // Build evidence nodes — always start with the seed as the first node let seed_sim = cosine_sim(concept_embedding, &seed.embedding); let seed_ev = EvidenceNode { engram_node_id: seed.id, content: String::from_utf8_lossy(&seed.content).into_owned(), evidence_type: EvidenceType::CausalAntecedent, confidence: (seed.importance * seed.salience.clamp(0.0, 1.0)).clamp(0.0, 1.0), activation_strength: seed_sim, hops_from_seed: 0, }; let mut ev_nodes: Vec = vec![seed_ev]; for (i, node) in traversal_nodes.iter().enumerate() { let sim = cosine_sim(concept_embedding, &node.embedding); let ev_type = match direction { CausalDirection::Backward => EvidenceType::CausalAntecedent, CausalDirection::Forward | CausalDirection::Both => { EvidenceType::CausalConsequent } }; ev_nodes.push(EvidenceNode { engram_node_id: node.id, content: String::from_utf8_lossy(&node.content).into_owned(), evidence_type: ev_type, confidence: (node.importance * node.salience.clamp(0.0, 1.0)).clamp(0.0, 1.0), activation_strength: sim, hops_from_seed: (i + 1) as u32, }); } // Need at least two nodes to form a chain if ev_nodes.len() < 2 { continue; } // Build inference edges along the chain let ev_edges: Vec = ev_nodes .windows(2) .map(|w| InferenceEdge { from_node: w[0].engram_node_id, to_node: w[1].engram_node_id, edge_type: InferenceEdgeType::Causes, strength: (w[0].confidence + w[1].confidence) / 2.0, engram_edge_id: None, }) .collect(); let chain_confidence = EvidenceChain::compute_confidence(&ev_edges); chains.push(EvidenceChain { nodes: ev_nodes, edges: ev_edges, chain_confidence, chain_type: ChainType::CausalChain, }); } // Sort by chain confidence descending chains.sort_by(|a, b| { b.chain_confidence .partial_cmp(&a.chain_confidence) .unwrap_or(std::cmp::Ordering::Equal) }); Ok(chains) } // ── Procedural chain ────────────────────────────────────────────────────── /// Find ordered steps for a HowTo query. /// /// Traverses `RelationType::Causes` and `RelationType::Contains` edges /// from Process/Procedural nodes matching the goal embedding. pub fn procedural_chain(&mut self, goal_embedding: &[f32]) -> EngramResult> { let process_nodes: Vec = { let db = self.db.lock().map_err(|_| { engram_core::EngramError::InvalidParam("db lock poisoned".into()) })?; // Search for nodes relevant to the goal let scored = db.search_embedding(goal_embedding, 10)?; scored .into_iter() .filter(|s| { matches!(s.node.node_type, NodeType::Process) && s.score > 0.3 }) .map(|s| s.node) .collect() }; if process_nodes.is_empty() { // Fallback: use any activated nodes sorted by hop/salience let db = self.db.lock().map_err(|_| { engram_core::EngramError::InvalidParam("db lock poisoned".into()) })?; let scored = db.search_embedding(goal_embedding, 5)?; return Ok(scored .into_iter() .map(|s| String::from_utf8_lossy(&s.node.content).into_owned()) .collect()); } // Follow the process chain from the best matching node let best_process = &process_nodes[0]; let steps: Vec = { let db = self.db.lock().map_err(|_| { engram_core::EngramError::InvalidParam("db lock poisoned".into()) })?; db.traverse(best_process.id, Some(RelationType::Causes), self.config.max_depth as u8)? }; let mut ordered_steps: Vec = Vec::new(); // The best process node itself is step 0 ordered_steps.push(String::from_utf8_lossy(&best_process.content).into_owned()); for node in steps { ordered_steps.push(String::from_utf8_lossy(&node.content).into_owned()); } Ok(ordered_steps) } // ── Contradiction detection ─────────────────────────────────────────────── /// Find pairs of nodes in the graph that contradict each other relative /// to the given topic embedding. /// /// Returns pairs `(supporting_node, refuting_node)` where both nodes are /// activated by the topic, but one has negation signals and the other does not. pub fn find_contradictions( &mut self, topic_embedding: &[f32], ) -> EngramResult> { // Activate the graph around the topic let seeds: Vec = { let db = self.db.lock().map_err(|_| { engram_core::EngramError::InvalidParam("db lock poisoned".into()) })?; db.search_embedding(topic_embedding, 10)? .into_iter() .map(|s| s.node.id) .collect() }; if seeds.is_empty() { return Ok(vec![]); } let activated = { let db = self.db.lock().map_err(|_| { engram_core::EngramError::InvalidParam("db lock poisoned".into()) })?; db.activate(&seeds, topic_embedding, 3, 30)? }; // Check explicit Contradicts edges from both seed nodes and activated nodes let mut contradicts_pairs: Vec<(EvidenceNode, EvidenceNode)> = Vec::new(); { let db = self.db.lock().map_err(|_| { engram_core::EngramError::InvalidParam("db lock poisoned".into()) })?; // Build combined candidate list: seed nodes + activated nodes let seed_nodes: Vec<(Uuid, f32, u8)> = seeds .iter() .filter_map(|&id| { db.get_node(id).ok().flatten().map(|n| { let sim = cosine_sim(topic_embedding, &n.embedding); (id, sim, 0u8) }) }) .collect(); let all_candidates: Vec<(Uuid, f32, u8)> = seed_nodes .into_iter() .chain(activated.iter().map(|an| { (an.node.id, an.activation_strength, an.hops) })) .collect(); for (node_id, activation_strength, hops) in &all_candidates { if let Some(from_node) = db.get_node(*node_id)? { let edges = db.get_edges_from(*node_id)?; for edge in edges { if edge.relation == RelationType::Contradicts { if let Some(target) = db.get_node(edge.to_id)? { let sim_a = cosine_sim(topic_embedding, &from_node.embedding); let sim_b = cosine_sim(topic_embedding, &target.embedding); if sim_a > 0.3 && sim_b > 0.3 { let ev_a = EvidenceNode { engram_node_id: from_node.id, content: String::from_utf8_lossy(&from_node.content) .into_owned(), evidence_type: EvidenceType::DirectSupport, confidence: from_node.importance.clamp(0.0, 1.0), activation_strength: *activation_strength, hops_from_seed: *hops as u32, }; let ev_b = EvidenceNode { engram_node_id: target.id, content: String::from_utf8_lossy(&target.content) .into_owned(), evidence_type: EvidenceType::DirectRefutation, confidence: target.importance.clamp(0.0, 1.0), activation_strength: sim_b, hops_from_seed: *hops as u32 + 1, }; contradicts_pairs.push((ev_a, ev_b)); } } } } } } } // Also find pairs where one node has negation signals and shares high // semantic similarity with another node that does not let mut support_nodes: Vec<&engram_core::types::ActivatedNode> = Vec::new(); let mut refutation_nodes: Vec<&engram_core::types::ActivatedNode> = Vec::new(); for an in &activated { let sim = cosine_sim(topic_embedding, &an.node.embedding); if sim < 0.4 { continue; } let content = String::from_utf8_lossy(&an.node.content); if has_negation_signals(&content) { refutation_nodes.push(an); } else { support_nodes.push(an); } } for sup in &support_nodes { for ref_node in &refutation_nodes { let mutual_sim = cosine_sim(&sup.node.embedding, &ref_node.node.embedding); if mutual_sim > 0.6 { // These two nodes are about the same thing but one negates let ev_sup = EvidenceNode { engram_node_id: sup.node.id, content: String::from_utf8_lossy(&sup.node.content).into_owned(), evidence_type: EvidenceType::DirectSupport, confidence: sup.node.importance.clamp(0.0, 1.0), activation_strength: sup.activation_strength, hops_from_seed: sup.hops as u32, }; let ev_ref = EvidenceNode { engram_node_id: ref_node.node.id, content: String::from_utf8_lossy(&ref_node.node.content).into_owned(), evidence_type: EvidenceType::DirectRefutation, confidence: ref_node.node.importance.clamp(0.0, 1.0), activation_strength: ref_node.activation_strength, hops_from_seed: ref_node.hops as u32, }; // Avoid duplicates from the Contradicts edge scan let already = contradicts_pairs.iter().any(|(a, b)| { a.engram_node_id == ev_sup.engram_node_id && b.engram_node_id == ev_ref.engram_node_id }); if !already { contradicts_pairs.push((ev_sup, ev_ref)); } } } } Ok(contradicts_pairs) } // ── Internal helpers ────────────────────────────────────────────────────── /// Classify an activated Engram node as an evidence node relative to the hypothesis. pub(crate) fn classify_evidence( &self, node: &Node, hypothesis: &Hypothesis, activation_strength: f32, hops: u8, ) -> EvidenceNode { let content = String::from_utf8_lossy(&node.content).into_owned(); let sim = cosine_sim(&hypothesis.embedding, &node.embedding); let negation = has_negation_signals(&content); let evidence_type = self.classify_evidence_type(node, hypothesis, sim, negation); let confidence = self.compute_node_confidence(node, sim, activation_strength); EvidenceNode { engram_node_id: node.id, content, evidence_type, confidence, activation_strength, hops_from_seed: hops as u32, } } fn classify_evidence_type( &self, node: &Node, hypothesis: &Hypothesis, sim: f32, negation: bool, ) -> EvidenceType { // Process nodes → procedural steps for HowTo queries if node.node_type == NodeType::Process && hypothesis.hypothesis_type == HypothesisType::HowTo { return EvidenceType::ProceduralStep; } // High similarity — direct evidence if sim > 0.8 { if negation { return EvidenceType::DirectRefutation; } else { return EvidenceType::DirectSupport; } } // Medium similarity — indirect evidence if sim >= 0.5 { if negation { return EvidenceType::IndirectRefutation; } else { return EvidenceType::IndirectSupport; } } // Below threshold — contextual EvidenceType::ContextualFact } fn compute_node_confidence(&self, node: &Node, sim: f32, activation_strength: f32) -> f32 { // Blend: semantic relevance × node importance × capped salience × activation let salience_factor = node.salience.clamp(0.0, 1.0); (sim * node.importance * salience_factor * activation_strength).clamp(0.0, 1.0) } /// Build inference edges between activated nodes using stored Engram edges. fn build_inference_edges( &self, activated_ids: &HashSet, _hypothesis: &Hypothesis, ) -> EngramResult> { let db = self.db.lock().map_err(|_| { engram_core::EngramError::InvalidParam("db lock poisoned".into()) })?; let mut edges: Vec = Vec::new(); let mut seen: HashSet<(Uuid, Uuid)> = HashSet::new(); for &node_id in activated_ids { let engram_edges = db.get_edges_from(node_id)?; for ee in engram_edges { if !activated_ids.contains(&ee.to_id) { continue; } let pair = (ee.from_id, ee.to_id); if seen.contains(&pair) { continue; } seen.insert(pair); let edge_type = relation_to_inference_edge(&ee.relation); edges.push(InferenceEdge { from_node: ee.from_id, to_node: ee.to_id, edge_type, strength: ee.weight, engram_edge_id: Some(ee.id), }); } } Ok(edges) } /// Propagate confidence through the evidence graph via inference edges. /// /// For each node, find all incoming edges from other evidence nodes and /// blend in the confidence carried by those edges. This models how a strong /// chain of reasoning can increase confidence in downstream nodes even if /// those nodes have weak intrinsic importance. pub(crate) fn propagate_confidence( &self, nodes: &mut Vec, edges: &[InferenceEdge], ) { // Build a map: to_node → [(from_node, strength, edge_type)] let mut incoming: HashMap> = HashMap::new(); for edge in edges { incoming .entry(edge.to_node) .or_default() .push((edge.from_node, edge.strength, &edge.edge_type)); } // Build lookup for quick confidence retrieval let conf_map: HashMap = nodes .iter() .map(|n| (n.engram_node_id, n.confidence)) .collect(); // Apply one pass of confidence propagation for node in nodes.iter_mut() { if let Some(incomers) = incoming.get(&node.engram_node_id) { let mut boost = 0.0f32; for (from_id, strength, edge_type) in incomers { if let Some(&from_conf) = conf_map.get(from_id) { // Supportive edges boost confidence; refuting edges reduce it let signed_boost = match edge_type { InferenceEdgeType::Supports | InferenceEdgeType::Implies | InferenceEdgeType::Causes => from_conf * strength * 0.3, InferenceEdgeType::Refutes | InferenceEdgeType::Contradicts => { -(from_conf * strength * 0.3) } _ => from_conf * strength * 0.1, }; boost += signed_boost; } } node.confidence = (node.confidence + boost).clamp(0.0, 1.0); } } } /// Build evidence chains from the classified evidence nodes and inference edges. fn build_chains( &self, nodes: &[EvidenceNode], edges: &[InferenceEdge], hypothesis: &Hypothesis, ) -> Vec { let mut chains: Vec = Vec::new(); // Build adjacency map for chain construction let mut adj: HashMap> = HashMap::new(); for edge in edges { adj.entry(edge.from_node).or_default().push(edge); } let node_map: HashMap = nodes.iter().map(|n| (n.engram_node_id, n)).collect(); // Support chain: follow Supports/Implies edges from direct support nodes let support_starts: Vec = nodes .iter() .filter(|n| n.evidence_type == EvidenceType::DirectSupport && n.hops_from_seed == 0) .map(|n| n.engram_node_id) .collect(); for start in support_starts { if let Some(chain) = self.trace_chain( start, &adj, &node_map, ChainType::SupportChain, 5, hypothesis, ) { if chain.nodes.len() > 1 { chains.push(chain); } } } // Refutation chain: follow Refutes/Contradicts edges from direct refutation nodes let refutation_starts: Vec = nodes .iter() .filter(|n| { n.evidence_type == EvidenceType::DirectRefutation && n.hops_from_seed == 0 }) .map(|n| n.engram_node_id) .collect(); for start in refutation_starts { if let Some(chain) = self.trace_chain( start, &adj, &node_map, ChainType::RefutationChain, 5, hypothesis, ) { if chain.nodes.len() > 1 { chains.push(chain); } } } // Causal chain: follow Causes edges let causal_starts: Vec = nodes .iter() .filter(|n| n.evidence_type == EvidenceType::CausalAntecedent) .map(|n| n.engram_node_id) .collect(); for start in causal_starts { if let Some(chain) = self.trace_chain( start, &adj, &node_map, ChainType::CausalChain, 5, hypothesis, ) { if chain.nodes.len() > 1 { chains.push(chain); } } } // Process chain: follow edges from procedural step nodes if hypothesis.hypothesis_type == HypothesisType::HowTo { let process_starts: Vec = nodes .iter() .filter(|n| n.evidence_type == EvidenceType::ProceduralStep) .map(|n| n.engram_node_id) .collect(); for start in process_starts { if let Some(chain) = self.trace_chain( start, &adj, &node_map, ChainType::ProcessChain, 8, hypothesis, ) { if chain.nodes.len() > 1 { chains.push(chain); } } } } // Sort by chain confidence chains.sort_by(|a, b| { b.chain_confidence .partial_cmp(&a.chain_confidence) .unwrap_or(std::cmp::Ordering::Equal) }); chains } /// DFS trace from a start node, following edges appropriate for the chain type. fn trace_chain( &self, start: Uuid, adj: &HashMap>, node_map: &HashMap, chain_type: ChainType, max_len: usize, _hypothesis: &Hypothesis, ) -> Option { let start_node = node_map.get(&start)?; let mut chain_nodes: Vec = vec![(*start_node).clone()]; let mut chain_edges: Vec = Vec::new(); let mut visited: HashSet = HashSet::from([start]); let mut current = start; for _ in 0..max_len { let Some(outgoing) = adj.get(¤t) else { break; }; // Find the best edge for this chain type let best_edge = outgoing.iter().filter(|e| { !visited.contains(&e.to_node) && edge_fits_chain_type(&e.edge_type, &chain_type) }).max_by(|a, b| { a.strength .partial_cmp(&b.strength) .unwrap_or(std::cmp::Ordering::Equal) }); let Some(edge) = best_edge else { break; }; let next = edge.to_node; let Some(next_node) = node_map.get(&next) else { break; }; visited.insert(next); chain_nodes.push((*next_node).clone()); chain_edges.push((*edge).clone()); current = next; } let chain_confidence = EvidenceChain::compute_confidence(&chain_edges); Some(EvidenceChain { nodes: chain_nodes, edges: chain_edges, chain_confidence, chain_type, }) } /// Compute the overall verdict from the evidence node set. fn compute_verdict( &self, nodes: &[EvidenceNode], hypothesis: &Hypothesis, ) -> (Verdict, f32) { // Handle HowTo specially — return procedural steps if hypothesis.hypothesis_type == HypothesisType::HowTo { let steps: Vec = nodes .iter() .filter(|n| n.evidence_type == EvidenceType::ProceduralStep) .map(|n| n.content.clone()) .collect(); if !steps.is_empty() { return (Verdict::Procedural(steps), 0.9); } } let mut support_mass = 0.0f32; let mut refute_mass = 0.0f32; for node in nodes { match node.evidence_type { EvidenceType::DirectSupport => support_mass += node.confidence * 1.0, EvidenceType::IndirectSupport => support_mass += node.confidence * 0.6, EvidenceType::DirectRefutation => refute_mass += node.confidence * 1.0, EvidenceType::IndirectRefutation => refute_mass += node.confidence * 0.6, EvidenceType::CausalAntecedent | EvidenceType::CausalConsequent => { // Causal evidence weakly supports the hypothesis support_mass += node.confidence * 0.3; } _ => {} } } let total = support_mass + refute_mass; if total < 0.01 { return (Verdict::Insufficient, 0.0); } let support_fraction = support_mass / total; let refute_fraction = refute_mass / total; // Both sides have substantial mass → Contradictory if support_fraction >= self.config.contradiction_threshold && refute_fraction >= self.config.contradiction_threshold { return (Verdict::Contradictory, 0.5); } let confidence = support_fraction.clamp(0.0, 1.0); if confidence > 0.6 { (Verdict::Supported(confidence), confidence) } else if confidence < 0.4 { let refute_conf = refute_fraction.clamp(0.0, 1.0); (Verdict::Refuted(refute_conf), refute_conf) } else { // Between 0.4 and 0.6 — insufficient evidence to commit if nodes.len() < 3 { (Verdict::Insufficient, confidence) } else { (Verdict::Contradictory, confidence) } } } /// Generate a natural language summary of the reasoning result. fn build_summary( &self, verdict: &Verdict, primary_evidence: &[EvidenceNode], hypothesis: &Hypothesis, ) -> String { let evidence_snippet: String = primary_evidence .iter() .take(3) .map(|e| format!("\"{}\"", e.content.chars().take(80).collect::())) .collect::>() .join("; "); match verdict { Verdict::Supported(conf) => format!( "Hypothesis \"{}\" is supported with {:.0}% confidence. \ Key evidence: {}.", hypothesis.text, conf * 100.0, if evidence_snippet.is_empty() { "graph activation patterns".into() } else { evidence_snippet } ), Verdict::Refuted(conf) => format!( "Hypothesis \"{}\" is refuted with {:.0}% confidence. \ Contradicting evidence: {}.", hypothesis.text, conf * 100.0, if evidence_snippet.is_empty() { "graph activation patterns".into() } else { evidence_snippet } ), Verdict::Insufficient => format!( "Insufficient evidence in the graph to evaluate: \"{}\". \ More nodes covering this topic are needed.", hypothesis.text ), Verdict::Contradictory => format!( "Contradictory evidence found for: \"{}\". \ The graph contains conflicting information: {}.", hypothesis.text, if evidence_snippet.is_empty() { "multiple conflicting nodes".into() } else { evidence_snippet } ), Verdict::Procedural(steps) => format!( "Procedural steps for \"{}\": {}.", hypothesis.text, steps.iter().enumerate() .map(|(i, s)| format!("{}. {}", i + 1, s)) .collect::>() .join(" ") ), } } } // ── Edge mapping helpers ────────────────────────────────────────────────────── fn relation_to_inference_edge(relation: &RelationType) -> InferenceEdgeType { match relation { RelationType::Causes => InferenceEdgeType::Causes, RelationType::Contradicts => InferenceEdgeType::Contradicts, RelationType::Supersedes => InferenceEdgeType::Implies, RelationType::Contains => InferenceEdgeType::Requires, RelationType::References => InferenceEdgeType::SimilarTo, RelationType::Exemplifies => InferenceEdgeType::InstanceOf, RelationType::Activates => InferenceEdgeType::Supports, RelationType::TemporallyPrecedes => InferenceEdgeType::Causes, } } fn edge_fits_chain_type(edge_type: &InferenceEdgeType, chain_type: &ChainType) -> bool { match chain_type { ChainType::SupportChain => matches!( edge_type, InferenceEdgeType::Supports | InferenceEdgeType::Implies | InferenceEdgeType::SimilarTo ), ChainType::RefutationChain => matches!( edge_type, InferenceEdgeType::Refutes | InferenceEdgeType::Contradicts ), ChainType::CausalChain => matches!(edge_type, InferenceEdgeType::Causes), ChainType::ProcessChain => matches!( edge_type, InferenceEdgeType::Causes | InferenceEdgeType::Requires | InferenceEdgeType::Implies ), } }