Skip to content

Trajectory optimization

BetaWorks, but under active development — APIs and results may shift. MPPI is the production path; iLQR has no agent driving it yet.

A trajectory optimizer answers one question: given a model and a starting state, what is the best sequence of actions over the next H steps? The first action of the winner gets executed, the rest is discarded, and the whole thing runs again from the new state. That is model-predictive control, and it is what TD-MPC2 does at every environment step.

from noeira.planners.trajectory import (
CategoricalRandomShooter, ContinuousRandomShooter,
CategoricalCEMOptimizer, ContinuousCEMOptimizer,
MPPICPU, MPPIGPUBatched,
ILQRCPU, ILQRGPUBatched,
)

The sampling-based planners are the same algorithm three times. Each draws candidate sequences, scores them through the callback, and refits the sampling distribution. They differ only in the refit rule:

One loop, three refit rulesSame samples, same callback, same scores (taller is better) — only what happens next differs.random shooterkeep the best, refit nothingunchanged — resampled uniformCEMtop-k, weighted equallyrefit to the elites, equallyMPPItop-k, weighted by scorerefit to the elites, softmax-weightedCEM and MPPI keep the same elites and discard the same rest — the cut is identical.Only the right panel lets a clearly-better elite pull the refit further than a marginal one.
samples that move the distributiondiscardednext iteration's mean ± std

Read as an escalation:

The random shooter never refits. It draws num_samples sequences, scores them, and returns the best. It exists as a baseline, and it is a sharper diagnostic than it looks: if a learned world model cannot beat a random shooter on a held-out task, the model is not useful for planning, whatever its validation loss says.

CEM keeps the top-k and fits the next distribution to those alone. The categorical version refits a per-timestep action distribution with add-k Laplace smoothing; the continuous version refits a diagonal Gaussian with a floor on the standard deviation, which is what stops the search collapsing to a point after a couple of iterations.

MPPI takes the same top-k cut and then weights within it, by the softmax of each elite’s return. So the difference from CEM is narrower than the textbook descriptions suggest: both discard the non-elites, but CEM treats its survivors as equals while MPPI lets a clearly-better sequence pull the mean further than a marginal one. That gradient inside the elite set is what makes it the least brittle of the three, and it is the one in production use.

Each sampling planner comes in two flavours, differing in what a “candidate” is:

DiscreteContinuous
ShooterCategoricalRandomShooterContinuousRandomShooter
CEMCategoricalCEMOptimizerContinuousCEMOptimizer
Distributionper-step categorical over ACT_DIMper-step diagonal Gaussian
Refitadd-k smoothed counts over eliteselite mean and std, std floored

Both take [BATCH, ACT_DIM] as compile-time parameters and everything else at construction:

var planner = ContinuousCEMOptimizer[BATCH, ACT_DIM](
horizon=5, cem_iters=30, cem_samples=300, cem_topk=30, init_std=1.0,
)
var best = planner.optimize(callback, best_plan_out_ptr)

Those are the LeWM PushT paper settings — 300 candidates, 30 iterations, top-30 elites.

The production planner, and the only one with a batched GPU implementation that plans for many environments at once.

comptime Planner = MPPIGPUBatched[
LATENT_DIM, ACTION_DIM, HORIZON,
NUM_SAMPLES, NUM_PI_TRAJS, NUM_ELITES, NUM_ITERATIONS, N_ENVS,
]

MPPIGPUBatched runs one kernel grid per horizon step covering all N_ENVS × NUM_SAMPLES trajectories, and owns its own scratch — separating planner state from agent state was the point of the refactor that produced this package.

Three details carry more weight than their parameter names suggest.

NUM_PI_TRAJS seeds the search with the policy. That many of the sampled sequences start from the policy prior’s action rather than from the current mean. In a high-dimensional continuous action space, purely random sequences essentially never find anything good, so without this the search does not get off the ground.

NUM_ELITES is the cut the softmax runs inside, as described above. Set it equal to NUM_SAMPLES and you get textbook MPPI; set it to 1 and you get a random shooter with extra steps.

Action selection is multinomial, not argmax. After the final iteration the executed sequence is drawn from the weighted set, matching the reference’s torch.multinomial. Taking the argmax instead is a defensible choice but it is a different algorithm and it collapses exploration.

The outlier in the package. Everything above is gradient-free — it needs only (z, a) → (z′, r). iLQR instead linearizes the dynamics around the current trajectory and solves the resulting linear-quadratic problem exactly.

One iterationThe nominal trajectory is linearized once, then improved twice — backward, then forward.backward — RiccatiV_z, V_zz → gains kₜ, Kₜ, with μI added to Q_uu before inversionz₀z₁z₂z₃z₄forward — rolloutu ← ū + α·kₜ + Kₜ·δz, backtracking α ∈ {1, 1/2, 1/4, …}Cost improved: μ decreases, the model is trusted further.Cost worsened at every α: μ increases, the step shrinks toward gradient descent.
backward sweep (exact, given the linearization)forward sweep (searched)

Each iteration runs a backward Riccati sweep to produce a feedforward gain kₜ and a feedback gain Kₜ at every step, then a forward rollout that applies them. Because the linearization is only locally valid, two safeguards wrap it: Levenberg-Marquardt regularization adds μI to Q_uu before inversion, and the forward pass backtracks on the step size until the cost actually improves. A successful iteration decreases μ; a failed line search increases it, shrinking the step toward plain gradient descent.

The derivative surface is why this needs its own callback trait (RolloutJacobianCallback) rather than extending the gradient-free one. CEM and MPPI should not have to implement Jacobian methods they will never call.

Status. ILQRCPU and ILQRGPUBatched are both implemented and tested — the CPU path matches an analytic finite-horizon Riccati gain to 1e-6, the GPU path to 1e-4 in float32, and a pendulum stabilizer test exercises the line search on genuinely nonlinear dynamics. But no agent drives either. The GPU Riccati backward pass also runs one block per environment with a single thread walking t = T-1 → 0, so it parallelizes across problems and not within one.

noeira/planners/testing/ holds world models with known optima, which is what makes it possible to test correctness rather than convergence:

StubWhat it pins down
LinearQuadratic1Dclosed-form optimal control — the strongest oracle here
IdentityDynamics + GoalReachRewardthe planner reaches a goal it can trivially reach
LinearQuadratic1DILQRCallbackthe iLQR derivative surface, against Riccati
Pendulum2DILQRCallbackline search and LM on nonlinear dynamics
Terminal window
pixi run mojo run -I . tests/planners/trajectory/test_mppi_lqr.mojo
pixi run mojo run -I . tests/planners/trajectory/test_cem_goal_reach.mojo
pixi run mojo run -I . tests/planners/trajectory/test_ilqr_lqr_oracle.mojo
pixi run -e apple mojo run -I . tests/planners/trajectory/test_mppi_gpu_goal_reach.mojo

For CEM and the random shooter, implement ScorePlanCallback — one plan in, one scalar out, lower is better. If you are scoring many candidates per iteration, implement BatchedScorePlanCallback as well: it hands you all K plans at once so you can run a single rollout instead of K sequential ones. That change is worth far more than it sounds. The first LeWM port’s paper-configuration evaluation was spending roughly a hundred thousand host syncs per pass before the batched path collapsed them to one per iteration.

For MPPI, implement RolloutCallbackCPU or RolloutCallbackGPU — three methods: policy_action for the prior-seeded trajectories, rollout_step for the hot path, and terminal_value for the bootstrap at the horizon end. A stub with no learned policy can return zeros from policy_action; those trajectories degenerate to mean-seeded ones, which is harmless.

  • Tree search — the other half of the package
  • TD-MPC2 — MPPI driving a learned latent model
  • MBPO — the alternative: train a policy on model data instead of planning