Living document (Phase F4). The dashboard ships seven learning methods — PPO (gradient, via Stable-Baselines3), a custom neuroevolution (population/fitness/mutation), tabular Q-learning (value-based, G2b), AlphaZero-lite (CNN policy+value + neural-guided MCTS self-play, board games only, G6f/ADR-055), SAC (off-policy continuous control, S5a), TD3 (off-policy continuous control, S5b/ADR-068), and DQN (off-policy value-based, discrete + Atari, S5c/ADR-069) — as peer trainers behind one manager (ADR-004/028). An eighth (A2C, Rainbow…) plugs into the same seam. See also
architecture.mdandadding-an-environment.md.
services/training_manager.py owns the run lifecycle and routes by config.algo in _run():
if config.algo == "neuroevolution":
terminal = train_evolution(config, gym_id, control,
emit_evolution, publish_predict, on_snapshot, resume)
elif config.algo == "q_learning":
terminal = train_q_learning(config, gym_id, control,
emit_q_learning, emit_qtable, publish_predict, on_snapshot, resume)
elif config.algo == "sac": # off-policy continuous (trainer_sac, lazy torch/SB3 import)
terminal = train_sac(config, gym_id, control,
emit_metrics, emit_progress, publish_predict, on_snapshot, resume)
elif config.algo == "td3": # off-policy continuous (trainer_td3)
terminal = train_td3(config, gym_id, control,
emit_metrics, emit_progress, publish_predict, on_snapshot, resume)
elif config.algo == "dqn": # off-policy value-based, discrete + Atari (trainer_dqn)
terminal = train_dqn(config, gym_id, control,
emit_metrics, emit_progress, publish_predict, on_snapshot, resume)
elif config.algo == "alphazero" or is_board_game(env): # OpenSpiel → trainer_az / trainer_board
terminal = train_az(...) if config.algo == "alphazero" else train_board(...)
else: # ppo — incl. multi-agent param-sharing + board MaskablePPO
terminal = train_ppo(config, gym_id, control,
emit_metrics, emit_progress, publish_predict, on_snapshot, resume)A trainer is a function that runs the learning loop on the manager's daemon thread (never the
event loop — ADR-007) and returns a terminal TrainState (finished / stopped / error). It receives:
| Argument | Purpose |
|---|---|
config: TrainConfig |
env, seed, budget, and the algo's hyperparameters |
gym_id: str |
the resolved Gymnasium id from the registry |
control: TrainControl |
cooperative pause (park between steps on an Event) + stop (return early) |
emit_* callbacks |
push WS metric frames (see below) — marshalled to the loop by the manager |
publish_predict(fn) |
hand the preview a decoupled read-only policy (ADR-019) — never the live model |
on_snapshot(artifact) |
hand up a CheckpointArtifact (bytes) at a quiescent point, so a Save can persist it |
resume: bytes | None |
a previously-saved artifact to continue from |
-
Pick its metric frame. Reuse
TrainingMetrics/TrainingProgress(gradient methods) orEvolutionMetrics(population methods), or add a new frame toschemas/training.pyandfrontend/src/api/types.ts(one pydantic model + one TS type — contracts in one place). Add the newtypeto the WS union inapi.md. -
Write
services/trainer_<name>.py. Import torch/numpy lazily inside the function (like the two existing trainers) so/health,/envs, and the WS echo stay import-light. Run the loop; between steps, honorcontrol(pause/stop); periodically call theemit_*callbacks. -
Publish a decoupled preview policy (load-bearing, not optional). Call
publish_predictwith a snapshot forward — for PPO that's_build_numpy_predict(model)(a numpy forward over copied weights, rebuilt at each rollout boundary); for neuroevolution it's the generation's leader. Do not let the preview call into the live model — concurrent access measurably perturbs a same-seed run (ADR-019). The forward must be action-space-agnostic (intforDiscrete, a clipped float vector forBox) per the ADR-021 contract. -
Emit checkpoint snapshots. At quiescent points, serialize the model to bytes and call
on_snapshot(CheckpointArtifact(...)); implement load-from-bytes forresume. This gives you Save/Load/Export and run-resume for free. -
Register it as data. Add the algo id to each env's
supported_algosinregistry.pywhere it applies (this gates the algorithm dropdown per-env; the store snaps to a valid algo on env switch), and add its tunables to the env'shyperparamsblock (withrecommended★ values). -
Bilingual content + tests. Add the algorithm's info-popup copy to
content/parameters.ts(CZ+EN, general + per-env), wire any new i18n keys (bothen.json/cz.json—.\tasks.ps1 i18nenforces parity), and add a smoke test that the trainer reaches a terminal state on CartPole. Then.\tasks.ps1 allmust be green.
The third trainer, services/trainer_q.py, is the value-based peer and a concrete tour of the steps:
- New frames (step 1). Q-learning is episodic, not rollout/generation-based, so it added a
QLearningMetricsframe (type:"q_learning", x-axis =episode,ep_rew_meanis the learning curve) plus a separateQTableFrame(type:"qtable") carrying the[n_states × n_actions]table for the live heatmap. The table frame is decoupled and never logged into the run's metric history (it is large — Taxi is 3 000 cells) and only the latest is retained onTrainStatus.last_qtablefor reconnect. - Discrete-obs decode (step 3). The shared
make_envfactory one-hot-wraps aDiscrete(n)observation (ADR-024), so Q-learning's greedy preview/AI policyargmax-decodes the one-hot back to the int state, thenargmaxes the table row — the same action-space-agnostic predict→step contract (ADR-021). - Checkpoint (step 4). Snapshots a numpy
qtable.npz; resume continues the episode counter. - Data + gating (step 5).
q_learningis insupported_algosonly on the discrete-obs Toy Text envs; itshyperparamsblock carries α / γ / ε-start / ε-end / ε-decay / episodes (per-env ★ episode budget). - UI for free + two small UI seams. Reward chart, skill meter, checkpoints, run history and play-vs-AI
all lit up from the shared contracts. Two deliberate additions: the Q-table heatmap panel (renders to
one
<canvas>, not per-cell DOM — ADR-029 — and takes over the Evolution-Stats + blank slots only forq_learning), and algorithm-aware chart-tab messaging (ADR-031): the empty-state hint reads from a singlealgo → tabsmap (ALGO_CHART_TABS), so a tab the algorithm doesn't produce says so ("Q-learning doesn't use the Loss chart — see Reward") instead of a misleading "start training". Add a row there for any new algorithm and the messaging stays correct.
The fourth trainer, services/trainer_az.py, is board-only and shows two ways the seam stretches:
- Routed by algo within a family. Board games (
is_board_game) route to the board subsystem; the manager then sub-routes onconfig.algo—"alphazero"→train_az, else G6b'strain_board. So a family carries two trainers and the user picks (supported_algos = ["ppo", "alphazero"]) — the first head-to-head algorithm comparison on one game. - Reuses an existing frame contract — no new frame. AlphaZero's honest curve is the same
eval-vs-reference-MCTS ∈ [−1, 1] as G6b, so it emits the standard
metrics+progressframes and addsalphazero: [reward, loss]toALGO_CHART_TABS. Its budget is iterations × games_per_iter self-play games (reported astimesteps), so the algo-aware sidebar shows AlphaZero's own sliders and hides the PPO Total-Steps ladder (same pattern as Q-learning's Episodes / evolution's Generations). A progress frame is emitted per self-play game so the chart advances smoothly between iterations. - PyTorch CNN + OpenSpiel MCTS, decoupled the ADR-019 way.
services/az_net.pyis a CNN over theobservation_tensorplanes; inference (eval + Play) runs neural-MCTS (az_move_fn) for real strength while the live preview keeps a fast raw-policy snapshot — andboard_engine.eval_vs_mctswas generalised to a(state) -> actionmove fn so both board trainers feed it. - CPU "lite" — and the scope lesson. It pins
device="cpu": self-play is single-position (batch-of-1) forwards, which the CPU runs faster than the GPU (a GPU only pays off with batched self-play + a bigger net — measured 6–18× at batch 64–256). That batched-GPU engine is the G6g (chess) foundation, not part of this lite version.
The fifth trainer, services/trainer_sac.py, is the first off-policy method and shows where the
shape stretches without forking the contract:
- No rollout boundary ⇒ a step-interval cadence. PPO emits a metrics frame per rollout; SAC collects
single transitions into a replay buffer and updates continuously. So the metrics callback emits on a
step interval (
_METRICS_INTERVAL_STEPS) — snapshot + a fresh decoupled preview ride the same interval — and the shared ~1 Hz_progress_ticker(reused verbatim fromtrainer_ppo) keeps the live stats + reward curve smooth between them. No new frame: SAC emits the standardmetrics+progressand addssac: ['reward', 'loss']toALGO_CHART_TABS(the Loss tab shows SAC'strain/critic_loss). - Decoupled preview = a CPU save/load copy of the policy. SAC's actor (squashed-Gaussian MLP) isn't
the PPO
mlp_extractor.policy_net+action_net, so the numpy forward doesn't apply; instead_build_sac_predictround-tripsmodel.policythrough SB3'ssave/loadinto an isolated CPU policy and callspredict(deterministic=True)(the CnnPolicy preview's trick, ADR-019) — never the live model. - Raw obs/rewards — explicitly NOT VecNormalize (the one cross-algorithm coordination). Unlike the PPO
MuJoCo path (G5c), SAC does not wrap the env in
VecNormalize: reward normalization is on-policy-shaped (a running return scaling) and would drift against a replay buffer of rewards stamped with old stats, and SAC's standard recipe needs neither. Soep_rew_meanstays raw and_sac_predict(AI-play) applies no normalizer — a PPO-vs-SAC comparison on one robot is apples-to-apples on the same raw scale. - Data + gating (step 5).
sacis insupported_algosonly on the continuous-Boxenvs (MuJoCo + BipedalWalker + Pendulum + MountainCarContinuous); itshyperparamsblock (lr/γ/τ/buffer/train_freq + theauto/fixedent_coefcategorical) rides on every env but is exposed only there — the same "block on all, exposed where listed" pattern as theq_learningblock. SAC reuses the PPOtotal_timestepsstep budget (so the sidebar shows the Total-Steps ladder forppoandsac); device is"cpu"— its per-step gradient updates are tiny batch-256 MLP forwards that the CPU runs faster than a latency-bound GPU shuttle (measured: HalfCheetah CPU 163 vs CUDA 120 steps/s, the same ADR-056 result PPO's MlpPolicy has), soapi/device.trainsOnGpureads CPU for SAC too.
The sixth trainer, services/trainer_td3.py, shows how cheap a sibling algorithm is once the seam fits:
TD3 is SAC's off-policy twin, so it is a near-copy of trainer_sac.py and reuses everything that file does
(the PPO metrics/progress frames, the CPU save/load preview, raw obs / no-VecNormalize, the step-interval
cadence, _ep_means/_progress_ticker). Two things differ:
- A deterministic policy ⇒ explore by injected noise, not entropy. TD3 has no
ent_coef; its actor is deterministic and its signature tricks (twin clipped critics, delayed policy updates, target-policy smoothing) are fixed SB3 defaults. So the one new tunable istrain_noise— the std of the GaussianNormalActionNoiseadded to collected actions, sized to the env's action dimension in_td3_kwargs. It is the conceptual analogue of SAC's entropy temperature and sits in the same sidebar slot. - It shares SAC's data, not just its shape. TD3 lists the same
supported_algosenvs and re-uses the sharedEnvSpec.offpolicy_total_timestepsbudget (no new field) —budgetForand the sidebar treatsacandtd3identically.deviceis"cpu"for the same ADR-056 reason as SAC.
Off-policy live-curve gate (applies to SAC, TD3 and DQN). Their ~1 Hz ticker fires within a few hundred
steps, when the episode buffer holds only one or two high-variance episodes (often a lucky random-warmup one),
which plotted as a misleading "starts high then dips". _ep_means gained a min_episodes param (default 1 for
PPO + snapshots) and the off-policy trainers pass 5 for their live chart frames, so the curve starts at the
settled baseline and climbs. Snapshots/checkpoints still record any available reward.
The seventh trainer, services/trainer_dqn.py, is the value-based counterpart to PPO and the
discrete-action mirror of SAC/TD3 — so it is a near-copy of trainer_td3.py reusing the same off-policy
machinery (the PPO metrics/progress frames, the CPU save/load preview, raw obs / no-VecNormalize, the
step-interval cadence, _ep_means(≥5)/_progress_ticker). What differs:
- Discrete, value-based ⇒ explore by ε-greedy. DQN learns a Q-function and acts by
argmax, so its predict (_dqn_predict) returns a plainint(the ADR-021 discrete arm). It has neither SAC's entropy nor TD3's action noise; its one distinctive knob is the ε schedule (exploration_fraction/exploration_final_eps), withtarget_update_intervalas the hard target-sync (DQN's blunt analogue of τ). - Two policies, two devices, gated to the discrete envs (the exact complement of the SAC/TD3 continuous
gate): an
MlpPolicyon CPU for the classic-control discretes + LunarLander, and aCnnPolicyon CUDA for Atari (DQN's birthplace) over the sharedimage_vec.make_image_vec(n_envs=1). - Per-env ★ recipes. Unlike SAC/TD3 (env-independent defaults), DQN's tuned values differ per env, so they
are applied post-construction from a
_DQN_TUNEDmap (rl-zoo3 recipes); Atari overrides the wholedqnblock to the Nature recipe in_cnn_hyperparams. The off-policy budget field was generalizedsac_total_timesteps→offpolicy_total_timestepsand set on the discrete rows too. - Atari replay-buffer memory. The stacked-frame buffer is large, so the image path uses
optimize_memory_usage=True+ a 50k cap (~1.4 GB), AI-play loads withbuffer_size=1(inference never samples), and resume forces the same onDQN.load.
Gotcha worth internalizing — reseed every baseline a resume restores. A per-run counter the trainer
restores on resume (num_timesteps) must reseed every threshold/baseline derived from it. The off-policy
metrics callback seeded its emit threshold to a fixed _METRICS_INTERVAL_STEPS (2000); on resume
num_timesteps already starts at the restored total (e.g. 1.4M), so the emit condition was true on every
step → a full model.save() snapshot per step for ~700 steps (~a minute of ~12 steps/s) until it caught up,
then "jumped". Fresh runs (start at 0) were immune. Fix = seed _next_emit = num_timesteps + interval on the
first step (DQN + SAC + TD3, which share the callback); the ~1 Hz progress ticker had the same class of bug
(last_steps=0 → first steps/s delta = the whole resumed total → a spike; now seeded to num_timesteps).
The reward chart, progress ticker, skill meter, checkpoints, run history/compare, and play-vs-AI all read the shared contracts — a peer trainer that emits the standard frames and publishes a decoupled predict fn lights all of them up without UI-specific code.