use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use argonauts_core::names::ModelRef;
use argonauts_core::settings::protocol::RoutingSettings;
use argonauts_core::{RunId, TaskId};
use crate::outcomes::{LogLine, Outcome, Prediction, Predictor};
pub const PASS_CHECKS_FIRST_TRY: &str = "p_pass_checks_first_try";
pub const WITHIN_TIME_CAP: &str = "p_within_time_cap";
pub const AT_MOST_TWO_TRIES: &str = "p_at_most_two_tries";
pub const PASSES_MERGE: &str = "p_passes_merge";
pub const WORKER_METRICS: [&str; 3] = [PASS_CHECKS_FIRST_TRY, WITHIN_TIME_CAP, AT_MOST_TWO_TRIES];
pub fn brier(p: f64, happened: bool) -> f64 {
let outcome = if happened { 1.0 } else { 0.0 };
(p - outcome).powi(2)
}
pub fn skill(pairs: &[(f64, bool)], base_rate: f64) -> f64 {
let scored: f64 = pairs.iter().map(|&(p, happened)| brier(p, happened)).sum();
let reference: f64 = pairs
.iter()
.map(|&(_, happened)| brier(base_rate, happened))
.sum();
skill_score(scored, reference)
}
fn skill_score(scored: f64, reference: f64) -> f64 {
if reference <= f64::EPSILON {
return 0.0;
}
1.0 - scored / reference
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Bin {
pub lower: f64,
pub upper: f64,
pub count: u32,
pub mean_predicted: f64,
pub actual_rate: f64,
}
pub fn reliability(pairs: &[(f64, bool)]) -> Vec<Bin> {
let mut bins = [(0_u32, 0.0, 0.0); 10];
for &(p, happened) in pairs {
let p = p.clamp(0.0, 1.0);
let index = ((p * 10.0 + 1e-9).floor() as usize).min(9);
let bin = &mut bins[index];
bin.0 += 1;
bin.1 += p;
bin.2 += if happened { 1.0 } else { 0.0 };
}
bins.iter()
.enumerate()
.filter(|(_, (count, _, _))| *count > 0)
.map(|(index, &(count, predicted, happened))| Bin {
lower: index as f64 / 10.0,
upper: (index + 1) as f64 / 10.0,
count,
mean_predicted: predicted / f64::from(count),
actual_rate: happened / f64::from(count),
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub struct Decomposition {
pub reliability: f64,
pub resolution: f64,
pub uncertainty: f64,
}
pub fn decompose(pairs: &[(f64, bool)]) -> Decomposition {
if pairs.is_empty() {
return Decomposition::default();
}
let total = pairs.len() as f64;
let base = pairs.iter().filter(|(_, happened)| *happened).count() as f64 / total;
let mut sorted = pairs.to_vec();
sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut reliability = 0.0;
let mut resolution = 0.0;
for group in sorted.chunk_by(|a, b| a.0.total_cmp(&b.0).is_eq()) {
let count = group.len() as f64;
let rate = group.iter().filter(|(_, happened)| *happened).count() as f64 / count;
reliability += count * (group[0].0 - rate).powi(2);
resolution += count * (rate - base).powi(2);
}
Decomposition {
reliability: reliability / total,
resolution: resolution / total,
uncertainty: base * (1.0 - base),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Calibration {
pub worker: ModelRef,
pub metric: String,
pub count: u32,
pub brier: f64,
pub skill: f64,
pub mean_predicted: f64,
pub actual_rate: f64,
pub overconfident: bool,
pub decomposition: Decomposition,
pub bins: Vec<Bin>,
}
pub fn calibration(lines: &[LogLine], settings: &RoutingSettings) -> Vec<Calibration> {
let mut outcomes: BTreeMap<(&RunId, &TaskId), Vec<&Outcome>> = BTreeMap::new();
let mut stated: BTreeMap<(&RunId, &TaskId, &ModelRef, bool), &Prediction> = BTreeMap::new();
for line in lines {
match line {
LogLine::Outcome(outcome) => outcomes
.entry((&outcome.run, &outcome.task))
.or_default()
.push(outcome),
LogLine::Prediction(prediction) => {
let reviewer = prediction.role == Predictor::Reviewer;
stated.insert(
(
&prediction.run,
&prediction.task,
&prediction.worker,
reviewer,
),
prediction,
);
}
_ => {}
}
}
let mut scored: BTreeMap<(&ModelRef, &'static str), Vec<Scored<'_>>> = BTreeMap::new();
for prediction in stated.values() {
let Some(task) = outcomes.get(&(&prediction.run, &prediction.task)) else {
continue;
};
let mut pairs = Vec::new();
if let Some(own) = task
.iter()
.rev()
.find(|outcome| outcome.worker == prediction.worker)
{
pairs.extend([
(
PASS_CHECKS_FIRST_TRY,
prediction.p_pass_checks_first_try,
*own,
Some(own.first_try),
),
(
WITHIN_TIME_CAP,
prediction.p_within_time_cap,
*own,
own.within_time_cap,
),
(
AT_MOST_TWO_TRIES,
prediction.p_at_most_two_tries,
*own,
Some(own.tries <= 2),
),
]);
}
if let Some(last) = task.last() {
pairs.push((
PASSES_MERGE,
prediction.p_passes_merge,
*last,
Some(last.merged),
));
}
for (metric, p, outcome, happened) in pairs {
if let (Some(p), Some(happened)) = (p, happened) {
scored
.entry((&prediction.worker, metric))
.or_default()
.push(Scored {
p,
happened,
task_type: &outcome.task_type,
});
}
}
}
scored
.into_iter()
.map(|((worker, metric), scored)| score_one(worker, metric, &scored, settings))
.collect()
}
struct Scored<'a> {
p: f64,
happened: bool,
task_type: &'a str,
}
fn score_one(
worker: &ModelRef,
metric: &str,
scored: &[Scored<'_>],
settings: &RoutingSettings,
) -> Calibration {
let pairs: Vec<(f64, bool)> = scored.iter().map(|s| (s.p, s.happened)).collect();
let total = pairs.len() as f64;
let mut base: BTreeMap<&str, (f64, f64)> = BTreeMap::new();
for s in scored {
let rate = base.entry(s.task_type).or_default();
rate.0 += if s.happened { 1.0 } else { 0.0 };
rate.1 += 1.0;
}
let brier_sum: f64 = pairs.iter().map(|&(p, happened)| brier(p, happened)).sum();
let reference: f64 = scored
.iter()
.map(|s| {
let (happened, of) = base[s.task_type];
brier(happened / of, s.happened)
})
.sum();
let mean_predicted = pairs.iter().map(|(p, _)| p).sum::<f64>() / total;
let actual_rate = pairs.iter().filter(|(_, happened)| *happened).count() as f64 / total;
let count = u32::try_from(pairs.len()).unwrap_or(u32::MAX);
Calibration {
worker: worker.clone(),
metric: metric.to_owned(),
count,
brier: brier_sum / total,
skill: skill_score(brier_sum, reference),
mean_predicted,
actual_rate,
overconfident: count >= settings.overconfident_after
&& mean_predicted - actual_rate > settings.overconfident_margin,
decomposition: decompose(&pairs),
bins: reliability(&pairs),
}
}
pub fn point_error(actual: f64, predicted: f64) -> f64 {
(actual / predicted).ln().abs()
}