| """Smoke tests for the src/ diffusion planner: build, forward, train, save, sample. |
| |
| Proves the pipeline runs end to end. Asserts nothing about result quality. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import jax |
| import jax.numpy as jnp |
| import numpy as np |
| import orbax.checkpoint as ocp |
| import pytest |
|
|
| from tests.conftest import ( |
| BATCH, |
| NUM_ACTIONS, |
| OBS_DIM, |
| PLAN_HORIZON, |
| ROOT, |
| SEED, |
| SRC_MODULES, |
| import_or_skip, |
| load_config, |
| ) |
|
|
|
|
| def _finite(x) -> bool: |
| return bool(jnp.all(jnp.isfinite(x))) |
|
|
|
|
| |
| |
| |
|
|
|
|
| @pytest.mark.parametrize("module_name", SRC_MODULES) |
| def test_src_module_imports(module_name: str) -> None: |
| assert import_or_skip(module_name) is not None |
|
|
|
|
| def test_module_discovery_found_the_package() -> None: |
| assert "src.models.denoiser" in SRC_MODULES |
| assert "src.diffusion.sampling" in SRC_MODULES |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_model_builds_from_real_config(real_config, craftax_env) -> None: |
| """Full-size model from the shipped configs/defaults.yaml at real env dims.""" |
| from src.planners.model import build_model, init_params |
|
|
| model = build_model(real_config, craftax_env["num_actions"]) |
| assert model.d_model == real_config["D_MODEL"] |
| assert model.n_layers == real_config["N_LAYERS"] |
| assert model.plan_horizon == real_config["PLAN_HORIZON"] |
|
|
| real_params = init_params( |
| model, jax.random.PRNGKey(SEED), |
| craftax_env["obs_dim"], real_config["PLAN_HORIZON"], |
| ) |
| leaves = jax.tree_util.tree_leaves(real_params) |
| assert leaves, "real config produced an empty parameter tree" |
| assert all(_finite(leaf) for leaf in leaves) |
|
|
|
|
| def test_tiny_model_params_are_finite(params) -> None: |
| leaves = jax.tree_util.tree_leaves(params) |
| assert leaves |
| assert all(_finite(leaf) for leaf in leaves) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_forward_pass_shape_dtype_and_no_nans(apply_fns, params, batch) -> None: |
| apply_eval, _ = apply_fns |
| logits = apply_eval(params, batch["obs"], batch["acts"], batch["timestep"]) |
|
|
| assert logits.shape == (BATCH, PLAN_HORIZON, NUM_ACTIONS) |
| assert logits.dtype == jnp.float32 |
| assert _finite(logits) |
|
|
|
|
| def test_forward_pass_accepts_mask_tokens(apply_fns, params, batch) -> None: |
| """MASK id == num_actions must be a valid input token.""" |
| apply_eval, _ = apply_fns |
| masked = jnp.full_like(batch["acts"], NUM_ACTIONS) |
| logits = apply_eval(params, batch["obs"], masked, batch["timestep"]) |
|
|
| assert logits.shape == (BATCH, PLAN_HORIZON, NUM_ACTIONS) |
| assert _finite(logits) |
|
|
|
|
| def test_forward_pass_is_deterministic(apply_fns, params, batch) -> None: |
| apply_eval, _ = apply_fns |
| first = apply_eval(params, batch["obs"], batch["acts"], batch["timestep"]) |
| second = apply_eval(params, batch["obs"], batch["acts"], batch["timestep"]) |
| assert jnp.array_equal(first, second) |
|
|
|
|
| def test_train_apply_runs_with_dropout(apply_fns, params, batch) -> None: |
| _, apply_train = apply_fns |
| logits = apply_train( |
| params, batch["obs"], batch["acts"], batch["timestep"], |
| jax.random.PRNGKey(SEED), |
| ) |
| assert logits.shape == (BATCH, PLAN_HORIZON, NUM_ACTIONS) |
| assert _finite(logits) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_compute_loss_is_finite(apply_fns, params, batch, schedules) -> None: |
| from src.diffusion.loss import compute_loss |
|
|
| _, apply_train = apply_fns |
| schedule_fn, schedule_deriv_fn = schedules |
| loss, info = compute_loss( |
| apply_train, params, jax.random.PRNGKey(SEED), |
| batch["acts"], batch["obs"], batch["valid"], |
| NUM_ACTIONS, schedule_fn, schedule_deriv_fn, |
| ) |
|
|
| assert loss.shape == () |
| assert _finite(loss) |
| assert all(_finite(v) for v in info.values()) |
|
|
|
|
| def test_adamw_at_zero_decay_matches_adam(model, apply_fns, params, batch, schedules) -> None: |
| """Core training moved from optax.adam to optax.adamw with an |
| explicit weight_decay=0.0 (author decision 2026-08-16). AdamW's |
| decay is decoupled and additive, so at 0.0 the two updates are the |
| same to the last bit - this is the equivalence guard for that |
| change. |
| |
| Only the optimiser-state *structure* differs (adamw's chain carries |
| an extra EmptyState), which is why a checkpoint saved by the old |
| chain cannot be resumed into the new one. |
| """ |
| import optax |
| from flax.training.train_state import TrainState |
|
|
| from src.planners.common import make_grad_step |
| from src.planners.model import create_train_state |
|
|
| _, apply_train = apply_fns |
| schedule_fn, schedule_deriv_fn = schedules |
| step_fn = make_grad_step( |
| apply_train, NUM_ACTIONS, schedule_fn, schedule_deriv_fn, 0.0, 0.0 |
| ) |
|
|
| adamw_state = create_train_state(model, params, 1e-3, 1.0, weight_decay=0.0) |
| adam_state = TrainState.create( |
| apply_fn=model.apply, |
| params=params, |
| tx=optax.chain(optax.clip_by_global_norm(1.0), optax.adam(1e-3, eps=1e-5)), |
| ) |
|
|
| args = (batch["acts"], batch["obs"], batch["valid"], |
| jax.random.PRNGKey(SEED), batch["advantages"]) |
| for _ in range(3): |
| adamw_state, _ = step_fn(adamw_state, *args) |
| adam_state, _ = step_fn(adam_state, *args) |
|
|
| for a, b in zip( |
| jax.tree_util.tree_leaves(adamw_state.params), |
| jax.tree_util.tree_leaves(adam_state.params), |
| ): |
| assert jnp.array_equal(a, b) |
|
|
|
|
| def test_one_grad_step_runs_and_updates_params( |
| model, apply_fns, params, batch, schedules, tiny_config, |
| ) -> None: |
| from src.planners.common import make_grad_step |
| from src.planners.model import create_train_state |
|
|
| _, apply_train = apply_fns |
| schedule_fn, schedule_deriv_fn = schedules |
| state = create_train_state(model, params, tiny_config["LR"], 1.0) |
|
|
| step = make_grad_step( |
| apply_train, NUM_ACTIONS, schedule_fn, schedule_deriv_fn, |
| sigma_t=tiny_config["TRAIN_SIGMA"], |
| label_smoothing=tiny_config["LABEL_SMOOTHING"], |
| ) |
| new_state, metrics = step( |
| state, batch["acts"], batch["obs"], batch["valid"], |
| jax.random.PRNGKey(SEED), batch["advantages"], |
| ) |
|
|
| assert _finite(metrics["loss"]) |
| assert _finite(metrics["grad_norm"]) |
| assert int(new_state.step) == int(state.step) + 1 |
| assert all(_finite(leaf) for leaf in jax.tree_util.tree_leaves(new_state.params)) |
|
|
| changed = any( |
| not jnp.array_equal(a, b) |
| for a, b in zip( |
| jax.tree_util.tree_leaves(state.params), |
| jax.tree_util.tree_leaves(new_state.params), |
| ) |
| ) |
| assert changed, "one gradient step left every parameter untouched" |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_checkpoint_roundtrip_preserves_output( |
| model, apply_fns, params, batch, tmp_path, |
| ) -> None: |
| from src.planners.model import load_checkpoint |
|
|
| apply_eval, _ = apply_fns |
| before = apply_eval(params, batch["obs"], batch["acts"], batch["timestep"]) |
|
|
| ckpt_dir = tmp_path / "ckpt" |
| with ocp.CheckpointManager(str(ckpt_dir)) as mgr: |
| mgr.save(0, args=ocp.args.PyTreeSave({"params": params})) |
| mgr.wait_until_finished() |
|
|
| restored = load_checkpoint( |
| model, jax.random.PRNGKey(SEED + 1), OBS_DIM, PLAN_HORIZON, str(ckpt_dir), |
| ) |
| after = apply_eval(restored, batch["obs"], batch["acts"], batch["timestep"]) |
|
|
| assert jnp.array_equal(before, after), "reloaded model produced different output" |
|
|
|
|
| def test_checkpoint_metadata_roundtrip(tmp_path, tiny_config) -> None: |
| from src.planners.model import load_checkpoint_metadata, save_checkpoint_metadata |
|
|
| assert load_checkpoint_metadata(str(tmp_path)) is None |
|
|
| save_checkpoint_metadata( |
| str(tmp_path), mode="offline", |
| update_step=np.int64(7), total_gradient_steps=np.int64(70), |
| wandb_run_id=None, config=tiny_config, |
| ) |
| meta = load_checkpoint_metadata(str(tmp_path)) |
|
|
| assert meta["mode"] == "offline" |
| assert meta["update_step"] == 7 |
|
|
|
|
| def test_missing_checkpoint_raises(model, tmp_path) -> None: |
| from src.planners.model import load_checkpoint |
|
|
| with pytest.raises(FileNotFoundError): |
| load_checkpoint( |
| model, jax.random.PRNGKey(SEED), OBS_DIM, PLAN_HORIZON, |
| str(tmp_path / "empty"), |
| ) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_schedule_endpoints_and_derivatives() -> None: |
| from src.diffusion.schedules import SCHEDULE_MAP |
|
|
| for name, (alpha, alpha_dot) in SCHEDULE_MAP.items(): |
| t = jnp.array([0.0, 0.5, 1.0]) |
| a, ad = alpha(t), alpha_dot(t) |
| assert _finite(a) and _finite(ad), name |
| assert np.isclose(float(a[0]), 1.0, atol=1e-6), f"{name}: alpha(0) != 1" |
| assert np.isclose(float(a[-1]), 0.0, atol=1e-6), f"{name}: alpha(1) != 0" |
| assert bool(jnp.all(ad <= 0)), f"{name}: alpha is not non-increasing" |
|
|
|
|
| def test_forward_process_masks_and_preserves(batch) -> None: |
| from src.diffusion.forward import forward_process |
|
|
| keep_all = forward_process( |
| jax.random.PRNGKey(SEED), batch["acts"], jnp.ones((BATCH,)), NUM_ACTIONS, |
| ) |
| mask_all = forward_process( |
| jax.random.PRNGKey(SEED), batch["acts"], jnp.zeros((BATCH,)), NUM_ACTIONS, |
| ) |
|
|
| assert jnp.array_equal(keep_all, batch["acts"]) |
| assert bool(jnp.all(mask_all == NUM_ACTIONS)) |
| assert mask_all.dtype == batch["acts"].dtype |
|
|
|
|
| |
| |
| |
| @pytest.mark.parametrize("remask_strategy", ["rescale", "cap", "conf"]) |
| @pytest.mark.parametrize("use_loop", [True]) |
| def test_sample_plan_runs( |
| apply_fns, params, batch, schedules, remask_strategy, use_loop, |
| ) -> None: |
| from src.diffusion.sampling import sample_plan |
|
|
| apply_eval, _ = apply_fns |
| schedule_fn, _ = schedules |
| plan = sample_plan( |
| apply_eval, params, jax.random.PRNGKey(SEED), batch["obs"], |
| NUM_ACTIONS, PLAN_HORIZON, num_steps=3, schedule_fn=schedule_fn, |
| remask_strategy=remask_strategy, eta=0.5, use_loop=use_loop, |
| t_on=0.7, t_off=0.3, temperature=0.5, top_p=0.95, |
| ) |
|
|
| assert plan.shape == (BATCH, PLAN_HORIZON) |
| assert jnp.issubdtype(plan.dtype, jnp.integer) |
| assert bool(jnp.all(plan >= 0)) and bool(jnp.all(plan < NUM_ACTIONS)), ( |
| "sampled plan contains a MASK or out-of-vocabulary action" |
| ) |
|
|
|
|
| def test_sample_plan_rejects_unknown_strategy(apply_fns, params, batch, schedules) -> None: |
| from src.diffusion.sampling import sample_plan |
|
|
| apply_eval, _ = apply_fns |
| schedule_fn, _ = schedules |
| with pytest.raises(ValueError): |
| sample_plan( |
| apply_eval, params, jax.random.PRNGKey(SEED), batch["obs"], |
| NUM_ACTIONS, PLAN_HORIZON, num_steps=2, schedule_fn=schedule_fn, |
| remask_strategy="not-a-strategy", |
| ) |
|
|
|
|
| def test_sample_plan_inpainting_locks_history( |
| apply_fns, params, batch, schedules, |
| ) -> None: |
| from src.diffusion.sampling import sample_plan_inpainting |
|
|
| apply_eval, _ = apply_fns |
| schedule_fn, _ = schedules |
| hist_len = jnp.full((BATCH,), 2, dtype=jnp.int32) |
| history = jnp.zeros((BATCH, PLAN_HORIZON), dtype=jnp.int32) |
|
|
| plan = sample_plan_inpainting( |
| apply_eval, params, jax.random.PRNGKey(SEED), batch["obs"], |
| history, hist_len, NUM_ACTIONS, PLAN_HORIZON, |
| diffusion_steps=3, schedule_fn=schedule_fn, |
| remask_strategy="rescale", eta=0.5, |
| use_loop=False, t_on=0.7, t_off=0.3, |
| temperature=0.5, top_p=0.95, |
| ) |
|
|
| assert plan.shape == (BATCH, PLAN_HORIZON) |
| assert jnp.issubdtype(plan.dtype, jnp.integer) |
| assert jnp.array_equal(plan[:, :2], history[:, :2]), "historical prefix was overwritten" |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_resolve_num_updates_from_frame_budget(real_config) -> None: |
| from src.planners.common import resolve_num_updates |
|
|
| config = {**real_config, "NUM_ENVS": 8, "NUM_STEPS": 4, "OFFLINE_TOTAL_TIMESTEPS": 320} |
| resolve_num_updates(config, "offline") |
|
|
| assert config["NUM_UPDATES"] == 10 |
| assert config["OFFLINE_TOTAL_TIMESTEPS"] == 320 |
|
|
| resolve_num_updates(config, "offline") |
| assert config["NUM_UPDATES"] == 10 |
|
|
|
|
| def test_resolve_scaled_hyperparams(real_config) -> None: |
| from src.planners.common import resolve_num_updates, resolve_scaled_hyperparams |
|
|
| config = { |
| **real_config, "NUM_ENVS": 8, "NUM_STEPS": 4, |
| "UPDATE_EPOCHS": 2, "NUM_MINIBATCHES": 3, |
| "ONLINE_TOTAL_TIMESTEPS": 320, "LR_WARMUP_FRAMES": 64, |
| "VAL_INTERVAL_FRAMES": 320, "DAGGER_BETA_FINAL": 0.1, |
| "DAGGER_BUFFER_CYCLES": 2, |
| } |
| resolve_num_updates(config, "online") |
| resolve_scaled_hyperparams(config, "online") |
|
|
| |
| |
| assert config["LR_WARMUP_STEPS"] == 12 |
| assert config["VAL_INTERVAL"] == 10 |
| assert config["DAGGER_BUFFER_MAX"] == 64 |
| assert 0.0 < config["DAGGER_BETA_DECAY"] < 1.0 |
|
|
|
|
| def test_resolve_num_updates_rejects_unknown_mode(real_config) -> None: |
| from src.planners.common import resolve_num_updates |
|
|
| with pytest.raises(ValueError): |
| resolve_num_updates({**real_config}, "nonsense") |
|
|
|
|
| def test_dagger_sizing_defaults_to_one_train_pass(real_config) -> None: |
| """The runner's default is 1 pass, keeping DAgger's per-update gradient |
| work equal to offline BC's.""" |
| from src.planners.common import dagger_sizing |
|
|
| config = {**real_config, "NUM_ENVS": 8, "NUM_STEPS": 8, "PLAN_HORIZON": 4, |
| "DAGGER_BUFFER_MAX": 1_000_000, "DAGGER_TRAIN_PASSES": None} |
| sizing = dagger_sizing(config, num_updates=10) |
|
|
| assert sizing["n_train_passes"] == 1 |
| assert sizing["valid_per_rollout"] == 5 |
| assert sizing["samples_per_update"] == 40 |
| assert sizing["n_cycles"] == 2 |
| |
| assert sizing["max_buffer_size"] == 400 |
|
|
| assert dagger_sizing({**config, "DAGGER_TRAIN_PASSES": 4}, 10)["n_train_passes"] == 4 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| FINAL_CONFIG_DERIVATIONS = { |
| "configs/final_craftax_classic_gpu_24gb.yaml": { |
| "NUM_ENVS": 512, |
| "NUM_UPDATES": 1525, |
| "LR_WARMUP_STEPS": 1600, |
| "DAGGER_BUFFER_MAX": 125_000, |
| }, |
| "configs/final_craftax_gpu_24gb.yaml": { |
| "NUM_ENVS": 448, |
| "NUM_UPDATES": 1743, |
| "LR_WARMUP_STEPS": 1792, |
| "DAGGER_BUFFER_MAX": 43_750, |
| }, |
| "configs/final_craftax_classic_gpu_h200.yaml": { |
| "NUM_ENVS": 96, |
| "NUM_UPDATES": 8138, |
| "LR_WARMUP_STEPS": 8512, |
| "DAGGER_BUFFER_MAX": 23_438, |
| }, |
| "configs/final_craftax_gpu_h200.yaml": { |
| "NUM_ENVS": 64, |
| "NUM_UPDATES": 12_207, |
| "LR_WARMUP_STEPS": 12_800, |
| "DAGGER_BUFFER_MAX": 6_250, |
| }, |
| } |
|
|
|
|
| @pytest.mark.parametrize( |
| "config_path", sorted(FINAL_CONFIG_DERIVATIONS) |
| ) |
| def test_final_configs_resolve_to_their_documented_quantities(config_path: str) -> None: |
| from src.planners.common import resolve_num_updates, resolve_scaled_hyperparams |
|
|
| config = {**load_config("configs/defaults.yaml"), **load_config(config_path)} |
| resolve_num_updates(config, "online") |
| resolve_scaled_hyperparams(config, "online") |
|
|
| for key, expected in FINAL_CONFIG_DERIVATIONS[config_path].items(): |
| assert int(config[key]) == expected, ( |
| f"{config_path}: {key} resolves to {config[key]}, not {expected}. " |
| "Update the config's comments in the same change." |
| ) |
|
|
|
|
| @pytest.mark.parametrize("config_path", sorted(FINAL_CONFIG_DERIVATIONS)) |
| def test_lr_warmup_is_shorter_than_the_cosine_horizon(config_path: str) -> None: |
| """The resolved warmup is a gradient-step count (author decision |
| 2026-08-15: frames convert through the effective geometry), so it |
| must sit strictly below decay_steps = num_updates * update_epochs * |
| num_minibatches, and the frames it spans must reproduce |
| lr_warmup_frames to within one optimiser update of slack.""" |
| from src.planners.common import resolve_num_updates, resolve_scaled_hyperparams |
|
|
| config = {**load_config("configs/defaults.yaml"), **load_config(config_path)} |
| resolve_num_updates(config, "online") |
| resolve_scaled_hyperparams(config, "online") |
|
|
| geometry = int(config["UPDATE_EPOCHS"]) * int(config["NUM_MINIBATCHES"]) |
| grad_steps = int(config["NUM_UPDATES"]) * geometry |
| warmup = int(config["LR_WARMUP_STEPS"]) |
| assert warmup < grad_steps |
| fpu = int(config["NUM_ENVS"]) * int(config["NUM_STEPS"]) |
| frames_covered = warmup * fpu / geometry |
| assert abs(frames_covered - float(config["LR_WARMUP_FRAMES"])) < fpu |
|
|
|
|
| def test_snapshot_minibatch_matches_what_the_runners_use(real_config, capsys) -> None: |
| """Regression: print_config_snapshot derived the minibatch from fpu. |
| |
| Both modes train on sliding windows, so a rollout of num_steps transitions |
| yields num_steps - plan_horizon + 1 windows per environment. offline.py:68 |
| sets MINIBATCH_SIZE from that, and the DAgger training scan reshapes a |
| dataset of the same size. Deriving from fpu overstated the printed |
| minibatch by num_steps / valid_per_rollout. |
| """ |
| from src.planners.common import ( |
| dagger_sizing, |
| print_config_snapshot, |
| resolve_num_updates, |
| resolve_scaled_hyperparams, |
| ) |
|
|
| config = {**real_config} |
| resolve_num_updates(config, "offline") |
| resolve_scaled_hyperparams(config, "offline") |
|
|
| sizing = dagger_sizing(config, config["NUM_UPDATES"]) |
| expected = sizing["samples_per_update"] // config["NUM_MINIBATCHES"] |
| fpu_derived = ( |
| config["NUM_STEPS"] * config["NUM_ENVS"] // config["NUM_MINIBATCHES"] |
| ) |
| assert expected != fpu_derived, "pick a config where the two disagree" |
|
|
| print_config_snapshot(config, "offline") |
| out = capsys.readouterr().out |
|
|
| assert f"minibatch={expected}" in out |
| assert f"minibatch={fpu_derived}" not in out |
| assert f"samples_per_update = {sizing['samples_per_update']:,}" in out |
|
|
|
|
| @pytest.mark.parametrize("config_path", sorted(FINAL_CONFIG_DERIVATIONS)) |
| def test_offline_and_dagger_stay_compute_matched(config_path: str, capsys) -> None: |
| """The BC baseline's whole purpose: same updates, same gradient steps. |
| |
| common.py:22 resolves NUM_UPDATES for both modes and dagger_sizing documents |
| DAGGER_TRAIN_PASSES=1 as the thing that keeps the per-update gradient work |
| equal. Both final config pairs set the two frame budgets equal. |
| """ |
| from src.planners.common import ( |
| print_config_snapshot, |
| resolve_num_updates, |
| resolve_scaled_hyperparams, |
| ) |
|
|
| base = {**load_config("configs/defaults.yaml"), **load_config(config_path)} |
| assert int(float(base["OFFLINE_TOTAL_TIMESTEPS"])) == int( |
| float(base["ONLINE_TOTAL_TIMESTEPS"]) |
| ), "the two budgets must match or the baseline is not compute-matched" |
|
|
| snapshots = {} |
| for mode in ("offline", "online"): |
| config = {**base} |
| resolve_num_updates(config, mode) |
| resolve_scaled_hyperparams(config, mode) |
| print_config_snapshot(config, mode) |
| out = capsys.readouterr().out |
| grad_steps = ( |
| config["NUM_UPDATES"] |
| * config["UPDATE_EPOCHS"] |
| * config["NUM_MINIBATCHES"] |
| ) |
| snapshots[mode] = (config["NUM_UPDATES"], grad_steps) |
| assert "total_grad_steps" in out |
| assert f"= {grad_steps:,}" in out |
|
|
| assert snapshots["offline"] == snapshots["online"], ( |
| f"{config_path}: offline {snapshots['offline']} vs " |
| f"online {snapshots['online']}" |
| ) |
|
|
|
|
| def test_compile_and_run_separates_compile_from_execute() -> None: |
| """Regression: the runners timed ``out = train_fn(rngs)`` with no block. |
| |
| JAX dispatch is asynchronous, so that call returns once compilation is done |
| and the work is enqueued. The reported SPS therefore divided total frames |
| by a duration that excluded nearly all of the execution. |
| """ |
| from src.planners.common import compile_and_run |
|
|
| @jax.jit |
| def train_fn(x): |
| def body(c, _): |
| return jnp.tanh(c @ c) * 1.0001, None |
|
|
| out, _ = jax.lax.scan(body, x, None, 200) |
| return out |
|
|
| x = jnp.eye(64, dtype=jnp.float32) * 0.5 |
|
|
| out, timing = compile_and_run(train_fn, x, total_frames=1000) |
|
|
| assert _finite(out) |
| assert set(timing) == { |
| "compile_s", |
| "execute_s", |
| "total_s", |
| "sps_execute", |
| "sps_total", |
| } |
| assert timing["compile_s"] > 0.0 |
| assert timing["execute_s"] > 0.0 |
| |
| assert timing["total_s"] == pytest.approx( |
| timing["compile_s"] + timing["execute_s"] |
| ) |
| assert timing["sps_total"] < timing["sps_execute"] |
|
|
|
|
| def test_format_timing_reports_both_legs() -> None: |
| from src.planners.common import format_timing |
|
|
| text = format_timing( |
| { |
| "compile_s": 52.0, |
| "execute_s": 3600.0, |
| "total_s": 3652.0, |
| "sps_execute": 27_000.0, |
| "sps_total": 26_600.0, |
| } |
| ) |
| assert "Compile: 52.0s" in text |
| assert "Execute: 3600.0s" in text |
| assert "27000 (execute)" in text |
| assert "26600 (including compile)" in text |
|
|
|
|
| def test_online_runner_no_longer_reports_one_fused_time() -> None: |
| """The old shape printed a single ``Time: ...s SPS: ...`` line.""" |
| source = (ROOT / "src" / "planners" / "online.py").read_text() |
| assert "compile_and_run" in source |
| assert 'f"Time: {elapsed' not in source |
|
|
|
|
| def test_snapshot_reports_the_gradient_steps_that_actually_run(real_config, capsys) -> None: |
| """Regression: print_config_snapshot used to derive n_train_passes as |
| ``buffer_max // samples_per_update`` while the runner used 1, overstating |
| total_grad_steps by 2x on defaults.yaml and 23x on classic_exp_c.""" |
| from src.planners.common import ( |
| dagger_sizing, |
| print_config_snapshot, |
| resolve_num_updates, |
| resolve_scaled_hyperparams, |
| ) |
|
|
| config = {**real_config} |
| resolve_num_updates(config, "online") |
| resolve_scaled_hyperparams(config, "online") |
| print_config_snapshot(config, "online") |
| out = capsys.readouterr().out |
|
|
| sizing = dagger_sizing(config, config["NUM_UPDATES"]) |
| expected = ( |
| config["NUM_UPDATES"] * sizing["n_train_passes"] |
| * config["UPDATE_EPOCHS"] * config["NUM_MINIBATCHES"] |
| ) |
| assert f"total_grad_steps = {expected:,}" in out, out |
|
|
| |
| stale_passes = max( |
| 1, int(config["DAGGER_BUFFER_MAX"]) // sizing["samples_per_update"] |
| ) |
| assert stale_passes > sizing["n_train_passes"], ( |
| "defaults.yaml no longer exercises the divergence; pick another config" |
| ) |
| stale = expected * stale_passes |
| assert f"{stale:,}" not in out |
|
|
|
|
| def test_validate_config_requires_checkpoints() -> None: |
| import main as main_module |
|
|
| with pytest.raises(ValueError): |
| main_module.validate_config({"MODE": "offline"}) |
| with pytest.raises(ValueError): |
| main_module.validate_config({"MODE": "inference"}) |
|
|
| main_module.validate_config({"MODE": "offline", "PPO_CHECKPOINT_PATH": "x"}) |
| main_module.validate_config({"MODE": "inference", "CHECKPOINT_PATH": "x"}) |
|
|
|
|
| def test_compilation_cache_is_opt_in_and_creates_its_directory(tmp_path) -> None: |
| import main as main_module |
|
|
| assert main_module.configure_compilation_cache({}) is None |
| assert main_module.configure_compilation_cache({"JAX_COMPILATION_CACHE_DIR": None}) is None |
|
|
| target = tmp_path / "nested" / "jax-cache" |
| try: |
| resolved = main_module.configure_compilation_cache( |
| {"JAX_COMPILATION_CACHE_DIR": str(target)} |
| ) |
| assert resolved == str(target) |
| assert target.is_dir() |
| assert jax.config.jax_compilation_cache_dir == str(target) |
| finally: |
| |
| jax.config.update("jax_compilation_cache_dir", None) |
|
|
|
|
| def test_defaults_config_declares_the_compilation_cache_key(real_config: dict) -> None: |
| """main.py reads it, so defaults.yaml must declare it or --override rejects it.""" |
| assert "JAX_COMPILATION_CACHE_DIR" in real_config |
| assert real_config["JAX_COMPILATION_CACHE_DIR"] is None, ( |
| "the shipped default must be off: the right path is machine-specific" |
| ) |
|
|
|
|
| def test_dispatch_table_covers_every_mode() -> None: |
| import main as main_module |
|
|
| modes = {"collect", "offline", "online", "inference", "smoke"} |
| assert set(main_module.DISPATCH) == modes |
| assert all(callable(fn) for fn in main_module.DISPATCH.values()) |
|
|
| parser = main_module._build_parser("configs/defaults.yaml") |
| choices = next(a.choices for a in parser._actions if a.dest == "mode") |
| assert set(choices) == modes, "--mode choices and DISPATCH disagree" |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_env_reset(craftax_env) -> None: |
| """Env stepping is covered by the rollout below; a separate step() call here |
| would cost another few seconds of tracing for no extra coverage.""" |
| env, env_params = craftax_env["env"], craftax_env["env_params"] |
|
|
| obs, _ = env.reset(jax.random.PRNGKey(SEED), env_params) |
|
|
| assert obs.shape == (craftax_env["num_envs"], craftax_env["obs_dim"]) |
| assert _finite(obs) |
|
|
|
|
| def test_plan_and_act_in_env(craftax_env, tiny_config, schedules) -> None: |
| """Plan with the diffusion model, execute the plan in Craftax, log metrics.""" |
| from src.planners.common import make_validate |
| from src.planners.model import ( |
| build_model, |
| create_train_state, |
| init_params, |
| make_apply_fns, |
| ) |
|
|
| num_actions, obs_dim = craftax_env["num_actions"], craftax_env["obs_dim"] |
| config = { |
| **tiny_config, "NUM_ACTIONS": num_actions, |
| |
| "VAL_DIFFUSION_STEPS": 1, "USE_LOOP": False, |
| } |
|
|
| model = build_model(config, num_actions) |
| real_dim_params = init_params( |
| model, jax.random.PRNGKey(SEED), obs_dim, PLAN_HORIZON, |
| ) |
| apply_eval, _ = make_apply_fns(model) |
| state = create_train_state(model, real_dim_params, config["LR"], 1.0) |
|
|
| validate = make_validate( |
| craftax_env["env"], craftax_env["env_params"], apply_eval, |
| num_actions, PLAN_HORIZON, schedules[0], config, |
| val_replan_every=1, n_val_cycles=1, |
| ) |
| metrics = validate(state, jax.random.PRNGKey(SEED)) |
|
|
| assert metrics, "validation rollout produced no metrics" |
| assert all(k.startswith("val/") for k in metrics) |
| assert all(_finite(v) for v in metrics.values()) |
|
|
|
|
| |
| |
| |
|
|
|
|
| @pytest.mark.parametrize( |
| "label", |
| ["main --help", "count_params --help", "eval_ppo_expert --help", |
| "hf_upload --help", "hf_upload_demo --help"], |
| ) |
| def test_entry_point_help(entry_point_runs, label: str) -> None: |
| """--help proves the script's full import chain and parser are intact.""" |
| result = entry_point_runs[label] |
| assert result.returncode == 0, f"{label} failed:\n{result.stderr[-2000:]}" |
| assert "usage" in result.stdout.lower() |
|
|
|
|
| def test_count_params_script_runs(entry_point_runs) -> None: |
| """count_params.py is the only entry point that runs a real job without a checkpoint.""" |
| result = entry_point_runs["count_params run"] |
| assert result.returncode == 0, result.stderr[-2000:] |
| assert "params" in result.stdout |
|
|
|
|
| def test_main_rejects_missing_mode(entry_point_runs) -> None: |
| result = entry_point_runs["main no-mode"] |
| assert result.returncode != 0 |
| assert "--mode" in result.stderr |
|
|
|
|
| def test_smoke_mode_trains_end_to_end(entry_point_runs) -> None: |
| """`main.py --mode smoke` is the full pipeline: rollout, DAgger, validation.""" |
| result = entry_point_runs["main --mode smoke"] |
| assert result.returncode == 0, result.stderr[-3000:] |
| assert "SMOKE TEST SUMMARY" in result.stdout |
| assert "all metrics finite = True" in result.stdout |
|
|
|
|
| def test_smoke_config_overlays_defaults() -> None: |
| """configs/smoke.yaml is overrides-only; main.build_config layers it on top.""" |
| from tests.conftest import load_config |
|
|
| defaults = load_config("configs/defaults.yaml") |
| smoke = load_config("configs/smoke.yaml") |
|
|
| import main as main_module |
|
|
| assert smoke, "smoke.yaml is empty" |
|
|
| |
| |
| cli_keys = {k.upper() for k in main_module._CLI_CONFIG_KEYS} |
| unknown = set(smoke) - set(defaults) - cli_keys |
| assert not unknown, f"smoke.yaml sets keys nothing reads: {unknown}" |
|
|
| merged = {**defaults, **smoke} |
| |
| num_envs, num_steps = merged["NUM_ENVS"], merged["NUM_STEPS"] |
| plan_horizon, num_minibatches = merged["PLAN_HORIZON"], merged["NUM_MINIBATCHES"] |
|
|
| assert num_steps >= plan_horizon |
| assert num_steps % plan_horizon == 0 |
| samples_per_update = num_envs * (num_steps - plan_horizon + 1) |
| assert samples_per_update % num_minibatches == 0 |
| |
| buffer_max = round(merged["DAGGER_BUFFER_CYCLES"] * num_envs * num_steps) |
| assert samples_per_update <= buffer_max |
|
|
|
|
| def test_smoke_budget_resolves_to_a_short_run() -> None: |
| """Every smoke-sizing key must survive resolution: the frame-denominated |
| keys are rescaled to the smoke rollout width, so a stale one silently |
| restores a full-size run.""" |
| from tests.conftest import load_config |
|
|
| from src.planners.common import resolve_num_updates, resolve_scaled_hyperparams |
|
|
| config = {**load_config("configs/defaults.yaml"), **load_config("configs/smoke.yaml")} |
| resolve_num_updates(config, "online") |
| resolve_scaled_hyperparams(config, "online") |
|
|
| assert config["NUM_UPDATES"] <= 10, config["NUM_UPDATES"] |
| assert config["VAL_INTERVAL"] <= config["NUM_UPDATES"], ( |
| "no validation rollout would run" |
| ) |
| samples_per_update = config["NUM_ENVS"] * ( |
| config["NUM_STEPS"] - config["PLAN_HORIZON"] + 1 |
| ) |
| assert config["DAGGER_BUFFER_MAX"] >= samples_per_update |
| assert config["LR_WARMUP_STEPS"] == 0, "warmup would eat the whole smoke run" |
|
|
|
|
| def test_checkpoint_restores_in_a_fresh_process(params, tmp_path) -> None: |
| """Restore must work in a process that never saw the live arrays. |
| |
| Every other checkpoint test saves and restores inside one process, where |
| orbax infers sharding from the arrays still in memory. Released |
| checkpoints are always loaded cold, and orbax >= 0.12 rejects the |
| ``sharding=None`` leaves ``jax.eval_shape`` produces, so only a process |
| boundary exercises the path README §Checkpoints documents. |
| |
| The ``_sharding`` file is deleted first: orbax only writes it from 0.12 |
| on, and when it is present orbax recovers the sharding from it and the |
| restore succeeds regardless. The released checkpoints predate the |
| upgrade and carry no such file, so removing it is what reproduces the |
| failure they actually hit. |
| """ |
| import subprocess |
| import sys |
|
|
| ckpt_dir = tmp_path / "ckpt" |
| with ocp.CheckpointManager(str(ckpt_dir)) as mgr: |
| mgr.save(0, args=ocp.args.PyTreeSave({"params": params})) |
| mgr.wait_until_finished() |
|
|
| sharding_files = list(ckpt_dir.rglob("_sharding")) |
| assert sharding_files, "expected orbax to write a _sharding file" |
| for f in sharding_files: |
| f.unlink() |
|
|
| script = f""" |
| import jax |
| from tests.conftest import TINY_ARCH |
| from src.planners.model import build_model, load_checkpoint |
| |
| config = {{**TINY_ARCH, "NUM_ACTIONS": {NUM_ACTIONS}}} |
| model = build_model(config, {NUM_ACTIONS}) |
| load_checkpoint( |
| model, jax.random.PRNGKey({SEED}), {OBS_DIM}, {PLAN_HORIZON}, {str(ckpt_dir)!r} |
| ) |
| print("RESTORED") |
| """ |
| result = subprocess.run( |
| [sys.executable, "-c", script], |
| capture_output=True, |
| text=True, |
| cwd=str(ROOT), |
| ) |
| assert result.returncode == 0, f"cold restore failed:\n{result.stderr}" |
| assert "RESTORED" in result.stdout |
|
|