Log · 2026-09-18

The engine, the ladder and three registrations

The Armies engine now plays the competitive rules and passes every rule test: movement, battles, the two-square rule with the Scout extension, the continuous-chase rule through a full lap around a lake, both draw rules and generator-checker agreement over thousands of random positions. In release mode on this machine it applies a move in 17 ns including the end-of-game check, generates legal moves in 71 ns, plays 4.5 million random plies per second on one core and 84 million on all 32 threads. The state is 440 bytes. The Ataraxos simulator's 10 million updates per second on an H100 is about three cores here.

The notebook outline follows the ladder: baselines, expectimax over sampled worlds, an n-tuple leaf, the Ataraxos pipeline and DeepNash's R-NaD under equal compute. Fourteen concept pages cover the two papers and the engine; every number from the papers is cited on the page that uses it, and the setup heatmap is labelled illustrative because the paper's measured distribution was not transcribed.

Three registrations are frozen and unrun: greedy against random, expectimax against greedy and the self-play smoke run. The ladder asset shows zero games on every rung until the records exist. The search crate, the arena binaries and the training package were being built in parallel today, so no match has been played through the arena yet; the one real game in the notebook is the greedy-against-greedy replay from the classic setup, recorded through the Python bindings.

Failures and gaps: no records, no reports. The two-square and chase rules follow the paper's formal definitions rather than the informal Strategus text, and the chase tracker keeps 32 positions, which any run record will flag if it is ever exceeded.

Evening: the three registered experiments ran

The arena and the training package landed, and all three registrations have records. Greedy scored 90.2% effective against random on 200 paired games (interval 85.4% to 93.6%), the random self-match covered 50%, and greedy is the reference (report, record). Expectimax over sixteen sampled worlds at depth 2 scored 69.5% against greedy with a per-seed contrast of 0.39 (0.24 to 0.54) at 9.8 ms per move, so it is promoted (report, record). The self-play smoke run trained twenty iterations in 43 seconds on the GPU with no entropy collapse, but its network scored 60% against random with an interval covering 50% and stalled 26 of 40 games to the battleless limit; the decision rule is not met and the rung stays unclaimed (report, record).

Deviations: the evaluator alternates seats with one seed per game rather than pairing identical setups, and the network's setups come from its setup network; both are stated in the record. A sixty-game random against expectimax pairing was added outside the registrations to close the Elo fit for the ladder asset (expectimax 1541, greedy 1392, smoke network 1089, random anchored at 1000).

Failures and gaps: the smoke network does not attack; the next registration adds a draw-share diagnostic and more iterations. The rollout and n-tuple rungs have no games.

Attribution: claude-fable-5-1 (Claude Code).

Second pass: play against every agent, records and a replay library

Play against expectimax and rollout failed in the browser: both search policies timed their work with the standard library's clock, which the WebAssembly target does not provide, so the first agent reply after the human's setup stopped the local engine. Greedy has no clock and worked, which is why the automated browser test passed. The policies crate now carries a stopwatch backed by the browser's clock on that target, and the server check includes a WebAssembly compile so the mistake cannot return. The home page's demo was a hand-scripted move list with a Scout entering a lake; demos now replay recorded games only.

Every finished game can now be written as one record format with both setups and, per ply, the moving piece, the combat, the thinking time, the mover's value estimate and the nodes searched. The arena writes records with --record-dir, the server and the browser build serve a finished game's record, and the site has a replay viewer with a perspective toggle, a value trace and an analysis overlay that shows one side's beliefs and candidate values computed from that side's observation alone. Game records and replays describes the format.

The replay library under records/games/ holds 64 records in 1.7 MB: six games for every pair of random, greedy, expectimax and rollout, seed 0 from both seats of every sweep cell, and seeds 0 to 4 from both seats of the rollout match. Every record replays through the engine.

Two registrations ran. The expectimax samples-by-depth sweep (report, record): nine cells of 100 paired games against greedy, every cell above 50% by the point estimate, the best sixteen worlds at depth 2 at 67.5% (57.8% to 75.9%) and 9.5 ms per move, and neither more worlds nor deeper search separated from it, so neither registered claim is supported and the browser keeps that cell. The rollout rung (report, record): flat Monte Carlo with the registry defaults scored 19% (12.5% to 27.8%) against that cell, per-seed contrast minus 0.62 (minus 0.81 to minus 0.43), and is not promoted. The Elo fit now reads expectimax 1535, greedy 1393, rollout 1283, the smoke network 1090, random 1000.

Failures and gaps: the sweep shows the hand-written leaf and the counting belief, not the search, as the limit; expectimax lost 241 attacks into Bombs in 100 games. The n-tuple leaf and the belief network are the next registrations. The library records for the sweep carry renamed contestant labels (expectimax 16w d2) so that the same seed in different cells has distinct ids.

Attribution: claude-fable-5-1 (Claude Code).

Value annotations, later the same day

The first records carried the raw mean over sampled worlds of the search value, and one world with a capturable Flag (a million-point terminal) made every candidate read as a certain win after the squash. The arena and the analysis now record the mean over worlds of each world's squashed value, while the policies still choose by the raw mean, so no measured result changes. Every library record was regenerated from its seed with the same contestant order and the same moves (verified file by file against the committed copies) and only the value fields differ. Choosing by the squashed mean instead is a candidate for a future registration: it is a risk-sensitive aggregation that the strategy-fusion caveat suggests could matter.

Third pass: the program and the delegated agents

The notebook now has a research program (search under imperfect information) with eight theories, each with a mechanism, a falsifiable prediction, a control, a disproof and an owning study: knowledge-limited subgame solving against expectimax at equal time, an opponent portfolio to shrink the subgame, the learned belief as the root sampler, update-equivalence search on this machine's checkpoint, a scaled self-play run, the throughput of the training loop, a NashDreamer infoset model as a belief, and R-NaD under equal compute. Three approach pages are new: subgame solving without common knowledge, world models and the pipeline on one machine. The three papers behind them are vendored under paper/ with text extracts.

Two measurements set the baseline for the engineering studies: the small preset collects 4,100 environment steps per second on 1,024 environments and takes 60 seconds per iteration, about 50 of them in collection, so the loop is bound by inference and data movement, not by the engine. The Elo ladder and the sweep's Bomb losses stand as the motivation for the belief and search theories.

Work is delegated one study per agent, the Settlers and Drop7 way: agents/delegate runs opencode with Kimi K3 or GLM 5.3 in worktrees under .worktrees/<brief>/, logs every event, resumes a session that stops without a summary, and collects a fixed-format SUMMARY.md. The orchestrator reads summaries only; the agents file registrations, records, reports, study updates and log entries themselves and merge fast-forward into the canonical checkouts. A smoke session with Kimi K3 ran the path end to end in a minute for fifteen cents and found that the content checker needs the shared docs/ beside the worktrees; the launcher now symlinks it. Nine briefs are filed under agents/briefs/; wave zero (run layout, monitor, throughput, the subgame-solving searcher) launches tonight, and the rest wait for their dependencies.

Attribution: claude-fable-5-1 (Claude Code); the smoke session by baseten/moonshotai/Kimi-K3 through opencode.

D

Initial placements become an approach, and the replay library gets an archive

Watching replays showed the cost of uniform setups: games that end in seven plies on a front-row Flag. Every engine player draws its setup with Setup::random, and the curated library's 128 setups put the Flag 30, 32, 32 and 34 times on the four rows, back to front. On 1,200 unregistered tournament games between random, greedy and rollout (training rules, raw output, exploratory) a side with a front-row Flag lost 61.5% of its games against 32.7% on the back row, and 107 games ended within 20 plies. That is motivation, not a result.

Filed: the approach initial placements, theory T9 in the program, two proposed studies (setups/structured-prior, setups/selection-by-self-play) and their briefs (agents/briefs/setup-prior.md, agents/briefs/setup-selection.md). The measurement is a setup-only contrast: the same move policy in both seats, only the setup source differs, with the Flag-square entropy and an exploitability proxy reported beside the score. The first brief waits for the kluss crate to merge because both touch the policy registry, and it leaves the decision to change every player's default setup to the user.

The same 1,200 games are 35 MB of JSON, about 28 KB a game, which is what a self-play experiment would do to a static library. The curated library now has a hard limit in the web build (500 records, 3 MB) and bulk games go to an archive instead: one DynamoDB table indexed by player, by result and by pair, holding each record gzipped at about 2 KB. Game records and replays describes the two tiers. The replay page lists players with their scores, opens one player's record by opponent and its best games, and pages through games in a table, the same way over the curated index, a visitor's own games and the archive.

Attribution: claude-fable-5-1 (Claude Code).

The monitoring interface

Hypothesis (registered before any implementation): one terminal interface built on textual and rich can follow a training run, its evaluations, the machine and the delegated agents from files alone, reading the run-layout contract (status.json, metrics.jsonl, events.jsonl, evaluations.jsonl), the arena's record directories and the delegate sessions under runs/agents/; and a new view is one plugin file implementing update and render, with no change to the interface. The decision rule from the approach page: each built-in monitor renders the expected values from a hand-written fixture in a test, and the interface stays responsive against a live source. This is an engineering study; its evidence is tests against recorded fixtures plus a live session, not a match, so no experiment registration applies.

Deviation noted before starting: the brief's frontmatter says GPU use is not allowed while its body asks for a one-minute smoke training run under the GPU lock as the live source. I treat the body's explicit instruction as the intended exception and hold the lock only for a three-iteration smoke run (seconds), never for anything longer.

Result, the same evening: the hypothesis holds. armies-watch is in training/armies_watch/ with eight built-in panels (training with sparklines over the last 60 iterations for entropy, clip rate, policy loss, steps per second and draw share; evaluations with the effective score and its interval; events; logtail; gpu from rocm-smi; system with loads, memory and the top processes; arena over a --record-dir; agents over runs/agents), tailers that survive truncation, replacement and half-written files, a watch.toml layout, and a plugin API where a monitor is one class with update and render (docs/monitoring.md documents it with an example). Textual and rich went into the shared virtualenv as the brief instructed. Thirty-four tests under tests/watch/ render every built-in from hand-written fixtures, cover truncation and replacement, reject an unknown monitor with the built-ins listed, and run the interface under textual's pilot: panels refresh in their own workers, the keys work, and a source sleeping 0.8 seconds per update does not block quitting.

Live session: a three-iteration smoke run (--preset smoke, about two seconds per iteration on the GPU, under the lock) ran while the interface watched it in a real terminal for twenty seconds. The gpu panel read card0 at 82% use and 96% memory during collection, the system panel watched the load climb to 23.9 of 32 threads with the training process at 2140% CPU, the agents panel listed the four concurrent sessions with live costs, and the logtail panel followed the run's output as it was written. The interface redrew every panel repeatedly throughout, which is the responsiveness the decision rule asks for. The run-layout branch is not on master yet, so the run wrote only history.jsonl on save and the training and evaluations panels were demonstrated on the fixture, exactly the fallback the brief provides. No registration applies: an engineering study, its evidence is tests and a live session, not a match.

Failures and gaps: none in the deliverables. The interface's panel keys (keys()) take precedence over the app keys while the panel is focused, which is a design choice the docs state; the run's history.jsonl from the old writer format is not parsed by any panel, waiting on the run-layout study to land.

Run layout and launcher

Hypothesis (stated before any implementation): a training run that writes status.json, metrics.jsonl, events.jsonl, evaluations.jsonl and full checkpoints under runs/<id>/ can be resumed from checkpoints/latest to the same second-iteration metrics as an uninterrupted run from the same seed, provided every source of randomness is checkpointed and the vectorised environment's mid-game states are restored; and a launcher based on systemd-run --user keeps a run alive through a closed terminal and can stop and restart it. The test configuration is the smoke preset with 64 environments and 8 moves per iteration, two iterations, seed 0. This is an engineering study (pipeline/run-layout); no experiment registration is needed.

One deviation found while reading the code before implementing: the brief's example evaluation opponents include expectimax:{"samples":16,"depth":2}, but the Python binding armies.RustPolicy wraps armies_policies::baseline, which builds only random and greedy; expectimax lives in the search crate, which the python crate does not depend on. The evaluation path accepts the registry-name-with-JSON format the brief specifies, but only random and greedy can actually play through the current bindings; expectimax fails with the binding's own error until a search bridge exists.

Result

Resume equality holds, and on the GPU it is bit-exact. Run B trains one iteration, saves, and resumes for the second; run A trains both in one go. On the GPU with the full smoke preset (256 environments, 50 moves), 21 games finished in iteration 1 and 22 in the resumed iteration 2, and every metric of the resumed iteration was identical to A's to the last digit (delta zero on policy_loss, value_loss, entropy, kept, games and the rest); six fresh GPU processes from one seed produced identical checkpoints byte for byte. On the CPU the brief's exact test configuration (64 environments, 8 moves, seed 0) passes: no battles happen in sixteen plies, so the iteration is degenerate and equality is trivially exact. A second CPU test with seed 12345, where two games finish and reset in each iteration, holds exactly for the environment trajectories, the game counts and the endings, and within 5e-4 for the float metrics: a rare residual per-process noise in this PyTorch build's CPU backend moves policy_loss by that much in about a quarter of runs, below everything the checkpoint restores. The checkpoint carries the live and EMA networks, both optimisers, the setup pool, the torch and numpy generators, and every environment slot's live game with its moves since the last reset; the resumed process replays each game through the engine and loads it into the environment.

Three findings came out of making the equality as exact as it is. First, the environment keeps a private Rust generator per slot that draws the next pool setups when a game finishes, and the bindings do not expose it. A resume now records how many games finished in each slot and replays exactly those draws by finishing crafted one-move games in the slots that owe them, which runs the same reset code and consumes the same generator stream before the saved games are loaded; without this the resumed run would pick different setups for later finished games. Second, this PyTorch build's oneDNN kernels pick different JIT paths per process, which moves logits by about a thousandth, and its OpenMP GEMM reductions vary from call to call with more than one thread; the training CLI now pins torch.backends.mkldnn.enabled = False and one OpenMP thread on the CPU, and torch.use_deterministic_algorithms(True) and disabling the transformer fast path were both tried and do not remove the residual. The launcher pins OMP_NUM_THREADS=1 unless set, and single-threaded collection was also about twenty times faster under the day's machine load (load average 70, four agents sharing 32 threads): the tiny iteration took 74 s with 32 threads and 3.5 s with one. Third, the numpy global generator, which the checkpoint also saves, seeds itself from the operating system in every process and is never read by the training loop, so its checkpoint entry differs harmlessly between runs.

The launcher works as specified. A four-iteration smoke run launched with just launch finished normally after the shell that launched it exited immediately (exit in a bash -c subshell), writing status.json finished at iteration 4 of 4. A second run stopped mid-flight with just stop marked itself stopped by signal and, after --resume, finished to iteration 6 of 6. just runs lists the systemd units and every run directory's state and iteration; the launcher uses systemd-run --user --unit armies-NAME --collect --same-dir with the virtualenv on PATH and PYTHONPATH=training, and falls back to setsid nohup when systemd is unavailable.

The evaluation path (--evaluate-every with seat-alternating games against --opponents, seeds eval_seed + g) writes evaluations.jsonl with Wilson intervals, mean plies, endings and the network's milliseconds per move; the inline evaluation of the tiny CPU run against greedy read 0.25 effective over 4 games with about 13 ms per move, measured while three other agents used the machine, so treat the time as an upper bound. A smoke evaluation against random scored 0.75 over 4 games. These are plumbing checks, not strength results.

Failures along the way: the first smoke run was killed by the tool timeout after 119 s and correctly recorded stopped with stopped by signal 15 in status.json (the signal path works); an early version of the resume lost the trainer's run log because Trainer.load rebuilt the trainer without it, so the resumed iteration wrote no metric line until the CLI attached the log again; the first equality comparison was degenerate, seed 0 at 8 moves per iteration trains nothing (no battles by ply 16, so all advantages are zero and the filter keeps none), which is why the GPU comparison uses the full smoke preset and a second CPU test uses seed 12345; and the first GPU equality run compared the degenerate tiny configuration and passed on all-zero metrics, so it was redone with the full preset before it counted as evidence. The CPU equality test with seed 12345 was written asserting exact float equality, passed twice, and then failed on a third run, which started the determinism hunt above; it now asserts the truth the files show, exact counts and small-tolerance floats, and the exact claim lives on the GPU test.

The scaled-run agent launches with:

just launch scaled " --preset small --iterations 2000 --save-every 20 --evaluate-every 20 --registration <uuid>"

Attribution: baseten/zai-org/GLM-5.3 through opencode.

Throughput of the training loop

Hypothesis, registered before any measurement tonight: profiling the collection loop of the small preset names one stage above 40% of wall time (the suspects are the plane encoding and host-to-device copy on every step, and the sampling over 10,000 actions), and fixing the top two stages lifts collection from about 4,100 to 40,000 environment steps per second with learning curves that match the old path over ten iterations from seed 0. The disproof is that no stage dominates and the loop is already within a factor of two of the move network's forward-pass cost, in which case the scaled run is planned at the measured speed. This is study pipeline/throughput, theory T6 of the program.

Result (report, record): the first half of the prediction held and the second did not. One stage dominates by far: the move network's forward pass is 95.7% of a collection step, and inside it the ROCm math-attention fallback is 136 of 225 milliseconds per step, because this build gates its fused attention kernels behind an experimental flag. The plane encoding, the copies and the 10,000-way sampling together cost about 4% of the step; the prior suspects were wrong. Setting the flag (now the package default; ARMIES_COLLECT_PATH=reference reproduces the old loop bit-for-bit on smoke stats) lifts collection from 4,231 to 10,150 steps per second on ten-iteration run means (2.4x; profiles read 4,553 to 10,376), and iteration wall time falls from 73.5 to 35.0 seconds because the update phase uses the same kernels. The registered 40,000 was not reachable: with attention fixed, the forward is GEMM-bound at about 8.5 effective TFLOP/s, and larger environment counts measured slower per step, not faster. Matched curves from seed 0: policy loss, games and mean plies inside the two-seed envelope, entropy at its edge, and the kept counter outside it in iterations 2 to 6 (the advantage filter's 0.01 floor amplifies one-to-two-ulp kernel rounding into set membership), rejoining by iteration 8. Environment share of collection: 2.0% before, 5.4% after, so the world-model study's premise stands with margin.

Failures and deviations: torch.compile of the forward measured a further 1.42x (60.9 ms against 86.1 ms) but was rejected: the inductor build on gfx1151 returns NaN rows on 44 to 99 of 202 real batches under every attention backend, both input dtypes and an encoder-only graph, while the eager path is clean everywhere tested; a per-step NaN guard with eager fallback caught every case but repaid the win. One ten-iteration fast run from that build trained on zero kept transitions and was discarded and rerun; it is preserved in the record under "Measured and rejected". The Gumbel-max sampler measured no faster than multinomial once eager (2.9 vs 2.8 ms per step) and was dropped. Timing note: some runs shared the machine with other agents' CPU jobs; results are deterministic per seed and the profile tables come from single back-to-back sessions.

Attribution: baseten/moonshotai/Kimi-K3 through opencode.