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.