Building a First World Model › Implementation: The Full Loop

Michael BrenndoerferJune 1, 202627 min read
World Models HandbookAll chapters
Part 1 of 12The World-Model Idea
Chapter 1Building a First World Model › Implementation: The Full Loop
Reading section 6 of 9 · Implementation: The Full Loop · 27 minSection 6 of 9 · 27 min

Book contents

Section 6 of 9 · 27 min read

Building a First World Model › Implementation: The Full LoopOne section · Sections in this chapter6 / 9 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Now we assemble everything. The code below is organized as the complete pipeline: environment, data collection, model, training, diagnostics, planner, and closed-loop evaluation. Each block is small and self-contained, and blocks build on earlier ones.

As you read the code, keep the conceptual map from the previous sections in view. The environment implements the physics we are pretending not to know. Data collection stands in for the experience an embodied agent would accumulate. The model class implements the four-component anatomy, with the filtering and imagination steps exposed as separate methods so their different roles remain visible. Training implements the alternating objective. Diagnostics implement the two evaluation protocols. The planner implements CEM over imagined returns. And the final loop wires filtering, planning, and acting together into the closed agent. Nothing in the code introduces a concept the prose has not already prepared you for.

Collecting Experience

First the environment. The true state holds position and velocity; the observation exposes position plus Gaussian noise; the reward is negative distance to goal minus a small force penalty, evaluated at the resulting state so that it is exactly predictable from the predicted next latent and the action taken.

In[5]:
Code
import numpy as np


class PointMassEnv:
    """A 2D point mass with friction. Observations reveal position only."""

    def __init__(self, dt=0.1, friction=0.6, goal=(1.0, 1.0), obs_noise=0.02):
        self.dt = dt
        self.friction = friction
        self.goal = np.array(goal)
        self.obs_noise = obs_noise

    def reset(self, rng):
        self.state = rng.uniform(-1.0, 1.0, size=4)  # [x, y, vx, vy]
        return self._observe(rng)

    def _observe(self, rng):
        return self.state[:2] + rng.normal(0.0, self.obs_noise, size=2)

    def step(self, action, rng):
        force = np.clip(action, -1.0, 1.0)
        accel = force - self.friction * self.state[2:]
        self.state[2:] = self.state[2:] + self.dt * accel
        self.state[:2] = self.state[:2] + self.dt * self.state[2:]
        obs = self._observe(rng)
        reward = -np.linalg.norm(self.state[:2] - self.goal) - 0.1 * np.sum(
            force**2
        )
        return obs, reward

Two details in this implementation repay attention. The semi-implicit Euler integration, updating velocity first and then position with the new velocity, is more stable than fully explicit Euler for damped systems, which keeps our simulated physics well-behaved over long episodes. And the reward's dependence on the post-step state means that when the model predicts the next latent correctly, the reward head has all the information it needs, so reward-prediction error becomes a clean proxy for transition-model error. Had we evaluated the reward at the pre-step state instead, part of the reward signal would be predictable without any dynamics knowledge at all, diluting the training signal.

Next we collect a dataset with a uniformly random policy. Random exploration wanders aimlessly, which is realistic: most practical world models are trained on data gathered by policies far less competent than the one they eventually enable.

In[6]:
Code
SEED = 7
rng = np.random.default_rng(SEED)
env = PointMassEnv()

N_EPISODES, EP_LEN = 150, 100
ACT_DIM, OBS_DIM = 2, 2

episodes = []
for _ in range(N_EPISODES):
    obs = env.reset(rng)
    obs_list, act_list, rew_list = [obs], [], []
    for _ in range(EP_LEN):
        action = rng.uniform(-1.0, 1.0, size=ACT_DIM)
        obs, reward = env.step(action, rng)
        obs_list.append(obs)
        act_list.append(action)
        rew_list.append(reward)
    episodes.append(
        {
            "obs": np.array(obs_list),  # [T+1, 2]
            "act": np.array(act_list),  # [T, 2]
            "rew": np.array(rew_list),  # [T,]
        }
    )
Out[7]:
Console
Collected 150 episodes of 100 steps
Random-policy return: mean -192.4, range [-455.7, -43.7]

The random policy scores terribly, as expected: it never approaches the goal on purpose. Improving on this number is the concrete target for the rest of the chapter.

Note also what the dataset contains: fifteen thousand transitions, each holding two positions, two force values, and one reward. The total information content is a few hundred kilobytes. That a competent controller can be extracted from so little is a consequence of the environment's simplicity, and it is worth remembering when we scale up: the fundamental currency of world-model learning is transitions, and the cost of acquiring informative ones dominates most real applications.

Out[8]:
Visualization
Several drifting trajectories in a square arena with a star marking the goal.
Random-policy trajectories in the arena. Without a model of consequences, the puck drifts with no preference for the goal region.

The figure shows eight sample trajectories wandering without any preference for the goal region. Look closely and you can see the effect of friction: paths curve and slow, occasionally lingering in one area before a burst of force sends the puck elsewhere. This aimlessness is not a defect of the data but a feature of the setup. Because the random policy covers the arena broadly, the model gets to see the dynamics in many regions of state space, which is exactly what a dynamics learner needs, even though the data contains almost no information about the task.

Defining the Model

The model class mirrors the four-component anatomy exactly. One design detail worth noticing: filter_step accepts a precomputed embedding, so the same method serves both the training loop (which embeds whole windows at once) and the closed-loop agent (which embeds one observation at a time).

In[9]:
Code
import torch
import torch.nn as nn


class LatentDynamicsModel(nn.Module):
    def __init__(
        self, obs_dim=OBS_DIM, act_dim=ACT_DIM, hidden=64, recurrent=True
    ):
        super().__init__()
        self.recurrent = recurrent
        self.encoder = nn.Sequential(
            nn.Linear(obs_dim, 64),
            nn.ReLU(),
            nn.Linear(64, hidden),
        )
        self.transition = nn.Sequential(
            nn.Linear(hidden + act_dim, 64),
            nn.ReLU(),
            nn.Linear(64, hidden),
        )
        self.gru = nn.GRUCell(hidden + act_dim, hidden) if recurrent else None
        self.decoder = nn.Sequential(
            nn.Linear(hidden, 64),
            nn.ReLU(),
            nn.Linear(64, obs_dim),
        )
        self.reward_head = nn.Sequential(
            nn.Linear(hidden + act_dim, 64),
            nn.ReLU(),
            nn.Linear(64, 1),
        )

    def filter_step(self, h, emb, prev_action):
        """Posterior update: fold a fresh observation embedding into the latent."""
        if self.gru is None:
            return emb
        return self.gru(torch.cat([emb, prev_action], dim=-1), h)

    def imagine_step(self, h, action):
        """Prior step: predict the next latent from the current one and an action."""
        return self.transition(torch.cat([h, action], dim=-1))

Setting recurrent=False removes the GRU, leaving a memoryless model whose latent is just the current observation's embedding. This is our experimental control: identical data, identical loss, identical planner, with the memory removed.

The ablation is deliberately surgical. By removing only the GRU and rerouting filter_step to pass the raw embedding through, we ensure that any performance difference between the two models is attributable to memory and nothing else. Architectural capacity differs slightly, since the recurrent model has additional parameters, but the difference is minor relative to the shared components, and the direction of the result will confirm that capacity is not the operative variable. This style of minimal paired comparison, changing one capability while freezing everything else, is the workhorse of empirical analysis in this field.

Training

Training windows are sampled uniformly from the collected episodes. Within each window we alternate prior predictions (which generate losses) and filtering updates (which consume fresh observations), exactly as the objective specifies.

In[10]:
Code
WINDOW = 25  # transitions per training window


def sample_batch(batch_size, rng):
    idx_ep = rng.integers(0, len(episodes), size=batch_size)
    idx_t = rng.integers(0, EP_LEN - WINDOW + 1, size=batch_size)
    obs_b, act_b, rew_b = [], [], []
    for ie, it in zip(idx_ep, idx_t):
        ep = episodes[ie]
        obs_b.append(ep["obs"][it : it + WINDOW + 1])
        act_b.append(ep["act"][it : it + WINDOW])
        rew_b.append(ep["rew"][it : it + WINDOW])
    to_tensor = lambda x: torch.tensor(np.array(x), dtype=torch.float32)
    return to_tensor(obs_b), to_tensor(act_b), to_tensor(rew_b)


def compute_losses(model, obs, act, rew):
    emb = model.encoder(obs)  # [B, WINDOW+1, H]
    h = emb[:, 0]
    recon_total, reward_total = 0.0, 0.0
    for t in range(act.shape[1]):
        h_pred = model.imagine_step(h, act[:, t])
        obs_pred = model.decoder(h_pred)
        rew_in = torch.cat([h_pred, act[:, t]], dim=-1)
        rew_pred = model.reward_head(rew_in).squeeze(-1)
        recon_total = (
            recon_total + ((obs_pred - obs[:, t + 1]) ** 2).sum(dim=-1).mean()
        )
        reward_total = reward_total + ((rew_pred - rew[:, t]) ** 2).mean()
        h = model.filter_step(h, emb[:, t + 1], act[:, t])
    return recon_total / act.shape[1], reward_total / act.shape[1]


def train_model(recurrent, epochs=30, lr=3e-3, batch_size=256, seed=0):
    torch.manual_seed(seed)
    model = LatentDynamicsModel(recurrent=recurrent)
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    rng_np = np.random.default_rng(seed)
    history = []
    for _ in range(epochs):
        sums, n = {"recon": 0.0, "reward": 0.0}, 0
        for _ in range(len(episodes) * EP_LEN // batch_size):
            obs, act, rew = sample_batch(batch_size, rng_np)
            recon_loss, reward_loss = compute_losses(model, obs, act, rew)
            loss = recon_loss + reward_loss
            opt.zero_grad()
            loss.backward()
            opt.step()
            b = obs.shape[0]
            sums["recon"] += recon_loss.item() * b
            sums["reward"] += reward_loss.item() * b
            n += b
        history.append({k: v / n for k, v in sums.items()})
    return model, history

Trace the flow inside compute_losses once, slowly, because it is the objective made executable. Embeddings for the whole window are computed in one batched pass, an efficiency choice that exploits the encoder's feedforward structure. The initial latent is simply the first embedding, since there is no prior history to filter. Then, for each step: imagine forward with the action, decode and score the prediction against the actual next observation, score the reward head against the actual received reward, and only afterward fold the actual next embedding into the latent via the filter step. The ordering is the whole point. Prediction always precedes correction, so the loss measures the model's foresight, not its hindsight.

We train both variants and compare their final losses.

In[11]:
Code
model_rnn, hist_rnn = train_model(recurrent=True, seed=1)
model_ff, hist_ff = train_model(recurrent=False, seed=1)
Out[12]:
Console
   recurrent: recon 0.00250   reward 0.00088
 feedforward: recon 0.00307   reward 0.00112

Both models drive their losses down, but look closely at the reconstruction values: the recurrent model reaches a markedly lower floor. That gap is the hidden velocity making itself visible. A memoryless encoder looking at a single noisy position simply cannot know how fast the puck moves, so its best possible one-step prediction is systematically wrong whenever the puck is in motion. The recurrent model infers velocity from the recent observation history and closes most of that gap.

It is worth articulating why the gap cannot be closed by the feedforward model no matter how long it trains. The observation at time tt is a noisy draw centered on the true position; conditioned on that single draw, the velocity is statistically independent of the observation, so no function of the observation alone can predict the next position better than the conditional mean allows. The recurrent model escapes this ceiling because its input is not one observation but a trajectory of them, and consecutive noisy positions jointly constrain velocity far more tightly than any single one does. The loss floor difference is thus not a training artifact but an information-theoretic necessity, which is what makes this comparison a genuine test of memory rather than of optimization luck.

Out[14]:
Visualization
Log-scale loss curves for reconstruction and reward losses over thirty epochs.
Training losses on a log scale. The recurrent model achieves lower reconstruction error because its latent state can represent velocity, which a memoryless encoder cannot infer from a single position observation.

On the log-scale plot, notice that the reward losses of the two models sit closer together than their reconstruction losses. This is partly because the reward is dominated by distance to goal, which depends mostly on position, a quantity both models represent well. The residual advantage of the recurrent model on reward prediction comes from situations where velocity matters for the next distance, for example when the puck is moving rapidly toward or away from the goal. Even in this tiny problem, the pattern is instructive: task-relevant losses can mask representational deficits that reconstruction exposes clearly, which is one reason to monitor multiple losses rather than a single aggregate.

Measuring Rollout Quality

Now the diagnostic that separates genuine simulators from lucky one-step predictors. On held-out episodes we track prediction error under two protocols: teacher-forced, where the latent is corrected with the true observation every step, and recursive, where the model consumes only its own predictions.

In[15]:
Code
rng_eval = np.random.default_rng(123)
eval_eps = []
for _ in range(20):
    obs = env.reset(rng_eval)
    obs_list, act_list = [obs], []
    for _ in range(EP_LEN):
        a = rng_eval.uniform(-1.0, 1.0, size=ACT_DIM)
        obs, _ = env.step(a, rng_eval)
        obs_list.append(obs)
        act_list.append(a)
    eval_eps.append({"obs": np.array(obs_list), "act": np.array(act_list)})

MAX_HORIZON = 50


@torch.no_grad()
def rollout_errors(model, eps):
    one_step = np.zeros(MAX_HORIZON)
    recursive = np.zeros(MAX_HORIZON)
    for ep in eps:
        obs = torch.tensor(ep["obs"], dtype=torch.float32)
        act = torch.tensor(ep["act"], dtype=torch.float32)
        emb = model.encoder(obs)
        h_true, h_free = emb[0], emb[0]
        for t in range(MAX_HORIZON):
            p_true = model.imagine_step(h_true, act[t])
            p_free = model.imagine_step(h_free, act[t])
            one_step[t] += (model.decoder(p_true) - obs[t + 1]).norm().item()
            recursive[t] += (model.decoder(p_free) - obs[t + 1]).norm().item()
            h_true = model.filter_step(h_true, emb[t + 1], act[t])
            h_free = p_free  # no correction: pure imagination
    return one_step / len(eps), recursive / len(eps)


err_rnn_1step, err_rnn_rec = rollout_errors(model_rnn, eval_eps)
_, err_ff_rec = rollout_errors(model_ff, eval_eps)

The evaluation episodes are freshly generated with a different seed from training, so none of the measured errors reflect memorization. Inside rollout_errors, two latents evolve in parallel from the same starting point: h_true receives a corrective filter update at every step, implementing the teacher-forced protocol, while h_free is overwritten with its own prediction, implementing the recursive protocol. Both are scored against the same ground-truth observations, so the only difference between the two curves is whether reality was allowed to intervene. That isolation is what makes the comparison interpretable.

Out[16]:
Console
horizon  1:  one-step 0.073   recursive RNN 0.073   recursive FF 0.071
horizon  5:  one-step 0.073   recursive RNN 0.935   recursive FF 0.862
horizon 10:  one-step 0.046   recursive RNN 1.006   recursive FF 0.886
horizon 25:  one-step 0.066   recursive RNN 1.261   recursive FF 1.146
horizon 50:  one-step 0.069   recursive RNN 1.563   recursive FF 1.461
Out[18]:
Visualization
Line chart of prediction error rising with horizon, recursive curves above one-step curve.
Position prediction error versus horizon. Teacher-forced error remains low because reality corrects the latent each step, while both recursive rollouts jump sharply and then drift. In this run the simpler feedforward rollout is slightly less unstable, showing that a richer latent does not by itself guarantee stable imagination.

Three phenomena are visible, and each teaches something general.

First, the teacher-forced curve stays essentially flat. Correcting the latent with real observations every step keeps errors from accumulating, confirming that the one-step transition model is genuinely accurate.

Second, the recurrent recursive curve jumps sharply and then climbs steadily. Nothing went wrong in execution; this is the train-test mismatch discussed above, visible in miniature. The model was optimized with corrected latent states, so it has no incentive to remain stable when its own predictions are fed back recursively.

Third, the feedforward recursive curve is slightly below the recurrent one in this particular run. That result is not evidence that memory is useless. It shows that representational richness and rollout stability are different properties: the recurrent latent can encode velocity, as the probe below demonstrates, while its learned transition can still amplify off-manifold errors more strongly than the simpler feedforward system. A fair world-model evaluation must measure both what a latent contains and how its dynamics behave under self-feedback.

A fourth observation hides in the shape of the curves rather than their levels. The recursive curves rise steeply at first and then flatten somewhat, reflecting the fact that the arena is bounded: once the imagined puck has drifted far from the truth, both trajectories wander within the same confined box, and the error saturates near the scale of the arena itself. In unbounded systems, compounding error grows without limit; in bounded ones it plateaus at a level that can still be useless for planning, since a prediction that is wrong by half the arena's width cannot distinguish good actions from bad ones. Always read error curves with the geometry of the domain in mind.

To verify directly that the recurrent latent really does encode the hidden velocity, we can run a simple linear probe. We generate fresh episodes where we also record the true velocities, filter the latent at every step, and fit a linear map from latent to velocity on half the data. If the GRU integrates observation history into an implicit velocity estimate, even a plain linear readout should recover it well; the memoryless model, whose latent sees only one noisy position, should fail.

In[19]:
Code
rng_probe = np.random.default_rng(321)
probe_eps = []
for _ in range(20):
    obs = env.reset(rng_probe)
    vel_list, obs_list, act_list = [env.state[2:].copy()], [obs], []
    for _ in range(EP_LEN):
        a = rng_probe.uniform(-1.0, 1.0, size=ACT_DIM)
        obs, _ = env.step(a, rng_probe)
        obs_list.append(obs)
        act_list.append(a)
        vel_list.append(env.state[2:].copy())
    probe_eps.append(
        {
            "obs": np.array(obs_list),
            "act": np.array(act_list),
            "vel": np.array(vel_list),  # [T+1, 2], ground-truth velocities
        }
    )


@torch.no_grad()
def collect_latents(model, eps):
    hs, vels = [], []
    for ep in eps:
        obs = torch.tensor(ep["obs"], dtype=torch.float32)
        act = torch.tensor(ep["act"], dtype=torch.float32)
        emb = model.encoder(obs)
        h = emb[0]
        hs.append(h.numpy())
        vels.append(ep["vel"][0])
        for t in range(len(act)):
            h = model.filter_step(h, emb[t + 1], act[t])
            hs.append(h.numpy())
            vels.append(ep["vel"][t + 1])
    return np.array(hs), np.array(vels)


H_rnn, V_true = collect_latents(model_rnn, probe_eps)
H_ff, _ = collect_latents(model_ff, probe_eps)

n_train = len(H_rnn) // 2
W_rnn, *_ = np.linalg.lstsq(H_rnn[:n_train], V_true[:n_train], rcond=None)
W_ff, *_ = np.linalg.lstsq(H_ff[:n_train], V_true[:n_train], rcond=None)

pred_rnn = H_rnn[n_train:] @ W_rnn
pred_ff = H_ff[n_train:] @ W_ff


def r2(y_true, y_pred):
    ss_res = ((y_true - y_pred) ** 2).sum()
    ss_tot = ((y_true - y_true.mean(axis=0)) ** 2).sum()
    return 1.0 - ss_res / ss_tot


print(
    f"velocity probe R^2:  recurrent {r2(V_true[n_train:], pred_rnn):.3f}   "
    f"feedforward {r2(V_true[n_train:], pred_ff):.3f}"
)
Out[20]:
Visualization
Scatter plots of probe-predicted versus true velocity for recurrent and feedforward models.
Linear probe of hidden velocity from the latent state, evaluated on held-out episodes. The recurrent latent supports an accurate linear readout of velocity; the memoryless latent, built from a single noisy position, does not.

The probe clarifies the representation story from the inside. A single linear layer fitted to the recurrent latents recovers both velocity components with high accuracy on unseen episodes, meaning the GRU's belief state dedicates linearly readable structure to the hidden variable. The feedforward latents, by contrast, support almost no velocity readout: their information content is exhausted by the current noisy position. This is direct evidence that the recurrent model remembers useful hidden state, while the rollout diagnostic reminds us that possessing that information does not automatically produce stable long-horizon predictions.

Imagining a Trajectory

Before trusting the model with control, let us look at what an open-loop rollout actually looks like in observation space. We take one held-out episode, filter the latent at the first step, then replay the episode's real actions through pure imagination and decode positions throughout.

In[21]:
Code
@torch.no_grad()
def open_loop_rollout(model, ep):
    obs = torch.tensor(ep["obs"], dtype=torch.float32)
    act = torch.tensor(ep["act"], dtype=torch.float32)
    h = model.encoder(obs[0:1])
    preds = []
    for t in range(len(act)):
        h = model.imagine_step(h, act[t : t + 1])
        preds.append(model.decoder(h)[0])
    return torch.stack(preds).numpy()


pred_traj = open_loop_rollout(model_rnn, eval_eps[0])
true_traj = eval_eps[0]["obs"][1:]
Out[22]:
Visualization
Predicted dashed path and solid true path over the first fifteen open-loop steps, beginning nearby and separating as errors accumulate.
The first fifteen steps of an open-loop imagined trajectory (dashed) and the true trajectory (solid) under the same action sequence. The short view exposes the planning-relevant window and the onset of compounding error.

The shortened view is the honest face of this learned world model. Even inside the planning-relevant window, the imagined and true paths begin to separate. Receding-horizon control limits the damage by executing only the first proposed action and then filtering a real observation before planning again, but replanning does not make an inaccurate model accurate. It merely prevents one long hallucinated rollout from controlling the whole episode.

Look at the divergence pattern rather than just its final magnitude. The imagined path remains smooth and physically plausible even as it loses alignment with the realized path. The model's failure is therefore one of calibration and accumulated state error rather than a sequence of impossible jumps. Such a model may still rank nearby action sequences usefully over short horizons, but the rollout-error plot warns us not to equate plausibility with accuracy.

The Planner

The planner is a compact, batched CEM. All candidate sequences in a population are rolled out simultaneously through the transition model, which keeps the per-step planning cost negligible.

In[23]:
Code
PLAN_HORIZON, POP, ELITES, ITERS = 10, 100, 25, 3


@torch.no_grad()
def imagine_returns(model, h0, actions, gamma=1.0):
    """Predicted returns for a batch of action sequences, rolled out in parallel."""
    h = h0
    total = torch.zeros(h0.shape[0])
    disc = 1.0
    for t in range(actions.shape[1]):
        h = model.imagine_step(h, actions[:, t])
        r_in = torch.cat([h, actions[:, t]], dim=-1)
        total = total + disc * model.reward_head(r_in).squeeze(-1)
        disc *= gamma
    return total


@torch.no_grad()
def cem_plan(model, h0, act_limit=1.0):
    mu = torch.zeros(PLAN_HORIZON, ACT_DIM)
    sigma = torch.full((PLAN_HORIZON, ACT_DIM), act_limit)
    for _ in range(ITERS):
        noise = torch.randn(POP, PLAN_HORIZON, ACT_DIM) * sigma
        candidates = (mu + noise).clamp(-act_limit, act_limit)
        returns = imagine_returns(model, h0.expand(POP, -1), candidates)
        elites = candidates[returns.topk(ELITES).indices]
        mu = elites.mean(dim=0)
        sigma = elites.std(dim=0) + 1e-3
    return mu[0].numpy()

Notice how the imagined rollout in imagine_returns mirrors the recursive protocol from the diagnostics section exactly: one filter at the start, then chained prior steps with no corrective observations. The planner therefore inherits precisely the error profile we measured, and our choice of a ten-step horizon is informed by the rollout-error plot, staying comfortably inside the region where the recurrent model's recursive predictions remained reliable. This is good practice in general: planning horizons should be chosen with reference to measured rollout fidelity, not set arbitrarily. Also note the expansion of h0 across the population dimension, which lets a single starting belief seed all hundred candidate futures simultaneously, a batching trick that turns per-candidate simulation cost into a single wide matrix product.

It is instructive to watch CEM's search distribution contract over its few iterations. We instrument one planning call from a real filtered belief and record the Gaussian's mean and standard deviation after each refitting.

In[24]:
Code
@torch.no_grad()
def cem_trace(model, h0, act_limit=1.0):
    mu = torch.zeros(PLAN_HORIZON, ACT_DIM)
    sigma = torch.full((PLAN_HORIZON, ACT_DIM), act_limit)
    trace = [(mu.numpy().copy(), sigma.numpy().copy())]
    for _ in range(ITERS):
        noise = torch.randn(POP, PLAN_HORIZON, ACT_DIM) * sigma
        candidates = (mu + noise).clamp(-act_limit, act_limit)
        returns = imagine_returns(model, h0.expand(POP, -1), candidates)
        elites = candidates[returns.topk(ELITES).indices]
        mu = elites.mean(dim=0)
        sigma = elites.std(dim=0) + 1e-3
        trace.append((mu.numpy().copy(), sigma.numpy().copy()))
    return trace


_obs0 = torch.tensor(eval_eps[0]["obs"], dtype=torch.float32)
_h0 = model_rnn.encoder(_obs0[0:1])
cem_iters = cem_trace(model_rnn, _h0)
plan_axis = np.arange(PLAN_HORIZON)
Out[25]:
Visualization
Line chart of the planned x-force versus plan step at CEM initialization: a flat mean near zero with a shaded one-standard-deviation band spanning nearly the full legal force range.
Cross-entropy method search distribution at initialization: the mean x-force plan with a one-standard-deviation band spanning nearly the entire legal force range before any elite selection.
Line chart after the first elite refitting: the shaded band narrows and the mean begins to vary across plan steps.
Cross-entropy method search distribution after the first elite refitting: the band narrows and the mean begins to differentiate actions across planning steps.
Line chart after the second elite refitting: the band tightens further while retaining substantial spread.
Cross-entropy method search distribution after the second elite refitting: uncertainty contracts further, although substantial spread remains.
Line chart after the final refitting: a differentiated mean force plan with a narrower but still visible uncertainty band.
Cross-entropy method search distribution after the final refitting: the search has concentrated relative to initialization but retains visible uncertainty across the plan.

The contraction is visible but incomplete. At iteration zero the search distribution spans nearly the entire legal force range, so the sampled plans are essentially noise. Elite refitting narrows the band and produces a differentiated mean plan, yet substantial uncertainty remains after the final iteration. That residual spread matters: with only three iterations and an approximate return model, CEM has found a better region rather than a uniquely trustworthy optimum. This is still useful for receding-horizon control, where the plan will be discarded and recomputed after one real action.

Closing the Loop

Finally, the complete agent: filter, imagine, plan, act, repeat. We evaluate three controllers on fresh episodes: the random policy, MPC driven by the feedforward model, and MPC driven by the recurrent model.

In[26]:
Code
@torch.no_grad()
def run_episode(model, rng, plan=True):
    obs = env.reset(rng)
    h = model.encoder(torch.tensor(obs, dtype=torch.float32).unsqueeze(0))
    total, positions = 0.0, [obs.copy()]
    for _ in range(EP_LEN):
        action = (
            cem_plan(model, h) if plan else rng.uniform(-1, 1, size=ACT_DIM)
        )
        obs, reward = env.step(action, rng)
        total += reward
        positions.append(obs.copy())
        emb = model.encoder(torch.tensor(obs, dtype=torch.float32).unsqueeze(0))
        a_t = torch.tensor(action, dtype=torch.float32).unsqueeze(0)
        h = model.filter_step(h, emb, a_t)
    return total, np.array(positions)


N_EVAL = 12
configs = [
    ("Random", model_rnn, False),
    ("Feedforward MPC", model_ff, True),
    ("Recurrent MPC", model_rnn, True),
]
results, demo_trajs = {}, {}
for name, mdl, plan in configs:
    rets, trajs = [], []
    for i in range(N_EVAL):
        ret, traj = run_episode(mdl, rng_eval, plan=plan)
        rets.append(ret)
        if plan and i < 5:
            trajs.append(traj)
    results[name] = np.array(rets)
    if trajs:
        demo_trajs[name] = trajs

The inner loop of run_episode is the receding-horizon cycle rendered in five lines: plan from the current belief, execute one action in the real environment, then filter the resulting observation into the belief before the next iteration. Everything the chapter has built converges here. The encoder supplies the sensory contribution, the GRU maintains the belief across steps, CEM searches imagined futures seeded by that belief, and the environment delivers the corrective observation that keeps the whole loop honest. It is worth appreciating that the agent's competence emerges entirely from this cycle; there is no separate policy network, no supervised demonstration, and no task-specific engineering beyond the reward definition.

Out[27]:
Console
          Random: return  -268.3 +/-  34.2
 Feedforward MPC: return  -195.2 +/-  93.4
   Recurrent MPC: return  -153.8 +/-  64.2
Out[29]:
Visualization
Several curved recurrent-MPC trajectories with marked starts and endpoints; some pass near the star-shaped goal while others finish far away.
Representative closed-loop trajectories from the recurrent model-predictive controller. Some paths approach or pass near the goal, while others drift or oscillate, exposing the gap between improved average return and reliable goal reaching.

Examine the marked starts and endpoints rather than reading every curved path as a success. The controller often bends trajectories toward the goal and improves average return, but several episodes finish far away or loop through high-error regions. This is exactly the distinction between better than a baseline and reliable control. The recurrent belief contains useful momentum information, yet inaccurate recursive dynamics and imperfect return predictions still mislead the planner. The figure therefore previews two themes developed later in the book: uncertainty-aware planning and protection against model exploitation.

Out[30]:
Visualization
Bar chart of mean episode return for three controllers with spread bars.
Mean episode return across evaluation episodes. Both model-predictive controllers improve on the random-policy mean, and the recurrent controller performs best in this small sample, although the wide overlapping error bars caution against a strong causal claim.

Read these results as a compact demonstration with a warning attached. Both planners improve mean return over the random policy, so decisions made inside a learned model can transfer to the real environment. The recurrent planner also improves on the feedforward planner in this run, consistent with the value of remembering hidden velocity. But the error bars overlap and the trajectory plot shows conspicuous failures, so this small experiment does not establish reliable control or isolate memory as the only cause. It establishes a working loop and gives us diagnostics for where that loop breaks.

It is worth pausing on what did not happen here. Nobody labeled velocity. Nobody provided equations of motion. Nobody gave the planner a curriculum. A generic recurrent network, squeezed by a reconstruction and reward objective, discovered that tracking an unobservable quantity was the cheapest way to predict the future, and a generic search procedure exploited those predictions to behave competently. That recipe, scaled up and refined, is the skeleton of essentially every modern world-model agent.

There is also a quieter lesson in the comparison worth naming explicitly. The feedforward model is not useless; it beats random by a clear margin, because position information alone supports short-horizon planning when velocities happen to be small. Its failure is graded, not absolute, and it concentrates exactly where the task demands momentum reasoning: fast approaches, sharp turns, final braking. Real systems exhibit the same graded structure of competence, and diagnosing where a model-based agent fails, by correlating failures with the hidden variables its latent cannot express, is one of the most practically valuable skills this book aims to teach.