use serde::Serialize;
pub(crate) const CALIBRATION_VERSION: &str = "ask-holdout-2026-08-21.v1";
pub(crate) const HIGH_MARGIN_PERMILLE: i64 = 200;
const MEDIUM_MARGIN_PERMILLE: i64 = 50;
const MIN_CALIBRATION_SAMPLE: usize = 10;
const AUTO_ACCEPT_PRECISION_PERMILLE: u16 = 950;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum RankingBucket {
High,
Medium,
Low,
Unrated,
}
impl RankingBucket {
pub(crate) fn from_label(label: &str) -> Option<Self> {
match label {
"high" => Some(Self::High),
"medium" => Some(Self::Medium),
"low" => Some(Self::Low),
"unrated" => Some(Self::Unrated),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub(crate) struct RankingMargin {
pub(crate) absolute: Option<i64>,
pub(crate) permille: Option<i64>,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub(crate) struct Calibration {
pub(crate) version: &'static str,
pub(crate) sample_size: usize,
pub(crate) correct: usize,
pub(crate) measured_precision: f64,
pub(crate) precision_interval_95: [f64; 2],
pub(crate) in_calibration: bool,
}
pub(crate) fn wilson_95(correct: usize, total: usize) -> [f64; 2] {
if total == 0 {
return [0.0, 0.0];
}
let (z, n, p) = (1.96_f64, total as f64, correct as f64 / total as f64);
let denominator = 1.0 + z * z / n;
let center = (p + z * z / (2.0 * n)) / denominator;
let half = z * (p * (1.0 - p) / n + z * z / (4.0 * n * n)).sqrt() / denominator;
[(center - half).max(0.0), (center + half).min(1.0)]
}
#[derive(Clone, Copy, Debug, Serialize)]
pub(crate) struct TermCoverage {
pub(crate) matched: usize,
pub(crate) total: usize,
pub(crate) permille: u16,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub(crate) struct Assessment {
pub(crate) ranking_bucket: RankingBucket,
pub(crate) ranking_margin: RankingMargin,
pub(crate) calibration: Calibration,
pub(crate) term_coverage: TermCoverage,
pub(crate) verify_required: bool,
pub(crate) abstain: bool,
pub(crate) reason: &'static str,
}
impl TermCoverage {
fn new(matched: usize, total: usize) -> Self {
let permille = matched
.saturating_mul(1000)
.checked_div(total)
.unwrap_or(0)
.min(1000) as u16;
Self {
matched,
total,
permille,
}
}
}
pub(crate) fn assess_top(scores: &[i64], matched_terms: usize, total_terms: usize) -> Assessment {
let coverage = TermCoverage::new(matched_terms, total_terms);
let Some(&score) = scores.first() else {
return unrated(coverage, "no_match");
};
let Some(&runner_up) = scores.get(1) else {
return unrated(coverage, "no_runner_up");
};
if score <= 0 {
return unrated(coverage, "non_positive_score");
}
let absolute = (score - runner_up).max(0);
let permille = absolute * 1000 / score;
let level = if permille >= HIGH_MARGIN_PERMILLE {
RankingBucket::High
} else if permille >= MEDIUM_MARGIN_PERMILLE {
RankingBucket::Medium
} else {
RankingBucket::Low
};
let (sample_size, correct) = calibration_bucket(level);
let precision_permille = (correct * 1000 / sample_size.max(1)) as u16;
let in_calibration = sample_size >= MIN_CALIBRATION_SAMPLE;
let weak_coverage = coverage.permille < 500;
let abstain = weak_coverage || !in_calibration;
let reason = if weak_coverage {
"weak_term_coverage"
} else if !in_calibration {
"insufficient_calibration_sample"
} else {
"calibrated_ranking"
};
Assessment {
ranking_bucket: level,
ranking_margin: RankingMargin {
absolute: Some(absolute),
permille: Some(permille),
},
calibration: Calibration {
version: CALIBRATION_VERSION,
sample_size,
correct,
measured_precision: correct as f64 / sample_size.max(1) as f64,
precision_interval_95: wilson_95(correct, sample_size),
in_calibration,
},
term_coverage: coverage,
verify_required: abstain || precision_permille < AUTO_ACCEPT_PRECISION_PERMILLE,
abstain,
reason,
}
}
fn unrated(coverage: TermCoverage, reason: &'static str) -> Assessment {
Assessment {
ranking_bucket: RankingBucket::Unrated,
ranking_margin: RankingMargin {
absolute: None,
permille: None,
},
calibration: Calibration {
version: CALIBRATION_VERSION,
sample_size: 0,
correct: 0,
measured_precision: 0.0,
precision_interval_95: [0.0, 0.0],
in_calibration: false,
},
term_coverage: coverage,
verify_required: true,
abstain: true,
reason,
}
}
const fn calibration_bucket(level: RankingBucket) -> (usize, usize) {
match level {
RankingBucket::High => (25, 22),
RankingBucket::Medium => (12, 8),
RankingBucket::Low => (9, 2),
RankingBucket::Unrated => (0, 0),
}
}
pub(crate) fn advice(assessment: Assessment, family_size: usize) -> Option<String> {
let family = if family_size > 1 {
format!("; {family_size} returned symbols share the top name")
} else {
String::new()
};
if assessment.abstain {
return Some(format!(
"abstain: {}{family}; refine the topic or inspect multiple candidates",
assessment.reason
));
}
if assessment.verify_required {
let calibration = assessment.calibration;
let [low, high] = calibration.precision_interval_95;
return Some(format!(
"verification required: {} ranking-margin bucket; holdout top-1 {}/{} correct (95% interval {:.0}-{:.0}%, small sample){family}",
match assessment.ranking_bucket {
RankingBucket::High => "high",
RankingBucket::Medium => "medium",
RankingBucket::Low => "low",
RankingBucket::Unrated => "unrated",
},
calibration.correct,
calibration.sample_size,
low * 100.0,
high * 100.0,
));
}
None
}
#[cfg(test)]
mod tests {
use super::{RankingBucket, advice, assess_top, wilson_95};
#[test]
fn wilson_interval_widens_for_small_samples() {
let [low, high] = wilson_95(22, 25);
assert!((low - 0.70).abs() < 0.01, "{low}");
assert!((high - 0.96).abs() < 0.01, "{high}");
assert_eq!(wilson_95(0, 0), [0.0, 0.0]);
let [wide_low, wide_high] = wilson_95(2, 9);
assert!(wide_high - wide_low > 0.4);
}
#[test]
fn score_gap_is_named_separately_from_empirical_confidence() {
let assessment = assess_top(&[400, 300], 2, 2);
assert_eq!(assessment.ranking_bucket, RankingBucket::High);
assert_eq!(assessment.ranking_margin.absolute, Some(100));
assert_eq!(assessment.ranking_margin.permille, Some(250));
assert_eq!(assessment.calibration.sample_size, 25);
assert_eq!(assessment.calibration.correct, 22);
assert_eq!(assessment.calibration.measured_precision, 0.88);
assert!(assessment.verify_required);
assert!(!assessment.abstain);
assert_eq!(
advice(assessment, 1).as_deref(),
Some(
"verification required: high ranking-margin bucket; holdout top-1 22/25 correct (95% interval 70-96%, small sample)"
)
);
}
#[test]
fn singleton_and_weak_evidence_abstain() {
let singleton = assess_top(&[500], 1, 1);
assert_eq!(singleton.ranking_bucket, RankingBucket::Unrated);
assert_eq!(singleton.ranking_margin.permille, None);
assert_eq!(singleton.reason, "no_runner_up");
assert!(singleton.abstain);
let weak = assess_top(&[500, 300], 1, 4);
assert_eq!(weak.reason, "weak_term_coverage");
assert!(weak.abstain);
assert!(advice(weak, 1).unwrap().starts_with("abstain:"));
}
#[test]
fn undersampled_low_bucket_abstains() {
let assessment = assess_top(&[1000, 980], 2, 2);
assert_eq!(assessment.ranking_bucket, RankingBucket::Low);
assert!(!assessment.calibration.in_calibration);
assert_eq!(assessment.reason, "insufficient_calibration_sample");
assert!(assessment.abstain);
}
}