From 0c83824243594c50093e2fac970528e60d5347cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=8B=9C=EC=9B=90?= Date: Wed, 15 Jul 2026 05:50:58 +0900 Subject: [PATCH] Correct the exploiter budget: it was a quarter of the target, not half The exploiters ran 32.5M learner actions, not the 65.5M I recorded -- they train one seat, so a 250x1024 run yields half of what the same shape gives the both-seat self-play trainer. Against targets trained on 131M and 122.6M, that makes the attacker roughly 4x underfunded. Which means the absolute number does not support "ours is only 22.8% exploitable". It supports exactly one claim: at a matched budget, league gives up more. Whether the ordering survives a properly funded attacker is now the open question, so the script takes --updates and --batch-games to run it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh --- docs/plans/lost-cities-classic-3round.md | 7 +++++-- scripts/measure_exploitability.py | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/plans/lost-cities-classic-3round.md b/docs/plans/lost-cities-classic-3round.md index c7b5e70..936c689 100644 --- a/docs/plans/lost-cities-classic-3round.md +++ b/docs/plans/lost-cities-classic-3round.md @@ -551,7 +551,7 @@ Fable은 이것을 "가장 큰 누락 아이디어"로 꼽았다. 실측은 ** **측정 방법:** 정책을 얼려놓고, **오직 그놈만 이기도록 특화된 새 정책을 처음부터 학습**시킨다 (`src/lost_cities_jax/exploit.py`). 착취자가 도달한 승률이 곧 그 정책이 못 막아낸 습관의 크기다. -**동일 착취자 예산 (250 업데이트 × batch 1024 = 65.5M learner 액션):** +**동일 착취자 예산 (250 업데이트 × batch 1024 = 32.5M learner 액션):** | 얼려놓은 정책 | 착취자 승률 | 95% CI | |---|---|---| @@ -571,7 +571,10 @@ Fable은 이것을 "가장 큰 누락 아이디어"로 꼽았다. 실측은 ** 1. **하한선이다.** 더 세거나 더 오래 학습한 착취자는 더 찾아낼 수 있다. **절대값이 아니라 동일 예산에서의 비교로만 의미가 있다.** -2. 착취자 예산이 목표의 절반이다(65M vs 131M). 키우면 두 수치 다 오른다. +2. **착취자 예산이 목표의 약 1/4에 불과하다 (32.5M vs 131M).** 약한 공격자다. + 따라서 **"우리는 22.8%밖에 안 털린다"고 말할 수 없다** — 그건 이 약한 공격자 기준일 뿐이다. + 살아남는 주장은 **"동일 예산에서 league가 더 털린다"** 하나뿐이다. + 착취자를 목표와 같은 예산(131M)으로 키워서 **순서가 유지되는지** 확인해야 한다. 3. league는 단판 정책이라 3라운드 게임에선 다소 제 물이 아니다 — 다만 carry가 무의미하다는 것이 이미 증명됐으므로 큰 불리함은 아니다. diff --git a/scripts/measure_exploitability.py b/scripts/measure_exploitability.py index ca20026..9953e54 100644 --- a/scripts/measure_exploitability.py +++ b/scripts/measure_exploitability.py @@ -8,6 +8,7 @@ frozen policy could not defend. from __future__ import annotations +import argparse import json from pathlib import Path @@ -77,6 +78,12 @@ def _final_score(cfg, exploiter_params, frozen, matches: int) -> dict: def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--updates", type=int, default=250, help="exploiter training updates") + parser.add_argument("--batch-games", type=int, default=1024) + parser.add_argument("--tag", default="", help="suffix for the run dir, to keep runs apart") + args = parser.parse_args() + match_cfg = load_config("configs/jax_ppo/match-selfplay.yaml") old_cfg = load_config("configs/jax_ppo/balanced.yaml") @@ -94,10 +101,10 @@ def main() -> None: for name, frozen in targets.items(): print(f"\n===== training an exploiter against: {name} =====", flush=True) cfg = load_config("configs/jax_ppo/match-selfplay.yaml") - cfg.ppo.batch_games = 1024 - cfg.run.total_updates = 250 - cfg.run.log_every = 25 - slug = name.split()[0] + cfg.ppo.batch_games = args.batch_games + cfg.run.total_updates = args.updates + cfg.run.log_every = max(1, args.updates // 10) + slug = name.split()[0] + args.tag state = train_exploiter(cfg, frozen, Path(f"runs/jax-ppo-match/exploit-{slug}")) result = _final_score(cfg, state.params, frozen, MATCHES) rows.append({"target": name, **result}) @@ -110,7 +117,7 @@ def main() -> None: ci = f"[{row['wilson_low']:.3f}, {row['wilson_high']:.3f}]" print(f"{row['target']:<28}{row['exploiter_win_rate']:>20.4f}{ci:>22}") print("\nhigher = the frozen policy had more to farm. 0.5 = nothing found.") - Path("runs/jax-ppo-match/exploitability.json").write_text(json.dumps(rows, indent=2)) + Path(f"runs/jax-ppo-match/exploitability{args.tag}.json").write_text(json.dumps(rows, indent=2)) if __name__ == "__main__":