fix(training): correct stage-3 eval (locomotion-mask bugfix) and add grounded aggression stage

Re-ran stage-3 (curric-s3-no_draws vs curric-s2-defend) and the missing
stage-4 gate now that the locomotion-mask inference bugfix is in. Both
reverse or contradict the pre-fix bookkeeping: curric-s2-defend (grounded)
beats curric-s3-no_draws 60-26 and curric-s4-mechanics 57-24 when fairly
evaluated, so lifting the locomotion mask in stage 3 was a real regression
in floor play, not the improvement the buggy eval reported.

Adds a stage-5 "aggression" curriculum entry that resumes from stage 2
directly (via new resume_from_experiment/reference_experiment stage-dict
overrides in curriculum.py) instead of compounding the regression through
stages 3-4, keeps the locomotion mask on, and retunes ball-pursuit reward
weights for much more aggressive floor play. Extends train.py with the
three new --velocity-to-ball-weight/--ball-distance-penalty/--ball-touch-reward
flags needed to forward that retune to Godot's existing SHIP_AI_OVERRIDES.

curriculum_state.json and TRAINING.md are corrected/annotated in place
rather than silently rewritten, so the regression stays visible in history.
This commit is contained in:
Josh Creek
2026-07-22 12:48:45 +01:00
parent cf4859e61c
commit fca6a46200
5 changed files with 149 additions and 20 deletions
+53 -5
View File
@@ -80,6 +80,30 @@ STAGES = [
"flags": [],
"grounded": False,
},
{
"name": "aggression",
# Deliberately resumes from stage 2 (curric-s2-defend), not stage 4
# (see resume_from_experiment/reference_experiment below) — the
# locomotion-mask inference bugfix (8c15c46) revealed that stage 3's
# full-3D unmask was a clear regression, not an improvement: fairly
# evaluated, curric-s2-defend beats both curric-s3-no_draws (26-60)
# and curric-s4-mechanics (24-57). Rather than compound that
# regression, this stage keeps the locomotion mask ON (matching
# stage 2's own regime) and just retunes ball-pursuit reward weights,
# so it can't reopen the same grounded-to-3D transition that caused
# the earlier failure. Full 3D flight is parked as a separate,
# later initiative.
"flags": [
"--opponent-mode", "self_play",
"--no-allow-vertical", "--no-allow-pitch-roll",
"--velocity-to-ball-weight", "0.05", # up from 0.02
"--ball-distance-penalty", "0.006", # up from 0.002
"--ball-touch-reward", "0.5", # up from 0.4
],
"grounded": True,
"resume_from_experiment": "curric-s2-defend",
"reference_experiment": "curric-s2-defend",
},
]
@@ -109,18 +133,31 @@ def resume_checkpoint(stage_index: int, attempt: int, seed_checkpoint: str | Non
# work, so stage 1 starts from a random policy under its own
# regime unless --seed-checkpoint says otherwise.
return seed_checkpoint
prev_stage = STAGES[stage_index - 1]["name"]
prev_experiment = _passing_experiment_for_stage(stage_index - 1)
prev_experiment = _resume_source_experiment(stage_index)
return str(TRAINING_DIR / "checkpoints" / prev_experiment / "final.zip")
def reference_bot(stage_index: int) -> str:
if stage_index == 0:
return str(ROOKIE_REFERENCE)
prev_experiment = _passing_experiment_for_stage(stage_index - 1)
prev_experiment = _reference_source_experiment(stage_index)
return str(TRAINING_DIR.parent / "Game" / "bots" / f"{prev_experiment}.json")
# A stage normally chains off "whatever passed at the previous index," but a
# stage can instead name an explicit resume_from_experiment/reference_experiment
# to skip a since-regressed branch (see the "aggression" stage) without
# rewriting history for the stages it's skipping past.
def _resume_source_experiment(stage_index: int) -> str:
override = STAGES[stage_index].get("resume_from_experiment")
return override if override else _passing_experiment_for_stage(stage_index - 1)
def _reference_source_experiment(stage_index: int) -> str:
override = STAGES[stage_index].get("reference_experiment")
return override if override else _passing_experiment_for_stage(stage_index - 1)
def _passing_experiment_for_stage(stage_index: int) -> str:
state = load_state()
for entry in state["log"]:
@@ -129,6 +166,15 @@ def _passing_experiment_for_stage(stage_index: int) -> str:
raise RuntimeError(f"No passing attempt recorded for stage {stage_index} ({STAGES[stage_index]['name']})")
def _grounded_for_experiment(experiment: str) -> bool:
if experiment == "rookie":
return False
for index, stage in enumerate(STAGES):
if experiment_name(index, 0) == experiment:
return stage["grounded"]
raise ValueError(f"Unknown experiment for groundedness lookup: {experiment}")
def run_stage_attempt(stage_index: int, attempt: int, args) -> str:
exp = experiment_name(stage_index, attempt)
resume = resume_checkpoint(stage_index, attempt, args.seed_checkpoint)
@@ -150,8 +196,10 @@ def run_stage_attempt(stage_index: int, attempt: int, args) -> str:
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"]
if stage_index == 0:
# rookie.json predates the locomotion mask entirely — always full 3D.
return False
return _grounded_for_experiment(_reference_source_experiment(stage_index))
def evaluate_attempt(experiment: str, reference: str, episodes: int, stage_index: int) -> dict:
+27 -9
View File
@@ -1,5 +1,5 @@
{
"stage_index": 3,
"stage_index": 4,
"attempt": 0,
"status": "in_progress",
"log": [
@@ -40,16 +40,34 @@
"experiment": "curric-s3-no_draws",
"attempt": 0,
"eval": {
"timestamp": "2026-07-21T20:46:34+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/curric-s3-no_draws.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/curric-s2-defend.json",
"timestamp": "2026-07-22T11:36:42+00:00",
"model_a": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/curric-s3-no_draws.json",
"model_b": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/curric-s2-defend.json",
"episodes": 100,
"wins_a": 44,
"wins_b": 19,
"draws": 37,
"win_rate_a": 0.44
"wins_a": 26,
"wins_b": 60,
"draws": 14,
"win_rate_a": 0.26
},
"decision": "pass"
"decision": "fail",
"note": "Original eval (44-19, recorded 2026-07-21T20:46:34) predates the locomotion-mask inference bugfix (8c15c46) and ran with the grounded stage-2 reference unfairly unmasked. Re-run post-fix with --grounded-b reverses the verdict: stage 3's full-3D unmask is a clear regression from stage 2, not an improvement. Not retried via the normal flag-retry mechanism — see stage_index 4 (aggression), which redirects around this branch by resuming from curric-s2-defend directly instead."
},
{
"stage_index": 3,
"experiment": "curric-s4-mechanics",
"attempt": 0,
"eval": {
"timestamp": "2026-07-22T11:38:57+00:00",
"model_a": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/curric-s4-mechanics.json",
"model_b": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/curric-s3-no_draws.json",
"episodes": 100,
"wins_a": 26,
"wins_b": 33,
"draws": 41,
"win_rate_a": 0.26
},
"decision": "pass",
"note": "Passes only against its own (already-regressed) predecessor, curric-s3-no_draws. Evaluated directly against grounded curric-s2-defend (2026-07-22T11:40:07), curric-s4-mechanics also loses clearly: 24-57-19. Do not treat this stage's 'pass' as evidence curric-s4-mechanics is the strongest available model overall — see stage 2's note and stage_index 4 (aggression)."
}
]
}
+30
View File
@@ -58,5 +58,35 @@
"wins_b": 19,
"draws": 37,
"win_rate_a": 0.44
},
{
"timestamp": "2026-07-22T11:36:42+00:00",
"model_a": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/curric-s3-no_draws.json",
"model_b": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/curric-s2-defend.json",
"episodes": 100,
"wins_a": 26,
"wins_b": 60,
"draws": 14,
"win_rate_a": 0.26
},
{
"timestamp": "2026-07-22T11:38:57+00:00",
"model_a": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/curric-s4-mechanics.json",
"model_b": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/curric-s3-no_draws.json",
"episodes": 100,
"wins_a": 26,
"wins_b": 33,
"draws": 41,
"win_rate_a": 0.26
},
{
"timestamp": "2026-07-22T11:40:07+00:00",
"model_a": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/curric-s4-mechanics.json",
"model_b": "/Users/jcreek/Documents/repos/GitHub/CosmicClash/Game/bots/curric-s2-defend.json",
"episodes": 100,
"wins_a": 24,
"wins_b": 57,
"draws": 19,
"win_rate_a": 0.24
}
]
+15
View File
@@ -84,6 +84,18 @@ def parse_args():
default=None,
help="Allow pitch/roll rotation (default true)",
)
curriculum.add_argument(
"--velocity-to-ball-weight", type=float, default=None,
help="Overrides ShipAIController.velocity_to_ball_weight (dense reward for closing speed toward the ball)",
)
curriculum.add_argument(
"--ball-distance-penalty", type=float, default=None,
help="Overrides ShipAIController.ball_distance_penalty (dense per-tick cost scaled by distance to the ball)",
)
curriculum.add_argument(
"--ball-touch-reward", type=float, default=None,
help="Overrides ShipAIController.ball_touch_reward (event reward on ball contact, cooldown-gated)",
)
return parser.parse_args()
@@ -101,6 +113,9 @@ def _curriculum_kwargs(args) -> dict:
"ball_near_goal_chance": args.near_goal_chance,
"ai_allow_vertical": args.allow_vertical,
"ai_allow_pitch_roll": args.allow_pitch_roll,
"ai_velocity_to_ball_weight": args.velocity_to_ball_weight,
"ai_ball_distance_penalty": args.ball_distance_penalty,
"ai_ball_touch_reward": args.ball_touch_reward,
}
return {key: value for key, value in mapping.items() if value is not None}