From db234a0a536bfcbdf0d7ccf4613fa8284a2ddcd5 Mon Sep 17 00:00:00 2001 From: Andy Killorin <37423245+Speedy6451@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:21:28 -0500 Subject: [PATCH] batch plotter via deepseek --- .gitignore | 4 + tools/README.md | 147 +++++++++++++++++ tools/plot_phase.py | 146 ++++++++++++++++ tools/sweep.py | 394 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 691 insertions(+) create mode 100644 .gitignore create mode 100644 tools/README.md create mode 100644 tools/plot_phase.py create mode 100644 tools/sweep.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7e8ff4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# grid-search / phase-diagram outputs +sweep_out/ +__pycache__/ +*.pyc diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..e95c873 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,147 @@ +# Optimal grid search over `conf.toml` space + +This directory contains a small pipeline for mapping the **phase diagram** of +the foraging simulator: which team wins as you vary two `conf.toml` parameters. +It is built for the specific experiment *red aggressive vs. blue pacifist, +varying `view_dist` and the ball weapon `radius`*, but generalises to any pair +of numeric fields. + +## The idea + +A naive sweep would run a fixed, large number of seeds for **every** grid point. +Most of that work is wasted: a point deep in red territory is decided after a +handful of seeds, while only points near the phase boundary need many. + +The pipeline therefore combines three things: + +1. **Sequential stopping.** Each grid point is evaluated in parallel batches of + seeds. After every batch we test the *n*-sigma stopping rule and stop as soon + as the outcome is certain (or a hard cap is hit). Decisive points cost ~64 + seeds; only boundary points pay for the full budget. +2. **Adaptive refinement.** After a coarse grid, cells whose four corners do not + agree on a winner are refined (centre + edge midpoints) so extra samples are + spent where the boundary actually is. Repeatable. +3. **Common random numbers.** Every point is evaluated with the *same* seed + sequence `0, 1, 2, …`, which removes seed-to-seed noise from comparisons + between neighbouring grid points and sharpens the apparent boundary. + +## The statistic and the stopping rule + +Each seed produces scores `(red, blu)` summing to at most `food_amount`. Define +the per-seed outcome + +``` +d = (red - blu) / food_amount # in [-1, 1], 0 == even match +``` + +`d > 0` means red out-foraged blue, `d < 0` means blue did. Across `N` seeds we +track the running mean `m`, sample standard deviation `s`, and standard error +`SE = s / sqrt(N)`. The stopping rule is + +``` +|m| / SE >= sigma (default sigma = 3) +``` + +i.e. the RED-BLU food-share difference is at least `sigma` standard errors from +zero. The winning side is the sign of `m`. A point that reaches +`--max-samples` without satisfying the rule is reported as **`tie`** +(undecided) — that is exactly the phase-boundary band. + +> **Caveat — optional stopping.** Looking at the data repeatedly and stopping at +> `n` sigma inflates the true type-I error (the "look-elsewhere" effect), so the +> nominal confidence is optimistic. For a phase *diagram* this is fine and +> standard; if you need calibrated error bars, raise `--sigma`, or use the +> per-point `n`, `sd_diff` and `sigma` columns to apply a sequential correction +> (e.g. a Bonferroni or alpha-spending adjustment) afterwards. + +## Architecture + +| Piece | Role | +| --- | --- | +| `simulator --sequential` | New flag on the Rust binary (in `src/main.rs`). Runs seeds in parallel batches, applies the n-sigma rule, prints one full-precision JSON line and exits. Additive: the GUI and fixed-count headless paths are untouched, and all existing tests still pass. | +| `tools/sweep.py` | Grid driver. Renders a `conf.toml` per point, fans points out over a thread pool (each simulator process pinned to 1 rayon thread), does coarse + adaptive passes, and appends resumable JSONL + CSV. | +| `tools/plot_phase.py` | Matplotlib phase diagram: discrete winner regions, the `mean_diff = 0` boundary, and the continuous difference field. | + +## Usage + +```bash +# build once +cargo build --release --manifest-path simulator/Cargo.toml + +# coarse grid only (fast) +python3 tools/sweep.py --view-dist 100:550:25 --radius 0:250:12.5 + +# coarse + one adaptive refinement pass, then plot +python3 tools/sweep.py \ + --view-dist 100:550:25 --radius 0:250:12.5 \ + --sigma 3 --max-samples 2000 \ + --refine 1 --refine-max-samples 2500 --seed-budget 3000000 \ + --workers 16 --rayon-threads 1 \ + --out sweep_out/results.jsonl +python3 tools/plot_phase.py sweep_out/results.jsonl -o sweep_out/phase.png + +# preview the grid without running +python3 tools/sweep.py --dry-run + +# pin other fields with --set (repeatable) +python3 tools/sweep.py --set kills_user=false --set food_amount=50 +``` + +Axes accept either a range (`start:stop:step`) or an explicit list +(`0,10,20,30`). + +### Key options + +| Flag | Meaning | +| --- | --- | +| `--view-dist`, `--radius` | the two swept axes | +| `--sigma` | n-sigma stopping threshold for the coarse pass (default 3) | +| `--min-samples` / `--max-samples` | lower bound before testing / hard cap | +| `--batch` | seeds per parallel batch between rule checks | +| `--refine` | number of adaptive refinement passes | +| `--refine-sigma` / `--refine-max-samples` | stopping settings for refined points | +| `--seed-budget` | global safety valve: stop scheduling once this many seeds have run | +| `--workers` / `--rayon-threads` | points in parallel / simulator threads per point | +| `--set KEY=VALUE` | pin any other `conf.toml` field | +| `--out` | JSONL path (a `.csv` is written alongside; both are resumable) | + +### Output columns + +`view_dist, weapon_radius, n, sigma, winner, stopped, mean_red, mean_blu, +mean_diff, sd_diff, se_diff, red_wins, blu_wins, ties, weapon_kills_user, +food_amount` + +- `n` — seeds actually run for this point +- `sigma` — `|mean_diff| / se_diff` achieved +- `winner` — `red` / `blu` (sign of `mean_diff`) +- `stopped` — `sigma` (decided) or `max_samples` (undecided → `tie` in the plot) + +## Result of the bundled experiment + +`conf.toml` (red aggressive, blue pacifist, `forwardball`, `kills_user = true`), +coarse grid `view_dist ∈ [100, 550] × 25`, `radius ∈ [0, 250] × 12.5`, one +refinement pass, `sigma = 3`: **714 points, ~650k seeds** (a fixed 2000-seed +sweep over the same grid would have been ~1.4M seeds). + +`phase.png` shows a clear transition: + +- **Low `view_dist` (≲ 140):** blue wins almost everywhere. Red starts far from + the food and, with little sensing, forages no better than the pacifist while + paying for an unused weapon. +- **Higher `view_dist`:** red wins above a curved `radius` threshold and blue + below it. The boundary is *non-monotonic*: the radius red needs dips to a + minimum near `view_dist ≈ 250` and rises again towards 550, because the + `forwardball` sits at `view_end` (`view_dist` ahead of the robot), so a large + `view_dist` places the blast far ahead and needs a larger radius to connect. +- The boundary band (`tie` points) is where `|mean_diff| / SE < 3` even after the + sample cap. + +## Extending + +- **Other axes:** point `--view-dist`/`--radius` at any numeric field, or add a + third axis. For a third axis, wrap `sweep.py` in a loop over the extra value + and use separate output files, then plot a slice montage. +- **Other statistics:** the sequential mode currently tracks the food-share + difference. Win-rate (`red_wins`/`blu_wins`) is already recorded if you prefer + a Bernoulli statistic. +- **Finer boundary:** more `--refine` passes and/or a larger `--seed-budget`. diff --git a/tools/plot_phase.py b/tools/plot_phase.py new file mode 100644 index 0000000..c52687f --- /dev/null +++ b/tools/plot_phase.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Plot the red/blue phase diagram from a `sweep.py` result file. + +Reads the JSONL produced by `tools/sweep.py` and renders: + + 1. the winning side per (view_dist, radius) point, with the estimated phase + boundary (mean RED-BLU food-share difference == 0) overlaid, and + 2. the continuous mean-difference field with a diverging colour map. + +Markers are sized by the number of seeds the sequential test needed, so it is +easy to see where the search had to work hardest. + +Example +------- + python3 tools/plot_phase.py sweep_out/results.jsonl -o sweep_out/phase.png +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.colors import TwoSlopeNorm +from matplotlib.lines import Line2D + +RED = "#d62728" +BLU = "#1f77b4" +TIE = "#9e9e9e" + + +def load(path: Path) -> dict: + if path.suffix == ".csv": + import csv + with path.open() as f: + rows = list(csv.DictReader(f)) + else: + rows = [] + with path.open() as f: + for line in f: + line = line.strip() + if line: + rows.append(json.loads(line)) + cols = { + "vd": np.array([float(r["view_dist"]) for r in rows]), + "rad": np.array([float(r["weapon_radius"]) for r in rows]), + "diff": np.array([float(r["mean_diff"]) for r in rows]), + "sigma": np.array([float(r["sigma"]) for r in rows]), + "n": np.array([float(r["n"]) for r in rows]), + "winner": [r["winner"] for r in rows], + "stopped": [r["stopped"] for r in rows], + } + return cols + + +def outcome(winner: str, stopped: str) -> str: + return "tie" if stopped != "sigma" else winner + + +def marker_sizes(n: np.ndarray, lo: float = 18.0, hi: float = 260.0) -> np.ndarray: + if n.max() <= n.min(): + return np.full_like(n, (lo + hi) / 2) + scaled = (n - n.min()) / (n.max() - n.min()) + return lo + scaled * (hi - lo) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("results", type=Path, help="results.jsonl or results.csv") + ap.add_argument("-o", "--out", type=Path, default=Path("sweep_out/phase.png")) + ap.add_argument("--title", default="Adversarial foraging: red (aggressive) vs blue (pacifist)") + ap.add_argument("--dpi", type=int, default=180) + args = ap.parse_args() + + d = load(args.results) + vd, rad, diff, sigma, n = d["vd"], d["rad"], d["diff"], d["sigma"], d["n"] + labels = [outcome(w, s) for w, s in zip(d["winner"], d["stopped"])] + color_of = {"red": RED, "blu": BLU, "tie": TIE} + colors = [color_of[l] for l in labels] + sizes = marker_sizes(n) + + # Regular grid for a smooth background field, if the points are lattice-like. + fig, axes = plt.subplots(1, 2, figsize=(15, 6.2), sharex=True, sharey=True) + + # ---- Panel 1: discrete winner regions -------------------------------- # + ax = axes[0] + ax.scatter(vd, rad, s=sizes, c=colors, marker="s", edgecolors="black", + linewidths=0.6, alpha=0.9, zorder=3) + # Boundary contour from the continuous field. + try: + ax.tricontour(vd, rad, diff, levels=[0.0], colors="black", + linewidths=2.0, zorder=4) + except Exception: + pass + ax.set_title("Winner (marker size ∝ seeds needed)") + ax.set_xlabel("view_dist") + ax.set_ylabel("weapon radius") + ax.set_axisbelow(True) + ax.grid(alpha=0.25) + handles = [ + Line2D([], [], marker="s", linestyle="", color=RED, markersize=11, label="red wins"), + Line2D([], [], marker="s", linestyle="", color=BLU, markersize=11, label="blue wins"), + Line2D([], [], marker="s", linestyle="", color=TIE, markersize=11, label="undecided (hit cap)"), + Line2D([], [], color="black", linewidth=2, label="boundary: mean diff = 0"), + ] + ax.legend(handles=handles, loc="best", framealpha=0.9) + + # ---- Panel 2: continuous mean-difference field ----------------------- # + ax = axes[1] + vmax = max(abs(diff).max(), 1e-6) + norm = TwoSlopeNorm(vmin=-vmax, vcenter=0.0, vmax=vmax) + sc = ax.scatter(vd, rad, s=sizes, c=diff, cmap="coolwarm", norm=norm, + marker="s", edgecolors="black", linewidths=0.6, zorder=3) + try: + cs = ax.tricontour(vd, rad, diff, levels=[0.0], colors="black", + linewidths=2.0, zorder=4) + except Exception: + pass + # A faint sigma=3 contour shows where confidence is marginal vs decisive. + try: + ax.tricontour(vd, rad, sigma, levels=[3.0], colors="black", + linestyles="dotted", linewidths=1.2, zorder=4) + except Exception: + pass + cb = fig.colorbar(sc, ax=ax, fraction=0.046, pad=0.04) + cb.set_label("mean RED-BLU food-share difference") + ax.set_title("Mean difference (dotted: |diff|/SE = 3)") + ax.set_xlabel("view_dist") + ax.set_axisbelow(True) + ax.grid(alpha=0.25) + + fig.suptitle(args.title, fontsize=13) + fig.tight_layout(rect=(0, 0, 1, 0.96)) + args.out.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(args.out, dpi=args.dpi) + print(f"wrote {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/sweep.py b/tools/sweep.py new file mode 100644 index 0000000..d697c94 --- /dev/null +++ b/tools/sweep.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +"""Adaptive grid search over the simulator's `conf.toml` space. + +For every point on a (view_dist x weapon-radius) grid this driver asks the +simulator for a *sequential* evaluation: seeds are run in batches and the run +stops as soon as the RED-BLU food-share difference is `--sigma` standard +errors away from zero, or `--max-samples` is hit. The per-point statistics are +appended to a JSONL file (resumable) and a flat CSV. + +The grid is *adaptive*: after the coarse pass, cells whose four corners do not +agree on a winner are refined (centre + edge midpoints) with a stricter sigma, +so extra samples are spent where the phase boundary actually is. + +Only one thing about the base config is required: red aggressive, blue +pacifist. `conf.toml` already satisfies that. + +Examples +-------- + # coarse pass only + python3 tools/sweep.py --view-dist 100:550:25 --radius 0:250:12.5 + + # coarse + two refinement passes + python3 tools/sweep.py --view-dist 100:550:50 --radius 0:250:25 --refine 2 + + # see the grid without running anything + python3 tools/sweep.py --dry-run +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +import threading +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +DEFAULT_BINARY = REPO / "simulator" / "target" / "release" / "simulator" +DEFAULT_BASE = REPO / "simulator" / "conf.toml" + + +# --------------------------------------------------------------------------- # +# Grid specification +# --------------------------------------------------------------------------- # +def parse_axis(spec: str) -> list[float]: + """Parse an axis spec into a sorted list of unique floats. + + Accepts either a comma/space separated list (`0,10,20`) or a + `start:stop:step` range (`0:250:12.5`, inclusive of `stop` when it lands). + """ + spec = spec.strip() + if ":" in spec and "," not in spec: + parts = [p.strip() for p in spec.split(":")] + if len(parts) != 3: + raise ValueError(f"range must be start:stop:step, got {spec!r}") + start, stop, step = (float(p) for p in parts) + if step == 0: + raise ValueError("range step must be non-zero") + out: list[float] = [] + v = start + # Tolerate float drift when deciding whether `stop` is reached. + eps = abs(step) * 1e-9 + if step > 0: + while v <= stop + eps: + out.append(round(v, 9)) + v += step + else: + while v >= stop - eps: + out.append(round(v, 9)) + v += step + return out + return sorted({round(float(p), 9) for p in re.split(r"[,\s]+", spec) if p}) + + +# --------------------------------------------------------------------------- # +# Config templating +# --------------------------------------------------------------------------- # +def render_config(base_text: str, overrides: dict[str, float | int | bool]) -> str: + """Replace `key = value` lines in the TOML text for each override.""" + text = base_text + for key, value in overrides.items(): + if isinstance(value, bool): + rendered = "true" if value else "false" + elif isinstance(value, int): + rendered = str(value) + else: + rendered = repr(float(value)) + pattern = re.compile(rf"^(\s*){re.escape(key)}(\s*)=.*$", re.M) + text, n = pattern.subn(rf"\g<1>{key}\g<2>= {rendered}", text) + if n == 0: + raise ValueError(f"key {key!r} not found in base config") + return text + + +def parse_value(raw: str) -> float | int | bool: + """Parse a `--set KEY=VALUE` value into a bool/int/float.""" + low = raw.strip().lower() + if low in ("true", "false"): + return low == "true" + try: + return int(raw) + except ValueError: + return float(raw) + + +def parse_set(items: list[str]) -> dict[str, float | int | bool]: + out: dict[str, float | int | bool] = {} + for item in items or []: + if "=" not in item: + raise ValueError(f"--set expects KEY=VALUE, got {item!r}") + key, _, raw = item.partition("=") + out[key.strip()] = parse_value(raw) + return out + + +# --------------------------------------------------------------------------- # +# Sequential evaluation of a single grid point +# --------------------------------------------------------------------------- # +class Evaluator: + def __init__( + self, + binary: Path, + base_text: str, + rayon_threads: int, + timeout: float, + extra: dict[str, float | int | bool] | None = None, + ) -> None: + self.binary = binary + self.base_text = base_text + self.rayon_threads = rayon_threads + self.timeout = timeout + self.extra = extra or {} + + def __call__(self, point: tuple[float, float, dict]) -> dict: + view_dist, radius, opts = point + overrides = {"view_dist": view_dist, "radius": radius, **self.extra} + text = render_config(self.base_text, overrides) + # Unique temp file per call so concurrent threads never race. + fd, path = tempfile.mkstemp(suffix=".toml", dir=tempfile.gettempdir()) + try: + with os.fdopen(fd, "w") as f: + f.write(text) + env = dict(os.environ) + env["RAYON_NUM_THREADS"] = str(self.rayon_threads) + cmd = [ + str(self.binary), + "--sequential", + "--config", path, + "--sigma", str(opts["sigma"]), + "--min-samples", str(opts["min_samples"]), + "--max-samples", str(opts["max_samples"]), + "--batch", str(opts["batch"]), + ] + proc = subprocess.run( + cmd, capture_output=True, text=True, env=env, timeout=self.timeout + ) + finally: + try: + os.unlink(path) + except OSError: + pass + + if proc.returncode != 0: + raise RuntimeError( + f"simulator failed for view_dist={view_dist} radius={radius}: " + f"{proc.stderr.strip() or proc.stdout.strip()}" + ) + + line = next( + (ln for ln in reversed(proc.stdout.splitlines()) if ln.strip().startswith("{")), + None, + ) + if line is None: + raise RuntimeError(f"no JSON in simulator output:\n{proc.stdout}") + result = json.loads(line) + # Normalise to the grid coordinates we asked for (JSON round-trip of + # the floats should match, but this makes the key exact). + result["view_dist"] = view_dist + result["weapon_radius"] = radius + return result + + +# --------------------------------------------------------------------------- # +# Persistence +# --------------------------------------------------------------------------- # +def result_key(r: dict) -> tuple[float, float]: + return (round(float(r["view_dist"]), 9), round(float(r["weapon_radius"]), 9)) + + +def load_results(path: Path) -> dict[tuple[float, float], dict]: + results: dict[tuple[float, float], dict] = {} + if path.exists(): + with path.open() as f: + for line in f: + line = line.strip() + if line: + r = json.loads(line) + results[result_key(r)] = r + return results + + +def write_csv(path: Path, results: dict[tuple[float, float], dict]) -> None: + if not results: + return + cols = [ + "view_dist", "weapon_radius", "n", "sigma", "winner", "stopped", + "mean_red", "mean_blu", "mean_diff", "sd_diff", "se_diff", + "red_wins", "blu_wins", "ties", "weapon_kills_user", "food_amount", + ] + rows = sorted(results.values(), key=result_key) + with path.open("w", newline="") as f: + import csv + w = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore") + w.writeheader() + for r in rows: + w.writerow(r) + + +# --------------------------------------------------------------------------- # +# Adaptive refinement +# --------------------------------------------------------------------------- # +def outcome(r: dict) -> str: + """Collapse a result to the label used for refinement decisions. + + A point that hit the sample cap without reaching sigma is reported as + `tie`, so the boundary around genuinely undecided regions is refined too. + """ + return "tie" if r.get("stopped") != "sigma" else r["winner"] + + +def find_refinement_points( + results: dict[tuple[float, float], dict], + vds: list[float], + rads: list[float], +) -> set[tuple[float, float]]: + """Midpoints of cells whose four corners disagree on the outcome.""" + def label(vd: float, rad: float) -> str | None: + r = results.get((round(vd, 9), round(rad, 9))) + return outcome(r) if r else None + + new: set[tuple[float, float]] = set() + for i in range(len(vds) - 1): + for j in range(len(rads) - 1): + corners = [ + label(vds[i], rads[j]), + label(vds[i + 1], rads[j]), + label(vds[i], rads[j + 1]), + label(vds[i + 1], rads[j + 1]), + ] + if None in corners or len(set(corners)) == 1: + continue + mid_vd = round((vds[i] + vds[i + 1]) / 2, 9) + mid_rad = round((rads[j] + rads[j + 1]) / 2, 9) + new.update({ + (mid_vd, rads[j]), + (mid_vd, rads[j + 1]), + (vds[i], mid_rad), + (vds[i + 1], mid_rad), + (mid_vd, mid_rad), + }) + return new + + +# --------------------------------------------------------------------------- # +# Main +# --------------------------------------------------------------------------- # +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--binary", type=Path, default=DEFAULT_BINARY) + ap.add_argument("--base", type=Path, default=DEFAULT_BASE, + help="base config; red must be aggressive and blue pacifist") + ap.add_argument("--view-dist", default="100:550:25", help="axis spec (default 100:550:25)") + ap.add_argument("--radius", default="0:250:12.5", help="axis spec (default 0:250:12.5)") + ap.add_argument("--out", type=Path, default=Path("sweep_out/results.jsonl")) + ap.add_argument("--sigma", type=float, default=3.0) + ap.add_argument("--min-samples", type=int, default=32) + ap.add_argument("--max-samples", type=int, default=2000) + ap.add_argument("--batch", type=int, default=64) + ap.add_argument("--refine", type=int, default=1, help="number of adaptive refinement passes") + ap.add_argument("--refine-sigma", type=float, default=3.0) + ap.add_argument("--refine-max-samples", type=int, default=2500) + ap.add_argument("--refine-batch", type=int, default=128) + ap.add_argument("--seed-budget", type=int, default=4_000_000, + help="stop scheduling work once this many seeds have run (safety valve)") + ap.add_argument("--workers", type=int, default=os.cpu_count() or 4) + ap.add_argument("--rayon-threads", type=int, default=1, + help="threads the simulator may use per point (keep at 1: we parallelise over points)") + ap.add_argument("--timeout", type=float, default=600.0) + ap.add_argument("--set", action="append", default=[], metavar="KEY=VALUE", + help="pin any other conf.toml field (repeatable), e.g. --set kills_user=false") + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + if not args.binary.exists(): + ap.error(f"binary not found: {args.binary} (build with `cargo build --release`)") + base_text = args.base.read_text() + + vds = parse_axis(args.view_dist) + rads = parse_axis(args.radius) + args.out.parent.mkdir(parents=True, exist_ok=True) + csv_path = args.out.with_suffix(".csv") + + results = load_results(args.out) + if results: + print(f"resuming: {len(results)} points already in {args.out}", file=sys.stderr) + + if args.dry_run: + print(f"view_dist ({len(vds)}): {vds}") + print(f"radius ({len(rads)}): {rads}") + print(f"coarse grid: {len(vds) * len(rads)} points; " + f"{args.refine} refinement pass(es); out={args.out}") + return 0 + + ev = Evaluator(args.binary, base_text, args.rayon_threads, args.timeout, + extra=parse_set(args.set)) + base_opts = dict(sigma=args.sigma, min_samples=args.min_samples, + max_samples=args.max_samples, batch=args.batch) + + out_lock = threading.Lock() + out_fh = args.out.open("a") + state = {"seeds": sum(int(r["n"]) for r in results.values())} + if state["seeds"]: + print(f"resuming: {state['seeds']} seeds already spent", file=sys.stderr) + + def evaluate(points: list[tuple[float, float]], opts: dict, label: str) -> bool: + """Evaluate `points`, appending results as they finish. + + Returns False if the seed budget ran out (caller should stop refining). + """ + todo = [ + (vd, rad, opts) for vd, rad in points + if (round(vd, 9), round(rad, 9)) not in results + ] + if not todo: + print(f"[{label}] nothing new to evaluate", flush=True) + return True + print(f"[{label}] evaluating {len(todo)} point(s) " + f"(sigma={opts['sigma']}, max={opts['max_samples']}, " + f"seeds={state['seeds']}/{args.seed_budget})", flush=True) + chunk = max(args.workers * 2, 8) + done = 0 + with ThreadPoolExecutor(max_workers=args.workers) as ex: + for i in range(0, len(todo), chunk): + if state["seeds"] >= args.seed_budget: + print(f"[{label}] seed budget exhausted; stopping", flush=True) + return False + for r in ex.map(ev, todo[i:i + chunk]): + key = result_key(r) + with out_lock: + results[key] = r + out_fh.write(json.dumps(r) + "\n") + out_fh.flush() + state["seeds"] += int(r["n"]) + done += 1 + print(f" [{done}/{len(todo)}] vd={r['view_dist']:>6} " + f"rad={r['weapon_radius']:>6} n={r['n']:>5} " + f"sigma={r['sigma']:.2f} -> {outcome(r)} ({r['stopped']})", + flush=True) + return True + + # Coarse pass. + evaluate([(vd, rad) for vd in vds for rad in rads], base_opts, "coarse") + + # Adaptive refinement passes. + for p in range(args.refine): + vds = sorted({k[0] for k in results}) + rads = sorted({k[1] for k in results}) + new = find_refinement_points(results, vds, rads) + if not new: + print("no boundary cells left to refine") + break + opts = dict(sigma=args.refine_sigma, min_samples=args.min_samples, + max_samples=args.refine_max_samples, batch=args.refine_batch) + if not evaluate(sorted(new), opts, f"refine {p + 1}"): + break + + out_fh.close() + write_csv(csv_path, results) + + red = sum(1 for r in results.values() if outcome(r) == "red") + blu = sum(1 for r in results.values() if outcome(r) == "blu") + tie = sum(1 for r in results.values() if outcome(r) == "tie") + print(f"\ndone: {len(results)} points -> red={red} blu={blu} tie={tie}") + print(f"wrote {args.out} and {csv_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())