zero2robot · Phase 1 · Imitationch1.9-bridge · bridge.py

Chapter 1.9

Graduation Bridge ILeRobot for Real

By the end you can

  1. Load your ch0.4 / gen_demos LeRobot-v3 dataset into the OFFICIAL lerobot stack and train the real ACTPolicy on it in ~20 lines — and read every constructor argument, because you built each piece by hand in ch1.3
  2. Bridge the one real schema wrinkle honestly — our recorder's observation.state is ACT's observation.environment_state, a rename plus a FeatureType re-type — and pass the dataset's own normalization stats into the processor pipeline
  3. COMPARE the official ACT to your from-scratch ch1.3 ACT on the SAME demos and the SAME held-out seeds, reported with the Wilson interval from ch1.6, and conclude honestly (the code shrinks ~380->~12 lines, yet the task-tuned from-scratch ACT lands OUTSIDE the official's Wilson CI at this budget => the framework buys the ecosystem, not a free higher number)
  4. Know the Hub publish path (push_to_hub for policy + dataset) and why it is HUMAN-GATED — offline/CI never needs a token, and the default run dry-runs it

See it work

live · P2

The code shrank ~32×

Same ACT algorithm, two implementations.

380 lines hand-rolled vs 12 lines official — the same ACTch1.3 act.pyhand-rolled ACT380 linesbridge.pyofficial region12 lines

The CVAE (use_vae) and the ResNet image path are one flag away in the official config — kept OFF here (use_vae=False, env-state input) to match ch1.3’s cut, so the comparison is apples-to-apples.

… the accuracy did not follow it down

Same demos, same held-out seeds. Success rate ± Wilson 95% interval (recomputed from ch1.6).

Official ACT 0.55 versus from-scratch ACT 0.95, with Wilson intervals0%25%50%75%100%held-out success rate (n=20)official 95% ceilingofficial lerobot ACT0.55from-scratch 1.3 ACT0.95

The framework did not lose — ch1.3’s four entity tokens (arms / cube / target) are a task-specific bias the general config lacks, and the same bias ports into lerobot. The framework buys the ecosystem (the Hub, real-robot deploy, other policies one import away), not a free number.

One flag inflates the score

The same official ACT under --break train_dist — scored on the training seeds.

Official ACT: held-out 0.55 versus train-seed 0.600%25%50%75%100%held-out success rate (n=20)official · held-out0.55official · train seeds0.60

train-seed 0.60 ≥ held-out 0.55: the ch1.6 sin, one flag away on the official stack. The inflation is small here (+0.05, CIs overlap) because our held-out seeds are same-distribution — the direction is the lesson, and it would be far larger on a genuinely out-of-distribution set.

all numbers measured (seed 0, cpu) · poster reads with JS off

Open in Colabsoon

Free-tier notebook — the button goes live when the course repository is published.

Where you are

You have built ACT from scratch. In 1.3 you wrote the encoder that lets four entity tokens attend to each other, the decoder whose K query tokens each emit one action of the chunk, the temporal ensembler that averages overlapping predictions into a smooth trajectory — ~380 lines you can read top to bottom. That was the point of the whole exercise: you now know what ACT is, mechanically, with no magic left in it.

This chapter cashes that in. The same policy already lives in lerobot, the library the field actually ships and the one whose dataset format your gen_demos and your 0.4 recorder have been writing all along. Instantiating ACT there is not 380 lines. It is about twenty:

bridge.py#officialsha256:61b68018c2…
# THE ENTIRE OFFICIAL POLICY. Contrast this block with all of 1.3's act.py: the# encoder, decoder, chunking head, CVAE, and temporal ensembler are ALL in here,# behind three constructors. temporal_ensemble_coeff=0.01 (with n_action_steps=1)# is the real ACT ensembling we hand-rolled in 1.3. use_vae=False MATCHES 1.3 (which# cut the CVAE) so the comparison is apples-to-apples — flip it to True for the full# CVAE ACT. This is the payoff of having built it yourself: every argument is legible.config = ACTConfig(    input_features=input_features, output_features=output_features,    chunk_size=K, n_action_steps=1, temporal_ensemble_coeff=0.01,    dim_model=args.model_dim, n_heads=8, dim_feedforward=2 * args.model_dim,    n_encoder_layers=1, n_decoder_layers=1, use_vae=False,    dropout=0.1, optimizer_lr=args.lr, device=args.device)policy = ACTPolicy(config, dataset_stats=stats).to(device)preprocessor, postprocessor = make_act_pre_post_processors(config, dataset_stats=stats)n_params = sum(p.numel() for p in policy.parameters())print(f"official lerobot ACT: {n_params:,} params (use_vae={config.use_vae}, "      f"chunk_size={K}, dim_model={args.model_dim})")

That block is the entire policy: the encoder, the decoder, the chunking head, the CVAE, and the temporal ensembler — every piece you built by hand — behind three constructors. This is the first time in the course you are encouraged to import a framework that hides the loop, because you are no longer learning the loop. You are graduating past it.

The reversal, and why you earned it

Everywhere else in Phase 1 the doctrine is strict: no hydra, no pytorch_lightning, no wrappers that swallow the training step, because a hidden loop you have never written is a loop you do not understand. That rule was for the learner you were. The rule for the learner you are now is the opposite: reach for the maintained, tested, community implementation — and read its arguments like a menu you understand. use_vae=True turns the CVAE latent back on (1.3 cut it). temporal_ensemble_coeff=0.01 is the exact ensembling you hand-rolled. The ResNet image backbone is in there too, dormant only because we feed state, not pixels. You can see all of it because you have already written all of it.

Loading your data, and the one honest wrinkle

The dataset loads unchanged — it is the same LeRobot-v3 format throughout Phase 1:

bridge.py#datasha256:f86ed429d4…
# Reuse 1.3's exact demo source so the two ACTs train on bit-identical data.if args.data is None:    args.data = args.out / "demos"    if not (args.data / "meta" / "info.json").is_file():        gen_demos.main(["--episodes", str(args.num_demos), "--seed", str(args.seed),                        "--out", str(args.data), "--no-video"])if not (args.data / "meta" / "info.json").is_file():    sys.exit(f"no dataset at {args.data} — generate one first:\n"             f"  python curriculum/common/envs/aloha_cube/gen_demos.py "             f"--episodes 50 --seed 0 --out {args.data} --no-video") # Heavy imports AFTER the cheap failures — the whole official stack, in one place.from lerobot.configs.types import FeatureType, PolicyFeature  # noqa: E402from lerobot.datasets.lerobot_dataset import (  # noqa: E402    LeRobotDataset, LeRobotDatasetMetadata)from lerobot.datasets.utils import dataset_to_policy_features  # noqa: E402from lerobot.policies.act.configuration_act import ACTConfig  # noqa: E402from lerobot.policies.act.modeling_act import ACTPolicy  # noqa: E402from lerobot.policies.act.processor_act import make_act_pre_post_processors  # noqa: E402 ds_meta = LeRobotDatasetMetadata(REPO_ID, root=args.data)features = dataset_to_policy_features(ds_meta.features)# THE BRIDGE, in two lines. Our state IS the whole world (arms+cube+target), so# it is ACT's `environment_state`, not proprioceptive `observation.state`. ACT's# state-only schema requires that key; re-typing it to ENV is the entire remap.features["observation.environment_state"] = PolicyFeature(    FeatureType.ENV, features.pop("observation.state").shape)output_features = {k: v for k, v in features.items() if v.type is FeatureType.ACTION}input_features = {k: v for k, v in features.items() if k not in output_features}# Normalization stats travel WITH the data (this is what the --break skips in 1.6# spirit — here we always pass them). Re-key state stats to the bridged name.stats = dict(ds_meta.stats)stats["observation.environment_state"] = stats.pop("observation.state")print(f"dataset: {ds_meta.total_episodes} episodes / {ds_meta.total_frames} frames "      f"@ {ds_meta.fps} Hz; bridged observation.state -> observation.environment_state")

There is exactly one bridge to build, and it is a good bug to have met: ACT's state-only schema requires the input key observation.environment_state, while our recorder emits observation.state. The fix is not just a rename — it is a re-type. lerobot classifies every feature by its FeatureType, and ACT accepts a camera-less policy only when it finds a feature typed ENV. Rename the key but leave the type STATE and the policy is rejected (or, in a looser setup, the environment-state token is silently never built and you train on a truncated input). Exercise 3 is exactly this bug, isolated.

Training: the chunk you built by hand is now one declaration

In 1.3 you assembled the action chunk with an explicit loop over every episode, gathering the next K expert actions per frame and a pad mask. The official stack does it from a single declaration — delta_timestamps — and hands you the action_is_pad mask for free:

bridge.py#trainsha256:72fa833a20…
# lerobot builds the action CHUNK for us: delta_timestamps asks the dataset for K# future action frames per sample, plus an `action_is_pad` mask at episode ends —# the exact bookkeeping we wrote by hand in 1.3, now a one-liner. The preprocessor# normalizes with the dataset stats; policy.forward returns the L1(+KL) loss.delta_timestamps = {"action": [i / ds_meta.fps for i in range(K)]}dataset = LeRobotDataset(REPO_ID, root=args.data, delta_timestamps=delta_timestamps)loader = torch.utils.data.DataLoader(    dataset, batch_size=args.batch_size, shuffle=True, num_workers=0,    generator=torch.Generator().manual_seed(args.seed))  # seeded order => reproducible on CPUoptimizer = torch.optim.AdamW(policy.parameters(), lr=args.lr)policy.train()train_loss, global_step = float("nan"), 0for epoch in range(args.epochs):    epoch_loss, num_batches = 0.0, 0    for batch in loader:        # The device-probe inside ACT reads observation.state even when it uses        # env_state, so we carry both keys (same tensor); only env_state is a        # model input. preprocessor() normalizes, batches, and moves to device.        batch["observation.environment_state"] = batch["observation.state"]        batch = preprocessor(batch)        loss, _ = policy.forward(batch)        optimizer.zero_grad()        loss.backward()        optimizer.step()        epoch_loss, num_batches = epoch_loss + loss.item(), num_batches + 1        if args.rerun:            rr.set_time("step", sequence=global_step)            rr.log("official/loss/train", rr.Scalars([loss.item()]))        global_step += 1    train_loss = epoch_loss / num_batches    if epoch % 25 == 0 or epoch == args.epochs - 1:        print(f"epoch {epoch:4d}  loss {train_loss:.5f}")

Same bookkeeping, one line. The preprocessor normalizes with the dataset's own stats (this is why the stats must travel with the data — pass the wrong stats and the policy silently trains on mis-scaled inputs), and policy.forward returns the L1 loss (it adds a KL term when use_vae=True). On CPU the loss falls from about 0.51 to about 0.08 over 150 epochs.

Run it

python curriculum/phase1_imitation/ch1.9_bridge/bridge.py --seed 0
wall-clock · rendered from wallclock.csvone source · every tier
cpu-laptopexpected wall-clock on cpu-laptop: ~4.91 min (measured)measured
mpswall-clock on mps: not yet measuredpending
t4expected wall-clock on t4: ~9.19 min (measured)measured
4090wall-clock on 4090: not yet measuredpending

One command trains the official ACT, trains your from-scratch 1.3 ACT on the same demos as a subprocess, evaluates both on the same held-out seeds, and dry-runs the publish. It finishes in a few minutes on a CPU laptop — the free-tier floor, no GPU required.

The comparison, with error bars

Here is the question the chapter turns on: now that the official ACT is twenty lines, is it better than your 380-line one? bridge.py answers it honestly — it trains your 1.3 act.py as a subprocess on the same demos and evaluates both on the same held-out seeds, and — because 1.6 taught you that a bare success rate is a lie — it reports the official policy's rate with its Wilson interval:

bridge.py#evalsha256:6c3355c377…
# Booleans in, statistics out — the 1.6 discipline. A bare success rate hides its# own uncertainty; the Wilson interval is the honest error bar at these few# episodes. --break train_dist swaps the held-out seeds for the TRAINING seeds:# the policy has effectively memorized those exact starts, so the number inflates# and you would wrongly crown the official stack. That is the 1.6 sin, committed# on the real ACT — the eval, not the model, is what breaks.def wilson_ci(k: int, n: int) -> tuple[float, float]:    """95% Wilson score interval for k successes in n trials (no scipy)."""    if n == 0:        return (0.0, 0.0)    p, z2 = k / n, Z95 * Z95    center = (p + z2 / (2 * n)) / (1 + z2 / n)    half = (Z95 * np.sqrt(p * (1 - p) / n + z2 / (4 * n * n))) / (1 + z2 / n)    return (max(0.0, center - half), min(1.0, center + half))  @torch.no_grad()def rollout(seed: int, episode: int) -> tuple[bool, float]:    policy.reset()  # clears the temporal-ensembling buffer between episodes    env = AlohaCubeEnv()    observation = env.reset(seed)    done, episode_return, info = False, 0.0, {}    while not done:        obs_t = torch.from_numpy(observation)        batch = preprocessor({"observation.state": obs_t, "observation.environment_state": obs_t})        action = postprocessor(policy.select_action(batch))  # un-normalized env action        observation, reward, done, info = env.step(action.squeeze(0).cpu().numpy())        episode_return += reward        if args.rerun:            rr.set_time("sim_time", duration=episode * (AlohaCubeEnv.MAX_STEPS                        / AlohaCubeEnv.CONTROL_HZ) + env.data.time)            rr.log("eval/official/dist", rr.Scalars([info["dist"]]))    return bool(info["success"]), episode_return  # Held-out by default (base 20000, disjoint from demo seeds 0..num_demos-1);# --break train_dist evaluates on the demo seeds themselves (in-distribution).eval_base = args.seed if args.break_mode == "train_dist" else HELD_OUT_BASE + args.seedoutcomes = [rollout(eval_base + ep, ep) for ep in range(args.eval_episodes)]successes = sum(s for s, _ in outcomes)official_success = successes / args.eval_episodesofficial_return = float(np.mean([r for _, r in outcomes]))lo, hi = wilson_ci(successes, args.eval_episodes)if args.rerun:    rr.log("eval/official/success_rate", rr.Scalars([official_success]))print(f"official ACT:  success {official_success:.2f}  [{lo:.2f}, {hi:.2f}]  "      f"return {official_return:.3f}  ({'TRAIN-DIST' if args.break_mode else 'held-out'} "      f"seeds, n={args.eval_episodes})") # The from-scratch baseline: run 1.3's act.py as a subprocess on the SAME dataset# and seed, then read its held-out metrics.json. Same env, same seed formula# (base 20000), same episode count => an apples-to-apples comparison.scratch = Noneif args.compare and ACT_PY.is_file():    fs_out = args.out / "from_scratch"    cmd = [sys.executable, str(ACT_PY), "--data", str(args.data), "--seed", str(args.seed),           "--device", "cpu", "--no-rerun", "--out", str(fs_out)]    cmd += ["--smoke"] if args.smoke else [        "--chunk_size", str(K), "--model_dim", str(args.model_dim),        "--num_demos", str(args.num_demos), "--epochs", str(args.epochs),        "--eval_episodes", str(args.eval_episodes)]    print("\n[compare] training the from-scratch 1.3 ACT on the same data ...")    subprocess.run(cmd, check=True, cwd=ROOT)    scratch = json.loads((fs_out / "metrics.json").read_text())    print(f"from-scratch 1.3 ACT:  success {scratch['success_rate']:.2f}  "          f"return {scratch['mean_return']:.3f}  (held-out seeds, n={args.eval_episodes})")    # Read the BARS, not the point estimates (1.6): is the from-scratch rate inside    # the official Wilson interval? Inside => indistinguishable at this n; outside =>    # a real gap. Either way the durable headline is the CODE ratio, not a winner.    inside = lo <= scratch["success_rate"] <= hi    verdict = ("their intervals overlap — indistinguishable at this n"               if inside else "the from-scratch rate is OUTSIDE the official CI — a real gap at this n")    print(f"compare: {verdict}. The headline is the CODE ratio (~380 lines by hand "          f"vs ~20 official), not the winner.")

At the free-tier default (matched to 1.3: no CVAE, lr 1e-3, 150 epochs) the official ACT lands at 0.55 [0.34, 0.74] on 20 held-out seeds. That interval is wide on purpose — 20 episodes is thin, exactly the 1.6 point. The from-scratch 1.3 ACT at the matched budget lands at 0.95, and its point estimate sits above the official interval: this is a real gap, not sampling noise.

Why does the hand-rolled version win? Not because the framework is worse — it is the same algorithm. It is because in 1.3 you split the ten observation numbers into four semantic tokens (right arm, left arm, cube, target) and let them attend to each other. That decomposition is a task-specific inductive bias. The general lerobot config sees one undifferentiated state vector. Your domain knowledge is the difference — and it is portable: you could inject the same tokenization into a custom lerobot input processor. What the framework bought you, then, is not a higher number; it is a maintained, tested implementation, a dataset-and-model Hub, a real-robot deploy path, and a dozen other policies one import away. That trade — you keep your modeling insight, you gain the ecosystem — is the honest shape of graduating to a real stack. (Both rates also rise with the scale knobs; the honest way up is --epochs and --num_demos, not a louder claim.)

Break it: the 1.6 sin, one flag away

--break train_dist evaluates the official ACT on the training seeds instead of the held-out ones — the starts it has already seen. In a brand-new framework this is an easy accident: you grab whatever seed list is handy. The rate inflates to 0.60 [0.39, 0.78] against the held-out 0.55 [0.34, 0.74]. Here the gap is small — our held-out seeds are the same distribution as training (random resets, different draws), so the policy has little to memorize — but it is in the wrong direction to trust, and on a genuinely out-of-distribution held-out set (1.6's LIBERO-style annulus, the block spawned farther than any demo) the same sin costs far more. The invariant is not the magnitude; it is that you never report the train number. Graduating to a real stack does not graduate you out of held-out evaluation.

Publishing (when you have a token)

The last step of the real workflow is sharing: push the dataset and the trained policy to the Hugging Face Hub so others can reproduce and build on them. That step is human-gated — it needs a token and a network — so by default, and always in CI, bridge.py dry-runs it: it prints the exact push_to_hub calls it would make and serializes the policy locally instead.

bridge.py#publishsha256:ad8770542e…
# HUMAN-GATED. Offline or without a token this DRY-RUNS: it prints the exact# push_to_hub calls and serializes the policy locally (proving the save path)# WITHOUT any network. A real publish needs `huggingface-cli login` (HF_TOKEN)# and HF_HUB_OFFLINE unset — never CI, never the default path.import os  # noqa: E402 local_model = args.out / "lerobot_act"have_token = bool(os.environ.get("HF_TOKEN")) and os.environ.get("HF_HUB_OFFLINE") not in ("1", "true")if args.publish and have_token:    policy.push_to_hub(args.repo_id)          # real push (needs network + token)    dataset.push_to_hub(f"{args.repo_id}_dataset")    print(f"published policy -> hf.co/{args.repo_id} and dataset -> {args.repo_id}_dataset")else:    policy.save_pretrained(local_model)       # local serialization, always offline-safe    reason = "no --publish" if not args.publish else "offline / no HF_TOKEN"    print(f"\n[publish DRY-RUN — {reason}] saved policy to {local_model}. To publish for real:")    print("    huggingface-cli login   # sets HF_TOKEN")    print(f"    policy.push_to_hub({args.repo_id!r})")    print(f"    dataset.push_to_hub({args.repo_id + '_dataset'!r})")

To publish for real: huggingface-cli login, unset HF_HUB_OFFLINE, and pass --publish. Nothing in this repository ships a checkpoint or a dataset — those live on the Hub; the repo carries only the code that regenerates them.

What the framework hides (and what you can now see through)

lerobot is not magic, and after 1.3 it is not opaque to you either. The CVAE you dropped is back; the ResNet path you never needed is waiting; the normalization is a processor pipeline instead of two lines of your own. When one of those matters — when you need to change the objective, add a camera, or debug why the loss will not fall — you have the from-scratch mental model to open the file and read it. That is the real deliverable of this bridge: not that you can call ACTPolicy, but that you can call it and still know exactly what it is doing.

Exercises

Four, in exercises/. Two are predict-then-run and turn on the comparison: before you run, you predict whether the official ACT beats your from-scratch 1.3 ACT at the matched budget (it does not — the entity-token bias wins, above the official's Wilson interval), and whether evaluating on the training seeds inflates the rate over held-out (it does — the 1.6 sin, on the official stack). Two are fast and self-contained: a bug-hunt on the observation.environment_state re-type (the key is not enough; the FeatureType must be ENV), and a code-completion that rebuilds 1.3's action chunk as a one-line delta_timestamps declaration.

Read the real thing

The paired reading is lerobot itself, at the course-pinned v0.4.4 — the code your # --- region: official --- just called. Everything lives under src/lerobot/ at this tag; read each file against the piece of yours it replaces.

The policy — src/lerobot/policies/act/modeling_act.py. Your 1.3 act.py was the ~380 lines you can hold in your head. This is roughly 800, and every class in it is one you already wrote: ACTPolicy wraps ACT (the CVAE-plus-transformer core), ACTTemporalEnsembler is the ensembler behind the official region's temporal_ensemble_coeff=0.01, and ACTEncoder / ACTDecoder are your encoder and chunking decoder. The docstring credits Zhao et al.'s original ACT — same algorithm, same paper. What the extra ~400 lines buy is not accuracy (your entity-token 1.3 ACT beat this file's general config, above its Wilson interval); they buy the torchvision ResNet backbone, gated on config.image_features and dormant only because you feed state — the camera path you never needed, waiting.

The wiring — src/lerobot/policies/factory.py. make_policy(cfg, ds_meta=...) is the dispatch you skipped by importing ACTPolicy directly, and get_policy_class is a registry mapping "act", "diffusion", "smolvla", "pi0", and six more to their classes — a dozen policies one string away. It calls dataset_to_policy_features (defined in src/lerobot/datasets/utils.py, not the factory itself), the exact function whose output you re-typed in # --- region: data --- to slot observation.environment_state in.

The chunk — src/lerobot/datasets/lerobot_dataset.py. The delta_timestamps you passed in # --- region: train --- is checked by check_delta_timestamps and turned into delta_indices; then _get_query_indices builds the {key}_is_pad mask — your hand-written episode-boundary padding loop from 1.3, now a few lines behind a tolerance_s.

The stats — src/lerobot/processor/normalize_processor.py. NormalizerProcessorStep (and its inverse UnnormalizerProcessorStep) is the pipeline step behind make_act_pre_post_processors. Read its missing-stats branch: when a feature has no stats, it returns the input unchanged — the silent mis-scaling this chapter warned you about, sitting in production code. It supports MEAN_STD, MIN_MAX, QUANTILES, and QUANTILE10.

Read next: open modeling_act.py beside your own act.py. You built each of these once; the framework's gift is that you never have to again — and that you can still read every line when it matters. The number stayed yours; the ecosystem is what you gained.

What's next

That closes the imitation spine. Everything in Phase 1 learned from a fixed dataset of expert demonstrations — behavior cloning, ACT, diffusion, flow, the evaluation harness, the VLA — and this chapter delivered you onto the stack the field actually uses to do it. Phase 2 changes the question. Instead of imitating demonstrations you already have, you will generate the data by trial and error: chapter 2.1 opens with PPO from a blank file, a policy that improves against a reward instead of a teacher. The Hub publish path you dry-ran here returns for real at the 4.4 capstone, where you ship a trained policy, its dataset, and a writeup — the graduation this bridge was named for.

Practice

practice · candidate exercisesdrafted by the exercise generator, pending human promotion. Answers reveal only after you predict — honor system.

  1. predict-then-run

    Exercise 1

    SUGGESTED exercise candidate (humans promote) — predict-then-run, ch1.9.

    The graduation question: now that the OFFICIAL lerobot ACT trains on your data in ~20 lines, is it BETTER than the ~380-line ACT you built by hand in 1.3? bridge.py trains both on the SAME demos and evaluates both on the SAME held-out seeds, and — because 1.6 taught you that a bare success number is a lie — it reports the official policy's success WITH its Wilson interval.

    PREDICT before you run (default budget, seed 0): how will the two compare?

    • A) The official ACT clearly WINS — its success rate clears the from-scratch rate, the interval leaves no doubt. The framework's engineering pays off.

    • B) They are statistically INDISTINGUISHABLE at this budget and episode count — the from-scratch rate sits inside the official policy's Wilson interval. Same algorithm, same data: the code you write differs, the ACT does not.

    • C) The from-scratch ACT clearly WINS — your hand-tuned version beats the general-purpose framework.

    (trains both ACTs; minutes on CPU). Estimated learner time: 20 minutes.

    Predict, then commit

    Pick the outcome you expect from the options above. The answer and the local run command reveal only after you commit — predicting after you know teaches nothing.

  2. predict-then-run

    Exercise 2

    SUGGESTED exercise candidate (humans promote) — predict-then-run, ch1.9.

    The Break-It, as a prediction. bridge.py evaluates the official ACT on HELD-OUT seeds (base 20000) — starts disjoint from the demo seeds (0..num_demos-1) it trained on. --break train_dist swaps those for the TRAINING seeds themselves: the exact episode starts the policy already saw. This is the 1.6 sin — grading a policy on its own training distribution — committed on the official stack, where it is one flag away and easy to do by accident when you are moving fast in a new framework.

    PREDICT before you run (default budget, seed 0): the official ACT evaluated on the TRAIN seeds vs the HELD-OUT seeds will show —

    • A) a HIGHER success rate on the train seeds: it has effectively memorized those starts, so the number inflates and you would over-credit the framework.

    • B) the SAME rate: a held-out seed and a training seed are interchangeable.

    • C) a LOWER rate on the train seeds.

    (trains the official ACT twice; minutes on CPU). Estimated learner time: 20 minutes.

    Predict, then commit

    Pick the outcome you expect from the options above. The answer and the local run command reveal only after you commit — predicting after you know teaches nothing.

  3. bug-hunt

    Exercise 3

    SUGGESTED exercise candidate (humans promote) — bug-hunt (fast), ch1.9.

    THE bridging bug, isolated. bridge.py maps our recorder's observation.state onto ACT's state-only schema, which requires the key observation.environment_ state. The dangerous part is not the KEY — it is the TYPE. lerobot classifies a feature by its FeatureType, and ACT's validate_features() accepts a state-only policy ONLY if it finds a feature typed ENV (or an image). Rename the key but leave the type STATE and one of two things happens: the policy is rejected, or (worse, in looser setups) the environment-state token is silently never built and the model trains on a truncated input — the "silently trains on garbage" trap.

    Features here are the tiny (type, shape) tuples lerobot's dataset_to_policy_ features produces, so this gate is fast and self-contained (no torch, no lerobot).

    Before you read the fix, write one sentence: the key is now observation.environment_state and every shape still checks out — so why does ACT still reject the policy when only the FeatureType is wrong?

    FIND THE BUG in bridge_state_feature below, then fix it so the bridged feature is typed ENV. checks.py gates on the signature (the bridged feature is still STATE) and then verifies your fix. Estimated learner time: 15 minutes.

    Run it locally:

    pytest curriculum/phase1_imitation/ch1.9_bridge/exercises/suggested/checks.py -k ex3
  4. code-completion

    Exercise 4

    SUGGESTED exercise candidate (humans promote) — code-completion (fast), ch1.9.

    In 1.3 you built the action chunk by hand: for each frame you gathered the next K expert actions and a pad mask, with an explicit Python loop over every episode. The official stack does it for you from ONE declaration — delta_timestamps. You tell the LeRobotDataset which future action frames each sample should carry, in SECONDS relative to the current frame, and it assembles the (K, act_dim) chunk and the action_is_pad mask itself.

    For a policy that predicts K actions at fps Hz, that list is the K offsets 0, 1/fps, 2/fps, ..., (K-1)/fps — the current frame plus the next K-1, in time.

    COMPLETE action_chunk_timestamps so bridge.py's delta_timestamps={"action": action_chunk_timestamps(fps, K)} reproduces 1.3's chunk. checks.py skips while it raises, then checks it against fps/K the dataset actually uses. Estimated learner time: 10 minutes.

    Run it locally:

    pytest curriculum/phase1_imitation/ch1.9_bridge/exercises/suggested/checks.py -k ex4

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight frombridge.py, and these fingerprints are its sha256, the same ones check_prose_code_drift re-checks on every PR. Edit a shown region without re-rendering and CI turns red.

#setup
547ffbd2b54ce7432cddd59d1b7f81a4bd10d9895ef850d3772300a9528ad473
#data
f86ed429d48372662ec90c0a96b74386cb6a0ca415a454293fe440a16e3b5c5b
#official
61b68018c202e409406934c58c4b053b0f4b240b72212532d90c4e540fdbf3ef
#train
72fa833a201f10f8b1a007050ea0c884e89de4d3675790415f0d5643d582573d
#eval
6c3355c37745c1e8a0668b27dc14115c6c7994ef044ec9ac512774db2b968d7f
#publish
ad8770542ef18cc6db0a070cb22548f9dc89dea2cb1db3b2d0aaf2444a2bf04d
#report
edcd77adae877562ddb488745ac3c0408a667de043666c3ee6dc58aee505ee9b