mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
1811e9333e
Three curriculum generations (2026-07-21 through 2026-08-04) all tried gating *when* the policy could use vertical thrust/pitch-roll on top of a continuous Gaussian action space, and all three failed the same way: PPO's action-distribution std collapsed within ~10% of steps and never recovered, landing at a 15-32% win rate vs the grounded reference regardless of mechanism (hard mask, then a gradual ramp). Generation 3's final attempt just landed at 24% — the worst of the three. Root cause, verified against this project's own physics: hovering this ship requires *holding* thrust.y ~= 0.408 continuously (mass 5.0, vertical_thrust 120, gravity 9.8). A collapsed near-zero-mean Gaussian can brush that value but never sustain it long enough to earn the reward gradient that would move the mean — no amount of gating *when* the axis acts fixes a problem in *how* the policy represents a decision on it. This also independently found and fixes a real bug: godot_rl never marks an episode timeout as a truncation, so PPO was bootstrapping V(s)=0 on every 30s draw in every generation to date. - Game/scripts/ship_action_codec.gd (new): single source of truth for a per-axis MultiDiscrete action space (7 heads, nvec [5,5,5,5,5,5,2]) shared by training and in-game inference, replacing the continuous Gaussian. thrust_y's bins are deliberately asymmetric so a random policy drifts through the volume instead of floor-pinning. Legacy continuous decode (ai_ship_controller.gd's old logic) preserved verbatim so every pre-generation-4 export (e.g. Game/bots/promoted/easy.json) keeps working unchanged via an optional "action_space" JSON field. - ship_observations.gd: append own contact state (SIZE 31 -> 35, append-only) so the value function can see what wall_contact_penalty fires on. - ship_ai_controller.gd: action space/decode via the codec; drop the vertical_ramp/pitch_roll_ramp mechanism entirely; tilt_penalty default lowered 4x (aerial approaches require pitching); flight telemetry (airborne_fraction, mean_altitude, air_touch_fraction, vertical_thrust_mean) and truncation-snapshot fields on get_info(). - training_mode.gd: new air_drill_chance state-setter branch (ball spawned high, ships low, kept clear of walls) so aerial practice is forced by the environment instead of relying on reward-driven exploration alone; snapshot terminal observations before a timeout reset for the truncation fix. - cosmic_env.py: remap ShipAIController's truncated/terminal_obs info into SB3's TimeLimit.truncated/terminal_observation keys. - train.py: --reset-logits (+ --reset-logits-heads) replaces the now-meaningless --reset-std; new EntropyFloorCallback (a persistent per-rollout ent_coef controller replacing the one-shot std-reset shock) and per-head entropy logging; FlightTelemetryCallback; --air-drill-chance/ --tilt-penalty flags; optional AbortIfCallback kill-criterion. - export_policy.py: writes the action_space block for MultiDiscrete models; index-level parity check (argmax per head) instead of comparing floats. - curriculum.py: full rewrite — 3 stages (bootstrap/selfplay/gauntlet), no grounded stage, full action space live from step 1; deletes generation 1-3's checkpoint-lineage machinery (nothing to resume from); final report evaluates against both promoted/easy.json and the new promoted/reference-grounded.json (a copy of curric-s5-aggression, the strongest grounded-era artifact, kept as a fixed yardstick). - run_training.sh/.gitignore: commit only final.zip, not the ~2400 intermediate checkpoint files a single stage was writing (~500MB -> ~0.2MB per run); requirements.txt pinned (behaviour here now depends on specific library internals, not just public APIs). - test_action_space.py (new): offline rung-0 check catching a head-order mismatch before it silently corrupts 24h of training. Validated: GDScript compiles clean (Godot --headless --import + script validation), free_play.tscn and training.tscn both boot headless without errors, offline action-space assertions pass. Not yet run: the actual smoke-training/A-B validation ladder steps in TRAINING.md's "Generation 4" section, before committing to the full ~32h curriculum. See TRAINING.md's "Generation 4" section for the full design writeup.
472 lines
23 KiB
Python
472 lines
23 KiB
Python
"""Orchestrate the staged curriculum (see TRAINING.md's "Curriculum training"
|
|
section): run each stage, evaluate the result against a reference bot, and
|
|
either advance to the next stage or retry the same one.
|
|
|
|
State is persisted to curriculum_state.json (committed to git) so the script
|
|
is safe to Ctrl-C and re-run — it picks up exactly where it left off. Each
|
|
attempt reuses run_training.sh (pull, train, export, commit+push) so every
|
|
attempt's checkpoint, log, and exported policy is versioned like any other
|
|
run; this script additionally evaluates the result and commits the updated
|
|
eval_history.json + curriculum_state.json.
|
|
|
|
The gate is deliberately lenient ("block only on a clear regression," not
|
|
"require improvement") — see TRAINING.md. A 40-episode eval can call a real
|
|
improvement a regression on sample noise alone (this happened with run11:
|
|
it was the first model to deliberately score, but lost its head-to-head
|
|
evals). A strict improvement-required gate would have retried that stage
|
|
forever for the wrong reason. When a stage does fail MAX_RETRIES times in a
|
|
row, the script stops and asks for a human look rather than retrying
|
|
indefinitely or silently advancing past a bad stage.
|
|
|
|
This is generation 4 of the curriculum — a full redesign, not a patch.
|
|
Generations 1-3 (archived in curriculum_state_gen1.json/_gen2.json/_gen3.json)
|
|
all tried teaching full 3D flight by training grounded first and then
|
|
opening up vertical/pitch-roll authority (a hard 0/1 mask in gen 1/2, a
|
|
gradual float ramp in gen 3) on top of a continuous Gaussian action space.
|
|
All three failed: gen 1's hard mask went 25% -> 20% -> 15% win rate across 3
|
|
attempts; gen 2's single-flip retune landed at a stable 32%/28%/31%; gen 3's
|
|
gradual ramp landed at 29%/30%/24% — actually the worst of the three by its
|
|
final attempt. Every attempt showed the same signature regardless of
|
|
mechanism: PPO's Gaussian action-distribution std collapsed from ~0.30 to
|
|
~0.13-0.15 within the first ~10% of steps and never recovered. The root
|
|
cause: hovering this ship (mass 5.0, vertical_thrust 120, default gravity
|
|
9.8 — see ship.gd) requires *holding* thrust.y ~= 0.408 continuously; a
|
|
collapsed near-zero-mean Gaussian can brush that value but never sustain it
|
|
long enough to earn the reward gradient that would move the mean. No amount
|
|
of gating *when* the axis is allowed to act fixes a problem in *how* the
|
|
policy represents a decision on it.
|
|
|
|
Generation 4 (see TRAINING.md and Game/scripts/ship_action_codec.gd)
|
|
replaces the action space itself with per-axis MultiDiscrete bins instead of
|
|
a continuous Gaussian, trains the full action space from step 1 with no
|
|
grounded stage at all (no successful self-play RL bot in this problem class
|
|
gates control authority — see the RLGym/RLBot research cited in
|
|
TRAINING.md), and adds a state-setter "air drill" episode-start branch
|
|
(training_mode.gd's air_drill_chance) to force aerial practice instead of
|
|
relying on reward-driven exploration alone. All of generation 1-3's
|
|
checkpoint-lineage machinery (FOUNDATION_EXPERIMENT, locomotion-groundedness
|
|
tracking, resume/reference overrides for skipping a regressed branch) is
|
|
gone because there is nothing to resume from: every prior checkpoint is a
|
|
different, incompatible action/observation shape. The two strongest prior
|
|
artifacts are kept as fixed evaluation references instead (see
|
|
PROMOTED_EASY/PROMOTED_REFERENCE_GROUNDED below) — they remain playable
|
|
opponents forever via PolicyNetwork's format-versioned JSON even though
|
|
their own checkpoints and generation are gone.
|
|
|
|
Every experiment name this script generates is timestamped
|
|
(YYYYMMDD-HHMM-<name>, applied once in run_stage_attempt) so runs stay
|
|
unique across restarts/generations and sort chronologically in TensorBoard
|
|
and checkpoints/.
|
|
|
|
Usage:
|
|
.venv/bin/python curriculum.py # run/resume the curriculum
|
|
.venv/bin/python curriculum.py --seed-checkpoint checkpoints/some/final.zip
|
|
.venv/bin/python curriculum.py --force-retry # after fixing something, retry the blocked stage
|
|
.venv/bin/python curriculum.py --skip-to-next-stage # human judgment call: good enough, move on anyway
|
|
|
|
Typically started via curriculum.sh, which runs this in a detached tmux
|
|
session the way start_training.sh does for a single run.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import pathlib
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime
|
|
|
|
TRAINING_DIR = pathlib.Path(__file__).resolve().parent
|
|
STATE_PATH = TRAINING_DIR / "curriculum_state.json"
|
|
EVAL_HISTORY_PATH = TRAINING_DIR / "eval_history.json"
|
|
|
|
# Fixed evaluation references — never touched by training scripts (see
|
|
# TRAINING.md's "Promoted bots" section) — kept forever as playable
|
|
# opponents via PolicyNetwork's format-versioned JSON even after their own
|
|
# checkpoints/generation are gone. The final report (not a gate) evaluates
|
|
# generation 4's result against both.
|
|
PROMOTED_EASY = TRAINING_DIR.parent / "Game" / "bots" / "promoted" / "easy.json"
|
|
PROMOTED_REFERENCE_GROUNDED = TRAINING_DIR.parent / "Game" / "bots" / "promoted" / "reference-grounded.json"
|
|
|
|
MAX_RETRIES = 2
|
|
EVAL_EPISODES = 100
|
|
# "Clear regression" = the reference beats the candidate by at least this
|
|
# many percentage points of win rate. Below this, noise in a 100-episode
|
|
# sample is a more likely explanation than the stage actually failing (see
|
|
# module docstring) — advance rather than retry.
|
|
REGRESSION_MARGIN = 0.15
|
|
|
|
# Standing flags applied to every attempt. Generation 3's "--reset-std 0.3"
|
|
# (a one-shot shock, and meaningless anyway under MultiDiscrete — there is
|
|
# no log_std) is gone; EntropyFloorCallback (see train.py) is a continuous
|
|
# controller instead, which every generation's TensorBoard data argues is
|
|
# what was actually needed (a single reset at attempt start reliably decayed
|
|
# away within ~10% of steps, every time). --ent-coef raised an order of
|
|
# magnitude from generation 3's 0.001: that value was tuned for a Gaussian's
|
|
# unbounded differential entropy, not MultiDiscrete's bounded (~10-nat)
|
|
# entropy.
|
|
STANDING_ARGS = ["--ent-coef", "0.01", "--entropy-floor"]
|
|
|
|
# Reward-shaping flags shared by every stage so the studied variables (state
|
|
# mix, opponent mode) stay isolated — carried forward unchanged from
|
|
# generation 2/3, which the reward-farmability analysis in TRAINING.md
|
|
# confirmed were never the actual problem. draw_penalty and airborne_penalty
|
|
# are deliberately NOT overridden here (both default to 0.0 in
|
|
# training_mode.gd/ship_ai_controller.gd): generation 3's draw_penalty=5 and
|
|
# airborne_penalty ramping up in lockstep with the unmask ramp were both
|
|
# grounded-era, anti-flight pressures that have no place in a curriculum
|
|
# whose entire point is teaching flight.
|
|
_SHARED_REWARD_FLAGS = [
|
|
"--velocity-to-ball-weight", "0.08",
|
|
"--ball-distance-penalty", "0.01",
|
|
"--ball-touch-reward", "0.7",
|
|
"--ball-velocity-to-goal-weight", "0.06",
|
|
"--goal-reward", "80",
|
|
]
|
|
|
|
# A stage dict may additionally set "abort_if": {"metric": "rollout/airborne_
|
|
# fraction", "below": 0.05, "at_steps": N} to end that attempt early if a
|
|
# flight-telemetry metric (see train.py's FlightTelemetryCallback) hasn't
|
|
# cleared a bar by N *absolute* PPO timesteps (model.num_timesteps keeps
|
|
# accumulating across --resume, so N must account for whatever this stage
|
|
# inherits from its predecessor, not just this stage's own budget).
|
|
# Deliberately unset on every stage below for now — rung 5 of TRAINING.md's
|
|
# validation ladder (a short controlled A/B) should establish what a
|
|
# sensible threshold actually looks like before any stage bets a real 12h+
|
|
# budget on a guessed one.
|
|
STAGES = [
|
|
{
|
|
"name": "bootstrap",
|
|
# Stage 1/3: empty-net finishing practice from a random policy — no
|
|
# live opponent, so the full action space's first behaviour to
|
|
# emerge is "fly to ball, push it toward the net" without a moving
|
|
# target complicating credit assignment. Generation 1's own stage 1
|
|
# (also inert-opponent, also empty-net) was the one stage across all
|
|
# 3 prior generations that unambiguously passed on its first
|
|
# attempt — reusing that shape here, just with the full action space
|
|
# live instead of yaw-only.
|
|
"flags": [
|
|
"--opponent-mode", "inert",
|
|
"--attack-goal-bias", "1.0",
|
|
"--kickoff-chance", "0.10",
|
|
"--near-goal-chance", "0.50",
|
|
"--air-drill-chance", "0.20",
|
|
*_SHARED_REWARD_FLAGS,
|
|
],
|
|
"gated": False, # ungated waypoint: trains, checkpoints, always advances — no eval
|
|
"timesteps": 40_000_000, # ~4h at the standing n-parallel/speedup
|
|
},
|
|
{
|
|
"name": "selfplay",
|
|
# Stage 2/3: this is where essentially all of the actual learning
|
|
# happens. Self-play (not frozen) as the main regime — it's what
|
|
# scales and what Necto/Nexto-class bots actually use; a frozen
|
|
# target this early would cap skill at "exploits one specific bot"
|
|
# instead of a moving, improving target. air_drill_chance stays on
|
|
# at a constant rate throughout (not introduced as a later stage) —
|
|
# gating *when* a skill is drilled reproduces the exact "gate what
|
|
# the policy is allowed to do" pattern that failed 3 generations in
|
|
# a row; only the state mix should vary between stages, never what
|
|
# the policy can act on.
|
|
"flags": [
|
|
"--opponent-mode", "self_play",
|
|
"--kickoff-chance", "0.15",
|
|
"--near-goal-chance", "0.25",
|
|
"--air-drill-chance", "0.25",
|
|
*_SHARED_REWARD_FLAGS,
|
|
],
|
|
"gated": True,
|
|
"timesteps": 160_000_000, # ~16h
|
|
},
|
|
{
|
|
"name": "gauntlet",
|
|
# Stage 3/3: a stationary opponent (this stage's own predecessor's
|
|
# export) gives a low-variance measurement — important when the gate
|
|
# is a 100-episode sample with a lenient 15-point margin — and
|
|
# catches a self-play fixed point: a policy that only learned to
|
|
# beat itself will look fine in stage 2 and stall here.
|
|
# opponent_model_from_previous_stage resolves --opponent-model at
|
|
# run time to whatever stage 2's own passing export turns out to be
|
|
# (see run_stage_attempt) rather than a hardcoded name.
|
|
"flags": [
|
|
"--opponent-mode", "frozen",
|
|
"--kickoff-chance", "0.15",
|
|
"--near-goal-chance", "0.25",
|
|
"--air-drill-chance", "0.25",
|
|
*_SHARED_REWARD_FLAGS,
|
|
],
|
|
"gated": True,
|
|
"timesteps": 120_000_000, # ~12h
|
|
"opponent_model_from_previous_stage": True,
|
|
},
|
|
]
|
|
|
|
|
|
def load_state() -> dict:
|
|
if STATE_PATH.exists():
|
|
return json.loads(STATE_PATH.read_text())
|
|
return {"stage_index": 0, "attempt": 0, "status": "in_progress", "log": []}
|
|
|
|
|
|
def save_state(state: dict) -> None:
|
|
STATE_PATH.write_text(json.dumps(state, indent=2) + "\n")
|
|
|
|
|
|
def experiment_name(stage_index: int, attempt: int) -> str:
|
|
name = f"curric-s{stage_index + 1}-{STAGES[stage_index]['name']}"
|
|
return name if attempt == 0 else f"{name}-retry{attempt}"
|
|
|
|
|
|
def _logged_experiment_name(stage_index: int, attempt: int) -> str:
|
|
"""The actual (timestamped) experiment name recorded when this attempt
|
|
ran — needed anywhere a *past* attempt's real name matters, since
|
|
experiment_name() alone no longer identifies a run on disk (see
|
|
run_stage_attempt's timestamp prefix)."""
|
|
state = load_state()
|
|
for entry in state["log"]:
|
|
if entry["stage_index"] == stage_index and entry["attempt"] == attempt:
|
|
return entry["experiment"]
|
|
raise RuntimeError(f"No logged experiment for stage {stage_index} attempt {attempt}")
|
|
|
|
|
|
def _passing_experiment_for_stage(stage_index: int) -> str:
|
|
state = load_state()
|
|
for entry in state["log"]:
|
|
if entry["stage_index"] == stage_index and entry["decision"] == "pass":
|
|
return entry["experiment"]
|
|
raise RuntimeError(f"No passing attempt recorded for stage {stage_index} ({STAGES[stage_index]['name']})")
|
|
|
|
|
|
def resume_checkpoint(stage_index: int, attempt: int, seed_checkpoint: str | None) -> str | None:
|
|
if attempt > 0:
|
|
# Retry: keep training the same stage's own last attempt. No
|
|
# per-stage "reset to a clean upstream checkpoint" override in
|
|
# generation 4 (unlike generation 3's "unmask" stage) — nothing yet
|
|
# suggests a generation-4 retry needs that; add one if a stage's
|
|
# retries turn out to be drifting rather than converging.
|
|
prev = _logged_experiment_name(stage_index, attempt - 1)
|
|
return str(TRAINING_DIR / "checkpoints" / prev / "final.zip")
|
|
if stage_index == 0:
|
|
# Deliberately fresh unless --seed-checkpoint says otherwise: full
|
|
# action space live from step 1, nothing to inherit — every prior
|
|
# generation's checkpoints are a different, incompatible
|
|
# action/observation shape (see module docstring).
|
|
return seed_checkpoint
|
|
prev_experiment = _passing_experiment_for_stage(stage_index - 1)
|
|
return str(TRAINING_DIR / "checkpoints" / prev_experiment / "final.zip")
|
|
|
|
|
|
def reference_bot(stage_index: int) -> str:
|
|
"""Only called for gated stages (stage 0 is ungated) — the previous
|
|
stage's own passing export, exactly like every prior generation's
|
|
default chaining."""
|
|
prev_experiment = _passing_experiment_for_stage(stage_index - 1)
|
|
return str(TRAINING_DIR.parent / "Game" / "bots" / f"{prev_experiment}.json")
|
|
|
|
|
|
def run_stage_attempt(stage_index: int, attempt: int, args) -> str:
|
|
# Timestamped so names stay unique across restarts/generations and sort
|
|
# chronologically in TensorBoard/checkpoints — see module docstring.
|
|
exp = f"{datetime.now().strftime('%Y%m%d-%H%M')}-{experiment_name(stage_index, attempt)}"
|
|
resume = resume_checkpoint(stage_index, attempt, args.seed_checkpoint)
|
|
timesteps = STAGES[stage_index].get("timesteps", args.timesteps)
|
|
cmd = [
|
|
"./run_training.sh", exp,
|
|
"--timesteps", str(timesteps),
|
|
"--n-parallel", str(args.n_parallel),
|
|
"--speedup", str(args.speedup),
|
|
*STANDING_ARGS,
|
|
*STAGES[stage_index]["flags"],
|
|
]
|
|
if resume:
|
|
cmd += ["--resume", resume]
|
|
if STAGES[stage_index].get("opponent_model_from_previous_stage"):
|
|
prev_experiment = _passing_experiment_for_stage(stage_index - 1)
|
|
opponent_model = TRAINING_DIR.parent / "Game" / "bots" / f"{prev_experiment}.json"
|
|
cmd += ["--opponent-model", str(opponent_model)]
|
|
abort_if = STAGES[stage_index].get("abort_if")
|
|
if abort_if:
|
|
cmd += [
|
|
"--abort-metric", abort_if["metric"],
|
|
"--abort-below", str(abort_if["below"]),
|
|
"--abort-at-steps", str(abort_if["at_steps"]),
|
|
]
|
|
print(f"\n=== Stage {stage_index + 1}/{len(STAGES)} ({STAGES[stage_index]['name']}), "
|
|
f"attempt {attempt + 1}/{MAX_RETRIES + 1}: {exp} ===")
|
|
print(" ".join(cmd))
|
|
subprocess.run(cmd, cwd=TRAINING_DIR, check=True)
|
|
return exp
|
|
|
|
|
|
def evaluate_attempt(experiment: str, reference: str, episodes: int) -> dict:
|
|
candidate = TRAINING_DIR.parent / "Game" / "bots" / f"{experiment}.json"
|
|
cmd = [".venv/bin/python", "evaluate.py", str(candidate), reference, "--episodes", str(episodes)]
|
|
print(" ".join(cmd))
|
|
subprocess.run(cmd, cwd=TRAINING_DIR, check=True)
|
|
history = json.loads(EVAL_HISTORY_PATH.read_text())
|
|
return history[-1]
|
|
|
|
|
|
def decide(record: dict) -> str:
|
|
win_rate_candidate = record["wins_a"] / record["episodes"]
|
|
win_rate_reference = record["wins_b"] / record["episodes"]
|
|
if win_rate_reference - win_rate_candidate >= REGRESSION_MARGIN:
|
|
return "fail"
|
|
return "pass"
|
|
|
|
|
|
def final_report(experiment: str) -> None:
|
|
"""Not a gate — the two numbers that actually answer "did generation 4
|
|
work?" (see TRAINING.md). promoted/easy.json is the shipped bot;
|
|
promoted/reference-grounded.json (a copy of generation 3's
|
|
curric-s5-aggression, made before the flat Game/bots/ dump was scrapped)
|
|
is the strongest grounded-era artifact and the yardstick generations 1-3
|
|
were all measured against. reference-grounded.json was trained with the
|
|
locomotion mask on, so needs --grounded-b; easy.json was itself promoted
|
|
from a *failed* unmask stage (curric-s6-unmask) and is full 3D like
|
|
every generation-4 candidate, so needs no flag."""
|
|
candidate = TRAINING_DIR.parent / "Game" / "bots" / f"{experiment}.json"
|
|
print("\n=== Curriculum complete — final report (informational, not a gate) ===")
|
|
for label, reference, extra_flags in [
|
|
("promoted/easy.json (shipped bot)", PROMOTED_EASY, []),
|
|
("promoted/reference-grounded.json (strongest grounded-era bot)", PROMOTED_REFERENCE_GROUNDED, ["--grounded-b"]),
|
|
]:
|
|
if not reference.exists():
|
|
print(f" vs {label}: skipped, file not found")
|
|
continue
|
|
cmd = [".venv/bin/python", "evaluate.py", str(candidate), str(reference), "--episodes", str(EVAL_EPISODES), *extra_flags]
|
|
print(" ".join(cmd))
|
|
subprocess.run(cmd, cwd=TRAINING_DIR, check=True)
|
|
record = json.loads(EVAL_HISTORY_PATH.read_text())[-1]
|
|
print(f" vs {label}: {record['wins_a']}-{record['wins_b']} ({record['draws']} draws), "
|
|
f"win rate {record['win_rate_a']:.0%}")
|
|
|
|
|
|
def commit_progress(experiment: str) -> None:
|
|
subprocess.run(["git", "add", "curriculum_state.json", "eval_history.json"], cwd=TRAINING_DIR, check=True)
|
|
result = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=TRAINING_DIR)
|
|
if result.returncode == 0:
|
|
return
|
|
subprocess.run(
|
|
["git", "commit", "-m", f"chore(training): curriculum progress after {experiment}"],
|
|
cwd=TRAINING_DIR, check=True,
|
|
)
|
|
subprocess.run(["git", "push"], cwd=TRAINING_DIR, check=True)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--timesteps", type=int, default=20_000_000)
|
|
parser.add_argument("--n-parallel", type=int, default=14)
|
|
parser.add_argument("--speedup", type=int, default=16)
|
|
parser.add_argument(
|
|
"--seed-checkpoint", default=None,
|
|
help="Resume stage 1 from this checkpoint instead of training from scratch",
|
|
)
|
|
parser.add_argument("--force-retry", action="store_true", help="Retry a blocked stage after human review")
|
|
parser.add_argument("--skip-to-next-stage", action="store_true", help="Human judgment call: treat the blocked stage as good enough, advance anyway")
|
|
args = parser.parse_args()
|
|
|
|
state = load_state()
|
|
|
|
if state["status"] == "blocked":
|
|
if args.skip_to_next_stage:
|
|
print(f"Human override: advancing past stage {state['stage_index'] + 1} "
|
|
f"({STAGES[state['stage_index']]['name']}) despite exhausted retries.")
|
|
skipped_experiment = _logged_experiment_name(state["stage_index"], state["attempt"])
|
|
state["log"].append({
|
|
"stage_index": state["stage_index"],
|
|
"experiment": skipped_experiment,
|
|
"attempt": state["attempt"], "decision": "pass", "override": "skip_to_next_stage",
|
|
})
|
|
state["stage_index"] += 1
|
|
state["attempt"] = 0
|
|
state["status"] = "in_progress"
|
|
save_state(state)
|
|
# If this was the last stage, the while loop below never runs
|
|
# (stage_index now == len(STAGES)), so this override's state
|
|
# change would otherwise never get committed/pushed.
|
|
commit_progress(skipped_experiment)
|
|
elif args.force_retry:
|
|
print(f"Human override: retrying stage {state['stage_index'] + 1} "
|
|
f"({STAGES[state['stage_index']]['name']}) after review.")
|
|
state["attempt"] += 1
|
|
state["status"] = "in_progress"
|
|
save_state(state)
|
|
else:
|
|
print(f"BLOCKED at stage {state['stage_index'] + 1} ({STAGES[state['stage_index']]['name']}) "
|
|
f"after {MAX_RETRIES + 1} attempts — see curriculum_state.json's log for eval results.")
|
|
print("Re-run with --force-retry (after adjusting flags/timesteps) or "
|
|
"--skip-to-next-stage (advance anyway) once you've looked at why.")
|
|
sys.exit(1)
|
|
|
|
last_experiment = None
|
|
while state["stage_index"] < len(STAGES):
|
|
stage_index = state["stage_index"]
|
|
attempt = state["attempt"]
|
|
|
|
experiment = run_stage_attempt(stage_index, attempt, args)
|
|
last_experiment = experiment
|
|
|
|
if not STAGES[stage_index].get("gated", True):
|
|
# Ungated waypoint (see the bootstrap stage): trains,
|
|
# checkpoints, and always advances — no eval, no regression
|
|
# gate, nothing to retry against.
|
|
print(f"{experiment}: ungated waypoint — skipping eval, advancing unconditionally")
|
|
state["log"].append({
|
|
"stage_index": stage_index, "experiment": experiment, "attempt": attempt,
|
|
"decision": "pass", "note": "ungated waypoint (no eval)",
|
|
})
|
|
state["stage_index"] += 1
|
|
state["attempt"] = 0
|
|
state["status"] = "in_progress"
|
|
save_state(state)
|
|
commit_progress(experiment)
|
|
continue
|
|
|
|
reference = reference_bot(stage_index)
|
|
record = evaluate_attempt(experiment, reference, EVAL_EPISODES)
|
|
decision = decide(record)
|
|
|
|
print(f"{experiment}: candidate {record['wins_a']}-{record['wins_b']} reference "
|
|
f"({record['draws']} draws) over {record['episodes']} episodes -> {decision}")
|
|
|
|
state["log"].append({
|
|
"stage_index": stage_index, "experiment": experiment, "attempt": attempt,
|
|
"eval": record, "decision": decision,
|
|
})
|
|
|
|
if decision == "pass":
|
|
state["stage_index"] += 1
|
|
state["attempt"] = 0
|
|
state["status"] = "in_progress"
|
|
save_state(state)
|
|
commit_progress(experiment)
|
|
continue
|
|
|
|
if attempt >= MAX_RETRIES:
|
|
state["status"] = "blocked"
|
|
save_state(state)
|
|
commit_progress(experiment)
|
|
print(f"\nBLOCKED: stage {stage_index + 1} ({STAGES[stage_index]['name']}) failed "
|
|
f"{MAX_RETRIES + 1} attempts in a row. Stopping for human review — see "
|
|
f"curriculum_state.json. Re-run with --force-retry or --skip-to-next-stage.")
|
|
sys.exit(1)
|
|
|
|
state["attempt"] += 1
|
|
save_state(state)
|
|
commit_progress(experiment)
|
|
|
|
print("\nCurriculum complete — all stages passed.")
|
|
state["status"] = "done"
|
|
save_state(state)
|
|
if last_experiment is not None:
|
|
# None only if the loop above never ran at all (e.g. re-invoking
|
|
# after the curriculum was already "done") — nothing new to commit
|
|
# in that case.
|
|
final_report(last_experiment)
|
|
commit_progress(last_experiment)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|