use crate::config::{DecisionScore, ScoreConfig};
use crate::observation::Observation;
use crate::schema::StabilityReport;
use crate::score_all_gold_weighted;
use crate::score_all_latent_truth;
use crate::scoring::score_all;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
const TRAIN_HELDOUT_GAP_WARNING_THRESHOLD: f64 = 0.05;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GroupKey {
SourceRootId,
OpeningFamily,
}
impl GroupKey {
pub fn field_name(self) -> &'static str {
match self {
GroupKey::SourceRootId => "source_root_id",
GroupKey::OpeningFamily => "opening_family",
}
}
fn extract(self, o: &Observation) -> Option<&str> {
match self {
GroupKey::SourceRootId => o.source_root_id.as_deref(),
GroupKey::OpeningFamily => o.opening_family.as_deref(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CalibrationOptions {
pub group_by: Option<GroupKey>,
}
const MAX_LEAKED_KEYS_SAMPLE: usize = 100;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupLeakage {
pub group_field: &'static str,
pub train_rows: usize,
pub heldout_rows: usize,
pub rows_with_group: usize,
pub rows_missing_group: usize,
pub train_unique_groups: usize,
pub heldout_unique_groups: usize,
pub leaked_group_count: usize,
pub leaked_keys_sample: Vec<String>,
pub leaked_keys_truncated: bool,
pub coverage: f64,
}
fn group_key_sets<'a>(
calibration: &'a [Observation],
heldout: &'a [Observation],
key: GroupKey,
) -> (BTreeSet<&'a str>, BTreeSet<&'a str>, BTreeSet<&'a str>) {
let calibration_keys: BTreeSet<&str> =
calibration.iter().filter_map(|o| key.extract(o)).collect();
let heldout_keys: BTreeSet<&str> = heldout.iter().filter_map(|o| key.extract(o)).collect();
let leaked: BTreeSet<&str> = calibration_keys
.intersection(&heldout_keys)
.copied()
.collect();
(calibration_keys, heldout_keys, leaked)
}
pub fn leaked_group_keys(
calibration: &[Observation],
heldout: &[Observation],
key: GroupKey,
) -> Vec<String> {
let (_, _, leaked) = group_key_sets(calibration, heldout, key);
leaked.into_iter().map(String::from).collect()
}
pub fn group_leakage(
calibration: &[Observation],
heldout: &[Observation],
key: GroupKey,
) -> GroupLeakage {
let (calibration_keys, heldout_keys, leaked) = group_key_sets(calibration, heldout, key);
let train_rows_missing = calibration
.iter()
.filter(|o| key.extract(o).is_none())
.count();
let heldout_rows_missing = heldout.iter().filter(|o| key.extract(o).is_none()).count();
let rows_missing_group = train_rows_missing + heldout_rows_missing;
let train_rows = calibration.len();
let heldout_rows = heldout.len();
let rows_with_group = (train_rows + heldout_rows) - rows_missing_group;
let coverage = if train_rows + heldout_rows == 0 {
0.0
} else {
rows_with_group as f64 / (train_rows + heldout_rows) as f64
};
let leaked_group_count = leaked.len();
let leaked_keys_sample: Vec<String> = leaked
.into_iter()
.take(MAX_LEAKED_KEYS_SAMPLE)
.map(String::from)
.collect();
let leaked_keys_truncated = leaked_group_count > leaked_keys_sample.len();
GroupLeakage {
group_field: key.field_name(),
train_rows,
heldout_rows,
rows_with_group,
rows_missing_group,
train_unique_groups: calibration_keys.len(),
heldout_unique_groups: heldout_keys.len(),
leaked_group_count,
leaked_keys_sample,
leaked_keys_truncated,
coverage,
}
}
#[derive(Debug)]
pub struct CalibrationResult {
pub keep_threshold: f64,
pub drop_threshold: f64,
pub decision_score_name: &'static str,
pub achieved_precision: f64,
pub precision_ci_low: f64,
pub precision_ci_high: f64,
pub coverage: f64,
pub n_keep: usize,
pub n_total: usize,
pub train_precision: Option<f64>,
pub note: Option<String>,
pub group_leakage: Option<GroupLeakage>,
}
pub fn compute_calibration(
observations: &[Observation],
decision_score: &DecisionScore,
confidence_level: f64,
target_precision: f64,
target_coverage: Option<f64>,
drop_threshold: f64,
heldout: Option<&[Observation]>,
) -> Option<CalibrationResult> {
compute_calibration_with_options(
observations,
decision_score,
confidence_level,
target_precision,
target_coverage,
drop_threshold,
heldout,
&CalibrationOptions::default(),
)
}
#[allow(clippy::too_many_arguments)]
pub fn compute_calibration_with_options(
observations: &[Observation],
decision_score: &DecisionScore,
confidence_level: f64,
target_precision: f64,
target_coverage: Option<f64>,
drop_threshold: f64,
heldout: Option<&[Observation]>,
options: &CalibrationOptions,
) -> Option<CalibrationResult> {
let gold: std::collections::HashMap<&str, &str> = observations
.iter()
.filter_map(|o| o.gold_label.as_deref().map(|g| (o.sample_id.as_str(), g)))
.collect();
if gold.is_empty() {
return None;
}
let heldout_gold: Option<std::collections::HashMap<&str, &str>> = match heldout {
Some(ho) => {
let m: std::collections::HashMap<&str, &str> = ho
.iter()
.filter_map(|o| o.gold_label.as_deref().map(|g| (o.sample_id.as_str(), g)))
.collect();
if m.is_empty() {
return None;
}
Some(m)
}
None => None,
};
let leakage: Option<GroupLeakage> = match (heldout, options.group_by) {
(Some(ho), Some(key)) => Some(group_leakage(observations, ho, key)),
_ => None,
};
let leakage_note: Option<String> = leakage.as_ref().and_then(|gl| {
let leak_sentence = (gl.leaked_group_count > 0).then(|| {
format!(
"{} group key(s) ({}) appear in both the calibration input and --heldout — those \
held-out samples are correlated with samples the threshold was selected on, so \
achieved_precision is optimistic.",
gl.leaked_group_count, gl.group_field
)
});
let coverage_sentence = (gl.rows_missing_group > 0).then(|| {
format!(
"{} of {} row(s) have no {} value ({:.1}% coverage) — leakage was checked only on \
the rows that do, so the true leak (if any) could be larger than reported.",
gl.rows_missing_group,
gl.train_rows + gl.heldout_rows,
gl.group_field,
gl.coverage * 100.0
)
});
match (leak_sentence, coverage_sentence) {
(Some(l), Some(c)) => Some(format!("{l} {c}")),
(Some(l), None) => Some(l),
(None, Some(c)) => Some(c),
(None, None) => None,
}
});
let config = ScoreConfig {
thresholds: crate::decision::Thresholds {
keep: 0.0,
drop: -1.0,
},
decision_score: decision_score.clone(),
confidence_level,
..ScoreConfig::default()
};
let reports = match decision_score {
DecisionScore::GoldWeighted => score_all_gold_weighted(observations.to_vec(), &config),
DecisionScore::LatentTruth => score_all_latent_truth(observations.to_vec(), &config),
_ => score_all(observations.to_vec(), &config),
};
let n_total = reports.len();
if n_total == 0 {
return None;
}
let decision_score_name = match decision_score {
DecisionScore::Raw => "raw",
DecisionScore::Adjusted => "adjusted",
DecisionScore::LowerConfidenceBound => "lcb",
DecisionScore::GoldWeighted => "gold-weighted",
DecisionScore::LatentTruth => "latent-truth",
};
for i in 0..=49usize {
let t = 0.99 - i as f64 * 0.01;
let score_val = |r: &StabilityReport| match decision_score {
DecisionScore::Adjusted => r.adjusted_stability_score,
_ => r.stability_score,
};
let kept: Vec<&StabilityReport> = reports.iter().filter(|r| score_val(r) >= t).collect();
if kept.is_empty() {
continue;
}
let n_keep = kept.len();
let coverage = n_keep as f64 / n_total as f64;
if let Some(tc) = target_coverage
&& coverage < tc
{
continue;
}
let matches = kept
.iter()
.filter(|r| {
let label = r
.weighted_majority_label
.as_deref()
.or(r.latent_truth_label.as_deref())
.or(r.majority_label.as_deref());
gold.get(r.sample_id.as_str())
.and_then(|&g| label.map(|m| m == g))
.unwrap_or(false)
})
.count();
let precision = matches as f64 / n_keep as f64;
if precision >= target_precision {
if let (Some(ho), Some(ho_gold)) = (heldout, &heldout_gold) {
let ho_reports = match decision_score {
DecisionScore::GoldWeighted => score_all_gold_weighted(ho.to_vec(), &config),
DecisionScore::LatentTruth => score_all_latent_truth(ho.to_vec(), &config),
_ => score_all(ho.to_vec(), &config),
};
let ho_n_total = ho_reports.len();
let ho_kept: Vec<&StabilityReport> =
ho_reports.iter().filter(|r| score_val(r) >= t).collect();
let ho_n_keep = ho_kept.len();
let ho_coverage = if ho_n_total == 0 {
0.0
} else {
ho_n_keep as f64 / ho_n_total as f64
};
let ho_matches = ho_kept
.iter()
.filter(|r| {
let label = r
.weighted_majority_label
.as_deref()
.or(r.latent_truth_label.as_deref())
.or(r.majority_label.as_deref());
ho_gold
.get(r.sample_id.as_str())
.and_then(|&g| label.map(|m| m == g))
.unwrap_or(false)
})
.count();
let ho_precision = if ho_n_keep == 0 {
0.0
} else {
ho_matches as f64 / ho_n_keep as f64
};
let (precision_ci_low, precision_ci_high) =
crate::scoring::wilson_ci(ho_matches, ho_n_keep, confidence_level);
let gap = precision - ho_precision;
let overfit_note = (gap > TRAIN_HELDOUT_GAP_WARNING_THRESHOLD).then(|| {
format!(
"in-sample precision at this threshold was {precision:.3}, held-out \
precision is {ho_precision:.3} (gap {gap:.3}) — the training-selected \
threshold likely overfit the training gold labels"
)
});
let note = match (&leakage_note, &overfit_note) {
(Some(lk), Some(of)) => Some(format!("{lk} {of}")),
(Some(lk), None) => Some(lk.clone()),
(None, Some(of)) => Some(of.clone()),
(None, None) => None,
};
return Some(CalibrationResult {
keep_threshold: t,
drop_threshold,
decision_score_name,
achieved_precision: ho_precision,
precision_ci_low,
precision_ci_high,
coverage: ho_coverage,
n_keep: ho_n_keep,
n_total: ho_n_total,
train_precision: Some(precision),
note,
group_leakage: leakage,
});
}
let (precision_ci_low, precision_ci_high) =
crate::scoring::wilson_ci(matches, n_keep, confidence_level);
return Some(CalibrationResult {
keep_threshold: t,
drop_threshold,
decision_score_name,
achieved_precision: precision,
precision_ci_low,
precision_ci_high,
coverage,
n_keep,
n_total,
train_precision: None,
note: Some(
"achieved_precision and its confidence interval were selected and measured \
on the same gold-labeled data; the threshold search makes this optimistic. \
Pass --heldout <path> for an independent estimate."
.to_string(),
),
group_leakage: None,
});
}
}
None
}