use crate::error::M1ndResult;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
pub const DEFAULT_TARGET_ALPHA: f32 = 0.1;
pub const CALIBRATION_SIGNAL_PREDICT: &str = "predict";
pub const CALIBRATION_SIGNAL_ENVELOPE: &str = "envelope";
pub const VERDICT_ACT: &str = "act";
pub const VERDICT_REVERIFY: &str = "reverify";
pub const VERDICT_ABSTAIN: &str = "abstain";
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CalibrationRow {
pub tau: f32,
pub target_alpha: f32,
pub measured_precision: f32,
pub coverage: f32,
pub n: usize,
pub calibrated_at_ms: u64,
}
impl CalibrationRow {
pub fn tau_low(&self) -> f32 {
self.tau * 0.5
}
pub fn verdict(&self, confidence: f32) -> &'static str {
verdict_for(confidence, self.tau, self.tau_low())
}
}
pub fn verdict_for(confidence: f32, tau: f32, tau_low: f32) -> &'static str {
if !confidence.is_finite() {
return VERDICT_ABSTAIN;
}
if confidence >= tau {
VERDICT_ACT
} else if confidence >= tau_low {
VERDICT_REVERIFY
} else {
VERDICT_ABSTAIN
}
}
pub fn conformal_quantile(scores: &[f32], alpha: f32) -> f32 {
let n = scores.len();
if n == 0 {
return 1.0;
}
let mut sorted: Vec<f32> = scores.iter().copied().filter(|s| s.is_finite()).collect();
let m = sorted.len();
if m == 0 {
return 1.0;
}
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let alpha = alpha.clamp(0.0, 1.0);
let rank = (((m + 1) as f32) * (1.0 - alpha)).ceil() as usize;
if rank >= m {
return 1.0;
}
sorted[rank.saturating_sub(1).min(m - 1)]
}
#[derive(Clone, Debug, Default)]
pub struct CalibrationTable {
rows: HashMap<String, CalibrationRow>,
}
impl CalibrationTable {
pub fn new() -> Self {
Self {
rows: HashMap::new(),
}
}
pub fn set(&mut self, signal: &str, row: CalibrationRow) {
self.rows.insert(signal.to_string(), row);
}
pub fn get(&self, signal: &str) -> Option<&CalibrationRow> {
self.rows.get(signal)
}
pub fn signals(&self) -> impl Iterator<Item = &str> {
self.rows.keys().map(String::as_str)
}
pub fn len(&self) -> usize {
self.rows.len()
}
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
}
#[derive(Serialize, Deserialize)]
struct CalibrationPersistenceFormat {
version: u32,
rows: HashMap<String, CalibrationRow>,
}
pub fn save_calibration_state(table: &CalibrationTable, path: &Path) -> M1ndResult<()> {
let format = CalibrationPersistenceFormat {
version: 1,
rows: table.rows.clone(),
};
let json = serde_json::to_string_pretty(&format).map_err(crate::error::M1ndError::Serde)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let temp_path = path.with_extension("tmp");
{
use std::io::Write;
let file = std::fs::File::create(&temp_path)?;
let mut writer = std::io::BufWriter::new(file);
writer.write_all(json.as_bytes())?;
writer.flush()?;
}
std::fs::rename(&temp_path, path)?;
Ok(())
}
pub fn load_calibration_state(path: &Path) -> M1ndResult<CalibrationTable> {
if !path.exists() {
return Ok(CalibrationTable::new());
}
let data = std::fs::read_to_string(path)?;
let format: CalibrationPersistenceFormat =
serde_json::from_str(&data).map_err(crate::error::M1ndError::Serde)?;
let mut valid_rows = HashMap::new();
for (signal, row) in format.rows {
if !row.tau.is_finite() || !row.measured_precision.is_finite() {
eprintln!(
"m1nd calibration: rejecting corrupt row for {}: non-finite fields",
signal
);
continue;
}
valid_rows.insert(signal, row);
}
Ok(CalibrationTable { rows: valid_rows })
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn sample_row() -> CalibrationRow {
CalibrationRow {
tau: 0.6,
target_alpha: DEFAULT_TARGET_ALPHA,
measured_precision: 0.85,
coverage: 0.4,
n: 100,
calibrated_at_ms: 1_700_000_000_000,
}
}
#[test]
fn conformal_quantile_exact_index_high_alpha_saturates_to_one() {
let scores = [
0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90f32,
];
let tau = conformal_quantile(&scores, 0.1);
assert_eq!(
tau, 1.0,
"rank ceil(11*0.9)=10 >= m=10 ⇒ τ saturates to 1.0"
);
}
#[test]
fn conformal_quantile_exact_index_mid_alpha() {
let scores = [
0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90f32,
];
let tau = conformal_quantile(&scores, 0.5);
assert_eq!(tau, 0.50, "rank ceil(11*0.5)=6 ⇒ 1-based[6]=index[5]=0.50");
}
#[test]
fn conformal_quantile_empty_is_one() {
let tau = conformal_quantile(&[], 0.1);
assert_eq!(tau, 1.0);
}
#[test]
fn verdict_bins_act_reverify_abstain() {
let row = sample_row(); assert_eq!(row.verdict(0.7), VERDICT_ACT);
assert_eq!(row.verdict(0.6), VERDICT_ACT); assert_eq!(row.verdict(0.45), VERDICT_REVERIFY);
assert_eq!(row.verdict(0.3), VERDICT_REVERIFY); assert_eq!(row.verdict(0.1), VERDICT_ABSTAIN);
}
#[test]
fn verdict_nan_confidence_abstains() {
let row = sample_row();
assert_eq!(row.verdict(f32::NAN), VERDICT_ABSTAIN);
assert_eq!(row.verdict(f32::INFINITY), VERDICT_ABSTAIN);
}
#[test]
fn save_load_round_trip() {
let mut table = CalibrationTable::new();
table.set("predict", sample_row());
let dir = std::env::temp_dir();
let path: PathBuf = dir.join(format!("calibration_test_{}.json", std::process::id()));
save_calibration_state(&table, &path).expect("save failed");
let loaded = load_calibration_state(&path).expect("load failed");
assert_eq!(loaded.get("predict"), Some(&sample_row()));
assert_eq!(loaded.len(), 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn save_load_round_trip_creates_parent_directories() {
let mut table = CalibrationTable::new();
table.set("predict", sample_row());
let dir = std::env::temp_dir().join(format!("calibration_nested_{}", std::process::id()));
let path: PathBuf = dir.join("deep").join("calibration_state.json");
save_calibration_state(&table, &path).expect("save failed");
let loaded = load_calibration_state(&path).expect("load failed");
assert_eq!(loaded.get("predict"), Some(&sample_row()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_missing_file_is_empty_table() {
let path = std::env::temp_dir().join("calibration_does_not_exist_xyz.json");
let _ = std::fs::remove_file(&path);
let table = load_calibration_state(&path).expect("load failed");
assert!(table.is_empty());
}
}