zero2robot · Phase 3 · Advancedch3.6-compare · compare.py

Chapter 3.6

Full CircleRun Your ch1.1 Policy in the Engine You Built

By the end you can

  1. Close the "Build a Physics Engine" arc: RE-CREATE PushT inside the from-scratch numpy engine you built — the pusher (a velocity-servo disk) and the T-block (TWO point masses on a ch3.4 rigid distance link, its yaw EMERGENT from the line between them), pushed by the ch3.5 body-body contact solver (`solve_contacts`, reused wholesale). Match the pusht obs[10]/action[2] contract so the ch1.1 policy plugs straight in.
  2. Run the SAME ch1.1 BC policy — trained in MuJoCo, reloaded not retrained — in BOTH the MuJoCo PushT (ground truth) and your-engine PushT (the test), from bit-identical initial states, and see with your own eyes that the thing you have trusted since ch0.1 is something you can now build, and your policy runs in it.
  3. MEASURE the sim-to-sim gap two honest ways: TRANSFER (BC success in MuJoCo vs in your engine) and DIVERGENCE (from one shared start, replay MuJoCo's exact actions in your engine and watch the block poses pull apart — pure dynamics gap, no policy feedback).
  4. Connect that sim-to-SIM gap to the sim-to-REAL gap of ch2.6: both are a policy meeting dynamics its training never showed it. The gap is not a bug to be eliminated but a quantity to be measured and respected — and a physics knob (block drag) that closes/widens it makes the point concrete.

See it work

live · P2

The same policy, two engines

Your ch1.1 BC policy, replayed from one shared start in MuJoCo and in the engine you built. It succeeds more often in MuJoCo and struggles in the simplified engine.

MuJoCoreached target ✓
your enginemissed target ✗
recorded rollouts · poster reads with JS off

MuJoCo succeeds, your engine does not — the SAME policy, only the physics changed.

The sim-to-sim gap

BC success over 50 held-out episodes, same policy in both sims, with Wilson 95% intervals (the ch1.6 error bar).

0%25%50%75%100%BC success rate (n=50)MuJoCoground truth0.62your enginech3.3–3.50.20

Why — your engine is a simplification

Your engine’s T-block is two point masses on a ch3.4 rigid link, pushed by frictionless normal contact — a deliberately simpler PushT than MuJoCo’s shaped, welded body. That is the whole reason for the gap, and it is diagnostic: the measured angle divergence, 0.94 rad, dwarfs the position divergence, 0.082 m. A two-mass dumbbell rotates the T with far less authority than MuJoCo’s box does, so the policy aligns the block much worse in your engine. Building the engine is what taught you what a sim approximation costs — the gap is a quantity to measure and respect, not a bug to erase.

Two PushT sims, side by side, driven by the very same policy. On the left, MuJoCo: the circular pusher noses up to the T-block, shoves it across the table, nudges it square to the target, and the episode latches success — the behavior-cloning policy you trained back in chapter 1.1, doing what it learned to do. On the right, the same policy, the same starting position, reading the same ten numbers of observation — but the sim underneath it is not MuJoCo. It is the physics engine you built by hand over the last three chapters: a few hundred lines of numpy, semi- implicit Euler, one distance constraint, a contact solve. And the policy still pushes. It finds the block, it drives it toward the goal, it mostly works.

Mostly. Watch the two blocks, which start on top of each other, slide apart as the episodes run — a little in position, a lot in angle. Watch the success counter on the right land at roughly a third of the left's. The policy you have trusted since chapter 0.1 is running inside an engine you can read every line of, and the gap between the two sims is not a bug. It is the measurement, and it is the whole point of this chapter — the last one in the "Build a Physics Engine" arc.

Open in Colabsoon

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

The problem

Since chapter 0.1 you have called mj_step and believed what it returned. Chapters 3.3, 3.4, and 3.5 took that black box apart: dynamics (a state (q, v, m) stepped by semi-implicit Euler), joints (a constraint g(q) = 0 you solve for as a Lagrange multiplier and hand back as a force), contact (the one-sided complementarity you project at the velocity level). You built each piece and measured exactly what it got wrong — energy drift, constraint drift, penetration.

This chapter spends it all at once. We take the three pieces and assemble PushT — the environment from Phase 0, the one the ch1.1 policy learned in — inside your engine, and then we run that policy. Not retrained. Reloaded: the exact TorchScript checkpoint bc.py saved. If your engine is a faithful enough PushT, the policy should still reach the goal; wherever your engine is a simplified PushT, the policy will degrade in a way we can put a number on.

That number is a sim-to-sim gap, and it is worth being precise about why it matters. In chapter 2.6 you measured a sim-to-real gap: a policy trained in one world, dropped into another whose dynamics it never saw, degrading measurably. This is the same phenomenon with the stakes turned down — both worlds are simulators, both are deterministic, both are on your laptop — which makes it the ideal place to study the thing. The mechanism is identical: unmodeled dynamics, and a policy that does not know they are missing.

Build

Setup

compare.py#setupsha256:3625849e1d…
import argparseimport hashlibimport jsonimport sysfrom pathlib import Path import numpy as npimport torch # Loose script from the repo root (same pattern as ch3.3-3.5); put the root on# sys.path so curriculum.common resolves. The engine here is pure numpy, and the# policy runs in torch.no_grad() eval on CPU — both bitwise deterministic. The# MuJoCo reference side is bitwise deterministic on CPU too (root CLAUDE.md #2),# so --seed 0 run twice produces byte-identical metrics.json.sys.path.insert(0, str(Path(__file__).resolve().parents[3]))from curriculum.common.device import banner  # noqa: E402from curriculum.common.envs.pusht import PushTEnv, wrap_angle  # noqa: E402 parser = argparse.ArgumentParser(description=__doc__)parser.add_argument("--seed", type=int, default=0, help="seeds nothing random in the sim (resets are seeded per-episode to MATCH MuJoCo); shifts the held-out eval seed block")parser.add_argument("--policy", type=Path, default=Path("outputs/ch1.1-bc/bc_policy.ts.pt"), help="TorchScript ch1.1 BC policy (obs[10]->action[2]); if missing, a fresh seeded policy is used with a loud warning")parser.add_argument("--episodes", type=int, default=50, help="eval episodes; each uses the SAME seed in both sims so initial states coincide")parser.add_argument("--pusher_mass", type=float, default=4.0, help="the engine's pusher inertia — the hyperparameter that widens/closes the sim-to-sim gap (heavier = firmer push, closer to MuJoCo)")parser.add_argument("--block_damp", type=float, default=6.0, help="viscous drag on the block (emulates MuJoCo's joint frictionloss/damping that keeps PushT quasi-static)")parser.add_argument("--baumgarte", type=float, default=40.0, help="ch3.4 link-stabilization frequency (rad/s) holding the two-mass T-block rigid under contact impulses")parser.add_argument("--device", choices=["cpu"], default="cpu", help="CPU only: pure-numpy engine + torch eval; the flag exists for banner/tier parity and honest wall-clock")parser.add_argument("--smoke", action="store_true", help="fixed short hermetic run for CI (fresh seeded policy, 2 episodes, 40-step horizon); two runs must match byte-for-byte")parser.add_argument("--out", type=Path, default=Path("outputs/ch3.6-compare"))parser.add_argument("--rerun", dest="rerun", action="store_true", default=True)  # recording is the default; opt OUT, not inparser.add_argument("--no-rerun", dest="rerun", action="store_false", help="skip the .rrd recording (CI smoke)")args = parser.parse_args() banner("ch3.6-compare", device=args.device)  # tier + measured wall-clock, printed firstnum_episodes = 2 if args.smoke else args.episodes  # smoke length is FIXED so CI can diff runs exactlyhorizon = 40 if args.smoke else PushTEnv.MAX_STEPS  # cap the smoke rollout so CI is fastargs.out.mkdir(parents=True, exist_ok=True)

Same house conventions as the rest of the arc: free-tier CPU, a --smoke mode with a fixed short horizon so CI can diff two runs byte-for-byte, no GPU tier. The one new input is the star of the show — a trained ch1.1 policy (--policy, a TorchScript bc_policy.ts.pt). MuJoCo appears in this file for the first time in the arc, but only as the reference sim — the ground truth we compare against. Your engine still contains no MuJoCo. The knobs that carry the lesson are --block_damp and --pusher_mass: two modeling choices that widen or close the gap.

The policy, reloaded

compare.py#policysha256:7371c9fecf…
# ch1.1's policy, reloaded and RUN — not retrained. bc.py saved a TorchScript# copy (bc_policy.ts.pt) that carries its own code AND its own normalization# buffers, so we load it with torch.jit.load and never need bc.py's BCPolicy# class on the path. The contract is exactly ch1.1's: raw obs float32[10] in,# raw action float32[2] (pusher velocity, [-1,1]) out. Nothing about the policy# changes; only the world it acts in does.BCPolicy = None  # only defined for the smoke fallback below (keeps torch.jit the default path)  def load_policy(path: Path) -> torch.nn.Module:    """Load the trained ch1.1 TorchScript policy, or a fresh seeded fallback.     A trained checkpoint is the whole point (the FULL CIRCLE). But smoke/CI is    hermetic — no trained artifact on disk — so when the file is absent we build    a fresh, SEEDED, untrained policy: it transfers badly in BOTH sims, which is    fine for a determinism check (the pipeline runs, two runs match). Real    transfer numbers require a real ch1.1 checkpoint; we say so, loudly.    """    if path.is_file() and not args.smoke:        policy = torch.jit.load(str(path), map_location="cpu")        policy.eval()        print(f"policy: loaded trained ch1.1 checkpoint {path}")        return policy    if not args.smoke:        print(f"WARNING: no policy at {path} — using a FRESH UNTRAINED policy. "              "Transfer numbers are meaningless until you point --policy at a trained bc_policy.ts.pt.")    # A small MLP standing in for ch1.1's policy (smoke determinism only). It is NOT    # ch1.1's BCPolicy — no in-model normalization, a narrower net — and never needs to    # be: smoke only checks the pipeline runs and two runs match byte-for-byte.    global BCPolicy    import torch.nn as nn     class BCPolicy(nn.Module):  # noqa: F811  (smoke-only stand-in; self-contained per doctrine)        def __init__(self):            super().__init__()            self.net = nn.Sequential(                nn.Linear(PushTEnv.OBS_DIM, 64), nn.ReLU(),                nn.Linear(64, 64), nn.ReLU(),                nn.Linear(64, PushTEnv.ACT_DIM),            )         def forward(self, obs):            return torch.tanh(self.net(obs))  # keep the raw action in [-1,1]     torch.manual_seed(args.seed)  # deterministic init -> deterministic smoke    policy = BCPolicy().eval()    return policy  policy = load_policy(args.policy)  def policy_action(obs: np.ndarray) -> np.ndarray:    """One obs[10] -> action[2] step through the policy (the ch1.1 rollout call)."""    with torch.no_grad():        batch = torch.from_numpy(obs.astype(np.float32)).unsqueeze(0)  # (10,) -> (1,10)        action = policy(batch)[0].cpu().numpy()    return np.clip(action, -1.0, 1.0).astype(np.float32)

Nothing about the policy changes — that is the entire idea. bc.py saved a TorchScript copy that carries its own weights and its own normalization buffers, so we load it with torch.jit.load and never need bc.py's class definition on the path. Its contract is exactly what it was in chapter 1.1: raw obs[10] in, raw action[2] (pusher velocity) out. The world it acts in is the only variable. (For CI, which has no trained checkpoint, a fresh seeded untrained policy stands in — enough to prove the pipeline runs and is deterministic, honestly useless for transfer. The real numbers need a real policy, and the file says so loudly if you hand it a stale one.)

PushT, re-created in your engine

compare.py#enginesha256:401f7050d9…
# PushT, RE-CREATED in the engine you built. The T-block is TWO point masses —# the bar center (= the body origin PushT reports as tee_xy) and the stem center# 0.06 m below it in the body frame — held rigid by ONE ch3.4 distance link. Its# yaw is EMERGENT: the orientation of the line between the two masses. The pusher# is a disk (a point mass with a radius). Pushing the bar mass off the block's# center of mass turns the whole dumbbell — that is how a frictionless normal# contact (ch3.5) can rotate a T at all. Masses/geometry come straight from# pusht.xml (bar 0.06 kg, stem 0.045 kg, welded; stem 0.06 m below the bar).## HONEST SIMPLIFICATIONS (the sources of the sim-to-sim gap): two point masses,# not the true two-box rigid body (approximate inertia, less rotational leverage# at the bar tips than MuJoCo); frictionless normal contact only, planar friction# faked as a viscous block drag; an idealized velocity-servo pusher, no continuous# collision. None is a bug — each is a modeling choice, and their sum is the gap. BAR_MASS, STEM_MASS = 0.06, 0.045          # pusht.xml geom massesSTEM_OFFSET = 0.06                          # body-frame distance bar-center -> stem-centerBAR_RADIUS, STEM_RADIUS = 0.032, 0.026      # disk approximations of the two boxesPUSHER_RADIUS = 0.015                        # pusht.xml pusher cylinder radiusDT, SUBSTEPS = 0.01, 10                      # match PushTEnv: 100 Hz physics, 10 Hz control (FRAME_SKIP=10)PUSHER_GAIN = 20.0                           # velocity-servo gain (pusht.xml actuator kv=20)  def block_yaw(p_bar: np.ndarray, p_stem: np.ndarray) -> float:    """Recover the T's yaw from the two masses: the body-frame line bar->stem is    (0, -L), so the world angle of (stem - bar) is yaw - pi/2. Invert it."""    d = p_stem - p_bar    return wrap_angle(float(np.arctan2(d[1], d[0])) + np.pi / 2.0)  def reset_engine(seed: int) -> dict:    """Build the engine PushT state, sampling the SAME draws as PushTEnv.reset so    the initial block/pusher pose is bit-for-bit the one MuJoCo starts from."""    rng = np.random.Generator(np.random.PCG64(seed))  # identical RNG to pusht_env.reset    r = rng.uniform(*PushTEnv._SPAWN_R)    phi = rng.uniform(0.0, 2.0 * np.pi)    tee_xy = np.array([r * np.cos(phi), r * np.sin(phi)])    tee_yaw = rng.uniform(-np.pi, np.pi)    while True:  # same rejection sampling, same order -> same pusher spawn        pusher_xy = rng.uniform(-PushTEnv._PUSHER_BOUND, PushTEnv._PUSHER_BOUND, size=2)        if np.linalg.norm(pusher_xy - tee_xy) > PushTEnv._PUSHER_CLEAR:            break    p_bar = tee_xy.astype(float)    p_stem = tee_xy + np.array([STEM_OFFSET * np.sin(tee_yaw), -STEM_OFFSET * np.cos(tee_yaw)])    # State = 3 point masses [pusher, bar, stem]; only the two block masses have velocity to start.    q = np.stack([pusher_xy.astype(float), p_bar, p_stem])    v = np.zeros((3, 2))    m = np.array([args.pusher_mass, BAR_MASS, STEM_MASS])    return {"q": q, "v": v, "m": m, "radius": np.array([PUSHER_RADIUS, BAR_RADIUS, STEM_RADIUS]),            "streak": 0, "success": False}  def engine_obs(state: dict) -> np.ndarray:    """The pusht obs[10] contract, read out of the engine state (target fixed at origin)."""    px, py = state["q"][0]    tx, ty = state["q"][1]                       # bar center == PushT body origin    tyaw = block_yaw(state["q"][1], state["q"][2])    return np.array([px, py, tx, ty, np.sin(tyaw), np.cos(tyaw), 0.0, 0.0, 0.0, 1.0], dtype=np.float32)  def link_force(q: np.ndarray, v: np.ndarray, m: np.ndarray, baumgarte: float) -> np.ndarray:    """ch3.4's distance constraint, specialized to the ONE bar<->stem link. Solve    the scalar (J M^-1 J^T) lambda = -(J M^-1 f_ext + Jdot v) - Baumgarte for the    Lagrange multiplier, return J^T lambda on the two block masses (bar=1, stem=2)."""    d = q[1] - q[2]    dv = v[1] - v[2]    g = 0.5 * (d @ d - STEM_OFFSET ** 2)         # 0 when the link is exactly rest length    gdot = d @ dv    jdotv = dv @ dv    inv_mass = 1.0 / m[1] + 1.0 / m[2]    a = inv_mass * (d @ d)                        # J M^-1 J^T (scalar, SPD)    b = -jdotv - (2.0 * baumgarte * gdot + baumgarte ** 2 * g)  # f_ext=0 in-plane; drag added by caller    lam = b / a    force = np.zeros((3, 2))    force[1] = d * lam    force[2] = -d * lam    return force  # --- ch3.5 contact, reused: the body-body overlap test + solve_contacts VERBATIM.# PushT is top-down, so there is NO floor contact (ch3.5's y=0 table is dropped);# the only contacts are the pusher (body 0) against each block mass (bodies 1, 2).def detect_pusher_contacts(q: np.ndarray, radius: np.ndarray) -> list[dict]:    """ch3.5 detect_contacts' body-body branch, specialized to pusher-vs-block."""    contacts = []    for j in (1, 2):  # pusher (i=0) against bar (1) and stem (2)        d = q[0] - q[j]        dist = float(np.linalg.norm(d))        overlap = radius[0] + radius[j] - dist        if overlap > -1.0e-3 and dist > 1e-12:            contacts.append({"i": 0, "j": j, "n": d / dist, "depth": overlap})    return contacts  def _normal_velocity(v: np.ndarray, c: dict) -> float:    """ch3.5 verbatim: relative velocity of the contact pair along the normal."""    vn = float(v[c["i"]] @ c["n"])    if c["j"] >= 0:        vn -= float(v[c["j"]] @ c["n"])    return vn  def solve_contacts(v: np.ndarray, minv: np.ndarray, contacts: list[dict],                   restitution: float, baumgarte: float, dt: float, iters: int) -> np.ndarray:    """ch3.5's projected Gauss-Seidel for contact impulses, copied VERBATIM."""    vn_pre = [_normal_velocity(v, c) for c in contacts]    lam = np.zeros(len(contacts))    for _ in range(iters):        for idx, c in enumerate(contacts):            i, j, n = c["i"], c["j"], c["n"]            k_eff = minv[i] + (minv[j] if j >= 0 else 0.0)            target = -restitution * min(vn_pre[idx], 0.0) + (baumgarte / dt) * c["depth"]            vn = _normal_velocity(v, c)            dlam = (target - vn) / k_eff            new = max(0.0, lam[idx] + dlam)            dlam, lam[idx] = new - lam[idx], new            v[i] += (dlam * minv[i]) * n            if j >= 0:                v[j] -= (dlam * minv[j]) * n    return v  def step_engine(state: dict, action: np.ndarray) -> None:    """One 10 Hz control step: hold `action` for SUBSTEPS physics ticks. Each tick    assembles ch3.3 (semi-implicit) + ch3.4 (link force) + ch3.5 (contact solve)."""    action = np.clip(action, -1.0, 1.0).astype(float)    q, v, m, radius = state["q"], state["v"], state["m"], state["radius"]    minv = 1.0 / m    for _ in range(SUBSTEPS):        # ch3.4 link force on the two block masses + viscous drag (faked planar friction).        f = link_force(q, v, m, args.baumgarte)        f[1] -= args.block_damp * v[1] * m[1]        f[2] -= args.block_damp * v[2] * m[2]        # ch3.3 semi-implicit velocity update (new velocity, then position below).        v[1:] += DT * f[1:] * minv[1:, None]        # pusher: velocity servo toward the commanded velocity (finite so contact can resist).        v[0] += DT * PUSHER_GAIN * (action - v[0])        # ch3.5 contact solve: pusher pushes the block masses (frictionless normal, restitution 0).        contacts = detect_pusher_contacts(q, radius)        if contacts:            solve_contacts(v, minv, contacts, 0.0, 0.2, DT, 10)        q += DT * v  # integrate all three masses    # success bookkeeping, mirroring PushTEnv.step (pos+ang tolerance held SUCCESS_HOLD steps).    tee_xy = q[1]    pos_err = float(np.linalg.norm(tee_xy))    ang_err = abs(block_yaw(q[1], q[2]))    in_tol = pos_err < PushTEnv.POS_TOL and ang_err < PushTEnv.ANG_TOL    state["streak"] = state["streak"] + 1 if in_tol else 0    if state["streak"] >= PushTEnv.SUCCESS_HOLD:        state["success"] = True    state["pos_err"], state["ang_err"] = pos_err, ang_err

Here is the assembly, and here is where the honesty lives. PushT needs a pusher, a T-block, and a target. The target is a fixed pose at the origin — a constant in the observation. The pusher is a disk (a point mass with a radius) driven by an idealized velocity servo toward the commanded action. The T-block is the interesting one: it is two point masses — the bar center and the stem center, 0.06 m apart in the body frame — held rigid by one distance constraint from chapter 3.4. It has no yaw variable; its orientation is emergent, read straight off the line between the two masses (block_yaw: one arctan2 and the + pi/2 that inverts the body frame). Its rotation is emergent too — push the bar mass off the block's center of mass and the whole rigid dumbbell turns. That is the only reason a frictionless normal contact — chapter 3.5's solve_contacts, reused wholesale on point masses — can rotate a T at all.

Read the simplifications in the region comment and take them seriously, because they are the sources of everything we are about to measure. Two point masses are not the real welded two-box body: the inertia is approximate, and the policy gets less rotational leverage at the bar tips than MuJoCo hands it. There is no friction — PushT's draggy planar surface is faked as a viscous block drag. The pusher is an idealized servo, and there is no continuous collision. None of these is a mistake. Each is a choice, and the sum of the choices is the gap. A version of this engine that matched MuJoCo exactly would not be a triumph; it would be a bug, because it is demonstrably the cruder model.

The observation assembly is what lets the policy plug in at all: the same ten numbers, in the same order, that pusht_env produces and ch1.1 trained on. Get that contract right and a policy trained in one engine runs, unmodified, in another.

Running it in both sims

compare.py#rolloutssha256:7e9bb69761…
# Three rollouts per episode, all from the SAME seed so both sims start identical:#   * MuJoCo closed-loop  -> ground-truth success + the exact action sequence.#   * engine  closed-loop -> your-engine success (the transfer number).#   * engine  open-loop   -> replay MuJoCo's actions from the shared start and#     measure how the block poses DIVERGE (pure dynamics gap, no policy feedback).# Poses are (x, y, yaw) of the block; divergence is position + angle error vs MuJoCo.  def mj_pose(env: PushTEnv) -> np.ndarray:    return env.tee_pose  # (x, y, yaw), yaw already wrapped  def run_mujoco(seed: int) -> dict:    """BC policy closed-loop in MuJoCo. Returns success, the action trace, and the block-pose trace."""    env = PushTEnv()    obs = env.reset(seed=seed)    actions, poses = [], [mj_pose(env)]    done, success, step = False, False, 0    while not done and step < horizon:        action = policy_action(obs)        actions.append(action)        obs, _, done, info = env.step(action)        poses.append(mj_pose(env))        success = bool(info["success"])        step += 1    return {"success": success, "actions": actions, "poses": np.array(poses)}  def run_engine_closed(seed: int) -> bool:    """BC policy closed-loop in YOUR engine (each sim visits its own states)."""    state = reset_engine(seed)    for _ in range(horizon):        step_engine(state, policy_action(engine_obs(state)))        if state["success"]:            return True    return False  def replay_engine(seed: int, actions: list[np.ndarray]) -> np.ndarray:    """Open-loop: same start, MuJoCo's EXACT actions -> the engine's block-pose trace."""    state = reset_engine(seed)    poses = [np.array([*state["q"][1], block_yaw(state["q"][1], state["q"][2])])]    for action in actions:        step_engine(state, action)        poses.append(np.array([*state["q"][1], block_yaw(state["q"][1], state["q"][2])]))    return np.array(poses)  def pose_divergence(mj_poses: np.ndarray, eng_poses: np.ndarray) -> tuple[float, float]:    """Mean position (m) and angle (rad) gap between the two sims' block-pose traces."""    n = min(len(mj_poses), len(eng_poses))    pos = np.linalg.norm(mj_poses[:n, :2] - eng_poses[:n, :2], axis=1)    ang = np.abs([wrap_angle(a - b) for a, b in zip(mj_poses[:n, 2], eng_poses[:n, 2])])    return float(pos.mean()), float(ang.mean())

Three rollouts per episode, all seeded identically so both sims start from the same block and pusher — which is why we mirror pusht_env's exact reset sampling, draw for draw. MuJoCo closed-loop gives the ground-truth success and records the exact action sequence the policy chose. Your-engine closed-loop gives the transfer number — the same policy, picking its own states in your world. And your-engine open-loop replays MuJoCo's exact actions from the shared start, with no policy feedback in the loop to quietly correct the drift, so we can watch the block poses diverge from pure dynamics alone. The first comparison answers "does it still succeed?"; the second isolates "how different are the two engines, really?"

Run it

python curriculum/phase3_advanced/ch3.6_compare/compare.py \
    --policy outputs/ch1.1-bc/bc_policy.ts.pt --seed 0
wall-clock · rendered from wallclock.csvone source · every tier
cpu-laptopexpected wall-clock on cpu-laptop: ~0.12 min (measured)measured
mpswall-clock on mps: not yet measuredpending
t4expected wall-clock on t4: ~0.44 min (measured)measured
4090wall-clock on 4090: not yet measuredpending

Note what you are not waiting for: the reload runs two small sims over 50 episodes in about seven seconds on a laptop. The ~4-minute training cost was ch1.1's, paid once, chapters ago. If you have no trained policy on disk yet, the file's header prints the two commands that make one.

Fifty episodes, the trained ch1.1 policy, seed 0:

metric MuJoCo (ground truth) your engine
BC success rate 0.62 0.20
mean block-position divergence (open-loop) 0.082 m
mean block-angle divergence (open-loop) 0.94 rad

Read it honestly. The policy transfers, and it degrades: it keeps about a third of its MuJoCo success in the engine you built. The ground-truth side is real, and here is the cross-check that proves it — this file loads ch1.1's own checkpoint and draws the same held-out seeds, so its MuJoCo rollout reproduces ch1.1's reference eval, 31/50 = 0.62, exactly. It is the same policy; only the physics changed. The trajectories start identical and pull apart, and the angle gap, 0.94 rad, dwarfs the position gap. That is diagnostic, not incidental: a two-point-mass dumbbell rotates under a push with far less authority than MuJoCo's shaped, welded body, so the policy aligns the block much worse in your world than in the one it trained in. It can still shove the block roughly toward the target; it struggles to square it up.

One caveat on the numbers, because this book does not oversell a decimal. The engine success rate (0.20) is a coarse 0/1 count over 50 episodes, and it is noisy — do not over-read the exact value. The divergence is the smooth, seed-robust quantity. The result to carry away is its shape — lower success, and trajectories that diverge, most of all in angle — not a single digit.

The gap is a knob

The best way to believe a modeling gap is real is to move it. --block_damp is your engine's stand-in for the block's surface friction; MuJoCo's tee is heavily damped and barely coasts. Turn your drag up toward that, and the two sims agree better — monotonically:

--block_damp mean position divergence mean angle divergence
2 0.123 m 1.204 rad
60 0.076 m 0.877 rad

More than a 1.6× reduction in the position gap, from nothing but making your block as sluggish as MuJoCo's. The gap was never a fixed fact about "your engine versus the real one"; it was a consequence of specific choices, and here is one of them with a slider on it. (Exercise 3 has you move that slider and read the divergence off it yourself.)

Matching MuJoCo would be a bug

It is tempting to read the gap as a defect — 0.62 in MuJoCo, 0.20 in your engine, so your engine is broken and the job is to drive the distance to zero. Predict what that would take. You would have to give the two point masses a shaped rigid body's inertia, add a real Coulomb friction cone, and resolve contact continuously — which is to say, rebuild the very subsystems this arc scoped out on purpose. An engine that matched MuJoCo exactly would not be a triumph; it would be a bug, a sign you had smuggled the cruder model back into agreement with the finer one it is demonstrably a simplification of.

So the gap is not the error term — it is the measurement, and it points at where the model is thin. The angle divergence (0.94 rad) dwarfing the position gap is not noise to tune away; it is the two-mass dumbbell telling you, in radians, exactly how much rotational authority it lacks against MuJoCo's shaped, welded body. You can move the gap — --block_damp narrows it monotonically toward MuJoCo's sluggish tee — but the honest run never tunes the knobs until the two success rates match. That would not be closing the gap; it would be hiding it. The number worth keeping is the one that says it works, and here is precisely how much it does not.

Why this is the sim-to-real story in miniature

Sit with what just happened, because it is the lesson the whole arc was building toward. You trained a policy in one simulator. You built a second, cruder simulator from scratch. You ran the policy in it, and it worked worse — in a way you could measure, partly explain (the missing rotational authority), and partly dial back (the friction knob). Now replace "your from-scratch engine" with "a real robot" and you have chapter 2.6, and you have the central problem of deploying learned policies: the world you train in is always a model, the world you run in always has dynamics the model left out, and the transfer gap is not something you eliminate — it is something you measure, respect, and shrink on purpose. You have now felt that from both ends: as the person whose policy degrades, and as the person who built the flawed physics that degraded it.

The honest limits

Say plainly what this engine and this comparison do not claim:

  • Your PushT engine is deliberately simplified: two point masses instead of a shaped rigid body, frictionless normal contact with friction faked as drag, an idealized pusher, no continuous collision. It is a worse PushT than MuJoCo on purpose. The point is the gap, not a competitive physics engine.
  • The transfer is partial, and the success number is noisy. 0.20 is one seed's coarse count; the divergence is the number to trust. We do not sell a clean transfer, and we do not tune the knobs until the two successes match — that would be faking it.
  • The comparison uses MuJoCo as ground truth, which it is not (MuJoCo has its own sim-to-real gap). Here it is only the more-faithful reference. The arc's "read the real thing" segment points at what MuJoCo itself leaves out — the Coulomb friction, the friction cone, and the shaped rigid body your two-mass block is a stripped-down approximation of.

Read the real thing

The honest limits above name the gaps in words. Here they are in MuJoCo's own C. meta.yaml pins google-deepmind/mujoco at tag 3.10.0 — the exact version the rest of the course calls — so read these three files against the step_engine you just wrote.

Your mj_step, in the engine region. Everything the last four chapters built lands in one function: step_engine in compare.py. Per physics tick it does three things in order — assemble forces (ch3.4's link_force holding the two masses rigid, plus the faked block drag), take the ch3.3 semi-implicit velocity-then-position step, and resolve contact with ch3.5's solve_contacts (detect_pusher_contacts → project the normal impulses). Forces, integrate, constraints, q += DT * v. That is a real mj_step, small enough to read in one sitting.

MuJoCo's mj_step, in src/engine/engine_forward.c. The real one has the same three moves and the same shape. mj_step runs mj_forward and then an integrator (mj_Euler by default; mj_RungeKutta / mj_implicit optional). mj_forward fans out through mj_forwardSkip into an ordered pipeline you will recognize: mj_fwdPositionmj_fwdVelocitymj_fwdActuationmj_fwdAccelerationmj_fwdConstraint, then integrate. Your one line of force assembly is their whole position stage: inside mj_fwdPosition (same file) the order is mj_fwdKinematics, build and factor the mass matrix (mj_makeM / mj_factorM), mj_collision, mj_makeConstraint, mj_projectConstraint. Same skeleton — kinematics, contact, constraints, integrate — but every rib of it is a real subsystem.

What they add, and why the transfer drops. Two ribs your engine stubs are exactly where the 0.62 → 0.20 gap lives. Contact: mj_collision in src/engine/engine_collision_driver.c runs broadphase then narrowphase over the shaped geoms of the actual T, producing contacts anywhere on the body; your detect_pusher_contacts tests two disks. Friction: mj_makeConstraint / mj_projectConstraint in src/engine/engine_core_constraint.c build and solve a real Coulomb friction cone — the mjCNSTR_CONTACT_PYRAMIDAL and mjCNSTR_CONTACT_ELLIPTIC contact types, tangential forces clamped by mj_assignFriction — while your solve_contacts is frictionless, normal-only. Those two omissions are not incidental to the result; they are it. A shaped body caught along a real contact patch, with friction resisting slip, rotates the T with an authority a two-disk frictionless push cannot — which is precisely why the angle divergence (0.94 rad) dwarfs the position gap. The gap you measured is the price of the pipeline you left out, printed in radians.

None of this makes your engine wrong. It makes it a faithful minimum — the smallest thing that steps PushT and runs a real policy — and it makes the missing subsystems legible. This is the reason production simulators exist: not because mj_step is doing something magic, but because it is doing the collision and the friction cone you can now name.

Read next: open src/engine/engine_forward.c and find mj_step. Follow its call into mj_forwardmj_fwdPosition, and there — in mj_collision and mj_makeConstraint — sit the shaped-body contact and the friction cone your two-mass block approximates. Read those two, and you have read the gap.

What you built, and where the arc closes

You closed the circle. Since chapter 0.1 you trusted mj_step; across 3.3–3.5 you rebuilt its insides — dynamics, constraints, contact — and measured what each cost; and here you re-created a whole task, PushT, out of those pieces and ran a real policy in it. The behavior-cloning agent from the very first chapter of Phase 1 executed inside the physics engine you wrote by hand, and the gap between your engine and MuJoCo turned out to be the same kind of gap as the one between MuJoCo and reality — now with a number on it, and a knob.

If you wanted to close that gap for real, you already know the three moves the honest limits named: give the block a shaped rigid body instead of two point masses, add real planar friction instead of faking it as drag, and resolve contact continuously. Each would narrow the divergence, and each is a chapter's worth of work — which is exactly why MuJoCo is a black box worth trusting, and exactly what it took you an arc to earn the right to say.

The print table below is the deliverable: two success rates and two divergences that say, together, it works, and here is exactly how much it does not.

compare.py#reportsha256:4358618200…
# Run every episode in all three modes and aggregate. The deliverable is the# comparison table: the BC policy's success IN MUJOCO vs IN YOUR ENGINE (the# transfer), and the mean block-pose DIVERGENCE (the dynamics gap). Expect the# engine success BELOW MuJoCo and the divergence to grow across the episode —# the honest sim-to-sim gap, the same shape as ch2.6's sim-to-real gap.if args.rerun:    import rerun as rr    rr.init("zero2robot/ch3.6-compare", spawn=False)    rr.save(str(args.out / "compare.rrd"))    rr.log("world/target", rr.Points3D([[0.0, 0.0, 0.0]], radii=[0.02], colors=[[90, 205, 100]]), static=True) mj_successes, eng_successes = 0, 0pos_divs, ang_divs = [], []for episode in range(num_episodes):    seed = 10_000 + args.seed + episode  # ch1.1's held-out eval seed block (never a trained start)    mj = run_mujoco(seed)    eng_pose = replay_engine(seed, mj["actions"])    pos_div, ang_div = pose_divergence(mj["poses"], eng_pose)    mj_successes += mj["success"]    eng_successes += run_engine_closed(seed)    pos_divs.append(pos_div)    ang_divs.append(ang_div)    if args.rerun:        rr.log(f"world/mujoco/ep{episode}", rr.LineStrips3D([[[*p[:2], 0.0] for p in mj["poses"]]], colors=[[115, 128, 242]]), static=True)        rr.log(f"world/engine/ep{episode}", rr.LineStrips3D([[[*p[:2], 0.0] for p in eng_pose]], colors=[[217, 76, 64]]), static=True)        rr.set_time("episode", sequence=episode)        rr.log("divergence/position", rr.Scalars([pos_div]))        rr.log("divergence/angle", rr.Scalars([ang_div])) mj_rate = mj_successes / num_episodeseng_rate = eng_successes / num_episodes# Stale-checkpoint guard: ch1.1's canonical policy (500 demos) scores ~0.62 in MuJoCo —# the sim it TRAINED in. If a loaded (non-smoke) checkpoint scores 0 there, it is untrained# or STALE (e.g. a 3-epoch toy run left at the default path), NOT a sim-to-sim gap. Say# so loudly rather than silently report a meaningless 0/0 transfer.if not args.smoke and args.policy.is_file() and mj_rate == 0.0:    print(f"WARNING: the loaded policy {args.policy} scored 0% even in MuJoCo — the sim it was "          "TRAINED in (ch1.1's canonical reference is ~0.62). This checkpoint looks UNTRAINED or STALE; "          "re-train ch1.1 to convergence and point --policy at its bc_policy.ts.pt. The sim-to-sim "          "numbers below measure a broken policy, not your engine's dynamics gap.")metrics = {    "episodes": num_episodes,    "seed": args.seed,    "smoke": bool(args.smoke),    "policy": str(args.policy) if (args.policy.is_file() and not args.smoke) else "fresh-untrained",    "mj_success_rate": round(mj_rate, 6),          # ground truth: the policy in the sim it trained in    "engine_success_rate": round(eng_rate, 6),     # the transfer: the SAME policy in the engine you built    "mean_pos_divergence_m": round(float(np.mean(pos_divs)), 6),   # open-loop block-position gap    "mean_ang_divergence_rad": round(float(np.mean(ang_divs)), 6), # open-loop block-yaw gap}(args.out / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n") print(f"ran {num_episodes} episodes (horizon {horizon}) of the ch1.1 BC policy in BOTH sims")print(f"{'':<22}{'MuJoCo (truth)':>16}{'your engine':>16}")print(f"{'BC success rate':<22}{mj_rate:>16.3f}{eng_rate:>16.3f}")print(f"open-loop divergence (same start, same actions): "      f"position {np.mean(pos_divs):.4f} m, angle {np.mean(ang_divs):.4f} rad (mean over the episode)")transfer = eng_rate / mj_rate if mj_rate > 0 else float("nan")print(f"sim-to-sim transfer: your engine keeps {transfer:.0%} of MuJoCo's success — "      "the gap is your engine's simplifications, the same shape as ch2.6's sim-to-real gap")print(f"metrics: {args.out / 'metrics.json'}")if args.rerun:    print(f"recording: {args.out / 'compare.rrd'} — open it with: rerun {args.out / 'compare.rrd'}")# config hash of the run knobs, for the wallclock ledger (author: paste into wallclock.csv)_cfg = f"{args.episodes}|{args.pusher_mass}|{args.block_damp}|{args.baumgarte}|{horizon}"print(f"config_hash: {hashlib.md5(_cfg.encode()).hexdigest()[:12]}")

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, ch3.6.

    Objective tested: the FULL CIRCLE and its honesty. You built a physics engine (ch3.3-3.5). You re-created PushT in it. Now you run the ch1.1 BC policy — trained in MuJoCo — in the engine YOU built, from bit-identical starts. What happens to a policy when the world underneath it is a simplified, from-scratch approximation?

    THE EXPERIMENT: run the SAME trained BC policy in BOTH sims for the smoke-sized comparison (a handful of episodes), and read the success rate in MuJoCo (ground truth) vs in your engine, plus the block-pose divergence (position and ANGLE).

    PREDICT before you run: how does the policy transfer to your engine?

    • A) perfectly — a policy is a function obs->action; feed it the same obs contract and it must reach the goal exactly as often, in any correct sim

    • B) it fails completely — your engine is not MuJoCo, so success drops to ~0 and the block goes nowhere near the target

    • C) it PARTLY works — success drops (roughly a third of MuJoCo's), and the trajectories start identical then diverge, MOST of all in ANGLE, because your frictionless two-point-mass block rotates far worse than MuJoCo's shaped one

    (needs a trained ch1.1 policy — see find_policy; if none is found the run explains how to make one).

    Before you run, write one sentence: WHY — what about a frictionless two-point-mass block would make its ANGLE harder to reproduce than its position?

    Estimated learner time: 15 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. code-completion

    Exercise 2

    SUGGESTED exercise candidate (humans promote) — code-completion, ch3.6.

    Objective tested: the two lines that let a MuJoCo-trained policy plug into the engine YOU built. The policy is a fixed function obs[10] -> action[2]; the ONLY way it runs unchanged in a from-scratch engine is if that engine reports the EXACT same observation contract MuJoCo did. Two pieces carry it:

    1. YAW FROM TWO MASSES. Your T-block is two point masses (bar center, stem center) on a ch3.4 rigid link — there is no yaw variable, only geometry. The body-frame line bar->stem is (0, -L), so the WORLD angle of (stem - bar) is yaw - pi/2. Recover yaw by inverting that (and wrap to [-pi, pi)).
    2. THE obs[10] ASSEMBLY. Pack pusher xy, block xy, sin/cos of the block yaw, and the fixed target (origin, yaw 0) into the pusht layout — the same 10 numbers, in the same order, that ch1.1 trained on.

    The geometry (masses, link, contact) is given. You fill the two blanks. checks.py compares your completed functions against the reference on random states; while a blank is unfilled it raises NotImplementedError and the check SKIPS. Estimated learner time: 20 minutes.

    Run it locally:

    pytest curriculum/phase3_advanced/ch3.6_compare/exercises/suggested/checks.py -k ex2
  3. hyperparameter-investigation

    Exercise 3

    SUGGESTED exercise candidate (humans promote) — hyperparameter-investigation, ch3.6.

    Objective tested: the sim-to-sim gap is a MODELING CHOICE you can measure yourself moving. Your engine fakes PushT's planar surface friction as a viscous drag on the block, --block_damp. MuJoCo's real tee is heavily damped and quasi-static (it barely coasts). So --block_damp is a proxy for how well your engine matches that: turn it up toward MuJoCo's draggy tee and the two sims should agree BETTER.

    THE EXPERIMENT: replay MuJoCo's exact action sequences in your engine (open-loop) at a LOW block drag and a HIGH block drag, and read the mean block-position divergence at each. The divergence is the smooth, seed-robust number here (success is a coarse 0/1 metric — do not read the gap off it).

    PREDICT before you run: as --block_damp rises from 2 to 60, the divergence...

    • A) rises — more drag means the block moves less like MuJoCo's, so they agree worse

    • B) falls — more drag makes your block quasi-static like MuJoCo's tee, so the two sims track each other more closely (the gap narrows)

    • C) does not move — friction is a detail; the divergence is set by the contact model alone and drag cannot touch it

    (needs a trained ch1.1 policy — see ex1.find_policy).

    Before you run, write one sentence: WHY — what does raising the block's drag do to how far it coasts, and how does that move it toward or away from MuJoCo's quasi-static tee?

    Estimated learner time: 15 minutes.

    Run it locally:

    pytest curriculum/phase3_advanced/ch3.6_compare/exercises/suggested/checks.py -k ex3

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight fromcompare.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
3625849e1d6167d675db80c57ba65abce1dd4f7abfb7674ba3277e1945517bbe
#policy
7371c9fecfb1094ed0c45b682f44cca839d3fe473f21c490abc46521592a5b94
#engine
401f7050d96e485f87467b9f74a278d872dd5e2a9efca29237e035666db038a8
#rollouts
7e9bb697613e0280b115d18ef39d1d0ce2ab3308d96f161e049852367a6865b8
#report
4358618200800aef36da87f0cc9363f8388c9b964975ae559bbf819b0b539b9d