1use serde::{Deserialize, Serialize};
17use serde_json::{Value, json};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct CalibrationPrediction {
22 pub id: String,
24 pub statement: String,
26 pub probability: f64,
28 pub confidence: f64,
30 pub scenario: String,
32 pub outcome: Option<bool>,
34 pub brier_score: Option<f64>,
36 pub adjusted_probability: Option<f64>,
38}
39
40impl CalibrationPrediction {
41 pub fn resolve(&mut self, outcome: bool) -> f64 {
43 self.outcome = Some(outcome);
44 let brier = (self.probability - f64::from(outcome)).powi(2);
45 self.brier_score = Some(brier);
46 brier
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct CalibrationBin {
53 pub label: String,
55 pub count: usize,
57 pub actual_rate: f64,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct BrierScorecard {
64 pub total_predictions: usize,
66 pub resolved: usize,
68 pub unresolved: usize,
70 pub avg_brier_score: f64,
72 pub reliability: f64,
75 pub resolution: f64,
78 pub uncertainty: f64,
80 pub skill_score: f64,
83 pub calibration_bins: Vec<CalibrationBin>,
85 pub calibration_gap: f64,
87 pub perfect_calibration: bool,
89 pub good_calibration: bool,
91}
92
93impl BrierScorecard {
94 #[must_use]
96 pub fn compute(resolved: &[CalibrationPrediction], gap: f64) -> Self {
97 let n = resolved.len();
98 let brier_scores = resolved
99 .iter()
100 .filter_map(|p| p.brier_score)
101 .collect::<Vec<_>>();
102 let avg_brier = if brier_scores.is_empty() {
103 0.0
104 } else {
105 brier_scores.iter().sum::<f64>() / brier_scores.len() as f64
106 };
107
108 let mut bins = Vec::with_capacity(10);
110 let mut total_n = 0usize;
111 let mut sum_outcome = 0.0_f64;
112 for i in 0..10 {
113 let lo = f64::from(i) / 10.0;
114 let hi = f64::from(i + 1) / 10.0;
115 let in_bin = resolved
116 .iter()
117 .filter(|p| {
118 let prob = p.probability.clamp(0.0, 0.999_999_9);
119 prob >= lo && prob < hi
120 })
121 .collect::<Vec<_>>();
122 let count = in_bin.len();
123 let actual_rate = if count > 0 {
124 in_bin.iter().filter(|p| p.outcome == Some(true)).count() as f64 / count as f64
125 } else {
126 0.0
127 };
128 total_n += count;
129 sum_outcome += actual_rate * count as f64;
130 bins.push(CalibrationBin {
131 label: format!("{lo:.1}-{hi:.1}"),
132 count,
133 actual_rate,
134 });
135 }
136
137 let base_rate = if total_n > 0 {
138 sum_outcome / total_n as f64
139 } else {
140 0.0
141 };
142
143 let mut reliability = 0.0;
144 let mut resolution = 0.0;
145 for bin in &bins {
146 if bin.count == 0 {
147 continue;
148 }
149 let weight = bin.count as f64 / total_n as f64;
150 let lo = bin
152 .label
153 .split('-')
154 .next()
155 .and_then(|s| s.parse::<f64>().ok())
156 .unwrap_or(0.0);
157 let predicted = lo + 0.05;
158 reliability = weight.mul_add((predicted - bin.actual_rate).powi(2), reliability);
159 resolution = weight.mul_add((bin.actual_rate - base_rate).powi(2), resolution);
160 }
161 let uncertainty = base_rate * (1.0 - base_rate);
162
163 let skill_score = if uncertainty > 1e-12 {
164 1.0 - avg_brier / uncertainty
165 } else {
166 0.0
167 };
168
169 Self {
170 total_predictions: 0,
171 resolved: n,
172 unresolved: 0,
173 avg_brier_score: avg_brier,
174 reliability,
175 resolution,
176 uncertainty,
177 skill_score,
178 calibration_bins: bins,
179 calibration_gap: gap,
180 perfect_calibration: avg_brier < 0.05,
181 good_calibration: avg_brier < 0.15,
182 }
183 }
184}
185
186#[derive(Debug, Default, Clone, Serialize, Deserialize)]
191pub struct CalibrationStore {
192 pub predictions: Vec<CalibrationPrediction>,
194 calibration_history: Vec<f64>,
196 next_id: u64,
198}
199
200impl CalibrationStore {
201 #[must_use]
203 pub fn new() -> Self {
204 Self::default()
205 }
206
207 pub fn record(
210 &mut self,
211 statement: &str,
212 probability: f64,
213 confidence: f64,
214 scenario: &str,
215 ) -> CalibrationPrediction {
216 let prob = probability.clamp(0.0, 1.0);
217 let gap = self.calibration_gap();
218 let adjusted = (prob - gap).clamp(0.0, 1.0);
219 self.next_id += 1;
220 let pred = CalibrationPrediction {
221 id: format!("pred-{:06}", self.next_id),
222 statement: statement.to_string(),
223 probability: prob,
224 confidence,
225 scenario: scenario.to_string(),
226 outcome: None,
227 brier_score: None,
228 adjusted_probability: Some(adjusted),
229 };
230 self.predictions.push(pred.clone());
231 pred
232 }
233
234 pub fn resolve(&mut self, id: &str, outcome: bool) -> Result<(f64, f64), String> {
237 let pred = self
238 .predictions
239 .iter_mut()
240 .find(|p| p.id == id)
241 .ok_or_else(|| format!("prediction '{id}' not found"))?;
242 if pred.outcome.is_some() {
243 return Err(format!("prediction '{id}' already resolved"));
244 }
245 let brier = pred.resolve(outcome);
246 self.calibration_history.push(brier);
247 if self.calibration_history.len() > 200 {
248 self.calibration_history.remove(0);
249 }
250 let gap = self.calibration_gap();
251 Ok((brier, gap))
252 }
253
254 #[must_use]
257 pub fn calibration_gap(&self) -> f64 {
258 if self.calibration_history.is_empty() {
259 0.0
260 } else {
261 let avg = self.calibration_history.iter().sum::<f64>()
262 / self.calibration_history.len() as f64;
263 avg * 0.1
264 }
265 }
266
267 #[must_use]
269 pub fn resolved(&self) -> Vec<&CalibrationPrediction> {
270 self.predictions
271 .iter()
272 .filter(|p| p.outcome.is_some())
273 .collect()
274 }
275
276 #[must_use]
278 pub fn scorecard(&self) -> BrierScorecard {
279 let resolved = self.resolved();
280 let unresolved = self.predictions.len() - resolved.len();
281 let mut card = BrierScorecard::compute(
282 &resolved.iter().map(|p| (*p).clone()).collect::<Vec<_>>(),
283 self.calibration_gap(),
284 );
285 card.total_predictions = self.predictions.len();
286 card.unresolved = unresolved;
287 card
288 }
289
290 #[must_use]
292 pub fn to_json(&self) -> Value {
293 serde_json::to_value(self).unwrap_or_else(|_| json!({}))
294 }
295
296 pub fn from_json(&mut self, value: &Value) -> Result<(), String> {
298 let restored: Self = serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
299 *self = restored;
300 Ok(())
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn record_assigns_ids_and_adjusts() {
310 let mut store = CalibrationStore::new();
311 let p = store.record("It will rain", 0.8, 0.6, "weather");
312 assert_eq!(p.id, "pred-000001");
313 assert!((p.probability - 0.8).abs() < 1e-9);
314 assert_eq!(store.predictions.len(), 1);
315 }
316
317 #[test]
318 fn resolve_computes_brier() {
319 let mut store = CalibrationStore::new();
320 let p = store.record("It will rain", 0.8, 0.6, "weather");
321 let (brier, _) = store.resolve(&p.id, true).unwrap();
322 assert!((brier - 0.04).abs() < 1e-9);
323 assert!(store.resolve(&p.id, false).is_err());
325 }
326
327 #[test]
328 fn resolve_missing_errors() {
329 let mut store = CalibrationStore::new();
330 assert!(store.resolve("nope", true).is_err());
331 }
332
333 #[test]
334 fn scorecard_perfect_calibration() {
335 let mut store = CalibrationStore::new();
336 for (p, o) in [
338 (0.9, true),
339 (0.9, true),
340 (0.9, true),
341 (0.1, false),
342 (0.1, false),
343 (0.1, false),
344 ] {
345 let pred = store.record("s", p, 0.5, "sc");
346 store.resolve(&pred.id, o).unwrap();
347 }
348 let card = store.scorecard();
349 assert_eq!(card.resolved, 6);
350 assert!(
351 card.avg_brier_score < 0.02,
352 "perfect calibration: {}",
353 card.avg_brier_score
354 );
355 assert!(card.perfect_calibration);
356 assert!(card.skill_score > 0.9, "skill: {}", card.skill_score);
357 assert!(card.reliability < 0.02, "reliability: {}", card.reliability);
358 }
359
360 #[test]
361 fn scorecard_inverted_predictions_are_bad() {
362 let mut store = CalibrationStore::new();
363 for o in [false, false, false, false, false, true] {
365 let pred = store.record("s", 0.9, 0.5, "sc");
366 store.resolve(&pred.id, o).unwrap();
367 }
368 let card = store.scorecard();
369 assert!(
370 card.avg_brier_score > 0.6,
371 "anti-calibrated Brier: {}",
372 card.avg_brier_score
373 );
374 assert!(!card.perfect_calibration);
375 assert!(
376 card.skill_score < 0.0,
377 "negative skill (vs climatology): {}",
378 card.skill_score
379 );
380 assert!(
381 card.reliability > 0.3,
382 "high reliability term: {}",
383 card.reliability
384 );
385 }
386
387 #[test]
388 fn empty_scorecard_does_not_panic() {
389 let store = CalibrationStore::new();
390 let card = store.scorecard();
391 assert_eq!(card.resolved, 0);
392 assert_eq!(card.total_predictions, 0);
393 assert_eq!(card.calibration_bins.len(), 10);
394 }
395
396 #[test]
397 fn json_roundtrip() {
398 let mut store = CalibrationStore::new();
399 let p = store.record("s", 0.7, 0.5, "sc");
400 store.resolve(&p.id, true).unwrap();
401 let json = store.to_json();
402 let mut restored = CalibrationStore::new();
403 restored.from_json(&json).unwrap();
404 assert_eq!(restored.predictions.len(), 1);
405 assert_eq!(restored.predictions[0].id, "pred-000001");
406 let card = restored.scorecard();
407 assert_eq!(card.resolved, 1);
408 }
409}