API reference
Every public module, class and function in the coadapt package, with its docstring pulled straight from the source. Read them in pipeline order: spaces → env → envs → network → agent → rollout → solvers → storage → plotting.
Modules
- coadapt
- coadapt.agent
- coadapt.env
- coadapt.envs
- coadapt.envs.relay_discrete
- coadapt.envs.robot_consensus
- coadapt.network
- coadapt.plotting
- coadapt.rollout
- coadapt.solvers
- coadapt.solvers.common
- coadapt.solvers.grpo
- coadapt.solvers.ppo
- coadapt.solvers.random_oracle
- coadapt.solvers.recurrent
- coadapt.solvers.reinforce
- coadapt.solvers.trpo
- coadapt.spaces
- coadapt.storage
coadapt
coadapt — a small, decentralized multi-agent RL harness under partial observability.
Pipeline (read the modules in this order to follow the flow):
spaces -> what an action looks like (Discrete / MultiDiscrete / Box)
env -> the Env contract + EnvSpec (the base class)
envs/ -> the N-actor worlds: task state, per-actor measurement, team reward
network -> the communication layer: who hears whom, and how stale (partial obs)
agent -> N independent local policies (one per actor), no parameter sharing
rollout -> the observe -> act -> step -> communicate loop (scan over time)
solvers -> from-scratch REINFORCE (one independent learner per agent)
storage -> save a run as a structured dict file
plotting -> read those dict files and draw the standard figures
The whole point of the design: environment actor i is bound 1:1 to agent i, each agent
sees only its own measurement plus *delayed* messages from graph neighbours, and each
agent trains its own policy — a decentralized, partially-observed cooperative setting.coadapt.agent
agent.py — N independent local policies, one per actor, with NO parameter sharing.
Each agent is a small memoryless MLP: obs -> hidden (tanh) -> action-distribution params.
The obs an agent sees is its OWN measurement (from the env) concatenated with the DELAYED
message from its parent (from the network). That concatenation is the only thing the policy
ever sees — partial observability is therefore structural.
How we keep N *independent* policies while staying fast on a GPU:
- We store all N policies as ONE pytree whose leaves have a leading agent axis [N, ...].
Agent i's parameters are just the i-th slice of every leaf.
- `jax.vmap(single_policy_forward)(params, obs)` runs all N MLPs at once, each on its own
parameters and its own obs. Same math as a Python loop over agents, but vectorised.
- Because every agent has its own leaf slice, updating agent i never touches agent j
(guaranteed; there is no shared weight tensor). This is what "strictly decentralized"
means here, in contrast to the old shared-parameter IPPO.
The `Agent(params, i)` handle is a thin convenience view for the experimenter / tests: it
exposes agent i's own parameters. The hot path uses the vmapped functions directly.class Agent(self, agents, i)
A read-only view of ONE agent inside the stacked bundle. For tests / introspection. `agents` is the stacked pytree; `i` is the agent index. `.policy` returns agent i's own parameter pytree (its leaf slices), demonstrating that each agent's policy is local.
forward(self, obs_i)
Run just this agent's policy on a single obs [obs_dim] -> dist params.
act(agents, obs, key, action_space)
All N agents act at once on their own obs (the decentralized-execution step).
agents : stacked pytree, leaves [N, ...]
obs : [N, obs_dim] each agent's own (partial) observation
returns:
actions : env-native action array (shape depends on the space)
dist_params : [N, param_size] each agent's raw distribution params (also the message)
logp : [N] log-prob of the action each agent tookforward(params, obs)
One agent: obs [obs_dim] -> action-distribution params [param_size].
init_agents(key, N, obs_dim, param_size, hidden=32)
Build N INDEPENDENT policies as one stacked pytree with leading axis [N, ...]. We split the key N ways and vmap init_params, so each agent gets its own random weights.
init_params(key, obs_dim, param_size, hidden=32)
Random parameters for ONE agent's MLP: obs_dim -> hidden -> param_size. Plain dict of arrays (a pytree). Small Glorot-ish init.
coadapt.env
The environment contract: EnvSpec + the base class every world implements.
An environment here is ONLY the task/world dynamics:
- it holds the hidden target the team must match,
- it produces each actor's own *internal measurement* (what agent i physically senses),
- it turns the agents' actions into a shared team reward.
It does NOT handle communication — that is the job of network.py. Keeping the two apart is
what makes "partial observability lives in the network" true and testable.
The world is functional (JAX-friendly): reset/step take a key and a state and return new
values instead of mutating anything, so we can `scan` over time and `vmap` over seeds.
reset(key) -> (measurement, world)
step(key, world, actions) -> (measurement, world, reward, done, info)
measurement : float32 [N, meas_dim] what each actor senses of ITSELF/the world
world : a small frozen dataclass holding the hidden task state
reward : scalar shared team reward in [0, 1]
actions : shape depends on the action space (see spaces.py)
info : dict; MUST contain "per_actor_error" -> float32 [N] (per-actor tracking
error). The rollout records it for the error-vs-distance metric, so every
env's step must return this key. Additional keys are free-form.
Reference bound note: `solvers.random_oracle.oracle_return` assumes the relay measurement
layout (measurement[:,0]=is_source flag, measurement[:,1]=target-if-source). A new world with
a different measurement layout should provide its own oracle/ceiling rather than rely on that
one (it raises TypeError for action spaces it does not cover).class Env
Base world. Subclasses set `self.spec` and implement reset/step.
reset(self, key)
(no docstring)
step(self, key, world, actions)
(no docstring)
class EnvSpec(self, num_agents: int, meas_dim: int, action_space: Any, horizon: int, jittable: bool = True) -> None
Everything a solver needs to know about a world without looking inside it.
coadapt.envs
Concrete worlds, each implementing the Env contract in ../env.py. Stage 1 ships two: relay_discrete — discrete actions (the Phase-1 reference-tracking toy) robot_consensus — continuous actions (a leader-follower platoon) Later stages add the checklist (multi-discrete) and coordination worlds.
coadapt.envs.relay_discrete
relay_discrete — the discrete reference-tracking world (the Phase-1 toy, in JAX).
The story in one paragraph:
There is a hidden scalar target theta_t in [0,1] that the whole team must match. Only the
SOURCE actor (actor 0) senses theta_t directly. Every other actor senses only its own last
action. So a non-source actor can only learn theta_t from what its neighbours tell it
(handled by network.py) — and because messages are delayed, information about a drifting
target arrives late the further you are from the source. The team reward rewards everyone
for matching theta_t. When theta_t drifts (nu > 0), distant actors chase a stale value and
the reward collapses. When theta_t is fixed (nu = 0), it is an easy static task (the control).
This file owns ONLY the target, the drift, the per-actor measurement, and the reward. Who
hears whom (and how stale) is network.py's job.
Action space: Discrete(K) — each actor outputs an integer in {0..K-1}; a/(K-1) is its guess
of theta_t in [0,1].class RelayDiscreteEnv(self, N=8, K=5, horizon=60, nu=0.0, source=0)
(no docstring)
reset(self, key)
(no docstring)
step(self, key, world, actions)
(no docstring)
class RelayWorld(self, theta: jax.Array, last_action: jax.Array, t: jax.Array) -> None
The hidden task state. A chex.dataclass so it is a JAX pytree we can scan over.
coadapt.envs.robot_consensus
robot_consensus — the continuous world (a leader-follower platoon). The story: N robots on a line. The leader (actor 0) knows a target position ref. Every other robot wants to sit at ref too, but only senses the robot ahead of it (through the network, so possibly delayed). Actions are continuous positions in [low, high]. The reward is how close the whole platoon is to ref. This is the continuous twin of relay_discrete: same "information arrives late down the line" story, but with a continuous action so we exercise the Box action space and a Gaussian policy. Owns ONLY the target, its drift, the per-robot measurement, and the reward. Communication (who senses whom, delayed) is network.py's job. Action space: Box((1,), low, high) — each robot outputs its next position.
class RobotConsensusEnv(self, N=8, horizon=60, nu=0.0, source=0, low=-1.0, high=1.0)
(no docstring)
reset(self, key)
(no docstring)
step(self, key, world, actions)
(no docstring)
class RobotWorld(self, ref: jax.Array, pos: jax.Array, t: jax.Array) -> None
RobotWorld(ref: jax.Array, pos: jax.Array, t: jax.Array)
coadapt.network
network.py — the communication layer, and the ONLY place partial observability lives.
What this module does:
- builds a communication graph over the N actors (line / ring / star / grid / tree),
- from a chosen source, computes each actor's PARENT (the neighbour one hop toward the
source) and DISTANCE (hops to the source),
- keeps a delay buffer of recent MESSAGES so that what an actor hears from its neighbour
is `d` rounds stale (messages arrive late; information from far away arrives later),
- exposes the two pings the plan requires:
ping_channel(net, i) -> what agent i is ALLOWED to hear (delayed neighbour message)
ping_truth(net,...) -> ground truth, for the experimenter's logs/plots ONLY.
A "message" from actor j is the concatenation of:
[ state-feature(j) , action-distribution-params(j) ]
i.e. what j did (its normalized action) and how j's policy is currently behaving. Agents
communicate BOTH, per the design. Messages are compact float vectors so the delay buffer
stays small.
The key modelling choice (kept simple on purpose): an actor's channel observation is the
delayed message of its PARENT. On a line this reproduces the classic result that information
about a change is stale by ~ distance x (1 + delay) — the "information-arrival wall". The
source has no parent, so it hears nothing over the channel (it already senses the target).class NetState(self, parent: jax.Array, dist: jax.Array, buffer: jax.Array) -> None
Everything about communication. The static graph fields never change during a run; only `buffer` advances each round. Kept in one pytree so it scans cleanly.
class Network(self, N, action_space, topology='line', source=0, delay=0)
Holds the graph shape + message size and knows how to build/advance NetState. `msg_dim = state_feature_size + action_param_size`. We fix it at construction so the delay buffer has a static shape (required by JAX).
init_state(self)
A fresh NetState with an empty (zero) message buffer.
make_message(self, action_feature, action_params)
Pack one round of messages: [N, msg_dim] = concat(feature, dist-params).
ping_channel(self, net)
What every agent is ALLOWED to hear this round: the DELAYED message of its parent. Returns [N, msg_dim]. The source hears a zero message (it has no parent / no need). This is the only communication an agent's observation may use — enforced by tests.
ping_truth(self, net, message)
GROUND TRUTH for the experimenter only: the *current* (undelayed) message of every actor. Never wire this into an agent's observation — it would erase partial observability. Used by storage/plotting to see what actually happened.
push(self, net, message)
Advance the delay buffer by inserting this round's message at the front and dropping the oldest. buffer[0] is newest; buffer[delay] is `delay` rounds old.
coadapt.plotting
plotting.py — the plotting harness: read stored run dicts and draw standard figures. This module is a PURE CONSUMER of storage.py output. It never runs a simulation; it only turns saved numbers into pictures. That separation means a figure can always be regenerated from a saved run without recomputing anything. Each function takes a loaded run dict (see storage.load_run) and an output path, draws one sensible figure, and returns the path written.
plot_error_by_distance(run, path)
Per-agent tracking error vs hops-from-source (the information-arrival wall). Expects curves['error_by_distance'] = [max_dist+1] and curves['dist'] optional.
plot_learning_curve(run, path)
A learning curve: mean per-step team reward vs iteration, with random/oracle lines if they were recorded in metrics. `run` is a loaded run dict.
plot_surface_3d(run, path)
3-D collapse surfaces: z = achievable (oracle) reward over (delay, nu), one subplot per N. Shows how the surface sinks as the network grows — the information-arrival wall. Expects curves['delays'], curves['nus'], and one Z array per N named 'oracle_N<k>' of shape [len(nus), len(delays)]. config['Ns'] lists the N values (subplot order).
plot_sweep(run, path)
Consolidated sweep figure: mean per-step team reward vs N, one line per (condition, solver). Expects curves['N'] and curves['<condition>_<solver>'] arrays. `run['config']['series']` lists the (condition, solver) keys to draw.
coadapt.rollout
rollout.py — the one loop that runs a whole episode: observe -> act -> step -> communicate.
Each round, for every agent i at once (vmapped over the agent axis):
1. observe : obs_i = own measurement (from env) ++ delayed parent message (from network)
2. act : actions, dist_params, logp = agents act on their obs
3. step : env turns actions into the next world + the shared team reward
4. commun. : pack this round's messages and push them into the network's delay buffer
We `scan` over time (one compiled loop) and `vmap` over seeds (many episodes in parallel).
`horizon` and `n_seeds` are arguments, so any run can use an arbitrary episode length and an
arbitrary number of seeds — this is what makes the harness scale on a GPU.
A Traj bundles the per-round records we need for learning and for metrics:
obs [T, N, obs_dim] actions [T, N, ...] logp [T, N]
reward[T] per_actor_error [T, N] dist [T, N] (hops to source, constant)obs_dim(env, net)
The width of an agent's observation vector = own measurement + one parent message. Also checks the 1:1 binding: the env's actor count must equal the network's node count (each solver then builds exactly this many independent policies).
observation(env_meas, channel_msg)
Assemble each agent's observation: own measurement ++ delayed parent message. This is the ONLY function that decides what a policy sees, so partial observability is localized here and in network.ping_channel.
rollout(env, net, agents, key, horizon=None)
Run ONE episode. Returns a Traj (dict of [T, ...] arrays).
rollout_batch(env, net, agents, key, n_seeds, horizon=None)
Run `n_seeds` independent episodes in parallel (vmap over seeds). Returns a Traj with a leading seed axis: [S, T, ...] (all fields are batched over seeds, including the per-round dist -> [S, N]).
coadapt.solvers
Solvers: things that learn a policy for the N agents, and reference bounds.
Every learning solver is a MODULE exposing the same two entry points, so the runner and the
comparison script can treat them uniformly and a new baseline plugs in by adding one line
to SOLVERS below:
solve(env, net, key, *, n_seeds, horizon, n_iters, **hp)
-> {"return_curve": [...], "final_return": float, ...}
default_hparams(env_name) -> dict # sensible per-env hyperparameters
Learning solvers (each an independent learner PER AGENT, no parameter sharing):
reinforce — vanilla policy gradient (baseline of baselines)
ppo — clipped surrogate + per-agent critic + GAE
grpo — critic-free, group-relative advantage over the seed batch
trpo — KL-constrained natural-gradient step (conjugate gradient + line search)
recurrent — PPO whose policy carries its OWN GRU hidden state (a belief over stale
messages); self-contained, does not touch agent.py
Reference bounds (not learners): random floor and oracle ceiling, in random_oracle.py.coadapt.solvers.common
common.py — shared building blocks for the from-scratch solvers. Kept in one place so every solver (ppo, grpo, trpo, recurrent) reads the same way and we do not repeat the returns / GAE / Adam code. Nothing here touches the environment or the agent definition — these are pure array helpers.
actor_params_all(actor, obs)
Run every agent's actor over a batched obs [S,T,N,obs_dim] -> dist params [S,T,N,P]. vmap innermost over the agent axis, then over T, then over S.
adam_init(params)
(no docstring)
adam_step(params, grads, m, v, t, lr, b1=0.9, b2=0.999, eps=1e-08)
One Adam update over a whole pytree (per-parameter, hence per-agent).
critic_values_all(critic, obs)
Run every agent's critic over obs [S,T,N,obs_dim] -> values [S,T,N].
discounted_returns(rewards, gamma)
rewards [S, T] -> discounted-to-go G [S, T] (reverse scan over time).
final_return(curve, n_iters)
Mean per-step team reward over the last 10% of iterations (same metric for all solvers, so the comparison table is apples-to-apples).
gae(rewards, values, gamma, lam)
Generalized Advantage Estimation. rewards [S, T] shared team reward values [S, T, N] per-agent value estimates returns adv [S, T, N], ret [S, T, N] (ret = adv + values, the critic target)
standardize_per_agent(adv)
Zero-mean/unit-std per agent over the (seed, time) batch — stabilizes the gradient.
coadapt.solvers.grpo
grpo.py — Group Relative Policy Optimization, from scratch, per agent. GRPO is PPO WITHOUT a critic. Instead of learning a value baseline, it uses the batch of parallel episodes as a "group": an agent's advantage for an episode is its return minus the group mean, divided by the group std. Our rollout already runs `n_seeds` episodes in parallel, so the group is free — this makes GRPO the cheapest of the strong baselines here (no second network to train). Same as ppo.py otherwise: independent per-agent actors (no parameter sharing), a clipped surrogate, several epochs per batch, and greedy evaluation. The advantage is per-EPISODE (one number per seed per agent), broadcast across time, which is the group-relative signal REINFORCE-with-a-mean-baseline lacks the normalization of.
default_hparams(env_name)
(no docstring)
solve(env, net, key, *, n_seeds=16, horizon=None, n_iters=200, lr=0.003, gamma=0.98, clip=0.2, epochs=4, ent_coef=0.01, hidden=64)
Train N independent GRPO agents. Returns {policy, return_curve, final_return}.coadapt.solvers.ppo
ppo.py — from-scratch PPO, one INDEPENDENT learner per agent.
Same decentralized setup as reinforce.py (each agent has its own actor, no parameter
sharing), but with the three things that make PPO strong and stable:
1. a per-agent CRITIC (a second MLP, reusing agent.forward) that estimates each agent's
value, giving a low-variance advantage via GAE,
2. a CLIPPED surrogate objective so each update cannot move the policy too far,
3. several epochs of minibatch-free full-batch updates per collected rollout.
Everything is vmapped over the agent axis, so the N critics/actors are independent but run in
one call. We reuse agent.py's MLP for BOTH the actor (outputs action-dist params) and the
critic (outputs a single value) — the critic is just an MLP with param_size = 1. This keeps
the change confined to solvers/ (agent.py is untouched).
Evaluation metric (`final_return`) uses GREEDY actions (argmax / distribution mean) so the
reported number reflects the learned policy without exploration noise — this is what lets
PPO get close to the oracle ceiling.default_hparams(env_name)
(no docstring)
solve(env, net, key, *, n_seeds=16, horizon=None, n_iters=200, lr=0.003, gamma=0.98, lam=0.95, clip=0.2, epochs=4, ent_coef=0.01, vf_coef=0.5, hidden=64)
Train N independent PPO agents. Returns {policy, critic, return_curve, final_return}.
return_curve[it] is the GREEDY mean per-step team reward (comparable to oracle/random).coadapt.solvers.random_oracle
random_oracle.py — reference policies that bound every result.
random_return : uniform-random actions. The floor any learner should beat.
oracle_return : the best simple decentralized policy for the relay task — copy the
freshest thing you can about the target. On the relay worlds this is the
"copy your delayed parent, or use the target if you are the source" rule.
It is the achievable ceiling given the staleness in the channel, so it
shows how much of the collapse is the SETTING (not the learner).
Both return the mean per-step team reward, averaged over seeds — directly comparable to
REINFORCE's return_curve entries.oracle_return(env, net, key, n_seeds=16, horizon=None)
Copy-the-freshest-target rule. obs = [own meas (meas_dim), channel msg (msg_dim)]. For the relay worlds: measurement[:,0]=is_source, measurement[:,1]=target if source. The channel message's first `feature_size` slots = parent's normalized action.
random_return(env, net, key, n_seeds=16, horizon=None)
(no docstring)
coadapt.solvers.recurrent
recurrent.py — PPO with a self-contained per-agent GRU (a belief over stale messages). This is the partial-observability baseline. Memoryless policies cannot integrate DELAYED messages into an estimate of the moving target, so they hit a structural ceiling below the oracle. A recurrent policy carries a hidden state through time, letting each agent build a belief from the history of its own (stale) observations — the standard POMDP remedy. Constraint honored: the recurrence lives ENTIRELY inside this solver. It defines its own GRU parameters and its own rollout; it does NOT modify agent.py or the environment. The problem setup (env, network, obs) is unchanged — only the policy that consumes the obs has memory. Each agent has its OWN GRU + heads (no parameter sharing), all stacked on a leading [N,...] axis and vmapped, exactly like the memoryless agents. We reuse the environment/network step functions but run our own scan so we can thread the hidden state.
default_hparams(env_name)
(no docstring)
solve(env, net, key, *, n_seeds=16, horizon=None, n_iters=200, lr=0.003, gamma=0.98, lam=0.95, clip=0.2, epochs=4, ent_coef=0.01, vf_coef=0.5, hidden=64)
Train N independent recurrent-PPO agents. Returns {policy, return_curve, final_return}.
Actor and critic share the GRU trunk (one net per agent with two heads).coadapt.solvers.reinforce
reinforce.py — REINFORCE written from scratch, one INDEPENDENT learner per agent.
No RL library. The only borrowed machinery is `jax.grad` for autodiff; the algorithm
(returns, baseline, loss, Adam update) is all here in plain code so the flow is readable.
Why "one independent learner per agent":
- The reward is a shared team scalar (cooperative task), so at time t every agent sees the
same reward r_t. We form the discounted return G_t from that shared reward.
- But each agent has its OWN policy parameters and its OWN log-probs. Agent i's loss uses
only agent i's log-probs and its own baseline; we take the gradient of agent i's loss
w.r.t. agent i's parameters ONLY. There is no pooling of gradients across agents.
- Concretely: we build a per-agent loss, then `jax.grad` w.r.t. the stacked agent pytree.
Because agent i's loss depends only on agent i's leaves, the gradient for agent j's
leaves comes only from agent j — the learners are independent, just computed in one
vectorised call.
Optimizer: plain Adam, hand-coded, with per-parameter moment estimates stored alongside the
policy parameters (same pytree shape).default_hparams(env_name)
Sensible hyperparameters for this env (falls back to the discrete defaults).
solve(env, net, key, *, n_seeds=16, horizon=None, n_iters=200, lr=0.003, gamma=0.98, hidden=32, ent_coef=0.01)
Train N independent REINFORCE agents on `env` communicating over `net`.
Returns {"policy", "return_curve", "final_return"} where return_curve[it] is the mean
per-step team reward at iteration it (so it is comparable to the random/oracle baselines).coadapt.solvers.trpo
trpo.py — Trust Region Policy Optimization, from scratch, one INDEPENDENT learner per agent.
TRPO takes the largest policy step that stays within a KL "trust region" of the current
policy. For a single agent, each iteration solves:
maximize surrogate advantage
subject to mean_KL(old || new) <= delta
via the natural gradient: step direction = F^{-1} g (g = policy gradient, F = Fisher matrix).
We never form F; we solve `F x = g` with CONJUGATE GRADIENT using Fisher-vector products, then
a backtracking LINE SEARCH scales the step so the KL constraint holds and the surrogate improves.
STRICT DECENTRALIZATION: the entire TRPO update (gradient, CG, trust-region step-scale, and the
accept/reject line search) is defined for ONE agent on ONE agent's own trajectory, then vmapped
over the agent axis. So each agent has its own natural-gradient direction, its OWN trust-region
scale, and its OWN accept decision — no agent's update depends on another's curvature or KL.
(The earlier version summed KL/surrogate over agents and used a single global step-scale and a
joint accept, which coupled the agents; that is fixed here.)
A per-agent critic (like PPO) supplies the GAE advantage. The critic MSE is a sum of per-agent
terms, so its gradient is also independent per agent.default_hparams(env_name)
(no docstring)
solve(env, net, key, *, n_seeds=16, horizon=None, n_iters=200, gamma=0.98, lam=0.95, delta=0.01, cg_iters=10, vf_lr=0.003, vf_epochs=4, hidden=64)
Train N independent TRPO agents. Returns {policy, critic, return_curve, final_return}.coadapt.spaces
Action spaces and their probability distributions.
Each space knows three things so that a solver never has to special-case the action type:
param_size how many numbers a policy must output per agent (the head width)
sample(params,key) draw an action from the distribution described by `params`
log_prob(params,a) log-probability of action `a`
entropy(params) entropy of the distribution (used to encourage exploration)
`params` is always a *flat* float vector (the raw output of a per-agent MLP head). Each
space reshapes it internally, so the policy network is just `Linear(hidden -> param_size)`
regardless of the action type. All methods work with arbitrary leading batch dimensions
(e.g. [N, param_size] for N agents, or [S, T, N, param_size] for a batched rollout).class Box(self, shape: tuple, low: float, high: float) -> None
Continuous action, diagonal Gaussian. params = [mean(D), log_std(D)] flattened. Actions are squashed into [low, high] with a tanh so the policy cannot run off to infinity; log_prob includes the tanh change-of-variables correction.
entropy(self, params)
(no docstring)
feature(self, action)
(no docstring)
log_prob(self, params, action)
(no docstring)
mode(self, params)
(no docstring)
sample(self, params, key)
(no docstring)
class Discrete(self, n: int) -> None
One categorical choice in {0, ..., n-1}. params = logits of length n.entropy(self, params)
(no docstring)
feature(self, action)
(no docstring)
log_prob(self, params, action)
(no docstring)
mode(self, params)
(no docstring)
sample(self, params, key)
(no docstring)
class MultiDiscrete(self, nvec: tuple) -> None
F independent categorical choices, each in {0, ..., K-1}.
We only need the *rectangular* case (every factor has the same number of options K),
which keeps the parameters a single [..., F, K] array — required for JAX. `nvec` is
kept for readability/introspection but must be uniform.entropy(self, params)
(no docstring)
feature(self, action)
(no docstring)
log_prob(self, params, action)
(no docstring)
mode(self, params)
(no docstring)
sample(self, params, key)
(no docstring)
coadapt.storage
storage.py — the storage harness: save/load a run as ONE structured dict file.
Every experiment is stored the same way so results are machine-readable and comparable, and
so plotting.py never has to know how a run was produced. The schema is fixed:
{
"config": {...}, # what was run (env, solver, N, seeds, horizon, hyperparams)
"metrics": {...}, # scalar summary numbers (final_return, random, oracle, ...)
"curves": {...}, # 1-D arrays over iterations/agents (return_curve, error_by_dist)
"meta": {...}, # free-form notes (git-free: timestamp passed in, versions, ...)
}
Small scalars/lists live in a human-readable JSON; any bulky arrays go in a sidecar .npz so
the JSON stays readable. `load_run` stitches them back into one dict.load_run(path)
Read back a run dict saved by save_run, resolving any .npz-backed arrays.
save_run(path, run)
Write a run dict to `path` (a .json). Big arrays go to `path`-with-.npz.
`run` must have the four top-level sections; missing ones default to {}.
Returns the json path written.