From fd634b066751f732832df73a098227e56e06278d Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 28 May 2026 20:19:19 -0700 Subject: [PATCH] =?UTF-8?q?feat(p1-settle-analysis):=20=E2=9C=A8=20Add=20P?= =?UTF-8?q?1=20settlement=20analysis=20script=20for=20transaction=20valida?= =?UTF-8?q?tion=20and=20reporting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- tools/p1-settle-analysis.py | 173 ++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tools/p1-settle-analysis.py diff --git a/tools/p1-settle-analysis.py b/tools/p1-settle-analysis.py new file mode 100644 index 00000000..9fbf76e4 --- /dev/null +++ b/tools/p1-settle-analysis.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +p1-settle-analysis.py — Convergence gate analyzer for objective p1-29d. + +Gate (dispatch + objective title): the trailing AI (P1) must be EITHER +eliminated OR stalled BEFORE T100 in 10/10 autoplay seeds. This is a +late-game *pacing* gate, not a survival/thrive gate — it asks whether P1's +structural fate has converged (stopped changing) by turn 100, so Wave-1 +balance work runs against a stable regime. + +"Settled" definition (rigorous, structural): + P1 is settled at turn T_s = the LAST turn at which P1's structural state + (city_count, tier_peak) changed from the previous turn. From T_s to game + end, neither cities nor tier_peak move. Two settled regimes: + - ELIMINATED: P1's city_count reaches 0 after having founded — the last + structural change is the capital loss. settle_turn = that turn. + - STALLED: P1 alive with a frozen footprint (cities + tier_peak flat) + through game end. settle_turn = last tier_peak/cities change. + Within-city pop growth is intentionally NOT a destabilizer — a frozen + single city whose pop ticks up does not change the strategic/pacing + picture (no new cities, no new unit tier). We track pop/mil for context + only. + +A seed PASSES the convergence gate iff settle_turn <= T_GATE (default 100). + +Reads game__seed/turn_stats.jsonl under a results dir (the smoke/ +subdir of a batch). stdlib only. + +Usage: + tools/p1-settle-analysis.py [--gate 100] [--player 1] [--json] +""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +T_GATE_DEFAULT = 100 +SEED_RE = re.compile(r"_seed(\d+)$") + + +def find_game_dirs(results_dir: Path) -> list[tuple[int, Path]]: + by_seed: dict[int, Path] = {} + for d in sorted(results_dir.iterdir()): + if not d.is_dir(): + continue + m = SEED_RE.search(d.name) + if not m: + continue + if not (d / "turn_stats.jsonl").exists(): + continue + seed = int(m.group(1)) + # Most recent stamp wins (lexicographic on dir name). + if seed not in by_seed or d.name > by_seed[seed].name: + by_seed[seed] = d + return sorted(by_seed.items()) + + +def analyze_seed(path: Path, pid: str) -> dict: + lines = [json.loads(l) for l in (path / "turn_stats.jsonl").open() if l.strip()] + if not lines: + return {"error": "empty turn_stats"} + last = lines[-1] + end_turn = last["turn"] + + # Build P1 structural series: (turn, cities, tier_peak). + series = [] + for d in lines: + p = d["player_stats"].get(pid, {}) + series.append((d["turn"], p.get("cities", 0), p.get("tier_peak", 0), + p.get("pop", 0), p.get("mil", 0), p.get("cities_lost", 0))) + + founded = any(c > 0 for _, c, *_ in series) + end_cities = series[-1][1] + end_tp = series[-1][2] + end_lost = series[-1][5] + + # Settle turn = last turn (cities, tier_peak) changed vs prior turn. + settle_turn = series[0][0] + prev = (series[0][1], series[0][2]) + for turn, cities, tp, *_ in series[1:]: + if (cities, tp) != prev: + settle_turn = turn + prev = (cities, tp) + + # Classify regime. + if founded and end_cities == 0: + regime = "eliminated" + elif end_cities >= 1: + regime = "stalled" if end_cities == 1 else "alive-multi" + else: + regime = "never-founded" + + return { + "end_turn": end_turn, + "settle_turn": settle_turn, + "regime": regime, + "end_cities": end_cities, + "end_tier_peak": end_tp, + "end_cities_lost": end_lost, + "end_pop": series[-1][3], + "outcome": last.get("outcome"), + "victory_type": last.get("victory_type"), + "winner_index": last.get("winner_index"), + } + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + print(__doc__, file=sys.stderr) + return 2 + results_dir = Path(argv[1]) + gate = T_GATE_DEFAULT + pid = "1" + as_json = False + rest = argv[2:] + i = 0 + while i < len(rest): + a = rest[i] + if a == "--gate": + gate = int(rest[i + 1]); i += 2 + elif a == "--player": + pid = rest[i + 1]; i += 2 + elif a == "--json": + as_json = True; i += 1 + else: + print(f"unknown arg: {a}", file=sys.stderr); return 2 + if not results_dir.is_dir(): + print(f"not a directory: {results_dir}", file=sys.stderr) + return 2 + + games = find_game_dirs(results_dir) + if not games: + print(f"no game_*_seed* dirs with turn_stats.jsonl under {results_dir}", file=sys.stderr) + return 1 + + rows = [] + for seed, path in games: + r = analyze_seed(path, pid) + r["seed"] = seed + r["pass"] = ("error" not in r) and (r["settle_turn"] <= gate) + rows.append(r) + + n_pass = sum(1 for r in rows if r.get("pass")) + n = len(rows) + + if as_json: + print(json.dumps({"gate": gate, "player": pid, "n_pass": n_pass, + "n": n, "rows": rows}, indent=2)) + return 0 if n_pass == n else 1 + + print(f"P1 convergence gate (player {pid}): settled (eliminated OR stalled) by T{gate}") + print(f"results: {results_dir}") + print() + print(f"{'seed':>4} {'settle':>6} {'regime':>13} {'end_t':>5} {'cities':>6} " + f"{'tp':>3} {'lost':>4} {'pop':>4} {'verdict':>7}") + for r in sorted(rows, key=lambda x: x["seed"]): + if "error" in r: + print(f"{r['seed']:>4} ERROR: {r['error']}") + continue + verdict = "PASS" if r["pass"] else "FAIL" + print(f"{r['seed']:>4} {r['settle_turn']:>6} {r['regime']:>13} " + f"{r['end_turn']:>5} {r['end_cities']:>6} {r['end_tier_peak']:>3} " + f"{r['end_cities_lost']:>4} {r['end_pop']:>4} {verdict:>7}") + print() + print(f"GATE {n_pass}/{n} seeds settled by T{gate} " + f"-> {'PASS (10/10 required)' if n_pass == n and n >= 10 else 'FAIL'}") + return 0 if (n_pass == n and n >= 10) else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))