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}