Solvers optimize action sequences against a Costable — anything exposing get_cost(info_dict, action_candidates). Some world models implement it natively (e.g. TD-MPC2); for the others, this module provides the glue: a ShootingCostEvaluator composes any model exposing the Dynamics surface (encode/rollout) with a swappable Objective, so changing the planning cost never requires subclassing the world model.
The rollout contract
rollout(info_dict, action_candidates) receives strictly-future candidates of shape (B, S, horizon, action_dim). Observation context arrives via the info dict: pixels holds H = history_len frames (B, S, H, C, h, w), and when H > 1 the executed action blocks between those frames are supplied as action_history (B, S, H - 1, action_dim) — frozen inputs, never optimizer variables. Inside the rollout, context frame k pairs with the action block leaving it (action_history[k] for past frames; the first candidate for the current frame), matching the training-time (frame[t], action[t]) alignment. The output predicted_emb has shape (B, S, H + horizon, dim) with the first H entries being the encoded context — objectives that read anything other than the last step must account for this (GoalMSE reads [:, :, -1:] and is unaffected).
[ Quick Tour ]
import stable_worldmodel as swm
from stable_worldmodel.planning import (
CEMSolver,
ControlPenalty,
GoalMSE,
ShootingCostEvaluator,
WeightedSum,
)
model = swm.wm.utils.load_pretrained('lewm/pusht')
# 1. Single-term cost: last-step MSE to the goal embedding
cost = ShootingCostEvaluator(model, GoalMSE())
# 2. Multi-term cost: goal distance + action magnitude penalty
cost = ShootingCostEvaluator(
model,
WeightedSum([(1.0, GoalMSE()), (0.1, ControlPenalty())]),
)
# 3. Plug into any solver — the evaluator duck-types as a Costable
solver = CEMSolver(cost=cost, n_steps=30, num_samples=300, topk=30)
config = swm.PlanConfig(horizon=10, receding_horizon=1, action_block=1)
policy = swm.policy.WorldModelPolicy(solver=solver, config=config)
To plan under inequality constraints, pass objectives as constraints= — the
evaluator then exposes get_constraints and satisfies the
Constrainable protocol that
LagrangianSolver
feature-detects:
cost = ShootingCostEvaluator(
model,
GoalMSE(),
constraints=[ControlPenalty()], # satisfied when <= 0
)
Split-latent models (PreJEPA / dinowm)
PreJEPA fuses the pixel embedding with one embedding per extra encoder
(proprio, action) along the feature axis. That latent cannot be scored against
a goal as a single tensor: it carries action slots, which a goal does not
prescribe. There is no special objective for this — point one GoalMSE at each
source and add them up with WeightedSum. The per-source goal embeddings come
from split_goal_encode, which
default_goal_encode selects
automatically for such models:
model = swm.wm.utils.load_pretrained('dinowm-pusht')
cost = ShootingCostEvaluator( # goal encoder auto-selected
model,
WeightedSum([
# extras before pixels, mean-reduced: reproduces the cost dinowm was
# evaluated with before the objective was pulled out of the model
(1.0, GoalMSE('predicted_proprio_emb', 'proprio_goal_emb', reduction='mean')),
(1.0, GoalMSE('predicted_pixels_emb', 'pixels_goal_emb', reduction='mean')),
]),
)
The leading coefficients trade the sources off against each other. reduction
matters here: 'sum' over a 196x384 pixel embedding versus a 10-dim proprio
embedding weights them ~7500x apart, so 'mean' is what puts them on a
comparable footing (and is what the original cost used).
From the plan scripts, pick the matching config —
objective=goal_mse_pixels_proprio for dinowm-pusht, or
objective=goal_mse_pixels for dinowm_noprop-pusht, whose only non-action
source is pixels. Note also that the extra encoders consume the same context
frames as pixels, so WorldModelPolicy.history_keys must cover every extra
encoder input (e.g. ('pixels', 'proprio')) — otherwise the fused
concatenation is malformed.
Writing a custom objective
An objective is any callable mapping a populated info_dict to a
per-candidate cost of shape (B, S). The evaluator rolls candidates out
first, so the info_dict already holds the rollout outputs (e.g.
predicted_emb) plus the raw candidates under action_candidates:
import torch.nn as nn
class SmoothnessPenalty(nn.Module):
"""Penalizes large action changes between consecutive steps."""
def forward(self, info_dict: dict) -> torch.Tensor:
actions = info_dict['action_candidates'] # (B, S, H, action_dim)
deltas = actions[..., 1:, :] - actions[..., :-1, :]
return deltas.pow(2).sum(dim=tuple(range(2, actions.ndim)))
[ Evaluator ]
ShootingCostEvaluator
ShootingCostEvaluator(
model: Dynamics,
objective: Objective,
constraints: list[Objective] | None = None,
encode_goal: Callable[[Dynamics, dict], Tensor]
| None = default_goal_encode,
)
Bases: Module
Single-shooting adapter that makes (model, objective) a Costable.
Subclasses torch.nn.Module and registers the world model as a submodule,
so parameters() reaches the real model and solvers (CEM/GD) infer the
candidate dtype from it instead of falling back to float32.
The actor warm-start tail-fill — a solver calling model.get_action for
Actionable models such as tdmpc2 — is not forwarded through the wrapper;
it is moot for the LeWM-style models this targets. Add get_action
delegation if an Actionable model is ever wrapped.
Parameters:
-
model(Dynamics) –World model providing
encode/rollout(theDynamicssurface). Registered as a submodule soparameters()reaches it. -
objective(Objective) –The cost to apply to the rolled-out
info_dict. -
constraints(list[Objective] | None, default:None) –Optional
Objectiveterms reused as constraints, each returning(B, S)under theLagrangianSolverconvention that a term is satisfied when<= 0. When given,get_constraintsis exposed and stacks them into(B, S, C); otherwise the attribute is absent, so a solver probing for it sees no constraints. -
encode_goal(Callable[[Dynamics, dict], Tensor] | None, default:default_goal_encode) –Optional
fn(model, info_dict) -> goal_embto override the default goal encoding. Set toNoneon the call to skip goal encoding entirely (e.g. when the caller pre-populatesgoal_emb).
get_cost
get_cost(
info_dict: dict, action_candidates: Tensor
) -> Tensor
Encode goal (if needed), roll out candidates, then score them.
criterion
criterion(
info_dict: dict, action_candidates: Tensor | None = None
) -> Tensor
Score an already-rolled-out info_dict with the objective.
default_goal_encode
Encode the goal using the encoder matching the model's latent layout.
Dispatches on whether the model fuses extra sources into its latent
(extra_encoders): split-latent models take :func:split_goal_encode,
everything else :func:flat_goal_encode. Pass encode_goal= explicitly
to ShootingCostEvaluator to override the choice, or None to skip
goal encoding entirely.
flat_goal_encode
Encode the goal for models whose latent is a single flat tensor.
Behavior-preserving extraction of the goal-encoding branch in
LeWM.get_cost; also covers PLDM. Pair with :class:GoalMSE.
split_goal_encode
Encode the goal for models whose latent concatenates several sources.
PreJEPA fuses the pixel embedding with one embedding per extra encoder
along the feature axis, so a goal cannot be built by the flat path: the
action encoder has no goal-side input at all (a goal prescribes a state,
not an action), and the cost must compare the parts, not the fused
tensor. This encodes the goal with the action encoder excluded, stores the
per-source goal embeddings (pixels_goal_emb, <key>_goal_emb) in
info_dict, and returns the fused goal embedding for the goal_emb
key. Score them with one
:class:~stable_worldmodel.planning.GoalMSE per source, combined with
:class:~stable_worldmodel.planning.WeightedSum.
Like the flat path, the goal embeddings are stored without a candidate axis; the objective broadcasts them over candidates.
Behavior-preserving extraction of the goal-encoding branch in the former
PreJEPA.get_cost.
[ Objectives ]
GoalMSE
Bases: Module
Last-step MSE between predicted and goal embeddings.
Reads predicted_emb (B, S, T, ...) and goal_emb
(B, T_goal, ...); returns per-candidate cost (B, S). The comparison
is against the last predicted step and the last goal frame.
Rank-agnostic: the time axis is addressed positionally (dim 2 of the
prediction, dim 1 of the goal) rather than from the right, so latents that
carry extra trailing axes work too — PreJEPA's pixel embedding has a
patch axis, (B, S, T, patches, dim), and indexing from the right would
silently slice patches instead of time.
Point pred_key/goal_key at one source of a split latent (e.g.
predicted_pixels_emb / pixels_goal_emb) and combine the terms with
:class:WeightedSum to score models whose latent concatenates several
sources; see the planning guide.
Behavior-preserving extraction of LeWM.criterion (reproduces it
bit-for-bit), so an existing model migrates to the ShootingCostEvaluator seam
without changing results.
Parameters:
-
pred_key(str, default:'predicted_emb') –info_dictkey holding the rollout predictions. -
goal_key(str, default:'goal_emb') –info_dictkey holding the goal embedding. -
reduction(str, default:'sum') –How the squared error is collapsed over every axis after the candidate axis.
'sum'(the default) matchesLeWM.criterion;'mean'matches the formerPreJEPA.criterion. This choice also sets the relative weight of sources with different shapes, so it matters when composing per-source terms: summing over a 196x384 pixel embedding and a 10-dim proprio embedding weights them ~7500x apart, whereas averaging puts them on a comparable scale.
ControlPenalty
ControlPenalty(action_key: str = 'action_candidates')
Bases: Module
L2 penalty on the action candidates themselves.
Reads action_candidates (shape (B, S, H, action_dim)) from the
info_dict — the ShootingCostEvaluator stores them there before scoring —
and returns a per-candidate cost (B, S). Demonstrates a cost the world
model never had to know about.
WeightedSum
Bases: Module
Linear combination of objectives: sum(w * term(info_dict)).
Lets us assemble multi-term costs (goal distance + control penalty + constraints) without touching the model or the solver.
[ Protocols ]
The structural contracts live in stable_worldmodel.protocols and are
re-exported from stable_worldmodel.planning. They are Protocol classes:
nothing subclasses them, anything with the right methods satisfies them.
Costable
Bases: Protocol
Protocol for the cost surface planning solvers consume.
This is the structural "has get_cost" contract every solver types
against. It is polymorphic across implementations: a
:class:~stable_worldmodel.planning.ShootingCostEvaluator (a world model composed
with an :class:Objective), as well as models that expose get_cost
natively (e.g. TD-MPC2), all satisfy it.
Methods:
-
criterion–Compute the cost criterion for action candidates.
-
get_cost–Compute cost for given action candidates based on info dictionary.
criterion
criterion(
info_dict: dict, action_candidates: Tensor
) -> Tensor
Compute the cost criterion for action candidates.
Parameters:
-
info_dict(dict) –Dictionary containing environment state information.
-
action_candidates(Tensor) –Tensor of proposed actions.
Returns:
-
Tensor–A tensor of cost values for each action candidate.
get_cost
get_cost(
info_dict: dict, action_candidates: Tensor
) -> Tensor
Compute cost for given action candidates based on info dictionary.
Parameters:
-
info_dict(dict) –Dictionary containing environment state information.
-
action_candidates(Tensor) –Tensor of proposed actions.
Returns:
-
Tensor–A tensor of cost values for each action candidate.
Constrainable
Bases: Protocol
Protocol for the (optional) constraint surface of a cost object.
A cost object exposes get_constraints when planning under inequality
constraints. LagrangianSolver feature-detects it via
isinstance(cost, Constrainable). Following the Lagrangian contract, a
constraint term g_i is satisfied when g_i <= 0.
Methods:
-
get_constraints–Compute constraint violations for given action candidates.
get_constraints
get_constraints(
info_dict: dict, action_candidates: Tensor
) -> Tensor
Compute constraint violations for given action candidates.
Parameters:
-
info_dict(dict) –Dictionary containing environment state information.
-
action_candidates(Tensor) –Tensor of proposed actions.
Returns:
-
Tensor–A tensor of shape
(B, S, C)of per-candidate constraint values, -
Tensor–where
Cis the number of constraints (satisfied when<= 0).
Dynamics
Bases: Protocol
The dynamics surface a ShootingCostEvaluator needs from a world model.
Methods:
-
encode–Embed raw observations into the model's latent space.
-
rollout–Roll candidate action sequences forward through the dynamics.
encode
rollout
Roll candidate action sequences forward through the dynamics.
Parameters:
-
info_dict(dict) –Dictionary containing environment state information.
pixelsholdsHcontext frames(B, S, H, C, h, w); whenH > 1the executed action blocks between those frames must be provided asaction_historyof shape(B, S, H - 1, action_dim). -
action_candidates(Tensor) –Tensor of proposed strictly-future action sequences of shape
(B, S, horizon, action_dim).
Returns:
Objective
Bases: Protocol
Maps a populated info_dict to per-candidate cost (B, S).
Unlike :class:Costable, an Objective scores an already-rolled-out
info_dict — it takes no action_candidates and performs no rollout.
The info_dict is expected to already contain rollout outputs (e.g.
predicted_emb) and any goal/conditioning the objective needs. The
ShootingCostEvaluator also stores the raw action_candidates under the
action_candidates key so action-space penalties can read them.