use crate::model::BuildConfig;
pub fn ratio(numerator: f64, denominator: f64) -> Option<f64> {
if denominator > 0.0 {
Some(numerator / denominator)
} else {
None
}
}
pub fn shrink_toward(observed_sum: f64, observed_weight: f64, alpha: f64, global_mean: f64) -> f64 {
(observed_sum + alpha * global_mean) / (observed_weight + alpha)
}
pub fn raw_score(
weighted_count: f64,
smoothed_success_rate: Option<f64>,
smoothed_mean_score: Option<f64>,
config: &BuildConfig,
) -> f64 {
let mut score = config.count_weight * (1.0 + weighted_count).ln();
if let Some(success) = smoothed_success_rate {
score += config.success_weight * success;
}
if let Some(mean_score) = smoothed_mean_score {
score += config.score_weight * mean_score;
}
score
}
pub fn normalize(raw_scores: &[f64]) -> Vec<f64> {
let sum: f64 = raw_scores.iter().sum();
if sum > 0.0 {
raw_scores.iter().map(|s| s / sum).collect()
} else if raw_scores.is_empty() {
Vec::new()
} else {
vec![1.0 / raw_scores.len() as f64; raw_scores.len()]
}
}
pub fn confidence(weighted_count: f64, k: f64) -> f64 {
weighted_count / (weighted_count + k)
}
pub fn effective_sample_size(sum_weights: f64, sum_weights_squared: f64) -> f64 {
if sum_weights_squared > 0.0 {
sum_weights * sum_weights / sum_weights_squared
} else {
0.0
}
}
pub fn wilson_lower_bound(successes: f64, effective_trials: f64, z: f64) -> Option<f64> {
if effective_trials <= 0.0 {
return None;
}
let p_hat = (successes / effective_trials).clamp(0.0, 1.0);
let z2 = z * z;
let denom = 1.0 + z2 / effective_trials;
let center = p_hat + z2 / (2.0 * effective_trials);
let margin = z
* ((p_hat * (1.0 - p_hat) / effective_trials)
+ z2 / (4.0 * effective_trials * effective_trials))
.sqrt();
Some(((center - margin) / denom).clamp(0.0, 1.0))
}
pub fn time_decay_multiplier(age_days: f64, half_life_days: f64) -> f64 {
0.5_f64.powf(age_days / half_life_days)
}
pub fn entropy_bits(distribution: &[f64]) -> f64 {
distribution
.iter()
.filter(|&&p| p > 0.0)
.map(|&p| -p * p.log2())
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shrink_toward_pulls_low_sample_rate_to_global_mean() {
let smoothed = shrink_toward(1.0, 1.0, 5.0, 0.3);
assert!((smoothed - 0.4166).abs() < 1e-3);
}
#[test]
fn shrink_toward_zero_trials_falls_back_to_global_mean() {
assert_eq!(shrink_toward(0.0, 0.0, 5.0, 0.3), 0.3);
}
#[test]
fn raw_score_drops_missing_components() {
let config = BuildConfig::default();
let count_only = raw_score(10.0, None, None, &config);
assert_eq!(count_only, config.count_weight * 11f64.ln());
}
#[test]
fn normalize_sums_to_one() {
let priors = normalize(&[1.0, 3.0]);
assert!((priors.iter().sum::<f64>() - 1.0).abs() < 1e-9);
assert!((priors[1] - 0.75).abs() < 1e-9);
}
#[test]
fn normalize_falls_back_to_uniform_when_all_zero() {
let priors = normalize(&[0.0, 0.0, 0.0]);
assert_eq!(priors, vec![1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]);
}
#[test]
fn confidence_grows_toward_one_with_more_samples() {
assert!((confidence(0.0, 20.0) - 0.0).abs() < 1e-9);
assert!(confidence(1000.0, 20.0) > 0.98);
}
#[test]
fn time_decay_multiplier_is_one_at_age_zero() {
assert_eq!(time_decay_multiplier(0.0, 30.0), 1.0);
}
#[test]
fn time_decay_multiplier_is_half_at_one_half_life() {
assert!((time_decay_multiplier(30.0, 30.0) - 0.5).abs() < 1e-9);
}
#[test]
fn time_decay_multiplier_is_quarter_at_two_half_lives() {
assert!((time_decay_multiplier(60.0, 30.0) - 0.25).abs() < 1e-9);
}
#[test]
fn entropy_is_zero_for_a_single_dominant_action() {
assert_eq!(entropy_bits(&[1.0, 0.0]), 0.0);
}
#[test]
fn entropy_is_maximal_for_a_uniform_two_way_split() {
assert!((entropy_bits(&[0.5, 0.5]) - 1.0).abs() < 1e-9);
}
#[test]
fn effective_sample_size_uniform_weights_equals_count() {
assert_eq!(effective_sample_size(5.0, 5.0), 5.0);
}
#[test]
fn effective_sample_size_nonuniform_weights_is_smaller_than_count() {
let n_eff = effective_sample_size(14.0, 104.0);
assert!(
n_eff < 5.0,
"n_eff={n_eff} should be well below the raw count 5"
);
assert!((n_eff - 196.0 / 104.0).abs() < 1e-9);
}
#[test]
fn effective_sample_size_zero_weight_is_zero() {
assert_eq!(effective_sample_size(0.0, 0.0), 0.0);
}
#[test]
fn wilson_lower_bound_is_conservative_for_one_of_one() {
let bound = wilson_lower_bound(1.0, 1.0, 1.96).unwrap();
assert!((bound - 0.2065).abs() < 1e-3);
}
#[test]
fn wilson_lower_bound_grows_with_more_consistent_data() {
let lucky = wilson_lower_bound(1.0, 1.0, 1.96).unwrap();
let proven = wilson_lower_bound(90.0, 100.0, 1.96).unwrap();
assert!(proven > lucky);
}
#[test]
fn wilson_lower_bound_all_failures_is_near_zero() {
let bound = wilson_lower_bound(0.0, 100.0, 1.96).unwrap();
assert!(bound < 0.05);
}
#[test]
fn wilson_lower_bound_none_when_no_effective_trials() {
assert_eq!(wilson_lower_bound(0.0, 0.0, 1.96), None);
}
#[test]
fn wilson_lower_bound_clamps_out_of_range_p_hat_instead_of_nan() {
let bound = wilson_lower_bound(2.0, 1.0, 1.96).unwrap();
assert!(bound.is_finite());
assert!((0.0..=1.0).contains(&bound));
}
}