fix(*): apply the locomotion mask during in-game/eval inference, not just training

AIShipController (eval + real gameplay) ran the raw policy output unmasked
regardless of allow_vertical/allow_pitch_roll, while ShipAIController
(training) correctly discarded those axes for grounded curriculum stages.
A grounded-trained model's untrained vertical/pitch-roll output reached the
ship as noise during eval, understating it against models that were never
handicapped this way.
This commit is contained in:
Josh Creek
2026-07-21 22:23:09 +01:00
parent dd2b3c570b
commit 8c15c466ef
5 changed files with 78 additions and 8 deletions
+17 -2
View File
@@ -60,6 +60,7 @@ STAGES = [
"--attack-goal-bias", "1.0",
"--no-allow-vertical", "--no-allow-pitch-roll",
],
"grounded": True,
},
{
"name": "defend",
@@ -67,14 +68,17 @@ STAGES = [
"--opponent-mode", "self_play",
"--no-allow-vertical", "--no-allow-pitch-roll",
],
"grounded": True,
},
{
"name": "no_draws",
"flags": ["--draw-penalty", "5"],
"grounded": False,
},
{
"name": "mechanics",
"flags": [],
"grounded": False,
},
]
@@ -145,9 +149,20 @@ def run_stage_attempt(stage_index: int, attempt: int, args) -> str:
return exp
def evaluate_attempt(experiment: str, reference: str, episodes: int) -> dict:
def reference_grounded(stage_index: int) -> bool:
# rookie.json predates the locomotion mask entirely — always full 3D.
return False if stage_index == 0 else STAGES[stage_index - 1]["grounded"]
def evaluate_attempt(experiment: str, reference: str, episodes: int, stage_index: int) -> dict:
candidate = TRAINING_DIR.parent / "Game" / "bots" / f"{experiment}.json"
cmd = [".venv/bin/python", "evaluate.py", str(candidate), reference, "--episodes", str(episodes)]
# Must match how each side was actually trained — see ai_ship_controller.gd's
# allow_vertical/allow_pitch_roll and evaluate.py's --grounded-a/-b.
if STAGES[stage_index]["grounded"]:
cmd.append("--grounded-a")
if reference_grounded(stage_index):
cmd.append("--grounded-b")
print(" ".join(cmd))
subprocess.run(cmd, cwd=TRAINING_DIR, check=True)
history = json.loads(EVAL_HISTORY_PATH.read_text())
@@ -217,7 +232,7 @@ def main():
experiment = run_stage_attempt(stage_index, attempt, args)
reference = reference_bot(stage_index)
record = evaluate_attempt(experiment, reference, EVAL_EPISODES)
record = evaluate_attempt(experiment, reference, EVAL_EPISODES, stage_index)
decision = decide(record)
print(f"{experiment}: candidate {record['wins_a']}-{record['wins_b']} reference "
+29 -3
View File
@@ -23,7 +23,16 @@ 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) -> dict:
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,
) -> dict:
cmd = [
godot_bin,
"--path",
@@ -37,6 +46,14 @@ def run_half(godot_bin: str, model_a: str, model_b: str, episodes: int, speedup:
f"--speedup={speedup}",
f"--env_seed={seed}",
]
# Must match how each model was actually trained (see AIShipController's
# allow_vertical/allow_pitch_roll) — a curriculum stage 1/2 model never
# got a reward gradient on these axes, so leaving them unmasked here adds
# untrained aerial noise the model's own 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():
@@ -57,6 +74,12 @@ def main():
)
parser.add_argument("--speedup", type=int, default=16)
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 (curriculum stages 1-2)"
)
parser.add_argument(
"--grounded-b", action="store_true", help="model_b was trained with locomotion masked (curriculum stages 1-2)"
)
args = parser.parse_args()
model_a = str(pathlib.Path(args.model_a).resolve())
@@ -65,8 +88,11 @@ def main():
# Half the episodes on each side to cancel any residual side asymmetry;
# different seeds so the halves see different randomized episode states.
first = run_half(args.godot_bin, model_a, model_b, half, args.speedup, seed=1)
second = run_half(args.godot_bin, model_b, model_a, half, args.speedup, seed=2)
# Groundedness is per physical model, so it swaps sides along with it.
first = run_half(args.godot_bin, model_a, model_b, half, args.speedup, seed=1,
grounded_a=args.grounded_a, grounded_b=args.grounded_b)
second = run_half(args.godot_bin, model_b, model_a, half, args.speedup, seed=2,
grounded_a=args.grounded_b, grounded_b=args.grounded_a)
record = {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),