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}