mirror of
https://gitea.ingwaz.work/Ingwaz/openbrain-mcp.git
synced 2026-06-16 06:17:08 +00:00
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
This commit is contained in:
319
src/truth/pln.rs
Normal file
319
src/truth/pln.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user