Skip to main content

oxibrain_core/
uncertainty.rs

1//! Uncertainty quantification for derived artifacts (DESIGN §13.1, P10, D23).
2//!
3//! P10: "Compression may lose detail, never doubt." Every derived episode
4//! (consolidation, community summary) carries an `Uncertainty` computed from
5//! its support — contradictions, single-source claims, staleness, and trust
6//! exclusions. A summary is never returned without its sources.
7//!
8//! The `compute` function is pure: same inputs → same `Uncertainty`. It takes
9//! belief statistics (counts) as input, not raw database rows, so it can be
10//! property-tested without a database.
11
12use serde::{Deserialize, Serialize};
13
14/// The four uncertainty factors computed from a group's support (§13.1).
15/// Each is in [0.0, 1.0]; 0.0 means "no uncertainty from this factor."
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct Uncertainty {
18    /// Fraction of beliefs in the group that are contradicted.
19    pub contradiction_rate: f32,
20    /// Fraction of beliefs backed by only one episode.
21    pub single_source_fraction: f32,
22    /// Normalised age of the oldest supporting episode (0 = fresh, 1 = ≥ 1 year).
23    pub staleness: f32,
24    /// Fraction of beliefs with untrusted support.
25    pub trust_exclusion_fraction: f32,
26}
27
28impl Uncertainty {
29    /// Aggregate uncertainty score — a single number in [0, 1] representing
30    /// overall doubt. Weighted sum: contradictions dominate, then single
31    /// source, then staleness, then trust exclusions.
32    pub fn score(&self) -> f32 {
33        (self.contradiction_rate * 0.4
34            + self.single_source_fraction * 0.25
35            + self.staleness * 0.15
36            + self.trust_exclusion_fraction * 0.2)
37            .clamp(0.0, 1.0)
38    }
39}
40
41impl Default for Uncertainty {
42    fn default() -> Self {
43        Self {
44            contradiction_rate: 0.0,
45            single_source_fraction: 0.0,
46            staleness: 0.0,
47            trust_exclusion_fraction: 0.0,
48        }
49    }
50}
51
52/// Raw belief statistics from which `Uncertainty` is computed. The store
53/// gathers these from a single GROUP BY query over the group's beliefs.
54#[derive(Debug, Clone, Default)]
55pub struct UncertaintyInput {
56    /// Total beliefs in the group.
57    pub total_beliefs: usize,
58    /// Beliefs with status = contradicted.
59    pub contradicted_beliefs: usize,
60    /// Beliefs backed by only 1 distinct episode.
61    pub single_source_beliefs: usize,
62    /// Beliefs whose support includes an untrusted episode.
63    pub untrusted_beliefs: usize,
64    /// Age of the oldest supporting episode in days.
65    pub max_episode_age_days: f64,
66}
67
68/// Compute uncertainty from belief statistics (§13.1, P10). Pure function.
69///
70/// `max_episode_age_days` is normalised to staleness: 0 days → 0.0,
71/// 365 days → 1.0, clamped.
72pub fn compute(input: &UncertaintyInput) -> Uncertainty {
73    let total = input.total_beliefs.max(1) as f32;
74    let staleness = (input.max_episode_age_days / 365.0).clamp(0.0, 1.0) as f32;
75    Uncertainty {
76        contradiction_rate: (input.contradicted_beliefs as f32 / total).clamp(0.0, 1.0),
77        single_source_fraction: (input.single_source_beliefs as f32 / total).clamp(0.0, 1.0),
78        staleness,
79        trust_exclusion_fraction: (input.untrusted_beliefs as f32 / total).clamp(0.0, 1.0),
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn empty_group_is_zero_uncertainty() {
89        let u = compute(&UncertaintyInput::default());
90        // total_beliefs = 0 → total = max(0, 1) = 1 → all fractions are 0.
91        assert_eq!(u, Uncertainty::default());
92        assert_eq!(u.score(), 0.0);
93    }
94
95    #[test]
96    fn all_contradicted_gives_max_contradiction_rate() {
97        let input = UncertaintyInput {
98            total_beliefs: 10,
99            contradicted_beliefs: 10,
100            ..Default::default()
101        };
102        let u = compute(&input);
103        assert!((u.contradiction_rate - 1.0).abs() < f32::EPSILON);
104        assert!(u.score() > 0.35); // 0.4 * 1.0 dominates
105    }
106
107    #[test]
108    fn score_weights_contradictions_above_single_source() {
109        let contradictions_only = compute(&UncertaintyInput {
110            total_beliefs: 10,
111            contradicted_beliefs: 5,
112            ..Default::default()
113        });
114        let single_source_only = compute(&UncertaintyInput {
115            total_beliefs: 10,
116            single_source_beliefs: 5,
117            ..Default::default()
118        });
119        assert!(contradictions_only.score() > single_source_only.score());
120    }
121
122    #[test]
123    fn staleness_normalises_to_year() {
124        let fresh = compute(&UncertaintyInput {
125            max_episode_age_days: 0.0,
126            ..Default::default()
127        });
128        let half_year = compute(&UncertaintyInput {
129            max_episode_age_days: 182.0,
130            ..Default::default()
131        });
132        let one_year = compute(&UncertaintyInput {
133            max_episode_age_days: 365.0,
134            ..Default::default()
135        });
136        let two_years = compute(&UncertaintyInput {
137            max_episode_age_days: 730.0,
138            ..Default::default()
139        });
140        assert!(fresh.staleness < half_year.staleness);
141        assert!((one_year.staleness - 1.0).abs() < 0.01);
142        assert!((two_years.staleness - 1.0).abs() < f32::EPSILON); // clamped
143    }
144
145    #[test]
146    fn compute_is_pure() {
147        let input = UncertaintyInput {
148            total_beliefs: 8,
149            contradicted_beliefs: 2,
150            single_source_beliefs: 3,
151            untrusted_beliefs: 1,
152            max_episode_age_days: 100.0,
153        };
154        let u1 = compute(&input);
155        let u2 = compute(&input);
156        assert_eq!(u1, u2);
157    }
158
159    #[test]
160    fn score_is_in_unit_interval() {
161        for cr in [0.0f32, 0.25, 0.5, 0.75, 1.0] {
162            for ss in [0.0f32, 0.5, 1.0] {
163                for st in [0.0f32, 0.5, 1.0] {
164                    for te in [0.0f32, 0.5, 1.0] {
165                        let u = Uncertainty {
166                            contradiction_rate: cr,
167                            single_source_fraction: ss,
168                            staleness: st,
169                            trust_exclusion_fraction: te,
170                        };
171                        let s = u.score();
172                        assert!(
173                            (0.0..=1.0).contains(&s),
174                            "score {s} out of range for cr={cr} ss={ss} st={st} te={te}"
175                        );
176                    }
177                }
178            }
179        }
180    }
181}