diff --git a/tools/sole-city-gate.py b/tools/sole-city-gate.py new file mode 100644 index 00000000..2503b701 --- /dev/null +++ b/tools/sole-city-gate.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""sole-city-gate.py — score the p1-29c / p1-29d sole-city tier gate for a batch. + +Reads every game_*/turn_stats.jsonl under a batch dir, extracts per-player +final tier_peak / cities / cities_lost, and scores the alive-aware gate: + + PASS seed := P0.tier_peak >= TIER AND P1.tier_peak >= TIER + Gate PASS := pass_seeds >= QUORUM (default 7/10) AND + median game length <= MAX_TURNS (default 384) + +Usage: + python3 tools/sole-city-gate.py [--tier 2] [--quorum 7] + [--max-turns 384] [--p0 0] [--p1 1] + +Exit code 0 = gate PASS, 1 = gate FAIL, 2 = usage / no data. +""" +from __future__ import annotations + +import argparse +import json +import statistics +import sys +from pathlib import Path + + +def final_line(ts: Path) -> dict | None: + last = None + for line in ts.read_text().splitlines(): + line = line.strip() + if line: + last = line + if last is None: + return None + return json.loads(last) + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser() + ap.add_argument("batch_dir") + ap.add_argument("--tier", type=int, default=2) + ap.add_argument("--quorum", type=int, default=7) + ap.add_argument("--max-turns", type=int, default=384) + ap.add_argument("--p0", default="0") + ap.add_argument("--p1", default="1") + args = ap.parse_args(argv[1:]) + + root = Path(args.batch_dir) + if not root.is_dir(): + print(f"Not a directory: {root}", file=sys.stderr) + return 2 + + games = sorted(g for g in root.glob("game_*") if g.is_dir()) + if not games: + print(f"No game_* dirs under {root}", file=sys.stderr) + return 2 + + rows = [] + turns = [] + pass_seeds = 0 + for game in games: + ts = game / "turn_stats.jsonl" + if not ts.exists(): + rows.append((game.name, "NO_STATS")) + continue + try: + final = final_line(ts) + except Exception as e: # noqa: BLE001 + rows.append((game.name, f"PARSE_ERR {e}")) + continue + if not final: + rows.append((game.name, "EMPTY")) + continue + ps = final.get("player_stats") or {} + p0 = ps.get(args.p0, {}) + p1 = ps.get(args.p1, {}) + turn = final.get("turn", 0) + turns.append(turn) + p0_tp = p0.get("tier_peak", 0) + p1_tp = p1.get("tier_peak", 0) + ok = p0_tp >= args.tier and p1_tp >= args.tier + pass_seeds += 1 if ok else 0 + rows.append(( + game.name, + f"T{turn} outcome={final.get('outcome')} " + f"P0_tp={p0_tp} P1_tp={p1_tp} " + f"P1_cities={p1.get('cities', '?')} P1_lost={p1.get('cities_lost', '?')} " + f"{'PASS' if ok else 'fail'}" + )) + + for name, detail in rows: + print(f"{name}: {detail}") + + scored = len(turns) + median_turn = statistics.median(turns) if turns else 0 + quorum_ok = pass_seeds >= args.quorum + length_ok = median_turn <= args.max_turns + gate = quorum_ok and length_ok + + print("─" * 60) + print(f"scored games: {scored}") + print(f"pass seeds (P0_tp>={args.tier} AND P1_tp>={args.tier}): " + f"{pass_seeds}/{scored} (quorum {args.quorum} → {'OK' if quorum_ok else 'MISS'})") + print(f"median game length: {median_turn} (<= {args.max_turns} → {'OK' if length_ok else 'MISS'})") + print(f"GATE: {'PASS' if gate else 'FAIL'}") + return 0 if gate else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))