Containers

The nnx_ppo.networks.containers module provides the four core composition primitives: Sequential, Parallel, Concat, and Splitter. Every container is itself a StatefulModule, so they nest freely.

Each container threads three things through its children:

  • carry state — sliced per-child the same way state is structured (list for Sequential, dict for Parallel/Concat, leaf for Splitter);

  • rollout_extras — same routing as state;

  • regularization loss & metrics — accumulated from all children and surfaced on the container’s own StatefulModuleOutput.

Sequential

Chain layers. Sequential([a, b, c]) runs a b c and routes each layer’s carry state through a list keyed by position.

from nnx_ppo.networks.containers import Sequential

stack = Sequential([encoder, body, head])
state = stack.initialize_state(batch_size)
out = stack(state, obs)
# out.next_state == [encoder.next_state, body.next_state, head.next_state]

Sequential is the workhorse: most “chains of layers” are Sequentials. Use it as the action / value port of a PPOAdapter, as the inner body of a Parallel branch, etc.

Parallel

Run several named sub-modules on the same input. Returns a dict keyed by sub-module name.

from nnx_ppo.networks.containers import Parallel

# Same input fed to every component.
branches = Parallel(action_params=actor, value=critic)
out = branches((), obs)
# out.output == {"action_params": <actor_out>, "value": <critic_out>}

Typical use: assemble per-key heads on top of a shared trunk, e.g. per-body-module value heads (Parallel({k: Dense(H, 1) for k in keys})) fed by the same trunk features.

Concat

Per-key dispatch + concat: dict input, single-tensor output. Each named sub-module sees the upstream’s same-named entry and the per-component outputs are concatenated along the last axis.

from nnx_ppo.networks.containers import Concat

# Per-stream encoders → single concatenated feature vector.
encoders = Concat(proprio=proprio_encoder, goal=goal_encoder)
out = encoders(state, {"proprio": (B, P), "goal": (B, G)})
# out.output : (B, H_proprio + H_goal)

Use at the start of a stack when the observation is structured.

Splitter

Inverse of Concat. Takes a flat tensor and splits it into named slices along the last axis.

from nnx_ppo.networks.containers import Splitter

s = Splitter(action_params=2 * action_size, value=1)
out = s((), x)  # x : (B, 2A + 1)
# out.output == {"action_params": (B, 2A), "value": (B, 1)}

Use at the end of a stack to turn a flat head into a dict output an adapter / downstream can route to samplers and value specs. With a single keyword (Splitter(action_params=N)) it simply relabels the input as a dict, taking the first N features.

The slices are taken in keyword-argument insertion order. The sum of declared sizes must not exceed the input’s last-axis size; any excess input features are silently ignored, matching plain slicing semantics.

Construction forms

Concat and Parallel accept either keyword arguments or a positional dict, e.g.:

Parallel({pop: head for pop in POPULATIONS})

Use the positional form when keys aren’t valid Python identifiers or when you’re building the container from a comprehension.

Writing your own container

The four shipped containers are not load-bearing on the rest of the library — they are ordinary StatefulModule s that thread plumbing through their children. If none of them captures your topology you can write your own. The contract has four parts.

1. Be a valid Flax NNX module

A container holds child modules and their parameters. To make NNX trace those parameters, every sub-module must be either a direct attribute or sit inside an NNX-tracked container. For a fixed set of children, plain attribute assignment is enough:

class TwoChild(StatefulModule):
    def __init__(self, a, b):
        self.a = a
        self.b = b

For a variable-size list or dict of children, wrap them in flax.nnx.List or flax.nnx.Dict. A plain Python list / dict of modules will not be traced by NNX and your parameters will silently disappear from the trainable set:

from flax import nnx

class MyMap(StatefulModule):
    def __init__(self, modules: dict[str, StatefulModule]):
        self.components = nnx.Dict(modules)   # NNX-tracked

This is what every shipped container does internally (Sequential uses nnx.List; Concat / Parallel use nnx.Dict).

2. Forward state and rollout_extras per child

In __call__, slice the incoming state and rollout_extras per child the same way, call each child, and reassemble both into output containers shaped exactly like the input. A typical loop:

def __call__(self, state, x, rollout_extras=None):
    new_state, new_extras = {}, {}
    ...
    for name, child in self.components.items():
        child_extras = None if rollout_extras is None else rollout_extras[name]
        out = child(state[name], <child input>, child_extras)
        new_state[name] = out.next_state
        new_extras[name] = out.rollout_extras
        ...

The crucial invariant is that the shape of returned next_state mirrors the shape of the incoming state, and the same for rollout_extras. If a caller passes rollout_extras=None, you pass None to every child (each child knows how to fall back). If a caller passes a non-None value, the structure must match what your container emitted on a previous step.

You also need to implement initialize_state() and (if your children carry resettable state) reset_state() with the same shape — for the same reasons.

3. Aggregate regularization_loss and metrics

Each child’s StatefulModuleOutput.regularization_loss should flow up to your container’s output. In most cases this is just a sum:

regularization_loss = jp.array(0.0)
for ...:
    out = child(...)
    regularization_loss += out.regularization_loss

If your container has a reason to scale or otherwise transform a child’s regularization (e.g. a hypothetical container that runs children alternately and only counts the active one’s loss), do that here — but the default is a plain sum.

metrics are typically collected into a per-child dict {child_name: out.metrics} so downstream logging can disambiguate. Sequential uses positional integer keys; the named-dispatch containers use string keys matching their children.

4. Route update_statistics per child

Stats accumulation runs outside __call__. Override update_statistics() to route the rollout’s stacked rollout_extras per child analogously to how __call__ routes the per-step extras:

def update_statistics(self, rollout_extras):
    for name, child in self.components.items():
        child.update_statistics(rollout_extras[name])

Without this override the default implementation is a no-op for the container, which means any stats-bearing module nested inside it will never see its data and silently fail to update. This is the most common mistake when writing a new container — easy to forget because every other rule fails loudly while this one fails silently.

Worked example

The shipped Parallel is the smallest container that exercises all four rules; reading [its source](../_modules/nnx_ppo/networks/containers.html) is the fastest way to see the pattern end-to-end (~50 LoC). Sequential is similar but lists rather than dicts; the per-key dispatch containers Map and Merge are also good references.