#!/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))