Skip to content

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.

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.

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
)
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.

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 readouts

The tick() context manager:

  1. Blocks execution if the dashboard has halted the loop.
  2. Applies any pending parameter edits from the dashboard to both your config and the bound live object (e.g., updating cfg.learning_rate and the optimizer’s state).
  3. Publishes a telemetry frame when the block exits.

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.

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 no torch import.
  • An object exposing .state_dict() (a torch.nn.Module or optimizer) — its state dict is saved via torch.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.

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.

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 anywhere
loop.watch("policy_loss", "greater_than", 50.0) # halt when a metric blows up
loop.watch("reward_mean", "less_than", -25.0) # halt on reward collapse
loop.clear_watch("policy_loss") # disarm one; clear_watch() disarms all
loop.watch_trip() # the trip that halted us, or None

Watching 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 next tick() 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. Unlike log(), this never declares a parameter — pass metrics to log() and tunables to tunable=.
  • 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.