use crate::ledger::MemoryStore;
use crate::trace::{PredictionRecord, TraceKind, TraceStatus};
use serde::Serialize;
use std::collections::BTreeMap;
pub const MIN_OUTCOMES: usize = 50;
const BINS: usize = 5;
pub const Z_CRITICAL: f64 = 1.96;
#[derive(Debug, Clone, Serialize)]
pub struct CalibrationBin {
pub range: [f64; 2],
pub n: usize,
pub mean_p: f64,
pub observed_rate: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FamilyFilter<'a> {
All,
Exact(&'a str),
Prefix(&'a str),
}
impl FamilyFilter<'_> {
fn matches(&self, family: &str) -> bool {
match self {
Self::All => true,
Self::Exact(name) => family == *name,
Self::Prefix(prefix) => family.starts_with(prefix),
}
}
}
pub const POOLED_KIND: &str = "top_label";
pub const ENGINE_FORECASTER: &str = "engine";
pub const PRINCIPAL_FORECASTER: &str = "principal";
fn forecaster_kind(model_authored: bool) -> &'static str {
if model_authored {
ENGINE_FORECASTER
} else {
PRINCIPAL_FORECASTER
}
}
#[derive(Debug, Clone, Serialize)]
pub struct FamilyCalibration {
pub engine: String,
pub forecaster_kind: &'static str,
pub family: String,
pub answer_kind: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub pooled_families: Vec<String>,
pub n: usize,
pub unscored: usize,
pub evidence: &'static str,
pub brier: f64,
pub top_label_brier: f64,
pub baseline_brier: Option<f64>,
pub ece: f64,
pub rate: f64,
pub z: Option<f64>,
pub p_value: Option<f64>,
pub brier_skill: Option<f64>,
pub measurable: bool,
pub calibrated: bool,
pub bins: Vec<CalibrationBin>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CalibrationReport {
pub min_outcomes: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub family_prefix: Option<String>,
pub unresolved_predictions: usize,
pub families: Vec<FamilyCalibration>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub pooled: Vec<FamilyCalibration>,
}
struct Pair<'a> {
prediction: &'a PredictionRecord,
p: f64,
observed: String,
}
impl MemoryStore {
pub(crate) fn latest_outcomes(&self) -> BTreeMap<&str, (u64, &str)> {
let mut outcomes: BTreeMap<&str, (u64, &str)> = BTreeMap::new();
for entry in self.all() {
let trace = &entry.trace;
if entry.status != TraceStatus::Active
|| trace.kind != TraceKind::Outcome
|| trace.is_model_authored()
{
continue;
}
if let Some(outcome) = &trace.outcome {
let slot = outcomes
.entry(outcome.prediction_id.as_str())
.or_insert((0, ""));
if trace.created_at_ms >= slot.0 {
*slot = (trace.created_at_ms, outcome.observed.as_str());
}
}
}
outcomes
}
pub fn calibration(&self, family: Option<&str>) -> CalibrationReport {
self.calibration_report(family.map_or(FamilyFilter::All, FamilyFilter::Exact))
}
pub fn calibration_report(&self, filter: FamilyFilter) -> CalibrationReport {
let min_outcomes = self.policy().calibration_min_outcomes;
let outcomes = self.latest_outcomes();
type Key = (String, String, &'static str, String);
let mut groups: BTreeMap<Key, (Vec<Pair>, usize)> = BTreeMap::new();
let mut unresolved = 0;
for entry in self.all() {
if entry.status != TraceStatus::Active {
continue;
}
let trace = &entry.trace;
let Some(prediction) = &trace.prediction else {
continue;
};
if !filter.matches(&prediction.family) {
continue;
}
let Some((_, observed)) = outcomes.get(trace.id.as_str()) else {
unresolved += 1;
continue;
};
let group = groups
.entry((
prediction.family.clone(),
prediction.answer_kind.clone(),
forecaster_kind(trace.is_model_authored()),
prediction.engine.clone(),
))
.or_default();
match prediction.p {
Some(p) => group.0.push(Pair {
prediction,
p,
observed: observed.trim().to_string(),
}),
None => group.1 += 1,
}
}
let pooled = match filter {
FamilyFilter::Prefix(prefix) => {
type Pool<'p> = (Vec<&'p Pair<'p>>, usize, Vec<String>);
let mut by_engine: BTreeMap<(&str, &str), Pool> = BTreeMap::new();
for ((family, _, kind, engine), (pairs, unscored)) in &groups {
let slot = by_engine.entry((kind, engine.as_str())).or_default();
slot.0.extend(pairs.iter());
slot.1 += unscored;
if !slot.2.contains(family) {
slot.2.push(family.clone());
}
}
by_engine
.into_iter()
.map(|((kind, engine), (pairs, unscored, families))| {
let points: Vec<(f64, f64)> = pairs.iter().map(|p| top_label(p)).collect();
let brier = mean_squared_error(&points);
let mut row = finish(
(kind, engine.to_string()),
format!("{prefix}*"),
POOLED_KIND.to_string(),
points,
brier,
true,
unscored,
min_outcomes,
);
row.pooled_families = families;
row
})
.collect()
}
_ => Vec::new(),
};
let families = groups
.into_iter()
.map(|((family, answer_kind, kind, engine), (pairs, unscored))| {
summarize(
(kind, engine),
family,
answer_kind,
&pairs,
unscored,
min_outcomes,
)
})
.collect();
CalibrationReport {
min_outcomes,
family_prefix: match filter {
FamilyFilter::Prefix(prefix) => Some(prefix.to_string()),
_ => None,
},
unresolved_predictions: unresolved,
families,
pooled,
}
}
}
fn top_label(pair: &Pair) -> (f64, f64) {
let record = pair.prediction;
let (p, hit) = if record.answer_kind == "noul" {
let says_true = record.value.eq_ignore_ascii_case("true");
let happened = pair.observed.eq_ignore_ascii_case("true");
(
if says_true { pair.p } else { 1.0 - pair.p },
says_true == happened,
)
} else {
(pair.p, pair.observed == record.value)
};
(p, if hit { 1.0 } else { 0.0 })
}
fn mean_squared_error(points: &[(f64, f64)]) -> f64 {
points.iter().map(|(p, y)| (p - y).powi(2)).sum::<f64>() / points.len().max(1) as f64
}
type Forecaster = (&'static str, String);
fn summarize(
forecaster: Forecaster,
family: String,
answer_kind: String,
pairs: &[Pair],
unscored: usize,
min_outcomes: usize,
) -> FamilyCalibration {
let is_noul = answer_kind == "noul";
let points: Vec<(f64, f64)> = pairs
.iter()
.map(|pair| {
let y = if is_noul {
pair.observed.eq_ignore_ascii_case("true")
} else {
pair.observed == pair.prediction.value
};
(pair.p, if y { 1.0 } else { 0.0 })
})
.collect();
let brier = if is_noul {
mean_squared_error(&points)
} else {
pairs
.iter()
.map(|pair| {
let probabilities = &pair.prediction.probabilities;
if probabilities.is_empty() {
let hit = pair.observed == pair.prediction.value;
(pair.p - if hit { 1.0 } else { 0.0 }).powi(2)
} else {
let mut sum: f64 = probabilities
.iter()
.map(|(k, p)| (p - if *k == pair.observed { 1.0 } else { 0.0 }).powi(2))
.sum();
if !probabilities.contains_key(&pair.observed) {
sum += 1.0;
}
sum
}
})
.sum::<f64>()
/ pairs.len().max(1) as f64
};
finish(
forecaster,
family,
answer_kind,
points,
brier,
is_noul,
unscored,
min_outcomes,
)
}
#[allow(clippy::too_many_arguments)]
fn finish(
(forecaster_kind, engine): Forecaster,
family: String,
answer_kind: String,
points: Vec<(f64, f64)>,
brier: f64,
report_baseline: bool,
unscored: usize,
min_outcomes: usize,
) -> FamilyCalibration {
let n = points.len();
let denominator = n.max(1) as f64;
let rate = points.iter().map(|(_, y)| y).sum::<f64>() / denominator;
let top_label_brier = mean_squared_error(&points);
let base = points.iter().map(|(_, y)| (rate - y).powi(2)).sum::<f64>() / denominator;
let (z, p_value) = match spiegelhalter_z(&points) {
Some(z) => (Some(z), Some(erfc(z.abs() / std::f64::consts::SQRT_2))),
None => (None, None),
};
let brier_skill = (n > 0 && base > f64::EPSILON).then(|| 1.0 - top_label_brier / base);
let measurable = n >= min_outcomes;
let calibrated = measurable
&& n >= MIN_OUTCOMES
&& z.is_some_and(|z| z.abs() < Z_CRITICAL)
&& brier_skill.is_some_and(|skill| skill > 0.0);
let mut bins = Vec::new();
let mut ece = 0.0;
for b in 0..BINS {
let lo = b as f64 / BINS as f64;
let hi = (b + 1) as f64 / BINS as f64;
let members: Vec<&(f64, f64)> = points
.iter()
.filter(|(p, _)| *p >= lo && (*p < hi || (b == BINS - 1 && *p <= hi)))
.collect();
if members.is_empty() {
continue;
}
let mean_p = members.iter().map(|(p, _)| p).sum::<f64>() / members.len() as f64;
let observed_rate = members.iter().map(|(_, y)| y).sum::<f64>() / members.len() as f64;
ece += members.len() as f64 / denominator * (mean_p - observed_rate).abs();
bins.push(CalibrationBin {
range: [lo, hi],
n: members.len(),
mean_p,
observed_rate,
});
}
FamilyCalibration {
engine,
forecaster_kind,
family,
answer_kind,
pooled_families: Vec::new(),
n,
unscored,
evidence: if measurable {
"measurable"
} else {
"anecdotal"
},
brier,
top_label_brier,
baseline_brier: report_baseline.then_some(base),
ece,
rate,
z,
p_value,
brier_skill,
measurable,
calibrated,
bins,
}
}
pub fn spiegelhalter_z(points: &[(f64, f64)]) -> Option<f64> {
let numerator: f64 = points.iter().map(|(p, y)| (y - p) * (1.0 - 2.0 * p)).sum();
let variance: f64 = points
.iter()
.map(|(p, _)| (1.0 - 2.0 * p).powi(2) * p * (1.0 - p))
.sum();
(variance > f64::EPSILON).then(|| numerator / variance.sqrt())
}
fn erfc(x: f64) -> f64 {
let z = x.abs();
let t = 1.0 / (1.0 + 0.5 * z);
let poly = -z * z - 1.265_512_23
+ t * (1.000_023_68
+ t * (0.374_091_96
+ t * (0.096_784_18
+ t * (-0.186_288_06
+ t * (0.278_868_07
+ t * (-1.135_203_98
+ t * (1.488_515_87 + t * (-0.822_152_23 + t * 0.170_872_77))))))));
let r = t * poly.exp();
if x >= 0.0 {
r
} else {
2.0 - r
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normal_p_values_match_tables() {
for (z, expected) in [
(0.0, 1.0),
(1.96, 0.049_996),
(2.576, 0.009_995),
(1.0, 0.317_311),
] {
let p = erfc(f64::abs(z) / std::f64::consts::SQRT_2);
assert!((p - expected).abs() < 1e-5, "z={z}: {p}");
}
}
}