Skip to main content

agentstatedeveloper_core/
calibration.rs

1//! Plan J t-015: confidence-bucket calibration helpers.
2//!
3//! Calibration asks: when our predictor (uncertainty.level,
4//! recovery estimate, result_bucket) says "medium", how often is
5//! the underlying result actually correct?
6//!
7//! A well-calibrated predictor's pass rate within a bucket should
8//! roughly match the bucket's nominal confidence. If `medium` has
9//! a 95% pass rate, it should probably be relabeled `high`. If
10//! `high` has a 60% pass rate, the threshold is too generous.
11//!
12//! This module is pure (no I/O, no async, no clock). It takes a
13//! flat list of `(predicted_bucket, passed)` observations and
14//! computes per-bucket statistics. The probe harness collects the
15//! observations and feeds them in; the kernel here is the same
16//! regardless of where observations come from (probe-harness
17//! results, golden-set scoring, A/B test outcomes).
18
19use std::collections::BTreeMap;
20
21/// Per-bucket calibration statistics.
22///
23/// Token economy (1.0.78): `advice` is skipped when empty — the
24/// `advice_status` enum carries the same signal in a more compact form
25/// for the well_calibrated / sample_too_small / unknown_label cases.
26#[derive(Debug, Clone, PartialEq, serde::Serialize)]
27pub struct BucketStats {
28    /// Bucket label (e.g. "low", "medium", "high", "critical").
29    pub bucket: String,
30    /// Total observations assigned to this bucket.
31    pub count: usize,
32    /// Observations that passed (predictor was right).
33    pub passed: usize,
34    /// Pass rate as a fraction in [0.0, 1.0]. NaN when count == 0
35    /// (guard before use). Rounded to 4 decimal places.
36    pub pass_rate: f64,
37    /// Plain-English advisory drawn from the gap between bucket
38    /// label semantics and observed pass rate. Empty string when
39    /// the bucket appears well-calibrated — skipped in serialization
40    /// for that case (advice_status carries the signal).
41    #[serde(skip_serializing_if = "String::is_empty")]
42    pub advice: String,
43    /// Why `advice` looks the way it does. Field-eval refinement
44    /// (1.0.73 AcmeProj run): an empty `advice` string had three
45    /// possible meanings — well-calibrated, sample-too-small,
46    /// or unknown-label — and consumers couldn't tell which from
47    /// the JSON alone. This explicit status lets downstream tools
48    /// gate alerting / dashboards without re-deriving the
49    /// classification from `count` + `bucket` semantics.
50    pub advice_status: AdviceStatus,
51}
52
53/// Why a bucket's `advice` field has the value it does.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
55#[serde(rename_all = "snake_case")]
56pub enum AdviceStatus {
57    /// Sample large enough AND observed pass rate within ±15pp of
58    /// the bucket's expected midpoint. `advice` is empty.
59    WellCalibrated,
60    /// `count < 5`. Statistical noise too high to act on; `advice`
61    /// is suppressed even if the rate looks off.
62    SampleTooSmall,
63    /// Bucket label is not in the known-semantics table (uncertainty
64    /// or quality axis). Can't judge calibration; `advice` is empty.
65    UnknownLabel,
66    /// `count ≥ 5` AND observed rate diverges from expected by >15pp.
67    /// `advice` contains the multi-cause analysis; this is the only
68    /// status that means "you should look at the bucket label vs.
69    /// the predictor's thresholds."
70    Actionable,
71}
72
73/// Aggregate calibration across all buckets.
74#[derive(Debug, Clone, PartialEq, serde::Serialize)]
75pub struct CalibrationReport {
76    /// Per-bucket stats, sorted by bucket label for stable output.
77    pub buckets: Vec<BucketStats>,
78    /// Total observations across all buckets.
79    pub total: usize,
80    /// Overall pass rate (sum_passed / total). NaN when total == 0.
81    pub overall_pass_rate: f64,
82}
83
84/// Compute calibration from a stream of `(bucket_label, passed)`
85/// observations. Buckets are grouped by exact-string label match
86/// (case-sensitive — caller should normalize first if needed).
87pub fn compute_calibration<S, I>(observations: I) -> CalibrationReport
88where
89    S: AsRef<str>,
90    I: IntoIterator<Item = (S, bool)>,
91{
92    let mut totals: BTreeMap<String, (usize, usize)> = BTreeMap::new();
93    let mut grand_total = 0usize;
94    let mut grand_passed = 0usize;
95
96    for (bucket, passed) in observations {
97        let entry = totals.entry(bucket.as_ref().to_string()).or_insert((0, 0));
98        entry.0 += 1;
99        if passed {
100            entry.1 += 1;
101            grand_passed += 1;
102        }
103        grand_total += 1;
104    }
105
106    let buckets: Vec<BucketStats> = totals
107        .into_iter()
108        .map(|(label, (count, passed))| {
109            let rate = if count == 0 {
110                f64::NAN
111            } else {
112                round4(passed as f64 / count as f64)
113            };
114            let (advice, advice_status) = bucket_advice_with_status(&label, count, rate);
115            BucketStats {
116                bucket: label,
117                count,
118                passed,
119                pass_rate: rate,
120                advice,
121                advice_status,
122            }
123        })
124        .collect();
125
126    let overall = if grand_total == 0 {
127        f64::NAN
128    } else {
129        round4(grand_passed as f64 / grand_total as f64)
130    };
131
132    CalibrationReport {
133        buckets,
134        total: grand_total,
135        overall_pass_rate: overall,
136    }
137}
138
139/// Bucket-semantics advisory. ASD uses TWO bucket axes with
140/// OPPOSITE directions — getting this wrong was the original
141/// 1.0.59-through-1.0.66 calibration bug (AcmeProj's `low`
142/// bucket at 100% pass rate looked miscalibrated only because
143/// this table had the uncertainty axis inverted).
144///
145/// **Uncertainty axis** (from `compute_uncertainty` →
146/// `uncertainty.level`): `low` means "low uncertainty / HIGH
147/// confidence", `critical` means "highest uncertainty / LOW
148/// confidence". A `low` bucket at 95% pass rate is correctly
149/// calibrated; a `critical` bucket at 95% would be miscalibrated
150/// (results are better than the predictor warned).
151///
152/// **Quality axis** (from `result_bucket` and recovery
153/// estimation): `core` / `strong` mean "high-quality result",
154/// `noisy` / `weak` mean "low-quality result". Direction
155/// matches intuition: high-quality buckets should pass at high
156/// rates, low-quality at low rates.
157///
158/// Reading the table below: `expected` is the OBSERVED pass
159/// rate we'd expect if the bucket label accurately describes
160/// the underlying signal. Advice fires when observed deviates
161/// from that by >15pp and sample size is ≥5.
162fn bucket_advice_with_status(label: &str, count: usize, rate: f64) -> (String, AdviceStatus) {
163    if count < 5 {
164        return (String::new(), AdviceStatus::SampleTooSmall);
165    }
166    if rate.is_nan() {
167        return (String::new(), AdviceStatus::SampleTooSmall);
168    }
169    let expected = match label {
170        // Uncertainty axis — low uncertainty = high confidence
171        // = expect high pass rate.
172        "low" => 0.95,
173        "medium" => 0.70,
174        "high" => 0.45,
175        "critical" => 0.20,
176
177        // Quality axis — high quality = expect high pass rate.
178        "core" | "strong" | "relevant" => 0.90,
179        "partial" | "peripheral" => 0.65,
180        "weak" | "noisy" => 0.25,
181
182        _ => return (String::new(), AdviceStatus::UnknownLabel),
183    };
184    let gap = rate - expected;
185    if gap > 0.15 {
186        // Field-eval (2026-06-04, AcmeProj 1.0.65): first real run
187        // showed 7 `low` observations at 100% pass rate. The original
188        // wording asserted "bucket threshold is too strict" — but
189        // that's only one of three possible explanations. The probes
190        // themselves may be too lenient (e.g. `qname_rank_lte
191        // max_rank=5` passes if the right symbol is anywhere in the
192        // top 5, not just at rank 1), or the predictor may have
193        // correctly classified queries that any reasonable retrieval
194        // would handle — `low` doesn't necessarily mean "expected to
195        // fail," it means "model has uncertainty about which exact
196        // candidate." Don't assert which one until precision-mode
197        // probes (Plan J t-019) are in place to distinguish.
198        let msg = format!(
199            "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.",
200            rate * 100.0,
201            expected * 100.0,
202            gap.abs() * 100.0,
203        );
204        (msg, AdviceStatus::Actionable)
205    } else if gap < -0.15 {
206        // The under-performing case is the clearer signal: probes
207        // are failing, predictor said the result was high-confidence.
208        // Leniency doesn't explain failures, so this advice can stay
209        // direct.
210        let msg = format!(
211            "observed pass rate {:.0}% trails expected midpoint {:.0}% by {:.0}pp — bucket threshold may be too generous (results are worse than the label promises)",
212            rate * 100.0,
213            expected * 100.0,
214            gap.abs() * 100.0,
215        );
216        (msg, AdviceStatus::Actionable)
217    } else {
218        (String::new(), AdviceStatus::WellCalibrated)
219    }
220}
221
222fn round4(x: f64) -> f64 {
223    (x * 10000.0).round() / 10000.0
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn empty_observations_yield_empty_report() {
232        let report = compute_calibration::<&str, _>(Vec::new());
233        assert_eq!(report.total, 0);
234        assert!(report.overall_pass_rate.is_nan());
235        assert!(report.buckets.is_empty());
236    }
237
238    #[test]
239    fn single_bucket_all_pass_reports_1_0() {
240        let obs = vec![("high", true), ("high", true), ("high", true)];
241        let r = compute_calibration(obs);
242        assert_eq!(r.total, 3);
243        assert_eq!(r.buckets.len(), 1);
244        assert_eq!(r.buckets[0].count, 3);
245        assert_eq!(r.buckets[0].passed, 3);
246        assert!((r.buckets[0].pass_rate - 1.0).abs() < 1e-9);
247        assert!((r.overall_pass_rate - 1.0).abs() < 1e-9);
248    }
249
250    #[test]
251    fn multi_bucket_groups_correctly() {
252        let obs = vec![
253            ("high", true),
254            ("high", true),
255            ("high", false),
256            ("low", false),
257            ("low", false),
258            ("medium", true),
259        ];
260        let r = compute_calibration(obs);
261        assert_eq!(r.total, 6);
262        // BTreeMap sorts alphabetically: high, low, medium
263        assert_eq!(r.buckets[0].bucket, "high");
264        assert_eq!(r.buckets[0].count, 3);
265        assert_eq!(r.buckets[0].passed, 2);
266        assert!((r.buckets[0].pass_rate - 0.6667).abs() < 0.001);
267        assert_eq!(r.buckets[1].bucket, "low");
268        assert_eq!(r.buckets[1].count, 2);
269        assert_eq!(r.buckets[1].passed, 0);
270        assert_eq!(r.buckets[1].pass_rate, 0.0);
271        assert_eq!(r.buckets[2].bucket, "medium");
272    }
273
274    #[test]
275    fn small_sample_suppresses_advice() {
276        // 4 obs is below the n=5 threshold; advice empty even if
277        // the rate looks miscalibrated.
278        let obs = vec![("high", false); 4];
279        let r = compute_calibration(obs);
280        assert_eq!(r.buckets[0].advice, "");
281    }
282
283    #[test]
284    fn low_bucket_underperforming_gets_too_generous_advice() {
285        // Uncertainty axis: `low` means LOW UNCERTAINTY = high
286        // confidence → expect ~95% pass rate. 10 obs at 30% pass
287        // rate is 65pp below — predictor said "high confidence,
288        // results trustworthy" but probes failed → bucket
289        // threshold was too generous (predictor admitted
290        // high-confidence too easily).
291        let mut obs: Vec<(&str, bool)> = Vec::new();
292        for i in 0..10 {
293            obs.push(("low", i < 3)); // 3 pass, 7 fail
294        }
295        let r = compute_calibration(obs);
296        let advice = &r.buckets[0].advice;
297        assert!(
298            advice.contains("too generous"),
299            "expected 'too generous' advice for low@30% (uncertainty axis); got: {advice:?}"
300        );
301        assert!(advice.contains("trails"), "got: {advice}");
302    }
303
304    #[test]
305    fn high_bucket_overperforming_advice_lists_competing_causes() {
306        // Uncertainty axis: `high` means HIGH UNCERTAINTY → expect
307        // ~45% pass rate. 10 obs at 95% pass rate is 50pp above —
308        // predictor warned low-confidence but probes passed →
309        // either bucket is too strict, probes too lenient, or
310        // "high uncertainty" describes scope-of-doubt rather than
311        // expected-failure-rate. The multi-cause advisory shape
312        // (added in 1.0.66, semantics direction corrected in
313        // 1.0.68) is pinned by checking for "(a)/(b)/(c)" and
314        // "Tighten probes" anchors.
315        let mut obs: Vec<(&str, bool)> = Vec::new();
316        for i in 0..10 {
317            obs.push(("high", i != 0)); // 9 pass, 1 fail
318        }
319        let r = compute_calibration(obs);
320        let advice = &r.buckets[0].advice;
321        assert!(
322            advice.contains("exceeds"),
323            "must report the over-performance; got: {advice:?}"
324        );
325        assert!(
326            advice.contains("(a)") && advice.contains("(b)") && advice.contains("(c)"),
327            "must enumerate three competing causes; got: {advice:?}"
328        );
329        assert!(
330            advice.contains("Tighten probes"),
331            "must suggest the precision-mode remediation before threshold tuning; got: {advice:?}"
332        );
333    }
334
335    #[test]
336    fn acmeproj_field_signal_now_well_calibrated() {
337        // Regression test for the AcmeProj 1.0.65/66/67 case: 10
338        // observations in the `low` bucket all passing. With the
339        // semantics-inverted advice table (1.0.59-1.0.67), this
340        // produced a confidently-wrong "threshold too strict"
341        // advisory. With the corrected semantics (1.0.68), `low`
342        // expects ~95% pass rate, so 100% observed is +5pp —
343        // well within the ±15pp band → advice empty (correctly
344        // calibrated).
345        let obs: Vec<(&str, bool)> = (0..10).map(|_| ("low", true)).collect();
346        let r = compute_calibration(obs);
347        assert_eq!(
348            r.buckets[0].advice, "",
349            "low@100% must be well-calibrated under uncertainty-axis semantics"
350        );
351    }
352
353    #[test]
354    fn well_calibrated_bucket_emits_no_advice() {
355        // 10 obs of "medium" at ~70% pass — within the ±15pp band
356        // around the 65% midpoint. Advice must be empty.
357        let mut obs: Vec<(&str, bool)> = Vec::new();
358        for i in 0..10 {
359            obs.push(("medium", i < 7));
360        }
361        let r = compute_calibration(obs);
362        assert_eq!(
363            r.buckets[0].advice, "",
364            "well-calibrated bucket should have empty advice"
365        );
366        assert_eq!(
367            r.buckets[0].advice_status,
368            AdviceStatus::WellCalibrated,
369            "well-calibrated bucket must report status explicitly"
370        );
371    }
372
373    #[test]
374    fn advice_status_distinguishes_three_empty_causes() {
375        // Refinement (1.0.73): an empty `advice` string had three
376        // possible meanings — well-calibrated, sample-too-small,
377        // unknown-label. The advice_status field disambiguates so
378        // consumers don't have to re-derive.
379
380        // SampleTooSmall: low bucket, only 4 observations (below
381        // n=5 threshold), all passing.
382        let small: Vec<(&str, bool)> = (0..4).map(|_| ("low", true)).collect();
383        let r1 = compute_calibration(small);
384        assert_eq!(r1.buckets[0].advice, "");
385        assert_eq!(r1.buckets[0].advice_status, AdviceStatus::SampleTooSmall);
386
387        // UnknownLabel: bucket name not in the semantics table.
388        let unknown: Vec<(&str, bool)> = (0..10).map(|_| ("freshly_invented", true)).collect();
389        let r2 = compute_calibration(unknown);
390        assert_eq!(r2.buckets[0].advice, "");
391        assert_eq!(r2.buckets[0].advice_status, AdviceStatus::UnknownLabel);
392
393        // WellCalibrated: low bucket at 100% pass (expected ~95%,
394        // gap +5pp within ±15pp band).
395        let well: Vec<(&str, bool)> = (0..10).map(|_| ("low", true)).collect();
396        let r3 = compute_calibration(well);
397        assert_eq!(r3.buckets[0].advice, "");
398        assert_eq!(r3.buckets[0].advice_status, AdviceStatus::WellCalibrated);
399    }
400
401    #[test]
402    fn actionable_advice_reports_actionable_status() {
403        // Mirror: when advice IS populated, status is Actionable.
404        // 10 obs of "high" at 95% pass — expected ~45%, +50pp gap.
405        let obs: Vec<(&str, bool)> = (0..10).map(|i| ("high", i != 0)).collect();
406        let r = compute_calibration(obs);
407        assert!(!r.buckets[0].advice.is_empty());
408        assert_eq!(r.buckets[0].advice_status, AdviceStatus::Actionable);
409    }
410
411    #[test]
412    fn unknown_bucket_label_emits_no_advice() {
413        // A label not in the bucket-semantics table → can't judge
414        // calibration; advice empty regardless of pass rate.
415        let obs = vec![("freshly_invented_label", false); 10];
416        let r = compute_calibration(obs);
417        assert_eq!(r.buckets[0].advice, "");
418    }
419
420    #[test]
421    fn recovery_estimate_labels_work() {
422        // Smoke test: strong/partial/weak (from
423        // recovery_suggestions) classify under the same semantics
424        // as low/medium/high.
425        let obs = vec![("strong", true); 8];
426        let r = compute_calibration(obs);
427        // 100% pass for "strong" (expected ~87.5%) → +12.5pp,
428        // within the 15pp band → no advice (well-calibrated).
429        assert_eq!(r.buckets[0].advice, "");
430    }
431
432    #[test]
433    fn overall_rate_aggregates_across_buckets() {
434        let obs = vec![
435            ("high", true),
436            ("high", true),
437            ("low", false),
438            ("low", false),
439        ];
440        let r = compute_calibration(obs);
441        // 2 pass / 4 total = 0.5
442        assert!((r.overall_pass_rate - 0.5).abs() < 1e-9);
443    }
444}