Files
2026-09-01 18:56:31 +01:00

201 lines
7.3 KiB
Python

"""Pit two exported policies against each other and record the result.
Uses the same in-Godot inference path that ships in the game
(AIShipController + PolicyNetwork), so eval strength = in-game strength.
Episodes are golden-goal: first goal wins, timeout is a draw. Every randomized
starting state is played twice with sides swapped, so physical-team or arena
asymmetry cannot be mistaken for model strength. Results are appended to
eval_history.json — the bot-progress-over-time record.
Example:
.venv/bin/python evaluate.py ../Game/bots/rookie.json checkpoints/run01/candidate.json --episodes 40
"""
import argparse
import datetime
import json
import os
import pathlib
import subprocess
import tempfile
TRAINING_DIR = pathlib.Path(__file__).resolve().parent
GAME_DIR = TRAINING_DIR.parent / "Game"
TRAINING_SCENE = "res://scenes/training.tscn"
DEFAULT_GODOT_MACOS = "/Applications/Godot.app/Contents/MacOS/Godot"
def run_half(
godot_bin: str,
model_a: str,
model_b: str,
episodes: int,
speedup: int,
seed: int,
grounded_a: bool = False,
grounded_b: bool = False,
team_size: int = 1,
) -> dict:
cmd = [
godot_bin,
"--display-driver",
"headless",
"--rendering-method",
"gl_compatibility",
"--audio-driver",
"Dummy",
"--log-file",
str(pathlib.Path(tempfile.gettempdir()) / "cosmic-clash-evaluate-godot.log"),
"--path",
str(GAME_DIR),
TRAINING_SCENE,
"--headless",
"--disable-render-loop",
f"--eval_model_a={model_a}",
f"--eval_model_b={model_b}",
f"--eval_episodes={episodes}",
f"--speedup={speedup}",
f"--env_seed={seed}",
]
if team_size not in (1, 2):
raise ValueError("team_size must be 1 or 2")
if team_size == 2:
cmd.append("--eval_team_size=2")
# Must match how each model was actually trained (see AIShipController's
# allow_vertical/allow_pitch_roll) — a grounded pre-generation-4 model
# never got a reward gradient on these axes, so leaving them unmasked here
# adds untrained aerial noise its training never had to contend with.
if grounded_a:
cmd += ["--eval_allow_vertical_a=false", "--eval_allow_pitch_roll_a=false"]
if grounded_b:
cmd += ["--eval_allow_vertical_b=false", "--eval_allow_pitch_roll_b=false"]
timeout = episodes * 30 / speedup * 3 + 120 # worst case: all draws, plus margin
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
for line in result.stdout.splitlines():
if line.startswith("EVAL_RESULT "):
return json.loads(line[len("EVAL_RESULT "):])
raise RuntimeError(f"No EVAL_RESULT in godot output:\n{result.stdout[-2000:]}\n{result.stderr[-2000:]}")
def evaluate_pair(
godot_bin: str,
model_a: str,
model_b: str,
episodes: int,
speedup: int,
seed: int,
grounded_a: bool = False,
grounded_b: bool = False,
team_size: int = 1,
) -> dict:
"""Replay one seeded state sequence with the models on opposite sides."""
if episodes < 2 or episodes % 2 != 0:
raise ValueError("--episodes must be an even number of at least 2 for paired side swaps")
if team_size not in (1, 2):
raise ValueError("team_size must be 1 or 2")
episodes_per_side = episodes // 2
first = run_half(
godot_bin, model_a, model_b, episodes_per_side, speedup, seed,
grounded_a=grounded_a, grounded_b=grounded_b,
team_size=team_size,
)
second = run_half(
godot_bin, model_b, model_a, episodes_per_side, speedup, seed,
grounded_a=grounded_b, grounded_b=grounded_a,
team_size=team_size,
)
a_team_0 = {
"wins_a": first["goals_a"],
"wins_b": first["goals_b"],
"draws": first["draws"],
}
a_team_1 = {
"wins_a": second["goals_b"],
"wins_b": second["goals_a"],
"draws": second["draws"],
}
record = {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"model_a": model_a,
"model_b": model_b,
"seed": seed,
"episodes": first["episodes"] + second["episodes"],
"wins_a": a_team_0["wins_a"] + a_team_1["wins_a"],
"wins_b": a_team_0["wins_b"] + a_team_1["wins_b"],
"draws": a_team_0["draws"] + a_team_1["draws"],
"side_results": {
"a_team_0": a_team_0,
"a_team_1": a_team_1,
},
"physical_team_wins": {
"team_0": first["goals_a"] + second["goals_a"],
"team_1": first["goals_b"] + second["goals_b"],
},
}
record["win_rate_a"] = round(record["wins_a"] / record["episodes"], 3)
return record
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("model_a", help="Path to first exported policy .json")
parser.add_argument("model_b", help="Path to second exported policy .json")
parser.add_argument(
"--episodes", type=int, default=20,
help="Total episodes; must be even so every seeded state is replayed with sides swapped",
)
parser.add_argument(
"--godot_bin",
default=os.environ.get("GODOT_BIN", DEFAULT_GODOT_MACOS),
help="Path to the Godot binary (or set GODOT_BIN)",
)
parser.add_argument("--speedup", type=int, default=16)
parser.add_argument("--team-size", type=int, choices=(1, 2), default=1)
parser.add_argument("--seed", type=int, default=1, help="Seed for the paired starting-state sequence")
parser.add_argument("--history", default=str(TRAINING_DIR / "eval_history.json"))
parser.add_argument(
"--grounded-a", action="store_true", help="model_a was trained with locomotion masked (pre-generation-4 models)"
)
parser.add_argument(
"--grounded-b", action="store_true", help="model_b was trained with locomotion masked (pre-generation-4 models)"
)
args = parser.parse_args()
model_a = str(pathlib.Path(args.model_a).resolve())
model_b = str(pathlib.Path(args.model_b).resolve())
try:
record = evaluate_pair(
args.godot_bin, model_a, model_b, args.episodes, args.speedup, args.seed,
grounded_a=args.grounded_a, grounded_b=args.grounded_b,
team_size=args.team_size,
)
except ValueError as error:
parser.error(str(error))
history_path = pathlib.Path(args.history)
history = json.loads(history_path.read_text()) if history_path.exists() else []
history.append(record)
history_path.write_text(json.dumps(history, indent=2) + "\n")
print(
f"{pathlib.Path(model_a).name} vs {pathlib.Path(model_b).name} over {record['episodes']} episodes: "
f"{record['wins_a']}-{record['wins_b']} ({record['draws']} draws), "
f"win rate A = {record['win_rate_a']:.0%}"
)
a_team_0 = record["side_results"]["a_team_0"]
a_team_1 = record["side_results"]["a_team_1"]
physical = record["physical_team_wins"]
print(
f"Paired seed {record['seed']} side split: "
f"A as team 0 {a_team_0['wins_a']}-{a_team_0['wins_b']} ({a_team_0['draws']} draws); "
f"A as team 1 {a_team_1['wins_a']}-{a_team_1['wins_b']} ({a_team_1['draws']} draws); "
f"physical teams 0-1 = {physical['team_0']}-{physical['team_1']}"
)
print(f"Appended to {history_path}")
if __name__ == "__main__":
main()