Configuration
SUMMARY
One YAML file describes a whole run: env: names the simulator and the task, runner: maps onto OnPolicyRunnerConfig field for field.
Every config validates itself on construction, so a bad value fails where you wrote it rather than three hundred iterations into training. Each converts to and from plain dictionaries, which is what lets the same run be written as YAML or as Python.
The Shape of a Configuration
Two blocks at the top, four configs nested under runner, and two more inside those. Everything else is a scalar on one of them.
envrunnerloggeralgorithmsymmetry_cfgactordistribution_cfgcriticEach box is one config class, and each level validates itself as it is built — so a bad value is rejected where you wrote it, not at the first training step. A CNNConfig actor or critic adds one more box, cnn_cfg, for the encoder placed before the MLP head.
In the tables below, a key whose Default is required has to be given; every other key can be left out.
The Reference Configuration
configs/example.yaml is the annotated reference — every field, with its accepted values, on a Gymnasium Humanoid run. The per-task configs in configs/<framework>/ are trimmed versions of it.
# Annotated reference config (Gymnasium Humanoid-v5) — one line per field, accepted values noted.
env:
# Simulator: gymnasium, mjlab, or isaaclab.
framework: gymnasium
# Registered task id for that framework (see configs/<framework>/ for real ones).
id: Humanoid-v5
# Parallel environments. Positive integer.
num_envs: 32
# Device: auto, cpu, mps, cuda, or cuda:<index>.
device: auto
# --- mjlab/isaaclab only; the gymnasium adapter ignores these ---
#
# clip_actions: symmetric action limit. Positive float; omit to pass actions through unclamped.
# clip_actions: 1.0
#
# nconmax (mjlab only): max contacts MuJoCo Warp allocates for. Positive integer; omit for the task's own limit.
# nconmax: 50000
#
# headless (isaaclab only): run Isaac Sim without a viewer. true or false; default true.
# headless: true
runner:
# Runner class: "OnPolicyRunner", or a "module:Class" import path.
class_name: OnPolicyRunner
# RNG seed for model init, action sampling and mini-batch order. Null, or integer in [0, 2**32).
seed: null
# Iterations to train for. Positive integer.
num_learning_iterations: 3000
# Env steps collected per env, per iteration. Positive integer.
num_steps_per_env: 32
# Observation groups per set: gymnasium="observation"; mjlab="actor"/"critic"; isaaclab="policy"/"critic".
obs_groups:
actor: [observation]
critic: [observation]
# Print progress to console. true or false.
verbose: true
# Check every env output for NaN (costs a GPU sync each step). true or false.
check_for_nan: false
# torch.compile mode: null, default, or max-autotune-no-cudagraphs.
torch_compile_mode: null
logger:
# Log at all. true or false; false trains without writing anything.
enabled: true
# Logger class: "Logger", or a "module:Class" import path.
log_name: Logger
# Root directory every experiment is written under. String, or null to write nothing.
log_dir: logs
# Experiment name — the directory grouping this run's siblings: <log_dir>/<experiment>/<timestamp>/.
experiment: gymnasium_ppo
# Console-report interval, in iterations. Positive integer.
log_interval: 10
# Write scalars to the event file. true or false.
log_metrics: true
# Record an MP4 beside every checkpoint. true or false.
log_video: true
# Dump the resolved config as config.json. true or false.
log_config: true
# Checkpoint interval, in iterations. Positive integer.
save_interval: 100
# Checkpoints to keep, oldest deleted first. Non-negative integer; 0 keeps all.
keep_last_n: 5
# Keep model_best.pt, rewritten on every new best mean reward. true or false.
save_best: true
# Resume from: null (scratch), "last", "best", or a checkpoint file name/path.
resume: null
algorithm:
# Algorithm class: "PPO", or a "module:Class" import path.
class_name: PPO
# Optimizer: adam, adamw, sgd, or rmsprop.
optimizer: adam
# Initial learning rate. Positive float.
learning_rate: 0.0001
# Optimization epochs per rollout. Positive integer.
num_learning_epochs: 2
# Mini-batches per epoch. Positive integer.
num_mini_batches: 4
# LR schedule: adaptive (KL-based, needs desired_kl) or fixed.
schedule: adaptive
# Target KL for the adaptive schedule. Positive float; required if schedule=adaptive.
desired_kl: 0.01
# PPO clip range. Positive float.
clip_param: 0.2
# Value loss weight. Non-negative float.
value_loss_coef: 1.0
# Clip the value loss too. true or false.
use_clipped_value_loss: true
# Entropy bonus weight. Non-negative float.
entropy_coef: 0.0011
# Discount factor. Float in [0, 1].
gamma: 0.99
# GAE lambda. Float in [0, 1].
lam: 0.95
# Gradient clip norm. Positive float.
max_grad_norm: 1.0
# Normalize advantages per mini-batch instead of over the whole rollout. true or false.
normalize_advantage_per_mini_batch: false
# Critic reuses the actor's CNN encoders. true or false.
share_cnn_encoders: false
# Random Network Distillation settings, or null to disable.
rnd_cfg: null
# Symmetry augmentation/mirror-loss settings, or null to disable (see README's Symmetry section).
symmetry_cfg: null
actor:
# Model class: "MLPModel", or a "module:Class" import path.
class_name: MLPModel
# Hidden layer widths. List of positive ints, or -1 to infer a dim from the input.
hidden_dims: [256, 256, 128]
# Activation: relu, elu, selu, crelu, lrelu, leaky_relu, tanh, sigmoid, softplus, gelu, swish, mish, or identity.
activation: elu
# Normalize observations with running statistics before the MLP. true or false.
obs_normalization: true
# What makes the actor stochastic; required on the actor, omit on a deterministic network (see critic).
distribution_cfg:
# Distribution class: "GaussianDistribution" or "SquashedGaussianDistribution", or a "module:Class" path.
class_name: GaussianDistribution
# Initial std. Positive float within std_range.
init_std: 1.0
# Std clamp [min, max]. min > 0 and min < max.
std_range: [0.2, 1.5]
# Std parameterization: scalar or log.
std_type: scalar
# Std is a learnable parameter, not fixed at init_std. true or false.
learn_std: true
critic:
class_name: MLPModel
hidden_dims: [256, 256, 128]
activation: elu
obs_normalization: true
# No distribution_cfg: a critic predicts one value rather than sampling an action.Train with it:
python examples/training_example.py configs/example.yamlclass_name import paths work for the runner, not for the algorithm
The comments above describe every class_name as accepting a "module:Class" path. That currently holds for runner.class_name — resolved by create_runner, which falls back to importing the path — and for symmetry_cfg.data_augmentation_func.
The algorithm is resolved as ALGORITHMS[class_name], a plain registry lookup, so "my_pkg:MyPPO" raises KeyError. Pass the class object instead. See Introduce a Custom Algorithm.
Environment Configuration
env is read by the training script rather than by OnPolicyRunnerConfig, and it is what decides which adapter gets built.
| Key | Type | Default | Description |
|---|---|---|---|
framework | str | required | gymnasium, mjlab or isaaclab |
id | str | required | Registered task id for that framework |
num_envs | int | required | Parallel environments |
device | str | auto | auto, cpu, mps, cuda, or cuda:<index> |
clip_actions | float | None | None | Symmetric action limit applied before the actions reach the task. mjlab and Isaac Lab only |
nconmax | int | None | None | Contacts MuJoCo Warp allocates buffers for. Raise it on nconmax overflow. mjlab only |
headless | bool | True | Run Isaac Sim without a viewer window. Isaac Lab only |
Six command-line options override fields for a single run — --num-envs, --device, --num-learning-iterations, --log-dir, --seed and --resume. The framework and the task deliberately have none: pointing at a different file is how you train something else.
Runner Configuration
OnPolicyRunnerConfig
The training loop, and the parent of every other config.
| Key | Type | Default | Description |
|---|---|---|---|
obs_groups | dict[str, list[str]] | required | Maps "actor" and "critic" to the environment observation groups each consumes |
class_name | str | Callable | "OnPolicyRunner" | Runner class, as a registered name, a "module:Class" path, or the class itself. Only honoured when the runner is built by create_runner |
seed | int | None | None | Seeds Python, NumPy and PyTorch once when the runner is built, making model init, action sampling and mini-batch order reproducible. Must be in [0, 2**32). Does not seed the environment |
num_learning_iterations | int | 1000 | Iterations to train for. learn() uses this when called with no argument |
num_steps_per_env | int | 24 | Environment steps collected per environment per learning iteration |
verbose | bool | True | Whether the runner prints progress. The cadence is logger.log_interval |
check_for_nan | bool | False | Check environment outputs for NaN each step |
torch_compile_mode | str | None | None | None, "default", or "max-autotune-no-cudagraphs" |
logger | LoggerConfig | LoggerConfig() | Logging, checkpointing and resume |
algorithm | PPOConfig | PPOConfig() | Algorithm hyperparameters |
actor | MLPConfig | CNNConfig | MLPConfig(distribution_cfg=GaussianDistributionConfig()) | Actor model. Requires a distribution_cfg |
critic | MLPConfig | CNNConfig | MLPConfig() | Critic model. Deterministic scalar output |
num_envs × num_steps_per_env is the batch one iteration optimizes over. CPU Gymnasium tasks want tens of environments with long rollouts; GPU simulators want thousands with short ones.
check_for_nan costs throughput
Each check forces a GPU-to-CPU sync — a Python if on a CUDA tensor — so leaving it on every step noticeably caps GPU utilization on GPU-resident environments like mjlab and Isaac Lab. Turn it on while debugging a new environment or reward function, off for real runs.
CUDA-graph torch.compile modes are rejected: they are incompatible with the multi-model forward pattern the algorithms use.
LoggerConfig
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | True | False trains without writing anything |
log_name | str | Callable | "Logger" | Logger class, as a name or the class itself |
log_dir | str | None | "logs" | Root directory for all experiments. None keeps metrics in memory only |
experiment | str | "experiment" | Name of this experiment, the directory grouping its runs |
log_interval | int | 100 | Iterations between console reports. Scalars still go to the event file every iteration |
log_metrics | bool | True | Write scalars to the event file |
log_video | bool | False | Record an MP4 beside every checkpoint |
log_config | bool | True | Dump the resolved config as config.json |
save_interval | int | 1000 | Iterations between checkpoints |
keep_last_n | int | 5 | Checkpoints kept, oldest deleted first. 0 keeps all. model_best.pt is never rotated away |
save_best | bool | True | Keep model_best.pt, rewritten whenever the mean episode reward beats every earlier iteration's |
resume | str | None | None | Which checkpoint to continue from |
The run directory
Everything a run produces lands in one place, logs/<experiment>/<timestamp>/:
logs/gymnasium_ppo/2026-08-06_18-08-47/
├── config.json the full config the run used
├── events.out.* TensorBoard scalars
├── model_100.pt checkpoints, rotated to keep the last keep_last_n
├── model_100.mp4 with log_video, the rollout that produced that checkpoint
├── model_best.pt the highest mean reward of the run, never rotated away
└── policy.onnx the exported policylog_video renders from that iteration's own rollout — no second simulator, and one render per step only on the iterations that checkpoint. Rotation deletes a video along with the checkpoint it belongs to.
Resuming
resume continues from an earlier checkpoint and writes a fresh timestamped directory, so a resumed run never overwrites the one it continued from. It takes one value and searches every run of the experiment:
| Value | Starts from |
|---|---|
"last" | The most recently written checkpoint |
"best" | The highest-scoring checkpoint of the experiment, which may be in an older run |
"model_500.pt" | That file, found among the experiment's runs |
"path/to/model.pt" | Exactly that file, no searching |
The best score travels inside the checkpoint, so a run that continues from a strong policy and then does worse cannot demote it.
Algorithm Configuration
PPOConfig
PPO is the only algorithm registered. To plug in your own, see Introduce a Custom Algorithm.
| Key | Type | Default | Description |
|---|---|---|---|
class_name | str | Callable | "PPO" | Algorithm class, as a registry name or the class itself |
optimizer | str | "adam" | adam, adamw, sgd or rmsprop |
learning_rate | float | 1e-3 | Initial rate. Adapted at runtime under the adaptive schedule |
num_learning_epochs | int | 5 | Optimization epochs over each collected rollout |
num_mini_batches | int | 4 | Mini-batches the rollout is split into per epoch |
schedule | str | "adaptive" | "adaptive" (KL-based) or "fixed" |
value_loss_coef | float | 1.0 | Weight of the value function loss. Non-negative |
clip_param | float | 0.2 | PPO surrogate clipping parameter |
use_clipped_value_loss | bool | True | Whether to clip the value function loss |
desired_kl | float | None | 0.01 | Target KL for the adaptive schedule. None disables adaptation, and is rejected when schedule is "adaptive" |
entropy_coef | float | 0.01 | Weight of the entropy bonus. Non-negative |
gamma | float | 0.99 | Discount factor, in [0, 1] |
lam | float | 0.95 | GAE lambda, in [0, 1] |
max_grad_norm | float | 1.0 | Gradient clipping norm |
normalize_advantage_per_mini_batch | bool | False | Normalize advantages per mini-batch instead of over the whole rollout |
share_cnn_encoders | bool | False | Critic reuses the actor's CNN encoders. Requires both to be CNN models |
rnd_cfg | dict[str, Any] | None | None | Random Network Distillation. See Extension Configuration |
symmetry_cfg | SymmetryConfig | dict | None | None | Symmetry augmentation and mirror loss. See Extension Configuration |
Which of these actually decide whether a run learns, with measured numbers behind each, is covered in Tuning Best Practices.
Model Configuration
Both the actor and the critic take a model config. The actor additionally requires a distribution, which is what makes it stochastic; a critic without one predicts a single scalar.
MLPConfig
| Key | Type | Default | Description |
|---|---|---|---|
class_name | str | Callable | "MLPModel" | Model class, as a registry name or the class itself |
hidden_dims | tuple[int, ...] | list[int] | (256, 256, 256) | Hidden layer widths. A -1 is inferred from the input dimension |
activation | str | "elu" | relu, elu, selu, crelu, lrelu/leaky_relu, tanh, sigmoid, softplus, gelu, swish, mish, identity |
obs_normalization | bool | False | Normalize observations with running statistics before the network |
distribution_cfg | GaussianDistributionConfig | None | None | Output distribution. None gives a deterministic output, which is what a critic wants |
Turn on obs_normalization
It is worth keeping on for every task: observation entries span angles, velocities and contact forces with wildly different scales, and the normalization is exported with the policy, so deployment sees the same inputs training did.
CNNConfig
For image observations. Inherits every MLPConfig field — those configure the MLP head placed after the encoder — and adds:
| Key | Type | Default | Description |
|---|---|---|---|
class_name | str | Callable | "CNNModel" | Model class, as a registry name or the class itself |
cnn_cfg | CNNEncoderConfig | dict[str, CNNEncoderConfig] | None | None | A single encoder config shared by all image observations, or a mapping from group name to encoder config |
A CNN model requires its observation set to name exactly one group shaped (channels, height, width). Set share_cnn_encoders on the algorithm to have the critic reuse the actor's encoders.
CNNEncoderConfig
Per-layer settings accept either a scalar shared by all layers or a sequence with exactly one entry per layer.
| Key | Type | Default | Description |
|---|---|---|---|
output_channels | tuple[int, ...] | list[int] | (32, 64, 64) | Output channels per layer. Its length defines the layer count |
kernel_size | int | tuple[int, ...] | list[int] | 3 | Convolution kernel size(s) |
stride | int | tuple[int, ...] | list[int] | 1 | Convolution stride(s) |
dilation | int | tuple[int, ...] | list[int] | 1 | Convolution dilation(s) |
padding | str | "none" | none, zeros, reflect, replicate, circular |
norm | str | tuple[str, ...] | list[str] | "none" | none, batch, layer |
activation | str | "elu" | Activation after each layer |
max_pool | bool | tuple[bool, ...] | list[bool] | False | Max pooling after each layer |
global_pool | str | "none" | none, max, avg on the final feature map |
flatten | bool | True | Flatten before the MLP head |
Distribution Configuration
GaussianDistributionConfig
| Key | Type | Default | Description |
|---|---|---|---|
class_name | str | Callable | "GaussianDistribution" | Or "SquashedGaussianDistribution" |
init_std | float | 1.0 | Initial standard deviation. Must lie within std_range |
std_range | tuple[float, float] | (1e-6, 1e6) | Bounds the standard deviation is clamped to. min > 0 and min < max |
std_type | str | "scalar" | "scalar" or "log" parameterization |
learn_std | bool | True | Whether the standard deviation is a learnable parameter |
Two things worth setting rather than leaving at the default:
- A real
std_rangefloor, e.g.(0.2, 1.5). The entropy bonus has no target, so without a floor the standard deviation decays until the policy is near-deterministic and stops improving — measured on Humanoid-v5, 1.00 → 0.29 while the reward peaked and fell back. - The plain Gaussian over the squashed one at high action dimension.
SquashedGaussianDistributionis numerically fragile once the mean drifts:tanhsaturates, its log probability explodes and the ratio overflows.
Extension Configuration
Both extensions hang off PPOConfig and are off by default.
Symmetry Augmentation
A left-right symmetric robot gives the algorithm an invariance for free. Mirrored samples are appended to every mini-batch, and an optional term penalizes the policy for disagreeing with itself on them.
| Key | Type | Default | Description |
|---|---|---|---|
data_augmentation_func | Callable | str | None | None | The mirror function, or an import path such as "my_robot.symmetry:mirror". Required — constructing the config without one raises |
use_data_augmentation | bool | True | Append the mirrored samples to every mini-batch |
use_mirror_loss | bool | True | Add the mirror loss to the objective |
mirror_loss_coeff | float | 1.0 | Weight of the mirror loss. Non-negative |
It is configured in Python rather than from a config file alone, because the piece it needs cannot be shipped: a mirror function saying which observation entry mirrors which, which only somebody who knows the robot's joint order can write. Setting both flags to False still computes and reports the loss, detached — a cheap way to watch how symmetric a policy is without changing what it optimizes. Not supported for recurrent policies.
See symmetry for how to write a mirror function and, more importantly, how to verify one.
Random Network Distillation
Not implemented
rnd_cfg exists on PPOConfig and is validated, but the extension behind it is not implemented: setting it to anything other than None raises at construction. Treat it as reserved.
Observation Groups
obs_groups is the wiring between what your environment publishes and what each network reads. The two keys on the left are fixed, "actor" and "critic"; the group names on the right are your environment's.
# Symmetric: both networks see the same thing
obs_groups={"actor": ["observation"], "critic": ["observation"]}
# Asymmetric: the critic also sees state the robot cannot measure
obs_groups={"actor": ["policy"], "critic": ["policy", "privileged"]}An environment returns its observations as a TensorDict of named groups, and each name is a group. Each backend names them differently:
| Backend | Groups it publishes |
|---|---|
| Gymnasium | observation — one flat group |
| mjlab | actor, plus a privileged critic, plus camera on the vision tasks |
| Isaac Lab | policy, plus critic on tasks set up for an asymmetric actor-critic |
observation_spec(env) reports what a task actually publishes, which is the reliable way to find out before writing a config:
from telekinesis.rlbotics.envs.base import observation_spec
observation_spec(env) # {"policy": (48,), "privileged": (235,)}A group named in obs_groups that the environment does not publish raises at construction, listing what was available.
Why the critic gets its own set
Only the actor is exported for deployment, so anything the critic reads costs nothing at runtime. That makes it free to give the critic state the robot could never measure — contact forces, terrain height under each foot, the true base velocity — which produces a better value estimate and therefore better advantages, without making the deployed policy depend on information it will not have. This is the asymmetric actor-critic setup, and it is why mjlab and Isaac Lab tasks publish a privileged group at all.
One group or several
How a set is sized follows from how many groups it names:
- One group keeps that group's shape, including a 3-dimensional
(channels, height, width)image. ACNNConfigmodel therefore requires its set to name exactly one group of images. - Several groups are concatenated along the feature dimension, which requires every one of them to be a flat vector. Mixing an image into a multi-group set raises rather than silently flattening it, with a message naming the offending group and its shape.
So {"critic": ["policy", "privileged"]} builds a critic sized 48 + 235 = 283, while {"actor": ["camera"]} keeps (3, 64, 64) for a CNN to consume.
Building a Configuration in Python
From a dictionary
from_dict is what the training example uses, and it validates on the way in — so a typo in a YAML file is caught at load rather than ignored:
import yaml
from telekinesis.rlbotics.config import OnPolicyRunnerConfig
data = yaml.safe_load(open("configs/gymnasium/Humanoid-v5.yaml"))
runner_cfg = OnPolicyRunnerConfig.from_dict(data["runner"])
env_cfg = data["env"] # the training script reads this itselfUnknown keys are rejected with a message listing the valid ones. Nested configs may be given as dictionaries or as config instances, so algorithm={"learning_rate": 1e-4} is coerced into a PPOConfig.
Two more methods come with every config:
runner_cfg.to_dict() # nested plain dictionaries, the inverse of from_dict
runner_cfg.replace(num_steps_per_env=48) # a revalidated copyto_dict round-trips, which is how configuration_example.py writes a Python-built configuration back out as a YAML file that training_example.py can then train from as-is.
Field by field
Worth doing when a configuration is computed rather than fixed — a sweep, a network sized from the observation, a hyperparameter that comes from somewhere else. This is make_runner_config from configuration_example.py:
def make_runner_config(
obs_dim: int,
log_dir: str,
num_learning_iterations: int = 20,
experiment: str = "configuration_example",
seed: int | None = None,
) -> OnPolicyRunnerConfig:
"""Build a runner configuration field by field.
Args:
obs_dim: Observation size of the task, used to size the networks. A 348-dimensional Humanoid
observation needs more capacity than a 3-dimensional Pendulum one, which is the kind of
decision a YAML file cannot make for itself.
log_dir: Directory the run is written under.
num_learning_iterations: Iterations to train for, which :meth:`OnPolicyRunner.learn` reads
from the config when called with no argument.
experiment: Experiment name, the directory its runs are grouped under.
seed: Seed for model init, action sampling and mini-batch order, or None to leave it random.
Returns:
The runner configuration.
"""
hidden_dims = (256, 256, 128) if obs_dim >= 100 else (64, 64)
# What makes the actor stochastic. The std floor keeps exploration alive: without one the
# standard deviation decays until the policy is near-deterministic and stops improving.
distribution_cfg = GaussianDistributionConfig(
class_name="GaussianDistribution",
init_std=1.0,
std_range=(0.2, 1.5),
)
# Observation normalization is worth keeping on for every task, since observation entries span
# angles, velocities and contact forces with wildly different scales. It is exported with the
# policy, so a deployment sees the same inputs training did.
actor = MLPConfig(
class_name="MLPModel",
hidden_dims=hidden_dims,
activation="elu",
obs_normalization=True,
distribution_cfg=distribution_cfg,
)
# No distribution: a critic predicts one value rather than sampling
critic = MLPConfig(
class_name="MLPModel",
hidden_dims=hidden_dims,
activation="elu",
obs_normalization=True,
)
algorithm = PPOConfig(
# A registered name, or an import path such as "my_pkg.algorithms:MyPPO" for your own
class_name="PPO",
optimizer="adam",
learning_rate=3.0e-4,
num_learning_epochs=10,
num_mini_batches=4,
# An adaptive schedule holds each update inside the KL trust region, which is what stops the
# reward climbing and then collapsing
schedule="adaptive",
desired_kl=0.01,
clip_param=0.2,
entropy_coef=0.01,
gamma=0.99,
lam=0.95,
max_grad_norm=1.0,
value_loss_coef=1.0,
)
# The run lands in <log_dir>/<experiment>/<timestamp>, with the event file, the config dump and
# the checkpoints flat inside it. Resuming looks across the experiment's runs.
logger_cfg = LoggerConfig(
log_dir=log_dir,
experiment=experiment,
log_interval=10,
save_interval=25,
keep_last_n=5,
save_best=True,
log_video=False,
resume=None,
)
return OnPolicyRunnerConfig(
# A registered name, or an import path such as "my_pkg.runner:MyRunner" for your own
class_name="OnPolicyRunner",
# Left None by default: set it for a reproducible run, e.g. when comparing two configs
seed=seed,
# Which observation groups each network reads. The Gymnasium adapter publishes one,
# "observation"; a simulator with privileged state gives the critic its own group here.
obs_groups={"actor": ["observation"], "critic": ["observation"]},
num_learning_iterations=num_learning_iterations,
num_steps_per_env=128,
verbose=True,
# A host sync every step, so leave it off unless an environment may return NaN
check_for_nan=False,
torch_compile_mode=None,
logger=logger_cfg,
algorithm=algorithm,
actor=actor,
critic=critic,
)The tree is built inside out: a distribution config goes into the actor's model config, and the model, logger and algorithm configs go into the runner config. Each piece validates as it is constructed, so a bad value is rejected at its own line.
Then build the runner through create_runner, which is what makes class_name mean something — OnPolicyRunner(...) always builds that one class:
from telekinesis.rlbotics.runner import create_runner
runner = create_runner(env=env, runner_cfg=runner_cfg, device="auto")
runner.learn() # uses num_learning_iterations from the config
