feat(runner): Monte Carlo runner, closed-form metrics and balance CLI

Adds the S6 layer on top of the deterministic combat engine:
- metrics.py: closed-form binomial statistics (win rate, standard error,
  3-sigma confidence band) with clamped degenerate proportions.
- runner.py: EncounterSpec/Side definitions, per-run SeededRng streams,
  global duplicate-id disambiguation, attrition averages per combatant.
- cli.py: pf1e-sim entry point producing a French balance report with
  win rates, 3-sigma bands, draws, rounds, and attrition.
- Movement fix: greedy straight-line heuristic could oscillate at walls;
  _step_toward now follows the true shortest path via an unbounded
  Dijkstra cost field from the target (Grid.reachable budget=None).
- Regression tests: melee unit routes around a wall and engages; grid
  unbounded-budget coverage.
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 577aec33c4
commit a21e234b41
10 changed files with 625 additions and 7 deletions
+32
View File
@@ -0,0 +1,32 @@
"""Closed-form statistics for Monte Carlo balance reports.
Proportions follow the binomial model: p = wins/n with standard error
sqrt(p(1-p)/n). The 3-sigma band is the default confidence interval used in
the CLI report; clamp keeps degenerate proportions (0 or 1) exact.
"""
from __future__ import annotations
import math
def win_rate(wins: int, runs: int) -> float:
"""Fraction of runs won: wins / runs."""
if runs <= 0:
msg = "runs must be positive"
raise ValueError(msg)
return wins / runs
def win_rate_sigma(p: float, runs: int) -> float:
"""Standard error of a proportion: sqrt(p(1-p)/n)."""
if runs <= 0:
msg = "runs must be positive"
raise ValueError(msg)
return math.sqrt(p * (1 - p) / runs)
def win_rate_band(p: float, runs: int, *, sigma_count: float = 3.0) -> tuple[float, float]:
"""p +/- sigma_count standard errors, clamped to [0, 1]."""
sigma = win_rate_sigma(p, runs)
return (max(0.0, p - sigma_count * sigma), min(1.0, p + sigma_count * sigma))