feat: engram-reasoning — graph-native inference engine, evidence chains, confidence propagation

This commit is contained in:
Will Anderson
2026-04-27 18:36:37 -05:00
parent 9a7302fceb
commit 37c87da9a6
11 changed files with 2052 additions and 0 deletions
Generated
+13
View File
@@ -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",
+1
View File
@@ -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)
]
+16
View File
@@ -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
+50
View File
@@ -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,
};
+531
View File
@@ -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);
}
}
+247
View File
@@ -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.01.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.50.8)
IndirectSupport,
/// Refutes via an inference chain (cosine sim 0.50.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.01.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.01.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,
}
+1
View File
@@ -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" }
+7
View File
@@ -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
View File
@@ -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(),
}))
}