use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Gates {
pub aggregate_mrr10_max_regression: f64,
pub protected_category_max_regression_pp: f64,
pub p95_latency_max_regression_pct: f64,
pub wall_time_max_regression_pct: f64,
pub zero_result_forbidden: bool,
pub variance_bands: VarianceBands,
pub committed_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VarianceBands {
pub aggregate_mrr10_stddev: f64,
pub aggregate_recall10_stddev: f64,
pub per_category_mrr10_stddev: HashMap<String, f64>,
pub p95_latency_stddev_ms: f64,
pub wall_time_stddev_secs: f64,
pub baseline_run_count: usize,
}
impl Default for Gates {
fn default() -> Self {
Self {
aggregate_mrr10_max_regression: 0.0,
protected_category_max_regression_pp: 1.0,
p95_latency_max_regression_pct: 0.0,
wall_time_max_regression_pct: 0.0,
zero_result_forbidden: true,
variance_bands: VarianceBands::default(),
committed_at: chrono::Utc::now(),
}
}
}
impl Gates {
pub fn with_variance_bands(mut self, bands: VarianceBands) -> Self {
self.variance_bands = bands;
self
}
pub fn check(&self, results: &GateCandidateResults) -> Result<(), GateFailure> {
let mrr_threshold =
self.aggregate_mrr10_max_regression + self.variance_bands.aggregate_mrr10_stddev;
if results.aggregate_mrr10_regression > mrr_threshold {
return Err(GateFailure {
gate: GateType::AggregateMrr10Regression,
threshold: mrr_threshold,
observed: results.aggregate_mrr10_regression,
message: format!(
"Aggregate MRR@10 regression {:.6} exceeds allowed {:.6} \
(gate {:.6} + variance band {:.6})",
results.aggregate_mrr10_regression,
mrr_threshold,
self.aggregate_mrr10_max_regression,
self.variance_bands.aggregate_mrr10_stddev,
),
});
}
let protected_threshold = self.protected_category_max_regression_pp / 100.0; for (category, regression) in &results.per_category_mrr10_regression {
if self.is_protected_category(category) {
let band = self
.variance_bands
.per_category_mrr10_stddev
.get(category)
.copied()
.unwrap_or(0.0);
let allowed = protected_threshold + band;
if *regression > allowed {
return Err(GateFailure {
gate: GateType::ProtectedCategoryRegression(category.clone()),
threshold: allowed,
observed: *regression,
message: format!(
"Protected category '{}' MRR@10 regression {:.4} ({}pp) \
exceeds allowed {:.4} ({:.1}pp + band {:.4})",
category,
*regression,
*regression * 100.0,
allowed,
self.protected_category_max_regression_pp,
band,
),
});
}
}
}
if results.p95_latency_regression_pct > self.p95_latency_max_regression_pct {
return Err(GateFailure {
gate: GateType::P95LatencyRegression,
threshold: self.p95_latency_max_regression_pct,
observed: results.p95_latency_regression_pct,
message: format!(
"p95 latency regression {:.2}% exceeds allowed {:.2}%",
results.p95_latency_regression_pct, self.p95_latency_max_regression_pct,
),
});
}
if results.wall_time_regression_pct > self.wall_time_max_regression_pct {
return Err(GateFailure {
gate: GateType::WallTimeRegression,
threshold: self.wall_time_max_regression_pct,
observed: results.wall_time_regression_pct,
message: format!(
"Index wall-time regression {:.2}% exceeds allowed {:.2}%",
results.wall_time_regression_pct, self.wall_time_max_regression_pct,
),
});
}
if self.zero_result_forbidden && results.new_zero_result_count > 0 {
return Err(GateFailure {
gate: GateType::ZeroResult,
threshold: 0.0,
observed: results.new_zero_result_count as f64,
message: format!(
"Candidate produced {} new zero-result case(s); \
zero-result cases are forbidden (section 9.4)",
results.new_zero_result_count,
),
});
}
Ok(())
}
fn is_protected_category(&self, category: &str) -> bool {
matches!(
category,
"hard_negatives" | "changed_deleted_freshness" | "same_name_different_behavior"
)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum GateType {
AggregateMrr10Regression,
ProtectedCategoryRegression(String),
P95LatencyRegression,
WallTimeRegression,
ZeroResult,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GateCandidateResults {
pub aggregate_mrr10_regression: f64,
pub per_category_mrr10_regression: HashMap<String, f64>,
pub p95_latency_regression_pct: f64,
pub wall_time_regression_pct: f64,
pub new_zero_result_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GateFailure {
pub gate: GateType,
pub threshold: f64,
pub observed: f64,
pub message: String,
}
impl std::fmt::Display for GateFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for GateFailure {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaselineRunMetrics {
pub aggregate_mrr10: f64,
pub aggregate_recall10: f64,
pub per_category_mrr10: HashMap<String, f64>,
pub p95_latency_ms: f64,
pub wall_time_secs: f64,
}
pub fn compute_variance_bands(runs: &[BaselineRunMetrics]) -> VarianceBands {
if runs.is_empty() {
return VarianceBands::default();
}
let n = runs.len() as f64;
let mrr_mean = runs.iter().map(|r| r.aggregate_mrr10).sum::<f64>() / n;
let mrr_variance = runs
.iter()
.map(|r| (r.aggregate_mrr10 - mrr_mean).powi(2))
.sum::<f64>()
/ n;
let mrr_stddev = mrr_variance.sqrt();
let recall_mean = runs.iter().map(|r| r.aggregate_recall10).sum::<f64>() / n;
let recall_variance = runs
.iter()
.map(|r| (r.aggregate_recall10 - recall_mean).powi(2))
.sum::<f64>()
/ n;
let recall_stddev = recall_variance.sqrt();
let mut per_category_stddev: HashMap<String, f64> = HashMap::new();
let category_names: std::collections::HashSet<&String> = runs
.iter()
.flat_map(|r| r.per_category_mrr10.keys())
.collect();
for cat in category_names {
let values: Vec<f64> = runs
.iter()
.filter_map(|r| r.per_category_mrr10.get(cat))
.copied()
.collect();
if values.len() > 1 {
let mean = values.iter().sum::<f64>() / values.len() as f64;
let variance =
values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
per_category_stddev.insert(cat.clone(), variance.sqrt());
}
}
let p95_mean = runs.iter().map(|r| r.p95_latency_ms).sum::<f64>() / n;
let p95_variance = runs
.iter()
.map(|r| (r.p95_latency_ms - p95_mean).powi(2))
.sum::<f64>()
/ n;
let p95_stddev = p95_variance.sqrt();
let wall_mean = runs.iter().map(|r| r.wall_time_secs).sum::<f64>() / n;
let wall_variance = runs
.iter()
.map(|r| (r.wall_time_secs - wall_mean).powi(2))
.sum::<f64>()
/ n;
let wall_stddev = wall_variance.sqrt();
VarianceBands {
aggregate_mrr10_stddev: mrr_stddev,
aggregate_recall10_stddev: recall_stddev,
per_category_mrr10_stddev: per_category_stddev,
p95_latency_stddev_ms: p95_stddev,
wall_time_stddev_secs: wall_stddev,
baseline_run_count: runs.len(),
}
}
pub fn time_execution<F, R>(f: F) -> (R, Duration)
where
F: FnOnce() -> R,
{
let start = std::time::Instant::now();
let result = f();
(result, start.elapsed())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gates_default_values() {
let gates = Gates::default();
assert_eq!(gates.aggregate_mrr10_max_regression, 0.0);
assert_eq!(gates.protected_category_max_regression_pp, 1.0);
assert_eq!(gates.p95_latency_max_regression_pct, 0.0);
assert_eq!(gates.wall_time_max_regression_pct, 0.0);
assert!(gates.zero_result_forbidden);
}
#[test]
fn test_gates_serialize_roundtrip() {
let gates = Gates::default();
let json = serde_json::to_string(&gates).expect("serialize gates");
let deserialized: Gates = serde_json::from_str(&json).expect("deserialize gates");
assert_eq!(
deserialized.aggregate_mrr10_max_regression,
gates.aggregate_mrr10_max_regression
);
assert_eq!(
deserialized.protected_category_max_regression_pp,
gates.protected_category_max_regression_pp
);
}
#[test]
fn test_candidate_passing_all_gates() {
let gates = Gates::default();
let results = GateCandidateResults {
aggregate_mrr10_regression: 0.001, per_category_mrr10_regression: HashMap::new(),
p95_latency_regression_pct: 0.5,
wall_time_regression_pct: 0.3,
new_zero_result_count: 0,
};
let results_pass = GateCandidateResults {
aggregate_mrr10_regression: 0.0,
per_category_mrr10_regression: HashMap::new(),
p95_latency_regression_pct: 0.0,
wall_time_regression_pct: 0.0,
new_zero_result_count: 0,
};
assert!(gates.check(&results_pass).is_ok());
assert!(gates.check(&results).is_err());
}
#[test]
fn test_mrr_regression_fails_gate() {
let gates = Gates::default();
let results = GateCandidateResults {
aggregate_mrr10_regression: 0.05, per_category_mrr10_regression: HashMap::new(),
p95_latency_regression_pct: 0.0,
wall_time_regression_pct: 0.0,
new_zero_result_count: 0,
};
let failure = gates.check(&results).expect_err("should fail");
assert_eq!(failure.gate, GateType::AggregateMrr10Regression);
}
#[test]
fn test_protected_category_regression_fails() {
let gates = Gates::default();
let mut per_cat = HashMap::new();
per_cat.insert("hard_negatives".to_string(), 0.02);
let results = GateCandidateResults {
aggregate_mrr10_regression: 0.0,
per_category_mrr10_regression: per_cat,
p95_latency_regression_pct: 0.0,
wall_time_regression_pct: 0.0,
new_zero_result_count: 0,
};
let failure = gates.check(&results).expect_err("should fail");
assert!(matches!(
failure.gate,
GateType::ProtectedCategoryRegression(ref c) if c == "hard_negatives"
));
}
#[test]
fn test_non_protected_category_regression_passes() {
let gates = Gates::default();
let mut per_cat = HashMap::new();
per_cat.insert("nl_to_symbol".to_string(), 0.02);
let results = GateCandidateResults {
aggregate_mrr10_regression: 0.0,
per_category_mrr10_regression: per_cat,
p95_latency_regression_pct: 0.0,
wall_time_regression_pct: 0.0,
new_zero_result_count: 0,
};
assert!(gates.check(&results).is_ok());
}
#[test]
fn test_zero_result_fails() {
let gates = Gates::default();
let results = GateCandidateResults {
aggregate_mrr10_regression: 0.0,
per_category_mrr10_regression: HashMap::new(),
p95_latency_regression_pct: 0.0,
wall_time_regression_pct: 0.0,
new_zero_result_count: 1,
};
let failure = gates.check(&results).expect_err("should fail");
assert!(matches!(failure.gate, GateType::ZeroResult));
}
#[test]
fn test_variance_band_allows_noise() {
let gates = Gates {
aggregate_mrr10_max_regression: 0.0,
..Gates::default()
}
.with_variance_bands(VarianceBands {
aggregate_mrr10_stddev: 0.01,
..VarianceBands::default()
});
let results = GateCandidateResults {
aggregate_mrr10_regression: 0.005, per_category_mrr10_regression: HashMap::new(),
p95_latency_regression_pct: 0.0,
wall_time_regression_pct: 0.0,
new_zero_result_count: 0,
};
assert!(gates.check(&results).is_ok());
let results_outside = GateCandidateResults {
aggregate_mrr10_regression: 0.015, ..results
};
assert!(gates.check(&results_outside).is_err());
}
#[test]
fn test_latency_regression_fails() {
let gates = Gates::default();
let results = GateCandidateResults {
aggregate_mrr10_regression: 0.0,
per_category_mrr10_regression: HashMap::new(),
p95_latency_regression_pct: 10.0,
wall_time_regression_pct: 0.0,
new_zero_result_count: 0,
};
let failure = gates.check(&results).expect_err("should fail");
assert_eq!(failure.gate, GateType::P95LatencyRegression);
}
#[test]
fn test_wall_time_regression_fails() {
let gates = Gates::default();
let results = GateCandidateResults {
aggregate_mrr10_regression: 0.0,
per_category_mrr10_regression: HashMap::new(),
p95_latency_regression_pct: 0.0,
wall_time_regression_pct: 15.0,
new_zero_result_count: 0,
};
let failure = gates.check(&results).expect_err("should fail");
assert_eq!(failure.gate, GateType::WallTimeRegression);
}
#[test]
fn test_compute_variance_bands_from_runs() {
let runs = vec![
BaselineRunMetrics {
aggregate_mrr10: 0.80,
aggregate_recall10: 0.85,
per_category_mrr10: HashMap::new(),
p95_latency_ms: 100.0,
wall_time_secs: 10.0,
},
BaselineRunMetrics {
aggregate_mrr10: 0.82,
aggregate_recall10: 0.87,
per_category_mrr10: HashMap::new(),
p95_latency_ms: 105.0,
wall_time_secs: 10.5,
},
BaselineRunMetrics {
aggregate_mrr10: 0.81,
aggregate_recall10: 0.86,
per_category_mrr10: HashMap::new(),
p95_latency_ms: 102.0,
wall_time_secs: 10.2,
},
BaselineRunMetrics {
aggregate_mrr10: 0.795,
aggregate_recall10: 0.845,
per_category_mrr10: HashMap::new(),
p95_latency_ms: 98.0,
wall_time_secs: 9.9,
},
BaselineRunMetrics {
aggregate_mrr10: 0.805,
aggregate_recall10: 0.855,
per_category_mrr10: HashMap::new(),
p95_latency_ms: 101.0,
wall_time_secs: 10.1,
},
];
let bands = compute_variance_bands(&runs);
assert_eq!(bands.baseline_run_count, 5);
assert!(bands.aggregate_mrr10_stddev > 0.0);
assert!(bands.aggregate_mrr10_stddev < 0.02); assert!(bands.p95_latency_stddev_ms > 0.0);
}
#[test]
fn test_compute_variance_bands_empty() {
let bands = compute_variance_bands(&[]);
assert_eq!(bands.baseline_run_count, 0);
assert_eq!(bands.aggregate_mrr10_stddev, 0.0);
}
#[test]
fn test_protected_categories() {
let gates = Gates::default();
assert!(gates.is_protected_category("hard_negatives"));
assert!(gates.is_protected_category("changed_deleted_freshness"));
assert!(gates.is_protected_category("same_name_different_behavior"));
assert!(!gates.is_protected_category("nl_to_symbol"));
assert!(!gates.is_protected_category("concept_to_impl"));
}
#[test]
fn test_time_execution() {
let (_result, elapsed) = time_execution(|| {
std::thread::sleep(std::time::Duration::from_millis(10));
42
});
assert_eq!(_result, 42);
assert!(elapsed >= std::time::Duration::from_millis(8));
}
}