Models¶
A model in RLMesh is a policy you serve or drive against an environment. You wrap a predict callable or subclass a backend Model, declare the payload your policy ingests with a ModelSpec, and RLMesh resolves the model-to-environment adapter for you. The model only ever sees its own declared input and output format; no per-environment glue lives in your code.
Authoring a model is independent of authoring an environment. The two sides meet only through roles, resolved by Adapters. The environment side is in Environments.
This page is the concept tour. Reach for Model Reference when you need the full corner-synthesis rules, the batching and device mechanics, the serve-and-connect surface, or the model-quirk recipes.
Construction styles¶
There are two ways to build a model, and the choice is about reuse.
Style |
What you write |
Reach for it when |
|---|---|---|
Wrap a predict callable |
|
a baseline or a one-file script |
Subclass a backend |
set the |
the form you serve and ship |
Pick the backend by the array type your policy speaks. The backend only changes how observation leaves are decoded before predict and how returned actions are encoded; the four predict corners and the lifecycle are identical across all of them.
Class |
Import |
Observation it receives |
Action it returns |
|---|---|---|---|
|
native (no extras) |
RLMesh-native values |
RLMesh-native values |
|
|
NumPy arrays + primitives |
NumPy arrays |
|
|
Torch tensors (device-aware) |
Torch tensors |
|
|
JAX arrays |
JAX arrays |
See Framework Backends for the backend helpers, and Framework Backends for choosing one.
The minimal model¶
A subclass loads its weights in load() and maps an observation to an action in predict(). This is the shape the bring-your-own-container example serves:
import rlmesh
class MyPolicy(rlmesh.numpy.Model):
def load(self):
self.bias = 0 # load weights INTO self
def predict(self, observation):
return self.bias
model = MyPolicy()
Set the spec class attribute to a ModelSpec and the adapter resolves from the environment’s published tags, so predict works in your model’s own conventions regardless of which environment it runs against. Set it to rlmesh.NO_ADAPTER to skip resolution and have predict receive the raw observation.
import rlmesh
import rlmesh.adapters as adapt
class MyPolicy(rlmesh.torch.Model):
spec = adapt.ModelSpec(
input={
"image": adapt.Image(adapt.IMAGE_PRIMARY, size=224),
"state": adapt.Concat(adapt.EEF_POS, adapt.GRIPPER_POS),
},
output=adapt.Action(adapt.Actuator(adapt.ACTION_DELTA_POS, dim=3)),
)
def load(self):
self.device = "cuda"
self.net = load_weights().to(self.device)
def predict(self, observation):
return self.net(observation["image"], observation["state"])
size=224 sets both height and width. The spec is the model side of the contract in Adapters; every field on every spec leaf is in Adapter Reference.
Lifecycle¶
A model has four lifecycle seams. They fire identically on the local run / session loop and the served wire path, so a model behaves the same whether you drive it in-process or dial it over a socket.
Seam |
When it fires |
What to do in it |
|---|---|---|
|
once, at construction |
Load weights into |
|
at each episode boundary |
Clear per-episode state (RNN hidden state, chunk replay). |
|
at the end of a run |
Release resources. |
|
constructor callbacks |
The same edges for a wrapped callable that cannot override methods. |
There is no episode-begin hook. Per-episode state is lazy-seeded on the first predict, so a stateful model clears its state at episode end via reset() (a subclass’s reset() is wired to the on_episode_end edge).
For torch and jax models, set self.device inside load() when you move your weights onto it. That is the one source of truth: RLMesh moves every observation tensor leaf onto self.device before predict, so you never call .to(device) yourself.
def load(self):
self.device = "cuda"
self.policy = Policy.from_pretrained("org/checkpoint").to(self.device)
self.stats = load_norm_stats() # normalization stats load here too
The device and framework mechanics, and what happens when you set device on a numpy or native model, are in Model Reference.
The four corners¶
predict is one of four corners. They form a 2×2 over a batch axis (one lane vs. all vectorized lanes at once) and a chunk axis (one action vs. a chunk of future actions per forward pass). You implement the corners your policy supports; the runtime derives the rest.
Corner |
Signature |
Returns |
|---|---|---|
|
|
one action |
|
|
action chunk, shape |
|
|
batched action |
|
|
batched chunk |
Implement the most general corner you can and the runtime derives the others by deriving downward: it drops the batch axis by running a batch of one, and drops the chunk axis by running at horizon 1 and taking the first action. So a model that defines predict_chunk_batch alone gets all four for free.
graph TD
PCB["predict_chunk_batch<br/>[N, H, ...]"] -->|de-batch| PC["predict_chunk<br/>[H, ...]"]
PCB -->|de-chunk| PB["predict_batch<br/>[N, ...]"]
PB -->|de-batch| P["predict<br/>one action"]
PC -->|de-chunk| P
Going up either axis is impossible: chunking is a model capability, not glue, and batching up is left to the engine’s per-lane loop. The exact derivation rules, the ambiguous predict case, and the one restriction on the native backend are in Model Reference.
Which corner should I implement?¶
Your policy |
Implement |
Why |
|---|---|---|
Batched VLA / action-head policy |
|
One forward over all lanes returning a chunk; all four derive from it. |
Simple per-step policy |
|
One action per observation; nothing to chunk or batch. |
ACT / diffusion / flow chunker |
|
Emits a native chunk per forward; the runtime replays it. |
A batched VLA is the common case. The SmolVLA-style pattern is a rlmesh.torch.Model with a two-camera, state, and instruction spec, from_pretrained in load(), and predict_chunk_batch returning the native chunk truncated to the horizon and moved to host:
import rlmesh
import rlmesh.adapters as adapt
class SmolVLA(rlmesh.torch.Model):
spec = adapt.ModelSpec(
input={
"observation.images.image": adapt.Image(adapt.IMAGE_PRIMARY, size=224),
"observation.images.wrist": adapt.Image(adapt.IMAGE_WRIST, size=224),
"observation.state": adapt.Concat(
adapt.EEF_POS,
adapt.State(adapt.EEF_ROT, encoding="axis_angle"),
adapt.GRIPPER_POS,
),
"instruction": adapt.Text(adapt.INSTRUCTION),
},
output=adapt.Action(
adapt.Actuator(adapt.ACTION_DELTA_POS, dim=3),
adapt.Actuator(adapt.ACTION_DELTA_ROT, dim=3, encoding="axis_angle"),
adapt.Actuator(adapt.ACTION_GRIPPER, dim=1, range=(-1.0, 1.0)),
),
)
def load(self):
from lerobot.policies.smolvla import SmolVLAPolicy
self.device = "cuda"
self.policy = SmolVLAPolicy.from_pretrained("org/smolvla").to(self.device)
def reset(self):
self.policy.reset()
def predict_chunk_batch(self, observations, execution_horizon=1):
chunk = self.policy.predict_action_chunk(self._obs(observations)) # [N, H, A]
return chunk[:, :execution_horizon].cpu()
The _obs() remap helper is the recipe for a checkpoint whose keys differ from the spec; see the model-quirk recipes. More worked VLA models live in examples/python/vla_adapters.
The execution horizon¶
execution_horizon is optional on the chunk corners. Most policies write predict_chunk(obs) and ignore it: a trained chunk length is fixed, and the runtime executes a prefix of the native chunk. A decoder that can stop early writes predict_chunk(obs, execution_horizon=1) to receive how many actions the runtime will execute before re-planning, and decodes exactly that many. Keep the =1 default so it stays a compatible override.
def predict_chunk(self, observation, execution_horizon=1):
chunk = self.policy.decode(observation) # native [H_native, A]
return chunk[:execution_horizon] # truncate to what runs
The runtime chooses the horizon at the call site (run(..., execution_horizon=N)); the spec output declares the layout of one action, not the chunk length. The full replay story is in Running Evaluations.
Batched observations¶
The batch corners do not receive a list of N observations. The runtime fuses the per-lane observations into one batched observation: every leaf gains a leading batch axis, so a Dict observation arrives as {key: array[N, ...]}. You return the batched action the same way, leaves carrying the leading batch axis, and the runtime splits it back per lane.
def predict_batch(self, observations):
# observations["image"] is array[N, H, W, C]; one forward over the batch.
return self.net(observations["image"]) # return array[N, action_dim]
Text leaves stay per-lane lists rather than stacked arrays, and lanes must share an observation space so the leaves align. The fusion rules, the tree_stack / tree_unstack machinery, and the native-backend fallback are in Model Reference.
Run it¶
Drive the model against an environment with run to auto-pump whole episodes, or session to step reset / predict / step by hand:
model = MyPolicy()
result = model.run(env, seeds=range(10), instruction="put the cup on the plate")
print(result.mean_reward, result.success_rate)
run returns a RunResult with .episodes, .mean_reward, and .success_rate. env may be a local env, an EnvFactory, a RemoteEnv, or a bare address string the loop dials. The module-level rlmesh.run(model, env, ...) and rlmesh.session(model, env, ...) accept a bare predict callable, a Model subclass or instance, or a served handle.
The full run / session / read story (seeds, instruction injection, the execution horizon end to end, and reading canonical roles off an observation) is in Running Evaluations.
Build into a container¶
A model ships as a container image that serves the policy on an endpoint. Write a Dockerfile and a small entrypoint, or skip the entrypoint with the lazy serve path.
The entrypoint subclasses a backend Model and calls serve:
import os
import rlmesh
class MyPolicy(rlmesh.numpy.Model):
def load(self):
self.bias = 0
def predict(self, observation):
return self.bias
if __name__ == "__main__":
MyPolicy().serve(os.environ.get("RLMESH_ADDRESS", "0.0.0.0:50051"))
FROM python:3.11-slim
RUN pip install --no-cache-dir "rlmesh" "gymnasium" "numpy"
WORKDIR /app
COPY entrypoint.py .
ENV RLMESH_ADDRESS=0.0.0.0:50051
ENTRYPOINT ["python", "entrypoint.py"]
The lazy path skips the entrypoint file. Point the image (or your shell) at the recipe and RLMesh writes the serve loop:
python -m rlmesh.serve my_pkg:MyPolicy
On the served path, load(**binding) receives its parameters from RLMESH_MAKE_KWARGS once the worker is built. Connect to a served model with rlmesh.RemoteModel(address) for a server you started yourself, or rlmesh.SandboxModel("image://my-model:latest") for a prebuilt image RLMesh runs:
sess = rlmesh.session(rlmesh.SandboxModel("image://my-model:latest"), env)
Both handles plug into rlmesh.run / rlmesh.session like a local model. The serve env vars and address forms, and the full bring-your-own-container example, are covered in Model Reference. The sandbox mechanics are in Sandbox Environments and connecting clients in Connect a Remote Environment.
Where next¶
Model Reference: every predict corner with its synthesis rules, the batching and chunk-replay semantics, the device and framework handling, the model-quirk recipes, and the serve-and-connect surface.
Running Evaluations:
run/session/read, seeds and instruction injection, and the execution horizon end to end.Adapters: how the model spec and the environment tags match by role.
Models: the autodoc signatures for every symbol above.