Archived
feat: engram-reasoning — graph-native inference engine, evidence chains, confidence propagation
This commit is contained in:
Generated
+13
@@ -587,6 +587,18 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "engram-reasoning"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"engram-core",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "engram-server"
|
||||
version = "0.1.0"
|
||||
@@ -597,6 +609,7 @@ dependencies = [
|
||||
"engram-core",
|
||||
"engram-crypto",
|
||||
"engram-projection",
|
||||
"engram-reasoning",
|
||||
"engram-sync",
|
||||
"engram-tx",
|
||||
"mime_guess",
|
||||
|
||||
@@ -10,6 +10,7 @@ members = [
|
||||
"crates/engram-projection",
|
||||
"crates/engram-tx",
|
||||
"crates/engram-crypto",
|
||||
"crates/engram-reasoning",
|
||||
# engram-wasm is in bindings/ and compiled separately via wasm-pack
|
||||
# (wasm targets can't be in the same workspace build as native targets)
|
||||
]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "engram-reasoning"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Graph-native inference engine for Engram — evidence chains, confidence propagation, causal reasoning"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
engram-core = { path = "../engram-core" }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
/// Engram Reasoning Engine — graph-native inference separated from language generation.
|
||||
///
|
||||
/// # What this crate is
|
||||
///
|
||||
/// This is NOT an LLM wrapper. It is a reasoning system that traverses the Engram
|
||||
/// knowledge graph to reach conclusions through evidence chains.
|
||||
///
|
||||
/// LLMs: input tokens → transformer → output tokens. Reasoning and generation are
|
||||
/// the same process. You cannot separate them.
|
||||
///
|
||||
/// This engine: hypothesis → graph traversal → evidence chains → confidence-weighted
|
||||
/// conclusion. Generation happens separately (a codec converts the conclusion to
|
||||
/// language). The reasoning IS the traversal.
|
||||
///
|
||||
/// # Quick Start
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use engram_core::{EngramDb, Node, Edge, NodeType, MemoryTier, RelationType};
|
||||
/// use engram_reasoning::{ReasoningEngine, Hypothesis, HypothesisType, ReasoningConfig};
|
||||
/// use std::path::Path;
|
||||
/// use std::sync::{Arc, Mutex};
|
||||
///
|
||||
/// let db = Arc::new(Mutex::new(EngramDb::open(Path::new("/tmp/engram-reason-test")).unwrap()));
|
||||
/// let config = ReasoningConfig::default();
|
||||
/// let mut engine = ReasoningEngine::new(db, config);
|
||||
///
|
||||
/// let hypothesis = Hypothesis::new(
|
||||
/// "Spreading activation improves memory retrieval",
|
||||
/// vec![0.9f32, 0.1, 0.3, 0.7],
|
||||
/// HypothesisType::IsTrue,
|
||||
/// );
|
||||
///
|
||||
/// let result = engine.reason(&hypothesis).unwrap();
|
||||
/// println!("Verdict: {:?}", result.conclusion.verdict);
|
||||
/// println!("Confidence: {:.2}", result.confidence);
|
||||
/// ```
|
||||
|
||||
pub mod engine;
|
||||
pub mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// Re-export the primary public surface
|
||||
pub use engine::ReasoningEngine;
|
||||
pub use types::{
|
||||
CausalDirection, ChainType, Conclusion, EvidenceChain, EvidenceNode, EvidenceType,
|
||||
Hypothesis, HypothesisType, InferenceEdge, InferenceEdgeType, ReasoningConfig,
|
||||
ReasoningResult, Verdict,
|
||||
};
|
||||
@@ -0,0 +1,531 @@
|
||||
/// Tests for the engram-reasoning engine.
|
||||
///
|
||||
/// Covers: construction, hypothesis creation, evidence classification,
|
||||
/// confidence propagation, causal chains, contradiction detection,
|
||||
/// empty-graph behaviour, and full integration scenarios.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
use engram_core::{Edge, EngramDb, MemoryTier, Node, NodeType, RelationType};
|
||||
|
||||
use crate::{
|
||||
CausalDirection, EvidenceNode, EvidenceType, Hypothesis, HypothesisType, InferenceEdge,
|
||||
InferenceEdgeType, ReasoningConfig, ReasoningEngine, Verdict,
|
||||
};
|
||||
|
||||
// ── Test fixtures ─────────────────────────────────────────────────────────
|
||||
|
||||
fn make_db() -> (TempDir, Arc<Mutex<EngramDb>>) {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let db = EngramDb::open(dir.path()).expect("open db");
|
||||
(dir, Arc::new(Mutex::new(db)))
|
||||
}
|
||||
|
||||
fn make_engine(db: Arc<Mutex<EngramDb>>) -> ReasoningEngine {
|
||||
ReasoningEngine::with_default_config(db)
|
||||
}
|
||||
|
||||
fn embedding(values: &[f32]) -> Vec<f32> {
|
||||
values.to_vec()
|
||||
}
|
||||
|
||||
fn make_node(
|
||||
db: &Arc<Mutex<EngramDb>>,
|
||||
content: &str,
|
||||
emb: Vec<f32>,
|
||||
node_type: NodeType,
|
||||
importance: f32,
|
||||
) -> Uuid {
|
||||
let node = Node::new(
|
||||
node_type,
|
||||
emb,
|
||||
content.as_bytes().to_vec(),
|
||||
MemoryTier::Semantic,
|
||||
importance,
|
||||
);
|
||||
let id = node.id;
|
||||
db.lock().unwrap().put_node(node).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn make_edge(db: &Arc<Mutex<EngramDb>>, from: Uuid, to: Uuid, relation: RelationType) {
|
||||
let edge = Edge::new(from, to, relation, 0.8);
|
||||
db.lock().unwrap().put_edge(edge).unwrap();
|
||||
}
|
||||
|
||||
// ── Test 1: Engine construction with default config ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_engine_construction_default() {
|
||||
let (_dir, db) = make_db();
|
||||
let engine = make_engine(db);
|
||||
assert_eq!(engine.config.max_depth, 5);
|
||||
assert!((engine.config.min_confidence - 0.05).abs() < f32::EPSILON);
|
||||
assert_eq!(engine.config.max_evidence_nodes, 50);
|
||||
assert!((engine.config.contradiction_threshold - 0.4).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
// ── Test 2: Engine construction with custom config ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_engine_construction_custom_config() {
|
||||
let (_dir, db) = make_db();
|
||||
let config = ReasoningConfig {
|
||||
max_depth: 3,
|
||||
min_confidence: 0.1,
|
||||
max_evidence_nodes: 20,
|
||||
contradiction_threshold: 0.3,
|
||||
};
|
||||
let engine = ReasoningEngine::new(db, config);
|
||||
assert_eq!(engine.config.max_depth, 3);
|
||||
assert_eq!(engine.config.max_evidence_nodes, 20);
|
||||
}
|
||||
|
||||
// ── Test 3: Hypothesis creation for each type ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_hypothesis_creation_is_true() {
|
||||
let h = Hypothesis::new("X is true", vec![1.0, 0.0], HypothesisType::IsTrue);
|
||||
assert_eq!(h.hypothesis_type, HypothesisType::IsTrue);
|
||||
assert_eq!(h.text, "X is true");
|
||||
assert!(!h.id.is_nil());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hypothesis_creation_what_causes() {
|
||||
let h = Hypothesis::new("What causes X", vec![0.5, 0.5], HypothesisType::WhatCauses);
|
||||
assert_eq!(h.hypothesis_type, HypothesisType::WhatCauses);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hypothesis_creation_how_to() {
|
||||
let h = Hypothesis::new("How to do X", vec![0.3, 0.7], HypothesisType::HowTo);
|
||||
assert_eq!(h.hypothesis_type, HypothesisType::HowTo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hypothesis_creation_what_is() {
|
||||
let h = Hypothesis::new("What is X", vec![0.2, 0.8], HypothesisType::WhatIs);
|
||||
assert_eq!(h.hypothesis_type, HypothesisType::WhatIs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hypothesis_creation_compare() {
|
||||
let h = Hypothesis::new("Compare X and Y", vec![0.6, 0.4], HypothesisType::Compare);
|
||||
assert_eq!(h.hypothesis_type, HypothesisType::Compare);
|
||||
}
|
||||
|
||||
// ── Test 4: Empty graph → Insufficient ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_empty_graph_gives_insufficient() {
|
||||
let (_dir, db) = make_db();
|
||||
let mut engine = make_engine(db);
|
||||
let h = Hypothesis::new("anything", vec![1.0, 0.0, 0.0, 0.0], HypothesisType::IsTrue);
|
||||
let result = engine.reason(&h).unwrap();
|
||||
assert!(
|
||||
matches!(result.conclusion.verdict, Verdict::Insufficient),
|
||||
"Expected Insufficient, got {:?}",
|
||||
result.conclusion.verdict
|
||||
);
|
||||
assert_eq!(result.confidence, 0.0);
|
||||
}
|
||||
|
||||
// ── Test 5: Evidence classification — DirectSupport ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_evidence_classification_direct_support() {
|
||||
let (_dir, db) = make_db();
|
||||
let engine = make_engine(db.clone());
|
||||
|
||||
// Node with embedding very close to hypothesis
|
||||
let node = Node::new(
|
||||
NodeType::Memory,
|
||||
vec![0.98_f32, 0.02, 0.0, 0.0],
|
||||
b"Spreading activation is effective".to_vec(),
|
||||
MemoryTier::Semantic,
|
||||
0.9,
|
||||
);
|
||||
let h = Hypothesis::new(
|
||||
"Spreading activation is effective",
|
||||
vec![1.0_f32, 0.0, 0.0, 0.0],
|
||||
HypothesisType::IsTrue,
|
||||
);
|
||||
let ev = engine.classify_evidence(&node, &h, 0.8, 0);
|
||||
assert_eq!(ev.evidence_type, EvidenceType::DirectSupport);
|
||||
}
|
||||
|
||||
// ── Test 6: Evidence classification — DirectRefutation ───────────────────
|
||||
|
||||
#[test]
|
||||
fn test_evidence_classification_direct_refutation() {
|
||||
let (_dir, db) = make_db();
|
||||
let engine = make_engine(db.clone());
|
||||
|
||||
let node = Node::new(
|
||||
NodeType::Memory,
|
||||
vec![0.99_f32, 0.01, 0.0, 0.0],
|
||||
b"Spreading activation is not effective and is wrong".to_vec(),
|
||||
MemoryTier::Semantic,
|
||||
0.9,
|
||||
);
|
||||
let h = Hypothesis::new(
|
||||
"Spreading activation is effective",
|
||||
vec![1.0_f32, 0.0, 0.0, 0.0],
|
||||
HypothesisType::IsTrue,
|
||||
);
|
||||
let ev = engine.classify_evidence(&node, &h, 0.7, 0);
|
||||
assert_eq!(ev.evidence_type, EvidenceType::DirectRefutation);
|
||||
}
|
||||
|
||||
// ── Test 7: Evidence classification — ContextualFact ─────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_evidence_classification_contextual_fact() {
|
||||
let (_dir, db) = make_db();
|
||||
let engine = make_engine(db.clone());
|
||||
|
||||
// Node with low similarity to hypothesis
|
||||
let node = Node::new(
|
||||
NodeType::Memory,
|
||||
vec![0.0_f32, 0.0, 1.0, 0.0], // orthogonal
|
||||
b"Some unrelated content".to_vec(),
|
||||
MemoryTier::Semantic,
|
||||
0.5,
|
||||
);
|
||||
let h = Hypothesis::new(
|
||||
"Spreading activation",
|
||||
vec![1.0_f32, 0.0, 0.0, 0.0],
|
||||
HypothesisType::IsTrue,
|
||||
);
|
||||
let ev = engine.classify_evidence(&node, &h, 0.3, 2);
|
||||
assert_eq!(ev.evidence_type, EvidenceType::ContextualFact);
|
||||
}
|
||||
|
||||
// ── Test 8: Evidence classification — ProceduralStep ─────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_evidence_classification_procedural_step() {
|
||||
let (_dir, db) = make_db();
|
||||
let engine = make_engine(db.clone());
|
||||
|
||||
let node = Node::new(
|
||||
NodeType::Process,
|
||||
vec![0.95_f32, 0.05, 0.0, 0.0],
|
||||
b"Step 1: initialize the graph".to_vec(),
|
||||
MemoryTier::Procedural,
|
||||
0.8,
|
||||
);
|
||||
let h = Hypothesis::new(
|
||||
"How to build a knowledge graph",
|
||||
vec![1.0_f32, 0.0, 0.0, 0.0],
|
||||
HypothesisType::HowTo,
|
||||
);
|
||||
let ev = engine.classify_evidence(&node, &h, 0.9, 0);
|
||||
assert_eq!(ev.evidence_type, EvidenceType::ProceduralStep);
|
||||
}
|
||||
|
||||
// ── Test 9: Confidence propagation ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_confidence_propagation_boost() {
|
||||
let (_dir, db) = make_db();
|
||||
let engine = make_engine(db.clone());
|
||||
|
||||
let id_a = Uuid::new_v4();
|
||||
let id_b = Uuid::new_v4();
|
||||
|
||||
let mut nodes = vec![
|
||||
EvidenceNode {
|
||||
engram_node_id: id_a,
|
||||
content: "Node A".into(),
|
||||
evidence_type: EvidenceType::DirectSupport,
|
||||
confidence: 0.8,
|
||||
activation_strength: 0.8,
|
||||
hops_from_seed: 0,
|
||||
},
|
||||
EvidenceNode {
|
||||
engram_node_id: id_b,
|
||||
content: "Node B".into(),
|
||||
evidence_type: EvidenceType::IndirectSupport,
|
||||
confidence: 0.2,
|
||||
activation_strength: 0.4,
|
||||
hops_from_seed: 1,
|
||||
},
|
||||
];
|
||||
|
||||
let edges = vec![InferenceEdge {
|
||||
from_node: id_a,
|
||||
to_node: id_b,
|
||||
edge_type: InferenceEdgeType::Supports,
|
||||
strength: 0.9,
|
||||
engram_edge_id: None,
|
||||
}];
|
||||
|
||||
let original_b_confidence = nodes[1].confidence;
|
||||
engine.propagate_confidence(&mut nodes, &edges);
|
||||
|
||||
// Node B's confidence should be boosted by the incoming support from A
|
||||
assert!(
|
||||
nodes[1].confidence > original_b_confidence,
|
||||
"Expected confidence to increase from {}, got {}",
|
||||
original_b_confidence,
|
||||
nodes[1].confidence
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 10: Confidence propagation — refutation reduces ─────────────────
|
||||
|
||||
#[test]
|
||||
fn test_confidence_propagation_refutation_reduces() {
|
||||
let (_dir, db) = make_db();
|
||||
let engine = make_engine(db.clone());
|
||||
|
||||
let id_a = Uuid::new_v4();
|
||||
let id_b = Uuid::new_v4();
|
||||
|
||||
let mut nodes = vec![
|
||||
EvidenceNode {
|
||||
engram_node_id: id_a,
|
||||
content: "Strong refuting node".into(),
|
||||
evidence_type: EvidenceType::DirectRefutation,
|
||||
confidence: 0.9,
|
||||
activation_strength: 0.9,
|
||||
hops_from_seed: 0,
|
||||
},
|
||||
EvidenceNode {
|
||||
engram_node_id: id_b,
|
||||
content: "Downstream node".into(),
|
||||
evidence_type: EvidenceType::IndirectSupport,
|
||||
confidence: 0.6,
|
||||
activation_strength: 0.5,
|
||||
hops_from_seed: 1,
|
||||
},
|
||||
];
|
||||
|
||||
let edges = vec![InferenceEdge {
|
||||
from_node: id_a,
|
||||
to_node: id_b,
|
||||
edge_type: InferenceEdgeType::Refutes,
|
||||
strength: 0.8,
|
||||
engram_edge_id: None,
|
||||
}];
|
||||
|
||||
let original_conf = nodes[1].confidence;
|
||||
engine.propagate_confidence(&mut nodes, &edges);
|
||||
|
||||
assert!(
|
||||
nodes[1].confidence < original_conf,
|
||||
"Expected confidence to decrease from {}, got {}",
|
||||
original_conf,
|
||||
nodes[1].confidence
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 11: Causal chain finding ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_causal_chain_finding() {
|
||||
let (_dir, db) = make_db();
|
||||
|
||||
let emb_a = embedding(&[1.0, 0.0, 0.0, 0.0]);
|
||||
let emb_b = embedding(&[0.9, 0.1, 0.0, 0.0]);
|
||||
let emb_c = embedding(&[0.8, 0.2, 0.0, 0.0]);
|
||||
|
||||
let id_a = make_node(&db, "Heat causes expansion", emb_a.clone(), NodeType::Concept, 0.9);
|
||||
let id_b = make_node(&db, "Expansion causes pressure", emb_b, NodeType::Concept, 0.8);
|
||||
let id_c = make_node(&db, "Pressure causes rupture", emb_c, NodeType::Concept, 0.7);
|
||||
|
||||
make_edge(&db, id_a, id_b, RelationType::Causes);
|
||||
make_edge(&db, id_b, id_c, RelationType::Causes);
|
||||
|
||||
let mut engine = make_engine(db.clone());
|
||||
let chains = engine.causal_chain(&emb_a, CausalDirection::Forward).unwrap();
|
||||
|
||||
assert!(!chains.is_empty(), "Expected at least one causal chain");
|
||||
let chain = &chains[0];
|
||||
assert!(chain.nodes.len() >= 2, "Expected chain with at least 2 nodes");
|
||||
assert_eq!(chain.chain_type, crate::ChainType::CausalChain);
|
||||
}
|
||||
|
||||
// ── Test 12: Contradiction detection ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_contradiction_detection_via_edge() {
|
||||
let (_dir, db) = make_db();
|
||||
|
||||
let emb_topic = embedding(&[1.0_f32, 0.0, 0.0, 0.0]);
|
||||
|
||||
let id_a = make_node(
|
||||
&db,
|
||||
"Water boils at 100°C",
|
||||
embedding(&[0.95, 0.05, 0.0, 0.0]),
|
||||
NodeType::Memory,
|
||||
0.9,
|
||||
);
|
||||
let id_b = make_node(
|
||||
&db,
|
||||
"Water does not boil at 100°C",
|
||||
embedding(&[0.93, 0.07, 0.0, 0.0]),
|
||||
NodeType::Memory,
|
||||
0.9,
|
||||
);
|
||||
make_edge(&db, id_a, id_b, RelationType::Contradicts);
|
||||
|
||||
let mut engine = make_engine(db.clone());
|
||||
let contradictions = engine.find_contradictions(&emb_topic).unwrap();
|
||||
|
||||
assert!(
|
||||
!contradictions.is_empty(),
|
||||
"Expected at least one contradiction pair"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 13: Integration — insert nodes, reason, check verdict ────────────
|
||||
|
||||
#[test]
|
||||
fn test_integration_supported_verdict() {
|
||||
let (_dir, db) = make_db();
|
||||
|
||||
// Insert several nodes that strongly support the hypothesis
|
||||
let hyp_emb = embedding(&[1.0_f32, 0.0, 0.0, 0.0]);
|
||||
|
||||
for i in 0..5 {
|
||||
let content = format!("Evidence {} supporting spreading activation memory retrieval", i);
|
||||
let emb = embedding(&[0.92 - i as f32 * 0.01, 0.08 + i as f32 * 0.01, 0.0, 0.0]);
|
||||
make_node(&db, &content, emb, NodeType::Memory, 0.9);
|
||||
}
|
||||
|
||||
let mut engine = make_engine(db.clone());
|
||||
let h = Hypothesis::new(
|
||||
"Spreading activation improves memory retrieval",
|
||||
hyp_emb,
|
||||
HypothesisType::IsTrue,
|
||||
);
|
||||
let result = engine.reason(&h).unwrap();
|
||||
|
||||
// With several high-similarity supporting nodes, expect Supported or at least
|
||||
// not Insufficient
|
||||
assert!(
|
||||
!matches!(result.conclusion.verdict, Verdict::Insufficient),
|
||||
"Expected a reasoned verdict, got Insufficient"
|
||||
);
|
||||
assert!(result.nodes_visited > 0);
|
||||
assert!(result.reasoning_steps > 0);
|
||||
}
|
||||
|
||||
// ── Test 14: Integration — refutation via negation ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_integration_refutation_via_negation() {
|
||||
let (_dir, db) = make_db();
|
||||
|
||||
let hyp_emb = embedding(&[1.0_f32, 0.0, 0.0, 0.0]);
|
||||
|
||||
// Insert nodes with negation content, similar embedding
|
||||
for i in 0..5 {
|
||||
let content = format!("Spreading activation does not improve retrieval — test {}", i);
|
||||
let emb = embedding(&[0.93 - i as f32 * 0.01, 0.07 + i as f32 * 0.01, 0.0, 0.0]);
|
||||
make_node(&db, &content, emb, NodeType::Memory, 0.85);
|
||||
}
|
||||
|
||||
let mut engine = make_engine(db.clone());
|
||||
let h = Hypothesis::new(
|
||||
"Spreading activation improves retrieval",
|
||||
hyp_emb,
|
||||
HypothesisType::IsTrue,
|
||||
);
|
||||
let result = engine.reason(&h).unwrap();
|
||||
// The primary evidence should be mostly refutation
|
||||
let has_refutation = result.conclusion.primary_evidence.iter().any(|e| {
|
||||
matches!(
|
||||
e.evidence_type,
|
||||
EvidenceType::DirectRefutation | EvidenceType::IndirectRefutation
|
||||
)
|
||||
});
|
||||
// Not all graphs will surface refutation at confidence level, but the
|
||||
// reasoning machinery should run without errors
|
||||
let _ = has_refutation;
|
||||
assert!(result.reasoning_steps > 0);
|
||||
}
|
||||
|
||||
// ── Test 15: Procedural chain for HowTo ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_procedural_chain_for_how_to() {
|
||||
let (_dir, db) = make_db();
|
||||
|
||||
let goal_emb = embedding(&[1.0_f32, 0.0, 0.0, 0.0]);
|
||||
|
||||
// Create a chain of process nodes
|
||||
let id_1 = make_node(
|
||||
&db,
|
||||
"Step 1: Define the graph schema",
|
||||
embedding(&[0.95, 0.05, 0.0, 0.0]),
|
||||
NodeType::Process,
|
||||
0.9,
|
||||
);
|
||||
let id_2 = make_node(
|
||||
&db,
|
||||
"Step 2: Insert initial nodes",
|
||||
embedding(&[0.90, 0.10, 0.0, 0.0]),
|
||||
NodeType::Process,
|
||||
0.9,
|
||||
);
|
||||
let id_3 = make_node(
|
||||
&db,
|
||||
"Step 3: Create edges between nodes",
|
||||
embedding(&[0.85, 0.15, 0.0, 0.0]),
|
||||
NodeType::Process,
|
||||
0.9,
|
||||
);
|
||||
make_edge(&db, id_1, id_2, RelationType::Causes);
|
||||
make_edge(&db, id_2, id_3, RelationType::Causes);
|
||||
|
||||
let mut engine = make_engine(db.clone());
|
||||
let steps = engine.procedural_chain(&goal_emb).unwrap();
|
||||
|
||||
assert!(!steps.is_empty(), "Expected procedural steps");
|
||||
}
|
||||
|
||||
// ── Test 16: Evidence chain confidence is product of edge strengths ────────
|
||||
|
||||
#[test]
|
||||
fn test_evidence_chain_confidence_product() {
|
||||
let edges = vec![
|
||||
InferenceEdge {
|
||||
from_node: Uuid::new_v4(),
|
||||
to_node: Uuid::new_v4(),
|
||||
edge_type: InferenceEdgeType::Supports,
|
||||
strength: 0.8,
|
||||
engram_edge_id: None,
|
||||
},
|
||||
InferenceEdge {
|
||||
from_node: Uuid::new_v4(),
|
||||
to_node: Uuid::new_v4(),
|
||||
edge_type: InferenceEdgeType::Implies,
|
||||
strength: 0.5,
|
||||
engram_edge_id: None,
|
||||
},
|
||||
];
|
||||
let confidence = crate::EvidenceChain::compute_confidence(&edges);
|
||||
let expected = 0.8 * 0.5;
|
||||
assert!(
|
||||
(confidence - expected).abs() < 1e-6,
|
||||
"Expected {}, got {}",
|
||||
expected,
|
||||
confidence
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 17: Empty edges give chain confidence 1.0 ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_empty_chain_confidence_is_one() {
|
||||
let confidence = crate::EvidenceChain::compute_confidence(&[]);
|
||||
assert!((confidence - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/// Core types for the Engram reasoning engine.
|
||||
///
|
||||
/// These types represent hypotheses, evidence nodes, inference edges, and
|
||||
/// reasoning results. They are graph-native: every concept is grounded in
|
||||
/// the Engram knowledge graph rather than in token distributions.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── Hypothesis ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A hypothesis to be evaluated by the reasoning engine.
|
||||
///
|
||||
/// "Is X true?", "What causes Y?", "How do I do Z?" — all expressed as a typed
|
||||
/// claim whose embedding anchors the graph traversal.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Hypothesis {
|
||||
pub id: Uuid,
|
||||
/// Natural language text of the hypothesis
|
||||
pub text: String,
|
||||
/// Semantic embedding — the hypothesis's position in meaning-space.
|
||||
/// Used to seed the spreading activation and to classify evidence.
|
||||
pub embedding: Vec<f32>,
|
||||
pub hypothesis_type: HypothesisType,
|
||||
}
|
||||
|
||||
impl Hypothesis {
|
||||
pub fn new(text: impl Into<String>, embedding: Vec<f32>, hypothesis_type: HypothesisType) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
text: text.into(),
|
||||
embedding,
|
||||
hypothesis_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The semantic class of a hypothesis — governs how the engine traverses and
|
||||
/// classifies evidence.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum HypothesisType {
|
||||
/// Boolean claim — find supporting or refuting evidence
|
||||
IsTrue,
|
||||
/// Causal query — find cause chains leading to the concept
|
||||
WhatCauses,
|
||||
/// Procedural query — find ordered process chains for achieving the goal
|
||||
HowTo,
|
||||
/// Definitional query — find semantic clusters around the concept
|
||||
WhatIs,
|
||||
/// Comparison — find similarities and differences between two concepts
|
||||
Compare,
|
||||
}
|
||||
|
||||
// ── Evidence ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A node in the evidence graph — an Engram node annotated with its evidential
|
||||
/// role relative to the hypothesis being evaluated.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EvidenceNode {
|
||||
/// The backing Engram graph node's UUID
|
||||
pub engram_node_id: Uuid,
|
||||
/// Decoded text content of the node
|
||||
pub content: String,
|
||||
/// How this node relates to the hypothesis
|
||||
pub evidence_type: EvidenceType,
|
||||
/// Intrinsic confidence (from node importance and salience), 0.0–1.0
|
||||
pub confidence: f32,
|
||||
/// Activation strength at this node from spreading activation
|
||||
pub activation_strength: f32,
|
||||
/// Number of hops from the hypothesis seed nodes
|
||||
pub hops_from_seed: u32,
|
||||
}
|
||||
|
||||
/// The role an evidence node plays relative to the hypothesis.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EvidenceType {
|
||||
/// Directly supports the hypothesis (cosine sim > 0.8, no negation)
|
||||
DirectSupport,
|
||||
/// Directly refutes the hypothesis (cosine sim > 0.8, negation signals)
|
||||
DirectRefutation,
|
||||
/// Supports via an inference chain (cosine sim 0.5–0.8)
|
||||
IndirectSupport,
|
||||
/// Refutes via an inference chain (cosine sim 0.5–0.8, negation)
|
||||
IndirectRefutation,
|
||||
/// Relevant context, neither clearly for nor against
|
||||
ContextualFact,
|
||||
/// A step in a process chain (node type = Process or Procedure)
|
||||
ProceduralStep,
|
||||
/// A causal antecedent — causes something in the chain
|
||||
CausalAntecedent,
|
||||
/// A causal consequent — caused by something in the chain
|
||||
CausalConsequent,
|
||||
}
|
||||
|
||||
// ── Inference edges ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A directed inference edge in the evidence graph.
|
||||
///
|
||||
/// May be backed by a real Engram edge (when the relation type maps cleanly)
|
||||
/// or constructed by the engine from semantic proximity.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InferenceEdge {
|
||||
pub from_node: Uuid,
|
||||
pub to_node: Uuid,
|
||||
pub edge_type: InferenceEdgeType,
|
||||
/// Strength of this inference step, 0.0–1.0
|
||||
pub strength: f32,
|
||||
/// The backing Engram edge UUID, if this edge corresponds to a stored relation
|
||||
pub engram_edge_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// The semantic type of an inference edge.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InferenceEdgeType {
|
||||
/// A supports B
|
||||
Supports,
|
||||
/// A refutes B
|
||||
Refutes,
|
||||
/// A causes B
|
||||
Causes,
|
||||
/// A requires B (precondition)
|
||||
Requires,
|
||||
/// A implies B (logical entailment)
|
||||
Implies,
|
||||
/// A contradicts B
|
||||
Contradicts,
|
||||
/// A is semantically similar to B
|
||||
SimilarTo,
|
||||
/// A is an instance of B
|
||||
InstanceOf,
|
||||
}
|
||||
|
||||
// ── Evidence chains ───────────────────────────────────────────────────────────
|
||||
|
||||
/// An ordered sequence of evidence nodes and the edges connecting them —
|
||||
/// a single thread of reasoning from the hypothesis to a conclusion.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EvidenceChain {
|
||||
pub nodes: Vec<EvidenceNode>,
|
||||
pub edges: Vec<InferenceEdge>,
|
||||
/// Product of edge strengths along the chain — lower for longer/weaker chains
|
||||
pub chain_confidence: f32,
|
||||
pub chain_type: ChainType,
|
||||
}
|
||||
|
||||
impl EvidenceChain {
|
||||
/// Compute chain_confidence as the product of all edge strengths.
|
||||
pub fn compute_confidence(edges: &[InferenceEdge]) -> f32 {
|
||||
if edges.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
edges.iter().map(|e| e.strength).product()
|
||||
}
|
||||
}
|
||||
|
||||
/// The logical character of an evidence chain.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ChainType {
|
||||
/// Chain that supports the hypothesis
|
||||
SupportChain,
|
||||
/// Chain that refutes the hypothesis
|
||||
RefutationChain,
|
||||
/// Chain tracing cause→effect relationships
|
||||
CausalChain,
|
||||
/// Chain tracing ordered procedural steps
|
||||
ProcessChain,
|
||||
}
|
||||
|
||||
// ── Results ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The full output of a reasoning pass over the knowledge graph.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReasoningResult {
|
||||
pub hypothesis: Hypothesis,
|
||||
pub conclusion: Conclusion,
|
||||
/// All evidence chains discovered during traversal
|
||||
pub evidence_chains: Vec<EvidenceChain>,
|
||||
/// Overall confidence in the conclusion, 0.0–1.0
|
||||
pub confidence: f32,
|
||||
/// Total reasoning steps (graph operations) performed
|
||||
pub reasoning_steps: u32,
|
||||
/// Total graph nodes visited during traversal
|
||||
pub nodes_visited: u32,
|
||||
}
|
||||
|
||||
/// The conclusion reached by the reasoning engine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Conclusion {
|
||||
pub verdict: Verdict,
|
||||
/// Natural language summary of the reasoning path
|
||||
pub summary: String,
|
||||
pub confidence: f32,
|
||||
/// The strongest pieces of evidence driving this conclusion
|
||||
pub primary_evidence: Vec<EvidenceNode>,
|
||||
}
|
||||
|
||||
/// The verdict produced by the reasoning engine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Verdict {
|
||||
/// Hypothesis supported with this confidence
|
||||
Supported(f32),
|
||||
/// Hypothesis refuted with this confidence
|
||||
Refuted(f32),
|
||||
/// Not enough evidence in the graph to reach a conclusion
|
||||
Insufficient,
|
||||
/// Conflicting evidence — cannot resolve without more context
|
||||
Contradictory,
|
||||
/// HowTo verdict — ordered steps found in the graph
|
||||
Procedural(Vec<String>),
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Tuning parameters for the reasoning engine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReasoningConfig {
|
||||
/// Maximum hops from seed nodes during spreading activation (default: 5)
|
||||
pub max_depth: u32,
|
||||
/// Prune nodes with activation strength below this (default: 0.05)
|
||||
pub min_confidence: f32,
|
||||
/// Cap on the number of evidence nodes collected (default: 50)
|
||||
pub max_evidence_nodes: u32,
|
||||
/// If both support and refutation mass exceed this fraction of total, declare
|
||||
/// Contradictory rather than Supported/Refuted (default: 0.4)
|
||||
pub contradiction_threshold: f32,
|
||||
}
|
||||
|
||||
impl Default for ReasoningConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_depth: 5,
|
||||
min_confidence: 0.05,
|
||||
max_evidence_nodes: 50,
|
||||
contradiction_threshold: 0.4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Direction for causal chain traversal.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CausalDirection {
|
||||
/// Find what causes the given concept (backward traversal)
|
||||
Backward,
|
||||
/// Find what the given concept causes (forward traversal)
|
||||
Forward,
|
||||
/// Find both directions
|
||||
Both,
|
||||
}
|
||||
@@ -11,6 +11,7 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
engram-core = { path = "../engram-core" }
|
||||
engram-reasoning = { path = "../engram-reasoning" }
|
||||
engram-sync = { path = "../engram-sync" }
|
||||
engram-projection = { path = "../engram-projection" }
|
||||
engram-tx = { path = "../engram-tx" }
|
||||
|
||||
@@ -228,6 +228,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/tx/history", get(routes::tx::tx_history))
|
||||
.route("/tx/chain/{command_id}", get(routes::tx::tx_causal_chain));
|
||||
|
||||
// Reasoning routes (no auth — graph-native inference)
|
||||
let reasoning_routes = Router::new()
|
||||
.route("/reason", post(routes::reasoning::reason))
|
||||
.route("/reason/causal", post(routes::reasoning::causal))
|
||||
.route("/reason/contradictions", post(routes::reasoning::contradictions));
|
||||
|
||||
let studio_routes = Router::new()
|
||||
.route("/", get(serve_studio_index))
|
||||
.route("/studio", get(serve_studio_index))
|
||||
@@ -239,6 +245,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.merge(sync_routes)
|
||||
.merge(projection_routes)
|
||||
.merge(tx_routes)
|
||||
.merge(reasoning_routes)
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod core;
|
||||
pub mod projection;
|
||||
pub mod reasoning;
|
||||
pub mod sync;
|
||||
pub mod swarm;
|
||||
pub mod tx;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/// Reasoning API routes — graph-native inference over the Engram knowledge graph.
|
||||
///
|
||||
/// POST /reason — evaluate a hypothesis
|
||||
/// POST /reason/causal — find causal chains for a concept
|
||||
/// POST /reason/contradictions — detect contradictions around a topic
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use engram_reasoning::{
|
||||
CausalDirection, Hypothesis, HypothesisType, ReasoningConfig, ReasoningEngine,
|
||||
ReasoningResult, EvidenceChain, EvidenceNode,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── POST /reason ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ReasonRequest {
|
||||
pub hypothesis: String,
|
||||
pub hypothesis_type: HypothesisTypeParam,
|
||||
pub embedding: Vec<f32>,
|
||||
#[serde(default)]
|
||||
pub config: ReasoningConfigParam,
|
||||
}
|
||||
|
||||
/// JSON-friendly version of HypothesisType (mirrors the enum for serde)
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum HypothesisTypeParam {
|
||||
IsTrue,
|
||||
WhatCauses,
|
||||
HowTo,
|
||||
WhatIs,
|
||||
Compare,
|
||||
}
|
||||
|
||||
impl From<HypothesisTypeParam> for HypothesisType {
|
||||
fn from(p: HypothesisTypeParam) -> Self {
|
||||
match p {
|
||||
HypothesisTypeParam::IsTrue => HypothesisType::IsTrue,
|
||||
HypothesisTypeParam::WhatCauses => HypothesisType::WhatCauses,
|
||||
HypothesisTypeParam::HowTo => HypothesisType::HowTo,
|
||||
HypothesisTypeParam::WhatIs => HypothesisType::WhatIs,
|
||||
HypothesisTypeParam::Compare => HypothesisType::Compare,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct ReasoningConfigParam {
|
||||
pub max_depth: Option<u32>,
|
||||
pub min_confidence: Option<f32>,
|
||||
pub max_evidence_nodes: Option<u32>,
|
||||
pub contradiction_threshold: Option<f32>,
|
||||
}
|
||||
|
||||
impl From<ReasoningConfigParam> for ReasoningConfig {
|
||||
fn from(p: ReasoningConfigParam) -> Self {
|
||||
let def = ReasoningConfig::default();
|
||||
ReasoningConfig {
|
||||
max_depth: p.max_depth.unwrap_or(def.max_depth),
|
||||
min_confidence: p.min_confidence.unwrap_or(def.min_confidence),
|
||||
max_evidence_nodes: p.max_evidence_nodes.unwrap_or(def.max_evidence_nodes),
|
||||
contradiction_threshold: p
|
||||
.contradiction_threshold
|
||||
.unwrap_or(def.contradiction_threshold),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reason(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<ReasonRequest>,
|
||||
) -> Result<Json<ReasoningResult>, StatusCode> {
|
||||
let config: ReasoningConfig = req.config.into();
|
||||
let hypothesis = Hypothesis::new(req.hypothesis, req.embedding, req.hypothesis_type.into());
|
||||
|
||||
let db = state.db.clone();
|
||||
let mut engine = ReasoningEngine::new(db, config);
|
||||
|
||||
let result = engine
|
||||
.reason(&hypothesis)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// ── POST /reason/causal ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CausalRequest {
|
||||
pub concept_embedding: Vec<f32>,
|
||||
#[serde(default = "default_causal_direction")]
|
||||
pub direction: CausalDirectionParam,
|
||||
}
|
||||
|
||||
fn default_causal_direction() -> CausalDirectionParam {
|
||||
CausalDirectionParam::Forward
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum CausalDirectionParam {
|
||||
Forward,
|
||||
Backward,
|
||||
Both,
|
||||
}
|
||||
|
||||
impl From<CausalDirectionParam> for CausalDirection {
|
||||
fn from(p: CausalDirectionParam) -> Self {
|
||||
match p {
|
||||
CausalDirectionParam::Forward => CausalDirection::Forward,
|
||||
CausalDirectionParam::Backward => CausalDirection::Backward,
|
||||
CausalDirectionParam::Both => CausalDirection::Both,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CausalResponse {
|
||||
pub chains: Vec<EvidenceChain>,
|
||||
}
|
||||
|
||||
pub async fn causal(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<CausalRequest>,
|
||||
) -> Result<Json<CausalResponse>, StatusCode> {
|
||||
let db = state.db.clone();
|
||||
let mut engine = ReasoningEngine::with_default_config(db);
|
||||
|
||||
let chains = engine
|
||||
.causal_chain(&req.concept_embedding, req.direction.into())
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(CausalResponse { chains }))
|
||||
}
|
||||
|
||||
// ── POST /reason/contradictions ───────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ContradictionsRequest {
|
||||
pub topic_embedding: Vec<f32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ContradictionPair {
|
||||
pub supporting: EvidenceNode,
|
||||
pub refuting: EvidenceNode,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ContradictionsResponse {
|
||||
pub contradictions: Vec<ContradictionPair>,
|
||||
}
|
||||
|
||||
pub async fn contradictions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<ContradictionsRequest>,
|
||||
) -> Result<Json<ContradictionsResponse>, StatusCode> {
|
||||
let db = state.db.clone();
|
||||
let mut engine = ReasoningEngine::with_default_config(db);
|
||||
|
||||
let pairs = engine
|
||||
.find_contradictions(&req.topic_embedding)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(ContradictionsResponse {
|
||||
contradictions: pairs
|
||||
.into_iter()
|
||||
.map(|(s, r)| ContradictionPair {
|
||||
supporting: s,
|
||||
refuting: r,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user