magicciv/tools/sole-city-gate.py
autocommit 35f55c9ff5 feat(sole-city-gate): Implement Sole City Gate integration tool for system interactions
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-27 21:46:38 -07:00

109 lines
3.5 KiB
Python

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