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,)Three planners, one loop
Section titled “Three planners, one loop”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:
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.
Discrete and continuous
Section titled “Discrete and continuous”Each sampling planner comes in two flavours, differing in what a “candidate” is:
| Discrete | Continuous | |
|---|---|---|
| Shooter | CategoricalRandomShooter | ContinuousRandomShooter |
| CEM | CategoricalCEMOptimizer | ContinuousCEMOptimizer |
| Distribution | per-step categorical over ACT_DIM | per-step diagonal Gaussian |
| Refit | add-k smoothed counts over elites | elite 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.
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.
Testing a planner
Section titled “Testing a planner”noeira/planners/testing/ holds world models with known optima, which is what
makes it possible to test correctness rather than convergence:
| Stub | What it pins down |
|---|---|
LinearQuadratic1D | closed-form optimal control — the strongest oracle here |
IdentityDynamics + GoalReachReward | the planner reaches a goal it can trivially reach |
LinearQuadratic1DILQRCallback | the iLQR derivative surface, against Riccati |
Pendulum2DILQRCallback | line search and LM on nonlinear dynamics |
pixi run mojo run -I . tests/planners/trajectory/test_mppi_lqr.mojopixi run mojo run -I . tests/planners/trajectory/test_cem_goal_reach.mojopixi run mojo run -I . tests/planners/trajectory/test_ilqr_lqr_oracle.mojopixi run -e apple mojo run -I . tests/planners/trajectory/test_mppi_gpu_goal_reach.mojoWriting a callback
Section titled “Writing a callback”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