use crate::error::{Error, Result};
use crate::hash::fnv1a;
use crate::model::DEFAULT_CONFIDENCE_Z;
use crate::score::effective_sample_size;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GateObservation {
pub candidate_id: String,
pub group_id: String,
pub features: BTreeMap<String, f64>,
pub gate_elo_delta: f64,
pub gate_games_played: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GateQuery {
pub features: BTreeMap<String, f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct GatePrediction {
pub expected_elo: f64,
pub interval_low: f64,
pub interval_high: f64,
pub probability_positive: f64,
pub missing_features: Vec<String>,
pub unknown_features: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct GateModelConfig {
pub lambda_grid: Vec<f64>,
pub cv_folds: usize,
pub calibration_bins: usize,
pub interval_z: f64,
}
pub fn default_gate_lambda_grid() -> Vec<f64> {
vec![
1e-4, 3e-4, 1e-3, 3e-3, 1e-2, 3e-2, 1e-1, 3e-1, 1.0, 3.0, 10.0, 30.0, 100.0,
]
}
impl Default for GateModelConfig {
fn default() -> Self {
Self {
lambda_grid: default_gate_lambda_grid(),
cv_folds: 5,
calibration_bins: 5,
interval_z: DEFAULT_CONFIDENCE_Z,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct GateCalibrationBin {
pub min_probability: f64,
pub max_probability: f64,
pub num_observations: u64,
pub empirical_positive_rate: Option<f64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct GateFitReport {
pub selected_lambda: f64,
pub cv_folds_used: usize,
pub num_observations: usize,
pub num_groups: usize,
pub weighted_rmse: f64,
pub calibration: Vec<GateCalibrationBin>,
}
#[derive(Debug, Clone)]
pub struct GateFitOutput {
pub model: GateModel,
pub report: GateFitReport,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct GateOofPrediction {
pub candidate_id: String,
pub group_id: String,
pub actual_elo: f64,
pub predicted_elo: f64,
pub prediction_stddev: f64,
pub interval_low: f64,
pub interval_high: f64,
pub probability_positive: f64,
pub residual: f64,
pub gate_games_played: f64,
pub outer_fold: usize,
pub inner_selected_lambda: f64,
}
#[derive(Debug, Clone)]
pub struct GateValidationOutput {
pub model: GateModel,
pub report: GateFitReport,
pub oof_predictions: Vec<GateOofPrediction>,
pub interval_level: f64,
}
#[derive(Debug, Clone)]
pub struct GateModel {
feature_names: Vec<String>,
feature_mean: Vec<f64>,
feature_std: Vec<f64>,
intercept: f64,
coefficients: Vec<f64>,
m: Vec<Vec<f64>>,
sigma2: f64,
intercept_variance_factor: f64,
interval_z: f64,
}
impl GateModel {
pub fn fit(
observations: &[GateObservation],
config: &GateModelConfig,
) -> Result<GateFitOutput> {
let validated = Self::fit_with_validation(observations, config)?;
Ok(GateFitOutput {
model: validated.model,
report: validated.report,
})
}
pub fn fit_with_validation(
observations: &[GateObservation],
config: &GateModelConfig,
) -> Result<GateValidationOutput> {
validate_config(config)?;
let feature_names = validate_observations(observations)?;
let num_features = feature_names.len();
let required = (num_features + 2).max(6);
if observations.len() < required {
return Err(Error::InsufficientGateObservations {
num_observations: observations.len(),
num_features,
required,
});
}
let all_rows: Vec<&GateObservation> = observations.iter().collect();
let num_groups = distinct_sorted(all_rows.iter().map(|o| o.group_id.as_str())).len();
if num_groups < 2 {
return Err(Error::InsufficientGateGroups { num_groups });
}
let (fold_ids, effective_folds) = assign_folds(&all_rows, config.cv_folds);
let (selected_lambda, _plain_rmse, _plain_predictions) = select_lambda(
&all_rows,
&feature_names,
&fold_ids,
effective_folds,
&config.lambda_grid,
);
let (folds_used, weighted_rmse, fold_predictions) = nested_cross_validate(
&all_rows,
&feature_names,
&fold_ids,
effective_folds,
&config.lambda_grid,
config.cv_folds,
);
let calibration = build_calibration(&fold_predictions, config.calibration_bins);
let oof_predictions = build_oof_table(&fold_predictions, config.interval_z);
let interval_level = 2.0 * standard_normal_cdf(config.interval_z) - 1.0;
let weights: Vec<f64> = observations.iter().map(|o| o.gate_games_played).collect();
let standardizer = Standardizer::fit(&all_rows, &feature_names, &weights);
let x: Vec<Vec<f64>> = observations
.iter()
.map(|o| standardizer.transform(&o.features, &feature_names).0)
.collect();
let y: Vec<f64> = observations.iter().map(|o| o.gate_elo_delta).collect();
let fit = fit_weighted_ridge(&x, &y, &weights, selected_lambda);
let model = GateModel {
feature_names,
feature_mean: standardizer.mean,
feature_std: standardizer.std,
intercept: fit.intercept,
coefficients: fit.coefficients,
m: fit.m,
sigma2: fit.sigma2,
intercept_variance_factor: fit.intercept_variance_factor,
interval_z: config.interval_z,
};
let report = GateFitReport {
selected_lambda,
cv_folds_used: folds_used,
num_observations: observations.len(),
num_groups,
weighted_rmse,
calibration,
};
Ok(GateValidationOutput {
model,
report,
oof_predictions,
interval_level,
})
}
pub fn predict(&self, query: &GateQuery) -> GatePrediction {
let (x, missing_features, unknown_features) = transform(
&query.features,
&self.feature_names,
&self.feature_mean,
&self.feature_std,
);
let expected_elo = self.intercept + dot(&x, &self.coefficients);
let variance =
predictive_variance(&self.m, &x, self.sigma2, self.intercept_variance_factor);
let sd = variance.sqrt();
let probability_positive = probability_positive(expected_elo, variance, sd);
GatePrediction {
expected_elo,
interval_low: expected_elo - self.interval_z * sd,
interval_high: expected_elo + self.interval_z * sd,
probability_positive,
missing_features,
unknown_features,
}
}
}
fn is_positive_finite(x: f64) -> bool {
x.is_finite() && x > 0.0
}
fn validate_config(config: &GateModelConfig) -> Result<()> {
if config.lambda_grid.is_empty() {
return Err(Error::InvalidConfig {
message: "gate lambda_grid must not be empty".to_string(),
});
}
if config.lambda_grid.iter().any(|&l| !is_positive_finite(l)) {
return Err(Error::InvalidConfig {
message: "gate lambda_grid values must be finite and > 0.0".to_string(),
});
}
if config.cv_folds < 2 {
return Err(Error::InvalidConfig {
message: "gate cv_folds must be >= 2".to_string(),
});
}
if config.calibration_bins == 0 {
return Err(Error::InvalidConfig {
message: "gate calibration_bins must be >= 1".to_string(),
});
}
if !is_positive_finite(config.interval_z) {
return Err(Error::InvalidConfig {
message: "gate interval_z must be finite and > 0.0".to_string(),
});
}
Ok(())
}
fn validate_observations(observations: &[GateObservation]) -> Result<Vec<String>> {
if observations.is_empty() {
return Err(Error::InsufficientGateObservations {
num_observations: 0,
num_features: 0,
required: 6,
});
}
let feature_names: Vec<String> = observations[0].features.keys().cloned().collect();
for obs in observations {
if !obs.gate_elo_delta.is_finite() {
return Err(Error::NonFiniteGateValue {
candidate_id: obs.candidate_id.clone(),
field: "gate_elo_delta".to_string(),
});
}
if !is_positive_finite(obs.gate_games_played) {
return Err(Error::NonPositiveGateWeight {
candidate_id: obs.candidate_id.clone(),
value: obs.gate_games_played,
});
}
let keys: Vec<String> = obs.features.keys().cloned().collect();
if keys != feature_names {
return Err(Error::InconsistentGateFeatures {
candidate_id: obs.candidate_id.clone(),
});
}
for (name, value) in &obs.features {
if !value.is_finite() {
return Err(Error::NonFiniteGateValue {
candidate_id: obs.candidate_id.clone(),
field: name.clone(),
});
}
}
}
Ok(feature_names)
}
fn distinct_sorted<'a>(ids: impl Iterator<Item = &'a str>) -> Vec<&'a str> {
let mut ids: Vec<&str> = ids.collect();
ids.sort_unstable();
ids.dedup();
ids
}
fn assign_folds(rows: &[&GateObservation], cv_folds: usize) -> (Vec<usize>, usize) {
let mut group_weight: BTreeMap<&str, f64> = BTreeMap::new();
for o in rows {
*group_weight.entry(o.group_id.as_str()).or_insert(0.0) += o.gate_games_played;
}
let effective_folds = cv_folds.min(group_weight.len());
let mut groups: Vec<(&str, f64)> = group_weight.into_iter().collect();
groups.sort_by(|(a_id, a_weight), (b_id, b_weight)| {
b_weight
.total_cmp(a_weight)
.then_with(|| fnv1a(a_id.as_bytes()).cmp(&fnv1a(b_id.as_bytes())))
.then_with(|| a_id.cmp(b_id))
});
let mut fold_load = vec![0.0_f64; effective_folds];
let mut group_fold: BTreeMap<&str, usize> = BTreeMap::new();
for (group_id, weight) in groups {
let fold = fold_load
.iter()
.enumerate()
.min_by(|(ia, wa), (ib, wb)| wa.total_cmp(wb).then_with(|| ia.cmp(ib)))
.map(|(idx, _)| idx)
.expect("effective_folds > 0: every row contributes at least one distinct group");
fold_load[fold] += weight;
group_fold.insert(group_id, fold);
}
let fold_ids = rows
.iter()
.map(|o| {
*group_fold
.get(o.group_id.as_str())
.expect("every row's group_id was inserted into group_fold above")
})
.collect();
(fold_ids, effective_folds)
}
struct Standardizer {
mean: Vec<f64>,
std: Vec<f64>,
}
impl Standardizer {
fn fit(rows: &[&GateObservation], feature_names: &[String], weights: &[f64]) -> Self {
let sum_w: f64 = weights.iter().sum();
let mean: Vec<f64> = feature_names
.iter()
.map(|name| {
let s: f64 = rows
.iter()
.zip(weights)
.map(|(r, w)| w * r.features[name])
.sum();
s / sum_w
})
.collect();
let std: Vec<f64> = feature_names
.iter()
.enumerate()
.map(|(j, name)| {
let m = mean[j];
let var: f64 = rows
.iter()
.zip(weights)
.map(|(r, w)| w * (r.features[name] - m).powi(2))
.sum::<f64>()
/ sum_w;
let sd = var.sqrt();
if sd > 1e-12 { sd } else { 1.0 }
})
.collect();
Standardizer { mean, std }
}
fn transform(
&self,
features: &BTreeMap<String, f64>,
feature_names: &[String],
) -> (Vec<f64>, Vec<String>, Vec<String>) {
transform(features, feature_names, &self.mean, &self.std)
}
}
fn transform(
features: &BTreeMap<String, f64>,
feature_names: &[String],
mean: &[f64],
std: &[f64],
) -> (Vec<f64>, Vec<String>, Vec<String>) {
let unknown_features: Vec<String> = features
.keys()
.filter(|k| !feature_names.iter().any(|n| &n == k))
.cloned()
.collect();
let mut missing_features = Vec::new();
let x: Vec<f64> = feature_names
.iter()
.enumerate()
.map(|(j, name)| match features.get(name) {
Some(&v) => (v - mean[j]) / std[j],
None => {
missing_features.push(name.clone());
0.0
}
})
.collect();
(x, missing_features, unknown_features)
}
fn dot(a: &[f64], b: &[f64]) -> f64 {
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
struct RidgeFit {
intercept: f64,
coefficients: Vec<f64>,
m: Vec<Vec<f64>>,
sigma2: f64,
intercept_variance_factor: f64,
}
fn fit_weighted_ridge(x_std: &[Vec<f64>], y: &[f64], weights: &[f64], lambda: f64) -> RidgeFit {
let n = x_std.len();
let p = x_std[0].len();
let raw_sum_w: f64 = weights.iter().sum();
let weights: Vec<f64> = weights.iter().map(|w| w * n as f64 / raw_sum_w).collect();
let weights = &weights;
let sum_w: f64 = weights.iter().sum(); let y_mean = weights.iter().zip(y).map(|(w, yy)| w * yy).sum::<f64>() / sum_w;
let y_centered: Vec<f64> = y.iter().map(|yy| yy - y_mean).collect();
let mut a = vec![vec![0.0; p]; p];
let mut b = vec![0.0; p];
for i in 0..n {
let w = weights[i];
for j in 0..p {
b[j] += w * x_std[i][j] * y_centered[i];
for k in 0..p {
a[j][k] += w * x_std[i][j] * x_std[i][k];
}
}
}
let m = invert_regularized(&a, lambda);
let coefficients: Vec<f64> = (0..p).map(|j| dot(&m[j], &b)).collect();
let mut rss = 0.0;
for i in 0..n {
let pred = y_mean + dot(&x_std[i], &coefficients);
let resid = y[i] - pred;
rss += weights[i] * resid * resid;
}
let sum_w_sq: f64 = weights.iter().map(|w| w * w).sum();
let n_eff = effective_sample_size(sum_w, sum_w_sq);
let df: f64 = m
.iter()
.zip(a.iter())
.map(|(m_row, a_row)| dot(m_row, a_row))
.sum();
let sigma2 = rss / (n_eff - 1.0 - df).max(1e-6);
let intercept_variance_factor = 1.0 / n_eff;
RidgeFit {
intercept: y_mean,
coefficients,
m,
sigma2,
intercept_variance_factor,
}
}
fn invert_regularized(a: &[Vec<f64>], lambda: f64) -> Vec<Vec<f64>> {
let p = a.len();
let mut aug = vec![vec![0.0; 2 * p]; p];
for i in 0..p {
for (j, &value) in a[i].iter().enumerate() {
aug[i][j] = value + if i == j { lambda } else { 0.0 };
}
aug[i][p + i] = 1.0;
}
for col in 0..p {
let pivot_row_index = (col..p)
.max_by(|&r1, &r2| aug[r1][col].abs().total_cmp(&aug[r2][col].abs()))
.expect("col..p is non-empty");
aug.swap(col, pivot_row_index);
let pivot = aug[col][col];
for v in aug[col].iter_mut() {
*v /= pivot;
}
let pivot_row_values = aug[col].clone();
for (r, row) in aug.iter_mut().enumerate() {
if r != col {
let factor = row[col];
if factor != 0.0 {
for (v, &pv) in row.iter_mut().zip(&pivot_row_values) {
*v -= factor * pv;
}
}
}
}
}
(0..p).map(|i| aug[i][p..2 * p].to_vec()).collect()
}
fn predictive_variance(
m: &[Vec<f64>],
x_std: &[f64],
sigma2: f64,
intercept_variance_factor: f64,
) -> f64 {
let p = x_std.len();
let mut q = 0.0;
for j in 0..p {
for k in 0..p {
q += x_std[j] * m[j][k] * x_std[k];
}
}
(sigma2 * (intercept_variance_factor + q)).max(0.0)
}
fn probability_positive(expected_elo: f64, variance: f64, sd: f64) -> f64 {
if variance > 0.0 {
standard_normal_cdf(expected_elo / sd)
} else if expected_elo > 0.0 {
1.0
} else if expected_elo < 0.0 {
0.0
} else {
0.5
}
}
fn standard_normal_cdf(z: f64) -> f64 {
0.5 * (1.0 + erf(z / std::f64::consts::SQRT_2))
}
fn erf(x: f64) -> f64 {
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x = x.abs();
let a1 = 0.254_829_592;
let a2 = -0.284_496_736;
let a3 = 1.421_413_741;
let a4 = -1.453_152_027;
let a5 = 1.061_405_429;
let p = 0.327_591_1;
let t = 1.0 / (1.0 + p * x);
let poly = ((((a5 * t + a4) * t) + a3) * t + a2) * t + a1;
let y = 1.0 - poly * t * (-x * x).exp();
sign * y
}
struct FoldPrediction {
candidate_id: String,
group_id: String,
actual_elo: f64,
predicted_elo: f64,
stddev: f64,
probability_positive: f64,
actual_positive: bool,
lambda: f64,
gate_games_played: f64,
outer_fold: usize,
}
fn fit_and_score_fold(
train_rows: &[&GateObservation],
val_rows: &[&GateObservation],
feature_names: &[String],
lambda: f64,
) -> (f64, f64, Vec<FoldPrediction>) {
let train_weights: Vec<f64> = train_rows.iter().map(|o| o.gate_games_played).collect();
let standardizer = Standardizer::fit(train_rows, feature_names, &train_weights);
let x_train: Vec<Vec<f64>> = train_rows
.iter()
.map(|o| standardizer.transform(&o.features, feature_names).0)
.collect();
let y_train: Vec<f64> = train_rows.iter().map(|o| o.gate_elo_delta).collect();
let fit = fit_weighted_ridge(&x_train, &y_train, &train_weights, lambda);
let mut sq_err_sum = 0.0;
let mut w_sum = 0.0;
let mut predictions = Vec::new();
for obs in val_rows {
let (x, _missing, _unknown) = standardizer.transform(&obs.features, feature_names);
let pred = fit.intercept + dot(&x, &fit.coefficients);
let variance = predictive_variance(&fit.m, &x, fit.sigma2, fit.intercept_variance_factor);
let sd = variance.sqrt();
let w = obs.gate_games_played;
sq_err_sum += w * (obs.gate_elo_delta - pred).powi(2);
w_sum += w;
predictions.push(FoldPrediction {
candidate_id: obs.candidate_id.clone(),
group_id: obs.group_id.clone(),
actual_elo: obs.gate_elo_delta,
predicted_elo: pred,
stddev: sd,
probability_positive: probability_positive(pred, variance, sd),
actual_positive: obs.gate_elo_delta > 0.0,
lambda,
gate_games_played: w,
outer_fold: 0,
});
}
(sq_err_sum, w_sum, predictions)
}
fn run_folds(
rows: &[&GateObservation],
fold_ids: &[usize],
effective_folds: usize,
feature_names: &[String],
lambda_for_fold: impl Fn(&[&GateObservation]) -> f64,
) -> (usize, f64, Vec<FoldPrediction>) {
let mut predictions = Vec::new();
let mut sq_err_sum = 0.0;
let mut w_sum = 0.0;
let mut folds_used = 0;
for fold in 0..effective_folds {
let train_rows: Vec<&GateObservation> = (0..rows.len())
.filter(|&i| fold_ids[i] != fold)
.map(|i| rows[i])
.collect();
let val_rows: Vec<&GateObservation> = (0..rows.len())
.filter(|&i| fold_ids[i] == fold)
.map(|i| rows[i])
.collect();
if train_rows.is_empty() || val_rows.is_empty() {
continue;
}
folds_used += 1;
let lambda = lambda_for_fold(&train_rows);
let (sq_err, w, fold_predictions) =
fit_and_score_fold(&train_rows, &val_rows, feature_names, lambda);
sq_err_sum += sq_err;
w_sum += w;
predictions.extend(fold_predictions.into_iter().map(|p| FoldPrediction {
outer_fold: fold,
..p
}));
}
let weighted_rmse = if w_sum > 0.0 {
(sq_err_sum / w_sum).sqrt()
} else {
f64::INFINITY
};
(folds_used, weighted_rmse, predictions)
}
fn select_lambda(
rows: &[&GateObservation],
feature_names: &[String],
fold_ids: &[usize],
effective_folds: usize,
lambda_grid: &[f64],
) -> (f64, f64, Vec<FoldPrediction>) {
let mut best: Option<(f64, f64, Vec<FoldPrediction>)> = None;
for &lambda in lambda_grid {
let (_folds_used, rmse, predictions) =
run_folds(rows, fold_ids, effective_folds, feature_names, |_| lambda);
let is_better = match &best {
None => true,
Some((_, best_rmse, _)) => rmse < *best_rmse,
};
if is_better {
best = Some((lambda, rmse, predictions));
}
}
best.expect("lambda_grid validated non-empty by validate_config")
}
fn most_conservative_lambda(lambda_grid: &[f64]) -> f64 {
lambda_grid
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max)
}
fn nested_cross_validate(
all_rows: &[&GateObservation],
feature_names: &[String],
outer_fold_ids: &[usize],
outer_effective_folds: usize,
lambda_grid: &[f64],
inner_cv_folds: usize,
) -> (usize, f64, Vec<FoldPrediction>) {
run_folds(
all_rows,
outer_fold_ids,
outer_effective_folds,
feature_names,
|outer_train_rows| {
let (inner_fold_ids, inner_effective_folds) =
assign_folds(outer_train_rows, inner_cv_folds);
if inner_effective_folds >= 2 {
select_lambda(
outer_train_rows,
feature_names,
&inner_fold_ids,
inner_effective_folds,
lambda_grid,
)
.0
} else {
most_conservative_lambda(lambda_grid)
}
},
)
}
fn build_calibration(predictions: &[FoldPrediction], bins: usize) -> Vec<GateCalibrationBin> {
let width = 1.0 / bins as f64;
let mut counts = vec![0u64; bins];
let mut positive_counts = vec![0u64; bins];
for p in predictions {
let idx = ((p.probability_positive / width) as usize).min(bins - 1);
counts[idx] += 1;
if p.actual_positive {
positive_counts[idx] += 1;
}
}
(0..bins)
.map(|i| GateCalibrationBin {
min_probability: i as f64 * width,
max_probability: (i as f64 + 1.0) * width,
num_observations: counts[i],
empirical_positive_rate: if counts[i] > 0 {
Some(positive_counts[i] as f64 / counts[i] as f64)
} else {
None
},
})
.collect()
}
fn build_oof_table(predictions: &[FoldPrediction], interval_z: f64) -> Vec<GateOofPrediction> {
let mut rows: Vec<GateOofPrediction> = predictions
.iter()
.map(|p| GateOofPrediction {
candidate_id: p.candidate_id.clone(),
group_id: p.group_id.clone(),
actual_elo: p.actual_elo,
predicted_elo: p.predicted_elo,
prediction_stddev: p.stddev,
interval_low: p.predicted_elo - interval_z * p.stddev,
interval_high: p.predicted_elo + interval_z * p.stddev,
probability_positive: p.probability_positive,
residual: p.actual_elo - p.predicted_elo,
gate_games_played: p.gate_games_played,
outer_fold: p.outer_fold,
inner_selected_lambda: p.lambda,
})
.collect();
rows.sort_by(|a, b| {
a.outer_fold
.cmp(&b.outer_fold)
.then_with(|| a.group_id.cmp(&b.group_id))
.then_with(|| a.candidate_id.cmp(&b.candidate_id))
});
rows
}
#[cfg(test)]
mod tests {
use super::*;
fn obs(candidate_id: &str, group_id: &str, x: f64, y: f64, games: f64) -> GateObservation {
let mut features = BTreeMap::new();
features.insert("x".to_string(), x);
GateObservation {
candidate_id: candidate_id.to_string(),
group_id: group_id.to_string(),
features,
gate_elo_delta: y,
gate_games_played: games,
}
}
fn linear_dataset() -> Vec<GateObservation> {
(0..12)
.map(|i| {
let x = i as f64;
let noise = if i % 2 == 0 { 1.0 } else { -1.0 };
obs(
&format!("c{i}"),
&format!("g{i}"),
x,
10.0 * x + noise,
100.0,
)
})
.collect()
}
fn logo_dataset() -> Vec<GateObservation> {
(0..12)
.map(|i| {
let x = i as f64;
let noise = if i % 2 == 0 { 1.0 } else { -1.0 };
obs(
&format!("c{i}"),
&format!("g{}", i / 3),
x,
10.0 * x + noise,
100.0,
)
})
.collect()
}
#[test]
fn standard_normal_cdf_matches_known_values() {
assert!((standard_normal_cdf(0.0) - 0.5).abs() < 1e-6);
assert!((standard_normal_cdf(1.96) - 0.975).abs() < 1e-3);
assert!((standard_normal_cdf(-1.96) - 0.025).abs() < 1e-3);
}
#[test]
fn invert_regularized_matches_hand_computed_2x2() {
let a = vec![vec![2.0, 0.0], vec![0.0, 2.0]];
let m = invert_regularized(&a, 1.0);
assert!((m[0][0] - 1.0 / 3.0).abs() < 1e-9);
assert!((m[1][1] - 1.0 / 3.0).abs() < 1e-9);
assert!(m[0][1].abs() < 1e-9);
assert!(m[1][0].abs() < 1e-9);
}
#[test]
fn fit_weighted_ridge_matches_hand_derived_single_feature_case() {
let x = vec![vec![-1.0], vec![0.0], vec![1.0]];
let y = vec![-2.0, 0.0, 2.0];
let w = vec![1.0, 1.0, 1.0];
let fit = fit_weighted_ridge(&x, &y, &w, 2.0);
assert!((fit.coefficients[0] - 1.0).abs() < 1e-9);
assert!((fit.intercept - 0.0).abs() < 1e-9);
assert!((fit.sigma2 - 2.0 / 1.5).abs() < 1e-9);
assert!((fit.intercept_variance_factor - 1.0 / 3.0).abs() < 1e-9);
}
#[test]
fn fit_weighted_ridge_is_invariant_to_uniform_weight_magnitude_at_a_fixed_lambda() {
let x = vec![vec![-1.0], vec![0.0], vec![1.0]];
let y = vec![-2.0, 0.5, 2.0]; let uniform_1 = fit_weighted_ridge(&x, &y, &[1.0, 1.0, 1.0], 2.0);
let uniform_100 = fit_weighted_ridge(&x, &y, &[100.0, 100.0, 100.0], 2.0);
assert!((uniform_1.coefficients[0] - uniform_100.coefficients[0]).abs() < 1e-9);
assert!((uniform_1.intercept - uniform_100.intercept).abs() < 1e-9);
assert!((uniform_1.sigma2 - uniform_100.sigma2).abs() < 1e-9);
assert!(
(uniform_1.intercept_variance_factor - uniform_100.intercept_variance_factor).abs()
< 1e-9
);
}
#[test]
fn fit_weighted_ridge_preserves_relative_weight_ratios() {
let x = vec![vec![-1.0], vec![0.0], vec![1.0]];
let y = vec![-2.0, 0.5, 2.0];
let ratio_2_1 = fit_weighted_ridge(&x, &y, &[2.0, 2.0, 1.0], 2.0);
let same_ratio_scaled = fit_weighted_ridge(&x, &y, &[200.0, 200.0, 100.0], 2.0);
assert!((ratio_2_1.coefficients[0] - same_ratio_scaled.coefficients[0]).abs() < 1e-9);
assert!((ratio_2_1.intercept - same_ratio_scaled.intercept).abs() < 1e-9);
}
#[test]
fn predictive_variance_grows_further_from_training_data() {
let m = invert_regularized(&[vec![4.0]], 1.0); let near = predictive_variance(&m, &[0.5], 1.0, 0.0);
let far = predictive_variance(&m, &[5.0], 1.0, 0.0);
assert!(far > near);
}
#[test]
fn predictive_variance_includes_intercept_uncertainty_even_at_x_zero() {
let m = invert_regularized(&[vec![4.0]], 1.0);
let at_mean = predictive_variance(&m, &[0.0], 1.0, 0.2);
assert!(at_mean.is_finite());
assert!(at_mean > 0.0);
assert!((at_mean - 0.2).abs() < 1e-12); }
#[test]
fn fit_selects_a_lambda_from_the_grid_and_reports_positive_rmse() {
let output = GateModel::fit(&linear_dataset(), &GateModelConfig::default()).unwrap();
assert!(
GateModelConfig::default()
.lambda_grid
.contains(&output.report.selected_lambda)
);
assert!(output.report.weighted_rmse.is_finite());
assert!(output.report.weighted_rmse >= 0.0);
assert_eq!(output.report.num_observations, 12);
assert_eq!(output.report.num_groups, 12);
assert_eq!(output.report.calibration.len(), 5);
}
#[test]
fn fit_is_deterministic() {
let data = linear_dataset();
let a = GateModel::fit(&data, &GateModelConfig::default()).unwrap();
let b = GateModel::fit(&data, &GateModelConfig::default()).unwrap();
assert_eq!(a.report.selected_lambda, b.report.selected_lambda);
assert!((a.report.weighted_rmse - b.report.weighted_rmse).abs() < 1e-12);
}
#[test]
fn fit_selected_lambda_is_invariant_to_the_uniform_magnitude_of_gate_games_played() {
let mut few_games = linear_dataset();
for o in &mut few_games {
o.gate_games_played = 1.0;
}
let mut many_games = linear_dataset();
for o in &mut many_games {
o.gate_games_played = 100.0;
}
let a = GateModel::fit(&few_games, &GateModelConfig::default()).unwrap();
let b = GateModel::fit(&many_games, &GateModelConfig::default()).unwrap();
assert_eq!(a.report.selected_lambda, b.report.selected_lambda);
assert!((a.report.weighted_rmse - b.report.weighted_rmse).abs() < 1e-9);
}
#[test]
fn predict_on_a_strongly_positive_candidate_favors_positive_probability() {
let output = GateModel::fit(&linear_dataset(), &GateModelConfig::default()).unwrap();
let mut features = BTreeMap::new();
features.insert("x".to_string(), 20.0); let prediction = output.model.predict(&GateQuery { features });
assert!(prediction.expected_elo > 0.0);
assert!(prediction.probability_positive > 0.5);
assert!(prediction.interval_low < prediction.expected_elo);
assert!(prediction.interval_high > prediction.expected_elo);
assert!(prediction.missing_features.is_empty());
assert!(prediction.unknown_features.is_empty());
}
#[test]
fn predict_reports_missing_and_unknown_features_without_erroring() {
let output = GateModel::fit(&linear_dataset(), &GateModelConfig::default()).unwrap();
let mut features = BTreeMap::new();
features.insert("y_never_trained_on".to_string(), 1.0); let prediction = output.model.predict(&GateQuery { features });
assert_eq!(prediction.missing_features, vec!["x".to_string()]);
assert_eq!(
prediction.unknown_features,
vec!["y_never_trained_on".to_string()]
);
assert!(prediction.expected_elo.is_finite()); }
#[test]
fn missing_feature_prediction_equals_explicit_prediction_at_the_training_mean() {
let output = GateModel::fit(&linear_dataset(), &GateModelConfig::default()).unwrap();
let missing = output.model.predict(&GateQuery {
features: BTreeMap::new(),
});
let mut at_mean = BTreeMap::new();
at_mean.insert("x".to_string(), 5.5); let explicit = output.model.predict(&GateQuery { features: at_mean });
assert_eq!(missing.missing_features, vec!["x".to_string()]);
assert!((missing.expected_elo - explicit.expected_elo).abs() < 1e-9);
assert!((missing.interval_low - explicit.interval_low).abs() < 1e-9);
assert!((missing.interval_high - explicit.interval_high).abs() < 1e-9);
assert!((missing.probability_positive - explicit.probability_positive).abs() < 1e-9);
}
#[test]
fn predictive_variance_at_the_training_feature_mean_is_finite_and_positive() {
let output = GateModel::fit(&linear_dataset(), &GateModelConfig::default()).unwrap();
let mut features = BTreeMap::new();
features.insert("x".to_string(), 5.5); let prediction = output.model.predict(&GateQuery { features });
let half_width = prediction.interval_high - prediction.expected_elo;
assert!(half_width.is_finite());
assert!(half_width > 0.0);
}
#[test]
fn missing_all_features_query_does_not_get_a_zero_width_interval() {
let output = GateModel::fit(&linear_dataset(), &GateModelConfig::default()).unwrap();
let prediction = output.model.predict(&GateQuery {
features: BTreeMap::new(),
});
assert_eq!(prediction.missing_features, vec!["x".to_string()]);
assert!(prediction.interval_high > prediction.interval_low);
assert!((prediction.interval_high - prediction.interval_low).is_finite());
}
#[test]
fn fit_falls_back_to_conservative_lambda_when_an_outer_folds_training_rows_have_too_few_groups_for_inner_cv()
{
let mut data = Vec::new();
for i in 0..6 {
data.push(obs(
&format!("a{i}"),
"gA",
i as f64,
10.0 * i as f64,
100.0,
));
}
for i in 0..6 {
data.push(obs(
&format!("b{i}"),
"gB",
i as f64 + 6.0,
10.0 * (i as f64 + 6.0),
100.0,
));
}
let output = GateModel::fit(&data, &GateModelConfig::default()).unwrap();
assert_eq!(output.report.num_groups, 2);
assert_eq!(output.report.cv_folds_used, 2);
assert!(output.report.weighted_rmse.is_finite());
assert_eq!(output.report.calibration.len(), 5);
}
#[test]
fn assign_folds_never_splits_a_group_across_folds() {
let data = [
obs("c0", "gA", 0.0, 1.0, 10.0),
obs("c1", "gA", 1.0, 2.0, 10.0),
obs("c2", "gB", 2.0, 3.0, 10.0),
obs("c3", "gB", 3.0, 4.0, 10.0),
obs("c4", "gC", 4.0, 5.0, 10.0),
obs("c5", "gC", 5.0, 6.0, 10.0),
];
let rows: Vec<&GateObservation> = data.iter().collect();
let (fold_ids, effective_folds) = assign_folds(&rows, 5);
assert_eq!(effective_folds, 3);
assert_eq!(fold_ids[0], fold_ids[1]); assert_eq!(fold_ids[2], fold_ids[3]); assert_eq!(fold_ids[4], fold_ids[5]); assert_ne!(fold_ids[0], fold_ids[2]);
assert_ne!(fold_ids[2], fold_ids[4]);
}
#[test]
fn assign_folds_balanced_branch_also_keeps_a_group_together() {
let data: Vec<GateObservation> = (0..20)
.map(|i| {
obs(
&format!("c{i}"),
&format!("g{}", i % 5),
i as f64,
i as f64,
10.0,
)
})
.collect();
let rows: Vec<&GateObservation> = data.iter().collect();
let (fold_ids, effective_folds) = assign_folds(&rows, 5);
assert_eq!(effective_folds, 5);
for group in 0..5 {
let rows_in_group: Vec<usize> = (0..20).filter(|&i| i % 5 == group).collect();
let folds: Vec<usize> = rows_in_group.iter().map(|&i| fold_ids[i]).collect();
assert!(
folds.iter().all(|&f| f == folds[0]),
"group g{group}'s rows landed in different folds: {folds:?}"
);
}
}
#[test]
fn every_requested_fold_is_non_empty_when_enough_groups_exist() {
let data = linear_dataset();
let output = GateModel::fit_with_validation(&data, &GateModelConfig::default()).unwrap();
assert_eq!(output.report.cv_folds_used, 5);
let observed_folds: std::collections::BTreeSet<usize> = output
.oof_predictions
.iter()
.map(|p| p.outer_fold)
.collect();
let expected_folds: std::collections::BTreeSet<usize> = (0..5).collect();
assert_eq!(observed_folds, expected_folds);
assert_eq!(output.oof_predictions.len(), data.len());
let mut got: Vec<&str> = output
.oof_predictions
.iter()
.map(|p| p.candidate_id.as_str())
.collect();
got.sort_unstable();
let mut expected: Vec<&str> = data.iter().map(|o| o.candidate_id.as_str()).collect();
expected.sort_unstable();
assert_eq!(got, expected);
}
#[test]
fn assign_folds_balances_fold_weight_within_the_largest_group_weight() {
let mut data = Vec::new();
for i in 0..5 {
data.push(obs(
&format!("whale{i}"),
&format!("gWhale{i}"),
i as f64,
i as f64,
1000.0,
));
}
for i in 0..8 {
data.push(obs(
&format!("small{i}"),
&format!("gSmall{i}"),
i as f64,
i as f64,
10.0,
));
}
let rows: Vec<&GateObservation> = data.iter().collect();
let (fold_ids, effective_folds) = assign_folds(&rows, 4);
assert_eq!(effective_folds, 4);
let mut fold_weight = vec![0.0; effective_folds];
for (i, o) in data.iter().enumerate() {
fold_weight[fold_ids[i]] += o.gate_games_played;
}
let total_weight: f64 = data.iter().map(|o| o.gate_games_played).sum();
let max_group_weight = 1000.0;
let bound = total_weight / effective_folds as f64 + max_group_weight;
let max_fold_weight = fold_weight.iter().copied().fold(f64::MIN, f64::max);
let min_fold_weight = fold_weight.iter().copied().fold(f64::MAX, f64::min);
assert!(
max_fold_weight > min_fold_weight + 1e-9,
"fixture didn't stress the bound: every fold landed at the same weight {fold_weight:?}"
);
for (fold, &w) in fold_weight.iter().enumerate() {
assert!(
w <= bound + 1e-9,
"fold {fold} weight {w} exceeds the LPT bound {bound}"
);
assert!(w > 0.0, "fold {fold} is empty");
}
}
#[test]
fn assign_folds_is_deterministic_and_input_order_invariant() {
let mut data = Vec::new();
let per_group_row_weights = [
[3.1, 3.0, 2.9], [13.3, 13.0, 12.7], [23.4, 23.0, 22.6], [33.5, 33.0, 32.5], ];
for (g, weights) in per_group_row_weights.iter().enumerate() {
for (r, &w) in weights.iter().enumerate() {
data.push(obs(
&format!("c{g}_{r}"),
&format!("g{g}"),
g as f64,
g as f64,
w,
));
}
}
let rows: Vec<&GateObservation> = data.iter().collect();
let (fold_ids_a, effective_a) = assign_folds(&rows, 3);
let mut shuffled = data.clone();
shuffled.reverse();
shuffled.swap(0, 6);
shuffled.swap(2, 9);
shuffled.swap(4, 11);
let shuffled_rows: Vec<&GateObservation> = shuffled.iter().collect();
let (fold_ids_b, effective_b) = assign_folds(&shuffled_rows, 3);
assert_eq!(effective_a, effective_b);
let group_fold_a: BTreeMap<&str, usize> = data
.iter()
.zip(&fold_ids_a)
.map(|(o, &f)| (o.group_id.as_str(), f))
.collect();
let group_fold_b: BTreeMap<&str, usize> = shuffled
.iter()
.zip(&fold_ids_b)
.map(|(o, &f)| (o.group_id.as_str(), f))
.collect();
assert_eq!(group_fold_a, group_fold_b);
}
#[test]
fn fit_rejects_a_single_group() {
let data: Vec<GateObservation> = (0..10)
.map(|i| obs(&format!("c{i}"), "only_group", i as f64, i as f64, 10.0))
.collect();
let err = GateModel::fit(&data, &GateModelConfig::default()).unwrap_err();
assert!(matches!(
err,
Error::InsufficientGateGroups { num_groups: 1 }
));
}
#[test]
fn fit_rejects_too_few_observations_for_the_feature_count() {
let data = vec![
obs("c0", "g0", 0.0, 0.0, 10.0),
obs("c1", "g1", 1.0, 1.0, 10.0),
];
let err = GateModel::fit(&data, &GateModelConfig::default()).unwrap_err();
assert!(matches!(
err,
Error::InsufficientGateObservations {
num_observations: 2,
..
}
));
}
#[test]
fn fit_rejects_inconsistent_feature_sets() {
let mut data = linear_dataset();
data[3].features.insert("extra".to_string(), 1.0);
let err = GateModel::fit(&data, &GateModelConfig::default()).unwrap_err();
assert!(matches!(err, Error::InconsistentGateFeatures { .. }));
}
#[test]
fn fit_rejects_non_finite_label() {
let mut data = linear_dataset();
data[0].gate_elo_delta = f64::NAN;
let err = GateModel::fit(&data, &GateModelConfig::default()).unwrap_err();
assert!(matches!(err, Error::NonFiniteGateValue { .. }));
}
#[test]
fn fit_rejects_non_positive_weight() {
let mut data = linear_dataset();
data[0].gate_games_played = 0.0;
let err = GateModel::fit(&data, &GateModelConfig::default()).unwrap_err();
assert!(matches!(err, Error::NonPositiveGateWeight { .. }));
}
#[test]
fn fit_rejects_invalid_config() {
let data = linear_dataset();
let err = GateModel::fit(
&data,
&GateModelConfig {
lambda_grid: vec![],
..GateModelConfig::default()
},
)
.unwrap_err();
assert!(matches!(err, Error::InvalidConfig { .. }));
let err = GateModel::fit(
&data,
&GateModelConfig {
lambda_grid: vec![0.0],
..GateModelConfig::default()
},
)
.unwrap_err();
assert!(matches!(err, Error::InvalidConfig { .. }));
}
#[test]
fn calibration_bins_are_always_the_configured_length() {
let output = GateModel::fit(
&linear_dataset(),
&GateModelConfig {
calibration_bins: 4,
..GateModelConfig::default()
},
)
.unwrap();
assert_eq!(output.report.calibration.len(), 4);
}
#[test]
fn oof_predictions_cover_every_observation_exactly_once_under_leave_one_group_out() {
let data = logo_dataset();
let output = GateModel::fit_with_validation(&data, &GateModelConfig::default()).unwrap();
assert_eq!(output.oof_predictions.len(), data.len());
let mut got: Vec<&str> = output
.oof_predictions
.iter()
.map(|p| p.candidate_id.as_str())
.collect();
got.sort_unstable();
let mut expected: Vec<&str> = data.iter().map(|o| o.candidate_id.as_str()).collect();
expected.sort_unstable();
assert_eq!(got, expected);
}
#[test]
fn oof_predictions_never_train_on_their_own_group_under_leave_one_group_out() {
let data = logo_dataset();
let output = GateModel::fit_with_validation(&data, &GateModelConfig::default()).unwrap();
let mut by_fold: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
for p in &output.oof_predictions {
by_fold.entry(p.outer_fold).or_default().push(&p.group_id);
}
for (fold, groups) in &by_fold {
assert!(
groups.iter().all(|g| *g == groups[0]),
"fold {fold} mixes groups: {groups:?}"
);
}
}
#[test]
fn inner_selected_lambda_is_uniform_within_an_outer_fold() {
let data = logo_dataset();
let output = GateModel::fit_with_validation(&data, &GateModelConfig::default()).unwrap();
let mut by_fold: BTreeMap<usize, Vec<f64>> = BTreeMap::new();
for p in &output.oof_predictions {
by_fold
.entry(p.outer_fold)
.or_default()
.push(p.inner_selected_lambda);
}
for (fold, lambdas) in &by_fold {
assert!(
lambdas.iter().all(|&l| (l - lambdas[0]).abs() < 1e-12),
"fold {fold} used more than one lambda: {lambdas:?}"
);
}
}
#[test]
fn weighted_rmse_is_exactly_reproducible_from_oof_predictions() {
let output =
GateModel::fit_with_validation(&linear_dataset(), &GateModelConfig::default()).unwrap();
let sq_err_sum: f64 = output
.oof_predictions
.iter()
.map(|p| p.gate_games_played * p.residual * p.residual)
.sum();
let w_sum: f64 = output
.oof_predictions
.iter()
.map(|p| p.gate_games_played)
.sum();
let recomputed = (sq_err_sum / w_sum).sqrt();
assert!((recomputed - output.report.weighted_rmse).abs() < 1e-9);
}
#[test]
fn calibration_bins_are_exactly_reproducible_from_oof_predictions() {
let output =
GateModel::fit_with_validation(&linear_dataset(), &GateModelConfig::default()).unwrap();
let bins = output.report.calibration.len();
let width = 1.0 / bins as f64;
let mut counts = vec![0u64; bins];
let mut positive_counts = vec![0u64; bins];
for p in &output.oof_predictions {
let idx = ((p.probability_positive / width) as usize).min(bins - 1);
counts[idx] += 1;
if p.actual_elo > 0.0 {
positive_counts[idx] += 1;
}
}
for (i, bin) in output.report.calibration.iter().enumerate() {
assert_eq!(bin.num_observations, counts[i]);
let expected_rate = if counts[i] > 0 {
Some(positive_counts[i] as f64 / counts[i] as f64)
} else {
None
};
assert_eq!(bin.empirical_positive_rate, expected_rate);
}
}
#[test]
fn interval_width_matches_the_configured_interval_level() {
let config = GateModelConfig::default();
let output = GateModel::fit_with_validation(&linear_dataset(), &config).unwrap();
let expected_level = 2.0 * standard_normal_cdf(config.interval_z) - 1.0;
assert!((output.interval_level - expected_level).abs() < 1e-9);
for p in &output.oof_predictions {
assert!(p.interval_low <= p.predicted_elo);
assert!(p.predicted_elo <= p.interval_high);
if p.prediction_stddev > 0.0 {
let implied_z = (p.interval_high - p.interval_low) / (2.0 * p.prediction_stddev);
assert!((implied_z - config.interval_z).abs() < 1e-9);
}
}
}
#[test]
fn oof_predictions_are_ordered_deterministically_regardless_of_input_order() {
let data = linear_dataset();
let mut reversed = data.clone();
reversed.reverse();
let a = GateModel::fit_with_validation(&data, &GateModelConfig::default()).unwrap();
let b = GateModel::fit_with_validation(&reversed, &GateModelConfig::default()).unwrap();
let keys = |predictions: &[GateOofPrediction]| -> Vec<(usize, String, String)> {
predictions
.iter()
.map(|p| (p.outer_fold, p.group_id.clone(), p.candidate_id.clone()))
.collect()
};
assert_eq!(keys(&a.oof_predictions), keys(&b.oof_predictions));
assert_eq!(a.oof_predictions.len(), b.oof_predictions.len());
for (pa, pb) in a.oof_predictions.iter().zip(&b.oof_predictions) {
assert_eq!(pa.candidate_id, pb.candidate_id);
assert!((pa.predicted_elo - pb.predicted_elo).abs() < 1e-9);
}
}
#[test]
fn duplicate_candidate_ids_are_preserved_not_collapsed() {
let mut data = logo_dataset();
data[0].candidate_id = "dup".to_string(); data[3].candidate_id = "dup".to_string(); let output = GateModel::fit_with_validation(&data, &GateModelConfig::default()).unwrap();
assert_eq!(output.oof_predictions.len(), data.len());
let dup_count = output
.oof_predictions
.iter()
.filter(|p| p.candidate_id == "dup")
.count();
assert_eq!(dup_count, 2);
}
}