mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
@@ -17,6 +17,7 @@ import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
TRAINING_DIR = pathlib.Path(__file__).resolve().parent
|
||||
GAME_DIR = TRAINING_DIR.parent / "Game"
|
||||
@@ -33,9 +34,18 @@ def run_half(
|
||||
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,
|
||||
@@ -47,6 +57,10 @@ def run_half(
|
||||
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
|
||||
@@ -72,19 +86,24 @@ def evaluate_pair(
|
||||
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 = {
|
||||
@@ -133,6 +152,7 @@ def main():
|
||||
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(
|
||||
@@ -149,6 +169,7 @@ def main():
|
||||
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))
|
||||
|
||||
+27
-2
@@ -38,6 +38,10 @@ PROMOTED_EASY = REPO_ROOT / "Game" / "bots" / "promoted" / "easy.json"
|
||||
MAX_RETRIES = 4
|
||||
EVAL_EPISODES = 100
|
||||
REGRESSION_MARGIN = 0.15
|
||||
# A single paired seed can produce a large physical-side swing even for a
|
||||
# policy playing itself. Keep the first historical seed for continuity, but
|
||||
# require two independent deterministic sequences before a stage can pass.
|
||||
DEFAULT_EVALUATION_SEEDS = (1, 19, 43)
|
||||
# --min-head-entropy-frac / --ent-coef-max added 2026-08-24. The aggregate
|
||||
# entropy target is a SUM and read healthy (21% of h_max, on target) through
|
||||
# all nine Stage-5 attempts while thrust_y alone sat at 14% of its own ceiling
|
||||
@@ -438,6 +442,11 @@ STAGES = [
|
||||
"--near-goal-chance", "0.25",
|
||||
"--air-drill-chance", "0.15",
|
||||
"--air-intercept-chance", "0.25",
|
||||
# Stage 5 established the aerial baseline; Stage 6 adds a
|
||||
# measured opportunity for wall/rebound decisions without
|
||||
# changing the preceding stages' distributions.
|
||||
"--wall-play-chance", "0.10",
|
||||
"--rebound-chance", "0.10",
|
||||
*HANDLING_REWARD_FLAGS,
|
||||
],
|
||||
"telemetry_floors": {
|
||||
@@ -582,11 +591,12 @@ def run_training(state: dict, stage_index: int, attempt: int, args) -> str:
|
||||
return experiment
|
||||
|
||||
|
||||
def evaluate(experiment: str, reference: pathlib.Path, args) -> dict:
|
||||
def evaluate(experiment: str, reference: pathlib.Path, args, seed: int) -> dict:
|
||||
candidate = REPO_ROOT / "Game" / "bots" / f"{experiment}.json"
|
||||
cmd = [
|
||||
".venv/bin/python", "evaluate.py", str(candidate), str(reference),
|
||||
"--episodes", str(EVAL_EPISODES), "--speedup", str(args.speedup),
|
||||
"--seed", str(seed),
|
||||
]
|
||||
if args.godot_bin:
|
||||
cmd += ["--godot_bin", args.godot_bin]
|
||||
@@ -628,11 +638,22 @@ def main() -> None:
|
||||
parser.add_argument("--n-parallel", type=int, default=14)
|
||||
parser.add_argument("--speedup", type=int, default=16)
|
||||
parser.add_argument("--godot-bin", default=None, help="Godot binary for post-stage evaluation")
|
||||
parser.add_argument(
|
||||
"--evaluation-seeds",
|
||||
default=",".join(str(seed) for seed in DEFAULT_EVALUATION_SEEDS),
|
||||
help="Comma-separated independent paired seeds required for every reference evaluation",
|
||||
)
|
||||
parser.add_argument("--foundation-checkpoint", default=str(FOUNDATION_CHECKPOINT))
|
||||
parser.add_argument("--force-retry", action="store_true")
|
||||
parser.add_argument("--skip-to-next-stage", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print the next run command without executing it")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
evaluation_seeds = tuple(dict.fromkeys(int(value) for value in args.evaluation_seeds.split(",") if value.strip()))
|
||||
except ValueError as error:
|
||||
parser.error(f"--evaluation-seeds must be comma-separated integers: {error}")
|
||||
if not evaluation_seeds:
|
||||
parser.error("--evaluation-seeds requires at least one seed")
|
||||
|
||||
state = load_state()
|
||||
if state["status"] == "done":
|
||||
@@ -670,7 +691,11 @@ def main() -> None:
|
||||
# Preserve order while avoiding a duplicate Stage-5 evaluation in
|
||||
# the league stage (its predecessor is also in the pool).
|
||||
references = list(dict.fromkeys(references))
|
||||
records = [evaluate(experiment, reference, args) for reference in references]
|
||||
records = [
|
||||
evaluate(experiment, reference, args, seed)
|
||||
for reference in references
|
||||
for seed in evaluation_seeds
|
||||
]
|
||||
match_ok = all(match_passes(record) for record in records)
|
||||
evaluation_goal_floor = stage.get("evaluation_goal_rate_floor", 0.0)
|
||||
evaluation_goal_failures = [
|
||||
|
||||
@@ -23,11 +23,11 @@ class EvaluatePairTests(unittest.TestCase):
|
||||
self.assertEqual(run_half.call_args_list[1].args, ("godot", "reference.json", "candidate.json", 4, 16, 42))
|
||||
self.assertEqual(
|
||||
run_half.call_args_list[0].kwargs,
|
||||
{"grounded_a": False, "grounded_b": True},
|
||||
{"grounded_a": False, "grounded_b": True, "team_size": 1},
|
||||
)
|
||||
self.assertEqual(
|
||||
run_half.call_args_list[1].kwargs,
|
||||
{"grounded_a": True, "grounded_b": False},
|
||||
{"grounded_a": True, "grounded_b": False, "team_size": 1},
|
||||
)
|
||||
self.assertEqual(record["wins_a"], 4)
|
||||
self.assertEqual(record["wins_b"], 3)
|
||||
@@ -42,6 +42,37 @@ class EvaluatePairTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "even number"):
|
||||
evaluate.evaluate_pair("godot", "a", "b", episodes, 16, 1)
|
||||
|
||||
def test_rejects_unsupported_team_size_before_launch(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "team_size must be 1 or 2"):
|
||||
evaluate.run_half("godot", "a", "b", 2, 16, 1, team_size=3)
|
||||
with self.assertRaisesRegex(ValueError, "team_size must be 1 or 2"):
|
||||
evaluate.evaluate_pair("godot", "a", "b", 2, 16, 1, team_size=3)
|
||||
|
||||
@patch("evaluate.subprocess.run")
|
||||
def test_2v2_run_passes_team_size_to_godot(self, run_process) -> None:
|
||||
run_process.return_value.stdout = 'EVAL_RESULT {"episodes": 2, "goals_a": 1, "goals_b": 0, "draws": 1}\n'
|
||||
evaluate.run_half("godot", "a", "b", 2, 16, 9, team_size=2)
|
||||
command = run_process.call_args.args[0]
|
||||
self.assertIn("--eval_team_size=2", command)
|
||||
|
||||
@patch("evaluate.subprocess.run")
|
||||
def test_run_uses_portable_headless_renderer_and_writable_log(self, run_process) -> None:
|
||||
run_process.return_value.stdout = 'EVAL_RESULT {"episodes": 2, "goals_a": 1, "goals_b": 0, "draws": 1}\n'
|
||||
evaluate.run_half("godot", "a", "b", 2, 16, 9)
|
||||
command = run_process.call_args.args[0]
|
||||
for option in ("--display-driver", "headless", "--rendering-method", "gl_compatibility", "--audio-driver", "Dummy", "--log-file"):
|
||||
self.assertIn(option, command)
|
||||
|
||||
@patch("evaluate.run_half")
|
||||
def test_2v2_evaluation_preserves_side_swap_and_team_size(self, run_half) -> None:
|
||||
run_half.side_effect = [
|
||||
{"episodes": 2, "goals_a": 1, "goals_b": 0, "draws": 1},
|
||||
{"episodes": 2, "goals_a": 0, "goals_b": 1, "draws": 1},
|
||||
]
|
||||
evaluate.evaluate_pair("godot", "a", "b", 4, 16, 9, team_size=2)
|
||||
self.assertEqual(run_half.call_args_list[0].kwargs["team_size"], 2)
|
||||
self.assertEqual(run_half.call_args_list[1].kwargs["team_size"], 2)
|
||||
|
||||
@patch("evaluate.run_half")
|
||||
def test_identical_policy_results_cancel_physical_side_bias(self, run_half) -> None:
|
||||
# Replaying the same deterministic matchup must produce the same
|
||||
|
||||
@@ -11,6 +11,10 @@ def flag_value(flags: list[str], name: str) -> str:
|
||||
|
||||
|
||||
class Generation5ConfigTests(unittest.TestCase):
|
||||
def test_default_evaluation_seeds_are_multiple_and_unique(self) -> None:
|
||||
self.assertEqual(len(generation5.DEFAULT_EVALUATION_SEEDS), 3)
|
||||
self.assertEqual(len(set(generation5.DEFAULT_EVALUATION_SEEDS)), 3)
|
||||
|
||||
def test_stage_sequence_and_lineage(self) -> None:
|
||||
self.assertEqual([stage["number"] for stage in generation5.STAGES], [4, 5, 6])
|
||||
state = generation5.fresh_state()
|
||||
@@ -23,12 +27,14 @@ class Generation5ConfigTests(unittest.TestCase):
|
||||
for stage in generation5.STAGES:
|
||||
flags = stage["flags"]
|
||||
total = sum(
|
||||
float(flag_value(flags, name))
|
||||
float(flag_value(flags, name)) if name in flags else 0.0
|
||||
for name in (
|
||||
"--kickoff-chance",
|
||||
"--near-goal-chance",
|
||||
"--air-drill-chance",
|
||||
"--air-intercept-chance",
|
||||
"--wall-play-chance",
|
||||
"--rebound-chance",
|
||||
)
|
||||
)
|
||||
with self.subTest(stage=stage["name"]):
|
||||
@@ -41,6 +47,12 @@ class Generation5ConfigTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(flag_value(generation5.STAGES[2]["flags"], "--opponent-mode"), "league")
|
||||
|
||||
def test_league_stage_enables_wall_and_rebound_states_after_intercepts(self) -> None:
|
||||
self.assertEqual(flag_value(generation5.STAGES[2]["flags"], "--wall-play-chance"), "0.10")
|
||||
self.assertEqual(flag_value(generation5.STAGES[2]["flags"], "--rebound-chance"), "0.10")
|
||||
self.assertNotIn("--wall-play-chance", generation5.STAGES[0]["flags"])
|
||||
self.assertNotIn("--rebound-chance", generation5.STAGES[1]["flags"])
|
||||
|
||||
def test_telemetry_floors_fail_closed_on_missing_metric(self) -> None:
|
||||
ok, failures = generation5.telemetry_passes(
|
||||
generation5.STAGES[0], {"rollout/upright_fraction": 1.0}
|
||||
|
||||
Reference in New Issue
Block a user