| """Recursive latent planner over the frozen LeWM PushT world model. |
| |
| Implements the HRM/TRM-style nested recursion of ``recursive_planner_design.pdf`` |
| section 9, placed inside an MPC rollout:: |
| |
| for k = 1 .. H: # outer - imagined environment steps |
| for j = 1 .. T: # middle - answer (action) improvement |
| for i = 1 .. n: # inner - latent reasoning refinement |
| z = f(z, h_G, h_{k-1}, y, c) |
| y = g(y, z, h_G) |
| c = consequence(M(C, psi(y)), h_G) # Change 2 |
| b_k = psi(y); h_k = M(h_{k-1}, b_k) # frozen model advances |
| |
| ``f`` and ``g`` are weight-tied across every ``i``, ``j`` and ``k`` — the |
| recursion buys depth and compute, not parameters. |
| |
| Three things in here are load-bearing and easy to get silently wrong: |
| |
| * **The gradient policy (Change 1).** Cycles ``1..T-1`` run under ``no_grad``; |
| only cycle ``T`` is differentiated, and ``(y, z)`` are detached when crossing |
| from horizon step ``k`` to ``k+1``. The chain of world-model states ``h_k`` is |
| *never* detached — that chain is the entire planning signal, and cutting it |
| leaves a loss that still falls while the controller quietly becomes greedy. |
| * **Action/frame alignment.** Block ``k`` is the block *leaving* context frame |
| ``k``. With ``N`` context frames there are ``N-1`` past blocks between them, |
| and the current frame pairs with the first block of the plan. This mirrors |
| ``lejepa_control.rollout.rollout_plan`` and ``LeWM.rollout`` exactly. |
| * **Recursion stability (Change 11).** RMSNorm on ``z`` and ``y`` before every |
| ``f``/``g``, a bounded residual gate on each update, and ``h_{k-1}``/``h_G`` |
| re-injected at *every* ``f`` application rather than only the first. |
| """ |
|
|
| import torch |
| from torch import nn |
|
|
| __all__ = [ |
| 'ActionEmbedding', |
| 'FTheta', |
| 'GTheta', |
| 'RMSNorm', |
| 'RecursivePlanner', |
| 'consequence_features', |
| 'm_step', |
| ] |
|
|
|
|
| class RMSNorm(nn.Module): |
| """Root-mean-square layer norm, no mean subtraction. |
| |
| Change 11's first guard. A weight-tied map applied ~90 times has no reason |
| to be norm-preserving; a mild 5% growth per step compounds to 80x over one |
| training step's recursion, and long before that ``f`` sees inputs outside |
| the range its weights were fit for. |
| """ |
|
|
| def __init__(self, dim, eps=1e-6): |
| super().__init__() |
| self.eps = eps |
| self.weight = nn.Parameter(torch.ones(dim)) |
|
|
| def forward(self, x): |
| scale = torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) |
| return x * scale * self.weight |
|
|
|
|
| class ActionEmbedding(nn.Module): |
| """The ``phi`` / ``psi`` pair: action block <-> answer space. |
| |
| ``phi`` encodes a real 10-dim block into the ``W``-dim answer space, ``psi`` |
| decodes an answer back to a tanh-bounded block. Pre-train the pair as a |
| plain autoencoder on dataset blocks (Change 7), then keep both trainable |
| with the round-trip anchor holding them consistent. |
| |
| Args: |
| block_dim: ``frameskip * action_dim`` (10 for PushT). |
| width: Answer-space width ``W``. |
| hidden: MLP hidden width. |
| action_dim: Native env action dim (2). |
| frameskip: Env actions per world-model transition (5). |
| action_center / action_scale: Per-dim tanh bounds expressed in the |
| *normalized* action units the world model was trained on. For raw |
| PushT actions in ``[-1, 1]`` these are ``-mean/std`` and ``1/std``. |
| """ |
|
|
| def __init__( |
| self, |
| block_dim=10, |
| width=256, |
| hidden=256, |
| action_dim=2, |
| frameskip=5, |
| action_center=0.0, |
| action_scale=1.0, |
| ): |
| super().__init__() |
| self.block_dim = block_dim |
| self.width = width |
| self.action_dim = action_dim |
| self.frameskip = frameskip |
|
|
| center = torch.as_tensor(action_center).float().expand(action_dim) |
| scale = torch.as_tensor(action_scale).float().expand(action_dim) |
| self.register_buffer('action_center', center.clone()) |
| self.register_buffer('action_scale', scale.clone()) |
|
|
| self.encode_net = nn.Sequential( |
| nn.Linear(block_dim, hidden), |
| nn.GELU(), |
| nn.Linear(hidden, width), |
| ) |
| self.decode_net = nn.Sequential( |
| RMSNorm(width), |
| nn.Linear(width, hidden), |
| nn.GELU(), |
| nn.Linear(hidden, block_dim), |
| ) |
|
|
| def encode(self, block): |
| """``phi``: ``(..., A)`` normalized block -> ``(..., W)`` answer.""" |
| return self.encode_net(block) |
|
|
| def bound(self, raw): |
| """Map pre-tanh activations to a valid normalized action block.""" |
| r = raw.unflatten(-1, (self.frameskip, self.action_dim)) |
| return (self.action_center + self.action_scale * torch.tanh(r)).flatten(-2) |
|
|
| def decode(self, y, return_raw=False): |
| """``psi``: ``(..., W)`` answer -> ``(..., A)`` bounded block. |
| |
| ``return_raw`` also yields the pre-tanh activations, which is what the |
| saturation barrier (Change 6) penalizes — the barrier has to act before |
| the tanh or it cannot reach a dimension that has already frozen. |
| """ |
| raw = self.decode_net(y) |
| block = self.bound(raw) |
| return (block, raw) if return_raw else block |
|
|
| def round_trip(self, y): |
| """``phi(psi(y))`` — the manifold anchor's prediction of ``y``.""" |
| return self.encode(self.decode(y)) |
|
|
|
|
| class _GatedUpdate(nn.Module): |
| """Shared body for ``f`` and ``g``: normalize, condition, gated residual. |
| |
| The update is ``x <- x + sigmoid(eta) * Delta(...)`` with ``Delta``'s output |
| layer initialized small, so the recursion starts near-identity and cannot |
| destroy a good answer in early training (Change 11's second guard). |
| """ |
|
|
| def __init__(self, width, cond_dim, hidden, gate_init=0.0, out_std=0.01): |
| super().__init__() |
| self.norm_state = RMSNorm(width) |
| self.norm_other = RMSNorm(width) |
| self.cond_proj = nn.Linear(cond_dim, width) |
| self.net = nn.Sequential( |
| nn.Linear(3 * width, hidden), |
| nn.GELU(), |
| nn.Linear(hidden, hidden), |
| nn.GELU(), |
| ) |
| self.out = nn.Linear(hidden, width) |
| nn.init.normal_(self.out.weight, std=out_std) |
| nn.init.zeros_(self.out.bias) |
| self.gate = nn.Parameter(torch.tensor(float(gate_init))) |
|
|
| |
| |
| self.grad_calls = 0 |
|
|
| def forward(self, state, other, cond): |
| if torch.is_grad_enabled(): |
| self.grad_calls += 1 |
| x = torch.cat( |
| [ |
| self.norm_state(state), |
| self.norm_other(other), |
| self.cond_proj(cond), |
| ], |
| dim=-1, |
| ) |
| return state + torch.sigmoid(self.gate) * self.out(self.net(x)) |
|
|
| def gate_value(self): |
| with torch.no_grad(): |
| return torch.sigmoid(self.gate).item() |
|
|
|
|
| class FTheta(nn.Module): |
| """Inner-loop latent reasoning update ``z <- f(z, h_prev, h_G, y, c)``. |
| |
| Conditioning is ``[h_{k-1}, h_G, c]`` where ``c`` is the consequence |
| feature from Change 2 — ``[h_hat, h_hat - h_G, ||h_hat - h_G||^2 / D]``. |
| All of it is re-supplied at every application, not just the first, so the |
| recursion cannot drift away from the question it was asked. |
| """ |
|
|
| def __init__(self, width=256, latent_dim=192, hidden=512, gate_init=0.0): |
| super().__init__() |
| self.latent_dim = latent_dim |
| |
| cond_dim = 4 * latent_dim + 1 |
| self.body = _GatedUpdate(width, cond_dim, hidden, gate_init) |
|
|
| def forward(self, z, h_prev, h_goal, y, consequence): |
| cond = torch.cat([h_prev, h_goal, consequence], dim=-1) |
| return self.body(z, y, cond) |
|
|
|
|
| class GTheta(nn.Module): |
| """Middle-loop answer update ``y <- g(y, z, h_G)``. |
| |
| ``g`` deliberately does not see ``h_{k-1}``: the current state reaches the |
| answer only through ``z``. That is TRM's convention and it is what makes |
| ``z`` a scratchpad rather than a redundant conditioning path. |
| """ |
|
|
| def __init__(self, width=256, latent_dim=192, hidden=512, gate_init=0.0): |
| super().__init__() |
| self.body = _GatedUpdate(width, latent_dim, hidden, gate_init) |
|
|
| def forward(self, y, z, h_goal): |
| return self.body(y, z, h_goal) |
|
|
|
|
| def consequence_features(h_hat, h_goal): |
| """Change 2's feedback vector: ``[h_hat, h_hat - h_G, ||.||^2 / D]``. |
| |
| Gives ``f`` an error vector in the same space it is trying to shrink, which |
| is what turns the middle loop from an open-loop guesser into a corrector. |
| """ |
| delta = h_hat - h_goal |
| dist = delta.pow(2).mean(dim=-1, keepdim=True) |
| return torch.cat([h_hat, delta, dist], dim=-1) |
|
|
|
|
| def m_step(model, frames, blocks, num_context): |
| """One frozen-world-model transition, matching ``rollout_plan``'s windows. |
| |
| At rollout step ``t`` the predictor consumes frames ``[t, t+N)`` and the |
| action blocks leaving those same frames. Because ``frames`` has ``N+t`` |
| entries and ``blocks`` has ``N+t`` entries once the step's block is |
| appended, both are just the trailing ``N``. |
| |
| Args: |
| model: The frozen ``LeWM``. Gradients flow *through* it, never into it. |
| frames: List of ``(B, D)`` latents, oldest first, length ``N + t``. |
| blocks: List of ``(B, A)`` normalized blocks, length ``N + t``, where |
| entry ``i`` is the block leaving ``frames[i]``. |
| num_context: ``N``. |
| |
| Returns: |
| ``(B, D)`` the predicted next latent. |
| """ |
| n = num_context |
| assert len(blocks) == len(frames), ( |
| f'alignment: {len(frames)} frames but {len(blocks)} blocks; block i ' |
| f'must be the block leaving frame i' |
| ) |
| emb_win = torch.stack(frames[-n:], dim=1) |
| act_win = model.action_encoder(torch.stack(blocks[-n:], dim=1)) |
| return model.predict(emb_win, act_win)[:, -1] |
|
|
|
|
| class RecursivePlanner(nn.Module): |
| """Three-loop driver: inner ``n``, middle ``T``, outer ``H``. |
| |
| Args: |
| latent_dim: World-model latent width ``D`` (192). |
| num_context: Context frames the predictor consumes ``N`` (3). |
| action_dim / frameskip: Native action dim and env steps per transition. |
| width: Recursion width ``W`` (256). |
| hidden: MLP hidden width inside ``f`` and ``g``. |
| inner: ``n``, latent refinements per cycle (6). |
| cycles: ``T``, answer revisions per horizon step (3; 1 during stage A). |
| horizon: ``H``, imagined lookahead steps (3 -> 5 curriculum). |
| use_feedback: Change 2. When off there is no per-cycle lookahead, so |
| no per-cycle distances are produced and deep supervision has |
| nothing to score. |
| warm_start: Change 9. Start ``y`` from the last executed block and |
| carry the answer across horizon steps instead of resetting it. |
| lambda_z: Change 9's ``lambda_z``. ``0`` means a fresh scratchpad |
| ``z0`` at every horizon step, which is the documented default; |
| larger values blend in the detached carried state. |
| learn_lambda_z: Make ``lambda_z`` a learned scalar. |
| action_center / action_scale: tanh bounds in normalized action units. |
| """ |
|
|
| def __init__( |
| self, |
| latent_dim=192, |
| num_context=3, |
| action_dim=2, |
| frameskip=5, |
| width=256, |
| hidden=512, |
| inner=6, |
| cycles=3, |
| horizon=5, |
| use_feedback=True, |
| warm_start=True, |
| lambda_z=0.0, |
| learn_lambda_z=False, |
| gate_init=0.0, |
| action_center=0.0, |
| action_scale=1.0, |
| detach_schedule='last-cycle', |
| ): |
| super().__init__() |
| assert detach_schedule in ('last-cycle', 'one-step', 'full') |
| self.detach_schedule = detach_schedule |
| self.latent_dim = latent_dim |
| self.num_context = num_context |
| self.action_dim = action_dim |
| self.frameskip = frameskip |
| self.block_dim = frameskip * action_dim |
| self.width = width |
| self.inner = inner |
| self.cycles = cycles |
| self.horizon = horizon |
| self.use_feedback = use_feedback |
| self.warm_start = warm_start |
|
|
| self.f = FTheta(width, latent_dim, hidden, gate_init) |
| self.g = GTheta(width, latent_dim, hidden, gate_init) |
| self.action_embed = ActionEmbedding( |
| block_dim=self.block_dim, |
| width=width, |
| hidden=hidden // 2, |
| action_dim=action_dim, |
| frameskip=frameskip, |
| action_center=action_center, |
| action_scale=action_scale, |
| ) |
|
|
| |
| self.z0 = nn.Parameter(torch.randn(1, width) * 0.02) |
| self.y0 = nn.Parameter(torch.randn(1, width) * 0.02) |
|
|
| self._capture = False |
| self.taps = {'z_norms': [], 'grad_first': [], 'grad_last': []} |
|
|
| if learn_lambda_z: |
| self.lambda_z = nn.Parameter(torch.tensor(float(lambda_z))) |
| else: |
| self.register_buffer( |
| 'lambda_z', torch.tensor(float(lambda_z)), persistent=True |
| ) |
|
|
| |
|
|
| def reset_call_counts(self): |
| self.f.body.grad_calls = 0 |
| self.g.body.grad_calls = 0 |
|
|
| def start_capture(self): |
| """Begin collecting the section-11 recursion diagnostics. |
| |
| Populates ``self.taps`` during the next forward/backward with: |
| ``z_norms`` (should be flat across ``i`` — RMSNorm makes it so) and, |
| after ``backward()``, ``grad_first`` / ``grad_last``, the gradient |
| norms at ``f``'s first and last application inside the gradient cycle. |
| A first/last ratio outside ~10x means the backprop depth is larger |
| than the detach schedule intends. |
| """ |
| self.taps = {'z_norms': [], 'grad_first': [], 'grad_last': []} |
| self._capture = True |
|
|
| def stop_capture(self): |
| self._capture = False |
|
|
| def _tap_grad(self, tensor, key): |
| if tensor.requires_grad: |
| tensor.register_hook( |
| lambda g, k=key: self.taps[k].append(g.norm().item()) |
| ) |
|
|
| @property |
| def grad_calls(self): |
| """``f`` and ``g`` applications made with grad enabled. |
| |
| Under the Change-1 schedule this is ``H * (n + 1)`` — one gradient |
| cycle per horizon step. Without it, it is ``H * T * (n + 1)``, which |
| is the failure the activation-count test exists to catch. |
| """ |
| return self.f.body.grad_calls + self.g.body.grad_calls |
|
|
| def gate_values(self): |
| return {'f': self.f.body.gate_value(), 'g': self.g.body.gate_value()} |
|
|
| |
|
|
| def _initial_answer(self, past_actions, batch): |
| if self.warm_start: |
| |
| |
| return self.action_embed.encode(past_actions[:, -1]) |
| return self.y0.expand(batch, -1) |
|
|
| def forward( |
| self, |
| model, |
| ctx_emb, |
| past_actions, |
| goal_emb, |
| horizon=None, |
| cycles=None, |
| inner=None, |
| ): |
| """Run one imagined rollout and return everything the loss needs. |
| |
| Args: |
| model: Frozen ``LeWM``. |
| ctx_emb: ``(B, N, D)`` context latents. |
| past_actions: ``(B, N-1, A)`` normalized executed blocks. |
| goal_emb: ``(B, D)`` goal latent. |
| horizon / cycles / inner: Per-call overrides of ``H`` / ``T`` / |
| ``n``, used by the curriculum and the anytime-inference sweep. |
| |
| Returns: |
| Dict with ``distances`` ``(B, H)``, ``cycle_distances`` |
| ``(B, H, T)`` or ``None``, ``blocks`` ``(B, H, A)``, ``raw`` |
| ``(B, H, A)`` pre-tanh, ``contexts`` ``(B, H, N, D)``, ``answers`` |
| ``(B, H, W)`` and ``frames`` ``(B, N+H, D)``. |
| """ |
| H = horizon or self.horizon |
| T = cycles or self.cycles |
| n = inner or self.inner |
| N = self.num_context |
| B = ctx_emb.size(0) |
|
|
| assert ctx_emb.size(1) == N, f'expected {N} context frames' |
| assert past_actions.size(1) == N - 1, ( |
| f'expected {N - 1} past blocks between {N} context frames, got ' |
| f'{past_actions.size(1)}' |
| ) |
|
|
| frames = list(ctx_emb.unbind(dim=1)) |
| blocks = list(past_actions.unbind(dim=1)) |
|
|
| y = self._initial_answer(past_actions, B) |
| z = self.z0.expand(B, -1) |
| |
| zero_c = ctx_emb.new_zeros(B, 2 * self.latent_dim + 1) |
|
|
| distances, cycle_d = [], [] |
| out_blocks, out_raw, out_ctx, out_y = [], [], [], [] |
|
|
| for _ in range(H): |
| |
| h_prev = frames[-1] |
| context = torch.stack(frames[-N:], dim=1) |
|
|
| |
| |
| y = y.detach() |
| |
| |
| z = self.z0.expand(B, -1) + self.lambda_z * z.detach() |
|
|
| c = zero_c |
| step_cycles = [] |
|
|
| |
| |
| |
| early_grad = self.detach_schedule == 'full' |
| with torch.set_grad_enabled( |
| early_grad and torch.is_grad_enabled() |
| ): |
| for _ in range(T - 1): |
| for _ in range(n): |
| z = self.f(z, h_prev, goal_emb, y, c) |
| y = self.g(y, z, goal_emb) |
| if self.use_feedback: |
| |
| b_hat = self.action_embed.decode(y) |
| h_hat = m_step(model, frames, blocks + [b_hat], N) |
| c = consequence_features(h_hat, goal_emb) |
| step_cycles.append( |
| (h_hat - goal_emb).pow(2).mean(dim=-1) |
| ) |
| if not early_grad: |
| |
| |
| step_cycles = [d.detach() for d in step_cycles] |
|
|
| |
| |
| |
| head = n - 1 if self.detach_schedule == 'one-step' else 0 |
| with torch.set_grad_enabled(False): |
| for _ in range(head): |
| z = self.f(z, h_prev, goal_emb, y, c) |
| for i in range(head, n): |
| z = self.f(z, h_prev, goal_emb, y, c) |
| if self._capture: |
| self.taps['z_norms'].append(z.detach().norm(dim=-1).mean().item()) |
| if i == head: |
| self._tap_grad(z, 'grad_first') |
| if i == n - 1: |
| self._tap_grad(z, 'grad_last') |
| y = self.g(y, z, goal_emb) |
|
|
| |
| block, raw = self.action_embed.decode(y, return_raw=True) |
| h = m_step(model, frames, blocks + [block], N) |
| frames.append(h) |
| blocks.append(block) |
|
|
| d = (h - goal_emb).pow(2).mean(dim=-1) |
| distances.append(d) |
| step_cycles.append(d) |
| cycle_d.append(torch.stack(step_cycles, dim=-1)) |
|
|
| out_blocks.append(block) |
| out_raw.append(raw) |
| out_ctx.append(context) |
| out_y.append(y) |
|
|
| return { |
| |
| |
| |
| |
| |
| 'distance_seq': distances, |
| 'block_seq': out_blocks, |
| 'distances': torch.stack(distances, dim=1), |
| 'cycle_distances': ( |
| torch.stack(cycle_d, dim=1) if self.use_feedback else None |
| ), |
| 'blocks': torch.stack(out_blocks, dim=1), |
| 'raw': torch.stack(out_raw, dim=1), |
| 'contexts': torch.stack(out_ctx, dim=1), |
| 'answers': torch.stack(out_y, dim=1), |
| 'frames': torch.stack(frames, dim=1), |
| } |
|
|