Testing¶
Ajax checks its code with two kinds of tests, and each answers a different question (the conformance checks, below, are a third, against an agent's original implementation):
- Unit tests check one component against an exact contract, with no learning. Where agents or extensions share a contract, the test is generic: it runs every one of them, and a new agent or extension fails it until it joins.
- Probes are tiny generic environments with known answers. An agent trains on one briefly, and the probe checks a capability of the whole agent: that it learns the right value, picks the right action, counts the right steps.
A convention that differs from a reference implementation (an ordering, a default, a constant) is a matter for code review only, not for a test.
The two kinds do not overlap. A unit test can show that a TD target takes a
max rather than a mean; it cannot show that the trained agent's values
come out right, which needs the target, the buffer, the episode-end handling,
the optimiser and the network to work together. A smoke test that trains on
CartPole and checks the return proves little either: a broken agent often
still solves CartPole.
Beside them, pins record what a tiny run produces today, so that a restructuring can show it changed nothing.
Where the tests live¶
| Kind | Where | CI job |
|---|---|---|
| Unit, one component | tests/<area>/ (environments, networks, buffers, logging, ...), tests/agents/<AGENT>/ |
Tests |
| Unit, generic contracts | tests/agents/test_*_contract.py, tests/extensions/test_phase_contract.py, tests/extensions/test_no_silent_noop.py, tests/environments/test_wrapper_contract.py, ... (below) |
Tests, Slow tier |
| Pins | tests/agents/test_agent_pins.py, tests/agents/SAC/test_sac_parity.py, tests/extensions/test_sac_extensions_equivalence.py, tests/test_downstream_api.py |
Tests |
| Slow | anywhere, marked @pytest.mark.slow: the option matrix, the resume contract, long trainings |
Slow tier |
| Probes | tests/probing/ |
Slow tier |
| Conformance | tests/conformance/ (below) |
Conformance |
| Learning checks | benchmarks/world_models/learning_checks.py: a world model learns its task at a small preset; hours on a CPU, run before merging such an agent |
none |
Every directory of tests/ that holds Python files is a package, so tests
import shared helpers by their full name (from tests.environments.identity_env
import IdentityEnv); tests/test_repo_hygiene.py checks it.
The generic contract tests¶
These tests are written once and run over every agent, extension or wrapper
that shares the contract. Each finds its subjects from the code (the
exported agents, the subclasses of Extension, the signatures), so a new
one cannot slip past: it fails a completeness check until it has a row.
Several run on the identity task (tests/environments/identity_env.py),
whose observation [t, c] says which step of which episode a row comes from:
an episode with c > 0 terminates at t = 3, the others are truncated at
t = 5.
| Test | The contract |
|---|---|
tests/agents/test_agent_contract.py |
every agent on the shared loop builds its configs on each action space it acts in and refuses the other; a default run keeps no extension state; a run of two seeds and two envs folds its extensions in step and adds their metrics to every evaluation; the measurement extensions find its critic by themselves; the run config drops the instance; the evaluation key is apart from the training key, and each evaluation draws fresh keys; a continuous agent acts on the env's whole box; episode_length and action_repeat reach the env |
tests/extensions/test_phase_contract.py |
for every agent and every phase it folds (on_target, critic_loss, actor_loss, post_update): each batch holds the keys PHASE_BATCH_KEYS documents; each fold gets a key of its own, never one the agent draws on; critic_loss gets the target on_target returned; a loss term moves the network it names, and that one only |
tests/extensions/test_no_silent_noop.py |
every extension implementing on_target, critic_loss or actor_loss raises on a batch without what it reads, rather than return its input or 0 |
tests/extensions/test_extension_phase_support.py |
each agent accepts exactly the phases it folds; a reader of phi* needs MCPretrain |
tests/agents/test_compatibility.py |
every extension and network option (memory, cnn) on every agent, as the agent is built: it builds, or it is refused for a capability the algorithm lacks, or it is a gap pinned with the work that closes it; the matrix is Compatibility, rewritten with AJAX_WRITE_COMPATIBILITY=1 |
tests/environments/test_wrapper_contract.py |
every Ajax env wrapper survives copy, deepcopy and pickle, and reports the episode-end flags each step raised, alone and in the stacks create.py builds |
tests/agents/test_transition_contract.py |
the transitions every agent hands on_target continue their own episode (a time-limited row's next observation is the final one, not the next episode's first), carry the flags the step raised, and carry the normalised rewards under reward normalisation |
tests/agents/test_expert_consumers.py |
every agent taking an expert_policy gives it the raw observation, and a stateful expert its state; the cloning dataset holds its actions in agent units; the evaluation's expert return is its closed form |
tests/test_evaluate.py, tests/extensions/test_instrumentation_extensions.py |
every output of the evaluation, and every metric of the measurement extensions, meets its closed form on a known rollout |
tests/agents/test_reward_normalisation.py |
every agent taking normalize_rewards hands the normaliser the discount it documents (none by default, reward_normalization_gamma when given) and reports its Train and Eval returns in the env's units (UDRL's Train return is a strict xfail: a known bug) |
tests/agents/test_carry_restart.py |
the recurrent actors of PPO, SAC, ASAC, REDQ and TD3 remember only their current episode, for every memory kind: the carry each acts with is the actor's run from a fresh carry over the episode's observations (the fast tier runs each agent once and each kind at least once, the other rows are slow; APG's controller has its own test in tests/agents/APG/test_episode_ends.py) |
tests/agents/test_option_smoke.py (slow) |
the option matrix: every pair of an agent's options, and every extension stacked into an agent that folds it, trains a tiny logged run that updates the policy and keeps every parameter finite |
tests/agents/test_resume_contract.py (slow) |
a run split in two, its second half on a new agent instance, ends in the state one uninterrupted call reaches, every leaf alike (floats to a relative 1e-5): every agent of the contract table and APG, with the normalisation it takes and with memory where it takes one, every call told the run's horizon so the schedules run as in one call (the world models' own tests hold theirs); fast, a two-call DQN run's epsilon at every step is the one call's |
The option matrix and the resume contract train, so their main tests run in the slow tier; their completeness checks (every switch of a signature is in a row, every extension has a builder, every agent has a row) are fast and run with the other unit tests.
What each agent computes is tested in its own directory, tests/agents/<AGENT>/:
GAE on a known trajectory, a loss's sign, a target on a hand-built batch,
and that every configuration value reaches the code that runs. That last
kind exists because of a real bug: PPO once treated num_minibatches=32 as
1, and its CartPole smoke test still passed
(tests/agents/PPO/test_PPO_code.py).
Pins¶
The pins are change detectors, not truth. test_agent_pins.py compares a
fingerprint of a tiny run (the sum of squares of each parameter tree, and
the state of a test extension) with the one recorded, under a relative
tolerance that absorbs the floating-point drift between CI's Linux x86 and
macOS ARM. A behaviour-neutral change that moves a pin only by that drift
re-records it; a change that moves the numbers on purpose re-records it in
its own commit, saying why. test_downstream_api.py pins the names and
keywords the downstream projects import, so that removing one is a
deliberate step (CLAUDE.md, rule 10).
Conformance checks¶
A conformance check runs one training step of an Ajax agent and of its
original implementation from the same state, with the same random numbers,
and compares the change each makes to every leaf of the state: parameters,
optimiser moments, and for PQN the collected rollout and its targets. The
tolerance comes from rounding alone: each side's float32 run against the
same run in float64, times 10 (tests/conformance/submaps.py). A planted
fault, such as TD3's target noise at 0.22 instead of 0.2, must fail the
check.
| Agent | Original | Test |
|---|---|---|
| PQN | purejaxql (JAX) | tests/conformance/test_pqn_purejaxql.py |
| TD3 | sfujim/TD3 (PyTorch) | tests/conformance/test_td3_sfujim.py |
| TD3+BC | sfujim/TD3_BC (PyTorch) | tests/conformance/test_td3bc_sfujim.py |
| REDQ | watchernyu/REDQ (PyTorch) | tests/conformance/test_redq_original.py |
| AVG | gauthamvasan/avg (PyTorch) | tests/conformance/test_avg_original.py |
| CrossQ | adityab/CrossQ (JAX, its own versions) | tests/conformance/test_crossq_original.py |
The originals are vendored under benchmarks/reference/vendor. A PyTorch
original runs in its own environment (benchmarks/reference/envs/torch),
as does CrossQ's, on the jax, flax and Stable-Baselines3 versions its
authors pinned (benchmarks/reference/envs/crossq). Each records its
states to disk; CI keeps those records between runs while
the original, its recorder and that environment's lock stay the same
(AJAX_CONFORMANCE_RECORDS). tests/conformance/kit.py holds the seams
that make one step comparable without changing the code under test: a tape
of the random draws, and the capture of an agent's update. A difference
that is the original's convention or defect is registered with its reason
(benchmarks/reference/refcmp_specs.py) and asserted by a test of its own;
PQN's also appear in the reproduction specifications.
Running tests locally¶
The full suite costs about an hour of CPU, so CI runs it on the pull request. Before pushing, run the cheap checks (CLAUDE.md, "Commit requirement"):
uv run pre-commit run --all-files # ruff lint, ruff-format, mypy
uv run pytest --collect-only -q # everything imports and collects
uv run pytest tests/agents/TD3 # the tests of what you changed
uv run pytest tests/probing/test_framework.py # any change under src/ajax
uv run pytest tests/probing -k TD3 # a change to TD3's training: all its probe cells
Two of these are process rules. A change under src/ajax runs
tests/probing/test_framework.py, which trains nothing: every planted fault
quotes a line of the source, so any source edit can break one. A change to an
agent's training runs all of that agent's probe cells (-k <Agent> over
tests/probing).
make ci runs the pull-request gate (ci-precommit, ci-test,
ci-conformance, and docs, the strict build of this site); make ci-all
adds the slow tier (ci-slow). The make targets
hide the GPU (CUDA_VISIBLE_DEVICES="", JAX_PLATFORMS=cpu), so tests never
compete with an experiment running on it. Outside make, set
JAX_PLATFORMS=cpu while a GPU is busy.
pyproject.toml registers one marker, slow, and runs pytest with
--strict-markers, so a misspelt marker is an error rather than a test that
silently runs in the wrong job.
How CI splits the suite¶
The tests come in two tiers. The fast tier is the tests not marked slow,
which every pull request must pass. A fast test may train, but for at most
128 gradient steps: tests/conftest.py fails a fast test past them, and
lists those past about 30 s without failing them. The slow tier
is every test that trains: tests/probing (marked slow as it is collected,
but for the harness's own checks) and the tests marked slow elsewhere.
Contributing describes both.
.github/workflows/ci.yml
runs on CPU for every pull request and every push to main:
| Job | What it runs |
|---|---|
| Pre-commit | ruff lint, ruff-format and mypy on every file |
| Tests (3 shards) | the tests not marked slow (the probing harness's checks among them), conformance excluded, under coverage, a pytest-xdist worker per vCPU, at XLA optimisation level 0, on arm64 runners. Shards 1 and 2 name directories; shard 3 runs everything else, so a new test file always runs somewhere |
| Coverage | the three shards' data combined; fails below 90% |
| Conformance | tests/conformance: one training step of each agent that has a check against its original implementation (above) |
| Docs | mkdocs build --strict with the docs group alone: a broken link, a page left out of the navigation or a docstring naming a parameter the signature lacks fails it |
| Lint and Test | passes only when every job above passed; the one check branch protection requires |
The Slow tier workflow (slow.yml)
runs the slow tier in six shards: daily on main when it changed, by hand,
and on a pull request each time the label run-slow-tier is added. A red run
on main opens an issue and, when that is cheap, bisects the merges since
the last green run. The Release workflow tags a version only once the slow
tier has passed on main's head.
The test and conformance jobs restore Ajax's compile cache as the last push
to main left it for that job, so a program a change leaves alone is loaded
instead of compiled. A new push to a pull request cancels the run it
supersedes. Nothing merges while any job is red; a failure that is
pre-existing and unrelated to the change is called out explicitly instead of
merged over.
The probing framework¶
A probe trains an agent on a tiny Markov decision process whose answer is known exactly (a value, a best action, a count) and compares what the agent learned with the right answer and with the answers that named bugs would produce. When it fails, it says which mistake the agent's reading is closest to.
The directory tests/probing holds:
| File | Role |
|---|---|
envs.py |
Spec: a small episodic environment from its transition function, with gymnax auto-reset, a time limit and, on request, a record of the executed actions |
agents.py |
each agent's frozen probe presets, pinned by DIGEST, and make, which checks every override reached the agent |
runs.py |
trains every seed of a case in one compiled program, and caches the readings so the tests of one case share its training |
readouts.py |
reads values and actions off a trained agent, through the same paths training uses |
oracles.py |
the right answers, and the wrong ones under named faults, from each estimator's fixed point or a closed form |
verdict.py |
Query, Case, the two-stage verdict, calibration and answer digests |
faults.py, faults/*.jsonl |
the planted-fault catalogue and its runner |
test_*.py |
the cases, grouped by subject (below); test_framework.py tests the harness itself |
| Module | What it probes |
|---|---|
test_value_chains.py |
values and actions on short chains: max bootstrap and action indexing, discounting, entropy bonuses, normalisation, gradients through a transition (APG) |
test_episode_ends.py |
terminations against time limits, rollout ends, parallel environments out of step, reward scale, average reward |
test_bookkeeping.py |
what an agent counts, logs, stores, draws and executes: step counters, UDRL's commands, an expert's warm-up, a declared action box |
test_extensions.py |
every extension phase an agent claims to fold, through test-only extensions of known effect; the expert extensions' numbers |
test_memory.py |
a recurrent agent acts on what an episode showed it and restarts at the episode's end |
test_world_models.py |
answers reachable only through a learned model (TD-MPC2's horizon, multi-task twins, DreamerV3's imagination) |
test_p4_split_run.py |
a run checkpointed, restored and resumed equals the run never interrupted |
test_action_box.py |
a trained policy's deterministic action at either edge of, and inside, a box that overhangs [-1, 1] unequally, in env units, against each agent family's oracle (no entropy term, an auto-tuned or a fixed temperature) |
Known-answer cases¶
A Query names one reading, its right answer and the wrong answers that
named faults produce. A Case adds the readings function, the training
budget and the calibrated tolerances. This one, from test_value_chains.py,
is a two-step chain: state s1 pays 0, 0.5 or 1 by action, and
Q(s0) = 0.8 max_a Q(s1, a):
# doc: skip (an excerpt of tests/probing/test_value_chains.py; it needs the test package)
R3, G3 = (0.0, 0.5, 1.0), 0.8 # s1's rewards by action; the discount
Q_S0_WRONG = {"mean bootstrap (D01)": G3 * 0.5, "no discount": 1.0}
CASES["p0a-DQN"] = Case(
"p0a-DQN",
(*Q_S1, *(Query(f"Q(s0, a={a})", G3, Q_S0_WRONG) for a in range(3)), GREEDY),
_three_run("DQN"), # train, then read Q at both states and the greedy action
2500, # budget, in environment steps
(0.026, 0.021, 0.033, 0.037, 0.032, 0.029, 0.02), # one tolerance per query
note="1250 qualified but 2 seeds of 3000-3031 read Q(s0) 0.047 off; cert 32/32",
)
The tolerance can never reach a wrong answer: it is at most half the gap to the nearest one, and at most 0.1. So a reading within tolerance is closer to the truth than to any named bug. A margin query asks only for the right sign by a margin, for readings such as the gap between two actions' values.
Some cases are exact rather than judged. The bookkeeping and split-run probes assert equalities (a counter, a logged timestep, a whole state) on two seeds trained in one program.
The two-stage seed rule¶
Learning is noisy, so a judged case trains several seeds and counts how many land within tolerance:
- Train seeds 0-7 in one program. Seven or more within tolerance: pass. Four or fewer: fail.
- Five or six: train seeds 8-15 as well, and pass when 13 of the 16 are within.
A clear result costs one training; only an ambiguous one pays for a second.
A precondition (a reading of the wrong shape, an override that did not
reach the agent) raises RuntimeError, never AssertionError, so a strict
xfail cannot absorb it.
Calibration and certification¶
A case's budget and tolerances are measured, never guessed, and on seeds no check uses:
- Ladder. Read two blocks of 32 seeds (1000-1031 and 2000-2031) untrained and at a series of budgets.
- Choose. Pool the blocks and take the smallest budget at which twice the worst seed's error fits under the cap (the smaller of 0.1 and half the gap to the nearest wrong answer); that doubled error, at least 0.02, becomes the tolerance. For a margin query, half the weakest seed's margin must reach 0.25.
- Certify. Check the chosen budget and tolerances on a third block,
3000-3031, and record the result in the case's
note.
uv run python -m tests.probing.verdict ladder test_value_chains p0a-DQN 1000 1250,2500,5000 a.json
uv run python -m tests.probing.verdict ladder test_value_chains p0a-DQN 2000 1250,2500,5000 b.json
uv run python -m tests.probing.verdict choose test_value_chains p0a-DQN a.json b.json
uv run python -m tests.probing.verdict certify test_value_chains p0a-DQN # seeds 3000-3031
test_framework.py checks that every calibrated tolerance lies inside its
gap.
Answer digests¶
A calibration holds only for the configuration and the answers it was made on. Two digests pin both:
agents.DIGESThashes every agent's probe presets.- Each scenario module's
ANSWER_DIGESThashes every query's right answer, wrong answers, tolerance and kind, to six decimals.
test_presets_and_answers_are_pinned fails when either moves. Change a
preset or an answer only together with a fresh calibration, then re-pin the
digest. To see what a module's digest covers:
Planted faults¶
A probe that has never caught a bug has not shown it can. Each scenario
module has a catalogue, faults/<module>.jsonl, with one line per fault:
{"id": "P0a-D01-DQN", "path": "agents/DQN/train_DQN.py",
"old": " max_q_next = jnp.max(q_next, axis=-1, keepdims=True)\n",
"new": " max_q_next = jnp.mean(q_next, axis=-1, keepdims=True)\n",
"catches": ["test_value_chains.py::test_learns[p0a-DQN]"]}
path is under src/ajax; old must occur exactly once; catches lists
every test node that fails with the fault planted. The runner copies the
repository to a scratch directory, plants the fault there (your checkout is
never modified), checks that Python imports the copy, and runs the listed
nodes with the compile cache off. A non-zero exit means caught:
JAX_PLATFORMS=cpu uv run python -m tests.probing.faults /tmp/faults P0a-D01-DQN P0a-gather-up
# {'P0a-D01-DQN': 'caught', 'P0a-gather-up': 'caught'}
CI does not plant faults; test_framework.py runs
test_every_planted_fault_applies_once_and_names_live_nodes, which checks
that each old text still occurs once and that each listed node still
exists. This is why a change under src/ajax runs test_framework.py
before the push: when a change moves the code a fault edits, update the
fault's line and run it again. The catalogue holds 209 faults across eight
modules.
Known defects¶
A probe that finds a live bug keeps failing, visibly. The case gets a
defect, which turns it into a strict xfail whose reason names the defect
(LIVE defect: ...). When the bug is fixed, the xfail passes, strict mode
fails the suite, and the annotation has to be removed. A known bug is never
hidden by loosening a tolerance.
Size budgets¶
The framework is meant to stay readable. test_framework.py caps each file's
length (CAPS) and the whole directory at 7,000 lines, and requires at least
one case for every agent ajax exports.
The bugs the probes target¶
The catalogue reads as a list of the classic ways reinforcement-learning code goes wrong without crashing:
| Bug | Example faults |
|---|---|
| The bootstrap averages instead of maximising; the taken action's value, or the greedy action, is read at the wrong index | P0a-D01-DQN, P0a-gather-up, P0a-greedy-up |
The discount is squared, replaced by 1 - gamma, or dropped |
the value-chain cases: at the probe discount 0.62 those wrong answers sit about 0.24 or more from the right one |
| A time limit is treated as a termination; the reset observation replaces the episode's final one; truncation is dropped | P6-PPO-OR, P6-RESET-OBS, P6-GYMNAX-TRUNC-DROPPED |
| The reward scale is applied twice | P5-S13, P5-REDQ-scaled-twice |
| GAE runs forwards in time; the value bootstraps on the wrong observation; the configured lambda is ignored | Q9-GAE-FORWARD, P09, Q9-PQN-LAMBDA |
| The entropy term has the wrong sign or is missing; the tanh log-probability misses its Jacobian, in the policy or in the target | P07, S-entropy-sign, S-no-jacobian, S-pre-tanh-target |
| The observation normaliser is skipped in the target or at evaluation | P1-sac-target-skips-agent-norm, P1-eval-skips-agent-norm |
| TD3's target policy noise is not clipped | T02 |
| Parallel environments share one random key; the step counter counts collection steps, not environment steps | B02, P9-shared-step-keys, B01 |
| Every seed gets the same key | B11 |
| A recurrent memory is never reset at an episode start, or reset every step; the burn-in is too short | P0b-never-restart, P0b-reset-every-step, Q2-short-burn |
| A resumed run loses its random key, optimiser state, counter or buffer | P4-rng, P4-optimizer, P4-counter, P4-buffer |
An extension's term has no gradient, its target is ignored, or post_update runs before the update |
P3-ppo-critic-term-no-gradient, P3-dqn-target-ignored, P3-post-update-first |
| Logs go to the wrong run; the last evaluation is dropped | W01, L01 |
| A planner forgets its terminal value; a world model ignores the action | Q12-terminal, Q13-action-cut |
| An action consumer clamps to the env's [-1, 1], or reads actions in the other units (env for agent, or the reverse) | box-executed-clamped, box-replay-env-units, box-TDMPC2-plan-clamped |
Adding a probe¶
- Pick the module by subject, and write the environment as an
envs.Spec: a transition function returning the next state, the reward and the termination flag. - Write the queries. Give each reading its exact right answer and the wrong answers the bugs you are after would produce. Choose the problem's numbers so the wrong answers sit well apart from the right one: the tolerance can be at most half that gap.
- Write the readings with
runs.readings(build, read):buildmakes the agent withagents.make(its probe preset plus your overrides), andreadappliesreadoutsto the trained state. - Calibrate and certify as above, and record the budget, the
tolerances and the certification in the case's
note. - Re-pin the module's
ANSWER_DIGEST(andagents.DIGESTif you changed a preset). The failing pin test shows the new value. - Plant the faults. Add a line per bug to
faults/<module>.jsonl, run them with the fault runner, and keep only lines it reports as caught. A fault that survives means the probe is weaker than you thought. - Mind the caps. If a file outgrows its cap, shorten the code before raising the cap.
A new agent needs probe presets in agents.py and at least one case; a new
probe file joins a probing shard without any workflow change (a test
missing from tests/.durations counts one second).