1009 lines
39 KiB
Rust
1009 lines
39 KiB
Rust
/// 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(¤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<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
|
||
),
|
||
}
|
||
}
|