use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use rustev_contract::calibration::{
CalibrationArtifact, CalibrationBinding, CalibrationMethod, CalibrationParameters,
};
use rustev_contract::decimal::Decimal;
use rustev_contract::eval_report::Split;
use rustev_contract::ids::{
ArtifactId, CalibrationId, ContentDigest, DatasetId, EvaluatorConfigId, SnapshotId,
};
use rustev_contract::limits::REPLAY_V1;
use rustev_contract::output::RawOutput;
use rustev_contract::{Identified, schema};
use rustev_core::calibrate::{self, BindingCheck, CalibrationInput};
use serde::{Deserialize, Serialize};
use crate::config::{EvaluatorConfig, MAX_GRID};
use crate::dataset::{DatasetManifest, Label};
use crate::doc::eval_document;
pub const CALIBRATION_FIT: &str = "rustev.calibration-fit/1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CalibrationFit {
pub schema: String,
pub calibration: CalibrationId,
pub dataset: DatasetId,
pub split: Split,
pub config: EvaluatorConfigId,
pub sources: Vec<String>,
pub cases: Vec<String>,
pub snapshots: Vec<SnapshotId>,
pub grid: Vec<Decimal>,
pub fitted: u64,
pub excluded: BTreeMap<String, u64>,
pub temperature: Decimal,
pub mean_log_loss: Decimal,
}
eval_document!(CalibrationFit, CALIBRATION_FIT, REPLAY_V1, ContentDigest);
pub struct FitTarget<'a> {
pub artifact: &'a ArtifactId,
pub task: &'a str,
pub question: &'a str,
pub options: &'a [String],
pub tolerance: Decimal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FitError {
Grid,
WrongSplit {
case: String,
},
UnknownCase {
case: String,
},
Empty,
NonFinite {
case: String,
},
Inconsistent {
case: String,
},
ZeroSupport {
case: String,
},
NoFiniteLoss,
Label {
case: String,
},
Dataset(crate::dataset::DatasetError),
DuplicateSample {
case: String,
},
Loss,
}
impl fmt::Display for FitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self:?}")
}
}
impl std::error::Error for FitError {}
fn artifact_at(target: &FitTarget<'_>, dataset: &DatasetId, t: Decimal) -> CalibrationArtifact {
CalibrationArtifact {
schema: schema::CALIBRATION.into(),
binding: CalibrationBinding {
artifact: target.artifact.clone(),
task: target.task.into(),
question: target.question.into(),
dataset: dataset.clone(),
method: CalibrationMethod::Temperature1,
},
options: target.options.to_vec(),
parameters: CalibrationParameters::Temperature { temperature: t },
}
}
fn to_decimal(x: f64) -> Result<Decimal, FitError> {
Decimal::parse(&format!("{x:.9}")).map_err(|_| FitError::Loss)
}
pub fn fit_temperature(
manifest: &DatasetManifest,
config: &EvaluatorConfig,
target: &FitTarget<'_>,
samples: &[(String, RawOutput)],
) -> Result<(CalibrationArtifact, CalibrationFit), FitError> {
let grid = &config.temperature_grid;
if grid.is_empty() || grid.len() > MAX_GRID || config.check().is_err() {
return Err(FitError::Grid);
}
manifest.check_structure().map_err(FitError::Dataset)?;
let dataset = manifest.id().map_err(|_| FitError::Grid)?;
let config_id = config.id().map_err(|_| FitError::Grid)?;
let by_id: BTreeMap<&str, _> = manifest.cases.iter().map(|c| (c.id.as_str(), c)).collect();
let options: BTreeSet<&str> = target.options.iter().map(String::as_str).collect();
let mut rows = vec![];
let mut excluded = BTreeMap::new();
let mut seen = BTreeSet::new();
for (id, raw) in samples {
if !seen.insert(id.as_str()) {
return Err(FitError::DuplicateSample { case: id.clone() });
}
let case = by_id
.get(id.as_str())
.ok_or_else(|| FitError::UnknownCase { case: id.clone() })?;
if case.split != Split::Calibration {
return Err(FitError::WrongSplit { case: id.clone() });
}
let Label::Value(label) = &case.label else {
*excluded.entry("unlabeled".to_string()).or_insert(0u64) += 1;
continue;
};
if !options.contains(label.as_str()) {
return Err(FitError::Label { case: id.clone() });
}
let m = match raw {
RawOutput::Logits(m) | RawOutput::Distribution(m) => m,
_ => return Err(FitError::Inconsistent { case: id.clone() }),
};
if m.keys().map(String::as_str).collect::<BTreeSet<_>>() != options {
return Err(FitError::Inconsistent { case: id.clone() });
}
if m.values().any(|v| !v.is_finite()) {
return Err(FitError::NonFinite { case: id.clone() });
}
if matches!(raw, RawOutput::Distribution(_)) && m[label] <= 0.0 {
return Err(FitError::ZeroSupport { case: id.clone() });
}
rows.push((*case, label.clone(), raw.clone()));
}
let unsampled = manifest
.split(Split::Calibration)
.filter(|c| !seen.contains(c.id.as_str()))
.count() as u64;
if unsampled > 0 {
excluded.insert("no-sample".to_string(), unsampled);
}
if rows.is_empty() {
return Err(FitError::Empty);
}
let check = BindingCheck {
artifact: target.artifact,
task: target.task,
question: target.question,
options: target.options,
};
let mut best: Option<(Decimal, f64)> = None;
for t in grid {
let art = artifact_at(target, &dataset, *t);
let Ok(cid) = art.id() else {
return Err(FitError::Grid);
};
let mut sum = 0.0;
for (case, label, raw) in &rows {
let input = match raw {
RawOutput::Logits(m) => CalibrationInput::Logits(m),
RawOutput::Distribution(m) => CalibrationInput::Distribution(m),
_ => unreachable!("checked above"),
};
let p =
calibrate::apply(&art, &cid, &check, input, target.tolerance).map_err(|_| {
FitError::Inconsistent {
case: case.id.clone(),
}
})?;
let q = p.mass(label).unwrap_or(0.0);
sum += if q > 0.0 {
-rustev_core::math::ln(q)
} else {
f64::INFINITY
};
}
let loss = sum / rows.len() as f64;
if loss.is_finite() && best.is_none_or(|(_, b)| loss < b) {
best = Some((*t, loss));
}
}
let (t, loss) = best.ok_or(FitError::NoFiniteLoss)?;
let artifact = artifact_at(target, &dataset, t);
let mut sources: Vec<String> = rows.iter().map(|(c, ..)| c.source.clone()).collect();
let mut cases: Vec<String> = rows.iter().map(|(c, ..)| c.id.clone()).collect();
let mut snapshots: Vec<SnapshotId> = rows.iter().map(|(c, ..)| c.snapshot.clone()).collect();
for v in [&mut sources, &mut cases] {
v.sort();
v.dedup();
}
snapshots.sort();
snapshots.dedup();
let fit = CalibrationFit {
schema: CALIBRATION_FIT.into(),
calibration: artifact.id().map_err(|_| FitError::Grid)?,
dataset,
split: Split::Calibration,
config: config_id,
sources,
cases,
snapshots,
grid: grid.clone(),
fitted: rows.len() as u64,
excluded,
temperature: t,
mean_log_loss: to_decimal(loss)?,
};
Ok((artifact, fit))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Qualification {
Qualified,
Unknown {
reason: String,
},
Refused {
reason: String,
},
}
pub fn qualify(
artifact: &CalibrationArtifact,
fit: Option<&CalibrationFit>,
evaluation: &DatasetManifest,
split: Split,
) -> Qualification {
if let Err(e) = evaluation.check_structure() {
return Qualification::Refused {
reason: format!("the evaluation manifest is refused: {e}"),
};
}
let Some(fit) = fit else {
return Qualification::Unknown {
reason: "no fit record: lineage is missing".into(),
};
};
if artifact.id().ok().as_ref() != Some(&fit.calibration) {
return Qualification::Unknown {
reason: "the fit record is for another artifact".into(),
};
}
let same_dataset = evaluation.id().ok().as_ref() == Some(&fit.dataset);
if same_dataset && split == fit.split {
return Qualification::Refused {
reason: "evaluated on the split it was fitted on".into(),
};
}
for c in evaluation.split(split) {
if (same_dataset && fit.cases.contains(&c.id))
|| fit.sources.contains(&c.source)
|| fit.snapshots.contains(&c.snapshot)
{
return Qualification::Refused {
reason: format!("case {} shares a fit source", c.id),
};
}
}
Qualification::Qualified
}