From cee70c627244033353d46b5936dcf03664bada84 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 24 Apr 2026 08:17:14 +0200 Subject: [PATCH] feat(factor): implement TeamSumFactor Computes the weighted sum of player performance Gaussians into a team-performance variable. Runs once per game (no iteration needed). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/factor/team_sum.rs | 85 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/src/factor/team_sum.rs b/src/factor/team_sum.rs index 1619ce0..9af7538 100644 --- a/src/factor/team_sum.rs +++ b/src/factor/team_sum.rs @@ -1,8 +1,14 @@ use crate::{ + N_INF, N00, factor::{Factor, VarId, VarStore}, gaussian::Gaussian, }; +/// Computes the weighted sum of player performances into a team-perf var. +/// +/// Inputs are pre-computed player performance Gaussians (i.e., player priors +/// already with beta² noise added via `Player::performance()`). The factor +/// runs once per game and writes the weighted sum to the output var. #[derive(Debug)] pub(crate) struct TeamSumFactor { pub(crate) inputs: Vec<(Gaussian, f64)>, @@ -10,7 +16,82 @@ pub(crate) struct TeamSumFactor { } impl Factor for TeamSumFactor { - fn propagate(&mut self, _vars: &mut VarStore) -> (f64, f64) { - unimplemented!("TeamSumFactor stub — implemented in Task 4") + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { + let perf = self.inputs.iter().fold(N00, |acc, (g, w)| acc + (*g * *w)); + let old = vars.get(self.out); + vars.set(self.out, perf); + old.delta(perf) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_player_unit_weight() { + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let g = Gaussian::from_ms(25.0, 5.0); + let mut f = TeamSumFactor { + inputs: vec![(g, 1.0)], + out, + }; + + f.propagate(&mut vars); + let result = vars.get(out); + assert!((result.mu() - 25.0).abs() < 1e-12); + assert!((result.sigma() - 5.0).abs() < 1e-12); + } + + #[test] + fn two_players_summed() { + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let g1 = Gaussian::from_ms(20.0, 3.0); + let g2 = Gaussian::from_ms(30.0, 4.0); + let mut f = TeamSumFactor { + inputs: vec![(g1, 1.0), (g2, 1.0)], + out, + }; + + f.propagate(&mut vars); + let result = vars.get(out); + // sum: mu = 20 + 30 = 50, var = 9 + 16 = 25, sigma = 5 + assert!((result.mu() - 50.0).abs() < 1e-12); + assert!((result.sigma() - 5.0).abs() < 1e-12); + } + + #[test] + fn weighted_inputs() { + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let g = Gaussian::from_ms(10.0, 2.0); + let mut f = TeamSumFactor { + inputs: vec![(g, 2.0)], + out, + }; + + f.propagate(&mut vars); + let result = vars.get(out); + // g * 2.0: mu = 10*2 = 20, sigma = 2*2 = 4 + assert!((result.mu() - 20.0).abs() < 1e-12); + assert!((result.sigma() - 4.0).abs() < 1e-12); + } + + #[test] + fn delta_is_zero_on_repeat_propagate() { + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let g = Gaussian::from_ms(5.0, 1.0); + let mut f = TeamSumFactor { + inputs: vec![(g, 1.0)], + out, + }; + + f.propagate(&mut vars); + let (dmu, dsig) = f.propagate(&mut vars); + assert!(dmu < 1e-12, "expected ~0 delta on repeat, got {}", dmu); + assert!(dsig < 1e-12); } }