use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct BucketStats {
pub bucket: String,
pub count: usize,
pub passed: usize,
pub pass_rate: f64,
#[serde(skip_serializing_if = "String::is_empty")]
pub advice: String,
pub advice_status: AdviceStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AdviceStatus {
WellCalibrated,
SampleTooSmall,
UnknownLabel,
Actionable,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct CalibrationReport {
pub buckets: Vec<BucketStats>,
pub total: usize,
pub overall_pass_rate: f64,
}
pub fn compute_calibration<S, I>(observations: I) -> CalibrationReport
where
S: AsRef<str>,
I: IntoIterator<Item = (S, bool)>,
{
let mut totals: BTreeMap<String, (usize, usize)> = BTreeMap::new();
let mut grand_total = 0usize;
let mut grand_passed = 0usize;
for (bucket, passed) in observations {
let entry = totals.entry(bucket.as_ref().to_string()).or_insert((0, 0));
entry.0 += 1;
if passed {
entry.1 += 1;
grand_passed += 1;
}
grand_total += 1;
}
let buckets: Vec<BucketStats> = totals
.into_iter()
.map(|(label, (count, passed))| {
let rate = if count == 0 {
f64::NAN
} else {
round4(passed as f64 / count as f64)
};
let (advice, advice_status) = bucket_advice_with_status(&label, count, rate);
BucketStats {
bucket: label,
count,
passed,
pass_rate: rate,
advice,
advice_status,
}
})
.collect();
let overall = if grand_total == 0 {
f64::NAN
} else {
round4(grand_passed as f64 / grand_total as f64)
};
CalibrationReport {
buckets,
total: grand_total,
overall_pass_rate: overall,
}
}
fn bucket_advice_with_status(label: &str, count: usize, rate: f64) -> (String, AdviceStatus) {
if count < 5 {
return (String::new(), AdviceStatus::SampleTooSmall);
}
if rate.is_nan() {
return (String::new(), AdviceStatus::SampleTooSmall);
}
let expected = match label {
"low" => 0.95,
"medium" => 0.70,
"high" => 0.45,
"critical" => 0.20,
"core" | "strong" | "relevant" => 0.90,
"partial" | "peripheral" => 0.65,
"weak" | "noisy" => 0.25,
_ => return (String::new(), AdviceStatus::UnknownLabel),
};
let gap = rate - expected;
if gap > 0.15 {
let msg = format!(
"observed pass rate {:.0}% exceeds expected midpoint {:.0}% by {:.0}pp — possible causes: (a) bucket threshold too strict, (b) probes too lenient to differentiate within-bucket precision, (c) bucket label genuinely describes uncertainty rather than expected failure rate. Tighten probes (rank_eq vs rank_lte) before retuning thresholds.",
rate * 100.0,
expected * 100.0,
gap.abs() * 100.0,
);
(msg, AdviceStatus::Actionable)
} else if gap < -0.15 {
let msg = format!(
"observed pass rate {:.0}% trails expected midpoint {:.0}% by {:.0}pp — bucket threshold may be too generous (results are worse than the label promises)",
rate * 100.0,
expected * 100.0,
gap.abs() * 100.0,
);
(msg, AdviceStatus::Actionable)
} else {
(String::new(), AdviceStatus::WellCalibrated)
}
}
fn round4(x: f64) -> f64 {
(x * 10000.0).round() / 10000.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_observations_yield_empty_report() {
let report = compute_calibration::<&str, _>(Vec::new());
assert_eq!(report.total, 0);
assert!(report.overall_pass_rate.is_nan());
assert!(report.buckets.is_empty());
}
#[test]
fn single_bucket_all_pass_reports_1_0() {
let obs = vec![("high", true), ("high", true), ("high", true)];
let r = compute_calibration(obs);
assert_eq!(r.total, 3);
assert_eq!(r.buckets.len(), 1);
assert_eq!(r.buckets[0].count, 3);
assert_eq!(r.buckets[0].passed, 3);
assert!((r.buckets[0].pass_rate - 1.0).abs() < 1e-9);
assert!((r.overall_pass_rate - 1.0).abs() < 1e-9);
}
#[test]
fn multi_bucket_groups_correctly() {
let obs = vec![
("high", true),
("high", true),
("high", false),
("low", false),
("low", false),
("medium", true),
];
let r = compute_calibration(obs);
assert_eq!(r.total, 6);
assert_eq!(r.buckets[0].bucket, "high");
assert_eq!(r.buckets[0].count, 3);
assert_eq!(r.buckets[0].passed, 2);
assert!((r.buckets[0].pass_rate - 0.6667).abs() < 0.001);
assert_eq!(r.buckets[1].bucket, "low");
assert_eq!(r.buckets[1].count, 2);
assert_eq!(r.buckets[1].passed, 0);
assert_eq!(r.buckets[1].pass_rate, 0.0);
assert_eq!(r.buckets[2].bucket, "medium");
}
#[test]
fn small_sample_suppresses_advice() {
let obs = vec![("high", false); 4];
let r = compute_calibration(obs);
assert_eq!(r.buckets[0].advice, "");
}
#[test]
fn low_bucket_underperforming_gets_too_generous_advice() {
let mut obs: Vec<(&str, bool)> = Vec::new();
for i in 0..10 {
obs.push(("low", i < 3)); }
let r = compute_calibration(obs);
let advice = &r.buckets[0].advice;
assert!(
advice.contains("too generous"),
"expected 'too generous' advice for low@30% (uncertainty axis); got: {advice:?}"
);
assert!(advice.contains("trails"), "got: {advice}");
}
#[test]
fn high_bucket_overperforming_advice_lists_competing_causes() {
let mut obs: Vec<(&str, bool)> = Vec::new();
for i in 0..10 {
obs.push(("high", i != 0)); }
let r = compute_calibration(obs);
let advice = &r.buckets[0].advice;
assert!(
advice.contains("exceeds"),
"must report the over-performance; got: {advice:?}"
);
assert!(
advice.contains("(a)") && advice.contains("(b)") && advice.contains("(c)"),
"must enumerate three competing causes; got: {advice:?}"
);
assert!(
advice.contains("Tighten probes"),
"must suggest the precision-mode remediation before threshold tuning; got: {advice:?}"
);
}
#[test]
fn acmeproj_field_signal_now_well_calibrated() {
let obs: Vec<(&str, bool)> = (0..10).map(|_| ("low", true)).collect();
let r = compute_calibration(obs);
assert_eq!(
r.buckets[0].advice, "",
"low@100% must be well-calibrated under uncertainty-axis semantics"
);
}
#[test]
fn well_calibrated_bucket_emits_no_advice() {
let mut obs: Vec<(&str, bool)> = Vec::new();
for i in 0..10 {
obs.push(("medium", i < 7));
}
let r = compute_calibration(obs);
assert_eq!(
r.buckets[0].advice, "",
"well-calibrated bucket should have empty advice"
);
assert_eq!(
r.buckets[0].advice_status,
AdviceStatus::WellCalibrated,
"well-calibrated bucket must report status explicitly"
);
}
#[test]
fn advice_status_distinguishes_three_empty_causes() {
let small: Vec<(&str, bool)> = (0..4).map(|_| ("low", true)).collect();
let r1 = compute_calibration(small);
assert_eq!(r1.buckets[0].advice, "");
assert_eq!(r1.buckets[0].advice_status, AdviceStatus::SampleTooSmall);
let unknown: Vec<(&str, bool)> = (0..10).map(|_| ("freshly_invented", true)).collect();
let r2 = compute_calibration(unknown);
assert_eq!(r2.buckets[0].advice, "");
assert_eq!(r2.buckets[0].advice_status, AdviceStatus::UnknownLabel);
let well: Vec<(&str, bool)> = (0..10).map(|_| ("low", true)).collect();
let r3 = compute_calibration(well);
assert_eq!(r3.buckets[0].advice, "");
assert_eq!(r3.buckets[0].advice_status, AdviceStatus::WellCalibrated);
}
#[test]
fn actionable_advice_reports_actionable_status() {
let obs: Vec<(&str, bool)> = (0..10).map(|i| ("high", i != 0)).collect();
let r = compute_calibration(obs);
assert!(!r.buckets[0].advice.is_empty());
assert_eq!(r.buckets[0].advice_status, AdviceStatus::Actionable);
}
#[test]
fn unknown_bucket_label_emits_no_advice() {
let obs = vec![("freshly_invented_label", false); 10];
let r = compute_calibration(obs);
assert_eq!(r.buckets[0].advice, "");
}
#[test]
fn recovery_estimate_labels_work() {
let obs = vec![("strong", true); 8];
let r = compute_calibration(obs);
assert_eq!(r.buckets[0].advice, "");
}
#[test]
fn overall_rate_aggregates_across_buckets() {
let obs = vec![
("high", true),
("high", true),
("low", false),
("low", false),
];
let r = compute_calibration(obs);
assert!((r.overall_pass_rate - 0.5).abs() < 1e-9);
}
}