Skip to main content

hikmah_kernel/
calibration.rs

1//! Calibration earned from outcomes.
2//!
3//! Pairs every *active* `Prediction` trace (a purged or superseded prediction was retracted and
4//! does not count) with the latest *active* `Outcome` trace that resolves it (written by a
5//! non-model principal; purged or superseded outcomes do not count) and reports, per forecaster
6//! (an engine such as `jev@jev-1.13.0`, or a person or agent such as `human:alex`) and question
7//! family: Brier score, expected calibration error over 5 equal-width bins, accuracy or base
8//! rate, and how many predictions carried no probability at all. Rows are ordered by family,
9//! then answer kind, then forecaster, so every forecaster's row for one family sits next to the
10//! others.
11//!
12//! A forecaster is its class and its name. The class comes from the trace's source, not from
13//! the name it records: `engine` for a `model:` source, `principal` for anyone else
14//! (`forecaster_kind`). A person who records forecasts under an engine's name therefore gets a
15//! row of their own, never a share of the engine's.
16//!
17//! A family is `measurable` once it has at least `calibration_min_outcomes` scored predictions
18//! (a [`crate::policy::KernelPolicy`] field, default [`MIN_OUTCOMES`]). Below that its scores are
19//! still reported, labelled `evidence: "anecdotal"` next to their `n`: they describe the
20//! outcomes so far but support no verdict. Sample size is necessary, not sufficient: a family is
21//! reported `calibrated` only when it has at least [`MIN_OUTCOMES`] scored predictions whatever
22//! the policy says (a policy can raise that floor through `calibration_min_outcomes`, never
23//! lower it), and in addition
24//!
25//! 1. Spiegelhalter's Z test does not reject calibration at alpha = 0.05:
26//!    `Z = Σ (y − p)(1 − 2p) / sqrt(Σ (1 − 2p)² p (1 − p))`, `|Z| < 1.96`
27//!    (two-sided p-value from the normal approximation), and
28//! 2. the Brier score beats always predicting the observed base rate:
29//!    `brier_skill = 1 − Brier / Brier(base rate) > 0`.
30//!
31//! Noul families use `p = P(true)` and `y = [observed is true]`. Choice and score families use the
32//! top-label view for both checks: `p` = probability of the reported answer and `y = [observed ==
33//! reported]`, with the binary Brier of that pair against the top-label accuracy base rate. When
34//! Z or the skill is undefined (zero variance, for example every `p` in {0, 0.5, 1}, or a base rate
35//! of 0 or 1) the family is not reported calibrated.
36//!
37//! With [`FamilyFilter::Prefix`], the report also has one pooled row per forecaster over every
38//! matching family, in the top-label view (for a noul forecast, the probability of the side it
39//! leaned to). Pooling trades family-level detail for sample size.
40use crate::ledger::MemoryStore;
41use crate::trace::{PredictionRecord, TraceKind, TraceStatus};
42use serde::Serialize;
43use std::collections::BTreeMap;
44
45/// Default of `KernelPolicy::calibration_min_outcomes`, and the minimum number of resolved
46/// predictions `hikmah gate-threshold` needs.
47pub const MIN_OUTCOMES: usize = 50;
48const BINS: usize = 5;
49/// Two-sided critical value of the standard normal at alpha = 0.05.
50pub const Z_CRITICAL: f64 = 1.96;
51
52#[derive(Debug, Clone, Serialize)]
53pub struct CalibrationBin {
54    pub range: [f64; 2],
55    pub n: usize,
56    pub mean_p: f64,
57    pub observed_rate: f64,
58}
59
60/// Which families a calibration report covers.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum FamilyFilter<'a> {
63    All,
64    Exact(&'a str),
65    /// Every family that starts with the prefix, plus one pooled row per forecaster.
66    Prefix(&'a str),
67}
68
69impl FamilyFilter<'_> {
70    fn matches(&self, family: &str) -> bool {
71        match self {
72            Self::All => true,
73            Self::Exact(name) => family == *name,
74            Self::Prefix(prefix) => family.starts_with(prefix),
75        }
76    }
77}
78
79/// Answer kind of a pooled row: every prediction is scored on its top label.
80pub const POOLED_KIND: &str = "top_label";
81/// `forecaster_kind` of a row whose predictions have a `model:` source.
82pub const ENGINE_FORECASTER: &str = "engine";
83/// `forecaster_kind` of a row whose predictions come from a person or agent.
84pub const PRINCIPAL_FORECASTER: &str = "principal";
85
86fn forecaster_kind(model_authored: bool) -> &'static str {
87    if model_authored {
88        ENGINE_FORECASTER
89    } else {
90        PRINCIPAL_FORECASTER
91    }
92}
93
94#[derive(Debug, Clone, Serialize)]
95pub struct FamilyCalibration {
96    /// Who forecast: an engine (`jev@jev-1.13.0`) or a person or agent (`human:alex`).
97    pub engine: String,
98    /// [`ENGINE_FORECASTER`] when the predictions have a `model:` source, otherwise
99    /// [`PRINCIPAL_FORECASTER`]. Part of the row's identity, so the same `engine` name from the
100    /// two classes gives two rows.
101    pub forecaster_kind: &'static str,
102    /// The family, or `<prefix>*` for a pooled row.
103    pub family: String,
104    /// `noul`, `choice`, `score`, or [`POOLED_KIND`] for a pooled row.
105    pub answer_kind: String,
106    /// Families in a pooled row (empty otherwise).
107    #[serde(skip_serializing_if = "Vec::is_empty")]
108    pub pooled_families: Vec<String>,
109    /// Resolved predictions that carried a probability.
110    pub n: usize,
111    /// Resolved predictions without any reported probability (excluded from the metrics).
112    pub unscored: usize,
113    /// `anecdotal` while `n` is below `min_outcomes`: the scores are shown, but they support no
114    /// verdict either way. `measurable` from `min_outcomes` on.
115    pub evidence: &'static str,
116    /// Noul and pooled rows: mean (p - y)². Choice/score: multiclass Brier over the reported
117    /// distribution (a forecast with only a top-label probability adds its binary Brier).
118    pub brier: f64,
119    /// Binary Brier of the probability the verdict tests (noul: P(true); others: P(reported
120    /// answer)). The same as `brier` for noul; comparable across forecasters and answer kinds.
121    pub top_label_brier: f64,
122    /// Brier of always predicting the observed base rate (noul and pooled rows; lower is better).
123    pub baseline_brier: Option<f64>,
124    /// Expected calibration error of the reported probability (noul: P(true); others: P(top)).
125    pub ece: f64,
126    /// Noul: share of outcomes that were `true`. Choice/score: accuracy of the top answer.
127    pub rate: f64,
128    /// Spiegelhalter's Z for the reported probability (noul: P(true); others: P(top)).
129    /// `None` when its variance is zero.
130    pub z: Option<f64>,
131    /// Two-sided p-value of `z` (normal approximation). Small values reject calibration.
132    pub p_value: Option<f64>,
133    /// `1 − Brier / Brier(base rate)` (choice/score: top-label binary Brier). Positive means the
134    /// probabilities beat always predicting the base rate. `None` when the base rate is 0 or 1.
135    pub brier_skill: Option<f64>,
136    /// At least `min_outcomes` scored predictions: the scores are evidence, not an anecdote.
137    pub measurable: bool,
138    /// Measurable, at least [`MIN_OUTCOMES`] scored predictions whatever the policy,
139    /// `|z| < 1.96`, and `brier_skill > 0`.
140    pub calibrated: bool,
141    pub bins: Vec<CalibrationBin>,
142}
143
144#[derive(Debug, Clone, Serialize)]
145pub struct CalibrationReport {
146    /// `calibration_min_outcomes` of the policy in effect.
147    pub min_outcomes: usize,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub family_prefix: Option<String>,
150    pub unresolved_predictions: usize,
151    pub families: Vec<FamilyCalibration>,
152    /// One row per forecaster over every family matching `family_prefix`.
153    #[serde(skip_serializing_if = "Vec::is_empty")]
154    pub pooled: Vec<FamilyCalibration>,
155}
156
157struct Pair<'a> {
158    prediction: &'a PredictionRecord,
159    p: f64,
160    observed: String,
161}
162
163impl MemoryStore {
164    /// Latest active outcome per prediction id, written by a non-model principal:
165    /// `prediction id -> (created_at_ms, observed)`.
166    pub(crate) fn latest_outcomes(&self) -> BTreeMap<&str, (u64, &str)> {
167        let mut outcomes: BTreeMap<&str, (u64, &str)> = BTreeMap::new();
168        for entry in self.all() {
169            let trace = &entry.trace;
170            if entry.status != TraceStatus::Active
171                || trace.kind != TraceKind::Outcome
172                || trace.is_model_authored()
173            {
174                continue;
175            }
176            if let Some(outcome) = &trace.outcome {
177                let slot = outcomes
178                    .entry(outcome.prediction_id.as_str())
179                    .or_insert((0, ""));
180                if trace.created_at_ms >= slot.0 {
181                    *slot = (trace.created_at_ms, outcome.observed.as_str());
182                }
183            }
184        }
185        outcomes
186    }
187
188    /// Calibration of every family, or of one family.
189    pub fn calibration(&self, family: Option<&str>) -> CalibrationReport {
190        self.calibration_report(family.map_or(FamilyFilter::All, FamilyFilter::Exact))
191    }
192
193    /// Calibration of the families `filter` selects; a prefix filter adds pooled rows.
194    pub fn calibration_report(&self, filter: FamilyFilter) -> CalibrationReport {
195        let min_outcomes = self.policy().calibration_min_outcomes;
196        let outcomes = self.latest_outcomes();
197
198        // (family, answer kind, forecaster class, forecaster): each forecaster's row for a family
199        // is adjacent, and the class keeps a principal out of an engine's row of the same name.
200        type Key = (String, String, &'static str, String);
201        let mut groups: BTreeMap<Key, (Vec<Pair>, usize)> = BTreeMap::new();
202        let mut unresolved = 0;
203        for entry in self.all() {
204            // A purged or superseded prediction was retracted: it must not count toward the
205            // metrics or the unresolved total (the same rule as `gate_threshold`).
206            if entry.status != TraceStatus::Active {
207                continue;
208            }
209            let trace = &entry.trace;
210            let Some(prediction) = &trace.prediction else {
211                continue;
212            };
213            if !filter.matches(&prediction.family) {
214                continue;
215            }
216            let Some((_, observed)) = outcomes.get(trace.id.as_str()) else {
217                unresolved += 1;
218                continue;
219            };
220            let group = groups
221                .entry((
222                    prediction.family.clone(),
223                    prediction.answer_kind.clone(),
224                    forecaster_kind(trace.is_model_authored()),
225                    prediction.engine.clone(),
226                ))
227                .or_default();
228            match prediction.p {
229                Some(p) => group.0.push(Pair {
230                    prediction,
231                    p,
232                    observed: observed.trim().to_string(),
233                }),
234                None => group.1 += 1,
235            }
236        }
237
238        let pooled = match filter {
239            FamilyFilter::Prefix(prefix) => {
240                type Pool<'p> = (Vec<&'p Pair<'p>>, usize, Vec<String>);
241                let mut by_engine: BTreeMap<(&str, &str), Pool> = BTreeMap::new();
242                for ((family, _, kind, engine), (pairs, unscored)) in &groups {
243                    let slot = by_engine.entry((kind, engine.as_str())).or_default();
244                    slot.0.extend(pairs.iter());
245                    slot.1 += unscored;
246                    if !slot.2.contains(family) {
247                        slot.2.push(family.clone());
248                    }
249                }
250                by_engine
251                    .into_iter()
252                    .map(|((kind, engine), (pairs, unscored, families))| {
253                        let points: Vec<(f64, f64)> = pairs.iter().map(|p| top_label(p)).collect();
254                        let brier = mean_squared_error(&points);
255                        let mut row = finish(
256                            (kind, engine.to_string()),
257                            format!("{prefix}*"),
258                            POOLED_KIND.to_string(),
259                            points,
260                            brier,
261                            true,
262                            unscored,
263                            min_outcomes,
264                        );
265                        row.pooled_families = families;
266                        row
267                    })
268                    .collect()
269            }
270            _ => Vec::new(),
271        };
272        let families = groups
273            .into_iter()
274            .map(|((family, answer_kind, kind, engine), (pairs, unscored))| {
275                summarize(
276                    (kind, engine),
277                    family,
278                    answer_kind,
279                    &pairs,
280                    unscored,
281                    min_outcomes,
282                )
283            })
284            .collect();
285        CalibrationReport {
286            min_outcomes,
287            family_prefix: match filter {
288                FamilyFilter::Prefix(prefix) => Some(prefix.to_string()),
289                _ => None,
290            },
291            unresolved_predictions: unresolved,
292            families,
293            pooled,
294        }
295    }
296}
297
298/// The top-label pair of a prediction: the probability of the answer it leaned to, and whether
299/// that answer happened. For noul, the lean is `true` when `P(true) >= 0.5`.
300fn top_label(pair: &Pair) -> (f64, f64) {
301    let record = pair.prediction;
302    let (p, hit) = if record.answer_kind == "noul" {
303        let says_true = record.value.eq_ignore_ascii_case("true");
304        let happened = pair.observed.eq_ignore_ascii_case("true");
305        (
306            if says_true { pair.p } else { 1.0 - pair.p },
307            says_true == happened,
308        )
309    } else {
310        (pair.p, pair.observed == record.value)
311    };
312    (p, if hit { 1.0 } else { 0.0 })
313}
314
315fn mean_squared_error(points: &[(f64, f64)]) -> f64 {
316    points.iter().map(|(p, y)| (p - y).powi(2)).sum::<f64>() / points.len().max(1) as f64
317}
318
319/// A row's forecaster: its class ([`ENGINE_FORECASTER`] or [`PRINCIPAL_FORECASTER`]) and name.
320type Forecaster = (&'static str, String);
321
322fn summarize(
323    forecaster: Forecaster,
324    family: String,
325    answer_kind: String,
326    pairs: &[Pair],
327    unscored: usize,
328    min_outcomes: usize,
329) -> FamilyCalibration {
330    let is_noul = answer_kind == "noul";
331    // (reported probability, outcome as 0/1) for the verdict and the reliability view.
332    let points: Vec<(f64, f64)> = pairs
333        .iter()
334        .map(|pair| {
335            let y = if is_noul {
336                pair.observed.eq_ignore_ascii_case("true")
337            } else {
338                pair.observed == pair.prediction.value
339            };
340            (pair.p, if y { 1.0 } else { 0.0 })
341        })
342        .collect();
343    let brier = if is_noul {
344        mean_squared_error(&points)
345    } else {
346        pairs
347            .iter()
348            .map(|pair| {
349                let probabilities = &pair.prediction.probabilities;
350                if probabilities.is_empty() {
351                    let hit = pair.observed == pair.prediction.value;
352                    (pair.p - if hit { 1.0 } else { 0.0 }).powi(2)
353                } else {
354                    let mut sum: f64 = probabilities
355                        .iter()
356                        .map(|(k, p)| (p - if *k == pair.observed { 1.0 } else { 0.0 }).powi(2))
357                        .sum();
358                    if !probabilities.contains_key(&pair.observed) {
359                        sum += 1.0;
360                    }
361                    sum
362                }
363            })
364            .sum::<f64>()
365            / pairs.len().max(1) as f64
366    };
367    finish(
368        forecaster,
369        family,
370        answer_kind,
371        points,
372        brier,
373        is_noul,
374        unscored,
375        min_outcomes,
376    )
377}
378
379/// Verdict, bins, and ECE over `(p, y)` points. `brier` is the row's reported Brier;
380/// `report_baseline` shows the base-rate Brier (rows whose `brier` is binary).
381#[allow(clippy::too_many_arguments)]
382fn finish(
383    (forecaster_kind, engine): Forecaster,
384    family: String,
385    answer_kind: String,
386    points: Vec<(f64, f64)>,
387    brier: f64,
388    report_baseline: bool,
389    unscored: usize,
390    min_outcomes: usize,
391) -> FamilyCalibration {
392    let n = points.len();
393    let denominator = n.max(1) as f64;
394    let rate = points.iter().map(|(_, y)| y).sum::<f64>() / denominator;
395    let top_label_brier = mean_squared_error(&points);
396    let base = points.iter().map(|(_, y)| (rate - y).powi(2)).sum::<f64>() / denominator;
397
398    let (z, p_value) = match spiegelhalter_z(&points) {
399        Some(z) => (Some(z), Some(erfc(z.abs() / std::f64::consts::SQRT_2))),
400        None => (None, None),
401    };
402    // Binary Brier of the tested pair against always predicting its base rate.
403    let brier_skill = (n > 0 && base > f64::EPSILON).then(|| 1.0 - top_label_brier / base);
404    let measurable = n >= min_outcomes;
405    // The verdict keeps its own sample floor: a policy may ask for more outcomes before a row is
406    // measurable, but it cannot let the Z test certify fewer than MIN_OUTCOMES.
407    let calibrated = measurable
408        && n >= MIN_OUTCOMES
409        && z.is_some_and(|z| z.abs() < Z_CRITICAL)
410        && brier_skill.is_some_and(|skill| skill > 0.0);
411
412    let mut bins = Vec::new();
413    let mut ece = 0.0;
414    for b in 0..BINS {
415        let lo = b as f64 / BINS as f64;
416        let hi = (b + 1) as f64 / BINS as f64;
417        let members: Vec<&(f64, f64)> = points
418            .iter()
419            .filter(|(p, _)| *p >= lo && (*p < hi || (b == BINS - 1 && *p <= hi)))
420            .collect();
421        if members.is_empty() {
422            continue;
423        }
424        let mean_p = members.iter().map(|(p, _)| p).sum::<f64>() / members.len() as f64;
425        let observed_rate = members.iter().map(|(_, y)| y).sum::<f64>() / members.len() as f64;
426        ece += members.len() as f64 / denominator * (mean_p - observed_rate).abs();
427        bins.push(CalibrationBin {
428            range: [lo, hi],
429            n: members.len(),
430            mean_p,
431            observed_rate,
432        });
433    }
434
435    FamilyCalibration {
436        engine,
437        forecaster_kind,
438        family,
439        answer_kind,
440        pooled_families: Vec::new(),
441        n,
442        unscored,
443        evidence: if measurable {
444            "measurable"
445        } else {
446            "anecdotal"
447        },
448        brier,
449        top_label_brier,
450        baseline_brier: report_baseline.then_some(base),
451        ece,
452        rate,
453        z,
454        p_value,
455        brier_skill,
456        measurable,
457        calibrated,
458        bins,
459    }
460}
461
462/// Spiegelhalter (1986) Z statistic for `(p, y)` pairs: under perfect calibration it is
463/// approximately standard normal. `None` when the variance term is zero.
464pub fn spiegelhalter_z(points: &[(f64, f64)]) -> Option<f64> {
465    let numerator: f64 = points.iter().map(|(p, y)| (y - p) * (1.0 - 2.0 * p)).sum();
466    let variance: f64 = points
467        .iter()
468        .map(|(p, _)| (1.0 - 2.0 * p).powi(2) * p * (1.0 - p))
469        .sum();
470    (variance > f64::EPSILON).then(|| numerator / variance.sqrt())
471}
472
473/// Complementary error function (Numerical Recipes `erfcc`, fractional error below 1.2e-7),
474/// so the kernel needs no maths dependency for a two-sided normal p-value.
475fn erfc(x: f64) -> f64 {
476    let z = x.abs();
477    let t = 1.0 / (1.0 + 0.5 * z);
478    let poly = -z * z - 1.265_512_23
479        + t * (1.000_023_68
480            + t * (0.374_091_96
481                + t * (0.096_784_18
482                    + t * (-0.186_288_06
483                        + t * (0.278_868_07
484                            + t * (-1.135_203_98
485                                + t * (1.488_515_87 + t * (-0.822_152_23 + t * 0.170_872_77))))))));
486    let r = t * poly.exp();
487    if x >= 0.0 {
488        r
489    } else {
490        2.0 - r
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn normal_p_values_match_tables() {
500        for (z, expected) in [
501            (0.0, 1.0),
502            (1.96, 0.049_996),
503            (2.576, 0.009_995),
504            (1.0, 0.317_311),
505        ] {
506            let p = erfc(f64::abs(z) / std::f64::consts::SQRT_2);
507            assert!((p - expected).abs() < 1e-5, "z={z}: {p}");
508        }
509    }
510}