Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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"}]}
]}

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:

Seven planes

Each plane is an H × W grid of 0 and 1, built from one field of the observation:

#PlaneBuilt byWhat it tells the graph
0your ants{"scatter": [{"var": "mine"}, {"var": "size"}, "i8"]}Where your ants are: the only positions you are told in full
1foesa scatter of foes, owner strippedEnemy ants you can see this turn
2food{"scatter": [{"var": "food"}, {"var": "size"}, "i8"]}Food you can see
3water{"rle_expand": [{"var": "water.rle"}, {"var": "size"}, "i8"]}Known water: the one field the game remembers for you
4your hillsa scatter of hills whose owner is 0What you lose a point each for
5enemy hillsa scatter of hills whose owner is not 0What you gain two points each for razing
6visible{"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"}]]}

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"}]
]}

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.

vis is 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 one rle_expand turns 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:

BoardCellsOperationsOf the budget
24 × 245768,103 – 8,1271%
48 × 643,07243,057 – 43,1094%
120 × 12414,880208,361 – 208,42321%

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 adaptdatalogic, 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.