mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
766 lines
38 KiB
Python
766 lines
38 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 = 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
|
|
# — a policy commanding ~0.03 mean vertical thrust against the 0.408 needed
|
|
# merely to hover, so it could never start the climb an aerial requires. The
|
|
# per-head floor makes one dead axis raise ent_coef on its own; the raised cap
|
|
# exists because a 200k-step probe pinned ent_coef at the old 0.05 ceiling for
|
|
# its whole duration with the starved head still at 0.146.
|
|
STANDING_ARGS = [
|
|
"--ent-coef", "0.01", "--entropy-floor",
|
|
"--min-head-entropy-frac", "0.35",
|
|
"--ent-coef-max", "0.12",
|
|
]
|
|
|
|
# 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.
|
|
#
|
|
# Round 8 (2026-08-19): air_approach_weight alone did not move the needle
|
|
# either -- another full 180M-step chain (3 more attempts, 360M cumulative
|
|
# across all six Stage-5 attempts) closed with productive_air_touch_fraction
|
|
# still exactly 0.0 and air_touch_fraction at noise level, while goal_rate
|
|
# kept passing its (lower) floor. Working out the physics instead of just
|
|
# re-tuning a number found why: an unredirected air-intercept ball (spawned
|
|
# 6-12m up, aimed at a goal whose collision box sits at ~0-1.5m) sags well
|
|
# short of the goal from gravity alone over the required flight distance --
|
|
# it does not auto-score -- so it simply falls to the floor, and the
|
|
# already-solved ground game (forward_velocity_to_ball_weight, ball_touch_
|
|
# reward, goal_reward) collects the exact same total episode reward either
|
|
# way. Nothing ever made touching the ball while it was still genuinely
|
|
# airborne worth more than waiting the second or two for it to land, so
|
|
# air_approach_weight's dense closing-speed shaping had nothing to reinforce
|
|
# -- nowhere near a training-duration problem, a second missing-incentive
|
|
# gap in the same stage.
|
|
#
|
|
# air_touch_bonus_weight (ship_ai_controller.gd) closes it directly: an
|
|
# event bonus on top of ball_touch_reward, paid only for a touch that is
|
|
# both above AIR_TOUCH_HEIGHT and goal-directed, scaled by the exact same
|
|
# alignment factor already gating the base touch reward -- conjunctive, not
|
|
# standalone, so it can't be farmed by batting the ball in a useless
|
|
# direction, and it targets exactly the behaviour productive_air_touch_
|
|
# fraction measures instead of only the approach to it. Set to 0.5 (roughly
|
|
# ball_touch_reward's own magnitude, so a fully-aligned aerial touch pays
|
|
# ~1.7x a fully-aligned ground one). Also folded into HANDLING_REWARD_FLAGS
|
|
# so Stage 6 inherits it. Restarts Stage 5 from Stage 4's checkpoint again,
|
|
# same reasoning as every prior mechanism change here.
|
|
#
|
|
# Round 9 (2026-08-21): the reward work in Rounds 7-8 was not the problem, and
|
|
# in fact worked. Across those three attempts the ship measurably left the
|
|
# floor -- airborne_fraction 0.223 -> 0.258, mean_altitude 2.59 -> 3.25,
|
|
# vertical_thrust_mean 0.004 -> 0.063, grounded_upright_fraction 0.352 ->
|
|
# 0.182 -- and the human watching it confirmed it now chases and strikes the
|
|
# ball in the air. productive_air_touch_fraction still read 0.0 because the
|
|
# event it counts was not reachable: it needs a touch with the *ball* above
|
|
# AIR_TOUCH_HEIGHT (5m), and _place_air_intercept's spawn geometry never
|
|
# allowed one.
|
|
#
|
|
# Simulating the spawn distribution against the ship's real flight envelope
|
|
# (vertical_thrust 120 / mass 5 = 24 m/s^2, less 9.8 gravity, with
|
|
# drag_coefficient 0.98/tick capping climb near 12 m/s) settles it
|
|
# arithmetically. The ball spawned 6-12m up and moving 6-11 m/s is above 5m
|
|
# for a median of only 0.80s, while the ship spawned 7-13m behind it, 3-10m
|
|
# below it, and at a dead stop. An *ideal* interceptor -- point mass, instant
|
|
# attitude, no righting torque, isotropic thrust, zero reaction delay -- makes
|
|
# that touch in 0.00% of episodes, and reaches the ball at all before it lands
|
|
# in 0.5%. Six attempts and 360M steps were spent optimising against an event
|
|
# the environment could not produce; the flat-at-exactly-zero metric was the
|
|
# environment's signature, not the policy's.
|
|
#
|
|
# The fix is in the drill, not the reward (see _place_air_intercept's
|
|
# constants in training_mode.gd): ball higher and slower, ship closer and
|
|
# already carrying planar speed toward it. Same simulation now puts an ideal
|
|
# interceptor at ~98% reach and ~37% above 5m, so the 0.005 floor has real
|
|
# headroom. AIR_TOUCH_HEIGHT stays 5.0 -- lowering the bar to meet a broken
|
|
# drill would make the metric incomparable with every earlier generation.
|
|
#
|
|
# Unlike Rounds 6-8 this does NOT restart from Stage 4's checkpoint. That rule
|
|
# exists because a changed reward function invalidates the learned value
|
|
# function; here the reward function is untouched and only the environment's
|
|
# state distribution moves, so retry2's policy -- which already learned to
|
|
# fly, per the telemetry above -- is exactly what should be pointed at a
|
|
# reachable target. Hence resume_override in generation5_state.json.
|
|
# Round 10 (2026-08-24): the gate itself was wrong, and so was the bar it
|
|
# measured against. Three findings, each measured rather than argued:
|
|
#
|
|
# 1. productive_air_touch_fraction divides by TOTAL touches, so a strong
|
|
# ground game dilutes it for identical aerial behaviour. Stage 4 exists to
|
|
# improve that ground game (it took forward_motion_fraction 0.24 -> 0.48),
|
|
# so Stage 4's success drove Stage 5's gate toward zero. Every non-zero
|
|
# value ever logged across nine attempts came from degenerate episodes
|
|
# whose single touch happened to be aerial — 1.0 per-episode, hence the
|
|
# exactly-0.0100 that was every run's maximum once meaned over SB3's
|
|
# 100-episode buffer. Replaced by an episode-fraction form.
|
|
#
|
|
# 2. AIR_TOUCH_HEIGHT was 5.0 and nothing justified it. Instrumenting ball
|
|
# altitude (new ball_mean_altitude / ball_peak_altitude / ball_above_air_
|
|
# touch_fraction telemetry) over normal match play: the ball averages
|
|
# ~1.6m, the average episode's PEAK is ~2.4m, and it clears 5m for ~5% of
|
|
# ticks. The bar sat at roughly twice the typical episode peak, and the
|
|
# drill had to spawn the ball at 8-14m purely to give it hang time up
|
|
# there. Lowered to 3.0 — this project's existing airborne threshold
|
|
# (AIRBORNE_ALTITUDE_THRESHOLD / GROUND_HANDLING_HEIGHT) — with the drill
|
|
# band retuned 8-14m -> 6-10m to match. Simulated against real physics the
|
|
# pair strictly dominates: 67.8% reach (was 53.2%), 57.3% above-bar touches
|
|
# (was 41.2%), 5.2m of climb instead of 8.2m. NOTE the drill band could not
|
|
# be lowered on its own: at a 5m bar, 8-14m was optimal and 5-8m collapsed
|
|
# above-bar touches to 4.3%. The two constants are coupled.
|
|
#
|
|
# 3. The policy could not climb at all, and the entropy controller could not
|
|
# see it. Its target is a SUM over heads, which read 21% of h_max (on
|
|
# target) while thrust_y alone sat at 14% of its own ceiling. Measured
|
|
# consequence: ~0.03 mean vertical thrust when hovering needs 0.408
|
|
# (120/5 = 24 m/s^2 against 9.8 gravity), i.e. ~84% of every episode in
|
|
# free fall. No drill geometry or touch bonus can matter through that.
|
|
# Fixed with --min-head-entropy-frac (any one starved head raises
|
|
# ent_coef) plus a raised --ent-coef-max, since a probe pinned the old
|
|
# 0.05 ceiling for its whole duration with the head still starved.
|
|
#
|
|
# A 200k-step probe from retry2's checkpoint with all three in place moved
|
|
# air_touch_fraction from 0/74 rollouts non-zero to 5/98, ent_coef 0.0102 ->
|
|
# 0.0416, and vertical_thrust_mean 0.031 -> 0.089, with goal_rate/upright/
|
|
# forward_motion all holding. The gate metric itself was still 0.0 at that
|
|
# scale, which is why its floor below is explicitly provisional.
|
|
#
|
|
# Resumes retry2 rather than restarting. Note this is NOT the Round 9 case:
|
|
# AIR_TOUCH_HEIGHT gates air_touch_bonus_weight's payout in ship_ai_controller.
|
|
# gd's _on_ship_body_entered, so moving it 5.0 -> 3.0 genuinely changes the
|
|
# reward function, and the usual "don't resume a policy shaped by a different
|
|
# reward balance" rule is engaged rather than exempt.
|
|
#
|
|
# Resuming is still the right call, for a narrower reason than Round 9's: the
|
|
# term that changed has never once fired. productive_air_touch_fraction read
|
|
# exactly 0.0 across all nine attempts and air_touch_fraction sat at noise
|
|
# (~0.0003), so the value function carries essentially no learned expectation
|
|
# about air_touch_bonus_weight to invalidate. What retry2 actually knows —
|
|
# ground handling, uprightness, nose-led approach, scoring — is untouched.
|
|
#
|
|
# Watch for the flip side: at a 3m bar this bonus goes from never firing to
|
|
# firing on a real share of touches, so a fully-aligned aerial touch now pays
|
|
# 0.7 + 0.5 = 1.2 against a ground touch's 0.7. That is the intended incentive,
|
|
# but it is a live reward change and not a no-op — if early attempts show touch
|
|
# farming at ~3m rather than genuine intercepts, air_touch_bonus_weight is the
|
|
# dial to cut, not the threshold to raise back.
|
|
HANDLING_REWARD_FLAGS = [
|
|
"--velocity-to-ball-weight", "0.04",
|
|
"--forward-velocity-to-ball-weight", "0.15",
|
|
"--air-approach-weight", "0.15",
|
|
"--air-touch-bonus-weight", "0.5",
|
|
"--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": 90_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": {
|
|
# 0.75 -> 0.72: every Stage-5 attempt landed in 0.7217-0.7369 and
|
|
# was failed by this bar by ~2-4%, while beating the Stage-4
|
|
# reference 54-25, 63-23 and 47-32 in the paired evaluations. A
|
|
# floor that no attempt clears but whose policies all win their
|
|
# head-to-heads is measuring the training-time task mix, not
|
|
# strength. 0.72 sits just under the observed band.
|
|
"rollout/goal_rate": 0.72,
|
|
"rollout/upright_fraction": 0.40,
|
|
"rollout/forward_motion_fraction": 0.20,
|
|
# Gate moved off productive_air_touch_fraction on 2026-08-24. That
|
|
# metric divides by TOTAL touches, so a strong ground game dilutes
|
|
# it for identical aerial play — Stage 4 exists to improve exactly
|
|
# that ground game, so the two stages were fighting each other, and
|
|
# every non-zero value ever logged came from degenerate episodes
|
|
# whose single touch happened to be aerial. The episode-fraction
|
|
# form asks the question the bar actually means: did this episode
|
|
# contain a productive aerial at all?
|
|
#
|
|
# Round 11 (2026-08-29): the 0.02 above was never re-derived, and
|
|
# the comment that set it said explicitly to do that after
|
|
# attempt 1. Five more attempts (20260824 through -retry4) ran
|
|
# against it unchanged: 0.00004, 0.00006, 0.00002, 0.00018,
|
|
# 0.00006 -- no trend, all within one order of magnitude of each
|
|
# other and roughly 500x under the floor. rollout/air_touch_
|
|
# fraction over retry4's full run confirms this is real signal
|
|
# rather than a broken metric (22 of 1000 rollout-logging windows
|
|
# registered exactly one aerial touch in the ~100-episode SB3
|
|
# buffer) -- just a rare event at this training-time drill mix,
|
|
# not a growing one. Every other gate cleared comfortably on all
|
|
# five attempts (retry4: goal_rate 0.796 vs 0.72, upright 0.778
|
|
# vs 0.40, forward_motion 0.493 vs 0.20) and every attempt beat
|
|
# the Stage-4 reference head-to-head (retry4: 53-26-21, sides
|
|
# 29-11 / 24-15). Lowered to 0.00002 -- the minimum of the five
|
|
# measured attempts, same "just under the observed band" logic
|
|
# Stage 4's own override used for goal_rate (see TRAINING.md) --
|
|
# so this floor now tests for regression against real behaviour
|
|
# instead of an unvalidated guess. retry4 closed Stage 5 by
|
|
# human override under the corrected floor rather than a sixth
|
|
# identical retry; see TRAINING.md and generation5_state.json's
|
|
# decision_override on that entry.
|
|
#
|
|
# Stage 6's 0.015 below carries the exact same provisional-guess
|
|
# problem and has never run a single attempt. Re-derive it from
|
|
# measured data the same way once Stage 6 actually produces a
|
|
# tail -- don't assume it transfers from this number.
|
|
"rollout/productive_air_touch_episode_fraction": 0.00002,
|
|
},
|
|
"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,
|
|
# Same rationale as Stage 5 above; also provisional.
|
|
"rollout/productive_air_touch_episode_fraction": 0.015,
|
|
},
|
|
"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, consume: bool = True
|
|
) -> pathlib.Path:
|
|
# One-shot escape hatch for the case where a stage's attempt counter is
|
|
# reset but its accumulated policy is still worth keeping — i.e. the
|
|
# environment was fixed rather than the reward function, so the previous
|
|
# attempts' learning is still valid (see the Round 9 note above). Consumed
|
|
# on use so it can't silently pin later attempts to a stale checkpoint.
|
|
override = state.get("resume_override")
|
|
if override and override.get("stage_index") == stage_index and attempt == 0:
|
|
if consume: # --dry-run must be able to show the resume path without spending it
|
|
state.pop("resume_override")
|
|
save_state(state)
|
|
return TRAINING_DIR / "checkpoints" / override["experiment"] / "final.zip"
|
|
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), consume=not args.dry_run
|
|
)
|
|
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, 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]
|
|
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(
|
|
"--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":
|
|
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, 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 = [
|
|
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()
|