Ajax DreamerV3 vs reference DreamerV3 (29eb964) on CartPole-v1: comparison protocol¶
On 2026-10-04 the absolute local paths in this file were replaced by the placeholders
<scratch>and<repo>(README.md, "Recorded locations"); nothing else was changed.A historical record: Ajax names and line numbers below are those of 5cb0738, the commit the rounds ran (README.md). Since then
update_rolling_meanwas folded intoupdate_episodic_return(aaa75c6), and Ajax draws a fresh evaluation key per evaluation (0baa25f): "Ajax's fixed eval key" describes Ajax before that change.
Single source of truth for the builder and the auditors. Question answered: on
the exact setup of the failed learning check (dreamerv3-cartpole: 1m model,
16 envs, train ratio 512, batch 16 x 64, 20 000 rows, eval every 2 000 rows
with 10 episodes), does the real reference code learn CartPole-v1 better
than Ajax does (an end-to-end bug in Ajax), or is Ajax's curve (final eval
155; 22, 100, 378, 123, 182, 219, 186, 126, 121, 155) within what the
reference itself produces? The invented bar (> 400) plays no role here.
Path abbreviations used below:
| name | path |
|---|---|
SP |
<scratch> |
AJ |
SP/wt-m7 (Ajax worktree, read only; Ajax sources are AJ/src/ajax/...) |
REF |
SP/refs/dreamerv3_29eb964 (reference checkout, read only) |
RR |
SP/refrun (every new file goes here) |
GX |
<repo>/.venv/lib/python3.12/site-packages/gymnax (gymnax 1.0.0, git YannBerthelot/gymnax@61ff068, the version Ajax runs) |
RPY |
SP/venvs/dreamerv3_ref/bin/python (jax 0.4.26 CPU, numpy 1.26, optax, tfp 0.24, ruamel, psutil, zmq, cloudpickle; no tensorflow, no gym) |
APY |
<repo>/.venv/bin/python |
All file:line citations are to these trees as they are now. Ajax file
paths are relative to AJ/src/ajax/; reference paths relative to REF/.
Verified facts this protocol rests on (probes in RR/inspect_ajax.py,
RR/inspect_ajax_metrics.py, RR/inspect_ajax_params.py,
RR/inspect_ref_config.py, RR/probe_ref_agent.py):
- Ajax resolved config (instantiating
DreamerV3(env_id="CartPole-v1", model_size="1m")): units = hidden = 64, deter 512, stoch 32, classes 4, blocks 8, train_ratio 512, batch 16 x 64, ring 1 250 rows/env for 20 000 rows, gate stept0 = 1040, 9 481 updates in 1 250 ticks, env = bare gymnaxCartPole(no wrapper),max_steps_in_episode = 500, 2 discrete actions. - The reference Agent built with the overrides of section 1 on the port's
spaces has 637 511 optimised variables; Ajax's learner has 637 381 =
637 511 - 130, exactly the two extra two-hot output columns (64 + 1 each) of
the reference's reward head and critic (
dreamerv3/nets.py:437-443;docs/world_models/deviations.mdsection 1 "Two-hot output width"). The dimensions therefore match. - Reference train metric names (one
Agent.traincall) were listed by the probe; the name map of section 5.4 is built from that list and from Ajax'strain_metric_keys(90 keys). - Reference cost on this machine under the current load (Ajax test suite running): build + compile 195 s; one update 1.0-1.4 s; one 16-env policy call 2-5 ms. The builder re-measures (section 7).
1. Config mapping table¶
Ajax values are the resolved values of DreamerV3(env_id="CartPole-v1",
model_size="1m") with every other argument at its default
(agents/DreamerV3/DreamerV3.py:148-203, DreamerV3Config.from_model_size
agents/DreamerV3/state.py:203-232, MODEL_SIZES state.py:46-54,
DreamerV3AgentConfig state.py:252-275), as printed by
RR/inspect_ajax.py. Reference keys are dreamerv3/configs.yaml of 29eb964
(defaults: block lines 1-146) and how main.py, agent.py, nets.py,
jaxutils.py, embodied/run/train.py read them. 29eb964 has no
size1m preset (presets size12m ... size400m, configs.yaml:148-176);
rows 3-6 give the explicit overrides that reproduce Ajax's 1m
(d = 64: units = hidden = d, deter = 8d, classes = d/16, state.py:225-231),
verified by RR/inspect_ref_config.py (the regex key .*\.units is applied
by embodied.Config.update exactly as the stock presets' .*\.units).
Status: EQUAL = the reference default already equals Ajax's value; OVERRIDE = set in this run (value in the "this run" column); UNMATCHABLE = cannot be made identical without changing reference code (listed again in section 6 with impact).
| # | Ajax setting = resolved value | Ajax source | Reference key | 29eb964 default (source) | This run | Status |
|---|---|---|---|---|---|---|
| 1 | n_envs = 16 |
DreamerV3.py:151 |
run.num_envs |
16 (configs.yaml:44) |
16 | EQUAL |
| 2 | model_size = '1m' (d = 64) |
DreamerV3.py:152; state.py:46-54, 225-231 |
none (no size1m in 29eb964) |
defaults = size200m dims (configs.yaml:113-129) |
rows 3-6 | OVERRIDE |
| 3 | units = 64 (enc, dec, rew, con, actor, critic MLP width) |
state.py:227 |
.*\.units (= enc.simple.units, dec.simple.units, rewhead.units, conhead.units, actor.units, critic.units) |
1024 (configs.yaml:117, 121, 122, 123, 128, 129) |
64 | OVERRIDE |
| 4 | hidden = 64 (RSSM input/hidden/prior/posterior width) |
state.py:228 |
dyn.rssm.hidden |
1024 (configs.yaml:113) |
64 | OVERRIDE |
| 5 | deter = 512 |
state.py:229 |
dyn.rssm.deter |
8192 (configs.yaml:113) |
512 | OVERRIDE |
| 6 | classes = 4 |
state.py:230 |
dyn.rssm.classes |
64 (configs.yaml:113) |
4 | OVERRIDE |
| 7 | stoch = 32 |
DreamerV3.py:157 |
dyn.rssm.stoch |
32 (configs.yaml:113) |
32 | EQUAL |
| 8 | blocks = 8, block-GRU core |
DreamerV3.py:158; networks.py:145-180, 211-250 |
dyn.rssm.cell, .blocks, .block_fans, .block_norm |
blockgru, 8, False, False (configs.yaml:113) |
same | EQUAL |
| 9 | RSSM depths: prior 2 layers, posterior 1, one core hidden layer; posterior input concat(deter, token) |
networks.py:84-85, 300-320; networks.py:204, 242 |
dyn.rssm.imglayers, .obslayers, .dynlayers, .absolute |
2, 1, 1, False (configs.yaml:113; used nets.py:66-67, 115, 129) |
same | EQUAL |
| 10 | enc_layers = 3, symlog input |
DreamerV3.py:159; networks.py:387-395 |
enc.typ, enc.simple.layers, enc.simple.symlog |
simple, 3, True (configs.yaml:116-117; nets.py:254-260) |
same | EQUAL |
| 11 | dec_layers = 3, symlog-MSE, tolerance 1e-8 |
DreamerV3.py:160; distributions.py:50-51, 144-150 |
dec.simple.layers, dec.simple.vecdist |
3, symlog_mse (configs.yaml:121; nets.py:315-326, 450-453; jaxutils.py:179-207 tol 1e-8) |
same | EQUAL |
| 12 | decoder output outscale 0.1 | networks.py:77-80, 362 |
(vector Dist built without outscale, nets.py:326) |
Dist.outscale 0.1 (nets.py:413); dec.simple.outscale: 1.0 applies to image heads only (nets.py:358) |
same | EQUAL |
| 13 | rew_layers = 1, two-hot (symexp bins), output outscale 0 |
DreamerV3.py:161; networks.py:365 |
rewhead.layers, .dist, .outscale |
1, symexp_twohot, 0.0 (configs.yaml:122; nets.py:466-476) |
same | EQUAL |
| 14 | con_layers = 1, Bernoulli, outscale 1 |
DreamerV3.py:162; networks.py:366 |
conhead.layers, .dist, .outscale |
1, binary, 1.0 (configs.yaml:123) |
same | EQUAL |
| 15 | actor_layers = 3, discrete one-hot actor with actor_unimix = 0.01, output outscale 0.01 |
DreamerV3.py:163, 168; networks.py:90, 410-440; distributions.py:233-253 |
actor.layers, actor_dist_disc, actor.unimix, actor.outscale |
3, onehot, 0.01, 0.01 (configs.yaml:128, 130; nets.py:523-530) |
same | EQUAL |
| 16 | critic_layers = 3, two-hot, output outscale 0 |
DreamerV3.py:164; networks.py:91, 444-454 |
critic.layers, .dist, .outscale |
3, symexp_twohot, 0.0 (configs.yaml:129) |
same | EQUAL |
| 17 | bins = 255 (reward head, critic) |
DreamerV3.py:165 |
rewhead.bins, critic.bins |
255 (configs.yaml:122, 129) |
255 | EQUAL (output width 256 vs 255, row 61) |
| 18 | minstd 0.1 / maxstd 1.0 (continuous actor only; inactive here) |
DreamerV3.py:189-190 |
actor.minstd, actor.maxstd |
0.1, 1.0 (configs.yaml:128) |
same | EQUAL |
| 19 | hidden layer = Dense (bias) -> RMSNorm(eps 1e-4) -> SiLU | networks.py:23-24, 204, 242 |
*.norm, *.act (rssm, enc, dec, heads, actor, critic) |
rms, silu (configs.yaml:113-129); Norm(eps=1e-4) (nets.py:733) |
same | EQUAL |
| 20 | latent unimix = 0.01 |
DreamerV3.py:167 |
dyn.rssm.unimix |
0.01 (configs.yaml:113; nets.py:212-215) |
0.01 | EQUAL |
| 21 | kernel init variance_scaling(outscale^2, fan_in, truncated_normal) |
networks.py:73-76 (deviation D23) |
*.winit |
normal: 1.1368 sqrt(1/fan_in) TruncNormal(-2,2) (nets.py:821-860, :848) |
normal |
UNMATCHABLE (constant factor, 4.16e-5 relative) |
| 22 | free_nats = 1.0 |
DreamerV3.py:166; world_model.py:131-155 |
rssm_loss.free |
1.0 (configs.yaml:125; nets.py:97-105) |
1.0 | EQUAL |
| 23 | rec_scale, rew_scale, con_scale, dyn_scale, rep_scale = 1, 1, 1, 1, 0.1 |
DreamerV3.py:169-173 |
loss_scales.dec_mlp, .reward, .cont, .dyn, .rep |
1, 1, 1, 1, 0.1 (configs.yaml:98; agent.py:92-97) |
same | EQUAL |
| 24 | actor_scale, critic_scale, repval_scale = 1, 1, 0.3 |
DreamerV3.py:174-176 |
loss_scales.actor, .critic, .replay_critic |
1, 1, 0.3 (configs.yaml:98) |
same | EQUAL |
| 25 | continue target gamma (1 - is_terminal) |
world_model.py:230 |
contdisc |
True (configs.yaml:124; agent.py:242-245) |
True | EQUAL |
| 26 | return_horizon = 333 (gamma = 1 - 1/333) |
DreamerV3.py:181; state.py:239-249 |
horizon |
333 (configs.yaml:136) |
333 | EQUAL |
| 27 | imag_horizon = 15, imagination from every posterior state, 1 repeat |
DreamerV3.py:180; world_model.py:118; learner.py:282 |
imag_length, imag_start, imag_repeat, imag_unroll |
15, all, 1, False (configs.yaml:132-135) |
same | EQUAL |
| 28 | lam = 0.95 |
DreamerV3.py:182 |
return_lambda |
0.95 (configs.yaml:137) |
0.95 | EQUAL |
| 29 | repval_lam = 0.95; replay critic on, with gradient, bootstrapped from imagination |
DreamerV3.py:183; actor_critic.py:21, 27-31, 270-280 |
return_lambda_replay, replay_critic_loss, replay_critic_grad, replay_critic_bootstrap |
0.95, True, True, imag (configs.yaml:104-106, 138) |
same | EQUAL |
| 30 | actent = 3e-4 |
DreamerV3.py:184 |
actent |
3e-4 (configs.yaml:144) |
3e-4 | EQUAL |
| 31 | slowreg = 1.0; targets from the online critic |
DreamerV3.py:185; actor_critic.py:211 |
slowreg, slowtar |
1.0, False (configs.yaml:145-146) |
same | EQUAL |
| 32 | slow_rate = 0.02, every update, hard copy at the first |
DreamerV3.py:186; learner.py:519-565 |
slow_critic_fraction, slow_critic_update |
0.02, 1 (configs.yaml:139-140; jaxutils.py:737-762) |
same | EQUAL |
| 33 | return normaliser: EMA of P5/P95, retnorm_rate 0.01, retnorm_limit 1.0 |
DreamerV3.py:187-188; normalizers.py:51-113; learner.py:209 |
retnorm |
{impl: perc, rate: 0.01, limit: 1.0, perclo: 5, perchi: 95} (configs.yaml:141; jaxutils.py:301-396) |
same | EQUAL |
| 34 | no value / advantage normalisation; actor-critic inputs stop-gradiented; reward head trains the RSSM; no context reset | actor_critic.py:27-31; learner.py:289; world_model.py:194 |
valnorm.impl, advnorm.impl, ac_grads, reward_grad, reset_context |
off, off, none, True, 0.0 (configs.yaml:102-103, 107, 142-143) |
same | EQUAL |
| 35 | learning_rate = 4e-5, one optimizer over world model + actor + critic |
DreamerV3.py:191; learner.py:190 |
opt.lr, separate_lrs |
4e-5, False (configs.yaml:99-100; agent.py:83-91) |
same | EQUAL |
| 36 | LaProp: beta1 0.9, beta2 0.999, eps 1e-20 |
DreamerV3.py:194-196; optim.py:121-139 |
opt.scaler, opt.momentum, opt.beta1, opt.beta2, opt.eps |
rms, True, 0.9, 0.999, 1e-20 (configs.yaml:99; jaxutils.py:436-444, 677-713) |
same | EQUAL |
| 37 | AGC agc 0.3, agc_pmin 1e-3; no global clip |
DreamerV3.py:192-193 |
opt.agc, opt.pmin, opt.globclip |
0.3, 1e-3, 0.0 (configs.yaml:99; jaxutils.py:431-434, 660-674) |
same | EQUAL |
| 38 | warmup 1000 updates, linear from 0, constant afterwards, no weight decay |
DreamerV3.py:197; optim.py:98-110 |
opt.warmup, opt.schedule, opt.anneal, opt.wd |
1000, constant, 0, 0.0 (configs.yaml:99; jaxutils.py:508-524) |
same | EQUAL |
| 39 | train_ratio = 512 |
DreamerV3.py:177; train_DreamerV3.py:140-182 |
run.train_ratio |
32.0 (configs.yaml:52) |
512.0 | OVERRIDE |
| 40 | batch_size = 16 |
DreamerV3.py:178 |
batch_size |
16 (configs.yaml:91) |
16 | EQUAL |
| 41 | batch_length = 64 trained rows + 1 context row |
DreamerV3.py:179; state.py:262-265 |
batch_length, replay_context |
65, 1 (configs.yaml:92, 96); batch_steps = 16 (65 - 1) (embodied/run/train.py:26) |
same | EQUAL |
| 42 | training-start gate at step t0 = 1040, then 1 + floor((t - t0)/2) updates (9 481 by 20 000) |
train_DreamerV3.py:147-182 (deviation D26) |
run.train_fill; gate len(replay) >= batch_size |
0 (configs.yaml:53); train.py:81-84; when.Ratio (embodied/core/when.py:26-42) |
0 | EQUAL (r = 1/2: D26 exact) |
| 43 | replay_capacity 5e6 rows -> ring of 1 250 rows/env for 20 000 rows (never overwrites) |
DreamerV3.py:198, 288-298 (D27) |
replay.size |
5e6 items (configs.yaml:12; main.py:164, 187) |
5e6 | EQUAL in effect (neither binds) |
| 44 | online queue first, then uniform with replacement | agents/DreamerV3/replay.py:29-36 |
replay.online, replay.fracs |
True, {uniform: 1.0, ...} (configs.yaml:13-14; embodied/replay/replay.py:140-144, 178-191) |
same | EQUAL |
| 45 | replay sampler seeded from the run seed | agents/DreamerV3/replay.py (D2) |
none (code) | selectors.Uniform(seed=0), never derived from config.seed (embodied/replay/selectors.py:29-32; embodied/replay/replay.py:20, 27; main.py:187) |
seed 0 for every reference seed | UNMATCHABLE |
| 46 | float32 compute and params | deviation D4 | jax.compute_dtype, jax.param_dtype |
bfloat16, float32 (configs.yaml:24-25; jaxagent.py:292-293) |
float32, float32 |
OVERRIDE |
| 47 | CPU | JAX_PLATFORMS=cpu |
jax.platform |
gpu (configs.yaml:22; jaxagent.py:286) |
cpu |
OVERRIDE |
| 48 | synchronous: acting with the latest parameters, metrics of the current update, no prefetch | train_DreamerV3.py:546-572 (D1) |
jax.sync_every + JAXAgent pending sync / pending outs and metrics; Prefetch threads |
1 (configs.yaml:32); jaxagent.py:147-153, 188-202; embodied/core/prefetch.py:9-41 |
stock | UNMATCHABLE |
| 49 | all updates of a vector step run after its 16 rows are added | train_DreamerV3.py:559-572 (D3) |
none (code) | train_step after each transition (train.py:69-72, 81-92) |
stock | UNMATCHABLE |
| 50 | discrete action = int32 index; one-hot inside the model; raw action zeroed on is_last rows |
train_DreamerV3.py:248, 262; row_collector.py:329 |
act space | port Space(np.int32, (), 0, 2); jaxutils.onehot_dict (jaxutils.py:723-734, agent.py:135, 233); driver mask (embodied/core/driver.py:70-74) |
same | EQUAL |
| 51 | the policy samples (actor and posterior) in every mode | train_DreamerV3.py:243-264 |
mode argument |
ignored by Agent.policy (agent.py:129-164) |
ignored | EQUAL |
| 52 | action_repeat = 1 |
DreamerV3.py:200 |
env repeat | port steps once per action | 1 | EQUAL |
| 53 | episode limit 500 agent steps from gymnax params (episode_length = 1000 is ignored for gymnax, DreamerV3.py:107-109) |
DreamerV3.py:199; environments/utils.py:175-215 |
wrapper.length, port |
0 = no TimeLimit (configs.yaml:77; main.py:238-239); the port truncates at 500 |
0 + port | EQUAL |
| 54 | one flat vector observation (4) into encoder and decoder | train_DreamerV3.py:297-303 |
enc.spaces, dec.spaces |
'.*' (configs.yaml:115, 119) selects the port's only non-flag key vector (agent.py:36-43) |
'.*' |
EQUAL (single key: D5 moot) |
| 55 | n_timesteps = 20 000 rows |
benchmarks/learning_checks.py:128 (AJ/benchmarks/...) |
run.steps |
1e10 (configs.yaml:42; loop train.py:107) |
20000 | OVERRIDE |
| 56 | log + eval every 2 000 rows, 10 episodes | benchmarks/learning_checks.py:113-114, 129 |
run.log_every (seconds) |
120 (configs.yaml:47; when.Clock when.py:69-89) |
-1 (stock wall-clock log block off; section 3.4 instrumentation logs at multiples of 2 000) | OVERRIDE |
| 57 | no report stream | D2 | run.eval_every (seconds) |
180 (configs.yaml:49; train.py:111-113) |
-1 | OVERRIDE |
| 58 | no periodic checkpoints | n/a | run.save_every (seconds) |
900 (configs.yaml:48; train.py:125-126) |
-1 (the stock initial save at train.py:100 stays; harmless) |
OVERRIDE |
| 59 | envs stepped in-process | row_collector.py:510-551 |
run.driver_parallel |
True (configs.yaml:73; driver.py:16-28) |
False | OVERRIDE (same observations) |
| 60 | seed s (0, 1, 2) |
DreamerV3.train(seed=...) (DreamerV3.py:391-423) |
seed |
0 (configs.yaml:3; param init [seed, 0] jaxagent.py:382; self.rng = default_rng(seed) jaxagent.py:39) |
s | OVERRIDE |
| 61 | two-hot: 255 outputs; bins = float32 rounding of float64 values; expectation exactly 0 for uniform p | distributional.py:11-45; networks.py:44-52 (D22) |
rewhead.bins, critic.bins |
256 outputs, last dropped (nets.py:437-443); bins symexp(linspace(-20, 0, 128)) in float32 under jit (nets.py:466-476); a zero-init head predicts 0.07-0.16 |
stock | UNMATCHABLE (layout: none; values: numerics) |
| 62 | (no counterpart) | n/a | jax.prealloc, jax.transfer_guard |
True, True (configs.yaml:26, 34; jaxagent.py:264-289) |
False, False | OVERRIDE (no computational effect; lets the evaluation of section 4 pass host arrays) |
| 63 | RNG: one JAX key per seed, split per tick / update / eval | train_DreamerV3.py:368, 425; row_collector.py:317 |
numpy Generator(seed) drawing per-call seeds, shared by the policy and two prefetch threads |
jaxagent.py:39, 236-237, 391-394 |
stock | UNMATCHABLE (independent streams) |
| 64 | (exploration flag unused) | n/a | run.expl_until |
0: when.Until(0) is always true, so the policy runs with mode='explore' (train.py:27, 104-105; when.py:57-66), ignored (agent.py:129) |
0 | EQUAL |
| 65 | env checks | n/a | wrapper.checks, wrapper.discretize |
True, 0 (configs.yaml:77; main.py:228-245: discrete action -> no NormalizeAction/ClipAction; ExpandScalars; CheckSpaces) |
same | EQUAL |
| 66 | task label | n/a | task |
dummy_disc (configs.yaml:5) |
gymnax_cartpole (label only: make_env is replaced, section 3.2) |
OVERRIDE |
| 67 | (output location) | n/a | logdir |
/dev/null (configs.yaml:6) |
RR/full/ref_s{s} (must not exist beforehand, section 3.5) |
OVERRIDE |
Complete override set for the reference run (a Python dict applied with
embodied.Config(agt.Agent.configs['defaults']).update(OVERRIDES), then the
stock main.py:35-38 update):
OVERRIDES = {
"seed": s, "task": "gymnax_cartpole", "logdir": f"{RR}/full/ref_s{s}",
"jax.platform": "cpu", "jax.compute_dtype": "float32", "jax.param_dtype": "float32",
"jax.prealloc": False, "jax.transfer_guard": False,
"dyn.rssm.deter": 512, "dyn.rssm.hidden": 64, "dyn.rssm.classes": 4,
r".*\.units": 64,
"run.train_ratio": 512.0, "run.steps": 20000, "run.num_envs": 16,
"run.log_every": -1, "run.eval_every": -1, "run.save_every": -1,
"run.driver_parallel": False,
}
Nothing else is overridden; every other key keeps its 29eb964 default. The
builder dumps the resolved config to RR/full/ref_s{s}/config.yaml (stock
main.py:54) and RR/compare.py asserts the values of the "This run"
column against it.
2. Environment¶
2.1 What Ajax's learner sees (gymnax CartPole-v1 through the row collector)¶
- Env stack.
DreamerV3(env_id="CartPole-v1")builds the bare gymnaxCartPole(wrapper chain['CartPole'],RR/inspect_ajax.py), defaultEnvParams(GX/environments/classic_control/cartpole.py:21-33: gravity 9.8, masscart 1.0, masspole 0.1, total_mass 1.1, length 0.5, polemass_length 0.05, force_mag 10.0, tau 0.02, theta threshold12 * 2 * pi / 360= 0.20943951 rad, x threshold 2.4,max_steps_in_episode500). - Dynamics (
cartpole.py:52-99):force = 10 a - 10 (1 - a)for action indexain {0, 1}; explicit Euler with the pre-step velocities (x += tau x_dot,x_dot += tau xacc,theta += tau theta_dot,theta_dot += tau thetaacc);reward = 1 - is_terminated(previous state)(:80-81), which is 1.0 on every step of a live episode;time += 1. Observation[x, x_dot, theta, theta_dot]float32 (:115-117). - Termination vs truncation (gymnax 1.0 six-value step,
GX/environments/environment.py:63-100):terminated = is_terminated(post-step state)=x < -2.4 or x > 2.4 or theta < -thr or theta > thr(cartpole.py:119-131, strict inequalities);truncated = post-step time >= 500(environment.py:150-152). Ajax takes the pair at face value (environments/interaction.py:238-241) and the row collector setsis_last = terminated or truncated,is_terminal = is_last and terminated(row_collector.py:529, 545-546): a time-limit end at step 500 hasis_terminal = Falseunless the pole also falls on that step. An episode has at most 500 steps, return = number of steps. - Reset (
cartpole.py:101-113):uniform(-0.05, 0.05)on all four state variables, time 0. Ajax's dynamic mode uses gymnax's auto-reset observation drawn at the ending step (environment.py:82-92; stashedrow_collector.py:548), emitted one tick later. - Rows (obs-aligned,
row_collector.py:9-27, 270-399): for an episode ofLsteps the env emitsL + 1rows: row 0 = reset observation,is_first = 1, reward 0 (row_collector.py:543-544); rows1..L= observation after stepk, reward of that step (1.0); rowLhasis_last = 1andis_terminalas above; the env is held on theis_lasttick (row_collector.py:517-551), the policy still filters the final observation and its action is zeroed in the row (row_collector.py:329); the next row is the next episode's reset row. First row of the run:is_first = 1, reward 0 (row_collector.py:221-253). - Action. The policy returns an int32 index (
train_DreamerV3.py:262);agent_action_to_envpasses discrete actions through (environments/utils.py:252-253); the env receives int32 (interaction.py:204-205). The replay stores the raw index, zeroed onis_lastrows.
2.2 Reference environment: RR/cartpole_env.py¶
A numpy port of the installed gymnax CartPole-v1 as an embodied.Env
subclass (REF/embodied/core/base.py:47-82, pattern of
embodied/envs/dummy.py), not via from_gym (no gym in the venv, and a
direct embodied.Env gives full control of the flags).
class CartPolePort(embodied.Env):
__init__(self, seed, index, recorder=None)
self._rng = np.random.default_rng([seed, index]) # reset draws of this env
self._recorder = recorder # list to append finished-episode records to (training envs), or None
self._row = -1 # row index of the last returned obs (0-based, per env)
self._state = None; self._time = 0; self._score = 0.0; self._length = 0
obs_space = {'vector': Space(np.float32, (4,)), 'reward': Space(np.float32),
'is_first': Space(bool), 'is_last': Space(bool), 'is_terminal': Space(bool)}
act_space = {'action': Space(np.int32, (), 0, 2), 'reset': Space(bool)}
step(action):
self._row += 1
if action['reset']: # driver sends reset = previous is_last (driver.py:73)
state = self._rng.uniform(-0.05, 0.05, 4).astype(np.float32); time = 0
return obs(state, reward=0.0, is_first=True, is_last=False, is_terminal=False)
assert self._state is not None and not self._done # never step past is_last without reset
a = int(action['action']) # 0 or 1
prev_terminal = terminated(self._state)
... gymnax step_env arithmetic in np.float32, same operation order (cartpole.py:61-78) ...
reward = np.float32(1.0 - prev_terminal)
time += 1
term = terminated(new_state); trunc = time >= 500
is_last = term or trunc; is_terminal = term
score += reward; length += 1
if is_last and recorder is not None: recorder.append({worker: index, row_last: self._row,
length, score, terminal: bool(term)})
return obs(new_state, reward, is_first=False, is_last, is_terminal)
obs(...) -> {'vector': float32[4] = [x, x_dot, theta, theta_dot], 'reward': np.float32,
'is_first': bool, 'is_last': bool, 'is_terminal': bool}
Constants: all float32 (np.float32(9.8), 1.0, 0.1, 1.1, 0.5,
0.05, 10.0, 0.02, np.float32(12 * 2 * np.pi / 360), 2.4); the
threshold comparisons use the float32 state, as gymnax does under jit.
score/length reset at every reset; score = sum of the step rewards of
the episode = length.
Wrapping: the stock wrap_env (main.py:228-245, imported from
dreamerv3.main) on the discrete action space adds only ExpandScalars and
CheckSpaces (embodied/core/wrappers.py:94-124, 226-250), exactly as for
the stock envs.
Equivalence with 2.1, row by row: the driver steps env w once per vector
step with the previous action (driver.py:55-65); the first call carries
reset = True (driver.py:34-38), so row 0 is the reset row (reward 0,
is_first); a step that terminates or truncates returns the final
observation with is_last and is_terminal = terminated; the driver
zeroes that row's action and sets reset (driver.py:70-74), so the next row
is a fresh reset row. Same observation, reward-on-entering, flags, action
index and L + 1 rows per episode as Ajax. Only the numbers of the reset
draws differ (numpy vs JAX PRNG; same distribution) and the float32
arithmetic may differ in the last ulp (numpy vs XLA sin/cos).
Port self-test (builder, before any run; RR/test_cartpole_env.py, run with
APY, which has both gymnax and numpy): for 200 random float32 states inside
the thresholds and both actions, the port's single step equals gymnax
CartPole().step_env (cartpole.py:52-99) on obs to 1e-6 absolute, and
terminated, truncated, reward exactly; one 600-step random-action
rollout with resets reproduces the L + 1 row structure and the
is_terminal rule (truncation at time 500 with is_terminal = False,
forced by starting a state at time = 499).
3. Training loop (reference side)¶
3.1 Entry point¶
RR/run_reference.py reproduces dreamerv3/main.py:22-66 for script == 'train':
the config built from agt.Agent.configs['defaults'] with OVERRIDES
(section 1), then the stock update replay_length = batch_length (65),
replay_length_eval = batch_length_eval (main.py:35-38), the stock
args = embodied.Config(**config.run, logdir, batch_size, batch_length,
batch_length_eval, replay_length, replay_length_eval, replay_context)
(main.py:39-47), config.save(logdir / 'config.yaml') (main.py:54),
the timer initialiser (main.py:56-59), and then calls
RR/train_instrumented.train(make_agent, make_replay, make_env, make_logger,
args) with:
make_agent(config): stockmain.py:136-144withmake_envbelow (buildsagt.Agent(env.obs_space, env.act_space, config)from a port instance that is never stepped, then closes it).make_replay = bind(dreamerv3.main.make_replay, config, 'replay'): the stock function imported fromdreamerv3.main(main.py:162-188):Replay(length=65, capacity=5e6, directory=logdir/'replay', online=True, chunksize=1024), uniform selector (fracs uniform 1.0), no rate limiter.make_env(config, index):wrap_env(CartPolePort(config.seed, index, recorder=EPISODES), config)with the stockdreamerv3.main.wrap_env.EPISODESis a module-level list inRR/run_reference.py.make_logger(config):embodied.Logger(embodied.Counter(), [embodied.logger.JSONLOutput(config.logdir, 'metrics.jsonl')], multiplier=1). Replaces the stockmain.py:147-159only because itsTensorBoardOutputneeds tensorflow (absent); the logger is passive here (section 3.4).
If importing dreamerv3.main is impossible, make_replay and wrap_env
are copied verbatim into RR/run_reference.py with a comment giving the
source lines.
3.2 RR/train_instrumented.py¶
A copy of REF/embodied/run/train.py (lines 1-128) whose stock lines are
unchanged; additions are marked # INSTRUMENTATION and are exactly:
- (after the stock
while step < args.steps:body, i.e. aftertrain.py:125-126, inside the loop)if int(step) % args.record_every == 0: record(int(step))withargs.record_every = 2000(section 3.4). - (after the loop, before
logger.close()) the flush step: with the driver's pending actionsdriver.acts(the actions the nextDriver._stepwould send,driver.py:56-64), callenv.step({k: v[i] for k, v in driver.acts.items()})once for each training enviindriver.envs(available becausedriver_parallel = False,driver.py:26-28). No policy call, noreplay.add, no training, no callbacks: its only purpose is to let the env port record episodes whoseis_lastrow is row 1 250 (the env step Ajax takes inside its last tick 1 249, see 5.2). Then writeepisodes.jsonlandrecords.jsonlfinally. record(N)(defined in the copy; reads only):train = agg.result()(stockembodied.Agg,train.py:20, 91; reset on read), keeping the scalartrain/<name>entries (drop*/dist);updates = int(agent.updates)(jaxagent.py:75, 186);retnorm:lo = float(np.asarray(agent.params['agent/retnorm/low/value'])),hilikewise withhigh(names fromRR/probe_ref_agent.py),scale = max(1.0, hi - lo)(jaxutils.py:382-386);- the evaluation of section 4;
- append one line to
RR/full/ref_s{s}/records.jsonl(section 5.1); rewriteepisodes.jsonlfromEPISODES; logger.write()(flushes the stockepisode/score,episode/lengthentries added by the stocklog_step,train.py:56-61, intometrics.jsonl: a cross-check of the port's episode log).
Nothing else changes: driver, callbacks (train.py:69-72, 92), gate and
when.Ratio (train.py:26-28, 81-91), datasets and prefetch
(train.py:74-79), policy lambda (train.py:104-106), checkpoint object
(train.py:94-101) are the stock lines.
3.3 Step counting and stopping¶
step(train.py:18,embodied.Counter) is incremented once per transition (one env's row) by the first callback (train.py:69); the driver adds 16 transitions per_step(driver.py:75-80), so a reference step is an Ajax row and reset rows are counted on both sides.driver(policy, steps=10)(train.py:109) runs exactly one vector step per call: its local counter starts at 0 and reaches 16 >= 10 after one_step(driver.py:50-53). The loop teststep < args.steps(train.py:107) is therefore evaluated after every vector step and the run stops at exactly 20 000 steps = 1 250 vector steps = rows 0..1 249 of each env, like Ajax'sn_timesteps // n_envs = 1250ticks (train_DreamerV3.py:618).- 2 000 rows = 125 vector steps:
record(N)fires after the driver call that bringssteptoN = 2000 k,k = 1..10. At that point every train call triggered by those 16 transitions has run (train_stepis a driver callback,train.py:92), as Ajax's log at tick125 k - 1runs after that tick's updates (train_DreamerV3.py:559-583;log.py:120-125withper_update = n_envs,train_DreamerV3.py:673). - Expected updates: gate at step 1 040 (
len(replay) >= 16once env 15 inserts its first 65-row item,embodied/replay/replay.py:130-138),Ratio(0.5)afterwards:1 + floor((20000 - 1040) / 2) = 9 481at 20 000, equal to Ajax (TrainRatio.total_updates(1250)).recordstoresupdatesso the auditors can check it at everyN.
3.4 Logging¶
The stock wall-clock blocks are off by config (run.log_every = -1,
run.eval_every = -1, run.save_every = -1: when.Clock returns False for
negative periods, when.py:77-78), so agg is read and reset only by
record and each window's means are over the updates of that window, as
Ajax's MetricsAccumulator (state.py:278-316; reset after each log
train_DreamerV3.py:575-583). Reference caveat (stock D1): JAXAgent.train
returns the metrics of the previous call (jaxagent.py:199-202), so a
window holds updates n0 - 1 .. n1 - 1 instead of n0 .. n1.
3.5 Checkpointing and logdir¶
logdir = RR/full/ref_s{s}; run_reference.py asserts it does not exist before
the run (with an existing checkpoint.ckpt, the stock load_or_save,
embodied/core/checkpoint.py:98-102, would load it and resume). The stock
initial save (train.py:100) writes checkpoint.ckpt once (empty replay);
save_every = -1 prevents any later save. The replay directory
logdir/replay is created by the stock Replay and stays empty.
3.6 Commands¶
The orchestrator starts all four processes with RR/launch.sh (absolute
paths only; refuses to start if RR/full/ exists; CPU only). Per process it
runs, under nohup and in parallel:
# reference, one process per seed S in 0 1 2 (cwd = REF, as stock main.py)
cd $REF && env JAX_PLATFORMS=cpu XLA_FLAGS="$REF_XLA_FLAGS" PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=$REF:$RR $RPY $RR/run_reference.py --seed S --rows 20000 \
--eval-every 2000 --eval-episodes 10 --out $RR/full/ref_sS > $RR/full/ref_sS.log 2>&1
# Ajax, one vmapped run over the 3 seeds (cwd = RR: nothing written under AJ)
cd $RR && env JAX_PLATFORMS=cpu XLA_FLAGS="$AJAX_XLA_FLAGS" PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=$AJ/src $APY $RR/run_ajax.py --seeds 0 1 2 --rows 20000 \
--log-every 2000 --eval-episodes 10 --out $RR/full/ajax > $RR/full/ajax.log 2>&1
When a process exits, its wrapper writes its exit code to
RR/full/<name>.DONE (name = ref_s0, ref_s1, ref_s2, ajax; via a
.tmp file and mv). REF_XLA_FLAGS / AJAX_XLA_FLAGS default to empty
(default threading, measured faster than single-thread Eigen even when two
reference processes share the machine; threading changes speed only).
PYTHONDONTWRITEBYTECODE=1 keeps even bytecode caches out of REF and
AJ. launch_info.txt records the launch time, load, the REF and AJ
revisions and a hash of AJ's uncommitted src diff. When the four DONE
files exist: $APY $RR/compare.py --root $RR/full --seeds 0 1 2.
run_ajax.py: agent = ajax.DreamerV3("CartPole-v1", model_size="1m");
state, metrics = agent.train(seed=[0, 1, 2], n_timesteps=20000,
num_episode_test=10, logging_config=LoggingConfig(config={},
log_frequency=2000, use_wandb=False, use_tensorboard=False)) (one vmapped
run, as benchmarks/learning_checks.py:11 runs a check). It must not modify
anything under AJ. run_ajax.py and run_reference.py record the file
the code was imported from (ajax.__file__; dreamerv3.agent.__file__,
embodied.__file__), checked by compare.py.
4. Evaluation protocol (both sides)¶
4.1 Ajax (as implemented; nothing to build)¶
- When: on ticks with
(tick + 1) % 125 == 0(log.py:119-125,every = 2000 // 16), at the end of the tick, after its updates (train_DreamerV3.py:575-578):N = 2000, 4000, ..., 20000. - What (
train_DreamerV3.py:466-491,evaluate.py:661-762):num_episode_test = 10freshly reset parallel envs, the bare gymnaxCartPole(evaluate.py:130,env.unwrapped), one episode per env: a scan ofhorizon = 500steps (evaluate.py:716,utils.agent_episode_length), the return and length of each env summed only while it has not yet been done (evaluate.py:736-739);done = terminated or truncated. Only each env's first episode counts, so short episodes are not over-represented. Return = episode length, at most 500. Logged: the mean return over the 10 envs (Eval/episodic mean reward) and the mean length. - Policy: the current parameters after the tick's updates
(
bind_policy,train_DreamerV3.py:267-281, 483); zero carry (initial_policy_carry,train_DreamerV3.py:205-215, 484),is_first = Trueon the first step only (evaluate.py:755, 744); sampled posterior and actor (policy_step,train_DreamerV3.py:218-264; no deterministic mode, as the reference). - Randomness:
eval_key = split(agent_state.eval_rng)[0](log.py:128);eval_rngis set once at init (train_DreamerV3.py:368, 377) and never advanced (maybe_eval_and_logonly replacesn_logs,log.py:152-154). Every evaluation of a seed therefore uses the same 10 initial states and the same noise keys; only the parameters change between evaluations.
4.2 Reference (instrumentation in record, RR/train_instrumented.py)¶
Mirror of 4.1 with the reference's own policy function:
- When: in
record(N), i.e. after the vector step that bringssteptoNand all its updates (3.3). - Envs: 10 fresh
CartPolePortinstances wrapped with the stockwrap_env,recorder=None, separate from the 16 training envs; envi's reset generatornp.random.default_rng([s, 7_000_001, i])is re-created at every evaluation (mirrors Ajax's fixed eval key: same 10 initial states at every evaluation of seeds). First obs:env.step({'action': 0, 'reset': True})(reset row,is_first = True). - Parameters: the latest trained policy parameters,
params = {k: agent.params[k].copy() for k in agent.policy_keys}(jaxagent.py:77-78, 81-83;.copy()because the nextJAXAgent.traindeletes the non-synced policy buffers,jaxagent.py:188-192). Notagent.policy_params, which lag by one to two vector steps (stock D1) and which Ajax's evaluation has no counterpart of. - Carry:
carry = agent._init_policy(params, seed0, batch_size=10)(jaxagent.py:297-299, 352-353:RSSM.initialzeros and zero previous action,agent.py:114-118). - Steps: up to 500 iterations:
obs = agent._filter_data(stacked obs of the 10 envs)(jaxagent.py:396-397);acts, _, carry = agent._policy(params, obs, carry, seed_t, 'eval')(jaxagent.py:301-303, 354-355; the sameAgent.policy,agent.py:129-164, which samples); for each env not yet done:o = env.step({'action': acts['action'][i], 'reset': False}),ret[i] += o['reward'],len[i] += 1,done[i] |= o['is_last']; a done env keeps being fed its last observation (its outputs are ignored; the batch rows are independent). Stop when all are done or after 500 iterations (the port truncates at 500 anyway).seed0and everyseed_tareuint32[2]drawn fromnp.random.default_rng([s, 7_000_001, 1_000_000]), re-created at every evaluation, andjax.device_putexplicitly. - No side effect on training: the evaluation calls the jitted
_policydirectly, notJAXAgent.policy, so it does not draw fromagent.rng(jaxagent.py:140, 391-394) and does not trigger the pending parameter swap of the training policy (jaxagent.py:147-153). (Going throughJAXAgent.policywould consume the agent's RNG stream, which alone would be acceptable, but it would also change the staleness of the training actor's parameters, which is not; hence the direct call.) - Outputs: per evaluation, the 10 returns and lengths, their means
(
eval_mean,eval_len_mean).
Unavoidable differences: the draws themselves (numpy vs JAX streams), and
eval compiles the policy once for batch 10 (time only).
5. Recording and statistics¶
5.1 Output files¶
Directory RR/full/ (smoke runs: RR/out_smoke/):
ref_s{s}.DONE,ajax.DONE(exit code of each process, written bylaunch.sh),ref_s{s}.log,ajax.log,*.nohup,pids.txt,launch_info.txt;-
ajax/config.json: Ajax's resolved config (constructor arguments,DreamerV3Config, agent config, schedule, ring rows, run arguments,ajax_file);ref_s{s}/run_args.json(run arguments, overrides, imported file locations) andref_s{s}/timing.json(written byInstr.finishafter the flush step:flush_episodes,t_end, timings); -
ref_s{s}/records.jsonlandajax_records.jsonl(Ajax: one file, aseedfield per line): one JSON object per log pointN, same schema on both sides:
{"side": "ref|ajax", "seed": 0, "rows": 2000, "updates": 481,
"eval_mean": 22.0, "eval_len_mean": 22.0, "eval_returns": [..10..] | null,
"s2_logged": 21.3 | null,
"train": {"<ajax name>": mean over the window, ...},
"train_raw": {"<native name>": mean over the window, ...},
"retnorm": {"lo": .., "hi": .., "scale": ..} | null,
"wall_s": 123.4}
Ajax: rows = timestep, updates = Train/n_updates, eval_mean =
Eval/episodic mean reward, eval_len_mean = Eval/mean episodic length,
eval_returns = null (Ajax returns only the mean,
evaluate.py:762), s2_logged = Train/episodic mean reward, train =
train_raw = every Train/<name> with the prefix stripped, retnorm =
null; selected as diag.py does (ticks where the eval is finite,
SP/m7/lc/diag.py:31-32). Reference: s2_logged = null (S2 is computed
by compare.py from episodes.jsonl), train = train_raw renamed with
the map of 5.4, train_raw = the agg.result() scalars with the train/
prefix stripped.
* ref_s{s}/episodes.jsonl: one line per finished training episode,
{"seed", "worker", "row_last", "length", "score", "terminal"}, from the
port's recorder, including the flush step (3.2).
* ref_s{s}/metrics.jsonl (stock logger, cross-check), config.yaml,
checkpoint.ckpt; ref_s{s}.log, ajax.log (stdout).
* ajax_final.npz: per seed, the final
state.collector_state.episodic_return_state (buffer [seeds, 10, 16, 1],
count, index, sum), last_episode_length, state.retnorm.lo/hi,
n_updates (state reads only), for the S2 audit.
* summary.json written by RR/compare.py, plus its printed tables.
5.2 Pre-registered statistics (fixed; not to be changed after results exist)¶
- S1 (gate statistic), per seed: the mean of
eval_meanatN = 16000, 18000, 20000(3 evaluations x 10 episodes; equivalently the mean of the 30 episode returns). - S2, per seed, at
N = 20000(and recorded at everyN): Ajax'sTrain/episodic mean rewarddefinition, made explicit: for envwletE_w(N)be its finished training episodes whoseis_lastrow has per-env row indexrow_last <= N / 16(0-based; the episode is finished by the env step Ajax takes in tickN/16 - 1), in completion order,k_w = |E_w(N)|, andm_wthe mean return of its lastmin(10, k_w)episodes;S2(N) = (1/16) sum_w m_wif everyk_w >= 1, else NaN. Ajax: by construction ofupdate_episodic_return/update_rolling_mean(interaction.py:79-101, 492-551: per-env ring of window 10,count = min(count + 1, 10), mean over envs ofsum / count, NaN while any env hascount = 0since0/0), the window size 10 (row_collector.py:211, 235-239), the episode counted when the env step ends it (ended = is_last & stepped,row_collector.py:367-374) and the return = sum of the stepped rewards (row_collector.py:368). The Ajax value is read from the log (s2_logged);compare.pyaudits it at 20 000 against the final buffer inajax_final.npz(mean_w sum_w / count_w, tolerance 1e-4). Reference: computed bycompare.pyfromepisodes.jsonlwith the formula above (score = sum of the episode's step rewards, as Ajax's return and as the stockepisode/score,train.py:37-42, 56-61). The flush step (3.2) supplies the episodes withrow_last = 1250. - Also recorded, not part of the decision: the eval curves (
eval_mean,eval_len_mean) andS2(N)at everyN = 2000 k, the training-metric window means (5.4),updatesat everyN, the reference's retnorm state at everyNand Ajax's at 20 000. - Seeds: 0, 1, 2 on each side; the seed values index independent runs (the two codebases' RNGs differ anyway).
- Reported rule (printed by
compare.py; the decision is the user's):A = mean over Ajax seeds of S1,[lo, hi] = [min, max]over reference seeds of S1.LEARNS = (A >= lo),WITHIN_RANGE = (lo <= A <= hi). Print both booleans,A,lo,hi, the per-seed S1 and S2(20000) tables for both sides, the eval and S2 curves for both sides, and a side-by-side table of the mapped training metrics per window (mean over seeds, with min/max).
5.3 RR/compare.py¶
Reads the files of 5.1 and asserts (exit 1 if any fails; full mode unless
--smoke):
- every process finished with exit code 0 (
RR/full/<name>.DONEholds0; required in full mode, checked when present in smoke mode); - reference, per seed: the
config.yamlvalues of section 1's "This run" column;run_args.json= seed,tinyFalse, rows 20 000, eval every 2 000, 10 evaluation episodes (full mode); every evaluation has that many episodes; the reference code was imported fromREF; - Ajax:
ajax/config.json= everyDreamerV3Configvalue of the protocol (widths 64 / 64 / 512 / 4 / 32 / 8 and all algorithm values, as audited; the--tinywidths in a tiny smoke run), agent config train ratio 512, batch 16 x 64, replay capacity 5e6, constructorCartPole-v1/1m/ 16 envs / no extensions, ring rows = rows / 16, gate 1 040, total updates =1 + floor((rows - 1040) / 2), seeds = the compared seeds, Ajax imported fromAJ/src; full mode alsotinyFalse, rows 20 000, log every 2 000, 10 evaluation episodes; - 10 records per seed per side with
rows = 2000 k;updatesat everyNequal to1 + floor((N - 1040) / 2)on both sides (9 481 at 20 000); - the Ajax S2 audit, and Ajax's final state rows / updates;
- the reference episode log consistency (
length == score, everylength <= 500,terminal == Falseonly whenlength == 500; per worker, the episodes tile rows0..row_lastwithL + 1rows each); - the reference run finished and its flush step ran:
timing.jsonhast_endandflush_episodes, andflush_episodesequals the number of episodes withrow_last = rows / 16(only the flush step can produce them), so S2(20 000) is never computed from a log without the flush.
Then prints and writes compare.md, summary.json and curves.png as
specified in 5.2, with a "Known differences" section listing D1 / U1
(stale acting parameters, prefetched first batch), D2 / U2 (shared
reference replay-sampler stream: the reference S1 range comes from three
not fully independent runs), the report prefetch stream (6.1), and the NaN
semantics of the two window means (5.4) together with any non-finite Ajax
window means of the run.
5.4 Training-metric name map¶
Ajax names (train_metric_keys, train_DreamerV3.py:316-356; values
learner.py:419-470, 553-580) to reference names (agent.py:355-389;
optimizer jaxutils.py:540-549, prefix opt_ from the optimizer's name
opt, agent.py:88; Agg prefix train/, train.py:91). Both sides log the
unscaled per-term loss means (agent.py:355, learner.py:431-440).
| Quantity | Ajax Train/<name> |
Reference train/<name> |
|---|---|---|
| decoder (vector) loss | rec_loss, rec_loss_std |
vector_loss, vector_loss_std |
| reward loss | rew_loss, rew_loss_std |
reward_loss, reward_loss_std |
| continue loss | con_loss, con_loss_std |
cont_loss, cont_loss_std |
| dynamics KL (free-bits) | dyn_loss, dyn_loss_std |
dyn_loss, dyn_loss_std |
| representation KL | rep_loss, rep_loss_std |
rep_loss, rep_loss_std |
| actor loss | actor_loss, actor_loss_std |
actor_loss, actor_loss_std |
| critic loss (imagination) | critic_loss, critic_loss_std |
critic_loss, critic_loss_std |
| replay critic loss | repval_loss, repval_loss_std |
replay_critic_loss, replay_critic_loss_std |
| total loss, grad / update / param norms, optimizer steps | opt_loss, opt_grad_norm, opt_update_norm, opt_param_norm, opt_grad_steps |
same names |
| policy entropy (imagination) | ent/action/{mean,std,mag,min,max} |
same |
| normalised entropy | rand/action/{...} |
same |
| imagined actions | act/action/{...} |
same |
| advantages | adv/{...} |
same |
| imagined rewards | rew/{...} |
same |
| weights (cumulative continuation) | weight/{...} |
same |
| values | val/{...} |
same |
| lambda returns | ret/{...} |
same |
normalised returns (ret - lo) / scale |
ret_normed/{...} |
same |
| replay returns | replay_ret/{...} |
same |
| prior / posterior entropy | prior_ent/{...}, post_ent/{...} |
same |
| TD error, return rate | td_error, ret_rate |
same |
| data / predicted reward | data_rew/{max,mean,std}, pred_rew/{max,mean,std} |
same |
| embedding magnitude | activation/embed |
same |
| return-normaliser scale | derived: window mean of ret/std / window mean of ret_normed/std (approximate, both sides identically); exact at 20 000 from ajax_final.npz |
exact at every N: retnorm.scale of records.jsonl (state read), and the same derived ratio |
| updates so far | n_updates |
int(agent.updates) (updates field) |
| reference only (recorded, not compared) | none | rewstats/*, constats/*, opt_param_count, */dist (dropped) |
{...} = {mean, std, mag, min, max} (jaxutils.py:52-66; Ajax learner.py:405-417
without the random dist subsample).
Window means and NaN: the reference Agg (embodied/core/agg.py:80-87,
107-117) skips NaN values (a NaN does not count, and a NaN initial value
is replaced by the next finite one); Ajax's MetricsAccumulator
(state.py:297-309) adds every value, so one NaN makes its window mean NaN
(written as null by run_ajax.py). The two agree whenever all values are
finite. A NaN Ajax window against a finite reference window therefore
means "a NaN occurred in that Ajax window", not a divergence;
compare.py lists the non-finite Ajax windows.
6. Departures from the stock reference, and unmatchable settings¶
6.1 Everything the reference run adds to or changes in stock 29eb964¶
- Config overrides (section 1,
OVERRIDES): 1m dims (rows 3-6), train ratio 512, float32, cpu,run.steps20000,run.log_every/run.eval_every/run.save_every= -1,run.driver_parallelFalse,jax.prealloc/jax.transfer_guardFalse,seed,tasklabel,logdir. None changes an algorithmic code path: the driver computes the same transitions in-process, andlog_every/save_everyonly read state (log) or write a checkpoint.eval_every = -1does change one input to training slightly, through the report stream (paragraph below). - Environment:
RR/cartpole_env.py(a newembodied.Env, section 2.2) with an episode recorder (append-only list; reads the env's own values). - Entry point
RR/run_reference.py: stockmain.pyconfig/args logic;make_env(port + stockwrap_env),make_logger(JSONL only, no tensorflow),make_agent(stock body, port spaces), stockmake_replay. RR/train_instrumented.py: stocktrain.pyplus the three# INSTRUMENTATIONadditions of 3.2:record(N)at multiples of 2 000 steps (readsagg,agent.updates, retnorm parameters; evaluation; file writes), the post-loop flush env step (reads episode ends only), and the final file writes.- Evaluation (section 4.2): separate env instances, separate numpy
generators, direct calls of the jitted
_init_policy/_policywith a copy of the latest policy parameters. - Recording only:
run_args.json(arguments, overrides, jax version, XLA flags, the filesdreamerv3.agentandembodiedwere imported from),timing.json,progress.jsonl. - Launch (
RR/launch.sh, 3.6):nohupwrapper writing a DONE marker with the exit code;PYTHONDONTWRITEBYTECODE=1; no XLA flags by default.
The stock report prefetch stream is still created (train.py:76-77,
dataset_report) although agent.report never runs (should_eval is
when.Clock(-1), always False, when.py:76-78). agent.dataset wraps it
in embodied.Prefetch(amount=1) (jaxagent.py:233-238), whose thread starts
at once (prefetch.py:11-15) and, as soon as the replay holds an item
(limiters.MinSize(1); the first items appear around row 1 040, when each
worker has 65 rows), assembles one batch (16 Replay._sample calls, each
popping the online queue first, replay.py:178-182, else drawing from the
Uniform selector; plus one agent.rng seed draw), puts it in the queue,
assembles a second one and blocks on put for good. So it consumes up to
32 samples, i.e. up to 32 of the earliest online-queue items (around rows
1 025-1 100), which training then does not receive as online items (they
stay in the replay for uniform sampling). In a stock run (eval_every 180
s of wall time) it would also take 16 more samples (one report batch of
length 33) every 180 s, a timing-dependent perturbation the override
removes. Ajax has no report stream (D2). Impact: negligible; training
itself draws 128 samples per vector step against 16 new items, so it
drains the online queue every step. This is stock behaviour and is kept;
compare.md repeats it in its "Known differences" section.
6.2 UNMATCHABLE settings and expected impact¶
| # | What differs | Reference (stock) | Ajax | Expected impact on S1 / S2 |
|---|---|---|---|---|
| U1 | Asynchrony (D1): acting parameters, prefetch, metrics | policy parameters swapped in only after the next policy call and only the pre-update parameters of the first update since the last swap are queued (jaxagent.py:147-153, 188-192): the training actor lags by about 1-2 vector steps (~8-16 of 9 481 updates); train/report batches prefetched by threads before the gate and one batch ahead (prefetch.py:32-41); write-back and metrics one call late (jaxagent.py:194-202) |
synchronous | Small: a lag of ~1 % of the run's updates; the first prefetched batches may come from the first items only. Measured on the real reference (RR/audit_loop, tiny, seed 0): acting parameters equal the latest at rows 400 / 800 (0 updates) and differ at 1 200 / 1 600 / 2 000; the first training batch had 11 distinct items of 16. Evaluation uses the latest parameters on both sides. Not expected to change whether CartPole is learned. |
| U2 | Replay sampler RNG (D2) | Uniform(seed=0) for every run seed (selectors.py:29-32, main.py:187): the three reference seeds share the index stream (the data differ) |
seeded per run | Small (the replay contents differ between seeds); slightly reduces the independence of the three reference seeds, so the reference S1 [min, max] range of the reported rule comes from three not fully independent runs. Stock behaviour, kept (make_replay must stay stock); disclosed in compare.md. |
| U3 | Update placement within a vector step (D3) | an update can run between env w and env w+1's adds (train.py:81-92) |
after the tick's 16 adds | Negligible (same rate, intra-tick order). |
| U4 | Two-hot bins and expectation (D22); output width 256 vs 255 | float32 bins inside jit; zero-init heads predict 0.07-0.16; 256 logits, last dropped | float64-rounded bins; exactly 0 at init; 255 logits | Width: none. Values: a tiny initial reward/value offset; negligible after the first updates. |
| U5 | Kernel init constant (D23) | 1.1368 sqrt(1/fan_in) truncated normal |
variance_scaling truncated normal |
4.16e-5 relative scale: negligible. |
| U6 | RNG streams | numpy Generator(seed) seeds shared by the policy and prefetch threads (jaxagent.py:39, 391-394), params from [seed, 0], port resets from default_rng([seed, w]) |
JAX keys from seed |
Different draws on every seed: handled statistically by 3 seeds per side. The reference is not bitwise reproducible across reruns (thread timing of the prefetch seeds and online-queue pops). |
| U7 | Env arithmetic | numpy float32 | XLA float32 | Last-ulp differences; same distribution. |
| U8 | Evaluation draws | numpy streams (structure of 4.1 mirrored: same initial states at every evaluation of a seed) | fixed JAX eval_rng |
None on expectations; per-evaluation numbers differ. |
7. Builder checklist (smoke and speed only; the orchestrator launches the long runs)¶
RR/test_cartpole_env.py(section 2.2) passes withAPY(RR/test_cartpole_env.log).- Reference smoke, seeds 0 and 1 (cwd
REF, environment as in 3.6):run_reference.py --seed S --rows 1200 --eval-every 400 --eval-episodes 2 --tiny --out $RR/out_smoke/ref_sS(400 = 25 vector steps; 1 200 > 1 040 so the gate opens and 1 + floor(160/2) = 81 updates run): 3 records withupdates0, 0, 81; fresh logdir (no checkpoint loaded). - Ajax smoke (cwd
RR):run_ajax.py --seeds 0 1 --rows 1200 --log-every 400 --eval-episodes 2 --tiny --out $RR/out_smoke/ajax: 3 records per seed,updates0, 0, 81. compare.py --root $RR/out_smoke --seeds 0 1 --smokepasses every check (checkpoints 400 k, S1 window = the last three checkpoints, smoke only).- Speed: full-size reference runs of about 1 200-1 500 rows
(
RR/out_speed/ref_s*,progress.jsonl): wall clock per update after the gate, build + compile time, minutes per seed for 9 481 updates; same for Ajax (RR/out_speed/ajax_1200). - No command over ~15 minutes; CPU only; nothing written outside
RR;launch.shis checked (bash -n, wrapper idiom tested separately) but not run by the builder: the orchestrator launches it, and its outputs go toRR/full/, separate from the smoke and speed outputs.