sba-scouting/rust/src/calc/matchup.rs
Cal Corum 3c0c206aba Add test scaffold and mark Phase 1 tasks complete
Set up lib.rs for integration test access, add 50 tests covering
calc engine (weights, standardization), model helpers (Player positions,
Lineup JSON roundtrips), and full query layer (in-memory SQLite).
Update PHASE1_PROJECT_PLAN.json to reflect all 12 tasks completed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:02:01 -06:00

132 lines
4.0 KiB
Rust

use super::league_stats::StatDistribution;
use super::weights::StatWeight;
/// Convert a raw stat value to a standardized score (-3 to +3).
///
/// Uses standard deviation thresholds from the league mean.
/// A value of 0 always returns +3 (best possible).
pub fn standardize_value(value: f64, distribution: &StatDistribution, high_is_better: bool) -> i32 {
if value == 0.0 {
return 3;
}
let avg = distribution.avg;
let stdev = distribution.stdev;
let base_score = if value > avg + 2.0 * stdev {
-3
} else if value > avg + stdev {
-2
} else if value > avg + 0.33 * stdev {
-1
} else if value > avg - 0.33 * stdev {
0
} else if value > avg - stdev {
1
} else if value > avg - 2.0 * stdev {
2
} else {
3
};
if high_is_better { -base_score } else { base_score }
}
/// Calculate weighted score for a single stat.
pub fn calculate_weighted_score(
value: f64,
distribution: &StatDistribution,
stat_weight: &StatWeight,
) -> f64 {
let std_score = standardize_value(value, distribution, stat_weight.high_is_better);
std_score as f64 * stat_weight.weight as f64
}
#[cfg(test)]
mod tests {
use super::*;
fn dist(avg: f64, stdev: f64) -> StatDistribution {
StatDistribution { avg, stdev }
}
// --- standardize_value tests ---
#[test]
fn zero_value_always_returns_3() {
let d = dist(5.0, 1.0);
assert_eq!(standardize_value(0.0, &d, true), 3);
assert_eq!(standardize_value(0.0, &d, false), 3);
}
#[test]
fn value_far_above_mean_is_minus3_base() {
// value > avg + 2*stdev => base_score = -3
let d = dist(5.0, 1.0);
// high_is_better=true: -(-3) = 3
assert_eq!(standardize_value(7.5, &d, true), 3);
// high_is_better=false: -3
assert_eq!(standardize_value(7.5, &d, false), -3);
}
#[test]
fn value_far_below_mean_is_plus3_base() {
// value <= avg - 2*stdev => base_score = 3
let d = dist(5.0, 1.0);
// high_is_better=true: -(3) = -3
assert_eq!(standardize_value(2.5, &d, true), -3);
// high_is_better=false: 3
assert_eq!(standardize_value(2.5, &d, false), 3);
}
#[test]
fn value_at_mean_returns_zero_base() {
// avg - 0.33*stdev < value < avg + 0.33*stdev => base_score = 0
let d = dist(5.0, 1.0);
assert_eq!(standardize_value(5.0, &d, true), 0);
assert_eq!(standardize_value(5.0, &d, false), 0);
}
#[test]
fn score_ranges_are_symmetric() {
// Walk through all 7 buckets with a clean distribution
let d = dist(10.0, 2.0);
// base_score mapping (raw, not flipped):
// > 14.0 (avg+2*std) => -3
// > 12.0 (avg+std) => -2
// > 10.66 (avg+0.33*std) => -1
// > 9.34 (avg-0.33*std) => 0
// > 8.0 (avg-std) => 1
// > 6.0 (avg-2*std) => 2
// <= 6.0 => 3
// Test with high_is_better=false (no flip)
assert_eq!(standardize_value(15.0, &d, false), -3);
assert_eq!(standardize_value(13.0, &d, false), -2);
assert_eq!(standardize_value(11.0, &d, false), -1);
assert_eq!(standardize_value(10.0, &d, false), 0);
assert_eq!(standardize_value(9.0, &d, false), 1);
assert_eq!(standardize_value(7.0, &d, false), 2);
assert_eq!(standardize_value(5.0, &d, false), 3);
}
// --- calculate_weighted_score tests ---
#[test]
fn weighted_score_multiplies_std_by_weight() {
let d = dist(10.0, 2.0);
let w = StatWeight { weight: 5, high_is_better: true };
// value=15.0 => base=-3, flipped=3 => 3*5=15.0
assert!((calculate_weighted_score(15.0, &d, &w) - 15.0).abs() < f64::EPSILON);
}
#[test]
fn weighted_score_zero_value() {
let d = dist(10.0, 2.0);
let w = StatWeight { weight: 3, high_is_better: false };
// value=0.0 => always 3 => 3*3=9.0
assert!((calculate_weighted_score(0.0, &d, &w) - 9.0).abs() < f64::EPSILON);
}
}