Files
el/engram/crates/engram-reasoning/src/engine.rs
T

1009 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().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<Mutex<EngramDb>>,
pub config: ReasoningConfig,
}
impl ReasoningEngine {
pub fn new(db: Arc<Mutex<EngramDb>>, config: ReasoningConfig) -> Self {
Self { db, config }
}
pub fn with_default_config(db: Arc<Mutex<EngramDb>>) -> 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<ReasoningResult> {
let mut reasoning_steps = 0u32;
// Step 1: Find seed nodes via vector search
let seeds: Vec<Uuid> = {
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<engram_core::types::ActivatedNode> = {
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<EvidenceNode> = 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<Uuid> = 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<EvidenceNode> = 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<Vec<EvidenceChain>> {
// Find seed nodes close to the concept
let seeds: Vec<Node> = {
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<EvidenceChain> = Vec::new();
for seed in &seeds {
let traversal_nodes: Vec<Node> = {
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<EvidenceNode> = 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<InferenceEdge> = 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<Vec<String>> {
let process_nodes: Vec<Node> = {
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<Node> = {
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<String> = 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<Vec<(EvidenceNode, EvidenceNode)>> {
// Activate the graph around the topic
let seeds: Vec<Uuid> = {
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<Uuid>,
_hypothesis: &Hypothesis,
) -> EngramResult<Vec<InferenceEdge>> {
let db = self.db.lock().map_err(|_| {
engram_core::EngramError::InvalidParam("db lock poisoned".into())
})?;
let mut edges: Vec<InferenceEdge> = 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<EvidenceNode>,
edges: &[InferenceEdge],
) {
// Build a map: to_node → [(from_node, strength, edge_type)]
let mut incoming: HashMap<Uuid, Vec<(Uuid, f32, &InferenceEdgeType)>> = 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<Uuid, f32> = 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<EvidenceChain> {
let mut chains: Vec<EvidenceChain> = Vec::new();
// Build adjacency map for chain construction
let mut adj: HashMap<Uuid, Vec<&InferenceEdge>> = HashMap::new();
for edge in edges {
adj.entry(edge.from_node).or_default().push(edge);
}
let node_map: HashMap<Uuid, &EvidenceNode> =
nodes.iter().map(|n| (n.engram_node_id, n)).collect();
// Support chain: follow Supports/Implies edges from direct support nodes
let support_starts: Vec<Uuid> = 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<Uuid> = 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<Uuid> = 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<Uuid> = 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<Uuid, Vec<&InferenceEdge>>,
node_map: &HashMap<Uuid, &EvidenceNode>,
chain_type: ChainType,
max_len: usize,
_hypothesis: &Hypothesis,
) -> Option<EvidenceChain> {
let start_node = node_map.get(&start)?;
let mut chain_nodes: Vec<EvidenceNode> = vec![(*start_node).clone()];
let mut chain_edges: Vec<InferenceEdge> = Vec::new();
let mut visited: HashSet<Uuid> = HashSet::from([start]);
let mut current = start;
for _ in 0..max_len {
let Some(outgoing) = adj.get(&current) 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<String> = 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::<String>()))
.collect::<Vec<_>>()
.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::<Vec<_>>()
.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
),
}
}