use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AblationSummary {
pub base_model: String,
pub full_mrr10: f64,
pub full_recall10: f64,
pub signal_impacts_mrr10: Vec<(String, f64)>,
}
impl std::fmt::Display for AblationSummary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "Ablation Summary ({})", self.base_model)?;
writeln!(f, " Full MRR@10: {:.4}", self.full_mrr10)?;
writeln!(f, " Full Recall@10: {:.4}", self.full_recall10)?;
writeln!(f, " Signal impacts (MRR@10 contribution):")?;
for (signal, impact) in &self.signal_impacts_mrr10 {
writeln!(f, " {signal:<12} {impact:+.4}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CandidateRow {
pub candidate: String,
pub embedding_model: String,
pub reranker_policy: String,
pub mrr_10: f64,
pub recall_10: f64,
pub ndcg_10: f64,
pub protected_categories_passed: bool,
pub gates_passed: bool,
pub host_rss_mib: f64,
pub gpu_vram_mib: f64,
pub p95_latency_ms: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BakeoffReport {
pub baseline_candidate: String,
pub rows: Vec<CandidateRow>,
pub winner: Option<String>,
pub conflict: bool,
pub conflict_reason: String,
}
impl BakeoffReport {
pub fn new(baseline: &str) -> Self {
Self {
baseline_candidate: baseline.to_string(),
rows: Vec::new(),
winner: None,
conflict: false,
conflict_reason: String::new(),
}
}
pub fn add(&mut self, row: CandidateRow) {
self.rows.push(row);
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RerankerDecision {
pub decision: String,
pub rationale: String,
pub configs: Vec<RerankerConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RerankerConfig {
pub name: String,
pub mrr_10: f64,
pub recall_10: f64,
pub memory_mib: f64,
pub passed: bool,
}
impl RerankerDecision {
pub fn new(decision: &str, rationale: &str) -> Self {
Self {
decision: decision.to_string(),
rationale: rationale.to_string(),
configs: Vec::new(),
}
}
pub fn add_config(&mut self, config: RerankerConfig) {
self.configs.push(config);
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ablation_summary_display() {
let summary = AblationSummary {
base_model: "test-model".to_string(),
full_mrr10: 0.85,
full_recall10: 0.90,
signal_impacts_mrr10: vec![("reranker".to_string(), 0.05), ("dense".to_string(), 0.02)],
};
let s = format!("{summary}");
assert!(s.contains("test-model"));
assert!(s.contains("0.8500"));
assert!(s.contains("reranker"));
}
#[test]
fn test_bakeoff_report_json() {
let mut report = BakeoffReport::new("baseline");
report.add(CandidateRow {
candidate: "test-candidate".to_string(),
embedding_model: "test-embedding".to_string(),
reranker_policy: "keep".to_string(),
mrr_10: 0.85,
recall_10: 0.90,
ndcg_10: 0.88,
protected_categories_passed: true,
gates_passed: true,
host_rss_mib: 300.0,
gpu_vram_mib: 0.0,
p95_latency_ms: 5.0,
});
let json = report.to_json().expect("json");
assert!(json.contains("\"baseline_candidate\""));
assert!(json.contains("test-candidate"));
assert!(json.contains("\"conflict\""));
assert!(json.contains("\"conflict_reason\""));
}
#[test]
fn test_reranker_decision_json() {
let mut decision =
RerankerDecision::new("keep", "Reranker contributes +5pp MRR@10 within budget");
decision.add_config(RerankerConfig {
name: "baseline-qwen3-reranker".to_string(),
mrr_10: 0.85,
recall_10: 0.90,
memory_mib: 1190.0,
passed: true,
});
let json = decision.to_json().expect("json");
assert!(json.contains("\"decision\""));
assert!(json.contains("keep"));
}
}