Files
openbrain-mcp/src/truth/pln.rs
Agent Zero 1672d5a145 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
2026-04-04 02:14:01 +00:00

320 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.01.0.
pub strength: f32,
/// Weight of evidence, 0.01.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);
}
}