use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::knob::KnobValue;
use super::loop_runner::{CalibrationLoop, StepReport};
pub const HISTORY_SCHEMA_VERSION: &str = "1.0";
#[derive(Debug)]
pub enum HistoryError {
Io(std::io::Error),
Parse(serde_json::Error),
SchemaMismatch {
found: String,
expected: &'static str,
},
}
impl std::fmt::Display for HistoryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "history IO: {e}"),
Self::Parse(e) => write!(f, "history JSON parse: {e}"),
Self::SchemaMismatch { found, expected } => write!(
f,
"history schema mismatch: file declares {found}, runtime expects {expected}"
),
}
}
}
impl std::error::Error for HistoryError {}
impl From<std::io::Error> for HistoryError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<serde_json::Error> for HistoryError {
fn from(e: serde_json::Error) -> Self {
Self::Parse(e)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalibrationHistory {
pub schema_version: String,
pub objective_metric: String,
pub steps: Vec<StepReport>,
pub best_loss_mean: Option<f64>,
pub best_loss_std: Option<f64>,
pub best_knob_values: BTreeMap<String, KnobValue>,
}
impl CalibrationHistory {
pub fn from_loop(loop_: &CalibrationLoop) -> Self {
Self {
schema_version: HISTORY_SCHEMA_VERSION.to_string(),
objective_metric: loop_.objective.metric.name().to_string(),
steps: loop_.history.clone(),
best_loss_mean: loop_.best_loss.map(|(m, _)| m),
best_loss_std: loop_.best_loss.map(|(_, s)| s),
best_knob_values: loop_.best_knob_values.clone(),
}
}
pub fn save(&self, path: &Path) -> Result<(), HistoryError> {
let tmp = path.with_extension("tmp");
let json = serde_json::to_string_pretty(self)?;
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn load(path: &Path) -> Result<Self, HistoryError> {
let bytes = std::fs::read(path)?;
let parsed: Self = serde_json::from_slice(&bytes)?;
if parsed.schema_version != HISTORY_SCHEMA_VERSION {
return Err(HistoryError::SchemaMismatch {
found: parsed.schema_version,
expected: HISTORY_SCHEMA_VERSION,
});
}
Ok(parsed)
}
pub fn apply_to(&self, loop_: &mut CalibrationLoop) -> Result<(), HistoryError> {
if self.objective_metric != loop_.objective.metric.name() {
return Err(HistoryError::SchemaMismatch {
found: self.objective_metric.clone(),
expected: loop_.objective.metric.name(),
});
}
loop_.history = self.steps.clone();
loop_.best_loss = match (self.best_loss_mean, self.best_loss_std) {
(Some(m), Some(s)) => Some((m, s)),
(Some(m), None) => Some((m, 0.0)),
_ => None,
};
loop_.best_knob_values = self.best_knob_values.clone();
if let Some(last) = self.steps.last() {
for knob in &mut loop_.knobs {
if let Some(v) = last.knob_values.get(&knob.path) {
knob.current = *v;
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::calibration::knob::CalibrationKnob;
use crate::calibration::loop_runner::{CalibrationConfig, StepOutcome};
use crate::calibration::objective::CalibrationObjective;
use std::collections::BTreeMap;
use tempfile::TempDir;
fn empty_loop() -> CalibrationLoop {
CalibrationLoop::new(
CalibrationObjective::bf_composite(),
vec![CalibrationKnob::new_f64("test.rate", 0.10, 0.0, 1.0, 0.02)],
CalibrationConfig::default(),
)
}
fn fake_step(iter: usize, before: f64, after: f64, knob_value: f64) -> StepReport {
let mut kv = BTreeMap::new();
kv.insert("test.rate".to_string(), KnobValue::F64(knob_value));
StepReport {
iter,
loss_before_mean: before,
loss_before_std: 1.0,
proposed_patch: None,
loss_after_mean: Some(after),
loss_after_std: Some(1.0),
knob_values: kv,
outcome: StepOutcome::Improved,
}
}
#[test]
fn save_and_load_round_trips() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("calibration_history.json");
let mut loop_ = empty_loop();
loop_.history.push(fake_step(0, 50.0, 45.0, 0.08));
loop_.history.push(fake_step(1, 45.0, 40.0, 0.06));
loop_.best_loss = Some((40.0, 1.0));
loop_
.best_knob_values
.insert("test.rate".into(), KnobValue::F64(0.06));
let history = CalibrationHistory::from_loop(&loop_);
history.save(&path).unwrap();
let loaded = CalibrationHistory::load(&path).unwrap();
assert_eq!(loaded.schema_version, HISTORY_SCHEMA_VERSION);
assert_eq!(loaded.objective_metric, "bf_composite");
assert_eq!(loaded.steps.len(), 2);
assert_eq!(loaded.best_loss_mean, Some(40.0));
assert_eq!(
loaded.best_knob_values.get("test.rate"),
Some(&KnobValue::F64(0.06))
);
}
#[test]
fn schema_mismatch_rejected_on_load() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("history.json");
let bad = r#"{
"schema_version": "99.99",
"objective_metric": "bf_composite",
"steps": [],
"best_loss_mean": null,
"best_loss_std": null,
"best_knob_values": {}
}"#;
std::fs::write(&path, bad).unwrap();
let err = CalibrationHistory::load(&path).expect_err("schema must mismatch");
assert!(
matches!(err, HistoryError::SchemaMismatch { .. }),
"expected SchemaMismatch, got {err:?}"
);
}
#[test]
fn apply_to_restores_knob_state_and_history() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("h.json");
let mut src = empty_loop();
src.history.push(fake_step(0, 50.0, 45.0, 0.06));
src.best_loss = Some((45.0, 1.0));
src.best_knob_values
.insert("test.rate".into(), KnobValue::F64(0.06));
src.knobs[0].current = KnobValue::F64(0.06);
CalibrationHistory::from_loop(&src).save(&path).unwrap();
let mut dst = empty_loop();
assert_eq!(dst.knobs[0].current.as_f64(), 0.10);
assert!(dst.history.is_empty());
CalibrationHistory::load(&path)
.unwrap()
.apply_to(&mut dst)
.unwrap();
assert_eq!(dst.history.len(), 1);
assert_eq!(dst.best_loss, Some((45.0, 1.0)));
assert!(
(dst.knobs[0].current.as_f64() - 0.06).abs() < 1e-9,
"knob should resume to last-step value: got {}",
dst.knobs[0].current
);
}
#[test]
fn apply_to_rejects_objective_mismatch() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("h.json");
let src = empty_loop();
CalibrationHistory::from_loop(&src).save(&path).unwrap();
let mut dst = CalibrationLoop::new(
CalibrationObjective::default()
.with_metric(crate::calibration::ObjectiveMetric::BfCompositeMedian),
vec![CalibrationKnob::new_f64("test.rate", 0.10, 0.0, 1.0, 0.02)],
CalibrationConfig::default(),
);
let err = CalibrationHistory::load(&path)
.unwrap()
.apply_to(&mut dst)
.expect_err("objective mismatch must reject");
assert!(matches!(err, HistoryError::SchemaMismatch { .. }));
}
#[test]
fn save_uses_atomic_rename() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("history.json");
let tmp_path = path.with_extension("tmp");
let loop_ = empty_loop();
CalibrationHistory::from_loop(&loop_).save(&path).unwrap();
assert!(path.exists(), "target file should exist after save");
assert!(
!tmp_path.exists(),
"tmp staging file should be renamed away after save"
);
}
}