A real manifest, piece by piece
The trained models in the starter kit — nano-bc and micro-bc in
ants-starter/models, trained by
ants/baselines — play with one manifest,
byte for byte: 930 bytes. It declares one input and one output, and its single adapter turns an observation
into seven planes stacked as one tensor, board: i8[1, 7, H, W]. The graph answers
policy: f32[1, 5, H, W], and the referee reads it. This page reads the manifest a piece at a time.
It is not written by hand. ants/baselines/src/tb_baselines/planes.py declares each plane once,
with two renderings side by side — the JSONLogic below, and the numpy the trainer uses — and a test
proves the two agree. The end of this page comes back to why
that matters.
Every fragment below has a link that opens it in DataLogic Studio, against this illustrative observation:
{"size": [64, 96], "mine": [[12, 30], [13, 30], [43, 66]], "foes": [[12, 33, 1]],
"food": [[11, 31]], "hills": [[12, 30, 0], [44, 70, 1]],
"water": {"rle": [0, 1250, 1, 3, 0, 4891]},
"vis": {"rle": [0, 1054, 1, 11, 0, 5079]}}
Three ants — one on your hill at [12, 30], one beside it, and a third within sight of the enemy
hill at [44, 70] — a foe next to the first two, one food, three cells of known water on row 13,
and the eleven cells visible this turn.
The declaration
"inputs": [{ "name": "board", "dtype": "i8", "shape": [1, 7, "H", "W"], "adapter": … }],
"outputs": [{ "name": "policy", "dtype": "f32", "shape": [1, 5, "H", "W"] }],
"probe_dims": { "H": 128, "W": 128 }
H and W are names. They bind to whatever board the call brings, and because the output names
the same two, the graph is held to answering at the same size it was given. A season’s boards may
be any size from 24 to 124 a side and at most 14,880 squares, and one admitted session serves them
all. probe_dims admits it at 128 × 128, which was the largest board when this manifest was
written; the game now allows at most 14,880 squares, which the 120 × 124 basic board is, and a
manifest of your own should declare that (why).
The whole adapter
{"reshape": [
{"stack": [
[
{"scatter": [{"var": "mine"}, {"var": "size"}, "i8"]},
{"scatter": [
{"map": [{"var": "foes"}, [{"var": "0"}, {"var": "1"}]]},
{"var": "size"},
"i8"
]},
{"scatter": [{"var": "food"}, {"var": "size"}, "i8"]},
{"rle_expand": [{"var": "water.rle"}, {"var": "size"}, "i8"]},
{"scatter": [
{"map": [
{"filter": [{"var": "hills"}, {"==": [{"var": "2"}, 0]}]},
[{"var": "0"}, {"var": "1"}]
]},
{"var": "size"},
"i8"
]},
{"scatter": [
{"map": [
{"filter": [{"var": "hills"}, {"!=": [{"var": "2"}, 0]}]},
[{"var": "0"}, {"var": "1"}]
]},
{"var": "size"},
"i8"
]},
{"rle_expand": [{"var": "vis.rle"}, {"var": "size"}, "i8"]}
],
0
]},
{"merge": [[1, 7], {"var": "size"}]}
]}
Open in DataLogic Studio — against an illustrative observation: three ants, a foe, a food, both hills, three cells of water
The Studio evaluates the JSON half and stops at the tensor half. Every tensor call comes back with its arguments already worked out — the points, the shape, the dtype — which is exactly what the node’s operator receives; the Studio never builds the tensor. Seeing it in DataLogic Studio says what to look at, and where the Studio and the arena disagree.
The same program against a real input, one of the 207 observations admission probes every entry against — a seat on a three-seat basic board, so its foes are labelled 1 and 2:
Open in DataLogic Studio — against a reference observation admission probes against — a seat on basic-small-3p at turn 20 — as the cartridge ships it
Seven planes
Each plane is an H × W grid of 0 and 1, built from one field of the observation:
| # | Plane | Built by | What it tells the graph |
|---|---|---|---|
| 0 | your ants | {"scatter": [{"var": "mine"}, {"var": "size"}, "i8"]} | Where your ants are: the only positions you are told in full |
| 1 | foes | a scatter of foes, owner stripped | Enemy ants you can see this turn |
| 2 | food | {"scatter": [{"var": "food"}, {"var": "size"}, "i8"]} | Food you can see |
| 3 | water | {"rle_expand": [{"var": "water.rle"}, {"var": "size"}, "i8"]} | Known water: the one field the game remembers for you |
| 4 | your hills | a scatter of hills whose owner is 0 | What you lose a point each for |
| 5 | enemy hills | a scatter of hills whose owner is not 0 | What you gain two points each for razing |
| 6 | visible | {"rle_expand": [{"var": "vis.rle"}, {"var": "size"}, "i8"]} | Every cell you can see right now |
scatter starts from a grid of zeros in the given shape and writes 1 at each [row, col] point.
The shape is {"var": "size"}, read from the observation and never written down: an adapter that
writes [80, 80] into itself fails every other board size.
Stripping the owner
A foe arrives as [row, col, owner], and scatter reads a third element as the value to write.
On a two-seat board every foe is labelled 1, so scattering them as they come happens to write
the right value; on the rest the label runs up to 7, and a scatter would write that into the plane.
Strip the owner, so the plane means “a foe is here” and not “foe number N is here”. A map rebuilds each triple as a pair:
{"map": [{"var": "foes"}, [{"var": "0"}, {"var": "1"}]]}
Open in DataLogic Studio — against an illustrative observation: three ants, a foe, a food, both hills, three cells of water
Inside the map body the document is one foe, so {"var": "0"} is its row and {"var": "1"} its
column.
Splitting hills by owner
hills holds both sides’ standing hills, and owners are relative to you: 0 is always yours,
and 1 upward is an opponent (ownership labels). A filter
on the third element selects one side, and the same map strips the owner:
{"map": [
{"filter": [{"var": "hills"}, {"==": [{"var": "2"}, 0]}]},
[{"var": "0"}, {"var": "1"}]
]}
Open in DataLogic Studio — against an illustrative observation: three ants, a foe, a food, both hills, three cells of water
The enemy plane is the same expression with !=. They are two planes and therefore two
expressions — there is no object to return both in
(why).
Water, and what you can see
Both are run-length masks in row-major order, [value, count, value, count, …], and rle_expand
unrolls each straight into a plane with no JSON loop over the cells. The runs must not add up to
more than H × W, and the game’s always add up to exactly that.
The two together are what make the encoding honest. A 0 in water means known land or never
seen — the game does not tell those apart — and vis is the half of that question the observation
answers: under a 1 for visible, a 0 for water is land you are looking at; under a 0, it means
only that no water is known there.
visis sent, not derived. Deriving it would mean marking every cell within squared radius 77 of one of your ants on a board that wraps. That cannot be written in this language: it is a disk drawn per ant, and an inner iterator cannot see the enclosing ant (scope). The engine computes the mask anyway and ships it, and onerle_expandturns it into the plane.
Stacking, and the batch axis
{"reshape": [
{"stack": [[<plane 0>, <plane 1>, …, <plane 6>], 0]},
{"merge": [[1, 7], {"var": "size"}]}
]}
stack on axis 0 turns seven [H, W] planes into one [7, H, W] tensor. reshape adds the
leading axis of 1, with the shape built at run time — merge of [1, 7] and the size is
[1, 7, 64, 96] — and costs a single operation, because a reshape moves no elements.
The leading 1 is the batch axis. The manifest declares it as the literal 1, because a match is
one seat per call and nothing batches; exporting the graph with a dynamic axis there costs nothing
and keeps the artifact usable in a trainer that does batch.
The adapter’s result is the tensor itself. There is no object naming it — the input’s name in
the declaration is what names it, and an adapter that returned {"board": …} would be calling an
operator called board.
What it costs
tinybrains adapt runs the manifest over the cartridge’s reference observations and prints what
each one charged. For this adapter, measured on 19 September 2026 over the five basic boards the
reference set is drawn on — the smallest, one in the middle and the largest:
| Board | Cells | Operations | Of the budget |
|---|---|---|---|
| 24 × 24 | 576 | 8,103 – 8,127 | 1% |
| 48 × 64 | 3,072 | 43,057 – 43,109 | 4% |
| 120 × 124 | 14,880 | 208,361 – 208,423 | 21% |
The cost is the board, not the ants. A plane-building operator is charged for every cell it produces, whether or not it writes anything there. Five scatters and two RLE expansions each produce a full grid, and the stack reads all seven planes again: fourteen charges per cell, which is 208,320 at 120 × 124. The ants, foes, food and hills add a few operations each, and everything else is noise.
That is the whole bill now. An earlier contract had a second program that read the policy back at your ants, and it was charged for reading all five channels of every cell: another 82,000 operations at 128 × 128, a third of the total, for a gather every entry wrote identically. The referee does it now and it costs you nothing (why).
The budget lists what every operator charges.
Where the head goes
The graph answers policy: f32[1, 5, H, W] — five scores for every move at every cell — and the
referee reads it: it gathers the five channels at each of your ants’ cells, in mine order, and
takes the argmax of each. Channel order is N, E, S, W, -, published in
What your model answers.
The letters must be in the order your graph was trained to mean them. The baselines put the hold,
-, last. A graph whose last channel wins everywhere plays valid actions and its whole colony stands still for
the entire match — Testing shows one, and how to count your
own moves.
If your graph indexes into the board itself and answers [N, 5] in mine order, declare that shape
instead and the referee skips the gather. It is cheaper at large boards and it constrains which axes
you may name (why).
The same encoding in your trainer
A model trained in Python sees observations through a numpy encoder, and plays through
manifest.json. That is one encoding written twice, and when the two disagree nothing fails: the
model trains on one distribution and plays on another, and the only symptom is a rating below what
training promised.
The baselines’ answer is worth copying. planes.py declares every plane once, with both renderings
next to each other, and tests/test_adapter_conformance.py runs tinybrains adapt — datalogic,
the evaluator a node runs the manifest on — over the cartridge’s reference observations and
asserts that the adapter’s tensors equal the numpy encoder’s, element for element:
theirs = np.load(out / f"case-{i}" / "board.npy") # the ladder's own tensor
ours = planes.encode(json.loads((out / f"case-{i}" / "observation.json").read_text()))
assert np.array_equal(ours, theirs)
Testing before you submit shows the command.