1
Fork 0

add variance measurement via deepseek

This commit is contained in:
Andy Killorin 2026-11-10 08:26:17 -05:00
parent 57e9bcab90
commit 764536be7a
Signed by: ank
GPG key ID: 23F9463ECB67FE8C
2 changed files with 135 additions and 3 deletions

23
simulator/equal.toml Normal file
View file

@ -0,0 +1,23 @@
food_amount = 100
view_dist = 200
red_count = 10
blu_count = 10
[weapon]
kills_user = false
type = "ball"
radius = 200
[red_policy]
aggressive = false
food = [1,0]
kin = [-0.3,0.02]
nonkin = [-0.3,-0.02]
none= [0.3, 0.01]
[blu_policy]
aggressive = false
food = [1,0]
kin = [-0.3,0.02]
nonkin = [-0.3,-0.02]
none= [0.3, 0.01]

View file

@ -661,6 +661,85 @@ fn load_config(path: &std::path::Path) -> SimConfig {
toml::from_str(&text).unwrap()
}
/// Online mean/variance accumulator (Welford's algorithm), used to report how
/// much headless outcomes vary from seed to seed rather than only their mean.
#[derive(Debug, Clone, Copy, Default)]
struct Variance {
n: u64,
mean: f64,
m2: f64,
}
impl Variance {
fn push(&mut self, x: f64) {
self.n += 1;
let delta = x - self.mean;
self.mean += delta / self.n as f64;
self.m2 += delta * (x - self.mean);
}
/// Unbiased sample variance (`n - 1` denominator); `None` for `n < 2`.
fn sample_variance(&self) -> Option<f64> {
(self.n > 1).then(|| self.m2 / (self.n - 1) as f64)
}
fn std_dev(&self) -> Option<f64> {
self.sample_variance().map(f64::sqrt)
}
fn std_err(&self) -> Option<f64> {
self.std_dev().map(|s| s / (self.n as f64).sqrt())
}
/// Confidence interval for the mean, using `z` as the critical value of the
/// standard normal distribution (e.g. 1.96 for ~95%). Normal is a good
/// approximation here because the harness normally runs many samples.
fn confidence_interval(&self, z: f64) -> Option<(f64, f64)> {
self.std_err()
.map(|se| (self.mean - z * se, self.mean + z * se))
}
fn describe(&self) -> String {
match (self.sample_variance(), self.std_dev(), self.confidence_interval(1.96)) {
(Some(var), Some(sd), Some((lo, hi))) => format!(
"{:.4} (sd {sd:.4}, var {var:.4}, 95% CI [{lo:.4}, {hi:.4}])",
self.mean
),
_ => format!("{:.4} (n={}, no variance estimate)", self.mean, self.n),
}
}
}
/// Summarise per-seed headless runs in terms of each team's proportion of the
/// initial food, including the variance of those proportions and of the RED-BLU
/// difference (which is what a fair match should centre on zero).
fn print_run_stats(results: &[(i32, i32)], food_amount: usize) {
if food_amount == 0 {
println!("no food configured (food_amount = 0); nothing to measure");
return;
}
let mut red = Variance::default();
let mut blu = Variance::default();
let mut diff = Variance::default();
for &(r, b) in results {
let r_share = r as f64 / food_amount as f64;
let b_share = b as f64 / food_amount as f64;
red.push(r_share);
blu.push(b_share);
diff.push(r_share - b_share);
}
let winner = if red.mean > blu.mean { "red" } else { "blue" };
println!(
"{winner} wins on average over {} runs! (share of initial {food_amount} food)",
results.len().max(1)
);
println!(" RED share: {}", red.describe());
println!(" BLU share: {}", blu.describe());
println!(" RED-BLU share: {}", diff.describe());
}
fn print_average(red: i32, blu: i32, iters: u64) {
let iters = iters.max(1) as f32;
let red = red as f32 / iters;
@ -677,11 +756,11 @@ fn main() {
if cli.no_gui {
let config = load_config(&cli.config);
let iters = cli.run_count.unwrap_or(1);
let (red, blu) = (0..iters)
let results: Vec<(i32, i32)> = (0..iters)
.into_par_iter()
.map(|s| run_headless_simulation(s, &config))
.reduce(|| (0, 0), |a, b| (a.0 + b.0, a.1 + b.1));
print_average(red, blu, iters);
.collect();
print_run_stats(&results, config.food_amount);
} else {
macroquad::Window::new("Adversarial Foraging Simulator", gui_main(cli));
}
@ -712,6 +791,10 @@ mod tests {
toml::from_str(include_str!("../conf.toml")).unwrap()
}
fn equal_config() -> SimConfig {
toml::from_str(include_str!("../equal.toml")).unwrap()
}
/// The analytic replacements must agree with parry bit-for-bit, both on
/// whether a contact exists and on the reported `point1.distance(origin)`.
/// This is the load-bearing test behind the whole speedup: the hot path no
@ -789,6 +872,32 @@ mod tests {
assert_eq!(blu, 9088, "blu total over 200 seeds");
}
/// `equal.toml` gives both teams identical policies and mirrored starting
/// geometry, so both teams should collect the same proportion of the
/// initial food. Verify that zero lies inside the 95% confidence interval
/// of the RED-BLU proportion difference.
#[test]
fn equal_config_is_balanced() {
let config = equal_config();
let mut diff = Variance::default();
for seed in 0..200u64 {
let (red, blu) = run_headless_simulation(seed, &config);
let r_share = red as f64 / config.food_amount as f64;
let b_share = blu as f64 / config.food_amount as f64;
diff.push(r_share - b_share);
}
let (lo, hi) = diff
.confidence_interval(1.96)
.expect("need at least two samples for a confidence interval");
assert!(
lo <= 0.0 && 0.0 <= hi,
"RED-BLU food share mean {:.4} with 95% CI [{lo:.4}, {hi:.4}] excludes zero",
diff.mean,
);
}
/// Same seed twice must produce the same result (no hidden global state).
#[test]
fn deterministic() {