Skip to main content

qualia_core_db/modalities/abductive/
probabilistic.rs

1//! Probabilistic abduction — Bayesian scoring and ranking of competing hypotheses.
2//!
3//! Among the hypotheses that *could* explain an observation, the best is the most probable a
4//! posteriori: `P(h | obs) ∝ P(h) · P(obs | h)` (prior × likelihood), normalised over the
5//! candidates. Zero-heap (caller-supplied `out`).
6
7/// A candidate abductive hypothesis: its id, Bayesian `prior` `P(h)`, and the `likelihood` it
8/// assigns to the observation `P(obs | h)`. Both in `[0, ∞)` (typically `[0,1]`).
9#[derive(Debug, Clone, Copy)]
10pub struct Hypothesis {
11    pub id: u64,
12    pub prior: f32,
13    pub likelihood: f32,
14}
15
16/// Posteriors `P(h | obs) ∝ prior·likelihood`, normalised over `hyps`, written into `out`
17/// (parallel to `hyps`). Returns the evidence `P(obs) = Σ prior·likelihood` (the normaliser);
18/// if it is ~0 (no hypothesis explains the observation) `out` is filled with zeros and `0.0` is
19/// returned. Refuses on a length mismatch by returning `0.0` without writing.
20pub fn bayesian_posteriors(hyps: &[Hypothesis], out: &mut [f32]) -> f32 {
21    if out.len() < hyps.len() {
22        return 0.0;
23    }
24    let mut evidence = 0.0f32;
25    for h in hyps {
26        evidence += h.prior * h.likelihood;
27    }
28    if evidence.abs() < 1e-12 {
29        for o in out.iter_mut().take(hyps.len()) {
30            *o = 0.0;
31        }
32        return 0.0;
33    }
34    for (i, h) in hyps.iter().enumerate() {
35        out[i] = (h.prior * h.likelihood) / evidence;
36    }
37    evidence
38}
39
40/// The **maximum-a-posteriori** hypothesis id — the one with the greatest `prior·likelihood`
41/// (argmax of the posterior; normalisation is monotone so it needs no division). `None` if `hyps`
42/// is empty or carries no probability mass.
43pub fn best_hypothesis(hyps: &[Hypothesis]) -> Option<u64> {
44    let mut best: Option<(u64, f32)> = None;
45    for h in hyps {
46        let score = h.prior * h.likelihood;
47        if score > 0.0 && best.map(|(_, s)| score > s).unwrap_or(true) {
48            best = Some((h.id, score));
49        }
50    }
51    best.map(|(id, _)| id)
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    fn close(a: f32, b: f32) -> bool {
59        (a - b).abs() < 1e-5
60    }
61
62    #[test]
63    fn posteriors_normalise_and_rank() {
64        // Two hypotheses: h1 prior .2 likelihood .9; h2 prior .8 likelihood .1.
65        let hyps = [
66            Hypothesis {
67                id: 1,
68                prior: 0.2,
69                likelihood: 0.9,
70            }, // joint .18
71            Hypothesis {
72                id: 2,
73                prior: 0.8,
74                likelihood: 0.1,
75            }, // joint .08
76        ];
77        let mut out = [0.0f32; 2];
78        let evidence = bayesian_posteriors(&hyps, &mut out);
79        assert!(close(evidence, 0.26));
80        assert!(close(out[0], 0.18 / 0.26));
81        assert!(close(out[1], 0.08 / 0.26));
82        assert!(close(out[0] + out[1], 1.0), "posteriors sum to 1");
83        // The high-likelihood hypothesis wins despite a lower prior (explaining-away).
84        assert_eq!(best_hypothesis(&hyps), Some(1));
85    }
86
87    #[test]
88    fn no_mass_yields_none_and_zero_evidence() {
89        let hyps = [Hypothesis {
90            id: 1,
91            prior: 0.0,
92            likelihood: 0.9,
93        }];
94        let mut out = [9.0f32; 1];
95        assert_eq!(bayesian_posteriors(&hyps, &mut out), 0.0);
96        assert_eq!(out[0], 0.0, "zeroed when no mass");
97        assert_eq!(best_hypothesis(&hyps), None);
98        assert_eq!(best_hypothesis(&[]), None);
99    }
100}