Bot programming tutorialsMighty Professional
Build a Legend Bot ยท Machine learning

Shipping a Network in 100,000 Characters

Training happens on your GPU, in Python, with every weight a 32-bit float. The bot that plays on CodinGame is one source file of at most 100,000 characters: the network's arithmetic written out in the bot's language, its weights stored as text, and an answer due inside the turn limit on a CPU you don't control. This page fits a trained network into that file and proves the file still decides like the network, then ships the Phase 5 project's light-cycle network as one 33,824-character Rust file.

Time~60 minLevelIntermediatePrereqsNeural Networks for Game Bots (the network and its first parity check), Building the Trainer (the champion checkpoint) and Fast Bots (one file, turn timing). AI Inference and Quantization and IEEE-754 Floating Point for depth.StackC++20 & Rust ยท Python & PyTorch ยท browser demos
โ—‚ Build a Legend BotPhase 5 ยท Machine learningNext ยท Capstone: Build the Lab โ–ธ
What you'll be able to do by the end

Work out before you train whether a network fits the file. Store each row of weights in a few bits with its own scale, and fine-tune through the rounding so the shipped network keeps deciding like the trained one. Compress the bits when you need room, and pack them into characters CodinGame counts once each. Prove turn by turn that the compiled bot picks the model's moves, allowing for exact ties, time its worst turn, and submit without stopping training.

01Count the budget before you train

Your trained network sits in a PyTorch checkpoint, every weight a 32-bit float. CodinGame takes one source file, and its help center sets the size: "The code size for any game is limited to 100k characters."[1] The weights travel inside that file as text, next to the code that reads them, so the first question about any network is whether it fits.

Two numbers decide it: the bits each weight keeps, and the bits each character carries. Text drawn from a 64-symbol alphabet, the one base64 uses, carries 6 bits a character. The file also needs a scale for each row of weights, which section 02 explains, and the bot's code: the decoder, the features, the forward pass and the loop that talks to the referee.

Worked example

The project's network ships 11,041 numbers: 10,928 weights in 113 rows, plus 113 biases. Its value head, 577 more weights and biases, stays home, because the bot only needs moves.

Weights at 6 bits: 10,928 ร— 6 = 65,568 bits. Scales and biases at 16 bits each: 226 ร— 16 = 3,616 bits. In all, 69,184 bits.

At 6 bits a character that's 69,184 / 6 = 11,530.7, so 11,531 characters. With 22,293 characters of code the file comes to 33,824 characters, about a third of the limit.[2] As 32-bit floats, the same numbers alone would take 58,886.

characters = โŒˆ(weights ร— b + 16 ร— (scales + biases)) / cโŒ‰ + code

In words: add every weight's b bits to the 16 bits of every scale and bias, spread them over characters of c bits each, round up to a whole character, and add the code. Fewer bits per weight, compression (section 04) and more bits per character (section 05) all make room.

A network ten times bigger needs those tricks. The Back Track King policy from the author's lab has 104,820 weights and biases and 28,390 characters of code. At 5 bits a weight its file would come to 118,370 characters in ASCII and 66,953 in CJK; a range coder brought the real exports to 95,987 and 57,361.[3]

From checkpoint to submissionEight boxes in two rows of four, in order: the checkpoint in float32; quantize to 6-bit levels with a float16 scale per row; fine-tune through the rounding; compress, which is optional; pack the bits into text; assemble one source file; run the gates; submit. Each box names the section that covers it. 1Checkpointfloat32 weightsfrom training 2Quantize6-bit levels,a float16 scaleper row ยท ยง02 3Fine-tunethrough therounding (QAT)ยง03 4Compressa range coder,if you need roomยง04 5Pack6 bits per ASCIIor 14 per CJKcharacter ยท ยง05 6Assembledecoder, networkand loop in onefile ยท ยง06 7Gatessize, compile,parity, games,time ยท ยง07, ยง08 8Submitthe file thatpassed, recordedยง11 From checkpoint to submissionEight boxes stacked top to bottom: the checkpoint in float32; quantize; fine-tune through the rounding; compress, optional; pack the bits into text; assemble one source file; run the gates; submit. Each box names the section that covers it. 1Checkpointfloat32 weights, from training 2Quantize ยท ยง026-bit levels, float16 scales 3Fine-tune ยท ยง03through the rounding (QAT) 4Compress ยท ยง04a range coder, if you need room 5Pack ยท ยง056 bits per ASCII or 14 per CJK char 6Assemble ยท ยง06decoder, network, loop: one file 7Gates ยท ยง07, ยง08size, compile, parity, games, time 8Submit ยท ยง11the file that passed, recorded
The export, in the order this page takes it. Stages 2 to 5 change how the weights are stored, stage 6 writes the one file, and stage 7 is the set of checks every new file passes before it's submitted. The project skips stage 4: at 6 bits a weight its network fits without compression.

Fit the network

Two bars show one export against the 100,000 limit, in the packing you chose and the other one; the table gives every bit width. It opens on the project's network as shipped, 33,824 characters, the real file exactly. The Back Track King presets show a network ten times bigger: 118,370 characters in ASCII and 66,953 in CJK with no compression, and 95,988 and 57,361 with the range coder's measured 3.71 bits per 5-bit weight (the real exports were 95,987 and 57,361). That ratio was measured at 5 bits on one network, so other widths with the coder on are estimates. Short rows, like the project's, carry more scales per weight.

Two recent contest winners used nearly all of the limit. reCurse's write-up of his Fall Challenge 2023 winner explains how to fit "156k parameters in 100k": 8 bits for most weights, 16 for the first layer, 99,264 characters in all.[4] delineate's Troll Farm winner had about 101,000 parameters in 98,000 characters.[5] The turn is the other budget, and section 06 measures it.

02Store each weight in a few bits

A trained weight such as 0.013548386 carries more precision than the move it helps choose. Quantization stores it as one of a few evenly spaced levels instead: a small whole number times a step size shared by its row. At 6 bits there are 63 levels, from โˆ’31 to +31 steps.

The step, the row's scale, is the row's largest magnitude divided by 31. Each weight becomes the nearest whole number of steps, and the bot multiplies back. The scale is stored as a float16, a 16-bit floating-point number, and the project rounds it to float16 before choosing the levels, so the bot rebuilds exactly the weights the exporter measured.

Worked example

The row [0.42, โˆ’0.07, 0.013, โˆ’0.31] at 6 bits. The step is 0.42 / 31 = 0.013548, stored as the float16 0.0135498.

Levels: 0.42 / 0.0135498 = 31.0 โ†’ 31; โˆ’0.07 / 0.0135498 = โˆ’5.17 โ†’ โˆ’5; 0.013 / 0.0135498 = 0.96 โ†’ 1; โˆ’0.31 / 0.0135498 = โˆ’22.88 โ†’ โˆ’23.

The bot rebuilds 0.42004, โˆ’0.06775, 0.01355 and โˆ’0.31165. The largest error is 0.00225, under half a step (0.00677). Storage: 16 bits for the scale and 4 ร— 6 for the levels, 40 bits against 128 as float32.

s = float16(max|row| / q), level = clamp(round(w / s), โˆ’q, q), ลต = level ร— s, with q = 2bโˆ’1 โˆ’ 1

In words: the step s is the row's largest magnitude divided by q, rounded to a float16. Each weight w is divided by the step, rounded to the nearest whole number and held within q levels of zero; the bot multiplies back to get ลต. At 6 bits q is 31, at 5 bits 15, and every weight lands within half a step of its trained value, unless the whole row is so close to zero that float16 can't hold its step precisely.

Each row gets its own scale because rows differ in size: with one scale for a whole matrix, one large weight anywhere makes the step coarse for every row. Jacob et al., whose integer-inference scheme used one scale per array, list "large differences (more than 100ร—) in ranges of weights for different output channels" and "outlier weight values that make all remaining weights less precise after quantization" among the ways rounding a trained network fails.[6] A scale costs 16 bits a row: 1 bit a weight in the project's 16-weight rows, 0.11 in its 144-weight ones. AI Inference and Quantization covers the variant with a zero point that lopsided values need.

Fewer bits, more error

On the left, one row of 32 weights from a small network: the trained value as a grey stem, the shipped value as a green dot, the allowed levels as faint lines. Drag the bits slider: each shipped weight sits on a line within about half a step of the trained one, and the row's largest weight sets the step. On the right, how many of 1,000 decisions change when the whole network runs on quantized weights: 616 at 2 bits, 51 at 6, 6 at 8. The network is a random one, so the counts show the trend only.

On the project's trained network, over 63,188 decisions with more than one legal move from the champion's own games:[2]

Bits per weightSame move as the float networkWeights as ASCIIWeights as CJK
257.54%4,2461,820
357.54%6,0672,600
492.52%7,8883,381
595.15%9,7104,162
695.77%11,5314,942
899.10%15,1746,503

Below 4 bits agreement falls to 57.5%. From 4 bits up it climbs more slowly, to 99.1% at 8 bits, which would cost 3,643 more characters than 6. The project had the room, but ships 6 bits as the teaching default, with the fine-tune below on top.

03Train through the rounding

Rounding after training, post-training quantization, hands the bot weights the network never saw while it learned. At 6 bits it changed 4.2% of the project's decisions. The fix is to let the network see the rounding while it trains.

Jacob et al. found that rounding a trained network "works sufficiently well for large models with considerable representational capacity, but leads to significant accuracy drops for small models", and a bot's network is small. Their method runs the forward pass on rounded weights while "all weights and biases are stored in floating point so that they can be easily nudged by small amounts".[6] That's (QAT): the loss measures the network you'll ship, and the optimizer updates the full-precision copy.

Rounding has a slope of zero almost everywhere, which would stop every gradient, so the backward pass treats it as if it weren't there and hands the gradient straight to the full-precision weight. That shortcut is the straight-through estimator: Hinton suggested it in a 2012 lecture for neurons with a hard threshold, and Bengio, Lรฉonard and Courville named it in 2013.[7]

Worked example

A weight of 0.0060 sits in a row whose step is 0.01355, so it rounds to level 0 and ships as 0. Trained without QAT, the loss sees 0.0060 and has no reason to move it.

With QAT the loss sees 0. Suppose it wants the weight larger, so each update adds 0.0001 to the full-precision copy. After eight updates the copy reaches 0.0068, past half a step (0.00677), and the shipped weight jumps to level 1, 0.01355. No nudge could have changed the shipped weight on its own; together they moved it to the level the loss prefers.

You can train through the rounding from the start or fine-tune afterwards. The author's lab trains its Back Track King network through its exporter's 5-bit rounding during self-play, and putibuzu, 20th in the Back Track King contest, lists "5bit weight encoding" with quantization-aware training for about 135,000 weights.[3][8] The project fine-tunes after self-play: two epochs on 72,221 recorded positions, the rounded network learning to match the float network's move probabilities, Adam at a learning rate of 3 ร— 10โˆ’5. It takes seconds.[2] The table also scores each version against the project's two yardsticks: the Voronoi bot from Evaluation Functions, which takes the move that leaves it the most cells it reaches first, and the alpha-beta search bot from Minimax and Alpha-Beta.

Version (6 bits)Agreement with the float networkvs Voronoivs alpha-beta
float32 (for reference)100%.693 [.646, .736].460 [.412, .509]
rounded after training95.77%.677 [.630, .721].443 [.395, .491]
fine-tuned through the rounding (shipped)97.64%.680 [.633, .724].470 [.422, .519]

The fine-tune cut the changed decisions by about 1,180 of 63,188. The strengths are shares of 400 games on the evaluation seeds with 95% intervals, and all three overlap: matches that size can't separate the versions, so the choice rests on agreement, which is measured on far more decisions. Measuring Bot Strength explains the intervals.[2]

04Compress what the levels don't use

Quantized weights don't use their levels evenly. Most trained weights sit near zero, so the middle levels come up far more often than the outer ones, and a fixed 5 or 6 bits spends as much on a common level as on a rare one. Entropy coding gives common symbols fewer bits and rare ones more.

Shannon's theorem sets the floor: messages from a model can't be encoded "into less bits (on average) than the entropy of that model".[9] Arithmetic coding gets close to it. In Witten, Neal and Cleary's description, "a message is represented by an interval of real numbers between 0 and 1", and "the more likely symbols reduce the range by less than the unlikely symbols and hence add fewer bits to the message."[9] A does the same in integer arithmetic and, as Martin noted in 1979, "can produce encodings to any base".[10]

Worked example

Four levels come up with probabilities 1/2, 1/4, 1/8 and 1/8. A fixed code spends 2 bits on each; an entropy coder can spend 1, 2, 3 and 3 bits: 0.5 ร— 1 + 0.25 ร— 2 + 0.125 ร— 3 + 0.125 ร— 3 = 1.75 bits per level on average, the entropy of that distribution.

The lab's Back Track King network has 104,256 levels of 5 bits, 65,160 bytes stored plainly. Its range coder wrote them in 48,373 bytes: 3.71 bits a level, 26% less.[3]

That saving decided whether the lab's 5-bit ASCII export fit: 118,370 characters without the coder, 95,987 with it.[3] General-purpose compressors work too. Indrill, who won the Back Track King contest with a Python bot, sent his weights out through "pickle>lzma>encode(UTF-16)" and reversed it inside the bot.[11] reCurse wrote in 2023 that CodinGame's servers "have gzip available from shell".[4] The output is bytes either way, and the next section turns bytes into characters. The project skips this stage: its 6-bit weights take 11,531 characters, under an eighth of the limit.

How the lab's coder learns its probabilities

It's adaptive. It codes each level one bit at a time, from the top bit down, and each bit gets a probability that depends on the bits above it in the same level. Every probability moves a little toward what the coder just saw, and the decoder makes the same moves as it decodes, so no table of probabilities goes into the file.

05Pack the bits into characters

A string literal can't hold raw bytes. A quote ends it, a backslash starts an escape, some control characters break it, and bytes that aren't valid UTF-8 won't compile in Rust or Python. The packed weights have to be text the compiler and CodinGame's editor take unchanged.

The simplest packing writes 6 bits at a time as one of 64 safe ASCII characters: A to Z, a to z, 0 to 9, + and /. Each is one byte and one character under any way of counting, which is why the project ships it.

Packing more bits per character depends on what CodinGame counts, and the help center doesn't define a character.[1] How Bot Contests Work covers the tests. The author's test games on October 25, 2026 accepted a 57,361-character source of 115,303 bytes in UTF-8 and refused one of 100,499 characters with "Max chars is 100000", so the limit doesn't count UTF-8 bytes.[3]

Each character has a , the number Unicode gives it, and code points up to U+FFFF form the Basic Multilingual Plane (BMP). How characters above it count, most emoji among them, is disputed. UTF-16 stores each of them as two 16-bit code units, and CarlWalsh's 2025 forum test found CodinGame counting them that way; the author's September 2026 test with the contest IDE's Play button found each counted once. Neither test is known to have gone through Submit.[12][3] Inside the BMP every reading agrees, so that's where packed data belongs.

The lab's second packing writes 14 bits at a time as the character U+4E00 plus that value: 16,384 consecutive CJK ideographs from U+4E00 to U+8DFF, each one code point in the BMP, one UTF-16 unit and three bytes in UTF-8.[13] The range avoids everything that breaks a source file: the surrogates U+D800 to U+DFFF, which UTF-16 reserves for pairs; the control codes U+0000 to U+001F and U+007F to U+009F; quotes and backslashes; and the special "control" code points TBali found "messing up left-to-right and right-to-left writing" in CodinGame's IDE.[13][12]

Worked example

The project's 69,184 bits, 14 at a time: 69,184 / 14 = 4,941.7, so 4,942 characters, against 11,531 in ASCII.

The whole file shrinks from 33,824 characters to 27,236, while its size in UTF-8 grows from 33,830 bytes to 37,126: three bytes per packed character instead of one, and only the characters are limited.[2]

Why 14 bits a character and not 15 or 16

The BMP holds 63,488 code points that aren't surrogates, a little under 216, so a fixed number of bits per character tops out at 15. reCurse used 15, "32k code points per character".[4] A run of 32,768 safe code points has to cross several Unicode blocks, while 16,384 fit inside one block of ideographs and decode with one subtraction. The 15th bit would save about 7% of the weight characters.

What counts as a character?

Pick a text and compare three ways of counting it. It opens on a file with the sizes of the lab's CJK export, 115,303 UTF-8 bytes and 57,361 code points, which CodinGame accepted on 2026-10-25. Inside the BMP, code units and code points agree. Above it each character takes two code units and the readings split: CarlWalsh's April 2025 forum test fit a code-unit count, while the author's September 2026 IDE test accepted the emoji preset's 120,966 code units (60,966 code points). Neither test is known to have gone through Submit. Type your own text, or raise the copies to see where it would cross the line.

A common question when an export comes out too big: should you zip the data, or encode it in Chinese? Both, in that order. Compression cuts the bits, and packing sets how many bits each counted character carries. The lab's 5-bit Back Track King network would need 118,370 characters as plain ASCII; the range coder brought it to 95,987, and packing the same coded bits 14 to a character brought it to 57,361.[3] Compressed output on its own is binary, and as base64 text it still costs a character per 6 bits.

Packed weights and the contest rules

In 2017 CodinGame added a contest rule against "Obfuscating your code as well as creating obstacles to its reading and understanding", and a staff member posted the code it targeted: a compiled program encoded as text, which the submission wrote to disk and ran.[14] When players asked whether compressed network weights counted, no CodinGame employee answered; reCurse, a player who also moderates the forum, replied "You both should be fine IMHO". Asked whether the rule covered multiplayer puzzles, staff replied "No, multiplayer is a zoo." reCurse and Indrill have since described packed weights in contest-winning bots.[4][11] Keep the weights as data, never encode executable code, and read the rules page of the contest you enter.

06Run the network inside the turn

The bot has no PyTorch. It rebuilds the weights from the string, computes the features and runs every layer as plain loops, all inside the turn limit. Tron Battle asks for an answer "in less than 100ms for each game turn" and states no separate limit for the first turn, so decoding the weights on turn one counts against the same 100 ms.[15]

The project's forward pass is the plain Rust one from Neural Networks for Game Bots: float32, layer for layer the same as the PyTorch model. The network counts as about 6.2 million multiply-adds a decision; the Rust convolution skips the taps that fall off the board, which leaves 5.1 million. It walks the board a row at a time, so the inner loop runs without bounds checks. SIMD instructions, which work on several numbers at once, could speed it up (SIMD covers them); this network didn't need them.

Measure the turn inside the bot, from the first line of input to the flushed answer, as Fast Bots does. The limit judges the slowest turn, and the first turn decodes the weights on top of its move. On a busy machine, time the same games several times and keep each turn's fastest run: the slower runs include time the operating system gave to other programs. The project measured both ways on a desktop running other training jobs:[2]

RunTurnsFirst turn (max)Mean99th percentileWorst
one run of all 400 parity games68,11338.3 ms2.58 ms16.7 ms96.5 ms
best of 3 runs, first 40 games6,5573.4 ms1.19 ms2.1 ms7.4 ms

The single run's worst turn is most likely another job taking the core in the middle of a turn; the best of three leaves the bot's own cost.

Worked example

One loop in Fast Bots ran 3.3 times slower in CodinGame's IDE than on the author's desktop, measured on one day in September 2026.[3] At that ratio the project's worst uncontended turn, 7.4 ms, would take 7.4 ร— 3.3 โ‰ˆ 24 ms there: a quarter of the 100 ms. That's an estimate from one loop on one day; the project's bot hasn't been timed on CodinGame.

From the author's lab

October 25, 2026: a test game on CodinGame replayed a Back Track King battle with the lab's network printing its own timings to stderr. The network took about 8.3 ms a turn and its features about 0.6 ms, and the slowest of the game's 39 turns took 10.19 ms, against that game's 50 ms limit.[3]

To time yours there, give the bot a switch that prints each turn's time to stderr, which test games show and ranked battles don't.[16] You submit only the source file, with no way to add command-line arguments, so where the project's bot reads a --timing argument locally, use a constant you set before pasting the file. Play test games on several seeds and read the largest time, the first turn separately. delineate reports using "only around 2-3ms out of the 50ms budget each turn"; the time a fast network leaves free is what Networks Inside Search spends.[5]

07Prove the bot decides like the model

The exported bot and the PyTorch model are two programs meant to compute one function. A transposed weight matrix, a feature computed differently in Rust, or a decoder one bit out of step all give a bot that still plays legal moves, just worse ones, and a few test games won't show it.

A compares the two on every turn of real games. Play games with the Python model running the shipped, rounded weights, and record every input frame the bot's seat received. Feed the frames in order to the compiled bot, exactly as CodinGame would, and compare its move with the model's on each turn. Compare with the rounded model: the fine-tuned network still differs from the float one on 2.4% of decisions, by design.

Exact equality is too strict, because floating-point addition depends on order: each addition rounds, so the same numbers added in another order can round differently. PyTorch on a GPU, a plain Rust loop and a SIMD loop all add in different orders (IEEE-754 Floating Point explains the rounding).

Worked example

In float32, add 16,777,216 + 1 + 1. Left to right, 16,777,216 + 1 lands exactly halfway between two float32 numbers and rounds back to 16,777,216, and the second 1 does the same. Right to left, 1 + 1 = 2, and 16,777,216 + 2 = 16,777,218 exactly. Same three numbers, results 2 apart.

The differences in a network's logits, the raw scores it gives each move before they become probabilities, are of that kind: tiny next to the gap between most moves, so they flip a move only when its two best moves are nearly tied.

Close calls do happen. Two moves can score exactly the same in real arithmetic, for example mirrored moves in a position that is its own mirror image, when the network's weights treat left and right alike. Then rounding alone picks the winner. So the check needs a tie tolerance. A move the bot chose differently still passes if the model scores it within the tolerance of its best move, and it's recorded as a near tie with its gap; anything further apart fails the export.

From the author's lab

October 19, 2026: an export of the lab's Back Track King champion from iteration 25,850 failed its parity check on a single turn. The bot and the Python model chose different disruption zones whose logits differed by 5.7 ร— 10โˆ’6, a summation-order tie. The exporter now accepts a differing command only when the rounded model rates it its best within a tolerance of 10โˆ’4 logits, records it with its gap, and still fails anything else. The re-export passed.[3]

The tolerance sits above the noise that summation order makes and below any real difference. The largest logit difference the project measured between PyTorch on a GPU and the Rust forward pass was 3.8 ร— 10โˆ’5, and the lab's tie was 5.7 ร— 10โˆ’6; 10โˆ’4 clears both, and a bug small enough to hide under it could flip a move only in a near tie. The project's check had no tie to forgive. Over 400 games, 68,513 turns against the Voronoi and alpha-beta bots, the compiled bot matched the model every time, and the closest call, the smallest gap between the model's two best logits, was 6.8 ร— 10โˆ’4.[2]

Same weights, different answers?

Each dot is one position of a toy policy over a 16 by 16 board, placed by the gap between its two best logits as a plain loop computes them. Raised dots are positions where an 8-lane sum, the order SIMD code often uses, picks a different move from the same float32 weights. The toy's weights treat left and right alike, and a quarter of the positions are their own mirror image, so rounding decides between two tied moves: 227 of those 512 flip, all with gaps of 2.9 ร— 10โˆ’6 or less, and no other position flips (the closest other gap is 2.8 ร— 10โˆ’4). At a tolerance of 10โˆ’4 every flip counts as a tie. Drag it below the rounding noise and ties start failing the export; set the mirror share to 0 and nothing flips.

08Gates before you submit

Every export is a new program built from new weights. Each one goes through the same gates before it can be submitted, and one that fails any gate doesn't ship.

  1. Size. Count characters the way CodinGame does, with every character in the BMP so the readings agree: wc -m under a UTF-8 locale counts characters, while wc -c counts bytes. Leave a margin for code you'll add later.
  2. Compile like the platform. Build the single file alone, with the compiler version CodinGame lists and warnings as errors. The project compiled its file with Rust 1.98.1, but the file uses nothing newer than let-else, stable since Rust 1.65, so Rust 1.90.0, the version CodinGame's help center listed in October 2026, can build it too.[2][1]
  3. Parity. Every turn of a few hundred games against the rounded model, with the tie tolerance from section 07.
  4. Games. Whole games through a referee, so the protocol is tested end to end, at every player count the ladder uses.
  5. Time. The worst turn and the first turn, measured inside the bot, with a margin for a slower machine.
  6. Record. Keep the report and the file's SHA-256 hash, so you can prove later which file you submitted; Capstone: Build the Lab keeps these records for every submission.

A common question while an export runs: it has been going for six minutes, so how long should it take? Minutes, and longer on a busy machine, because the gates replay thousands of turns and play whole games. The project's export took 667 seconds, about 11 minutes, on a shared and heavily loaded machine. It recorded 72,221 positions from 300 games, fine-tuned the network, compared three versions of it over 2,400 games, compiled two builds, replayed 68,513 turns of 400 games through the compiled bot and timed 40 games three times over.[2] Nearly all of that is checking; the rounding and packing take seconds.

09Quantize, pack and decode in code

These complete programs do the exporter's arithmetic and the bot's decoding: per-row quantization with float16 scales (rounding ties to even, as NumPy does), a packer that writes 6 bits per ASCII character or 14 per CJK character, a reader that takes them back, and the tie-tolerant comparison from section 07. main() builds a network of the project's shape from seeded random weights and checks that every weight lands within half a step of its trained value, that both packings decode bit for bit, that the character counts are section 01's 11,531 and section 05's 4,942, and that no packed character is a surrogate, a control code, a quote or a backslash.

Fed the project's real shipped weights instead, both programs write the exact weight strings of its ASCII and CJK builds, and the C++ toHalf agrees with GCC's own float16 conversion on all 4,278,190,080 finite floats.

Quantize, pack and decode, complete
// Quantize a network's weight rows to 6 bits with a float16 scale each, pack the bits into text (6 bits
// per ASCII character or 14 per CJK character), decode them the way a bot would, and compare moves with
// a tie tolerance. main() checks each step on a network with the project's shape.
#include <algorithm>
#include <array>
#include <cassert>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>

constexpr int kBits = 6;                    // bits per weight: levels -31 to 31
constexpr std::uint32_t kCjkBase = 0x4E00;  // 16,384 CJK ideographs from here: U+4E00 to U+8DFF
const std::string kAscii = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

// The nearest IEEE half-precision number to a finite float, as its 16-bit pattern. Ties go to even,
// the rounding NumPy's astype(float16) uses, so the scales match a Python exporter's.
std::uint16_t toHalf(float value) {
    std::uint32_t bits;
    std::memcpy(&bits, &value, sizeof bits);
    const std::uint32_t sign = (bits >> 16) & 0x8000u;
    const int exponent = int((bits >> 23) & 0xFFu) - 127;
    if (exponent < -25) return std::uint16_t(sign);  // below half the smallest half: 0
    const std::uint32_t mantissa = (bits & 0x7FFFFFu) | 0x800000u;  // with its implicit leading 1
    // A half keeps 11 significant bits down to 2^-14; below that it counts in steps of 2^-24.
    const int dropped = exponent >= -14 ? 13 : -1 - exponent;
    std::uint32_t kept = mantissa >> dropped;
    const std::uint32_t rest = mantissa & ((1u << dropped) - 1), halfway = 1u << (dropped - 1);
    if (rest > halfway || (rest == halfway && (kept & 1u))) ++kept;
    if (exponent < -14) return std::uint16_t(sign | kept);  // a kept of 0x400 is the smallest normal
    int field = exponent + 15;
    if (kept == 0x800u) { kept >>= 1; ++field; }  // rounding carried into the next power of 2
    if (field >= 31) return std::uint16_t(sign | 0x7C00u);  // too large for a half: infinity
    return std::uint16_t(sign | (std::uint32_t(field) << 10) | (kept & 0x3FFu));
}

// The value of a half's bit pattern. Exact: every half is also a float.
float fromHalf(std::uint16_t half) {
    const int field = (half >> 10) & 0x1F, fraction = half & 0x3FF;
    assert(field != 31 && "the exporter never stores infinity or NaN");
    const float magnitude = field == 0 ? std::ldexp(float(fraction), -24)
                                       : std::ldexp(float(1024 + fraction), field - 25);
    return (half & 0x8000u) ? -magnitude : magnitude;
}

// One weight matrix as the file stores it: a float16 scale per row, a small integer level per weight.
struct QuantizedMatrix {
    std::size_t columns = 0;
    std::vector<std::uint16_t> scales;
    std::vector<int> levels;  // -largest to largest, row after row
};

// Per-row symmetric quantization, as the project's export.py does it: round the scale to float16
// first, then give each weight the nearest level of that stored scale, so the bot rebuilds exactly
// the weights the exporter measured.
QuantizedMatrix quantizeRows(const std::vector<float>& weights, std::size_t rows, int bits) {
    const int largest = (1 << (bits - 1)) - 1;
    QuantizedMatrix matrix{weights.size() / rows, {}, {}};
    for (std::size_t row = 0; row < rows; ++row) {
        const float* first = weights.data() + row * matrix.columns;
        float biggest = 0;
        for (std::size_t column = 0; column < matrix.columns; ++column) {
            assert(std::isfinite(first[column]));
            biggest = std::max(biggest, std::fabs(first[column]));
        }
        std::uint16_t scale = toHalf(biggest / float(largest));  // the biggest weight gets the top level
        if (scale == 0) scale = toHalf(1.0f);                      // an all-zero row: every level is 0
        const float step = fromHalf(scale);
        matrix.scales.push_back(scale);
        for (std::size_t column = 0; column < matrix.columns; ++column) {
            // nearbyint rounds ties to even in the default rounding mode, like NumPy's rint.
            const int level = int(std::nearbyint(first[column] / step));
            matrix.levels.push_back(std::clamp(level, -largest, largest));
        }
    }
    return matrix;
}

// Writes (value, width) pairs as characters of charBits bits each, lowest bits first.
class BitPacker {
public:
    explicit BitPacker(int charBits) : charBits_(charBits) {}
    void put(std::uint32_t value, int width) {
        buffer_ |= std::uint64_t(value) << pending_;
        for (pending_ += width; pending_ >= charBits_; pending_ -= charBits_) emit();
    }
    std::string finish() {  // pads the last character with zero bits
        if (pending_ > 0) emit();
        return text_;
    }

private:
    void emit() {
        const auto code = std::uint32_t(buffer_ & ((1u << charBits_) - 1));
        buffer_ >>= charBits_;
        if (charBits_ == 6) { text_ += kAscii[code]; return; }
        const std::uint32_t point = kCjkBase + code;  // UTF-8, three bytes: 1110xxxx 10xxxxxx 10xxxxxx
        text_ += char(0xE0 | (point >> 12));
        text_ += char(0x80 | ((point >> 6) & 0x3F));
        text_ += char(0x80 | (point & 0x3F));
    }
    int charBits_, pending_ = 0;
    std::uint64_t buffer_ = 0;
    std::string text_;
};

// The code points of UTF-8 text this program wrote (well-formed by construction).
std::vector<std::uint32_t> codePoints(const std::string& text) {
    std::vector<std::uint32_t> points;
    for (std::size_t at = 0; at < text.size();) {
        const std::uint32_t lead = std::uint8_t(text[at++]);
        const int extra = lead < 0x80 ? 0 : lead < 0xE0 ? 1 : lead < 0xF0 ? 2 : 3;
        std::uint32_t point = extra == 0 ? lead : lead & (0x3Fu >> extra);
        for (int i = 0; i < extra; ++i) point = point << 6 | (std::uint8_t(text[at++]) & 0x3Fu);
        points.push_back(point);
    }
    return points;
}

// Reads the characters back as one stream of bits, in the order BitPacker wrote them.
class BitReader {
public:
    BitReader(const std::string& text, int charBits) : points_(codePoints(text)), charBits_(charBits) {}
    std::uint32_t take(int width) {
        for (; pending_ < width; pending_ += charBits_) {
            assert(next_ < points_.size() && "the text ended early");
            const std::uint32_t point = points_[next_++];
            const auto code = charBits_ == 6 ? std::uint32_t(kAscii.find(char(point))) : point - kCjkBase;
            assert(code < (1u << charBits_) && "not a character BitPacker writes");
            buffer_ |= std::uint64_t(code) << pending_;
        }
        const auto value = std::uint32_t(buffer_ & ((1u << width) - 1));
        buffer_ >>= width;
        pending_ -= width;
        return value;
    }

private:
    std::vector<std::uint32_t> points_;
    std::size_t next_ = 0;
    int charBits_, pending_ = 0;
    std::uint64_t buffer_ = 0;
};

// The project's network as bot.rs lists it: rows and weights per row of each matrix. Each matrix is
// followed by its bias vector, one bias per row: 10,928 weights in 113 rows, and 113 biases.
struct Shape { std::size_t rows, columns; };
const std::vector<Shape> kLayout = {{16, 72}, {16, 144}, {16, 144}, {16, 144}, {16, 144},
                                    {16, 16}, {16, 18}, {1, 16}};
struct Layer { std::vector<float> weights, biases; };

// The exporter's side: per row a 16-bit scale and then each weight's level; per layer its biases.
std::string encodeWeights(const std::vector<Layer>& layers, int charBits) {
    const int largest = (1 << (kBits - 1)) - 1;
    BitPacker packer(charBits);
    for (std::size_t layer = 0; layer < layers.size(); ++layer) {
        const QuantizedMatrix matrix = quantizeRows(layers[layer].weights, kLayout[layer].rows, kBits);
        for (std::size_t index = 0; index < matrix.levels.size(); ++index) {
            if (index % matrix.columns == 0) packer.put(matrix.scales[index / matrix.columns], 16);
            packer.put(std::uint32_t(matrix.levels[index] + largest), kBits);  // 0 to 62: fits in 6 bits
        }
        for (const float bias : layers[layer].biases) packer.put(toHalf(bias), 16);
    }
    return packer.finish();
}

// The bot's side: the same walk, rebuilding each number as level x scale.
std::vector<float> decodeWeights(const std::string& text, int charBits) {
    const int largest = (1 << (kBits - 1)) - 1;
    BitReader reader(text, charBits);
    std::vector<float> numbers;
    for (const Shape& shape : kLayout) {
        for (std::size_t row = 0; row < shape.rows; ++row) {
            const float step = fromHalf(std::uint16_t(reader.take(16)));
            for (std::size_t column = 0; column < shape.columns; ++column)
                numbers.push_back(float(int(reader.take(kBits)) - largest) * step);
        }
        for (std::size_t row = 0; row < shape.rows; ++row)
            numbers.push_back(fromHalf(std::uint16_t(reader.take(16))));
    }
    return numbers;
}

// A character a source file can carry: not a surrogate (U+D800 to U+DFFF, which valid text never
// holds), not a control code (U+0000 to U+001F, U+007F to U+009F), not a quote or backslash that would
// end the string literal, and in the BMP, where every way of counting characters gives 1.
bool safeCharacter(std::uint32_t point) {
    const bool surrogate = point >= 0xD800 && point <= 0xDFFF;
    const bool control = point <= 0x1F || (point >= 0x7F && point <= 0x9F);
    return !surrogate && !control && point != '"' && point != '\\' && point <= 0xFFFF;
}

// The bot agrees with the reference model when the model rates the bot's move within `tolerance` of
// its best. Two moves that close are a tie that summation order can break either way.
bool agrees(int botMove, const std::array<float, 4>& referenceLogits, float tolerance) {
    const float best = *std::max_element(referenceLogits.begin(), referenceLogits.end());
    return best - referenceLogits[std::size_t(botMove)] <= tolerance;
}

int main() {
    std::uint64_t state = 2026;  // SplitMix64, seeded: every run checks the same weights
    auto uniform = [&state] {    // a float in [-1, 1)
        std::uint64_t mixed = (state += 0x9E3779B97F4A7C15ull);
        mixed = (mixed ^ (mixed >> 30)) * 0xBF58476D1CE4E5B9ull;
        mixed = (mixed ^ (mixed >> 27)) * 0x94D049BB133111EBull;
        return float(double((mixed ^ (mixed >> 31)) >> 11) * 0x1.0p-52 - 1.0);
    };
    std::vector<Layer> layers;
    std::vector<float> expected;  // what the bot must rebuild, in stream order
    double worstError = 0;        // in steps of each row's scale
    for (const Shape& shape : kLayout) {
        Layer layer{std::vector<float>(shape.rows * shape.columns), std::vector<float>(shape.rows)};
        for (float& weight : layer.weights) {  // cubed: most near 0, a few large, like a trained row
            const float draw = uniform();
            weight = 0.3f * draw * draw * draw;
        }
        for (float& bias : layer.biases) bias = 0.05f * uniform();
        const QuantizedMatrix matrix = quantizeRows(layer.weights, shape.rows, kBits);
        for (std::size_t index = 0; index < layer.weights.size(); ++index) {
            const float step = fromHalf(matrix.scales[index / shape.columns]);
            expected.push_back(float(matrix.levels[index]) * step);
            const double error = std::fabs(double(layer.weights[index]) - double(expected.back()));
            worstError = std::max(worstError, error / step);
        }
        for (const float bias : layer.biases) expected.push_back(fromHalf(toHalf(bias)));
        layers.push_back(std::move(layer));
    }
    assert(expected.size() == 11041 && worstError <= 0.5 + 1e-6);  // within half a step of its level
    for (const int charBits : {6, 14}) {
        const std::string text = encodeWeights(layers, charBits);
        const std::vector<std::uint32_t> points = codePoints(text);
        // 10,928 x 6 + 226 x 16 = 69,184 bits: ceil(69,184 / 6) = 11,531, ceil(69,184 / 14) = 4,942.
        assert(points.size() == (charBits == 6 ? 11531u : 4942u));
        assert(std::all_of(points.begin(), points.end(), safeCharacter));
        assert(decodeWeights(text, charBits) == expected);  // every number, bit for bit
        std::printf("%2d bits a character: %zu characters, %zu UTF-8 bytes, decoded exactly\n",
                    charBits, points.size(), text.size());
    }
    const float tolerance = 1e-4f;  // a 5.7e-6 gap is a tie; 6.8e-4 is not; an illegal move never agrees
    assert(agrees(1, {0.5f, 0.5f - 5.7e-6f, -INFINITY, 0.1f}, tolerance));
    assert(!agrees(1, {0.5f, 0.5f - 6.8e-4f, -INFINITY, 0.1f}, tolerance));
    assert(!agrees(2, {0.5f, 0.5f - 5.7e-6f, -INFINITY, 0.1f}, tolerance));
    std::printf("largest rounding error: %.3f of a step; parity rule: ok\n", worstError);
}
// Quantize a network's weight rows to 6 bits with a float16 scale each, pack the bits into text (6 bits
// per ASCII character or 14 per CJK character), decode them the way a bot would, and compare moves with
// a tie tolerance. main() checks each step on a network with the project's shape.
const BITS: u32 = 6; // bits per weight: levels -31 to 31
const CJK_BASE: u32 = 0x4E00; // 16,384 CJK ideographs from here: U+4E00 to U+8DFF
const ASCII: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

/// The nearest IEEE half-precision number to a finite f32, as its 16-bit pattern. Ties go to even,
/// the rounding NumPy's astype(float16) uses, so the scales match a Python exporter's.
fn to_half(value: f32) -> u16 {
    let bits = value.to_bits();
    let sign = (bits >> 16) & 0x8000;
    let exponent = ((bits >> 23) & 0xFF) as i32 - 127;
    if exponent < -25 {
        return sign as u16; // below half the smallest half: 0
    }
    let mantissa = (bits & 0x7F_FFFF) | 0x80_0000; // with its implicit leading 1
    // A half keeps 11 significant bits down to 2^-14; below that it counts in steps of 2^-24.
    let dropped = if exponent >= -14 { 13 } else { (-1 - exponent) as u32 };
    let mut kept = mantissa >> dropped;
    let (rest, halfway) = (mantissa & ((1 << dropped) - 1), 1 << (dropped - 1));
    if rest > halfway || (rest == halfway && kept & 1 == 1) {
        kept += 1;
    }
    if exponent < -14 {
        return (sign | kept) as u16; // a kept of 0x400 is the smallest normal
    }
    let mut field = (exponent + 15) as u32;
    if kept == 0x800 {
        kept >>= 1; // rounding carried into the next power of 2
        field += 1;
    }
    if field >= 31 {
        return (sign | 0x7C00) as u16; // too large for a half: infinity
    }
    (sign | (field << 10) | (kept & 0x3FF)) as u16
}

/// The value of a half's bit pattern. Exact: every half is also an f32.
fn from_half(half: u16) -> f32 {
    let (field, fraction) = (i32::from((half >> 10) & 0x1F), f32::from(half & 0x3FF));
    assert!(field != 31, "the exporter never stores infinity or NaN");
    let magnitude = if field == 0 { fraction * 2f32.powi(-24) } else { (1024.0 + fraction) * 2f32.powi(field - 25) };
    if half & 0x8000 != 0 { -magnitude } else { magnitude }
}

/// One weight matrix as the file stores it: a float16 scale per row, a small integer level per weight.
struct QuantizedMatrix {
    columns: usize,
    scales: Vec<u16>,
    levels: Vec<i32>, // -largest to largest, row after row
}

/// Per-row symmetric quantization, as the project's export.py does it: round the scale to float16
/// first, then give each weight the nearest level of that stored scale, so the bot rebuilds exactly
/// the weights the exporter measured.
fn quantize_rows(weights: &[f32], rows: usize, bits: u32) -> QuantizedMatrix {
    let largest = (1i32 << (bits - 1)) - 1;
    let mut matrix = QuantizedMatrix { columns: weights.len() / rows, scales: Vec::new(), levels: Vec::new() };
    for row in weights.chunks(matrix.columns) {
        assert!(row.iter().all(|weight| weight.is_finite()));
        let biggest = row.iter().fold(0f32, |most, weight| most.max(weight.abs()));
        let mut scale = to_half(biggest / largest as f32); // the biggest weight gets the top level
        if scale == 0 {
            scale = to_half(1.0); // an all-zero row: every level is 0
        }
        let step = from_half(scale);
        matrix.scales.push(scale);
        for weight in row {
            // round_ties_even matches NumPy's rint.
            let level = (weight / step).round_ties_even() as i32;
            matrix.levels.push(level.clamp(-largest, largest));
        }
    }
    matrix
}

/// Writes (value, width) pairs as characters of `char_bits` bits each, lowest bits first.
struct BitPacker {
    char_bits: u32,
    pending: u32,
    buffer: u64,
    text: String,
}

impl BitPacker {
    fn new(char_bits: u32) -> Self {
        BitPacker { char_bits, pending: 0, buffer: 0, text: String::new() }
    }
    fn put(&mut self, value: u32, width: u32) {
        self.buffer |= u64::from(value) << self.pending;
        self.pending += width;
        while self.pending >= self.char_bits {
            self.emit();
            self.pending -= self.char_bits;
        }
    }
    fn finish(mut self) -> String {
        if self.pending > 0 {
            self.emit(); // pads the last character with zero bits
        }
        self.text
    }
    fn emit(&mut self) {
        let code = (self.buffer & ((1 << self.char_bits) - 1)) as u32;
        self.buffer >>= self.char_bits;
        self.text.push(if self.char_bits == 6 {
            char::from(ASCII[code as usize])
        } else {
            char::from_u32(CJK_BASE + code).expect("U+4E00 to U+8DFF are all characters")
        });
    }
}

/// Reads the characters back as one stream of bits, in the order BitPacker wrote them.
struct BitReader<'a> {
    characters: std::str::Chars<'a>,
    char_bits: u32,
    pending: u32,
    buffer: u64,
}

impl<'a> BitReader<'a> {
    fn new(text: &'a str, char_bits: u32) -> Self {
        BitReader { characters: text.chars(), char_bits, pending: 0, buffer: 0 }
    }
    fn take(&mut self, width: u32) -> u32 {
        while self.pending < width {
            let point = self.characters.next().expect("the text ended early");
            let code = if self.char_bits == 6 {
                ASCII.iter().position(|&symbol| char::from(symbol) == point).map_or(u32::MAX, |index| index as u32)
            } else {
                (point as u32).wrapping_sub(CJK_BASE)
            };
            assert!(code < 1 << self.char_bits, "not a character BitPacker writes");
            self.buffer |= u64::from(code) << self.pending;
            self.pending += self.char_bits;
        }
        let value = (self.buffer & ((1 << width) - 1)) as u32;
        self.buffer >>= width;
        self.pending -= width;
        value
    }
}

/// The project's network as bot.rs lists it: rows and weights per row of each matrix. Each matrix is
/// followed by its bias vector, one bias per row: 10,928 weights in 113 rows, and 113 biases.
struct Shape {
    rows: usize,
    columns: usize,
}
const LAYOUT: [Shape; 8] = [
    Shape { rows: 16, columns: 72 },
    Shape { rows: 16, columns: 144 },
    Shape { rows: 16, columns: 144 },
    Shape { rows: 16, columns: 144 },
    Shape { rows: 16, columns: 144 },
    Shape { rows: 16, columns: 16 },
    Shape { rows: 16, columns: 18 },
    Shape { rows: 1, columns: 16 },
];
struct Layer {
    weights: Vec<f32>,
    biases: Vec<f32>,
}

/// The exporter's side: per row a 16-bit scale and then each weight's level; per layer its biases.
fn encode_weights(layers: &[Layer], char_bits: u32) -> String {
    let largest = (1i32 << (BITS - 1)) - 1;
    let mut packer = BitPacker::new(char_bits);
    for (layer, shape) in layers.iter().zip(&LAYOUT) {
        let matrix = quantize_rows(&layer.weights, shape.rows, BITS);
        for (index, &level) in matrix.levels.iter().enumerate() {
            if index % matrix.columns == 0 {
                packer.put(u32::from(matrix.scales[index / matrix.columns]), 16);
            }
            packer.put((level + largest) as u32, BITS); // 0 to 62: fits in 6 bits
        }
        for &bias in &layer.biases {
            packer.put(u32::from(to_half(bias)), 16);
        }
    }
    packer.finish()
}

/// The bot's side: the same walk, rebuilding each number as level x scale.
fn decode_weights(text: &str, char_bits: u32) -> Vec<f32> {
    let largest = (1i32 << (BITS - 1)) - 1;
    let mut reader = BitReader::new(text, char_bits);
    let mut numbers = Vec::new();
    for shape in &LAYOUT {
        for _ in 0..shape.rows {
            let step = from_half(reader.take(16) as u16);
            for _ in 0..shape.columns {
                numbers.push((reader.take(BITS) as i32 - largest) as f32 * step);
            }
        }
        for _ in 0..shape.rows {
            numbers.push(from_half(reader.take(16) as u16));
        }
    }
    numbers
}

/// A character a source file can carry: not a surrogate (U+D800 to U+DFFF; a Rust char can't be one,
/// and valid text never holds one), not a control code (U+0000 to U+001F, U+007F to U+009F), not a
/// quote or backslash that would end the string literal, and in the BMP, where every way of counting
/// characters gives 1.
fn safe_character(point: char) -> bool {
    let point = point as u32;
    let surrogate = (0xD800..=0xDFFF).contains(&point);
    let control = point <= 0x1F || (0x7F..=0x9F).contains(&point);
    !surrogate && !control && point != '"' as u32 && point != '\\' as u32 && point <= 0xFFFF
}

/// The bot agrees with the reference model when the model rates the bot's move within `tolerance` of
/// its best. Two moves that close are a tie that summation order can break either way.
fn agrees(bot_move: usize, reference_logits: &[f32; 4], tolerance: f32) -> bool {
    let best = reference_logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
    best - reference_logits[bot_move] <= tolerance
}

fn main() {
    let mut state: u64 = 2026; // SplitMix64, seeded: every run checks the same weights
    let mut uniform = || {
        // a float in [-1, 1)
        state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
        let mut mixed = state;
        mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
        mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
        (((mixed ^ (mixed >> 31)) >> 11) as f64 * 2f64.powi(-52) - 1.0) as f32
    };
    let mut layers = Vec::new();
    let mut expected = Vec::new(); // what the bot must rebuild, in stream order
    let mut worst_error = 0f64; // in steps of each row's scale
    for shape in &LAYOUT {
        let mut layer = Layer { weights: vec![0.0; shape.rows * shape.columns], biases: vec![0.0; shape.rows] };
        for weight in &mut layer.weights {
            let draw = uniform(); // cubed: most near 0, a few large, like a trained row
            *weight = 0.3 * draw * draw * draw;
        }
        for bias in &mut layer.biases {
            *bias = 0.05 * uniform();
        }
        let matrix = quantize_rows(&layer.weights, shape.rows, BITS);
        for (index, &weight) in layer.weights.iter().enumerate() {
            let step = from_half(matrix.scales[index / shape.columns]);
            expected.push(matrix.levels[index] as f32 * step);
            let error = (f64::from(weight) - f64::from(matrix.levels[index] as f32 * step)).abs();
            worst_error = worst_error.max(error / f64::from(step));
        }
        expected.extend(layer.biases.iter().map(|&bias| from_half(to_half(bias))));
        layers.push(layer);
    }
    assert!(expected.len() == 11041 && worst_error <= 0.5 + 1e-6); // within half a step of its level
    for char_bits in [6, 14] {
        let text = encode_weights(&layers, char_bits);
        // 10,928 x 6 + 226 x 16 = 69,184 bits: ceil(69,184 / 6) = 11,531, ceil(69,184 / 14) = 4,942.
        assert_eq!(text.chars().count(), if char_bits == 6 { 11531 } else { 4942 });
        assert!(text.chars().all(safe_character));
        assert!(decode_weights(&text, char_bits) == expected); // every number, bit for bit
        println!(
            "{:2} bits a character: {} characters, {} UTF-8 bytes, decoded exactly",
            char_bits,
            text.chars().count(),
            text.len()
        );
    }
    let tolerance = 1e-4; // a 5.7e-6 gap is a tie; 6.8e-4 is not; an illegal move never agrees
    assert!(agrees(1, &[0.5, 0.5 - 5.7e-6, f32::NEG_INFINITY, 0.1], tolerance));
    assert!(!agrees(1, &[0.5, 0.5 - 6.8e-4, f32::NEG_INFINITY, 0.1], tolerance));
    assert!(!agrees(2, &[0.5, 0.5 - 5.7e-6, f32::NEG_INFINITY, 0.1], tolerance));
    println!("largest rounding error: {:.3} of a step; parity rule: ok", worst_error);
}
What's intentionally missing

Reading the weights out of a PyTorch checkpoint, which the project's exporter does in Python; the range coder from section 04; the forward pass, which Neural Networks for Game Bots builds; error handling for text the program didn't write, since a bot only ever decodes its own constant; and a layout table generated from the model, which the project's exporter writes into the file. The asserts are the checks, so build without NDEBUG.

10Project step 5: ship it

The Phase 5 project trains a bot for Tron Battle's rules, one against one. Its last step turns the champion from Building the Trainer into the file you would submit, and runs every gate on it.

Project step 5 ยท Ship it

Build export/export.py (quantize, fine-tune, pack, assemble, compile, check) and export/bot_main.rs (the decoder and the loop that talks to the referee). Run it on the champion checkpoint, the last network to win the trainer's promotion match against the previous champion, after 30,720 self-play games:

python export/export.py --model runs/ppo/champion.pt --out export/out

Measured on October 30, 2026 with Rust 1.98.1, on an Intel i9-11900KF shared with other training jobs.[2]

What's inside bot.rsA bar for the 33,824-character file, split left to right into the header and constants, 714 characters; board.rs, 3,331; features.rs, 4,757; net.rs, 8,345; the weight string, 11,531; and bot_main.rs, 5,146. Below it a second bar shows the whole file as a third of the 100,000-character limit. bot.rs, 33,824 characters boardfeaturesnetweightsbot_main 3,3314,7578,34511,5315,146 code 22,293weights 11,531first sliver: headerand constants, 714 the same file against the 100,000-character limit 33,82466,176 to spare What's inside bot.rsThe 33,824-character file split into its parts, one row each: the header and constants, 714 characters; board.rs, 3,331; features.rs, 4,757; net.rs, 8,345; the weight string, 11,531; bot_main.rs, 5,146. Below, the whole file is shown as a third of the 100,000-character limit. bot.rs, 33,824 characters header, constants714 board.rs3,331 features.rs4,757 net.rs8,345 weight string11,531 bot_main.rs5,146 code 22,293 ยท weights 11,531 against the 100,000 limit 33,824 used66,176 to spare
What's inside the submitted file. The exporter pastes board.rs, features.rs and net.rs from the engine unchanged, comments included, then the shape constants, the LAYOUT table and the weight string, then bot_main.rs; the bar draws the header and the constants together as its first sliver. Nothing is stripped or minified: at a third of the limit there's no need, and Fast Bots shows how when there is.

The listings are the project's files. export.py is shown in part: the rounding, the fine-tune through it and the packing, with quantize rounding each scale to float16 before it picks the levels, as section 02 describes. bot_main.rs is shown in full: decode_weights reads the string once, on the first turn, and the loop rebuilds the board from each turn's input lines, removing the trail of any player whose line says it crashed.

Project step 5: the exporter and the bot
# export/export.py, excerpt: quantization, the quantization-aware fine-tune and packing. The rest of
# the file records positions, compares versions, assembles bot.rs and runs the checks; TronNet comes
# from train/model.py and tensors() from train/play.py.
ASCII = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
CJK_BASE = 0x4E00
EXPORT_SEEDS = 6_000_000      # seeds for the export's checks, never used in training or evaluation


# ---------------------------------------------------------------- quantization

def policy_tensors(model):
    """The weights the bot needs, in order: everything but the value head."""
    return [(name, weights.detach().float().cpu()) for name, weights in model.state_dict().items()
            if not name.startswith('value')]


def quantize(model, bits):
    """Per-row symmetric quantization: each row of a weight matrix gets one float16 scale (its largest
    magnitude / (2^(bits-1) - 1)) and each weight becomes the nearest whole multiple of it. Biases are
    stored as float16. Returns the stream items and the dequantized weights (what the bot computes)."""
    largest = 2**(bits-1)-1
    items, restored = [], {}
    for name, tensor in policy_tensors(model):
        values = tensor.numpy().astype(np.float32)
        if values.ndim == 1:
            halves = values.astype(np.float16)
            items.append(('vector', halves, None))
            restored[name] = halves.astype(np.float32)
            continue
        rows = values.reshape(values.shape[0], -1)
        scales = (np.abs(rows).max(1)/largest).astype(np.float16)
        scales[scales == 0] = 1
        scales32 = scales.astype(np.float32)
        levels = np.clip(np.rint(rows/scales32[:, None]), -largest, largest).astype(np.int32)
        items.append(('matrix', scales, levels))
        restored[name] = (levels.astype(np.float32)*scales32[:, None]).reshape(values.shape)
    return items, restored


# โ€ฆ dequantized_model(model, bits) is left out here: a copy of the network on the quantized weights.


def on(model, device):
    """A copy of `model` on `device`."""
    copy = TronNet(**model.shape)
    copy.load_state_dict({name: weights.cpu() for name, weights in model.state_dict().items()})
    return copy.to(device).eval()


@contextlib.contextmanager
def fake_quant(model, bits):
    """Quantization-aware training: forward and backward run on the quantized weights; the optimizer
    then updates the full-precision copy (the straight-through estimator)."""
    saved = {}
    restored = quantize(model, bits)[1]
    for name, parameter in model.named_parameters():
        if name in restored:
            saved[name] = parameter.data
            parameter.data = torch.from_numpy(restored[name]).to(parameter.device)
    try:
        yield model
    finally:
        for name, parameter in model.named_parameters():
            if name in saved:
                parameter.data = saved[name]


FIELDS = ('planes', 'features', 'targets', 'legal')


def qat_finetune(model, positions, bits, epochs, device, lr=3e-5, batch=512):
    """Distillation through the quantizer: the quantized network learns to match the float network's
    move probabilities on recorded positions."""
    teacher = on(model, device)
    student = on(model, device).train()
    optimizer = torch.optim.Adam(student.parameters(), lr=lr)
    order = np.random.default_rng(0).permutation(len(positions['legal']))
    for _ in range(epochs):
        for start in range(0, len(order), batch):
            inputs = tensors(device, *(positions[key][order[start:start+batch]] for key in FIELDS))
            legal = inputs[3]
            with torch.no_grad():
                target = torch.softmax(teacher(*inputs)[0], 1)
            with fake_quant(student, bits):
                log_probs = torch.log_softmax(student(*inputs)[0], 1)
                # Cross-entropy against the float network's probabilities (illegal moves add nothing).
                loss = -(torch.where(legal, target*log_probs, torch.zeros_like(log_probs))).sum(1).mean()
                optimizer.zero_grad()
                loss.backward()
            optimizer.step()
    return on(student, 'cpu')


# โ€ฆ agreement(first, second, positions, device) is left out here: the share of positions with a choice
# where two networks pick the same move.


# ---------------------------------------------------------------- packing

def bitstream(items, bits):
    """(value, width) pairs in the order the bot reads them: per matrix row a float16 scale then each
    weight's level shifted to 0 .. 2^bits - 2; per bias vector its float16 numbers."""
    largest = 2**(bits-1)-1
    for kind, halves, levels in items:
        codes = halves.view(np.uint16)          # the float16 bit patterns
        if kind == 'vector':
            yield from ((int(code), 16) for code in codes)
            continue
        for row, code in enumerate(codes):
            yield int(code), 16
            yield from ((int(level)+largest, bits) for level in levels[row])


def pack(stream, char_bits):
    """Packs the bits into text: 6 bits per ASCII character or 14 per CJK character, lowest bits first."""
    buffer, count, chars = 0, 0, []
    for value, width in stream:
        buffer |= value << count
        count += width
        while count >= char_bits:
            code = buffer & ((1 << char_bits)-1)
            chars.append(ASCII[code] if char_bits == 6 else chr(CJK_BASE+code))
            buffer >>= char_bits
            count -= char_bits
    if count:
        chars.append(ASCII[buffer] if char_bits == 6 else chr(CJK_BASE+buffer))
    return ''.join(chars)
// ---- The bot: decode the weights once, then read a turn, think, answer, until the input ends. ----
use board::{Cells, CELLS, NAMES, WIDTH};
use features::{move_features, move_targets, planes, PLANES};
use net::{greedy, Net, Shape};
use std::io::{self, BufRead, Write};

/// Characters of the packed weight text: 6 bits each (ASCII) or 14 bits each (CJK, from U+4E00).
const ASCII: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const CJK_BASE: u32 = 0x4E00;

/// Reads the packed text as one long string of bits, lowest bits first.
struct BitReader<'a> {
    chars: std::str::Chars<'a>,
    buffer: u64,
    count: u32,
}

impl BitReader<'_> {
    fn take(&mut self, bits: u32) -> u32 {
        while self.count < bits {
            let character = self.chars.next().expect("weights end early");
            let value = if CHAR_BITS == 6 {
                ASCII.iter().position(|&symbol| symbol as char == character).expect("packed character") as u64
            } else {
                (character as u32 - CJK_BASE) as u64
            };
            self.buffer |= value << self.count;
            self.count += CHAR_BITS;
        }
        let out = (self.buffer & ((1 << bits) - 1)) as u32;
        self.buffer >>= bits;
        self.count -= bits;
        out
    }
}

/// An IEEE half-precision number (the stored scales and biases) as f32. Exact.
fn half(bits: u32) -> f32 {
    let sign = if bits & 0x8000 != 0 { -1.0 } else { 1.0 };
    let exponent = ((bits >> 10) & 31) as i32;
    let fraction = (bits & 1023) as f32;
    sign * match exponent {
        0 => fraction * (2f32).powi(-24),
        31 => f32::INFINITY,
        _ => (1.0 + fraction / 1024.0) * (2f32).powi(exponent - 15),
    }
}

/// The weights in PyTorch's order. LAYOUT lists each tensor as (rows, values per row): a matrix stores
/// one float16 scale per row and then each weight as a BITS-bit integer q (weight = q ร— scale); a bias
/// (values per row = 0) stores `rows` float16 numbers.
fn decode_weights() -> Vec<f32> {
    let mut reader = BitReader { chars: WEIGHTS.chars(), buffer: 0, count: 0 };
    let largest = (1i32 << (BITS - 1)) - 1;
    let mut weights = Vec::new();
    for &(rows, per_row) in LAYOUT.iter() {
        if per_row == 0 {
            weights.extend((0..rows).map(|_| half(reader.take(16))));
            continue;
        }
        for _ in 0..rows {
            let scale = half(reader.take(16));
            for _ in 0..per_row {
                let level = reader.take(BITS) as i32 - largest;
                weights.push(level as f32 * scale);
            }
        }
    }
    weights
}

fn main() {
    let shape = Shape { channels: CHANNELS, dilations: DILATIONS.to_vec(), move_hidden: MOVE_HIDDEN, value_hidden: 0 };
    let mut network: Option<Net> = None;
    let mut cells: Cells = [0; CELLS];
    let mut heads: Vec<Option<usize>> = Vec::new();
    let timing = std::env::args().any(|argument| argument == "--timing");
    let stdin = io::stdin();
    let mut lines = stdin.lock().lines();
    while let Some(Ok(header)) = lines.next() {
        let clock = std::time::Instant::now();
        let numbers: Vec<usize> = header.split_whitespace().map(|value| value.parse().unwrap()).collect();
        let (players, me) = (numbers[0], numbers[1]);
        heads.resize(players, None);
        for player in 0..players {
            let line = lines.next().unwrap().unwrap();
            let values: Vec<i32> = line.split_whitespace().map(|value| value.parse().unwrap()).collect();
            if values[0] < 0 {
                // A crashed player's trail disappears from the board.
                if heads[player].take().is_some() {
                    for cell in cells.iter_mut().filter(|cell| **cell == player as u8 + 1) {
                        *cell = 0;
                    }
                }
                continue;
            }
            // Every player moves once between two of my turns, so the start and the current head
            // are the only cells to add: the rest of each trail is already on the board.
            let start = (values[1] * WIDTH as i32 + values[0]) as usize;
            let head = (values[3] * WIDTH as i32 + values[2]) as usize;
            cells[start] = player as u8 + 1;
            cells[head] = player as u8 + 1;
            heads[player] = Some(head);
        }
        // Decoding the weights is part of the first turn's 100 ms.
        let network = network.get_or_insert_with(|| Net::read(&shape, &decode_weights(), false));
        let head = heads[me].expect("I am alive while asked to move");
        let mut observed = vec![0u8; PLANES * CELLS];
        planes(&cells, me, &heads, &mut observed);
        let features = move_features(&cells, me, &heads);
        let (targets, legal) = move_targets(&cells, head);
        let (logits, _) = network.forward(&observed, &features, &targets, &legal);
        let mut out = io::stdout().lock();
        writeln!(out, "{}", NAMES[greedy(&logits)]).unwrap();
        out.flush().unwrap();
        if timing {
            eprintln!("{}", clock.elapsed().as_micros());
        }
    }
}
What's intentionally missing

A range coder: at 6 bits the network fits with two thirds of the file to spare. Submitting: the exporter stops at a file that passed its gates, and section 11 takes it from there. Training for 3 and 4 players: the bot reads any number of players, merges every opponent into the enemy planes and drops a crashed player's trail, but it trained one against one and was never measured for strength in bigger games. Search on top of the network, which Networks Inside Search adds.

11Submit, then read the first games

The project's bot stops here: it hasn't played on CodinGame's ladder, so this page has no league to report for it. Yours goes further, and the first hours after you submit decide whether you learn anything from it.

A common question with training still running: does submitting stop it? It doesn't have to. The trainer keeps its champion in its own checkpoint file, so you copy that file, export from the copy while training continues, run the gates, submit the file that passed and record it. The gates compete with the trainer for the CPU, so expect them to run slower. Capstone: Build the Lab shows the lab sending a Spring Challenge 2022 network to the ladder this way, and when you do stop, the --export switch from Building the Trainer runs this exporter on the champion after Ctrl+C. Every step, in order:

  1. Export from a copy of the champion checkpoint, never from the file the trainer is writing.
  2. Pass every gate from section 08, and keep the report.
  3. Compare it with your best bot on fresh seeds, both seats, with an interval (Measuring Bot Strength). The project's shipped network scored .473 [.442, .504] over 1,000 games against the alpha-beta bot from Minimax and Alpha-Beta: level at best. If that search bot were live, this network wouldn't be worth a submission.[2]
  4. Test it in the IDE. Paste the exact file and play a few test games against the boss and players near your rank, with the timing switch on and an eye on the first turn.
  5. Submit and record the submission's id, the file's hash and the date.

Then watch the first ranked games. A loss by timeout or by an invalid answer usually means a broken bot, not a weak one: find it in the battle list, replay its seed in the IDE, and fix the bot before anything else. Look at the player counts too. In Tron Battle only the first league always has one opponent; above it battles gather 2 to 4 cycles,[15] and a network trained one against one meets games it never practiced. The score keeps moving until the battles reach 100%, as How Bot Contests Work explains.

Then judge the result against the noise: the same code, submitted again, doesn't land on the same score.

From the author's lab

Six submissions of identical Back Track King code to the game's permanent ladder, from October 22 to 26, 2026, scored within 1.62 points of each other in Legend.[3]

On a ladder like that one, a difference of about a point between two submissions says nothing on its own. Measure a change on your own panel before you spend a submission on it, compare the ladder result with that band, and resubmit unchanged code when you need a second reading. Measuring Bot Strength works out how many games a decision needs.

12Where exports go wrong

Counting bytes instead of characters

wc -c reports the project's CJK build as 37,126; CodinGame counts its 27,236 characters. It can go the other way too: a file of emoji has half as many code points as UTF-16 units, and one of the two tests counted units. Count code points and keep every packed character in the BMP, where both counts agree.

Checking parity against the float network. Rounding changes some decisions on purpose: 2.4% of the project's, even after the fine-tune. Compare the bot with the model running the rounded weights, and measure what the rounding costs separately, by agreement and by games.

A training quantizer that differs from the exporter's. Quantization-aware training trains the network you ship only if it rounds exactly as the exporter will. The lab picks each level with the unrounded scale and rebuilds with the float16 one; the project rounds the scale first. Either works, as long as training and export run the same function.

A tolerance that hides bugs. A tie tolerance of 10โˆ’2 would pass a decoder bug that moves some logits by 10โˆ’3. Keep it a little above the noise, record every near tie with its gap, and read the record.

Timing the average on an idle desktop. The limit judges the slowest turn on CodinGame's machine, and the first turn decodes the weights as well as moving.

Shipping the value head. The bot only needs moves. The project leaves out its value head's 577 weights and biases, and a bigger network's value head can hold thousands.

13What's next

Capstone: Build the Lab puts the pieces of all five phases into one repository that carries the next contest from its statement to the ladder: the frozen contest bot, the official referee, a verified engine, measurement panels, search, cloning and self-play, the exporter with its gates, and a record of every submission. The Phase 5 project is complete. Its files, with this step's in teal:

project/
โ”œโ”€โ”€ engine/                    Rust: rules, bots, features, network, C interface
โ”‚   โ”œโ”€โ”€ Cargo.toml             builds an rlib and a cdylib (libtron.so)
โ”‚   โ”œโ”€โ”€ src/
โ”‚   โ”‚   โ”œโ”€โ”€ board.rs           grid, distances, room, Voronoi territory
โ”‚   โ”‚   โ”œโ”€โ”€ game.rs            rules, seeded starts, CodinGame frames
โ”‚   โ”‚   โ”œโ”€โ”€ bots.rs            flood-fill and Voronoi bots (the teacher)
โ”‚   โ”‚   โ”œโ”€โ”€ alphabeta.rs       the alpha-beta bot (a yardstick)
โ”‚   โ”‚   โ”œโ”€โ”€ features.rs        what the network sees
โ”‚   โ”‚   โ”œโ”€โ”€ net.rs             the network's forward pass in Rust
โ”‚   โ”‚   โ”œโ”€โ”€ ffi.rs             the C interface Python calls
โ”‚   โ”‚   โ”œโ”€โ”€ lib.rs
โ”‚   โ”‚   โ””โ”€โ”€ bin/arena.rs       bot-against-bot matches with intervals
โ”‚   โ””โ”€โ”€ tests/rules.rs         engine tests
โ”œโ”€โ”€ train/
โ”‚   โ”œโ”€โ”€ tron_env.py            drives the engine from Python
โ”‚   โ”œโ”€โ”€ model.py               the network in PyTorch
โ”‚   โ”œโ”€โ”€ parity.py              Rust and PyTorch choose the same moves
โ”‚   โ”œโ”€โ”€ record.py              records the teacher's decisions
โ”‚   โ”œโ”€โ”€ bc.py                  behavior cloning
โ”‚   โ”œโ”€โ”€ play.py                matches, both seats, Wilson intervals
โ”‚   โ”œโ”€โ”€ evaluate.py            a network against the yardsticks
โ”‚   โ”œโ”€โ”€ tron.py                the engine in plain Python
โ”‚   โ”œโ”€โ”€ ppo.py                 the single-process PPO trainer
โ”‚   โ”œโ”€โ”€ trainer.py             the batched trainer: checkpoints, resume, Ctrl+C, status
โ”‚   โ””โ”€โ”€ throughput.py          games per hour, Python engine against the Rust batch
โ”œโ”€โ”€ export/
โ”‚   โ”œโ”€โ”€ export.py              quantize, fine-tune, pack, assemble, check
โ”‚   โ”œโ”€โ”€ bot_main.rs            the decoder and the loop that talks to the referee
โ”‚   โ””โ”€โ”€ out/
โ”‚       โ”œโ”€โ”€ bot.rs             the submission: one file, 33,824 characters
โ”‚       โ”œโ”€โ”€ bot-alternative.rs the same bot packed 14 bits a character: 27,236
โ”‚       โ”œโ”€โ”€ bot                compiled, for the parity and timing checks
โ”‚       โ”œโ”€โ”€ shipped.pt         the rounded weights the checks compare with
โ”‚       โ””โ”€โ”€ report.json        sizes, agreement, parity, timing
โ”œโ”€โ”€ checks/
โ”‚   โ”œโ”€โ”€ python_engine.py       the Python engine against the Rust engine, ply by ply
โ”‚   โ”œโ”€โ”€ durability.sh          Ctrl+C and resume on a small run
โ”‚   โ”œโ”€โ”€ multiplayer.py         the exported bot in 3- and 4-player games
โ”‚   โ””โ”€โ”€ โ€ฆ                      the scripts behind the other measurements
โ”œโ”€โ”€ data/                      recorded demonstrations and positions
โ””โ”€โ”€ runs/
    โ”œโ”€โ”€ bc/                    the clone
    โ”œโ”€โ”€ ppo-single/            the single-process run
    โ”œโ”€โ”€ ppo/                   the batched run; its champion.pt is what this step ships
    โ””โ”€โ”€ logs/                  every command's output

14Sources

Quotes are from each author's own text. Numbers marked as the project's were measured with the code on this page and the series' earlier Phase 5 pages; numbers marked as the author's lab are first-hand records from one lab and aren't general results. Widget readouts are computed in the page.

  1. CodinGame Help Center, last changed 2025. Languages Versions (source file languages.md). The 100k-character limit, which the page doesn't define further, and Rust 1.90.0.
  2. Build a Legend Bot running project, step 5, run on October 30, 2026 on one machine (Intel Core i9-11900KF; NVIDIA RTX 3080 Ti shared with other training jobs; Rust 1.98.1, PyTorch 2.14.1). export/out/report.json and runs/logs/export.log (sizes, agreement by bit width, the rounded and fine-tuned versions' matches, parity, timing, 667 seconds), runs/logs/multiplayer.log (3 and 4 players) and runs/logs/final-alphabeta-test.log (1,000 games against alpha-beta). The code is on this page and the earlier Phase 5 pages; records not yet published.
  3. Author's lab records, 2026 (private repository). Back Track King export sizes and its range coder (5-bit levels in 3.71 bits): experiments/runs/unicode-check-20261025/legend47250-unicode/report.json, framework/rl/codec.py; the October 25 size test and the timing on CodinGame's machine: experiments/runs/unicode-check-20261025/record.json, docs/submission-size.md; the September 16 IDE test: the contest repository's DISCORD_REVIEW.md and tools/utf16_probe.py; the September 17 speed probe: the contest repository's POLICY_NET_PLAN.md; training through 5-bit rounding: framework/rl/quant.py, framework/rl/trainer.py; the October 19 tie and the tolerance: AGENTS.md (October 19 entry), games/backtrackking/nn_export.py; six identical submissions: experiments/live-submissions/summer-challenge-2026-back-track-king/submitted.jsonl, AGENTS.md (October 26 entry).
  4. reCurse, 2023. Codingame Fall Challenge 2023 Postmortem (gist, first place). "Runtime on CG": 156k parameters in 99,264 characters, 65,714 of them weights; 8 bits per parameter, 16 for the first layer; 15 bits per character from 32k code points; gzip on CodinGame's servers.
  5. delineate, 2026. Codingame Spring Challenge 2026 (gist, first place in Troll Farm). About 101,000 parameters in 98,000 characters, and 2 to 3 ms of a 50 ms turn.
  6. Benoit Jacob, Skirmantas Kligys, Bo Chen, Menglong Zhu, Matthew Tang, Andrew Howard, Hartwig Adam, Dmitry Kalenichenko, 2018. Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference, CVPR 2018. Section 3: rounding after training fails small models; training with quantization simulated in the forward pass and floating-point weights.
  7. Yoshua Bengio, Nicholas Lรฉonard, Aaron Courville, 2013. Estimating or Propagating Gradients Through Stochastic Neurons for Conditional Computation, arXiv:1308.3432. Section 4 names the straight-through estimator and credits the idea to Hinton's 2012 lecture 15b.
  8. putibuzu, 2026. Post #4 in "Summer Challenge 2026 feedback & strategies", CodinGame forum (20th on the contest's final leaderboard; he states no placement). About 135,000 weights with 5-bit weight encoding and quantization-aware training.
  9. Ian H. Witten, Radford M. Neal, John G. Cleary, 1987. Arithmetic Coding for Data Compression, Communications of the ACM 30(6), 520โ€“540. The interval idea of arithmetic coding and Shannon's entropy bound (p. 521).
  10. G. N. N. Martin, 1979. Range encoding: an algorithm for removing redundancy from a digitised message, Video & Data Recording Conference, Southampton (a later transcription). Range coding, which produces encodings to any base.
  11. Indrill, 2026. Post #5 (1st place) and post #8 in "Summer Challenge 2026 feedback & strategies", CodinGame forum. His weights packed with pickle, lzma and UTF-16 for a Python bot.
  12. CarlWalsh and TBali, 2025. Official documentation for "100k character limit"?, CodinGame forum, posts 1 and 4. CarlWalsh's test on a classic puzzle that fit a count of UTF-16 code units (the post doesn't say which button ran it); TBali's code points to avoid, and characters above U+2000 that disturbed text direction in the IDE.
  13. The Unicode Consortium. The Unicode Standard, Version 18.0, Chapter 2 and the Unicode Glossary. Code points, the BMP, surrogates, control codes, and the sizes of characters in UTF-8 and UTF-16.
  14. CodinGame forum, 2017. New contests rule, posts 9 and 25 (_CG_SaiksyApo, CodinGame staff) and 12 (reCurse, a player and forum moderator). The rule against obfuscated code, the encoded binary it targeted, reCurse's own view on compressed data, and the rule not applying to multiplayer puzzles.
  15. CodinGame. Tron Battle, game statement, read on 2026-10-30. Under 100 ms for each game turn, no separate first-turn limit, and battles of 2 to 4 cycles with one opponent in the first league.
  16. CodinGame, 2026. CodinGame API reference, "Game logs", retrieved 2026-10-30. "stderr: only for the user's own agents, and only in test games (battles run without debug output)."