From 38b11508385f40bddfc38af4837fed292cfd1c45 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 4 Apr 2026 02:05:36 +0000 Subject: [PATCH 1/4] feat(config): add TruthConfig for truth scoring engine (#30) Add TruthConfig struct to config.rs with all truth engine parameters: - enabled, scoring_interval_seconds, batch_size, rescore_after_seconds - pln_base_confidence, ecan_decay_rate, ecan_spread_factor - contradiction_threshold, verification_threshold, cross_ref_limit All settings configurable via OPENBRAIN__TRUTH__* env vars with sensible defaults. Update .env.example with full documentation of new variables. Update Config::load() builder and Default impl. Part of #29 --- .env.example | 24 +++++++++++ src/config.rs | 116 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 09364df..03a3002 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,27 @@ OPENBRAIN__AUTH__ENABLED=false # Logging RUST_LOG=info,openbrain_mcp=debug + +# Truth Engine (optional) +# Enable the background truth scoring worker. When enabled, stored memories +# are continuously evaluated for truthfulness using PLN deduction, ECAN +# attention economy, and cross-referencing against related memories. +OPENBRAIN__TRUTH__ENABLED=false +# Seconds between scoring cycles (default: 300 = 5 minutes) +OPENBRAIN__TRUTH__SCORING_INTERVAL_SECONDS=300 +# Number of memories to score per cycle (default: 50) +OPENBRAIN__TRUTH__BATCH_SIZE=50 +# Seconds before a scored memory is re-evaluated (default: 86400 = 24 hours) +OPENBRAIN__TRUTH__RESCORE_AFTER_SECONDS=86400 +# PLN base confidence for deduction chains (0.0–1.0, default: 0.85) +OPENBRAIN__TRUTH__PLN_BASE_CONFIDENCE=0.85 +# ECAN STI decay rate per cycle (0.0–1.0, default: 0.95) +OPENBRAIN__TRUTH__ECAN_DECAY_RATE=0.95 +# ECAN attention spread factor (default: 0.05) +OPENBRAIN__TRUTH__ECAN_SPREAD_FACTOR=0.05 +# Similarity threshold for contradiction detection (0.0–1.0, default: 0.85) +OPENBRAIN__TRUTH__CONTRADICTION_THRESHOLD=0.85 +# Truth value threshold for "verified" categorization (0.0–1.0, default: 0.8) +OPENBRAIN__TRUTH__VERIFICATION_THRESHOLD=0.8 +# Max related memories to cross-reference per scoring (default: 10) +OPENBRAIN__TRUTH__CROSS_REF_LIMIT=10 diff --git a/src/config.rs b/src/config.rs index e9aa29f..c43c351 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,6 +15,7 @@ pub struct Config { pub dedup: DedupConfig, pub ttl: TtlConfig, pub auth: AuthConfig, + pub truth: TruthConfig, } /// Server configuration @@ -71,6 +72,41 @@ pub struct TtlConfig { pub cleanup_interval_seconds: u64, } +/// Truth scoring engine configuration +#[derive(Debug, Clone, Deserialize)] +pub struct TruthConfig { + /// Enable truth scoring background worker + #[serde(default = "default_truth_enabled")] + pub enabled: bool, + /// Seconds between scoring cycles + #[serde(default = "default_scoring_interval_seconds")] + pub scoring_interval_seconds: u64, + /// Number of memories to score per cycle + #[serde(default = "default_truth_batch_size")] + pub batch_size: usize, + /// Seconds before a scored memory is re-evaluated + #[serde(default = "default_rescore_after_seconds")] + pub rescore_after_seconds: u64, + /// Base confidence for PLN deduction chains + #[serde(default = "default_pln_base_confidence")] + pub pln_base_confidence: f32, + /// ECAN STI decay rate per cycle (0.0–1.0) + #[serde(default = "default_ecan_decay_rate")] + pub ecan_decay_rate: f32, + /// ECAN attention spread factor + #[serde(default = "default_ecan_spread_factor")] + pub ecan_spread_factor: f32, + /// Similarity threshold above which conflicting memories are contradictions + #[serde(default = "default_contradiction_threshold")] + pub contradiction_threshold: f32, + /// Truth value threshold for "verified" categorization + #[serde(default = "default_verification_threshold")] + pub verification_threshold: f32, + /// Max related memories to cross-reference per scoring + #[serde(default = "default_cross_ref_limit")] + pub cross_ref_limit: i64, +} + /// Authentication configuration #[derive(Debug, Clone, Deserialize)] pub struct AuthConfig { @@ -139,6 +175,38 @@ fn default_auth_enabled() -> bool { false } +// Truth engine defaults +fn default_truth_enabled() -> bool { + false +} +fn default_scoring_interval_seconds() -> u64 { + 300 +} +fn default_truth_batch_size() -> usize { + 50 +} +fn default_rescore_after_seconds() -> u64 { + 86400 +} +fn default_pln_base_confidence() -> f32 { + 0.85 +} +fn default_ecan_decay_rate() -> f32 { + 0.95 +} +fn default_ecan_spread_factor() -> f32 { + 0.05 +} +fn default_contradiction_threshold() -> f32 { + 0.85 +} +fn default_verification_threshold() -> f32 { + 0.8 +} +fn default_cross_ref_limit() -> i64 { + 10 +} + impl Config { /// Load configuration from environment variables pub fn load() -> Result { @@ -167,6 +235,41 @@ impl Config { )? // Auth settings .set_default("auth.enabled", default_auth_enabled())? + // Truth engine settings + .set_default("truth.enabled", default_truth_enabled())? + .set_default( + "truth.scoring_interval_seconds", + default_scoring_interval_seconds() as i64, + )? + .set_default("truth.batch_size", default_truth_batch_size() as i64)? + .set_default( + "truth.rescore_after_seconds", + default_rescore_after_seconds() as i64, + )? + .set_default( + "truth.pln_base_confidence", + default_pln_base_confidence() as f64, + )? + .set_default( + "truth.ecan_decay_rate", + default_ecan_decay_rate() as f64, + )? + .set_default( + "truth.ecan_spread_factor", + default_ecan_spread_factor() as f64, + )? + .set_default( + "truth.contradiction_threshold", + default_contradiction_threshold() as f64, + )? + .set_default( + "truth.verification_threshold", + default_verification_threshold() as f64, + )? + .set_default( + "truth.cross_ref_limit", + default_cross_ref_limit(), + )? // Load from environment with OPENBRAIN_ prefix .add_source( config::Environment::with_prefix("OPENBRAIN") @@ -231,6 +334,17 @@ impl Default for Config { enabled: default_auth_enabled(), api_keys: Vec::new(), }, - } + truth: TruthConfig { + enabled: default_truth_enabled(), + scoring_interval_seconds: default_scoring_interval_seconds(), + batch_size: default_truth_batch_size(), + rescore_after_seconds: default_rescore_after_seconds(), + pln_base_confidence: default_pln_base_confidence(), + ecan_decay_rate: default_ecan_decay_rate(), + ecan_spread_factor: default_ecan_spread_factor(), + contradiction_threshold: default_contradiction_threshold(), + verification_threshold: default_verification_threshold(), + cross_ref_limit: default_cross_ref_limit(), + }, } } From 5f9d884187c6d81d9560b2f0522f65cee72830ae Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 4 Apr 2026 02:10:15 +0000 Subject: [PATCH 2/4] feat(db): add truth scoring columns migration (#31) Add V5__truth_scoring.sql migration: - truth_value (REAL): 0.0-1.0 truth score from PLN reasoning - truth_confidence (REAL): 0.0-1.0 confidence in the score - truth_category (TEXT): verified/plausible/unverified/contradicted - truth_evaluated_at (TIMESTAMPTZ): when last scored - ecan_sti (REAL): Short-Term Importance (recency-weighted) - ecan_lti (REAL): Long-Term Importance (reliability) Partial indexes for efficient unscored memory queries. Update MemoryRecord struct with optional truth fields. Part of #29 --- migrations/V5__truth_scoring.sql | 34 ++++++++++++++++++++++++++++++++ src/db.rs | 14 +++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 migrations/V5__truth_scoring.sql diff --git a/migrations/V5__truth_scoring.sql b/migrations/V5__truth_scoring.sql new file mode 100644 index 0000000..499d214 --- /dev/null +++ b/migrations/V5__truth_scoring.sql @@ -0,0 +1,34 @@ +-- V5: Add truth scoring columns for the Truth Engine integration +-- Part of: https://gitea.ingwaz.work/Ingwaz/openbrain-mcp/issues/31 +-- +-- All columns are nullable — existing memories start unscored and +-- get populated by the background truth scoring worker. +-- +-- Rollback (manual): +-- ALTER TABLE memories +-- DROP COLUMN IF EXISTS truth_value, +-- DROP COLUMN IF EXISTS truth_confidence, +-- DROP COLUMN IF EXISTS truth_category, +-- DROP COLUMN IF EXISTS truth_evaluated_at, +-- DROP COLUMN IF EXISTS ecan_sti, +-- DROP COLUMN IF EXISTS ecan_lti; +-- DROP INDEX IF EXISTS idx_memories_truth_category; +-- DROP INDEX IF EXISTS idx_memories_truth_unevaluated; + +ALTER TABLE memories + ADD COLUMN truth_value REAL, + ADD COLUMN truth_confidence REAL, + ADD COLUMN truth_category TEXT, + ADD COLUMN truth_evaluated_at TIMESTAMPTZ, + ADD COLUMN ecan_sti REAL, + ADD COLUMN ecan_lti REAL; + +-- Partial index: quickly find memories by truth category +CREATE INDEX idx_memories_truth_category + ON memories (truth_category) + WHERE truth_category IS NOT NULL; + +-- Partial index: efficiently find unscored memories for the background worker +CREATE INDEX idx_memories_truth_unevaluated + ON memories (created_at) + WHERE truth_evaluated_at IS NULL; diff --git a/src/db.rs b/src/db.rs index 5cd70c6..ae3447b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -30,6 +30,13 @@ pub struct MemoryRecord { pub metadata: serde_json::Value, pub created_at: chrono::DateTime, pub expires_at: Option>, + // Truth scoring fields (populated by background worker) + pub truth_value: Option, + pub truth_confidence: Option, + pub truth_category: Option, + pub truth_evaluated_at: Option>, + pub ecan_sti: Option, + pub ecan_lti: Option, } /// Query result with similarity score @@ -299,6 +306,13 @@ impl Database { metadata: row.get("metadata"), created_at: row.get("created_at"), expires_at: row.get("expires_at"), + // Truth fields will be populated by issue #39 + truth_value: None, + truth_confidence: None, + truth_category: None, + truth_evaluated_at: None, + ecan_sti: None, + ecan_lti: None, }, similarity: row.get("hybrid_score"), vector_score: row.get("vector_score"), From 1672d5a145345818c7363e11b6c50aa52e4b89bd Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 4 Apr 2026 02:14:01 +0000 Subject: [PATCH 3/4] feat(truth): add PLN deduction engine (#32) Implement Probabilistic Logic Networks (PLN) inference rules: - TruthValue struct with clamped strength/confidence - deduction(): chain two implications A->B and B->C - revision(): merge two independent truth estimates - negation(): logical NOT (inverts strength, preserves confidence) - conjunction(): logical AND (multiply strength, min confidence) - score_with_evidence(): combine base score with confirmations and contradictions 10 unit tests covering basic operations, boundary cases, symmetry, zero-confidence handling, and output bounds. Part of #29 --- src/lib.rs | 1 + src/truth/mod.rs | 3 + src/truth/pln.rs | 319 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 src/truth/mod.rs create mode 100644 src/truth/pln.rs diff --git a/src/lib.rs b/src/lib.rs index f4b3909..52be22a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod embedding; pub mod migrations; pub mod tools; pub mod transport; +pub mod truth; pub mod ttl; use anyhow::Result; diff --git a/src/truth/mod.rs b/src/truth/mod.rs new file mode 100644 index 0000000..76ce9fe --- /dev/null +++ b/src/truth/mod.rs @@ -0,0 +1,3 @@ +//! Truth scoring engine — PLN deduction, ECAN attention, and memory scoring. + +pub mod pln; diff --git a/src/truth/pln.rs b/src/truth/pln.rs new file mode 100644 index 0000000..dd0812c --- /dev/null +++ b/src/truth/pln.rs @@ -0,0 +1,319 @@ +//! Probabilistic Logic Networks (PLN) deduction engine. +//! +//! Implements core PLN inference rules for truth-value propagation: +//! deduction, revision, negation, conjunction, and evidence-based scoring. + +/// A PLN truth value combining strength (probability estimate) and confidence +/// (weight of evidence behind that estimate). Both fields are clamped to \[0.0, 1.0\]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TruthValue { + /// Probability estimate, 0.0–1.0. + pub strength: f32, + /// Weight of evidence, 0.0–1.0. + pub confidence: f32, +} + +impl TruthValue { + /// Create a new `TruthValue`, clamping both fields to \[0.0, 1.0\]. + pub fn new(strength: f32, confidence: f32) -> Self { + Self { + strength: strength.clamp(0.0, 1.0), + confidence: confidence.clamp(0.0, 1.0), + } + } +} + +/// Clamp a `TruthValue` so both fields lie within \[0.0, 1.0\]. +#[inline] +fn clamped(tv: TruthValue) -> TruthValue { + TruthValue::new(tv.strength, tv.confidence) +} + +// --------------------------------------------------------------------------- +// PLN Rules +// --------------------------------------------------------------------------- + +/// **Deduction rule** — chain two implications A→B and B→C. +/// +/// ```text +/// strength = s1 * s2 +/// confidence = c1 * c2 * (s2 + (1 - s2) * (1 - c1)) +/// ``` +pub fn deduction(a_to_b: TruthValue, b_to_c: TruthValue) -> TruthValue { + let s1 = a_to_b.strength; + let c1 = a_to_b.confidence; + let s2 = b_to_c.strength; + let c2 = b_to_c.confidence; + + let strength = s1 * s2; + let confidence = c1 * c2 * (s2 + (1.0 - s2) * (1.0 - c1)); + + clamped(TruthValue { strength, confidence }) +} + +/// **Revision rule** — merge two independent estimates into one. +/// +/// ```text +/// strength = (s1*c1 + s2*c2) / (c1 + c2) +/// confidence = (c1 + c2) / (c1 + c2 + 1) +/// ``` +/// +/// If both confidences are zero the input `tv1` is returned unchanged +/// (no evidence to merge). +pub fn revision(tv1: TruthValue, tv2: TruthValue) -> TruthValue { + let s1 = tv1.strength; + let c1 = tv1.confidence; + let s2 = tv2.strength; + let c2 = tv2.confidence; + + let denom = c1 + c2; + if denom == 0.0 { + return tv1; + } + + let strength = (s1 * c1 + s2 * c2) / denom; + let confidence = denom / (denom + 1.0); + + clamped(TruthValue { strength, confidence }) +} + +/// **Negation rule** — logical NOT. +/// +/// ```text +/// strength = 1.0 - s +/// confidence = c (unchanged) +/// ``` +pub fn negation(tv: TruthValue) -> TruthValue { + clamped(TruthValue { + strength: 1.0 - tv.strength, + confidence: tv.confidence, + }) +} + +/// **Conjunction rule** — logical AND (independent assumptions). +/// +/// ```text +/// strength = s1 * s2 +/// confidence = min(c1, c2) +/// ``` +pub fn conjunction(tv1: TruthValue, tv2: TruthValue) -> TruthValue { + clamped(TruthValue { + strength: tv1.strength * tv2.strength, + confidence: tv1.confidence.min(tv2.confidence), + }) +} + +/// **Evidence-based scoring** — refine a base truth value with confirming and +/// contradicting evidence. +/// +/// 1. Iteratively apply [`revision`] across all `confirmations` to boost the +/// base estimate. +/// 2. For each contradiction, reduce confidence proportionally to the +/// contradiction's strength. +/// 3. Clamp the final result to \[0.0, 1.0\]. +pub fn score_with_evidence( + base: TruthValue, + confirmations: &[TruthValue], + contradictions: &[TruthValue], +) -> TruthValue { + // Phase 1: merge confirmations via revision + let mut current = base; + for &conf in confirmations { + current = revision(current, conf); + } + + // Phase 2: reduce confidence proportional to contradiction strength + for &contra in contradictions { + // Each contradiction chips away at confidence in proportion to its + // own strength (strong contradictions hurt more). + current.confidence *= 1.0 - contra.strength * contra.confidence; + } + + clamped(current) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: approximate f32 equality. + fn approx_eq(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-5 + } + + // -- deduction --------------------------------------------------------- + + #[test] + fn pln_deduction_basic() { + let ab = TruthValue::new(0.8, 0.9); + let bc = TruthValue::new(0.7, 0.8); + let result = deduction(ab, bc); + + // strength = 0.8 * 0.7 = 0.56 + assert!(approx_eq(result.strength, 0.56)); + + // confidence = 0.9 * 0.8 * (0.7 + 0.3 * 0.1) = 0.72 * 0.73 = 0.5256 + let expected_c = 0.9 * 0.8 * (0.7 + (1.0 - 0.7) * (1.0 - 0.9)); + assert!(approx_eq(result.confidence, expected_c)); + } + + #[test] + fn pln_deduction_boundary() { + // (0, 0) -> zero everything + let r1 = deduction(TruthValue::new(0.0, 0.0), TruthValue::new(0.0, 0.0)); + assert!(approx_eq(r1.strength, 0.0)); + assert!(approx_eq(r1.confidence, 0.0)); + + // (1, 1) -> perfect chain + let r2 = deduction(TruthValue::new(1.0, 1.0), TruthValue::new(1.0, 1.0)); + assert!(approx_eq(r2.strength, 1.0)); + // confidence = 1*1*(1 + 0*0) = 1.0 + assert!(approx_eq(r2.confidence, 1.0)); + + // (0.5, 0.5) + let half = TruthValue::new(0.5, 0.5); + let r3 = deduction(half, half); + assert!(approx_eq(r3.strength, 0.25)); + let expected_c = 0.5 * 0.5 * (0.5 + 0.5 * 0.5); + assert!(approx_eq(r3.confidence, expected_c)); + } + + // -- revision ---------------------------------------------------------- + + #[test] + fn pln_revision_merge() { + let tv1 = TruthValue::new(0.6, 0.5); + let tv2 = TruthValue::new(0.8, 0.7); + let result = revision(tv1, tv2); + + let expected_s = (0.6 * 0.5 + 0.8 * 0.7) / (0.5 + 0.7); + let expected_c = (0.5 + 0.7) / (0.5 + 0.7 + 1.0); + assert!(approx_eq(result.strength, expected_s)); + assert!(approx_eq(result.confidence, expected_c)); + } + + #[test] + fn pln_revision_symmetry() { + let a = TruthValue::new(0.3, 0.4); + let b = TruthValue::new(0.9, 0.6); + + let ab = revision(a, b); + let ba = revision(b, a); + + assert!(approx_eq(ab.strength, ba.strength)); + assert!(approx_eq(ab.confidence, ba.confidence)); + } + + // -- negation ---------------------------------------------------------- + + #[test] + fn pln_negation() { + let tv = TruthValue::new(0.7, 0.8); + let result = negation(tv); + + assert!(approx_eq(result.strength, 0.3)); + assert!(approx_eq(result.confidence, 0.8)); + } + + // -- conjunction ------------------------------------------------------- + + #[test] + fn pln_conjunction() { + let tv1 = TruthValue::new(0.8, 0.9); + let tv2 = TruthValue::new(0.6, 0.7); + let result = conjunction(tv1, tv2); + + assert!(approx_eq(result.strength, 0.48)); + assert!(approx_eq(result.confidence, 0.7)); + } + + // -- score_with_evidence ----------------------------------------------- + + #[test] + fn pln_score_with_confirmations() { + let base = TruthValue::new(0.5, 0.3); + let confirmations = [ + TruthValue::new(0.7, 0.5), + TruthValue::new(0.8, 0.6), + ]; + + let result = score_with_evidence(base, &confirmations, &[]); + + // Confirmations should boost both strength and confidence above base + assert!(result.strength > base.strength); + assert!(result.confidence > base.confidence); + assert!(result.strength <= 1.0); + assert!(result.confidence <= 1.0); + } + + #[test] + fn pln_score_with_contradictions() { + let base = TruthValue::new(0.8, 0.9); + let contradictions = [ + TruthValue::new(0.7, 0.8), + ]; + + let result = score_with_evidence(base, &[], &contradictions); + + // Contradictions should reduce confidence + assert!(result.confidence < base.confidence); + // Strength unchanged when no confirmations + assert!(approx_eq(result.strength, base.strength)); + } + + // -- bounds ------------------------------------------------------------ + + #[test] + fn pln_values_bounded() { + // Extreme inputs that might push results out of range + let big = TruthValue::new(2.0, 5.0); + assert!(big.strength <= 1.0 && big.strength >= 0.0); + assert!(big.confidence <= 1.0 && big.confidence >= 0.0); + + let neg = TruthValue::new(-1.0, -3.0); + assert!(neg.strength >= 0.0); + assert!(neg.confidence >= 0.0); + + // Deduction of clamped extremes stays in bounds + let d = deduction(big, big); + assert!(d.strength >= 0.0 && d.strength <= 1.0); + assert!(d.confidence >= 0.0 && d.confidence <= 1.0); + + // Negation of boundary stays in bounds + let n = negation(TruthValue::new(0.0, 1.0)); + assert!(n.strength >= 0.0 && n.strength <= 1.0); + assert!(n.confidence >= 0.0 && n.confidence <= 1.0); + + // score_with_evidence stays bounded + let scored = score_with_evidence( + TruthValue::new(1.0, 1.0), + &[TruthValue::new(1.0, 1.0)], + &[TruthValue::new(1.0, 1.0)], + ); + assert!(scored.strength >= 0.0 && scored.strength <= 1.0); + assert!(scored.confidence >= 0.0 && scored.confidence <= 1.0); + } + + // -- zero-confidence edge case ----------------------------------------- + + #[test] + fn pln_revision_zero_confidence() { + let tv1 = TruthValue::new(0.5, 0.0); + let tv2 = TruthValue::new(0.8, 0.0); + + // Should not panic; returns tv1 when both confidences are zero + let result = revision(tv1, tv2); + assert!(approx_eq(result.strength, tv1.strength)); + assert!(approx_eq(result.confidence, tv1.confidence)); + + // One zero, one nonzero — should still work + let tv3 = TruthValue::new(0.9, 0.6); + let result2 = revision(tv1, tv3); + assert!(result2.strength >= 0.0 && result2.strength <= 1.0); + assert!(result2.confidence >= 0.0 && result2.confidence <= 1.0); + } +} From 3f11f8f531c64258dad941939e6c396cdddeff87 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 4 Apr 2026 02:29:51 +0000 Subject: [PATCH 4/4] fix(config): close missing brace in Default impl for Config --- src/config.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config.rs b/src/config.rs index c43c351..e0e6c87 100644 --- a/src/config.rs +++ b/src/config.rs @@ -346,5 +346,6 @@ impl Default for Config { verification_threshold: default_verification_threshold(), cross_ref_limit: default_cross_ref_limit(), }, + } } }