zero2robot · Phase 5 · Practitionerch5.4-vla-shape · vla_shape.py

Chapter 5.4

The Production VLA ShapePrefix, Suffix, and the Action Expert

By the end you can

  1. Graduate ch1.8's single-tower TinyVLA into the production TWO-TOWER shape — a PREFIX (the VLM: vision + state + instruction tokens, bidirectional among themselves, no CLS readout) and a SUFFIX (the ACTION EXPERT: H learned action-query tokens that carry the noised action chunk + the flow time). Read the action off the SUFFIX, not a CLS vector.
  2. Build the BLOCK-ATTENTION MASK by hand — prefix<->prefix full; suffix->prefix full (the cross-attention); suffix<->suffix intra-chunk; and prefix NEVER reads suffix (so the prefix is action-independent and KV-cacheable, pi0's trick). Fifteen lines that ARE the architecture.
  3. Build the action expert as SEPARATE WEIGHTS on the suffix positions (the pi0 "mixture"): ONE shared scaled-dot-product attention over the joint [prefix|suffix] sequence, but the prefix tokens use the VLM tower's Q/K/V/MLP and the suffix tokens use the expert tower's — two parameter sets, one attention op, NOT a layer stacked on a CLS vector. Then the ch1.5 FLOW HEAD on each expert token's output emits a CHUNK of H actions (ch1.3), not ch1.8's single action.
  4. Measure, and frame honestly, the MECHANISM (not a task score, ch1.8's honesty): the expert's ONLY window onto the state, pixels, and words is the suffix->prefix cross-attention. CUT that block at inference (--break cut_cross) and the trained expert's HELD-OUT VELOCITY FIT collapses toward the unconditional prior — routing, not just parameters, is load-bearing. The seed-robust, byte-reproducible flow-MSE gap is the gated headline; the PushT ROLLOUT is the higher bar and FLOORS for both masks (from-scratch on ch1.7's frozen-random backbone can't drive PushT, exactly as ch1.8 warned). ch5.2's aligned encoder is the upgrade that would make the pixels load-bearing.

See it work

live · P2

The production VLA shape — one cell from blind

The block-attention mask is the whole architecture: a [ prefix | suffix ] sequence where the action expert (suffix) reads the frozen VLM (prefix) through exactly one quadrant — the suffix → prefix cross-attention. Cut that block and the held-out flow-MSE jumps 1.57 → 2.54 (a +0.98 routing gap — a large, positive collapse on every seed): the expert loses its only path to the state. Routing, not just parameters, is load-bearing.

The block-attention mask (full)visionstatetok0tok1tok2tok3tok4tok5tok6tok7tok8tok9tok10tok11act0act1act2act3act4act5act6act7visionstatetok0tok1tok2tok3tok4tok5tok6tok7tok8tok9tok10tok11act0act1act2act3act4act5act6act7final-block self-attention: last layer self-concentrates on the action tokens(prefix keys ≈ 0) — NOT prefix-reading; proven by the +0.98 flow-MSE collapse
prefix↔prefixsuffix→prefixsuffix↔suffixblockedflow-MSE is the measured signal · poster reads with JS off
Attention mask: full (intact). The action expert reads the prefix — vision, state, and language. Held-out flow-MSE is 1.57. Cutting one block of the mask severs the expert's only path to the state.

The reproducible result is the flow-MSE routing gap (1.57 → 2.54, +0.98, a large positive collapse on every seed 0/1/2): severing the suffix→prefix block collapses the trained expert's held-out velocity fit toward the unconditional prior. The recorded PushT rollout, by contrast, floors for both masks (0/8 full · 0/8 cut) — a from-scratch action expert on a frozen-random vision backbone can't drive PushT at free-tier, so the rollout is the Scale Lab, not the lesson. Real precomputed masks + MSE from vla_shape.py (seed 0, H=8); the two-tower shape does not drive PushT here — ch5.2's aligned encoder is the upgrade that makes pixels load-bearing. Poster reads with JS off.

pusherthe agent that pushesT-blockthe thing being pushedtargetwhere it needs to end up
Open in Colabsoon

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

One tower was a teaching simplification

In ch1.8 you built a VLA as a single tower. You laid the three inputs out as one sequence — [CLS, vision, state, tok_0..tok_15] — ran a few self-attention blocks, read the fused representation off the CLS token, and flowed a single action out of that one vector. It worked as a lesson. It is not the shape of a production VLA.

Open pi0, SmolVLA, or OpenVLA-OFT and you find the same different picture every time: two towers sharing one attention. A prefix — the vision-language model — and a suffix — an action expert that reads the prefix and emits a whole chunk of actions. This chapter graduates ch1.8's single tower into that shape, by hand, PushT-only, still no transformers. Open vla_shape.py. Six regions: setup, data, model, train, eval, report.

The claim, up front (it is a mechanism, not a score)

We keep ch1.8's honesty. The vision here is still ch1.7's frozen random CNN, and PushT is solvable from state, so — as in ch1.8 — this from-scratch policy will not drive PushT to success in a closed-loop rollout. That is fine, because the thing this chapter measures is not a task score. It is the routing:

The action expert's only window onto the state, the pixels, and the words is the suffix→prefix cross-attention. Cut that one block of the attention mask and the trained expert goes blind — its held-out velocity fit collapses toward the unconditional prior.

That collapse is byte-reproducible and seed-robust (a flow-MSE gap of +0.55 to +1.0 across seeds 0/1/2). It is the gated headline. The rollout is the higher bar, and we report — honestly — that it floors.

The data: ch1.7's pile, PushT half, now with time

vla_shape.py#datasha256:09ea7423c3…
# ch1.7's recipe, PushT-only and re-contained (no ALOHA -> no padding): replay the scripted expert;# keep every Nth 64x64 frame + state + action + episode index (the index lets us build CHUNKS). A frozen# RANDOM CNN featurizes them — ch1.7's stand-in, NOT perception (ch5.2 is the aligned upgrade).class FrozenVisionEncoder(nn.Module):    """ch1.7's conv stack, rebuilt: (B,64,64,3) uint8 -> (B, feature_dim), random-init + FROZEN. The    SAME instance featurizes training frames AND live eval frames (no train/eval mismatch to guard)."""     def __init__(self, width: int, out_dim: int) -> None:        super().__init__()        self.stem = nn.Sequential(            nn.Conv2d(3, width, 3, stride=2, padding=1), nn.ReLU(),            nn.Conv2d(width, 2 * width, 3, stride=2, padding=1), nn.ReLU(),            nn.Conv2d(2 * width, 4 * width, 3, stride=2, padding=1), nn.ReLU(),            nn.AdaptiveAvgPool2d(1))        self.head = nn.Linear(4 * width, out_dim)        for p in self.parameters():            p.requires_grad_(False)  # FROZEN — never trained here        self.eval()     @torch.no_grad()    def forward(self, images_uint8: torch.Tensor) -> torch.Tensor:        x = images_uint8.to(torch.float32).permute(0, 3, 1, 2) / 127.5 - 1.0        return self.head(self.stem(x).flatten(1))  encoder = FrozenVisionEncoder(CONV_WIDTH, args.feature_dim).to(device)  # FIRST torch-RNG use  def tokenize(text: str, stoi: dict) -> np.ndarray:    """ch1.7's word-level tokenizer over a FIXED vocab (no BPE, no HF): [BOS] ids [EOS], OOV-><unk>."""    ids = [stoi["<bos>"]] + [stoi.get(w, 1) for w in text.split()] + [stoi["<eos>"]]    ids = ids[:INSTR_TOKENS] + [PAD_ID] * (INSTR_TOKENS - len(ids))    return np.asarray(ids[:INSTR_TOKENS], dtype=np.int64)  def collect(episodes: int, seed: int, stride: int):    env = PushTEnv()    frames, states, actions, ep_index = [], [], [], []    for e in range(episodes):        obs = env.reset(seed + e)        expert = ScriptedExpert(noise=0.0, seed=seed + e)        step, done = 0, False        while not done:            action = expert.action(env)            if step % stride == 0:                frames.append(env.render_frame(IMG_HW, IMG_HW))                states.append(obs.astype(np.float32))                actions.append(action[:ACT_DIM].astype(np.float32))                ep_index.append(e)            obs, _, done, _ = env.step(action)            step += 1    return (np.asarray(frames, np.uint8), np.asarray(states, np.float32),            np.asarray(actions, np.float32), np.asarray(ep_index, np.int64))  frames_np, states_np, actions_np, ep_np = collect(args.episodes, args.seed, args.frame_stride)N = len(frames_np)VOCAB = ["<pad>", "<unk>", "<bos>", "<eos>"] + sorted({w for t in TEMPLATES for w in t.split()})STOI = {w: i for i, w in enumerate(VOCAB)}tokens_np = np.stack([tokenize(TEMPLATES[(args.seed + int(e)) % len(TEMPLATES)], STOI) for e in ep_np])# CHUNK targets (ch1.3): frame i -> its next H expert actions; pad the episode tail (masked out).chunks_np = np.zeros((N, H, ACT_DIM), np.float32)cmask_np = np.zeros((N, H), np.float32)for e in np.unique(ep_np):    idx = np.nonzero(ep_np == e)[0]    for j, f in enumerate(idx):        valid = min(H, len(idx) - j)        chunks_np[f, :valid] = actions_np[idx[j:j + valid]]        chunks_np[f, valid:] = actions_np[idx[-1]]        cmask_np[f, :valid] = 1.0# Split by EPISODE (last 25%) so near-duplicate frames never straddle train/test (ch1.6/ch5.1).test_ep = ep_np >= int(math.ceil(args.episodes * 0.75))train_idx, test_idx = np.where(~test_ep)[0], np.where(test_ep)[0]if len(test_idx) == 0:  # tiny smoke budgets can leave no held-out episode; fall back to a frame split    test_idx, train_idx = train_idx[-max(1, len(train_idx) // 3):], train_idx[:-max(1, len(train_idx) // 3)]image_feats = torch.cat([encoder(torch.from_numpy(frames_np[i:i + 256]).to(device))                         for i in range(0, N, 256)])                       # (N, feature_dim), frozen# Normalize on the TRAIN split (ch1.5/1.8): standardize actions + feature, min-max state; const->1._safe = lambda a: np.where(a < 1e-4, np.float32(1.0), a).astype(np.float32)  # noqa: E731tr_acts = chunks_np[train_idx][cmask_np[train_idx].astype(bool)]st_tr, ft = states_np[train_idx], image_feats[torch.from_numpy(train_idx).to(device)]STATS = {"act_mean": tr_acts.mean(0).astype(np.float32), "act_std": _safe(tr_acts.std(0)),         "state_min": st_tr.min(0).astype(np.float32), "state_range": _safe(st_tr.max(0) - st_tr.min(0)),         "feat_mean": ft.mean(0).cpu().numpy().astype(np.float32), "feat_std": _safe(ft.std(0).cpu().numpy())}act_mean_t = torch.from_numpy(STATS["act_mean"]).to(device)act_std_t = torch.from_numpy(STATS["act_std"]).to(device)tokens_t = torch.from_numpy(tokens_np).to(device)states_t = torch.from_numpy(states_np).to(device)chunks_t = torch.from_numpy((chunks_np - STATS["act_mean"]) / STATS["act_std"]).to(device)cmask_t = torch.from_numpy(cmask_np).to(device)print(f"dataset: {args.episodes} PushT episodes / {N} frames ({len(train_idx)} train / {len(test_idx)} test), "      f"vocab {len(VOCAB)}, feature_dim {args.feature_dim}, horizon {H}")

We re-use ch1.7's recipe and re-contain it (no imports): replay the scripted PushT expert, keep every other 64×64 frame with its state and action, and — the one new thing — the episode index each frame came from. That index is what lets us build action chunks: for frame i, the next H=8 expert actions within its episode (ch1.3), padded and masked at the episode's tail. Dropping ALOHA is deliberate: one embodiment means one action dimensionality, so there is no padding and no masked multi-task loss to carry — the LOC goes into the two-tower instead. The frozen random CNN featurizes every frame once; we keep the same encoder instance for training and for live eval frames, so there is no train/eval vision mismatch to guard.

The mask is the architecture

vla_shape.py#modelsha256:212b2e90cc…
# The two-tower VLA: P prefix [vision, state, tokens] then H suffix (action-expert) positions.P = 2 + INSTR_TOKENS  # prefix length: 1 vision + 1 state + L instruction tokens  def sinusoidal_embed(t: torch.Tensor, dim: int) -> torch.Tensor:    """Continuous flow time (B,) -> (B, dim) sinusoidal features (ch1.5)."""    half = dim // 2    freqs = torch.exp(-math.log(10000.0) * torch.arange(half, device=t.device) / half)    ang = t.float()[:, None] * freqs[None]    return torch.cat([ang.sin(), ang.cos()], dim=1)  def block_mask(cut_cross: bool, dev) -> torch.Tensor:    """THE block-attention mask, an additive bias (0 = allowed, -inf = blocked), (S, S). Row =    query, col = key. prefix=[0,P), suffix=[P,P+H). prefix<->prefix full (the VLM fusion);    suffix->prefix full (the expert READS the VLM — the cross-attention); suffix<->suffix full    (the H chunk steps coordinate); prefix->suffix BLOCKED (the prefix never reads the actions, so    it is action-independent and KV-cacheable — pi0). cut_cross drops ONLY the suffix->prefix block."""    S = P + H    allowed = torch.zeros(S, S, dtype=torch.bool, device=dev)    allowed[:P, :P] = True            # prefix <-> prefix    allowed[P:, P:] = True            # suffix <-> suffix (intra-chunk)    if not cut_cross:        allowed[P:, :P] = True        # suffix -> prefix (the cross-attention we can cut)    return torch.where(allowed, 0.0, float("-inf"))  def per_tower(x: torch.Tensor, f_pre, f_suf) -> torch.Tensor:    """Run f_pre on the first P (prefix) positions and f_suf on the rest (suffix), then re-join —    the 'separate weights, shared sequence' trick in one line."""    return torch.cat([f_pre(x[:, :P]), f_suf(x[:, P:])], dim=1)  class ExpertBlock(nn.Module):    """ONE pre-norm self-attention over the joint [prefix|suffix] sequence — but the prefix and the    suffix tokens each own their Q/K/V, output projection, MLP, and norms (the pi0 mixture): the    softmax under the block mask is SHARED, the weights are NOT. That makes the expert a tower."""     def __init__(self, dim: int, heads: int) -> None:        super().__init__()        self.heads = heads        self.ln1_pre, self.ln1_suf = nn.LayerNorm(dim), nn.LayerNorm(dim)        self.ln2_pre, self.ln2_suf = nn.LayerNorm(dim), nn.LayerNorm(dim)        self.qkv_pre, self.qkv_suf = nn.Linear(dim, 3 * dim), nn.Linear(dim, 3 * dim)        self.proj_pre, self.proj_suf = nn.Linear(dim, dim), nn.Linear(dim, dim)        self.mlp_pre = nn.Sequential(nn.Linear(dim, 4 * dim), nn.GELU(), nn.Linear(4 * dim, dim))        self.mlp_suf = nn.Sequential(nn.Linear(dim, 4 * dim), nn.GELU(), nn.Linear(4 * dim, dim))        self.last_attn = None  # (B, S, S) head-averaged attention, for the routing viz     def forward(self, x: torch.Tensor, bias: torch.Tensor) -> torch.Tensor:        B, S, dim = x.shape        h, hd = self.heads, dim // self.heads        qkv = per_tower(per_tower(x, self.ln1_pre, self.ln1_suf), self.qkv_pre, self.qkv_suf)        q, k, v = qkv.reshape(B, S, 3, h, hd).permute(2, 0, 3, 1, 4)          # each (B, h, S, hd)        scores = (q @ k.transpose(-2, -1)) / math.sqrt(hd) + bias[:, None]    # + (B,1,S,S) mask        attn = scores.softmax(dim=-1)        self.last_attn = attn.mean(1).detach()        x = x + per_tower((attn @ v).transpose(1, 2).reshape(B, S, dim), self.proj_pre, self.proj_suf)        return x + per_tower(per_tower(x, self.ln2_pre, self.ln2_suf), self.mlp_pre, self.mlp_suf)  class TwoTowerVLA(nn.Module):    """Prefix [vision, state, instruction] + suffix [H action-query tokens = noised action chunk +    flow time]. Shared masked attention, per-tower weights; each suffix output -> one step's flow    velocity, so the model emits a CHUNK of H velocities in one pass."""     def __init__(self, vocab: int, feat_dim: int, dim: int, layers: int, heads: int, stats: dict) -> None:        super().__init__()        self.tok_embed = nn.Embedding(vocab, dim, padding_idx=PAD_ID)        self.vision_proj = nn.Linear(feat_dim, dim)        self.state_proj = nn.Linear(STATE_DIM, dim)        self.prefix_pos = nn.Parameter(0.02 * torch.randn(1, P, dim))        self.action_in = nn.Linear(ACT_DIM, dim)                          # the noised action -> a token        self.action_query = nn.Parameter(0.02 * torch.randn(1, H, dim))    # "which chunk step am I"        self.time_mlp = nn.Sequential(nn.Linear(TIME_DIM, dim), nn.SiLU(), nn.Linear(dim, dim))        self.blocks = nn.ModuleList([ExpertBlock(dim, heads) for _ in range(layers)])        self.norm_suf = nn.LayerNorm(dim)        self.vel_head = nn.Linear(dim, ACT_DIM)                            # per suffix token -> velocity        for name, value in stats.items():            self.register_buffer(name, torch.from_numpy(value))     def prefix(self, tokens, feat, state):        feat_n = (feat - self.feat_mean) / self.feat_std        state_n = (2.0 * (state - self.state_min) / self.state_range - 1.0).clamp(-1.0, 1.0)        return torch.cat([self.vision_proj(feat_n)[:, None], self.state_proj(state_n)[:, None],                          self.tok_embed(tokens)], dim=1) + self.prefix_pos     def forward(self, tokens, feat, state, x_t, t, cut_cross):        B = tokens.shape[0]        tvec = self.time_mlp(sinusoidal_embed(t * TIME_SCALE, TIME_DIM))[:, None]  # (B, 1, dim)        suf = self.action_in(x_t) + self.action_query + tvec              # (B, H, dim)        seq = torch.cat([self.prefix(tokens, feat, state), suf], dim=1)   # (B, P+H, dim)        bias = block_mask(cut_cross, seq.device).expand(B, P + H, P + H).clone()        key_pad = torch.zeros(B, P + H, dtype=torch.bool, device=seq.device)        key_pad[:, 2:P] = tokens == PAD_ID                                # ignore padded instruction slots        bias = bias.masked_fill(key_pad[:, None, :], float("-inf"))        for blk in self.blocks:            seq = blk(seq, bias)        return self.vel_head(self.norm_suf(seq[:, P:]))                   # (B, H, ACT_DIM) velocity  # ch1.5's conditional flow matching, over an H-step CHUNK, conditioned through the mask (ch1.5/1.8).def flow_loss(model, chunk, cmask, tokens, feat, state):    t = torch.rand(len(chunk), generator=gen).to(device)    noise = torch.randn(chunk.shape, generator=gen).to(device)    x_t = (1.0 - t)[:, None, None] * noise + t[:, None, None] * chunk    pred = model(tokens, feat, state, x_t, t, False)                     # ALWAYS trained with full routing    per_step = ((pred - (chunk - noise)) ** 2).mean(-1)                  # (B, H) velocity MSE per step    return (per_step * cmask).sum() / cmask.sum()                        # ignore padded chunk steps  @torch.no_grad()def sample_chunk(model, tokens, feat, state, steps, cut_cross):    """Integrate the velocity ODE from noise to a chunk of H actions (ch1.5), standardized space."""    x = torch.randn((tokens.shape[0], H, ACT_DIM), generator=gen).to(device)    for i in range(steps):        t = torch.full((tokens.shape[0],), i / steps, device=device)        x = x + (1.0 / steps) * model(tokens, feat, state, x, t, cut_cross)    return x

Two things live in this region, and both are the lesson.

First, the block-attention mask. The sequence is [prefix | suffix]: P = 2 + 12 prefix positions (vision, state, twelve instruction tokens) then H = 8 suffix positions (the action expert). block_mask fills four blocks:

  • prefix ↔ prefix — full. The VLM fuses vision, state, and language bidirectionally (ch1.8's fusion, minus the CLS).
  • suffix → prefix — full. Each action-query token reads the whole prefix. This is the cross-attention, and it is the block we will cut.
  • suffix ↔ suffix — full. The H chunk steps coordinate among themselves.
  • prefix → suffixblocked. The prefix never reads the actions. That asymmetry is not cosmetic: it makes the prefix action-independent, so a deployment can compute it once and KV-cache it while the action expert denoises. This is exactly pi0's make_attn_mask.

Second, the expert as separate weights — the pi0 "mixture." Look at ExpertBlock. There is one attention — a single scaled-dot-product softmax over the joint sequence, under the mask. But the prefix tokens and the suffix tokens each own their Q/K/V, their output projection, their MLP, and their norms. per_tower is the whole trick in one line: run the prefix module on the first P positions, the expert module on the rest, re-join. Two parameter sets, one attention op. The action expert is a tower with its own weights, not a head bolted onto a pooled vector.

The suffix tokens themselves carry only a noised action chunk + the flow time (action_in + action_query + the time embedding). They have no other access to the world. Hold onto that — it is why the cut works. Each expert token's output goes through the ch1.5 flow head (vel_head), so the model predicts the velocity of an H-step action chunk in one pass.

Training is ordinary; the shape did the work

vla_shape.py#trainsha256:3874e29aa9…
torch.manual_seed(args.seed)   # policy init reproducible, independent of the frozen encoder abovegen.manual_seed(args.seed)     # fresh flow-noise stream for trainingpolicy = TwoTowerVLA(len(VOCAB), args.feature_dim, args.model_dim, args.layers, args.heads, STATS).to(device)optimizer = torch.optim.Adam([p for p in policy.parameters() if p.requires_grad], lr=args.lr)shuffle = torch.Generator().manual_seed(args.seed + 1)  # torch-side RNG for batch ordertrain_t = torch.from_numpy(train_idx).to(device)train_loss, step = float("nan"), 0for epoch in range(args.epochs):    epoch_loss, nb = 0.0, 0    for order in torch.randperm(len(train_idx), generator=shuffle).split(args.batch_size):        batch = train_t[order]        loss = flow_loss(policy, chunks_t[batch], cmask_t[batch], tokens_t[batch],                         image_feats[batch], states_t[batch])        optimizer.zero_grad()        loss.backward()        optimizer.step()        epoch_loss, nb = epoch_loss + loss.item(), nb + 1        if args.rerun:            rr.set_time("step", sequence=step)            rr.log("policy/loss/train", rr.Scalars([loss.item()]))        step += 1    train_loss = epoch_loss / nb    if epoch % 10 == 0 or epoch == args.epochs - 1:        print(f"epoch {epoch:3d}  flow_mse {train_loss:.5f}")

Nothing surprising here — it is ch1.5's conditional flow matching over a chunk, always trained with the full mask. Sample a time t, put the chunk on its straight noise→data line, ask the two-tower for the velocity, MSE over the valid (unpadded) chunk steps. The interesting part already happened in the model: because the expert can only reach the state through suffix→prefix, the optimizer is forced to route state through that block to fit the state-dependent action. Remember that when you read the next region.

The measurement: sever one block, watch the fit collapse

vla_shape.py#evalsha256:c804db7db0…
# The HEADLINE (byte-reproducible): the trained weights' HELD-OUT velocity fit, full mask vs the SAME# weights SEVERED, on a FIXED (t, noise) pair (paired, no rendering) — severing raises the MSE toward# the unconditional prior. The PushT ROLLOUT is the HIGHER bar and FLOORS for both masks (Wilson, ch1.6).test_t = torch.from_numpy(test_idx).to(device)eval_gen = torch.Generator().manual_seed(args.seed + 99)        # fixed (t, noise) for the paired MSEt_eval = torch.rand(len(test_idx), generator=eval_gen).to(device)noise_eval = torch.randn((len(test_idx), H, ACT_DIM), generator=eval_gen).to(device)x_t_eval = (1.0 - t_eval)[:, None, None] * noise_eval + t_eval[:, None, None] * chunks_t[test_t]target_eval, cmask_eval = chunks_t[test_t] - noise_eval, cmask_t[test_t]  @torch.no_grad()def held_out_flow_mse(cut_cross: bool) -> float:    pred = policy(tokens_t[test_t], image_feats[test_t], states_t[test_t], x_t_eval, t_eval, cut_cross)    per_step = ((pred - target_eval) ** 2).mean(-1)    return float(((per_step * cmask_eval).sum() / cmask_eval.sum()).item())  def wilson_ci(k: int, n: int) -> tuple[float, float]:  # 95% Wilson score interval (ch1.6)    if n == 0:        return (0.0, 1.0)    p, z = k / n, Z95    denom = 1.0 + z * z / n    center, half = (p + z * z / (2 * n)) / denom, (z / denom) * math.sqrt(p * (1.0 - p) / n + z * z / (4 * n * n))    return (max(0.0, center - half), min(1.0, center + half))  eval_tok = torch.from_numpy(tokenize(TEMPLATES[0], STOI)).to(device).unsqueeze(0)  @torch.no_grad()def rollout(cut_cross: bool, ep_seed: int, record: bool = False):    policy.eval()    env = PushTEnv()    obs = env.reset(ep_seed)    gen.manual_seed(ep_seed)   # seed the sampler from the episode: reproducible AND order-independent    done, info, ret, traj = False, {}, 0.0, []    while not done:        feat = encoder(torch.from_numpy(env.render_frame(IMG_HW, IMG_HW)[None]).to(device))        state = torch.from_numpy(obs[None]).to(device)        chunk = sample_chunk(policy, eval_tok, feat, state, args.flow_steps, cut_cross)[0]  # (H, 2)        for a in (chunk * act_std_t + act_mean_t).cpu().numpy():         # execute the chunk open-loop            if done:                break            if record:                px, py = env.pusher_pos                tx, ty, tyaw = env.tee_pose                traj.append([round(float(px), 4), round(float(py), 4), round(float(tx), 4),                             round(float(ty), 4), round(float(tyaw), 4)])            obs, reward, done, info = env.step(a.clip(-1.0, 1.0))            ret += reward    return bool(info["success"]), ret, traj  policy.eval()mse_full, mse_cut = held_out_flow_mse(False), held_out_flow_mse(True)      # the byte-reproducible headline# Roll out under the --break-chosen mask (full by default, severed under --break); floors either way.outs = [rollout(EVAL_CUT, 10_000 + args.seed + ep) for ep in range(args.eval_episodes)]succ = sum(s for s, _, _ in outs)ci_lo, ci_hi = wilson_ci(succ, args.eval_episodes)mean_ret = float(np.mean([r for _, r, _ in outs]))print(f"eval[{'cut' if EVAL_CUT else 'full'}] PushT: {succ}/{args.eval_episodes} = {succ / args.eval_episodes:.2f}"      f"  95% CI [{ci_lo:.2f}, {ci_hi:.2f}]  mean_return {mean_ret:.2f}")print(f"HEADLINE (held-out flow MSE): full {mse_full:.4f}  cut-cross {mse_cut:.4f}  gap {mse_cut - mse_full:+.4f}"      f" — {'routing is load-bearing' if mse_cut > mse_full else 'NO collapse — reframe!'}")

We freeze the trained weights and ask one question two ways. held_out_flow_mse(False) measures the held-out velocity fit under the full mask; held_out_flow_mse(True) measures the same weights with suffix→prefix severed — the --break cut_cross mask, applied at inference. A fixed (t, noise) pair makes it a clean paired comparison, and because it runs on cached features (no rendering) the numbers are stable to the last ulp — but we gate the direction (gap > 0), not an exact value, per the course's determinism honesty. The result, every seed:

held-out flow-MSE:  full 1.57   cut-cross 2.54   gap +0.98   (seed 0)

The gap is the headline. Deny the expert its cross-attention and it loses its only path to the state; the best it can do is predict the marginal velocity, and the held-out MSE jumps toward that unconditional prior. This is why ch1.8's --break blind did nothing while this one bites: ch1.8 zeroed only the vision, and PushT is state-solvable, so nothing moved. Here the cut severs the state — the actually load-bearing signal — because in a two-tower the state is only reachable through the mask.

The rollout is the honest counterweight. We roll the trained policy out on PushT (Wilson 95% CI, ch1.6) and it floors at zero for both masks — a from-scratch tiny two-tower on a frozen random vision backbone cannot drive PushT any more than ch1.8's could. We report it plainly and gate nothing on it.

What would make the vision load-bearing

vla_shape.py#reportsha256:4a229e995f…
metrics = {    # HEADLINE: flow_mse_gap = cut - full; > 0 == severing suffix->prefix collapses the held-out fit    "flow_mse_full": round(mse_full, 6), "flow_mse_cut": round(mse_cut, 6),    "flow_mse_gap": round(mse_cut - mse_full, 6),    # the rolled-out policy (full by default, severed under --break); PushT rollout floors either way    "reported_success_rate": round(succ / args.eval_episodes, 6), "reported_mean_return": round(mean_ret, 6),    "reported_ci_lo": round(ci_lo, 6), "reported_ci_hi": round(ci_hi, 6), "eval_cut": bool(EVAL_CUT),    "break_mode": args.break_mode or "none", "final_train_loss": round(train_loss, 6),    "epochs": args.epochs, "eval_episodes": args.eval_episodes, "horizon": H, "model_dim": args.model_dim,    "num_frames": int(N), "num_train": int(len(train_idx)), "num_test": int(len(test_idx)),    "prefix_len": int(P), "seed": args.seed, "smoke": bool(args.smoke),}(args.out / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n") # demo/vizdata.json: the MASK (full + cut) heatmaps + a recorded rollout under each (the toy toggle).viz_seed = 20_000 + args.seedf_ok, f_ret, f_traj = rollout(False, viz_seed, record=True)c_ok, c_ret, c_traj = rollout(True, viz_seed, record=True)suffix_row = policy.blocks[-1].last_attn[0, P:].mean(0).cpu().double().numpy()  # avg action-token attentionvizdata = {  # PushT rollout geometry is standard (ch5.3's toy renders it; frame rows = pusher_xy, tee_xyw)    "provenance": f"vla_shape.py seed {args.seed}, {args.device}, {'smoke' if args.smoke else 'default'}; replay geometry only",    "seed": args.seed, "prefix_len": P, "horizon": H, "world_half_extent_m": 0.45,    "labels_seq": ["vision", "state"] + [f"tok{j}" for j in range(INSTR_TOKENS)] + [f"act{j}" for j in range(H)],    "mask_full": (block_mask(False, device) == 0.0).int().cpu().tolist(),   # 1 = allowed to attend    "mask_cut": (block_mask(True, device) == 0.0).int().cpu().tolist(),     # cut drops the suffix->prefix block    "suffix_attention": [round(float(x), 6) for x in suffix_row],           # where the expert actually looks    "target": {"x": 0.0, "y": 0.0, "yaw": 0.0},    "tee": {"bar_half": [0.06, 0.015], "stem_half": [0.015, 0.045], "stem_offset_y": -0.06},    "full": {"success": f_ok, "mean_return": round(f_ret, 4), "frames": f_traj},  # both rollouts floor at free-tier    "cut": {"success": c_ok, "mean_return": round(c_ret, 4), "frames": c_traj},    "meta": {k: metrics[k] for k in ("flow_mse_full", "flow_mse_cut", "flow_mse_gap", "num_frames")},}demo_dir = Path(__file__).resolve().parent / "demo"demo_dir.mkdir(exist_ok=True)(demo_dir / "vizdata.json").write_text(json.dumps(vizdata, indent=2) + "\n")if args.rerun:    rr.log("mask/full", rr.Image((np.array(vizdata["mask_full"]) * 255).astype(np.uint8)), static=True)    rr.log("mask/cut", rr.Image((np.array(vizdata["mask_cut"]) * 255).astype(np.uint8)), static=True)    rr.log("routing/suffix_attention", rr.BarChart(np.asarray(suffix_row)))print(f"metrics: {args.out / 'metrics.json'}  |  vizdata: {demo_dir / 'vizdata.json'}")print(f"two-tower shape: prefix {P} (vision+state+{INSTR_TOKENS} tok) | suffix {H} (action expert) — "      f"cut suffix->prefix and the expert goes blind")if args.rerun:    print(f"recording: {args.out / 'vla_shape.rrd'} — open it with: rerun {args.out / 'vla_shape.rrd'}")

The report writes metrics.json (headline: flow_mse_gap) and the toy's vizdata.json — the two masks as heatmaps and a recorded rollout under each, so you can toggle the suffix→prefix cell and watch the expert go blind.

One honest thread ties the arc together. The reason the rollout floors, and the reason the cut's payload is state rather than pixels, is the frozen random encoder in the vision slot. You already built the fix: ch5.2's aligned encoder puts scene geometry into the features. Drop that into this prefix and the vision token starts carrying signal a controller can use — the cross-arc payoff. And the whole two-tower — with a pretrained SigLIP tower, a real subword tokenizer, and this exact prefix/suffix flow-expert — is pi0 / SmolVLA, the read-the-real-thing and the Scale Lab. You have now built its skeleton from scratch and measured the one thing that makes it a VLA and not a pile of tokens: the routing.

Break it

--break cut_cross severs suffix→prefix at inference — the exact ablation above, now as the policy you roll out. Predict what happens to the held-out flow-MSE before you run it (ex1), predict what happens to the rollout (ex2, the honest floor), and write the mask yourself with the classic "fully bidirectional" bug and see the check catch it (ex3).

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, ch5.4.

    Objective tested: the chapter's headline mechanism. vla_shape.py trains a two-tower VLA — a PREFIX (vision + state + instruction) and a SUFFIX action expert whose ONLY window onto the prefix is the suffix->prefix cross-attention. The run reports the trained weights' HELD-OUT velocity fit under the FULL mask and under the SAME weights with that cross-attention SEVERED (--break cut_cross's mask).

    PREDICT before you run: what happens to the held-out flow-MSE when you cut suffix->prefix?

    • A) About the same — the expert's action-query tokens already carry the state (the noised action + the flow time is enough), so the cross-attention barely matters.

    • B) It COLLAPSES (flow_mse_cut >> flow_mse_full) — the suffix tokens carry only a noised action + a clock, so the cross-attention is the expert's ONLY path to the state; deny it and the fit falls toward the unconditional prior.

    • C) It IMPROVES — dropping attention edges regularizes the expert.

    It TRAINS the two-tower (~90 s on CPU); the automated reproduce check is marked slow. The flow-MSE gap (cut - full > 0) is the seed-robust, byte- reproducible, GATED headline. Note the PushT rollout it also prints: it FLOORS for both masks — that is the higher bar and the Scale Lab, not this exercise's claim (see ex2). Estimated learner time: 15 min.

    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, ch5.4.

    Objective tested: the chapter's HONESTY bar and ch1.8's warning. ex1 showed the two-tower's ROUTING is load-bearing (cutting suffix->prefix collapses the held-out velocity fit). This exercise asks the other honest question: does the two-tower, with a CORRECT full mask, actually DRIVE PushT to success in a closed-loop rollout? The vision here is ch1.7's FROZEN RANDOM CNN — the same stand-in ch1.8 used.

    PREDICT before you run: how does the full-mask two-tower do on the PushT rollout (success rate)?

    • A) It solves PushT (high success) — the two-tower shape + action chunking is enough.
    • B) The full mask succeeds and the cut mask fails — the rollout mirrors the flow-MSE gap exactly.
    • C) It FLOORS near 0 for the full mask too — a from-scratch model on a FROZEN RANDOM vision backbone can't drive PushT (ch1.8's ceiling). The mechanism this chapter measures lives in the flow-MSE gap (ex1), NOT in a task-success %. An ALIGNED encoder (ch5.2) is what would lift the rollout.

    It TRAINS the two-tower (~90 s CPU); the reproduce check is slow AND deliberately NOT gated on a success number (the rollout is the higher bar and floors). This is the point: at free-tier, report the mechanism (flow-MSE gap), report the rollout floor HONESTLY, and name the upgrade (ch5.2 aligned encoder + a bigger tier / pretrained VLA). Estimated time: 15 min.

    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. code-completion

    Exercise 3

    SUGGESTED exercise candidate (humans promote) — code-completion, ch5.4 (FAST, self-contained).

    Objective tested: the block-attention mask that IS the two-tower architecture. Implement block_mask so the four blocks are exactly right. The sequence is [prefix (0..P-1) | suffix (P..P+H-1)]; the entry (query i, key j) is True when token i is ALLOWED to attend to token j. The four rules (pi0 / SmolVLA):

    prefix <-> prefix   ALLOWED   the VLM fuses vision + state + language (bidirectional)
    suffix -> prefix    ALLOWED   the action expert READS the whole VLM (the cross-attention) —
                                  unless cut_cross, which severs exactly this block (the --break)
    suffix <-> suffix   ALLOWED   the H chunk steps coordinate (intra-chunk)
    prefix -> suffix    BLOCKED   the prefix NEVER reads the actions, so it is action-independent and
                                  KV-CACHEABLE. Forget this and you make the mask fully bidirectional —
                                  the classic bug the check below catches.
    

    Return a boolean numpy array of shape (P+H, P+H), True = allowed. The checks in checks.py compare it to a reference AND verify it is NOT the "fully bidirectional" bug (prefix reading the suffix). This gate is fast + deterministic and runs in make check.

    Run it locally:

    pytest curriculum/phase5_practitioner/ch5.4_vla_shape/exercises/suggested/checks.py -k ex3
wall-clock · rendered from wallclock.csvone source · every tier
cpu-laptopexpected wall-clock on cpu-laptop: ~1.26 min (measured)measured
mpswall-clock on mps: not yet measuredpending
t4wall-clock on t4: not yet measuredpending
4090wall-clock on 4090: not yet measuredpending

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight fromvla_shape.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
561a4905d57d2e58bbdd797627a7e4e07045b3bf19fcbb1811c11e41b60c1a42
#data
09ea7423c39735f27044149e888cba442de66f35cb1fd7d784d6e044a20c8ca9
#model
212b2e90cc8a8d66902c493bddbfe05cb39dd4fe3699e5c81d373456c71e621c
#train
3874e29aa98b4863b9f63c5e92df0928d7fd275feab407deb19807092a3416a1
#eval
c804db7db00cb82497a4a31eec17168673af0e1b1c6236fd6a4d3826f9872daa
#report
4a229e995fe3eaf1f79ce0cdba998e87f8bc4a3daac46999fe43c7415d90a564