Files
CosmicClash/training/generation5.py
T
Josh Creek 88591e031f chore(training): add air_approach_weight and restart stage-5 intercepts
Stage 5 blocked all three attempts on productive_air_touch_fraction
stuck exactly at 0.0 across a continuous 180M-step lineage, while
goal_rate/upright_fraction/forward_motion_fraction kept improving on
the same budget. forward_velocity_to_ball_weight (the term that solved
Stage 4's ground pursuit) is hard-gated below GROUND_HANDLING_HEIGHT
and does nothing in the air, so Stage 5's air_intercept_chance had no
matching aerial incentive to learn from. air_approach_weight adds the
airborne mirror (nose-first 3D closing speed, no uprightness
multiplier) and folds into HANDLING_REWARD_FLAGS so Stage 6 inherits
it too. Deleted the three blocked attempts and reset state to resume
Stage 5 from the Stage-4 checkpoint with the new term.
2026-08-18 16:03:56 +01:00

549 lines
25 KiB
Python

"""Run the post-generation-4 curriculum from the promoted Stage-3 policy.
This is intentionally separate from curriculum.py/curriculum_state.json:
generation 4 is a completed lineage and its final checkpoint is generation
5's fixed foundation. Stages 4-6 add one difficulty at a time:
4 handling -- upright, nose-led low-altitude movement
5 intercepts -- useful moving-ball aerial interceptions
6 league -- robustness against a pool of frozen historical styles
Each stage resumes from its passing predecessor, exports through the normal
run_training.sh parity check, records tail telemetry, and runs a paired
100-episode regression evaluation. State is restart-safe in
generation5_state.json.
"""
from __future__ import annotations
import argparse
import json
import pathlib
import subprocess
import sys
from datetime import datetime
from tensorboard.backend.event_processing.event_accumulator import EventAccumulator
TRAINING_DIR = pathlib.Path(__file__).resolve().parent
REPO_ROOT = TRAINING_DIR.parent
STATE_PATH = TRAINING_DIR / "generation5_state.json"
EVAL_HISTORY_PATH = TRAINING_DIR / "eval_history.json"
FOUNDATION_EXPERIMENT = "20260806-1939-curric-s3-gauntlet"
FOUNDATION_CHECKPOINT = TRAINING_DIR / "checkpoints" / FOUNDATION_EXPERIMENT / "final.zip"
FOUNDATION_EXPORT = REPO_ROOT / "Game" / "bots" / f"{FOUNDATION_EXPERIMENT}.json"
PROMOTED_EASY = REPO_ROOT / "Game" / "bots" / "promoted" / "easy.json"
MAX_RETRIES = 2
EVAL_EPISODES = 100
REGRESSION_MARGIN = 0.15
STANDING_ARGS = ["--ent-coef", "0.01", "--entropy-floor"]
# Scoring/ball-direction shaping inherited from generation 4. Handling
# replaces half the orientation-agnostic closing reward and all generic speed
# reward with nose-led ground approach, while keeping global tilt pressure
# small enough for flight. The first three Stage-4 attempts (2026-08-08/09)
# plateaued with upright_fraction/forward_motion_fraction flat at ~0.22-0.26
# against 0.45/0.25 floors for 120M cumulative timesteps: ground_tilt_penalty
# at 0.003 only cost a fully-sideways episode ~2.7 reward, trivial next to a
# goal (80) or a touch (0.7). ground_tilt_penalty is raised ~17x to 0.05 (a
# full sideways episode now costs ~45, comparable to a goal) and
# non_forward_penalty is a new term (ship_ai_controller.gd) directly costing
# sideways/reverse planar velocity near the floor, independent of the ball,
# since nothing previously penalized that at all. Both are floor-proximity
# penalties only, with nothing equivalent above GROUND_HANDLING_HEIGHT — on
# its own that risks teaching "avoid the floor" instead of "handle well on
# it", worsening Stage 3's already-airborne-heavy baseline. grounded_upright_
# reward is the positive counterpart: a bonus for genuine floor contact
# (not just low altitude) while upright, so grounding well is the locally
# profitable choice rather than merely the least-punished one.
#
# Round 2 (2026-08-11): grounded_upright_reward at 0.015 overshot. Four
# force-retries pushed upright_fraction from 0.265 to a plateauing 0.331,
# then the fifth jumped it to 0.696 (55% over the 0.45 floor) while
# goal_rate collapsed 0.542->0.366 and forward_motion_fraction fell
# 0.244->0.184 — the ship learned to sit pinned upright on the floor
# (vertical_thrust_mean went negative) and farm the bonus instead of
# playing. Root cause: 0.015/tick was actually *larger* than
# ball_distance_penalty's worst case (0.01/tick), so idling near the ball
# beat chasing it — not "comparable to time_penalty/ball_distance_penalty"
# as originally sized. Cut to 0.004/tick (a full grounded episode now caps
# at ~7.2, versus ball_distance_penalty's worst-case ~18 and a single goal's
# 80) — enough to stop "avoid the floor" without being worth farming over
# actually playing. Resets from the Stage-3 foundation again rather than
# continuing from the farming checkpoint, same reasoning as the ground_tilt/
# non_forward_penalty retune: don't resume a policy shaped by one reward
# balance into a meaningfully different one.
#
# Round 3 (2026-08-12): 0.004 stopped the farming (vertical_thrust_mean
# stayed positive, airborne_fraction flat) and goal_rate rose across the
# chain 0.569->0.598->0.604 — but upright_fraction went flat at ~0.26, and
# retry1 posted the best head-to-head in Stage-4 history (eval goal_rate
# 0.820, 53-29-18). Lining rounds 2 and 3 up by attempt shows the actual
# problem: where upright climbed goal_rate sagged, and where goal_rate
# climbed upright went flat. An *additive* uprightness bonus is an
# alternative to playing well, so the policy just picks whichever is
# cheaper and the magnitude only slides along that tradeoff — no value can
# buy both. Round 4 therefore changes the mechanism instead of the number:
# grounded_upright_reward drops to 0, and uprightness becomes a multiplier
# inside the nose-led approach term (ship_ai_controller.gd), which already
# requires moving forward at the ball. Upright now pays only *while*
# playing, so parked-and-upright and fast-but-sideways both pay zero and
# only all three behaviours together pay full.
#
# forward-velocity-to-ball rises 0.06 -> 0.15 because multiplying by
# uprightness cuts that term's expected per-tick value roughly 2-3x at
# current behaviour; without the raise the approach incentive would quietly
# weaken. non-forward-penalty is unchanged at 0.04 — it targets a specific
# behaviour and has not misfired.
#
# Round 5 (2026-08-14): round 4 also dropped ground-tilt-penalty 0.05 ->
# 0.02 on the theory that the multiplier could carry posture on its own.
# That confounded the experiment — two of the three changes *reduced*
# upright pressure at once (grounded_upright_reward to 0, tilt penalty cut
# 2.5x) while the multiplier only pays below GROUND_HANDLING_HEIGHT *and*
# while moving forward *and* facing the ball, i.e. a far narrower slice of
# ticks than the penalty it replaced. Net pressure fell and so did
# upright_fraction (0.268 -> 0.239 -> 0.238, the lowest of any round). The
# conjunctive part worked though: forward_motion_fraction reached its best
# sustained value (0.242) *without* goal_rate sagging, ep_rew_mean turned
# positive for the first time (+0.28), and the eval win rate hit 49% with
# no reward hacking. So round 5 restores ground-tilt-penalty to 0.05 and
# changes nothing else — a genuine single-variable test of multiplier plus
# full tilt pressure.
#
# Also added this round: grounded_upright_fraction, a *diagnostic, ungated*
# telemetry signal measuring uprightness over real floor-contact ticks
# rather than sub-3m ticks. upright_fraction has never exceeded 0.331
# across four rounds and ~560M steps without the policy cheating, and its
# denominator is dominated by ballistic transit (airborne_fraction ~0.45,
# mean_altitude ~4.4m) where attitude is not meaningfully controllable —
# so it likely cannot measure the behaviour the 0.45 floor was meant to
# capture. Re-baseline that floor from what the new signal reports rather
# than from another round of reshaping.
#
# Round 6 (2026-08-16): round 5 read grounded_upright_fraction 0.050 /
# 0.069 / 0.052 — when the ship touches the floor it is upright about 1
# time in 17 — and the user's own observation was "it spends the vast
# majority of the time on its side, driving upwards towards the ball". A
# critical review of the *simulation* rather than the reward found why six
# rounds of shaping could never work:
#
# 1. The hull was a 1x1x4 box with inertia (1,1,1) and no restoring
# torque anywhere, so belly-down and rolled-90 were geometrically
# identical resting states. "Upright" was not a physically
# distinguished state at all — the reward was paying for a property
# the simulation did not have.
# 2. ~65% of episodes spawned ships from _random_position, which samples
# Y uniformly over the full 18m volume (mean ~8.7m). The measured
# airborne_fraction ~0.44 was largely that spawn distribution, and
# every ground-handling term fades out above 3m, so the shaping
# being tuned barely ever applied.
# 3. air_drill_chance 0.20 spawned deliberately unreachable-without-
# climbing states in the stage meant to teach ground driving, and its
# own air_touch_fraction (0.0002) shows the drills were never solved.
#
# Fixes land in the physics and the task distribution instead of the
# reward: an altitude-faded righting torque plus a flat-bottomed hull and
# realistic inertia (ship.gd / ship.tscn) make belly-down a genuine
# attractor, ground_start_chance 0.50 actually starts the ship on the
# floor, and air-drill-chance goes to 0. The reward terms already built
# are left exactly as they were — they should finally pull in a direction
# the ship can go.
#
# Round 7 (2026-08-18): Stage 4 closed by human override (see TRAINING.md).
# Stage 5 (intercepts) then blocked all three attempts on the same single
# floor every time — rollout/productive_air_touch_fraction stayed exactly
# 0.0 across a continuous 180M-step lineage (each retry resumes the
# previous attempt's checkpoint, not a fresh run), while air_touch_fraction
# sat at noise level (0.00008 -> 0.00006 -> 0.00006) and goal_rate/
# upright_fraction/forward_motion_fraction all kept improving on the same
# budget. A dead-flat metric across that much continued training, next to
# metrics that keep moving, is the missing-mechanism signature from Round 6
# again, not a slow-learning one: forward_velocity_to_ball_weight -- the
# term that actually solved ground handling -- is hard-gated to
# ship.global_position.y < GROUND_HANDLING_HEIGHT and does nothing in the
# air, so air_intercept_chance (added for Stage 5) was asking for aerial
# pursuit with only the generic, orientation-agnostic velocity_to_ball_
# weight (0.04) to learn it from -- the same class of gap as Stage 4's
# missing ground-tilt/non-forward pressure before those were added.
#
# air_approach_weight (ship_ai_controller.gd) is the airborne mirror:
# nose-first 3D closing speed on the ball, active above
# GROUND_HANDLING_HEIGHT instead of below it (mutually exclusive with
# forward_velocity_to_ball_weight by altitude), with no uprightness
# multiplier since a real aerial requires pitching away from level. Set to
# 0.15 to match forward_velocity_to_ball_weight's proven-effective
# magnitude; added to HANDLING_REWARD_FLAGS (not just Stage 5's flags) so
# it also carries into Stage 6, which reuses these flags and its own
# air_intercept_chance. Stage 5 restarts from Stage 4's checkpoint rather
# than continuing retry2's, same reasoning as every previous mechanism
# change in this file: don't resume a policy shaped by an absent term into
# one where it now exists.
HANDLING_REWARD_FLAGS = [
"--velocity-to-ball-weight", "0.04",
"--forward-velocity-to-ball-weight", "0.15",
"--air-approach-weight", "0.15",
"--ball-distance-penalty", "0.01",
"--ball-touch-reward", "0.7",
"--ball-velocity-to-goal-weight", "0.06",
"--goal-reward", "80",
"--speed-reward-weight", "0.0",
"--tilt-penalty", "0.0002",
"--ground-tilt-penalty", "0.05",
"--non-forward-penalty", "0.04",
"--grounded-upright-reward", "0.0",
]
STAGES = [
{
"number": 4,
"name": "handling",
"timesteps": 40_000_000,
"flags": [
"--opponent-mode", "self_play",
"--kickoff-chance", "0.15",
"--near-goal-chance", "0.25",
"--air-drill-chance", "0.0",
"--air-intercept-chance", "0.0",
"--ground-start-chance", "0.50",
*HANDLING_REWARD_FLAGS,
],
# Conservative catastrophe floors, not claims of mastery. Tail values
# are recorded in state so later thresholds can be based on evidence.
"telemetry_floors": {
"rollout/goal_rate": 0.80,
"rollout/upright_fraction": 0.45,
"rollout/forward_motion_fraction": 0.25,
},
# At least 80% of the paired candidate-vs-Stage-3 episodes must end
# in a goal. This is separate from win-rate regression: a draw-heavy
# handling policy must not advance merely because neither bot won.
"evaluation_goal_rate_floor": 0.80,
# The paired side swap also measures physical spawn/team bias. This
# catches a broken team-frame action mapping even when model A's
# aggregate result looks balanced because it plays both sides.
"physical_side_imbalance_ceiling": 0.20,
},
{
"number": 5,
"name": "intercepts",
"timesteps": 60_000_000,
"flags": [
"--opponent-mode", "self_play",
"--kickoff-chance", "0.10",
"--near-goal-chance", "0.20",
"--air-drill-chance", "0.10",
"--air-intercept-chance", "0.45",
*HANDLING_REWARD_FLAGS,
],
"telemetry_floors": {
"rollout/goal_rate": 0.75,
"rollout/upright_fraction": 0.40,
"rollout/forward_motion_fraction": 0.20,
"rollout/productive_air_touch_fraction": 0.005,
},
"evaluation_goal_rate_floor": 0.75,
"physical_side_imbalance_ceiling": 0.20,
},
{
"number": 6,
"name": "league",
"timesteps": 100_000_000,
"flags": [
"--opponent-mode", "league",
"--kickoff-chance", "0.15",
"--near-goal-chance", "0.25",
"--air-drill-chance", "0.15",
"--air-intercept-chance", "0.25",
*HANDLING_REWARD_FLAGS,
],
"telemetry_floors": {
"rollout/goal_rate": 0.70,
"rollout/upright_fraction": 0.35,
"rollout/forward_motion_fraction": 0.18,
"rollout/productive_air_touch_fraction": 0.003,
},
"evaluation_goal_rate_floor": 0.70,
"physical_side_imbalance_ceiling": 0.20,
"league_pool": True,
},
]
def fresh_state() -> dict:
return {"stage_index": 0, "attempt": 0, "status": "in_progress", "log": []}
def load_state() -> dict:
return json.loads(STATE_PATH.read_text()) if STATE_PATH.exists() else fresh_state()
def save_state(state: dict) -> None:
STATE_PATH.write_text(json.dumps(state, indent=2) + "\n")
def passing_entry(state: dict, stage_index: int) -> dict:
for entry in state["log"]:
if entry["stage_index"] == stage_index and entry["decision"] == "pass":
return entry
raise RuntimeError(f"No passing generation-5 stage index {stage_index}")
def previous_attempt_entry(state: dict, stage_index: int, attempt: int) -> dict:
for entry in reversed(state["log"]):
if entry["stage_index"] == stage_index and entry["attempt"] == attempt - 1:
return entry
raise RuntimeError(f"No previous attempt for stage index {stage_index}, attempt {attempt}")
def resume_checkpoint(state: dict, stage_index: int, attempt: int, foundation: pathlib.Path) -> pathlib.Path:
if attempt > 0:
exp = previous_attempt_entry(state, stage_index, attempt)["experiment"]
return TRAINING_DIR / "checkpoints" / exp / "final.zip"
if stage_index == 0:
return foundation
exp = passing_entry(state, stage_index - 1)["experiment"]
return TRAINING_DIR / "checkpoints" / exp / "final.zip"
def reference_export(state: dict, stage_index: int) -> pathlib.Path:
if stage_index == 0:
return PROMOTED_EASY
exp = passing_entry(state, stage_index - 1)["experiment"]
return REPO_ROOT / "Game" / "bots" / f"{exp}.json"
def league_pool(state: dict) -> list[pathlib.Path]:
stage4 = passing_entry(state, 0)["experiment"]
stage5 = passing_entry(state, 1)["experiment"]
return [
FOUNDATION_EXPORT,
REPO_ROOT / "Game" / "bots" / f"{stage4}.json",
REPO_ROOT / "Game" / "bots" / f"{stage5}.json",
]
def telemetry_tail(experiment: str, count: int = 500) -> dict[str, float]:
log_dirs = sorted((TRAINING_DIR / "logs").glob(f"{experiment}_*"))
if not log_dirs:
return {}
event_files = sorted(log_dirs[-1].glob("events.out.tfevents.*"))
if not event_files:
return {}
accumulator = EventAccumulator(str(event_files[-1]), size_guidance={"scalars": 0})
accumulator.Reload()
result = {}
for tag in accumulator.Tags().get("scalars", []):
if not tag.startswith("rollout/"):
continue
values = [point.value for point in accumulator.Scalars(tag)[-count:]]
if values:
result[tag] = sum(values) / len(values)
return result
def telemetry_passes(stage: dict, telemetry: dict[str, float]) -> tuple[bool, list[str]]:
failures = []
for metric, floor in stage.get("telemetry_floors", {}).items():
value = telemetry.get(metric)
if value is None:
failures.append(f"{metric} missing")
elif value < floor:
failures.append(f"{metric}={value:.4f} < {floor:.4f}")
return not failures, failures
def run_training(state: dict, stage_index: int, attempt: int, args) -> str:
stage = STAGES[stage_index]
suffix = "" if attempt == 0 else f"-retry{attempt}"
experiment = f"{datetime.now().strftime('%Y%m%d-%H%M')}-gen5-s{stage['number']}-{stage['name']}{suffix}"
resume = resume_checkpoint(state, stage_index, attempt, pathlib.Path(args.foundation_checkpoint))
if not resume.exists():
raise FileNotFoundError(f"Resume checkpoint not found: {resume}")
cmd = [
"./run_training.sh", experiment,
"--timesteps", str(stage["timesteps"]),
"--n-parallel", str(args.n_parallel),
"--speedup", str(args.speedup),
"--resume", str(resume),
*STANDING_ARGS,
*stage["flags"],
]
if stage.get("league_pool"):
pool = league_pool(state)
missing = [str(path) for path in pool if not path.exists()]
if missing:
raise FileNotFoundError(f"League pool models missing: {missing}")
cmd += ["--opponent-pool", ",".join(str(path) for path in pool)]
print(f"\n=== Generation 5 Stage {stage['number']} {stage['name']} attempt {attempt + 1} ===")
print(" ".join(cmd))
if args.dry_run:
return experiment
subprocess.run(cmd, cwd=TRAINING_DIR, check=True)
return experiment
def evaluate(experiment: str, reference: pathlib.Path, args) -> 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),
]
if args.godot_bin:
cmd += ["--godot_bin", args.godot_bin]
subprocess.run(cmd, cwd=TRAINING_DIR, check=True)
return json.loads(EVAL_HISTORY_PATH.read_text())[-1]
def match_passes(record: dict) -> bool:
candidate = record["wins_a"] / record["episodes"]
reference = record["wins_b"] / record["episodes"]
return reference - candidate < REGRESSION_MARGIN
def evaluation_goal_rate(record: dict) -> float:
"""Fraction of paired evaluation episodes that ended in either bot scoring."""
return (record["wins_a"] + record["wins_b"]) / record["episodes"]
def physical_side_imbalance(record: dict) -> float:
"""Absolute physical-team win margin as a fraction of all episodes."""
physical = record["physical_team_wins"]
return abs(physical["team_0"] - physical["team_1"]) / record["episodes"]
def commit_progress(experiment: str) -> None:
subprocess.run(["git", "add", STATE_PATH.name, EVAL_HISTORY_PATH.name], cwd=TRAINING_DIR, check=True)
if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=TRAINING_DIR).returncode == 0:
return
subprocess.run(
["git", "commit", "-m", f"chore(training): generation 5 progress after {experiment}"],
cwd=TRAINING_DIR,
check=True,
)
subprocess.run(["git", "push"], cwd=TRAINING_DIR, check=True)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
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("--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()
state = load_state()
if state["status"] == "done":
print("Generation 5 is already complete.")
return
if state["status"] == "blocked":
if args.force_retry:
state["attempt"] += 1
state["status"] = "in_progress"
save_state(state)
elif args.skip_to_next_stage:
state["stage_index"] += 1
state["attempt"] = 0
state["status"] = "in_progress"
save_state(state)
else:
stage = STAGES[state["stage_index"]]
print(f"BLOCKED at Stage {stage['number']} {stage['name']}; inspect {STATE_PATH.name}.")
print("Use --force-retry after adjustment or --skip-to-next-stage after human review.")
sys.exit(1)
while state["stage_index"] < len(STAGES):
stage_index = state["stage_index"]
attempt = state["attempt"]
stage = STAGES[stage_index]
experiment = run_training(state, stage_index, attempt, args)
if args.dry_run:
return
telemetry = telemetry_tail(experiment)
telemetry_ok, telemetry_failures = telemetry_passes(stage, telemetry)
references = [reference_export(state, stage_index)]
if stage.get("league_pool"):
references.extend(league_pool(state))
# 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]
match_ok = all(match_passes(record) for record in records)
evaluation_goal_floor = stage.get("evaluation_goal_rate_floor", 0.0)
evaluation_goal_failures = [
f"{pathlib.Path(record['model_b']).name}: goal_rate={evaluation_goal_rate(record):.3f} "
f"< {evaluation_goal_floor:.3f}"
for record in records
if evaluation_goal_rate(record) < evaluation_goal_floor
]
scoring_ok = not evaluation_goal_failures
side_imbalance_ceiling = stage.get("physical_side_imbalance_ceiling", 1.0)
side_balance_failures = [
f"{pathlib.Path(record['model_b']).name}: physical_side_imbalance="
f"{physical_side_imbalance(record):.3f} > {side_imbalance_ceiling:.3f}"
for record in records
if physical_side_imbalance(record) > side_imbalance_ceiling
]
side_balance_ok = not side_balance_failures
decision = "pass" if match_ok and telemetry_ok else "fail"
if not scoring_ok or not side_balance_ok:
decision = "fail"
entry = {
"stage_index": stage_index,
"stage_number": stage["number"],
"stage_name": stage["name"],
"experiment": experiment,
"attempt": attempt,
"telemetry_tail": telemetry,
"telemetry_failures": telemetry_failures,
"evaluation_goal_failures": evaluation_goal_failures,
"side_balance_failures": side_balance_failures,
"eval": records[0],
"evals": records,
"decision": decision,
}
state["log"].append(entry)
print(
f"{experiment}: match={'pass' if match_ok else 'fail'}, "
f"scoring={'pass' if scoring_ok else 'fail'}, "
f"side_balance={'pass' if side_balance_ok else 'fail'}, "
f"telemetry={'pass' if telemetry_ok else 'fail'} -> {decision}"
)
for failure in telemetry_failures:
print(f" {failure}")
for failure in evaluation_goal_failures:
print(f" {failure}")
for failure in side_balance_failures:
print(f" {failure}")
if decision == "pass":
state["stage_index"] += 1
state["attempt"] = 0
save_state(state)
commit_progress(experiment)
continue
if attempt >= MAX_RETRIES:
state["status"] = "blocked"
save_state(state)
commit_progress(experiment)
print(f"BLOCKED after {MAX_RETRIES + 1} attempts at Stage {stage['number']}.")
sys.exit(1)
state["attempt"] += 1
save_state(state)
commit_progress(experiment)
state["status"] = "done"
save_state(state)
commit_progress(state["log"][-1]["experiment"])
print("Generation 5 complete: handling, intercepts, and league stages passed.")
if __name__ == "__main__":
main()