Loop / TrainingLoop API
Loop is the recommended integration path for OmniLoop. It derives the dashboard schema from your existing config dataclass (meaning no YAML to maintain), coerces incoming mutations to the correct type, and binds each one back to the live object it controls. This eliminates the need to hand-write a schema, a mutation if-ladder, and a telemetry payload and keep all three in sync.
When to use
Section titled “When to use”Use it when you have a Python-based loop driven by a configuration object (like a dataclass or SimpleNamespace) and want to seamlessly bind dashboard sliders to both your configuration and live application objects (such as a PyTorch optimizer). Whether that loop trains a policy, integrates a PDE or closes a control loop makes no difference to the class — see Is OmniLoop for me? if you are unsure your workload fits at all.
Constructor
Section titled “Constructor”Create a TrainingLoop instance using from_dataclass:
from omniloop import TrainingLoop
loop = TrainingLoop.from_dataclass( cfg.ppo, # your existing config object tunable=["learning_rate", "entropy_coef", "clip_param"], bind={"learning_rate": optimizer}, # writes optimizer.param_groups[*]["lr"] bounds={"learning_rate": (1e-5, 1e-2, 1e-5)}, # optional slider-range overrides journal="run.omni", # optional: record for replay)Parameters
Section titled “Parameters”| Parameter | Type | Description |
|---|---|---|
cfg |
Any |
Configuration object (dataclass, SimpleNamespace, or any object with attributes). |
tunable |
list[str] |
List of attribute names to expose as sliders in the dashboard. |
bind |
dict |
Maps parameter names to live objects. Targets can be a torch-style optimizer, a callable fn(value), or an (obj, "attr") tuple. |
bounds |
dict |
Maps parameter names to (min, max, step) tuples, overriding default slider ranges (a UI hint). |
limits |
dict |
Maps parameter names to (min, max) hard safety limits enforced in the core — out-of-range edits are clamped before reaching actuator memory. See Limit Enforcement. |
journal |
str |
Optional path for .omni journal recording (enables replay). |
journal_max_bytes |
int |
Optional size cap per journal segment. Once exceeded, the segment is finalized and recording continues in run.1.omni, run.2.omni, … Default None (one unbounded file). See Journal rotation. |
journal_max_seconds |
float |
Optional wall-clock cap per journal segment, same rollover behaviour as journal_max_bytes. Default None. |
hash_every |
int |
Checkpoint hash interval for divergence detection (default 0 = disabled). |
freeze_on_exception |
bool |
Whether to halt the loop instead of crashing on exception (default True). |
halt_policy |
str |
"block" (default), "handoff" or "advisory". What a halt is allowed to do to your loop. Use "handoff" if this loop commands actuators — see Halt & Step. |
safety_controller |
callable |
Called instead of the loop body while halted under "handoff". Must be safe to run indefinitely and must command the machine every iteration. |
halt_timeout |
float |
Deadman in seconds: escalate if a halt outlives it. None/0 disables (the default). |
on_halt_escalation |
callable |
Called once per halt when the deadman fires, with a detail dict. Exceptions from it are contained. |
checkpoint |
callable | object | dict |
Optional model/optimizer state to serialize to disk on a freeze (see State checkpoint on freeze). Default None. |
checkpoint_path |
str |
Optional destination for the freeze checkpoint (default omniloop_checkpoint_<epoch_ms>.pt). |
tracker |
Tracker |
Optional injectable tracker instance. |
The Main Loop
Section titled “The Main Loop”Wrap your per-iteration logic in a with loop.tick(): block.
for it in range(num_iterations): with loop.tick(): # halt/step barrier + apply edits train_one_iteration(...) loop.log(reward_mean=r, policy_loss=pl) # read-only dashboard readoutsThe tick() context manager:
- Blocks execution if the dashboard has halted the loop.
- Applies any pending parameter edits from the dashboard to both your config and the bound live object (e.g., updating
cfg.learning_rateand the optimizer’s state). - Publishes a telemetry frame when the block exits.
Edits that can’t be applied
Section titled “Edits that can’t be applied”An incoming edit whose value will not coerce to the parameter’s declared type —
a word typed into a kind="text" box on a float parameter, or a non-scalar
value from any client — is skipped, not fatal. The loop keeps its previous
value and continues; the rejection is warned once per distinct value on stderr
and recorded in the journal as a mutation_rejected control event, so an
ignored edit is still visible after the fact.
This matters because edits are applied at the tick() barrier, outside the
freeze_on_exception guard that wraps your loop body — so an uncaught error
there would end the run outright rather than freeze it.
A bind= target that TrainingLoop cannot write to is rejected at
construction rather than on the first edit, so a wiring mistake surfaces at
startup instead of hours into a run.
State checkpoint on freeze
Section titled “State checkpoint on freeze”A freeze preserves the telemetry run-up automatically (see Flight Recorder), but not your in-memory tensors — so a multi-hour training run that faults still loses its weights. Pass checkpoint= to have TrainingLoop serialize model/optimizer state to disk at the moment of the freeze, before it blocks:
loop = TrainingLoop.from_dataclass( cfg.ppo, tunable=["learning_rate"], bind={"learning_rate": optimizer}, checkpoint={"model": model, "optimizer": optimizer, "epoch": epoch},)checkpoint accepts three forms:
- A callable
fn(path)— you do the saving (torch.save, or any format you like). Full control, and the only form with notorchimport. - An object exposing
.state_dict()(atorch.nn.Moduleor optimizer) — its state dict is saved viatorch.save. - A dict
{name: obj}— the classic checkpoint dict. Entries exposing.state_dict()are expanded to their state dicts; plain values (epoch counters, config) are kept as-is.
The checkpoint only fires when freeze_on_exception is True. It never raises: a failed save is logged and does not block the freeze or mask the original exception. The saved path is recorded in the journal as a checkpoint_saved control event. This is available on every integration that surfaces freeze_on_exception — the LeRobot callback forwards checkpoint / checkpoint_path.
Safe halt (halt_policy)
Section titled “Safe halt (halt_policy)”By default a halt blocks the loop at its tick() barrier. That is right for a
training run and wrong for anything driving hardware, where a loop that stops
emitting commands trips a watchdog rather than pausing. Declare "handoff" and
the loop keeps running at rate, diverting to a fallback controller:
loop = TrainingLoop.from_dataclass( cfg.controller, tunable=["kp", "kd"], limits={"kp": (0.0, 400.0)}, halt_policy="handoff", safety_controller=damping_hold, halt_timeout=5.0,)
while running: loop.step(lambda: controller.update(dt))step(body, safety=None) is the whole contract in one call. If you need the
branch yourself:
with loop.tick() as t: if t.run_safety: damping_hold() else: controller.update(dt) t.log(tracking_error=err)t.halted, t.escalated and t.halt (the raw decision dict) round out the
surface, and loop.halt(reason=...) / loop.resume() / loop.halt_escalations()
drive it from code. Full semantics — including how the deadman differs by policy
and what it does not cover — are in Halt & Step.
Watchpoints and run control
Section titled “Watchpoints and run control”These sit on TrainingLoop so an integration never has to reach into
loop._tracker to arm a NaN guard or pause itself — the two things most often
wanted alongside live tuning. Each is a no-op (rather than an AttributeError)
against a tracker that predates it.
loop.watch_all_nonfinite(True) # halt on the first NaN/inf anywhereloop.watch("policy_loss", "greater_than", 50.0) # halt when a metric blows uploop.watch("reward_mean", "less_than", -25.0) # halt on reward collapseloop.clear_watch("policy_loss") # disarm one; clear_watch() disarms allloop.watch_trip() # the trip that halted us, or NoneWatching an outcome is usually better than hand-validating hyperparameters: rather than guessing which learning rate is too large, let the loop freeze the moment the loss diverges, with the journal holding the edit that caused it.
loop.halt()/loop.resume(): Freeze the loop at its nexttick()barrier, and release it.loop.set_active_node(node_id): Mark which node of a declared execution graph is running.loop.meta(name, value): Set a reserved__-prefixed telemetry key consumed by a dedicated dashboard panel. Unlikelog(), this never declares a parameter — pass metrics tolog()and tunables totunable=.
Other Methods
Section titled “Other Methods”-
loop.log(**kwargs): Add read-only readouts to the current telemetry frame. Keys are auto-declared as read-only metrics on first use; reserved__-prefixed keys are published but never declared as dashboard parameters. -
loop.add_sink(callable): Attach additional telemetry sinks (e.g.,RerunSink). -
loop.export_tuned_config("tuned.yaml"): Export the final configured state after live tuning. The exported file is the artefact of a tuning session: the values you found by hand, in a form you can commit. -
loop.close(): Finalize the journal and clean up resources. -
TrainingLoop.replay(path, cfg, ...): Re-execute a recorded run, sourcing mutations from the journal instead of live IPC and verifying a state hash per tick. See Replay & Causality.