Skip to main content

wm_simulation/
calibration.rs

1//! Prediction calibration — Brier scorecard with the Murphy decomposition.
2//!
3//! Tracks recorded predictions, resolves them against reality, and produces
4//! an honest calibration scorecard:
5//!
6//! - **Brier score**: mean squared error `(p − o)²` over resolved forecasts
7//! - **Reliability**: how well predicted probabilities match observed rates
8//! - **Resolution**: how much predictions separate positive from negative outcomes
9//! - **Uncertainty**: base rate `ō(1 − ō)` — the difficulty of the problem
10//! - **Brier skill score (BSS)**: `1 − Brier/Uncertainty` vs. climatology
11//!
12//! The calibration gap feeds a small adjustment back into future predictions,
13//! matching the v26 `simulation.calibrate` bridge but with the full
14//! decomposition the v26 version lacked.
15
16use serde::{Deserialize, Serialize};
17use serde_json::{Value, json};
18
19/// A single recorded prediction, resolved or pending.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct CalibrationPrediction {
22    /// Stable identifier for the prediction.
23    pub id: String,
24    /// The prediction statement.
25    pub statement: String,
26    /// Predicted probability in [0, 1].
27    pub probability: f64,
28    /// Self-reported confidence in [0, 1] (informational).
29    pub confidence: f64,
30    /// Scenario / context label.
31    pub scenario: String,
32    /// Observed outcome (None until resolved).
33    pub outcome: Option<bool>,
34    /// Brier score once resolved: (p − o)².
35    pub brier_score: Option<f64>,
36    /// Probability after the historical calibration adjustment.
37    pub adjusted_probability: Option<f64>,
38}
39
40impl CalibrationPrediction {
41    /// Resolve against reality and compute the Brier score.
42    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/// A single calibration bin: predicted-probability range vs. observed rate.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct CalibrationBin {
53    /// Bin label, e.g. "0.3-0.4".
54    pub label: String,
55    /// Number of resolved predictions in this bin.
56    pub count: usize,
57    /// Observed positive rate within the bin.
58    pub actual_rate: f64,
59}
60
61/// Full Brier scorecard with the Murphy decomposition.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct BrierScorecard {
64    /// Total predictions ever recorded.
65    pub total_predictions: usize,
66    /// Predictions resolved against reality.
67    pub resolved: usize,
68    /// Predictions still awaiting resolution.
69    pub unresolved: usize,
70    /// Average Brier score over resolved predictions (lower is better).
71    pub avg_brier_score: f64,
72    /// Reliability term — mean squared gap between predicted probability
73    /// and observed rate within bins. 0 = perfectly calibrated.
74    pub reliability: f64,
75    /// Resolution term — how well predictions separate outcomes. Higher
76    /// is better (upper bounded by uncertainty).
77    pub resolution: f64,
78    /// Uncertainty — base-rate variance ō(1 − ō). The difficulty ceiling.
79    pub uncertainty: f64,
80    /// Brier skill score vs. climatology (1 = perfect, 0 = no better than
81    /// always predicting the base rate, negative = worse).
82    pub skill_score: f64,
83    /// Decile calibration bins (predicted probability → observed rate).
84    pub calibration_bins: Vec<CalibrationBin>,
85    /// Historical rolling calibration gap used for adjustments.
86    pub calibration_gap: f64,
87    /// Whether the model is essentially perfectly calibrated.
88    pub perfect_calibration: bool,
89    /// Whether calibration is good (Brier < 0.15).
90    pub good_calibration: bool,
91}
92
93impl BrierScorecard {
94    /// Compute the scorecard from resolved predictions.
95    #[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        // Murphy decomposition over 10 decile bins
109        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            // Midpoint of the bin as the representative predicted probability
151            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/// In-memory calibration store — shared per MCP server instance.
187///
188/// Persistable via [`to_json`](Self::to_json) / [`from_json`](Self::from_json),
189/// mirroring the conformal store pattern.
190#[derive(Debug, Default, Clone, Serialize, Deserialize)]
191pub struct CalibrationStore {
192    /// All recorded predictions (resolved and pending).
193    pub predictions: Vec<CalibrationPrediction>,
194    /// Historical Brier scores for the rolling calibration gap.
195    calibration_history: Vec<f64>,
196    /// Sequence counter for prediction IDs.
197    next_id: u64,
198}
199
200impl CalibrationStore {
201    /// Create an empty store.
202    #[must_use]
203    pub fn new() -> Self {
204        Self::default()
205    }
206
207    /// Record a new prediction. Returns the stored prediction with its ID
208    /// and the historical calibration adjustment applied.
209    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    /// Resolve a prediction against reality. Returns the Brier score and
235    /// the updated calibration gap.
236    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    /// Rolling average Brier score over the recent history (small
255    /// adjustment term, mirroring v26's `gap = avg_brier * 0.1`).
256    #[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    /// Resolved predictions.
268    #[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    /// The full scorecard.
277    #[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    /// Serialize for persistence.
291    #[must_use]
292    pub fn to_json(&self) -> Value {
293        serde_json::to_value(self).unwrap_or_else(|_| json!({}))
294    }
295
296    /// Restore from JSON.
297    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        // Resolving twice is an error
324        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        // Perfect predictions: p = o for every resolved forecast
337        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        // Anti-calibrated: predict 0.9 when outcomes are mostly false
364        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}