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
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
# 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
# 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_errHere 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
# 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
| cpu-laptop | expected wall-clock on cpu-laptop: ~0.12 min (measured) | measured |
| mps | wall-clock on mps: not yet measured | pending |
| t4 | expected wall-clock on t4: ~0.44 min (measured) | measured |
| 4090 | wall-clock on 4090: not yet measured | pending |
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_fwdPosition →
mj_fwdVelocity → mj_fwdActuation → mj_fwdAcceleration → mj_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_forward → mj_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.
# 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]}")