oxibrain_core/
uncertainty.rs1use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct Uncertainty {
18 pub contradiction_rate: f32,
20 pub single_source_fraction: f32,
22 pub staleness: f32,
24 pub trust_exclusion_fraction: f32,
26}
27
28impl Uncertainty {
29 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#[derive(Debug, Clone, Default)]
55pub struct UncertaintyInput {
56 pub total_beliefs: usize,
58 pub contradicted_beliefs: usize,
60 pub single_source_beliefs: usize,
62 pub untrusted_beliefs: usize,
64 pub max_episode_age_days: f64,
66}
67
68pub 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 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); }
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); }
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}