<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Chauncey Wang</title>
    <description>The latest articles on DEV Community by Chauncey Wang (@chncwang).</description>
    <link>https://dev.to/chncwang</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4029378%2F71a792f4-5747-49a1-8ea6-518d3e31d5d7.png</url>
      <title>DEV Community: Chauncey Wang</title>
      <link>https://dev.to/chncwang</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chncwang"/>
    <language>en</language>
    <item>
      <title>In 2018 I hand-wrote a C++ deep learning framework so I'd never pad a batch. In 2023 LLM serving landed on the same structure.</title>
      <dc:creator>Chauncey Wang</dc:creator>
      <pubDate>Thu, 24 Sep 2026 05:25:13 +0000</pubDate>
      <link>https://dev.to/chncwang/in-2018-i-hand-wrote-a-c-deep-learning-framework-so-id-never-pad-a-batch-in-2023-llm-serving-6ob</link>
      <guid>https://dev.to/chncwang/in-2018-i-hand-wrote-a-c-deep-learning-framework-so-id-never-pad-a-batch-in-2023-llm-serving-6ob</guid>
      <description>&lt;p&gt;There's a sentence in the README of a library I wrote that I've been thinking about lately:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"To summarize, we believe that &lt;strong&gt;Padding-free Dynamic Batching is the feature that NLPers will dive into but is surprisingly not supported by today's deep learning libraries&lt;/strong&gt;."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wrote that around 2021, about &lt;a href="https://github.com/chncwang/InsNet" rel="noopener noreferrer"&gt;InsNet&lt;/a&gt;, a C++14 deep learning library I'd been building since 2018. Then transformers ate the field, everyone padded their batches to rectangles like they always had, and I moved on.&lt;/p&gt;

&lt;p&gt;Two years later, vLLM launched, "continuous batching" became the load-bearing idea of the entire LLM serving industry, and the input to a modern inference engine became — a flat, padding-free token stream with per-sequence offsets riding alongside as data.&lt;/p&gt;

&lt;p&gt;I wasn't wrong. I was early, and I was aiming at the wrong layer of the stack.&lt;/p&gt;

&lt;p&gt;This post is the story of that bet: how a C++ library made padding disappear, how the canonical prior art (DyNet) made a subtly different choice at the same fork, and how the modern serving stack (vLLM + FlashAttention) ended up rediscovering the same trick — with receipts from all three codebases, because last time I compared engines from memory people rightly asked for sources, and reading the actual code is where all the good surprises live anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I hated padding
&lt;/h2&gt;

&lt;p&gt;In 2018 I was a master's student doing NLP research — the last years before transformers swallowed the field. The workhorses were still RNNs and LSTMs, and the frontier I found interesting was the models whose computation graph &lt;em&gt;changed shape with every single input&lt;/em&gt;: tree-LSTMs folded along a sentence's parse tree, transition-based parsers emitting a different sequence of stack operations for each sentence, hierarchical encoders running one sub-model per sentence and another over the document. Two examples in a batch almost never had the same shape — one had 7 tokens, its neighbor had 212, and their tree structures didn't line up at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmqgtg8vt2yx6e4bfjxjj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmqgtg8vt2yx6e4bfjxjj.png" alt="Three pre-transformer NLP models, each building a differently-shaped computation graph per input" width="800" height="369"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Tree-LSTM, transition-based parser, hierarchical encoder — three inputs, three graph shapes, no shared rectangle.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The standard answer was padding: extend everything to the longest sequence in the batch, add a mask tensor, and burn FLOPs computing values you'd immediately multiply by zero. For flat same-ish-length batches this is a mild tax. For instance-dependent structures it's obscene — and worse than the wasted compute was the wasted &lt;em&gt;thinking&lt;/em&gt;: every model became two models, the one you meant and the one that handles the mask.&lt;/p&gt;

&lt;p&gt;I wanted to write the model for &lt;strong&gt;one instance&lt;/strong&gt; and have the library figure out the batching. So I wrote a library: InsNet. It grew out of N3LDG — an earlier dynamic-computation-graph NLP library I helped build and first-authored the 2019 paper for — reworked into about 21,000 lines of first-party C++, of which 4,828 lines are one file of hand-written CUDA kernels.&lt;/p&gt;
&lt;h2&gt;
  
  
  Design decision #1: there is no such thing as a padded tensor
&lt;/h2&gt;

&lt;p&gt;InsNet's core representation makes padding impossible rather than optional. A value's data lives in one flat buffer, and its shape is just two integers: the total element count and the width. In an NLP model every value is a 2-D matrix — d rows (the hidden size) by some number of columns, and only the column count ever changes. A transformer holds a whole sentence at once, so a value is a d×L matrix, one column per token; an RNN steps through the sentence one token at a time, so each value is a single column — a d×1 vector. Same d rows either way; the width is the only thing that moves, which is exactly why two integers pin the whole shape. There isn't even a stored length — divide the buffer's size by the hidden dimension and the token count falls out. A value is never a &lt;code&gt;[batch, L_max, d]&lt;/code&gt; slice with a mask; it's a matrix as wide as the work in front of it. No batch dimension anywhere in the type system, no max length, no mask tensor. You cannot pad because there is nothing to pad &lt;em&gt;to&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Famput1feyci8fzmfyujk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Famput1feyci8fzmfyujk.png" alt="A padded [batch, L_max, d] rectangle (29% wasted, plus a mask) versus InsNet's exact-width matrices (0% wasted)" width="800" height="346"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Padding stretches every sentence to the longest and burns compute on the gaps (29% here); InsNet keeps each value exactly as wide as its sentence.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Design decision #2: the batching key deliberately forgets sizes
&lt;/h2&gt;

&lt;p&gt;The interesting part is how batching happens with no batch dimension. InsNet uses lazy execution: your model code builds a graph of small nodes, and nothing runs until you call &lt;code&gt;forward()&lt;/code&gt;. At that point the executor repeatedly takes the current &lt;em&gt;wave&lt;/em&gt; of ready nodes (Kahn's algorithm — every node whose inputs are all computed) and buckets the wave by a &lt;strong&gt;type signature&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// graph.h — the ready set, bucketed by signature&lt;/span&gt;
&lt;span class="k"&gt;typedef&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;unordered_map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NodeAbs&lt;/span&gt; &lt;span class="o"&gt;*&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NodeMap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;NodeMap&lt;/span&gt; &lt;span class="n"&gt;free_nodes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each bucket becomes one batch, executed by one operator call.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fus3aevcalysbinlevg4g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fus3aevcalysbinlevg4g.png" alt="A live computation graph: computed leaves, a ready wave of two signatures, and pending nodes; the executor batches one signature per op call" width="800" height="385"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The ready wave, grouped by signature: one signature per op call — the two Linear·W₁ nodes together, the two Linear·W₂ next, while the pending Adds wait.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And the whole design lives or dies on one question: &lt;em&gt;what goes into the signature?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's InsNet's signature for a linear layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// operator/linear.cc&lt;/span&gt;
&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="nf"&gt;typeSignature&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Node&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;getNodeType&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"-"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;addressToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;param_&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Operator type, plus the &lt;em&gt;address of the weight matrix&lt;/em&gt;. That's it. Not the input width, not the number of columns. Two linear nodes batch together if they apply the &lt;strong&gt;same weights&lt;/strong&gt; — even if one is transforming 3 columns and the other 300. The column count, the part that varies per instance, is deliberately left out of the key, so a single "batch" in InsNet genuinely contains matrices of different shapes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwo174a2wl8fnorw7uh4c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwo174a2wl8fnorw7uh4c.png" alt="W times the concatenation of a 3-column input and a 300-column input equals their concatenated outputs, one GEMM" width="800" height="277"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Why width doesn't matter to a linear layer: W hits each column on its own, so a 3-column and a 300-column input concatenate into one GEMM.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the fork in the road, and to see why it matters you have to look at what the grown-ups did.&lt;/p&gt;
&lt;h2&gt;
  
  
  The road not taken: DyNet puts shapes IN the signature
&lt;/h2&gt;

&lt;p&gt;Dynamic batching was not my idea. DyNet — the library from CMU that powered a lot of 2016–2018 NLP research — shipped on-the-fly autobatching in 2017 (Neubig et al., "On-the-fly Operation Batching in Dynamic Computation Graphs"), and its implementation is beautiful. Same skeleton as InsNet: build the graph lazily, compute a signature per node, batch same-signature ready nodes. But look at the signature for the workhorse op, affine transform (&lt;code&gt;b + W*x&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// dynet/nodes-affinetransform.cc&lt;/span&gt;
&lt;span class="n"&gt;Sig&lt;/span&gt; &lt;span class="nf"&gt;s&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nt&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;affine&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;                     &lt;span class="c1"&gt;// identity of b&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;                   &lt;span class="c1"&gt;// identity of W&lt;/span&gt;
  &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_dim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// SHAPE of x  &amp;lt;-- the fork&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff3fdg5p4ylvrbouzft46.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff3fdg5p4ylvrbouzft46.png" alt="The signature fork: DyNet keys on shape and gets same-shape buckets; InsNet leaves width out and gets one ragged batch" width="799" height="538"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The fork: DyNet's key includes shape → same-shape buckets; InsNet's key omits width → one batch of mixed shapes, and the raggedness moves into the kernel.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The shape of the data operand is &lt;strong&gt;part of the key&lt;/strong&gt;. Only identically-shaped operations ever share a batch. That single decision shapes everything downstream:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DyNet's kernels stay simple.&lt;/strong&gt; A batch is N same-shape tensors, so batched execution is just… a bigger tensor. Standard GEMM, standard everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;But a batch must be physically assembled.&lt;/strong&gt; A batched op needs one contiguous input, so DyNet checks at runtime whether the N operands happen to already sit contiguously in memory (in which case it aliases them, zero-copy) — and otherwise pays a gather:
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// dynet/exec.cc — check contiguity, else memcpy into fresh memory&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;          &lt;span class="c1"&gt;// use current mem, zero copy&lt;/span&gt;
  &lt;span class="n"&gt;my_xsi&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;min_node&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;my_batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;               &lt;span class="c1"&gt;// gather into new mem&lt;/span&gt;
  &lt;span class="n"&gt;combine_tensors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;my_batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;my_xsi&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Variable-length sequences batch per time-step.&lt;/strong&gt; Two LSTMs over a 7-token and a 212-token sentence share batches for steps 1–7; after that, the short sentence simply stops producing nodes. No padding, no masking — sequences just &lt;em&gt;leave the batch&lt;/em&gt; when they end. (File that thought; it comes back at the end of this post.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;DyNet even ships a wonderful piece of engineering honesty: pass &lt;code&gt;--dynet-autobatch 100&lt;/code&gt; and it &lt;em&gt;benchmarks its own three scheduling strategies on your graph and keeps the fastest&lt;/em&gt; — a built-in admission that the scheduler itself has a cost worth measuring.&lt;/p&gt;

&lt;p&gt;So: same problem, same lazy-graph skeleton, and one bit of difference in the key. DyNet says a batch is &lt;em&gt;same-shaped things&lt;/em&gt;, and keeps its kernels boring. InsNet says a batch is &lt;em&gt;same-operation things&lt;/em&gt;, and pays for it one level down — because now the kernels have to handle a batch of differently-shaped matrices.&lt;/p&gt;

&lt;p&gt;Why the two defaults differ comes down to &lt;em&gt;where each era put the variable length&lt;/em&gt;. DyNet grew up in the RNN age, where a sentence's length becomes a variable number of &lt;em&gt;steps&lt;/em&gt; — but each step's value is still a fixed d×1 vector. The length lives in the node count, not the node shape, so shape stays constant and keying on it costs nothing. Transformers move that length into the &lt;em&gt;width&lt;/em&gt; of a value: one d×L matrix per sentence, L varying by input. Now the length lives in the shape, so keying on shape fragments the batch by length — two sentences of different length can't share a sequence-level op. InsNet drops the column count to undo exactly that: it keys each op on the fixed part — the weight matrix, or the attention head dimension — and lets the width vary, so a length-7 and a length-12 sentence batch together right through the attention matmuls, not just the linear layers. Same fork, opposite defaults — each matched to where its era hid the raggedness.&lt;/p&gt;
&lt;h2&gt;
  
  
  Design decision #3: kernels that take size arrays
&lt;/h2&gt;

&lt;p&gt;Which brings us to the part of InsNet I'm simultaneously proudest and most embarrassed of: the CUDA. Here's the batched matmul kernel's signature and how it handles the ragged batch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// cuda/impl.cu — one launch, a whole ragged batch&lt;/span&gt;
&lt;span class="n"&gt;__global__&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;KernelMatMul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dtype&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;transpose_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;transpose_b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;a_rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;b_cols&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;dtype&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;use_lower_triangle_mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;count_i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blockIdx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// which instance am I?&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;b_col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b_cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;count_i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;       &lt;span class="c1"&gt;// THIS instance's width&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;b_col_i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blockDim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;blockIdx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;threadIdx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b_col_i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;b_col&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// past my instance's edge: do nothing&lt;/span&gt;
    &lt;span class="c1"&gt;// ... then accumulate this cell's dot product over ks[count_i] ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the parameter list. Not tensors — &lt;strong&gt;arrays of per-instance pointers&lt;/strong&gt; (&lt;code&gt;dtype **a&lt;/code&gt;) and &lt;strong&gt;arrays of per-instance sizes&lt;/strong&gt; (&lt;code&gt;int *a_rows, int *b_cols, int *ks&lt;/code&gt;). The launch grid is rectangular, sized to the &lt;em&gt;largest&lt;/em&gt; matrix in the batch; every thread first looks up which instance it belongs to and its instance's true dimensions, and threads that fall past their instance's real edge just return. One kernel launch, one batch, N different shapes — the raggedness rides in the size arrays, not in padded buffers, so the kernel masks &lt;em&gt;threads&lt;/em&gt; instead of data. No thread ever computes a pad cell, and the real payoff isn't the memory saved so much as the compute never spent on values you'd only multiply by zero.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5u9dbbhcg2thgfs0cx1l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5u9dbbhcg2thgfs0cx1l.png" alt="Three matrices of width 3, 7, and 5 share one rectangular launch; the b_cols size array masks the threads past each instance's real width" width="800" height="331"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One rectangular launch over a ragged batch — the b_cols size array masks the threads past each instance's real width, no padded memory, only skipped threads.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;(The causal attention mask is baked into the same kernel: &lt;code&gt;use_lower_triangle_mask&lt;/code&gt; writes −1e30 — its finite stand-in for −∞ — above the diagonal. In 2018 that felt like a hack. In 2024 fusing the mask into the attention kernel is called "writing an attention kernel.")&lt;/p&gt;

&lt;p&gt;The implementation is rough — a scalar accumulation loop, no tiling, no cuBLAS &lt;em&gt;on this path&lt;/em&gt; (cuBLAS wants uniform shapes, and a ragged, masked batch isn't that).&lt;/p&gt;

&lt;p&gt;One person, nights and weekends, hand-rolling ragged-batch GEMMs because the alternative was padding. You make trade-offs.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the autograd got for free
&lt;/h2&gt;

&lt;p&gt;One thing I want to defend properly: this wasn't an inference trick. InsNet trains. The executor records batches in the order it ran them, and backprop just replays the recording backwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// graph.cc — backward = the forward tape, reversed&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;execs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;backwardFully&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because forward executed in topological waves, the reversed tape is a valid reverse-topological schedule, and each backward step is &lt;em&gt;batched exactly like its forward step was&lt;/em&gt; — the linear layer's backward is two batched GEMMs (weight grad and input grad) over the same concatenated columns. The padding-free property propagates through training for free: no masked loss terms, no gradient contributions from pad tokens, because pad tokens don't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  2023: the same bet, different battlefield
&lt;/h2&gt;

&lt;p&gt;Now put InsNet down and look at what LLM serving converged on.&lt;/p&gt;

&lt;p&gt;vLLM's scheduler — the "continuous batching" everyone talks about — recomposes its batch on &lt;strong&gt;every single step&lt;/strong&gt;. The v1 scheduler's own docstring is admirably blunt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# vllm/v1/core/sched/scheduler.py
# NOTE(woosuk) on the scheduling algorithm:
# There's no "decoding phase" nor "prefill phase" in the scheduler.
# Each request just has the num_computed_tokens and num_tokens_with_spec.
# At each step, the scheduler tries to assign tokens to the requests
# so that each request's num_computed_tokens can catch up ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requests join the running batch mid-stream, advance by whatever token budget allows, and leave the moment they finish. And the tensor that actually enters the model? A comment in the input-preparation code draws you the picture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# vllm/v1/worker/gpu_model_runner.py
# E.g., [2, 5, 3] -&amp;gt; [0, 0, 1, 1, 1, 1, 1, 2, 2, 2]
&lt;/span&gt;&lt;span class="n"&gt;req_indices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arange_np&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;num_reqs&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;num_scheduled_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# cu_num_tokens: [2, 5, 3] -&amp;gt; [2, 7, 10]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three requests with 2, 5, and 3 tokens this step become &lt;strong&gt;one flat array of 10 tokens&lt;/strong&gt;. Not a &lt;code&gt;[3, 5]&lt;/code&gt; matrix with padding — a length-10 stream, with the boundaries &lt;code&gt;[2, 7, 10]&lt;/code&gt; carried alongside as data. Those cumulative offsets are handed to FlashAttention's variable-length entry point as &lt;code&gt;cu_seqlens_q&lt;/code&gt;, and the attention kernel does per-sequence masking internally — one kernel launch spanning differently-sized sequences, boundaries as arrays, not as tensor shape.&lt;/p&gt;

&lt;p&gt;Which is to say: the modern stack's answer to raggedness is &lt;em&gt;put the sizes in a side-array and make the kernel consult it&lt;/em&gt; — the same structural move as &lt;code&gt;int *b_cols&lt;/code&gt; in a 2018 &lt;code&gt;impl.cu&lt;/code&gt;, executed by professionals with tiling, TMA, and a few billion dollars of demand behind them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwr0deprmsivp9ctu6cg4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwr0deprmsivp9ctu6cg4.png" alt="The same move five years apart: InsNet's int *b_cols over packed matrices versus vLLM and FlashAttention's cu_seqlens_q over a packed token stream" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Flat memory, sizes carried as a side-array, a kernel that reads them — int *b_cols in 2018, cu_seqlens_q in 2023. An old friend in much better clothes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Even the "sequences just leave the batch when they end" idea — DyNet's per-time-step LSTM batching from 2017 — is recognizable as the &lt;em&gt;ancestor&lt;/em&gt; of continuous batching: batch membership defined per step by who still has work, rather than per batch by who arrived together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest differences
&lt;/h2&gt;

&lt;p&gt;I want to be precise here, because "I invented vLLM in 2018" is not the claim and would be false in at least three ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I didn't invent dynamic batching.&lt;/strong&gt; DyNet's autobatching (2017) and TensorFlow Fold (2017) are prior art, published and cited; InsNet was a contemporary of that line making a different trade at the signature fork — raggedness in the kernels rather than homogeneity in the batches. I'd been working in that line since N3LDG (2019), which auto-batched dynamic graphs — tree-LSTMs included — faster than PyTorch; the &lt;em&gt;padding-free&lt;/em&gt; reframing came later, with InsNet. The idea was in the air; my bet was on &lt;em&gt;which layer&lt;/em&gt; should absorb the raggedness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;vLLM batches requests, not graphs.&lt;/strong&gt; Its scheduler moves token-chunks of many requests through &lt;em&gt;one static model&lt;/em&gt;, under &lt;code&gt;@torch.inference_mode()&lt;/code&gt; — no autograd, no operator-level graph surgery. InsNet batched &lt;em&gt;arbitrary training graphs&lt;/em&gt;, operators-first: it would happily batch two transformers from the same instance, something entirely outside vLLM's problem statement. Same enemy (padding), same weapon (flat memory + offset bookkeeping), different battlefield (serving scheduler vs. training executor).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And they solved the problem I never had.&lt;/strong&gt; Serving needs the KV cache of every in-flight request to survive while the batch churns around it — that's PagedAttention, block tables decoupling logical sequence length from physical memory, and it has no analogue in InsNet because training graphs don't have persistent per-request state. The part with no analogue in InsNet is on the memory side, not the batching side.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got right, what I got wrong
&lt;/h2&gt;

&lt;p&gt;Right: padding is not a law of nature. It's an artifact of insisting that a batch be a rectangle, and if you're willing to carry sizes as data and write kernels that read them, the rectangle dissolves — along with the mask logic that infects every model built on top of it. The 2021 README sentence holds up; "NLPers will dive into it" just turned out to mean &lt;em&gt;the inference-serving industry&lt;/em&gt; rather than the tree-LSTM researchers I was writing for.&lt;/p&gt;

&lt;p&gt;Wrong: the layer. I bet that padding-free batching mattered for &lt;em&gt;training arbitrary structures&lt;/em&gt;, and built a general graph executor with autograd. The world's raggedness problem turned out to be concentrated in one place — transformer decoding, where sequences in a serving batch naturally diverge in length — and the winning implementations attached themselves to that single, ferociously-optimized case. Generality was the wrong axis. Specificity, plus paged memory, plus a scheduler that treats the batch as a per-step decision, is what shipped.&lt;/p&gt;

&lt;p&gt;Also wrong, in a smaller way: nearly everything about how I wrote it. The erased early history. The scalar GEMM loop. A hyperparameter I'd tune differently today on basically every line. But the &lt;em&gt;shape&lt;/em&gt; of the thing — flat buffers, size arrays, kernels that mask threads instead of data — is the shape the field landed on, and there's something quietly vindicating about opening &lt;code&gt;flash_attn.py&lt;/code&gt;, seeing &lt;code&gt;cu_seqlens_q&lt;/code&gt;, and recognizing an old friend wearing much better clothes.&lt;/p&gt;

&lt;p&gt;The library is at &lt;a href="https://github.com/chncwang/InsNet" rel="noopener noreferrer"&gt;github.com/chncwang/InsNet&lt;/a&gt;, erased early history and all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Code excerpts are from the actual sources of InsNet, DyNet, and vLLM (v1 engine), read for this post. If you find a misreading, tell me — my previous source-diving post survived three rounds of adversarial review and I'd like this one to earn the same.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>machinelearning</category>
      <category>ai</category>
      <category>performance</category>
    </item>
    <item>
      <title>Nobody inside a Go game can say "game over" — so seven engines invented five referees</title>
      <dc:creator>Chauncey Wang</dc:creator>
      <pubDate>Mon, 14 Sep 2026 03:19:39 +0000</pubDate>
      <link>https://dev.to/chncwang/nobody-inside-a-go-game-can-say-game-over-so-seven-engines-invented-five-referees-5849</link>
      <guid>https://dev.to/chncwang/nobody-inside-a-go-game-can-say-game-over-so-seven-engines-invented-five-referees-5849</guid>
      <description>&lt;p&gt;Chess ends by rule: checkmate is checkmate, the referee is built into the move generator. Go doesn't work like that. A game of Go ends by &lt;strong&gt;agreement&lt;/strong&gt; — both players pass, then they discuss which stones are dead, take them off the board, and count. The rules don't decide when the game is over; the players do.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Falok941yd4n8spuykqhy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Falok941yd4n8spuykqhy.png" alt="Two 9x9 Go boards. Left, titled " width="799" height="441"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at the two circled stones on the left board. They still have a liberty. No rule has captured them. They are also, to any human player, obviously dead — they can never make two eyes — which is why both players pass, lift them off, and count the points under them for Black. Their death is a fact about &lt;em&gt;futures that were never played&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Which is a problem if your players are programs, because agreement is the one thing programs can't do — and that fact about unplayed futures is the fact every engine in this post has to manufacture for itself.&lt;/p&gt;

&lt;p&gt;In 2012 I wrote &lt;a href="https://github.com/chncwang/FoolGo" rel="noopener noreferrer"&gt;FoolGo&lt;/a&gt;, a Monte-Carlo Go engine that plays about 40,000 random games per second on a 9×9 board. In my &lt;a href="https://dev.to/chncwang/no-neural-nets-no-tree-inside-a-go-engine-that-plays-40000-games-per-second-1om5"&gt;last post&lt;/a&gt; I compared its data structures with six other engines and found they had all converged on the same answer. This time I went looking at a question I never consciously answered when I wrote the thing: &lt;strong&gt;how does each engine know a game is over?&lt;/strong&gt; A Monte-Carlo engine finishes tens of thousands of games per second, so whatever your answer is, it runs constantly, and it had better be cheap.&lt;/p&gt;

&lt;p&gt;I had Claude Code read the sources of the same seven engines — FoolGo, GNU Go 3.8, Pachi, libego, Fuego 1.1, Leela Zero, and KataGo — and verified the load-bearing claims against the code myself. Last time everyone had converged. This time they didn't. Five different referees came back:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;by exhaustion&lt;/strong&gt; — play until the board runs out of answers (FoolGo, libego, Fuego)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;by statistics&lt;/strong&gt; — a stone is dead if it usually ends up dead (Pachi)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;by reasoning&lt;/strong&gt; — prove each group alive or dead with a specialized search (GNU Go)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;by fiat&lt;/strong&gt; — declare everything on the board alive and make it the players' problem (Leela Zero)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;by mathematics&lt;/strong&gt; — end the game only when life is &lt;em&gt;provable&lt;/em&gt; (KataGo)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My engine cannot pass
&lt;/h2&gt;

&lt;p&gt;Start with the confession. FoolGo cannot &lt;em&gt;choose&lt;/em&gt; to pass — a pass happens only when a player has no legal move at all, a mechanical necessity so that one side running out of moves first doesn't deadlock the loop. It also has no move limit. Strictly speaking, it doesn't have a concept of the game ending at all — only a board with no moves left on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/board/full_board.h&lt;/span&gt;
&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;FullBoard&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BOARD_LEN&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;::&lt;/span&gt;&lt;span class="n"&gt;IsEnd&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;is_end_&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PlayableIndexBitSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Force&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;BLACK_FORCE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;PlayableIndexBitSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Force&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;WHITE_FORCE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;none&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A playout ends when &lt;strong&gt;neither color has a single playable point left&lt;/strong&gt;. Each side keeps a bitset of playable points; stones fill the board until both bitsets are empty. That's the whole referee.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwyu4afs7g5ws69gj6kne.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwyu4afs7g5ws69gj6kne.png" alt="The same 9x9 game as the opening figure, played to exhaustion by FoolGo: every intersection is filled with stones except four points circled in dashed blue — each side's two real eyes. In the top-right corner, the two points where the dead white invasion sat are now black stones, ringed in red and annotated " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's the same game as the opening figure — but where the humans stopped and agreed, FoolGo kept going. The invasion they lifted off the board by agreement, it captured and refilled en route. Same verdict on the dead stones, computed the slow way.&lt;/p&gt;

&lt;p&gt;Why does that terminate? Because of the one thing a random player refuses to do: fill its own eyes. FoolGo marks a point as a &lt;em&gt;real eye&lt;/em&gt; when all four orthogonal neighbors are friendly and enough diagonals are controlled — the threshold is a three-entry lookup table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/board/full_board.h — diagonals required: center 3, edge 2, corner 1&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;PositionIndex&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;calculator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CentralOrEdgeOrCorner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;piece_or_eye_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;SetRealEyeAsTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ForceAndPositionIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;force&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indx&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A real eye is removed from &lt;em&gt;both&lt;/em&gt; colors' playable bitsets: the owner won't fill it, the opponent can't legally sit in it. Every other point eventually gets a stone. Groups with two real eyes survive; everything else gets captured and refilled; the board monotonically runs out of legal moves, and &lt;code&gt;IsEnd()&lt;/code&gt; fires. Termination isn't a rule in FoolGo — it's a &lt;em&gt;theorem&lt;/em&gt;, and the eye test is the proof.&lt;/p&gt;

&lt;p&gt;The theorem has a crack in it, though, and the crack has a name: &lt;strong&gt;seki&lt;/strong&gt; (双活, "mutual life"). Two groups can stand in a truce where neither has two eyes, but whoever fills a shared liberty first puts their own group in atari and dies. Correct play is to leave those points alone forever — which is to say, correct play is to &lt;em&gt;pass&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;FoolGo can't. Its bitsets exclude only real eyes, and a seki's shared liberties look like ordinary playable points. So once the rest of the board fills up, the engine — forbidden from passing while any playable point remains — is eventually &lt;em&gt;forced&lt;/em&gt; to fill one, put its own group in atari, and hand the opponent a capture that correct play would never allow. The exhaustion referee doesn't merely fail to recognize mutual life; it is structurally incapable of letting a truce stand. Every seki in a FoolGo playout collapses, and which side it collapses against is decided by who runs out of safe moves first — so a position whose true value is "standoff" gets evaluated, over thousands of playouts, as a weighted coin flip.&lt;/p&gt;

&lt;p&gt;To be honest about it: this was a choice, not an oversight. Seki is rare — the large majority of games never produce one — and ignoring it bought me a referee with no pass logic, no self-atari exceptions, no special cases at all. Accept a coin flip on a rare position, and the whole design stays one &lt;code&gt;IsEnd()&lt;/code&gt; check. 2012 me judged that a good trade, and for a 9×9 hobby engine it probably was.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2s7cnjn7nz7tbpnaom9r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2s7cnjn7nz7tbpnaom9r.png" alt="A 9x9 board filled to exhaustion, with a seki in the bottom-left corner: a black group with exactly one eye at b1 and a white group with exactly one eye at f1, sharing the single liberty d1 marked with a dashed red circle. Every other point on the board carries a stone except each side's two real eyes (all eyes marked with dashed blue circles), so d1 is the only playable point left. Side text: whoever fills d1 puts their own group in atari, and capturing the sacrifice would hand the opponent the whole corner alive — so neither ever fills it. Correct play is to leave d1 alone forever, that is, pass. FoolGo can't: d1 is no one's eye, so it never leaves the playable bitsets, and someone is eventually forced to fill it." width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The grown-up engines all knew about this trap — and, tellingly, not one of them recognizes seki by &lt;em&gt;looking for it&lt;/em&gt;. Each solves it in the style of its own referee, a preview of everything below.&lt;/p&gt;

&lt;p&gt;Fuego solves it with an escape hatch. Pass is a legal move at every node of its search tree, and the values do the rest: filling a seki liberty leads to positions where your group is gone, passing leads to positions where the truce holds, and the search notices which door not to open. No detector — just an exit, and a search smart enough to take it. The documentation names the trap in so many words: "in the in-tree-phase of the game, pass moves are always allowed to avoid zugzwang situations, if there is a seki on the board." And note the "if": it's motivation, not a condition. Nothing in Fuego checks whether a seki exists — the pass move is appended to every node's move list unconditionally, one line, no questions asked. The tree never knows there's a truce; it discovers, a few thousand simulations later, that every move except pass loses a group. In other words: there is no seki algorithm anywhere in Fuego — MCTS itself discovers that pass is the only move that doesn't lose.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frbeymi7sxew5cnpsrhhy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frbeymi7sxew5cnpsrhhy.png" alt="A search tree with real boards at seven nodes. Root: the seki position at exhaustion, Black to move — only d1 and pass remain. Left branch (thin red, " width="800" height="1282"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pachi solves it with statistics. Its playout policy refuses &lt;em&gt;bad&lt;/em&gt; self-atari (&lt;code&gt;is_bad_selfatari&lt;/code&gt;) — putting your own group at one liberty for no gain; the check is careful enough to still permit sacrificial self-ataris like throw-ins and nakade (deliberate sacrifices that ruin the opponent's shape or eyespace) — so the truce survives most of its random futures — and then its ownership map notices what survived: a point that stays &lt;em&gt;empty&lt;/em&gt; in 80% of playouts earns a named verdict, &lt;code&gt;PJ_SEKI&lt;/code&gt;. That's how a truce looks when you count futures.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzup02pjyp9uuy0cawme.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzup02pjyp9uuy0cawme.png" alt="The filled seki board on the left with three lettered sample points: A on a white stone at c6, B on a black stone at g6, C inside the empty shared liberty at d1. On the right, three ownership-frequency bars across ten thousand playout endings: A is ~97% white, verdict PJ_WHITE, alive; B is ~98% black, verdict PJ_BLACK, alive; C is ~95% owned by no one, verdict PJ_SEKI — the truce, found statistically. Caption: percentages illustrative; empty points are credited to whoever surrounds them, so d1 is the only point on the board that thousands of futures agree belongs to nobody." width="799" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And GNU Go solves it by elimination, the prover's way. Its aftermath module tries to make every friendly group invincible and remove every enemy stone; whatever resists both — stones that "cannot be removed, nor turned invincible" — is declared alive in seki. Run that on our corner: every attempt to capture the white group means filling d1 and dying, so removal fails; every attempt to give it a second eye fails too, because there's no room for one. The stones survive both procedures while belonging to neither side's territory — and that leftover gets the label GNU Go reserved for it: &lt;code&gt;ALIVE_IN_SEKI&lt;/code&gt;. Seki isn't detected; it's what's left when killing and securing have both been tried and both failed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7tks0uxoav2qx4jqgk6q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7tks0uxoav2qx4jqgk6q.png" alt="A flowchart. Top box: every group on the board, when the game seems over. Procedure 1, try to KILL it — if capture succeeds, the group exits right into a DEAD bucket, removed from the board. If it survives every attempt, flow continues down to Procedure 2, try to GUARANTEE it — connect it, give it two eyes, make it invincible; if securing succeeds, it exits right into an ALIVE bucket, unconditional. If securing fails too, the flow drops into a red box: ALIVE_IN_SEKI — the leftover that couldn't be killed and couldn't be guaranteed. A side note traces our white corner group: the only attack is filling d1, self-atari, so killing fails; one eye at f1 with no room for a second, so guaranteeing fails; it falls through both — seki, by elimination. Caption: GNU Go has no definition of seki — only procedures for killing and securing; seki is the name of what's left when both fail." width="799" height="503"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three referees, three ways of letting a truce be a truce. Mine made everyone fight to the death.&lt;/p&gt;

&lt;p&gt;The scoring, in turn, is exactly as simple as the no-pass design allows it to be. FoolGo counts black — and only black:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/board/full_board.h&lt;/span&gt;
&lt;span class="n"&gt;PositionIndex&lt;/span&gt; &lt;span class="nf"&gt;BlackRegion&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;black_pieces_count_&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;eye_states_array_&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BLACK_FORCE&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;RealCount&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Black's score is its stones plus its real eyes, as a fraction of the board; white's is defined as the complement, &lt;code&gt;1 − black_ratio&lt;/code&gt;. That sounds reckless until you remember what exhaustion guarantees: when &lt;code&gt;IsEnd()&lt;/code&gt; fires, every point on the board is a stone or a real eye — nothing neutral, nothing contested. On such a board the complement of black &lt;em&gt;is&lt;/em&gt; white, so one counter suffices. The scheme is internally consistent with the referee that feeds it — assuming, as ever, no seki.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lemma everyone needs
&lt;/h2&gt;

&lt;p&gt;Here's the thing though: strip away the naivety, and FoolGo's termination argument is &lt;em&gt;everyone's&lt;/em&gt; termination argument. Every playout engine rests on the same lemma — &lt;strong&gt;a random player that never fills its own true eyes runs out of moves&lt;/strong&gt; — and every one of them needs the same sub-routine: a cheap test for "is this point a real eye, or a false one?"&lt;/p&gt;

&lt;p&gt;A false eye looks like an eye (four friendly orthogonal neighbors) but the diagonals betray it: with enough enemy stones on the diagonals, the connecting stones can be captured and the "eye" collapses. The classic heuristic is: in the center, an eye survives at most one bad diagonal; on the edge or corner, none. I found three independent implementations of that sentence — and a fourth engine that has one, but deliberately keeps it out of its playouts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe1n0vckrh3hvsdmcdhah.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe1n0vckrh3hvsdmcdhah.png" alt="One 9x9 board with two identical black crosses. Left, around c6: four black stones orthogonally, three black diagonals and one white — labeled " width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of the four spends more than a few lines on it — and they split into exactly two schools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pachi, libego, and FoolGo&lt;/strong&gt; count enemy diagonals — one rule, three spellings: Pachi's virtual enemy stone for the board edge, libego's single boolean, FoolGo's &lt;code&gt;{3, 2, 1}&lt;/code&gt; table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fuego&lt;/strong&gt;'s playout never looks at the diagonals — the classic test exists in its codebase, but it's reserved for the search tree. The playout filter, inherited from Rémi Coulom's Crazy Stone paper, treats &lt;em&gt;any&lt;/em&gt; point fully walled by friendly stones as an eye, false or not, with one release valve: the point becomes fillable the moment an adjacent wall block drops to its last liberty. Stricter before the fight — it refuses to fill even a blatant false eye, enemy diagonals and all, while no wall block is in danger — and exact during it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three codebases converge on the same diagonal count — the same forced-answer convergence I found in the chain data structures last time. Fuego's playout is the exception that sharpens the rule: the diagonal test was never the point. The point is a cheap answer to one question — &lt;em&gt;will this eye still exist after the next move?&lt;/em&gt; — and you can buy it statically, by counting diagonals, or dynamically, by watching for atari. Either way, the answer is the &lt;em&gt;termination proof&lt;/em&gt; of the whole Monte-Carlo method, implemented as a neighbor count.&lt;/p&gt;

&lt;p&gt;Pachi's version carries my favorite comment in this entire investigation, admitting the heuristic's known defeat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* XXX: We attempt false eye detection but we will yield false
 * positives in case of http://senseis.xmp.net/?TwoHeadedDragon :-( */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Nobody trusts the natural ending
&lt;/h2&gt;

&lt;p&gt;FoolGo lets every playout run to structural exhaustion, uncapped — the only engine of the seven that does. (It once had a cap; a rewrite dropped it, nobody ever noticed, and the orphaned constant still sits in a header.) Everyone else wears a belt with their suspenders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;libego&lt;/strong&gt; ends a playout on two consecutive passes &lt;em&gt;or&lt;/em&gt; a hard cap of &lt;code&gt;3 × board_area&lt;/code&gt; moves — and here's the detail I love: a playout that hits the cap is &lt;strong&gt;thrown away, not scored&lt;/strong&gt;. &lt;code&gt;DoOnePlayout&lt;/code&gt; just &lt;code&gt;return&lt;/code&gt;s without updating the tree. An over-long game is treated as a measurement failure, not a data point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pachi&lt;/strong&gt; caps at &lt;code&gt;MAX_GAMELEN&lt;/code&gt; — 600 moves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fuego&lt;/strong&gt; caps at &lt;code&gt;3 * size * size&lt;/code&gt; with a comment explaining exactly why the cap must exist: for speed, playouts only check &lt;em&gt;simple&lt;/em&gt; ko. A superko cycle — a position repeating after four or six moves — would loop forever, and nobody's going to spend hash lookups on full cycle detection at playout speed. The cap is the cheap insurance against the rules corner they deliberately chose not to implement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's what that fear looks like on a board — the classic &lt;strong&gt;triple ko&lt;/strong&gt;: two eyeless groups locked together, three kos between them, both lives hanging on the fight:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frspt4445vtaxeydwao9p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frspt4445vtaxeydwao9p.png" alt="The classic triple-ko position on a 9x9 board: two interlocked comb-shaped groups, neither with an eye — White's comb breathes only at kos a and c, Black's only at ko b, the three mouths marked with dashed red circles. Sidebar: the six-move cycle — Black takes a (saving his comb and putting White's in atari), White takes b, Black takes c, White retakes a, Black retakes b, White retakes c — and the exact position recurs, forever. Every move is forced: decline the ko and your whole group is captured next move. Every move passes the simple-ko check, since each retake comes three moves after its capture. Catching the loop needs whole-board memory; nobody pays for that at playout speed." width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How scared should anyone actually be? Not very. Of the 168,813 games played by Nihon Kiin professionals between 1924 and 2007, exactly 19 ended "no result" — the scoreboard's name for an unbreakable cycle. About one game in nine thousand. (The most famous one is also why triple ko is considered bad luck: in 1582 it suspended a game played in Oda Nobunaga's presence in Kyoto, and the next day Nobunaga was betrayed and killed.) Which explains FoolGo one more time: no cap, no superko check, nothing that could ever break this cycle — the same wager as the seki one. Accept a catastrophic failure mode on a one-in-nine-thousand position, and the referee stays one line long.&lt;/p&gt;

&lt;p&gt;Fuego adds one more mechanism, my favorite name in the whole codebase — the &lt;strong&gt;mercy rule&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// gouct/GoUctGlobalSearch.h&lt;/span&gt;
&lt;span class="n"&gt;m_mercyRuleThreshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;static_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It keeps a running stone-difference counter, updated by captures after every playout move. The moment one side is ahead by 30% of the board — 102 stones on 19×19 — the playout stops and is scored as a certain win. No need to play the last 200 moves of a massacre. The game is over long before it's over, and Fuego is the only engine honest enough to write that down as a rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoring the corpse
&lt;/h2&gt;

&lt;p&gt;Once a playout ends, someone has to count. The engines' scorers are a study in how much correctness you can trade for speed when you're only feeding a win-rate estimator.&lt;/p&gt;

&lt;p&gt;libego's playout scorer doesn't flood-fill territory at all. It counts stones, then makes one assumption: any empty point left on a finished board must be an eye of whoever surrounds it — because playouts fill everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// board.cpp — PlayoutScore(): stones + one pass over the empties&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;RawBoard&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;EyeScore&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Vertex&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;nbr_cnt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;player_cnt_is_max&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Player&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Black&lt;/span&gt; &lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;
    &lt;span class="n"&gt;nbr_cnt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;player_cnt_is_max&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Player&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;White&lt;/span&gt; &lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No dame arbitration, no seki handling, O(area), branch-light. The exact scorer exists in the codebase too: &lt;strong&gt;Tromp-Taylor&lt;/strong&gt;, the computer-friendly statement of Go's rules — every stone counts as it stands, nothing is ever judged dead, and each empty region is flood-filled to see whose stones it touches: one color's only, it's their territory; both colors', it's nobody's. That scorer is reserved for the search tree, where positions end in passes rather than exhaustion and empty regions can still be large.&lt;/p&gt;

&lt;p&gt;Here's the whole rule on one board:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftr4fij4josh9j2gzamgn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftr4fij4josh9j2gzamgn.png" alt="A simplified end-of-game 9x9 position: a white wall and a black wall facing each other with the dame corridor between them unfilled. Small white dots mark the left region — every path through empty points reaches only White: 20 points of White territory. Small dark dots mark the right region — reaches only Black: 20 points. Red rings mark the middle corridor — it reaches both colors, so its 19 points belong to nobody. Sidebar: every stone counts as it stands, nothing is ever judged dead; score = stones + territory, 31–31 here before komi. No judgment anywhere — just arithmetic over reachability." width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fuego makes that same split explicit with two scorers and a selector: if the two passes happened &lt;em&gt;inside the playout&lt;/em&gt; (board full, position clean), use the fast &lt;code&gt;ScoreSimpleEndPosition&lt;/code&gt;, whose per-point helper literally asserts there are no empty neighbors left to worry about. If the passes happened &lt;em&gt;in the search tree&lt;/em&gt; (real-game-shaped position, open regions), use the flood-filling &lt;code&gt;TrompTaylorScore&lt;/code&gt;. Where the passes happened tells you which scorer you're allowed to afford.&lt;/p&gt;

&lt;p&gt;And FoolGo? Black's stones plus black's eyes, divided by 81, white gets the complement — which, on an exhaustion-finished board, is exact area counting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Referee by statistics
&lt;/h2&gt;

&lt;p&gt;Pachi's answer to the dead-stone problem is the most Monte-Carlo idea in this whole story: &lt;strong&gt;don't judge — count.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every finished playout deposits its per-point winner into an &lt;em&gt;ownermap&lt;/em&gt; — a per-intersection tally of who owned that point when the game ended. Play a few thousand random futures, and life and death becomes a frequency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* ownermap.c — a point's status, judged by its futures */&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt;      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;     &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;thres&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PJ_SEKI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* stays empty 80% of the time */&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;thres&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PJ_BLACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;thres&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PJ_WHITE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;                              &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PJ_UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A group is declared &lt;strong&gt;dead when the enemy ends up owning its points in at least 67% of playouts&lt;/strong&gt;. Seki — the mutual-life stalemate that breaks naive scorers — falls out for free: it's a point that stays &lt;em&gt;empty&lt;/em&gt; in 80% of futures, because neither side can afford to fill it — exactly what the frequency bars showed at d1, back in the seki section. Nobody wrote a seki detector. The statistics are the seki detector.&lt;/p&gt;

&lt;p&gt;And here is the same tally meeting a harder version of the question this post opened with — the corner invasion unanswered, reinforced by a fresh white stone at h7, with black pressing from below at h5:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5nenufnwupddlavbuq62.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5nenufnwupddlavbuq62.png" alt="A variant of the opening position: the two-stone white invasion at h9 and i9, ringed in red, unanswered but backed by a white stone at h7, with a black stone pressing at h5. Lettered points: A on the white wall stone d5, B on the black stone f8, C on the invasion. Sidebar, headed " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One measured footnote on that 73%: remove the black stone at h5 from that board and the tally slides to 65% — under the bar — and the verdict flips from &lt;em&gt;dead&lt;/em&gt; to &lt;em&gt;withheld&lt;/em&gt;. Pachi has furniture for exactly that case: &lt;code&gt;ownermap_dead_groups&lt;/code&gt; files every group into one of two queues — &lt;em&gt;dead&lt;/em&gt; or &lt;em&gt;unclear&lt;/em&gt; — and &lt;code&gt;board_position_final&lt;/code&gt; refuses to call the position finished while anything sits in the unclear queue: the game simply continues until the futures agree. The verdict isn't a boolean; it's a threshold crossing, and the referee knows which side it's standing on. (Both numbers come from my own uniform-random referee; Pachi's production playouts add patterns and capture heuristics — sharper futures, sharper verdicts.)&lt;/p&gt;

&lt;p&gt;What sold me is how far Pachi trusts this. The GTP command &lt;code&gt;final_status_list dead&lt;/code&gt; — the protocol question "which stones do we remove before counting?" — is answered by seeding the ownermap with at least 500 fresh playouts and reading death off the frequencies. Even Pachi's decision to pass &lt;em&gt;in a real game&lt;/em&gt; is gated on the ownermap: it passes only when the opponent has passed, the ownership-based score estimate says it's winning, and the map says the position is final. The referee, the scorer, and the "is it safe to stop?" instinct are all the same object: a histogram.&lt;/p&gt;

&lt;h2&gt;
  
  
  Referee by reasoning — with a confession
&lt;/h2&gt;

&lt;p&gt;GNU Go is the classical engine in the group — pre-Monte-Carlo, pure knowledge — and its referee is philosophically opposite. It doesn't even have a pass &lt;em&gt;rule&lt;/em&gt;. It has an accountant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* engine/genmove.c */&lt;/span&gt;
&lt;span class="n"&gt;move&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PASS_MOVE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every candidate move gets a value in estimated points. If the best value on the whole board is not strictly positive — after escalating through endgame patterns, re-examined capturing races, and finally dame worth a single point — the move stays &lt;code&gt;PASS_MOVE&lt;/code&gt;. GNU Go passes when the ledger is empty. Passing isn't a decision; it's what's left when arithmetic finds nothing worth one point.&lt;/p&gt;

&lt;p&gt;I watched it happen. GNU Go 3.8 still compiles, and fed the opening position over GTP, it prices the board out loud:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy1t8a77ipwrviau8p7kh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy1t8a77ipwrviau8p7kh.png" alt="Two 9x9 boards. Left, " width="799" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Deeper judgments get the full symbolic treatment — a dedicated life-and-death search (the &lt;em&gt;owl&lt;/em&gt; code) stamps each group &lt;code&gt;DEAD&lt;/code&gt;, &lt;code&gt;CRITICAL&lt;/code&gt;, or &lt;code&gt;ALIVE&lt;/code&gt; — but here is the confession: ask GNU Go to actually &lt;em&gt;score&lt;/em&gt; a finished game, and it sets every symbolic verdict aside, plays the position out with one careful, deterministic playout (threats priced at zero), and reads life and death off the finished board. The reasoning engine's most trusted referee is a Monte-Carlo sample of size one — and the module that does it is named &lt;em&gt;aftermath&lt;/em&gt;, which exists, per its own header comment, "to &lt;em&gt;robustly&lt;/em&gt; determine life and death status": the proofs weren't robust enough to bet the score on. Run live on the opening position, the whole apparatus names exactly two dead stones — h9 and i9 — and returns &lt;code&gt;B+12.0&lt;/code&gt;: the humans' verdict, reached by one careful playout instead of a handshake. (One point shy of the area count from the fiat figure only because GNU Go defaults to territory scoring, Japanese-style, while Tromp-Taylor counts area, Chinese-style — a parity quirk between the two systems, not a disagreement about life.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Referee by fiat
&lt;/h2&gt;

&lt;p&gt;Leela Zero — the AlphaZero-style engine — solved the dead-stone problem by deleting it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// FastBoard.cpp — Needed for scoring passed out games not in MC playouts&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;FastBoard&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;area_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;komi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;white&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;calc_reach_color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WHITE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;black&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;calc_reach_color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BLACK&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;black&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;white&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;komi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's Tromp-Taylor area scoring where the flood-fill is seeded from &lt;em&gt;every stone on the board, unconditionally&lt;/em&gt;. There is no dead-stone adjudication anywhere in the engine. &lt;strong&gt;If a stone is on the board when the game ends, it is alive&lt;/strong&gt;, and it counts — even if it's a hopeless prisoner sitting in your territory.&lt;/p&gt;

&lt;p&gt;Here's what that fiat does to the position this post opened with — the one where the humans agreed the invasion was dead:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3xu8oko44u6buudnx5vw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3xu8oko44u6buudnx5vw.png" alt="The opening figure's board, re-scored by Tromp-Taylor as it stands: white territory marked with small white dots on the left, and 29 points of Black's side marked with red rings — every one of them reaches the two uncaptured white invasion stones, so they belong to nobody. Only a three-point pocket sealed behind Black's own wall survives as territory. Sidebar: by agreement, Black wins by 13; by Tromp-Taylor as it stands, White wins by 20. One un-captured group flips a won game — capture it before you pass, or it counts. That is the entire training signal: Leela Zero learns to clean the board because the scorer never will." width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two "dead" stones aren't just alive — they poison everything that can reach them. Twenty-nine points of Black's territory become no-man's-land, and a game Black won by 13 becomes a game White wins by 20. Mercy, under this referee, costs more than the mercy was worth.&lt;/p&gt;

&lt;p&gt;This isn't an oversight; it's the training contract. The scorer's rule is pushed onto the player: if you want your opponent's dead stones to not count, &lt;em&gt;capture them before you pass&lt;/em&gt;. The network learns to physically clean the board because the referee will not do it any favors. The judgment that GNU Go performs with a specialized search module, and Pachi with a histogram, Leela Zero absorbs into the policy network's weights.&lt;/p&gt;

&lt;h2&gt;
  
  
  Referee by mathematics
&lt;/h2&gt;

&lt;p&gt;KataGo looked at all of the above and did the most engineer thing possible: it treated "when is the game over, and what does it score" as a first-class rules-engineering problem. Its &lt;code&gt;Rules&lt;/code&gt; object has knobs the other six engines don't even have vocabulary for: area vs territory scoring, a group tax (every living group forfeits its two eye points — the ancient Chinese 还棋头, surviving as an enum value), a button (half-point to whoever passes first, buying area rules the fine endgame incentives of territory rules), and spight-style single-pass endings under certain ko rules. And one more, wonderfully: &lt;code&gt;friendlyPassOk&lt;/code&gt;. Under area scoring an uncaptured dead stone counts against you — the fiat figure showed the bill — so a strict engine must capture everything before daring to pass. This flag tells KataGo whether the opponent can be trusted to &lt;em&gt;agree&lt;/em&gt; about dead stones after the passes; that is, whether the human ending is available. The problem this post opened with, shipped as a boolean.&lt;/p&gt;

&lt;p&gt;But the crown jewel is the referee that can't be wrong. KataGo implements &lt;strong&gt;Benson's algorithm&lt;/strong&gt; — the 1976 result that identifies groups that are &lt;em&gt;unconditionally alive&lt;/em&gt;: alive even if their owner passes forever. (Stronger than what club players usually mean by the phrase — alive even if the opponent moves first — Benson's sense allows the owner &lt;em&gt;no answering moves at all&lt;/em&gt;.) Pass-alive is not a heuristic, a frequency, or a search verdict; it's a fixpoint computation with a proof:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// game/board.cpp — https://senseis.xmp.net/?BensonsAlgorithm&lt;/span&gt;
&lt;span class="c1"&gt;// Walk all player heads and kill them if they haven't&lt;/span&gt;
&lt;span class="c1"&gt;// accumulated at least 2 vital liberties&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vitalCountByPlaHead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;plaHead&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;plaHasBeenKilled&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the distinction the theorem draws, on a board — two black groups any club player would call alive:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr1znh0pu8xcz10z8rnt1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr1znh0pu8xcz10z8rnt1.png" alt="A 9x9 board with two white-enclosed black groups. Bottom-left, labeled " width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bottom-left group's two eyes are each entirely walled by the chain — both &lt;em&gt;vital&lt;/em&gt;, in Benson's vocabulary — and the fixpoint keeps it: no sequence of White moves, of any length, can ever capture it. The top-right group has &lt;em&gt;more&lt;/em&gt; eyespace and less life: the corner point of its big eye touches only the eyespace, never the chain, and that single non-liberty is both why the region isn't vital and how the kill works &lt;em&gt;if Black never responds&lt;/em&gt;: White fills the three real liberties while breathing on exactly that point, then takes the last eye as a capture. In a real game Black defends trivially, which is the whole distinction: one group's life is a theorem, the other's is a promise to keep answering. Benson's algorithm certifies only theorems.&lt;/p&gt;

&lt;p&gt;And it's used exactly the way a theorem should be. During self-play, after every single move, KataGo checks: does &lt;em&gt;every point on the board&lt;/em&gt; now resolve to a pass-alive owner? If yes, the game ends immediately — no passes needed, the outcome is provably settled. If even one point is unproven, the check bails and play continues:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// game/boardhistory.cpp — endGameIfAllPassAlive&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;C_WHITE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="n"&gt;boardScore&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;C_BLACK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;boardScore&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// one unproven point → keep playing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For everything Benson can't prove — the ordinary dead stones of an ordinary human game — KataGo falls back to a learned judge: the search's visit-averaged &lt;em&gt;ownership map&lt;/em&gt;, thresholded (a chain is alive only if its average ownership is safely its own color, and no point of it is badly contested). Which is to say: KataGo's referee is a theorem where a theorem is possible, and Pachi's histogram — upgraded from playout frequencies to neural predictions — everywhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five referees
&lt;/h2&gt;

&lt;p&gt;So: the same question, seven codebases, five answers. (Five philosophies, not five disjoint mechanisms — KataGo layers three of them, and GNU Go's confession shows reasoning leaning on exhaustion when the score is on the line.)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Referee&lt;/th&gt;
&lt;th&gt;Engine(s)&lt;/th&gt;
&lt;th&gt;"The game is over when..."&lt;/th&gt;
&lt;th&gt;Dead stones are...&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Exhaustion&lt;/td&gt;
&lt;td&gt;FoolGo, libego, Fuego&lt;/td&gt;
&lt;td&gt;the board runs out of legal non-eye moves&lt;/td&gt;
&lt;td&gt;whatever got captured along the way&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Statistics&lt;/td&gt;
&lt;td&gt;Pachi&lt;/td&gt;
&lt;td&gt;the ownership histogram says the position is final&lt;/td&gt;
&lt;td&gt;stones the enemy owns in ≥67% of futures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reasoning&lt;/td&gt;
&lt;td&gt;GNU Go&lt;/td&gt;
&lt;td&gt;no move on the board is worth a single point&lt;/td&gt;
&lt;td&gt;what the owl search proves is dead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fiat&lt;/td&gt;
&lt;td&gt;Leela Zero&lt;/td&gt;
&lt;td&gt;two passes — and the scorer believes the board as-is&lt;/td&gt;
&lt;td&gt;nothing; kill them yourself before passing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mathematics&lt;/td&gt;
&lt;td&gt;KataGo&lt;/td&gt;
&lt;td&gt;every point is provably pass-alive (or the rules say so)&lt;/td&gt;
&lt;td&gt;Benson-unprovable chains the ownership net condemns&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Last time, seven engines converged on one data structure, and the lesson was that the problem forces the answer. This time the same seven engines &lt;em&gt;diverged&lt;/em&gt; into five referees, and I think the lesson is the mirror image: the board doesn't force an answer, because the board genuinely doesn't contain one. "Is this group dead?" is a question about futures that haven't been played, and each era answered it with whatever epistemology it had — exhaustive filling in 2012, statistics in the playout era, symbolic proof in the classical era, learned judgment after AlphaZero, and, in KataGo, an honest layering of all of them with a theorem at the bottom.&lt;/p&gt;

&lt;p&gt;Grouped one level up, though, the five referees are really two. Pachi, GNU Go, and KataGo &lt;em&gt;adjudicate&lt;/em&gt; death — by histogram, by owl proof, by theorem plus a trained judge. The exhaustion engines and Leela Zero refuse to adjudicate at all: they insist death be made physical — the corpse gets captured, by the playout or by you, or it isn't a corpse. Every referee makes death &lt;em&gt;factual&lt;/em&gt; before it counts. The one thing no engine does is the human thing from the first figure: leave the dead stones on the board and agree. Agreement was never on the menu — so half of them replaced it with violence, and the other half with a courtroom.&lt;/p&gt;

&lt;p&gt;FoolGo's referee asked no questions at all. It just waited for the board to run out of answers — no voluntary pass, no move cap, and a scoring function that counted one side and inferred the other. I built the degenerate case of the idea everyone else spent a decade refining. But it terminated, forty thousand times a second, for the same underlying reason every playout in this story terminates: a random player who refuses to fill his own eyes eventually has nothing left to say. Even the fool stops playing when the board is full.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Every code excerpt above is from the engines' actual sources: FoolGo (github), GNU Go 3.8, Pachi, libego, Fuego 1.1, Leela Zero, KataGo — read with Claude Code, with the quotable claims re-checked by hand. If you find a misreading, tell me — the previous post survived three rounds of adversarial review and I'd like this one to earn the same.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>ai</category>
      <category>algorithms</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>No neural nets, no tree: inside a Go engine that plays 40,000 games per second</title>
      <dc:creator>Chauncey Wang</dc:creator>
      <pubDate>Fri, 21 Aug 2026 02:54:37 +0000</pubDate>
      <link>https://dev.to/chncwang/no-neural-nets-no-tree-inside-a-go-engine-that-plays-40000-games-per-second-1om5</link>
      <guid>https://dev.to/chncwang/no-neural-nets-no-tree-inside-a-go-engine-that-plays-40000-games-per-second-1om5</guid>
      <description>&lt;p&gt;In December 2012 — three years before AlphaGo — I wrote a Go AI in C++ called &lt;a href="https://github.com/chncwang/FoolGo" rel="noopener noreferrer"&gt;FoolGo&lt;/a&gt;. It has no neural networks, no opening book, no hand-crafted evaluation function. Just Monte Carlo tree search with UCB1, and enough systems engineering that it simulates &lt;strong&gt;about 40,000 complete games of Go per second&lt;/strong&gt; — benchmarked on my 2014 MacBook Air.&lt;/p&gt;

&lt;p&gt;It never got past beginner strength on 9×9, and I've always been upfront about that. Most of the repo's stars actually arrived after AlphaGo beat Lee Sedol in 2016: once the whole world wanted to know how Go AI worked, people came looking for the &lt;em&gt;pre-intuition&lt;/em&gt; machinery in readable form — what the field ran on before neural networks learned to feel a board. This post is a tour of the engineering that makes vanilla MCTS fast — because for vanilla MCTS, fast is the only thing there is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why throughput is the whole game
&lt;/h2&gt;

&lt;p&gt;Pure MCTS has no way to evaluate a Go position directly. To judge a candidate move, it plays random games from that position all the way to the end, and counts wins. Strength is roughly a function of how many of these playouts you can afford per move. There's no clever prior to save you — the engine &lt;em&gt;is&lt;/em&gt; its simulation throughput. So every design decision below is about the same thing: making "play a full random game of Go" as close to free as possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Board size fixed at compile time
&lt;/h2&gt;

&lt;p&gt;Everything in FoolGo is templated on the board length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BoardLen&lt;/span&gt; &lt;span class="n"&gt;BOARD_LEN&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FullBoard&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 9×9 engine and a 19×19 engine are different types, compiled separately. That sounds like C++ showing off, but the payoff is concrete: every array in the hot path — the board, the chain structures, the hash tables of the hasher — has a size known at compile time. No &lt;code&gt;std::vector&lt;/code&gt; growth, no heap allocation during search, no pointer chasing where an array index will do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stones as disjoint sets, in flat arrays
&lt;/h2&gt;

&lt;p&gt;In Go, stones of the same color that touch form a &lt;em&gt;string&lt;/em&gt;, and the string — not the stone — is the unit of life and death: the moment a string's last liberty (adjacent empty point) is filled, the whole string is captured and removed from the board at once.&lt;/p&gt;

&lt;p&gt;Watch what a single move can do:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy3m2ci7rp3ij38wpkwea.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy3m2ci7rp3ij38wpkwea.png" alt="Before/after: black connects two strings into one; liberty rings show the merged count" width="800" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before the move, black has two strings — and they are not equal. The pair at c4–c5 shares five liberties as one unit; the lone stone at e5 has been squeezed down to exactly one. In Go terms it is in atari: one White move from being eaten. Connecting at d5 is the rescue — and notice that this single placement fuses two strings and the new stone into one four-stone string with five liberties. Count the rings on the right: the arithmetic isn't additive. Merging liberty sets means deduplicating them — a detail that will matter shortly — and enemy stones don't count: White's four stones block four of what would otherwise be nine. The rescue cost White too: its d4–e4 string just lost one of its own liberties. Push any string's count to zero and it gets eaten — captured and removed from the board at once. A stone has four neighbors, so a single placement can fuse up to four strings (plus the new stone) into one.&lt;/p&gt;

&lt;p&gt;And that's the disjoint-set view of the same event — before, two sets; after, one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before:   set A: head = c5,  stones: c4 → c5,  c5 → c5
                 liberties {b4, b5, c3, c6, d5}
          set B: head = e5,  stones: e5 → e5
                 liberties {d5}

play d5:  union(A, B, d5)

after:    set A: head = c5,  stones: c4 → c5,  c5 → c5,  d5 → c5,  e5 → c5
                 liberties {b4, b5, c3, c6, d6}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each stone points straight at its set's head, so &lt;em&gt;find&lt;/em&gt; is a single array read. The price shows up at merge time: the absorbed side's stones get repointed to the surviving head — and &lt;code&gt;MergeLists&lt;/code&gt; chooses sides by size, relabeling the smaller list into the larger (the classic union-by-size trick that keeps total relabeling cheap). So the freshly played stone, a list of one, never wins the election: here d5 and then e5 are repointed into the c4–c5 pair's head. And the liberty sets OR together, minus the point just filled, plus the new stone's own empty neighbors. Hold that picture; the code below is nothing more than this, made fast.&lt;/p&gt;

&lt;p&gt;Now count what the engine must answer on &lt;em&gt;every&lt;/em&gt; move of every simulated game:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Which string does this neighbor belong to?&lt;/strong&gt; — &lt;em&gt;find&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did my move drop that enemy string's liberties to zero?&lt;/strong&gt; If so, remove &lt;em&gt;all&lt;/em&gt; of its stones, immediately — an aggregate query plus member enumeration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is my move suicide?&lt;/strong&gt; — the same query, pointed at my own string&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did my move connect friendly strings?&lt;/strong&gt; Merge them — &lt;em&gt;union&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At 40,000 games per second, a couple hundred moves per game, that's millions of move-executions per second, each running several of these queries. They all have to be near-O(1).&lt;/p&gt;

&lt;p&gt;This is the union-find problem wearing a Go costume — with two extra requirements the textbook version doesn't have: each set needs cheap &lt;strong&gt;member enumeration&lt;/strong&gt; (to delete a captured string from the board) and a cheap &lt;strong&gt;aggregate statistic&lt;/strong&gt; (the liberty count that decides capture and suicide). FoolGo's &lt;code&gt;ChainSet&lt;/code&gt; answers all of it with two flat arrays — one node per board point, one list record per potential chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;Node&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;PositionIndex&lt;/span&gt; &lt;span class="n"&gt;next_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;list_head_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;nodes_&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BoardLenSquare&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BOARD_LEN&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;

&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;PositionIndex&lt;/span&gt; &lt;span class="n"&gt;tail_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;len_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;BitSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BOARD_LEN&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;air_set_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;AirCount&lt;/span&gt; &lt;span class="n"&gt;air_count_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;lists_&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BoardLenSquare&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BOARD_LEN&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;list_head_&lt;/code&gt; is the &lt;em&gt;find&lt;/em&gt; pointer — every stone knows its string's representative. The &lt;code&gt;next_&lt;/code&gt; chain makes members enumerable without searching the board — that's the capture-removal path. And the per-list &lt;code&gt;air_set_&lt;/code&gt;/&lt;code&gt;air_count_&lt;/code&gt; are the aggregates that answer capture and suicide checks in constant time. Merging two strings is a list splice plus head-pointer updates — index arithmetic on preallocated arrays, no allocation, no tree balancing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Liberties as bitsets
&lt;/h2&gt;

&lt;p&gt;The expensive bookkeeping is the liberties themselves ("airs" in FoolGo's vocabulary): every placed stone changes the liberties of all its neighbors. FoolGo stores each string's liberties as a bitset over board points. When strings merge, their liberty sets merge with a bitwise OR; counting is a popcount. Some of the hottest bookkeeping in the engine compiles down to word-sized bit operations.&lt;/p&gt;

&lt;p&gt;Here is the connection at d5 again, seen through the bitsets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    b4 b5 c3 c6 d5 d6      (75 more bits, all 0)
pair {c4,c5}         1  1  1  1  1  0
lone {e5}            0  0  0  0  1  0
new  {d5}            0  0  0  0  0  1

OR                   1  1  1  1  1  1
clear d5 (filled)    1  1  1  1  0  1     popcount → 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three ORs, one bit-clear, one popcount. There is no deduplication logic anywhere: d5, a liberty of both black strings, is simply the same bit twice, and OR-ing makes the duplicate vanish by construction. On a 9×9 board the whole set fits in two machine words, so "merge three strings and recount the result's liberties" costs a handful of CPU instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a move
&lt;/h2&gt;

&lt;p&gt;All of that bookkeeping exists to make one operation cheap: actually playing a move. Here is FoolGo's real pipeline, from &lt;code&gt;full_board.h&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, is the move even legal?&lt;/strong&gt; &lt;code&gt;IsSuicide&lt;/code&gt; looks at the four neighbors of the empty point — and at nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;for each of the 4 neighbors:
    empty?                                 → not suicide (instant liberty)
    friendly string with ≥ 2 liberties?    → not suicide (joins a string that still breathes)
    enemy string with exactly 1 liberty?   → not suicide (the move captures it, freeing space)
none of the above                          → suicide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four array reads. No board scan, no trial placement — every clause is answered by a per-string &lt;code&gt;air_count_&lt;/code&gt; that the merge machinery has been keeping current all along. The friendly clause needs ≥ 2 because the new stone fills one of that string's liberties on arrival; the enemy clause is the elegant one — a move onto your last-looking point is perfectly legal if it kills the surrounder first.&lt;/p&gt;

&lt;p&gt;Here are the clauses on the board:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fda4wics760fwaolwzewf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fda4wics760fwaolwzewf.png" alt="Three panels: a suicide point, the same point made legal by the kill clause, and the capture executed" width="800" height="234"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the left position, e5 fails every clause: no empty neighbor, no friendly string to join, no enemy string at one liberty — suicide, illegal. Add a single black stone at d3 (middle) and the three-stone white string d4–d5–e4 falls to its last liberty: the &lt;em&gt;same point&lt;/em&gt; now passes the kill clause. Play it (right) and the pipeline below runs — the string is eaten first, &lt;code&gt;RemoveChain&lt;/code&gt; walking its cyclic list, so by the time the black stone lands it has two liberties: points it just vacated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then, play it.&lt;/strong&gt; &lt;code&gt;PlayBasicMove&lt;/code&gt; runs the same four-neighbor scan once more, now with consequences, in a very deliberate order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Captures first.&lt;/strong&gt; Any enemy neighbor string down to its last liberty is eaten on the spot: &lt;code&gt;RemoveChain&lt;/code&gt; walks its cyclic list and clears the stones — and the newly emptied point next door is immediately recorded as a liberty of the stone about to be placed. The new stone breathes into the space it just vacated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Place the stone&lt;/strong&gt;, and clear its point's bit from every adjacent string's liberty bitset — friend and enemy alike.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge.&lt;/strong&gt; &lt;code&gt;AddPiece&lt;/code&gt; creates the one-stone set and union-by-size folds it together with its friendly neighbors — the disjoint-set dance from earlier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suicide cleanup.&lt;/strong&gt; If, after all that, the just-built string has zero liberties, it is removed by the very same &lt;code&gt;RemoveChain&lt;/code&gt; — suicide isn't a special case, it's a capture whose victim is yourself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every step is either a four-neighbor loop or an operation on the structures above — nothing touches the other 76 points of the board. That locality, times forty thousand games a second, is the entire performance story.&lt;/p&gt;

&lt;h2&gt;
  
  
  There is no tree
&lt;/h2&gt;

&lt;p&gt;The textbook picture of MCTS is a tree of nodes with parent/child pointers. FoolGo doesn't build one. Instead, every game state maps to a 64-bit Zobrist hash, and node statistics live in a flat hash table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;StaySelfHasher&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="k"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;()(&lt;/span&gt;&lt;span class="n"&gt;HashKey&lt;/span&gt; &lt;span class="n"&gt;hash_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hash_key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// the key is already a hash — don't hash a hash&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;unordered_map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HashKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NodeRecord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StaySelfHasher&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;node_record_map_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;StaySelfHasher&lt;/code&gt; is my favorite two lines in the repo: the key is a Zobrist hash, which is already uniformly distributed, so the map's hasher is the identity function.)&lt;/p&gt;

&lt;p&gt;This is a transposition table, and it quietly upgrades the search from a tree to a DAG: two different move orders reaching the same position share one node and one set of statistics, for free. Whenever different move orders transpose to the same hashed state, that's not a micro-optimization — it's extra effective simulations without simulating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zobrist hashing, incrementally
&lt;/h2&gt;

&lt;p&gt;Recomputing a position's hash from scratch would cost O(board area) per move. Zobrist hashing makes it O(changed stones): XOR out what left, XOR in what arrived.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;HashKey&lt;/span&gt; &lt;span class="n"&gt;GetHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;FullBoard&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BOARD_LEN&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// full&lt;/span&gt;
&lt;span class="n"&gt;HashKey&lt;/span&gt; &lt;span class="n"&gt;GetHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HashKey&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;BoardDifference&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;chng&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// incremental&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scheme is beautifully dumb. At startup, generate one random 64-bit number for every (point, state) pair — FoolGo's table is literally &lt;code&gt;board_hash_[81][3]&lt;/code&gt; — plus numbers for the side to move and for each possible ko point. A position's hash is the XOR of the numbers matching its current contents. Everything rests on one property: XOR is its own inverse, so XOR-ing the same number twice removes it. "Update" and "undo" are the same operation, and a move's hash cost is proportional to what the move changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="n"&gt;Z&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;d5&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;EMPTY&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="c1"&gt;// d5 stops being empty...&lt;/span&gt;
       &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="n"&gt;Z&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;d5&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;BLACK&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="c1"&gt;// ...and becomes black&lt;/span&gt;
       &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="n"&gt;Z&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BLACK&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="n"&gt;Z&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;WHITE&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;// turn marker: XOR out "Black to move", XOR in "White to move"&lt;/span&gt;
       &lt;span class="c1"&gt;// plus one pair of XORs per captured stone, if any&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The turn-marker pair is there because whose move it is &lt;em&gt;is part of the position&lt;/em&gt;. Look back at the diagram: with Black to move, e5 gets rescued; with White to move, e5 gets eaten — opposite fates, identical stones. If those two states hashed the same, the transposition table would merge them and their statistics would be garbage. So exactly one of two random "to move" numbers is always XOR-ed into the hash, and each move swaps them. Our connection at d5 thus touches four numbers; a capture would touch two more per removed stone. Nothing else on the board is looked at — which is what lets forty thousand games per second afford a fresh hash after every single move.&lt;/p&gt;

&lt;p&gt;The hasher also carries a table for the ko point (&lt;code&gt;ko_hash_&lt;/code&gt;, plus a no-ko number), and the reason is subtle. After a ko capture, one point is temporarily illegal to play. Two boards with identical stones but different ko status therefore have different legal moves — and if they hashed the same, the transposition table would happily merge them into one node, letting statistics gathered in one position answer questions about the other. Getting ko into the hash is the kind of detail that costs you an afternoon of debugging exactly once.&lt;/p&gt;

&lt;h2&gt;
  
  
  UCB1, verbatim
&lt;/h2&gt;

&lt;p&gt;The selection policy is the classic formula, and the code is honest about it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nf"&gt;Ucb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NodeRecord&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;node_record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;visited_count_sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;node_record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetAverageProfit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;visited_count_sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;node_record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetVisitedTime&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Average observed value plus an optimism bonus that shrinks as a node gets visited: exploitation plus exploration in one line. Here it is with numbers — three candidate moves, a thousand playouts spent so far:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parent visits N = 1000          2·ln N ≈ 13.8

move   visits n   avg profit   bonus √(2·ln N / n)    UCB
A         700        0.52             0.14            0.66
B         250        0.46             0.24            0.70
C          50        0.38             0.53            0.91   ← next playout goes here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next simulation goes to C — the move with the &lt;em&gt;worst&lt;/em&gt; observed average. That's not a bug; the bonus is uncertainty made numeric. After only 50 samples, the search cannot yet distinguish a bad move from an unlucky one, so C's claim on the budget is still large. If C keeps disappointing, its average stays low while its bonus shrinks like √(ln N / n), and the playouts drift back to A, whose higher mean now stands on 700 samples. Spend where confidence is thinnest, harvest where confidence is strongest — the formula does both without a single special case. Everything else in the search exists to make evaluating it cheap, millions of times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multithreading by mutual avoidance
&lt;/h2&gt;

&lt;p&gt;FoolGo searches on multiple CPU threads sharing the one transposition table (a mutex guards it). The interesting choice is how threads avoid redundant work: rather than implementing virtual loss — the standard trick of temporarily penalizing a node while a thread explores it — FoolGo simply &lt;strong&gt;forbids a thread from descending into a node a peer is currently exploring&lt;/strong&gt;. Here's what that means at the node we just scored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;thread 1 arrives:  UCB says C (0.91) → descends into C, marks it in-progress
thread 2 arrives:  UCB says C — but C is taken → settles for B (0.70)
thread 3 arrives:  C and B both taken → gets pushed to A (0.66)
threads return:    marks cleared, statistics updated, selection sees fresh numbers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thread 2 wanted C — the policy's actual choice — and got the runner-up; thread 3 got third-best. That's the distortion: under contention, the search behaves as if the top candidates were briefly invisible. Virtual loss is the gentler version of the same idea — a temporary penalty instead of a wall, so a favorite whose lead is big enough can still absorb several threads at once. FoolGo's wall is cruder. But it's a few lines, it can't deadlock, and it was enough to scale a hobby engine across the cores of a laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the grown-ups solved the same problems
&lt;/h2&gt;

&lt;p&gt;FoolGo's choices get more interesting next to the famous open-source engines that came after it — &lt;a href="https://github.com/leela-zero/leela-zero" rel="noopener noreferrer"&gt;Leela Zero&lt;/a&gt; (2017, the community AlphaGo-Zero reproduction) and &lt;a href="https://github.com/lightvector/KataGo" rel="noopener noreferrer"&gt;KataGo&lt;/a&gt; (one of today's strongest open-source Go engines) — and the ones that came before it: &lt;a href="https://www.gnu.org/software/gnugo/" rel="noopener noreferrer"&gt;GNU Go&lt;/a&gt; (whose board code dates to the 1990s), &lt;a href="https://github.com/pasky/pachi" rel="noopener noreferrer"&gt;Pachi&lt;/a&gt;, and &lt;a href="https://github.com/lukaszlew/libego" rel="noopener noreferrer"&gt;libego&lt;/a&gt; (Łukasz Lew's minimalist MCTS library). I went digging through their codebases to compare notes on the same subproblems.&lt;/p&gt;

&lt;h3&gt;
  
  
  One skeleton, five engines
&lt;/h3&gt;

&lt;p&gt;Five engines, written independently across more than two decades, landed on the same core data structure — flat arrays over board points, a circular linked list threading each string's stones, one stone as the representative. The names barely differ: GNU Go (1990s) has &lt;code&gt;string_number[]&lt;/code&gt; and &lt;code&gt;next_stone[]&lt;/code&gt;, Pachi has &lt;code&gt;group_at[]&lt;/code&gt; and &lt;code&gt;groupnext_at[]&lt;/code&gt;, FoolGo has &lt;code&gt;list_head_&lt;/code&gt; and &lt;code&gt;next_&lt;/code&gt;, Leela Zero has &lt;code&gt;m_parent[]&lt;/code&gt; and &lt;code&gt;m_next[]&lt;/code&gt;, KataGo has &lt;code&gt;chain_head[]&lt;/code&gt; and &lt;code&gt;next_in_chain[]&lt;/code&gt;. There is no lineage here — the problem shape forces the answer — and GNU Go's comment from the 1990s could caption them all: "the stones in a string are linked together in a cyclic list." Five engines, two decades, one skeleton.&lt;/p&gt;

&lt;h3&gt;
  
  
  Six ways to count a breath
&lt;/h3&gt;

&lt;p&gt;The chain skeleton converged; liberty bookkeeping went six different ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GNU Go&lt;/strong&gt; keeps an exact count &lt;em&gt;plus&lt;/em&gt; the full list of liberty coordinates (&lt;code&gt;string_libs[].list[]&lt;/code&gt;) — a classical engine wants to reason about specific liberties, not just count them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pachi&lt;/strong&gt; caps the list at ten, refilled lazily — its own comment admits &lt;code&gt;libs&lt;/code&gt; "is only LOWER BOUND for the number of real liberties!!!" Playouts rarely need more than "is this 0, 1, or 2."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;libego&lt;/strong&gt; never dedupes at all: pseudo-liberties plus algebra. Its &lt;code&gt;Chain&lt;/code&gt; stores the count, sum, and sum of squares of its pseudo-liberty vertices — a chain is in atari exactly when count · Σx² = (Σx)², and the atari point is sum ÷ count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FoolGo&lt;/strong&gt;: the exact set as a bitset — merge is OR, dedup by construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leela Zero&lt;/strong&gt;: exact scalar counts, paid for with a dedup walk at merge time (plus a pseudo-liberty helper for rough checks).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KataGo&lt;/strong&gt;: exact scalar counts, incremental, with bound-estimating shortcuts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same invariant, six answers — ranked, roughly, by how much each engine wants to &lt;em&gt;know about&lt;/em&gt; its liberties versus merely count them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three clauses, rediscovered
&lt;/h3&gt;

&lt;p&gt;Playing a move converged even harder than the data structures. FoolGo's three-clause suicide test — empty neighbor: no; friendly with two-plus liberties: no; enemy with exactly one: no — turns out to be structurally identical in Leela Zero's &lt;code&gt;is_suicide&lt;/code&gt; and KataGo's &lt;code&gt;isSuicide&lt;/code&gt;: the same three clauses, in the same order — independent rediscovery, because the test is simply the minimal correct answer the rules allow. There is no fourth clause to invent: a move survives by gaining a liberty directly, joining something that outlives it, or making room by killing. Leela Zero merely bolts a pseudo-liberty fast path onto the front (&lt;code&gt;if (count_pliberties(i)) return false;&lt;/code&gt; — any adjacent empty point settles it immediately). The real differences live at the edges. Pachi caches, for every point, how many of its neighbors are black, white, or off-board — &lt;code&gt;immediate_liberty_count&lt;/code&gt; is just 4 minus those counts — so the commonest clause is answered without visiting the neighbors at all. And GNU Go plays moves &lt;em&gt;reversibly&lt;/em&gt;: every board mutation is pushed onto a change stack (its comments report 20–30 entries per typical move) so the classical engine can read out a line and take it all back on a single board — whereas the playout engines, FoolGo included, never undo anything: they copy the board and let the copy die with the game.&lt;/p&gt;

&lt;h3&gt;
  
  
  The wall, the tax, and no locks at all
&lt;/h3&gt;

&lt;p&gt;Parallel search reads as generations of one lineage — though not a chronological one. &lt;a href="https://fuego.sourceforge.net/" rel="noopener noreferrer"&gt;Fuego&lt;/a&gt;, the strong open MCTS engine of FoolGo's own era, had already gone further than everyone: a fully lock-free multithreaded tree search (Enzenberger &amp;amp; Müller, 2009) — three years &lt;em&gt;before&lt;/em&gt; FoolGo's global mutex. Its source rewards reading even now: each thread allocates nodes from its own pre-allocated array and links them to the parent only once fully initialized; if several threads expand the same node, the last writer wins and the others' work — including value updates already made — is knowingly thrown away. Correctness traded against ever waiting. Every node field is &lt;code&gt;volatile&lt;/code&gt;, virtual loss is already there (&lt;code&gt;m_virtualLossCount&lt;/code&gt;), and the in-code docs cite the exact chapters of the Intel manual whose memory-ordering guarantees the whole scheme leans on. The fool was not early; he was simple. Within the lineage of the simple: FoolGo's forbid-peers rule (2012) is the blunt ancestor, Leela Zero (2017) does it properly with textbook &lt;code&gt;virtual_loss()&lt;/code&gt; / &lt;code&gt;virtual_loss_undo()&lt;/code&gt; folded into node evaluations, and KataGo is the industrial endpoint — virtual loss as a &lt;em&gt;tunable&lt;/em&gt; (&lt;code&gt;numVirtualLossesPerThread&lt;/code&gt;, an atomic counter per node), over a sharded node table with a mutex pool and fully atomic stats structs.&lt;/p&gt;

&lt;h3&gt;
  
  
  From tree to graph
&lt;/h3&gt;

&lt;p&gt;The part that surprised me most. Leela Zero, for all its strength, uses a literal pointer tree and does &lt;em&gt;not&lt;/em&gt; merge transpositions; there's even a comment in &lt;code&gt;uct_select_child&lt;/code&gt; about counting parent visits manually "to avoid issues with transpositions." But in &lt;strong&gt;v1.11.0 (March 2022)&lt;/strong&gt;, KataGo shipped what its release notes call "a new stronger MCTS implementation that operates on a graph rather than a tree" — transposed move orders recombined into shared nodes, keyed by hash in a sharded node table. Which is, architecturally, what FoolGo's &lt;code&gt;unordered_map&amp;lt;HashKey, NodeRecord&amp;gt;&lt;/code&gt; was doing in 2012.&lt;/p&gt;

&lt;p&gt;Before I take a bow: KataGo's author also wrote a &lt;a href="https://github.com/lightvector/KataGo/blob/master/docs/GraphSearch.md" rel="noopener noreferrer"&gt;first-principles document&lt;/a&gt; explaining that naively applying tree-MCTS statistics to a DAG — exactly what FoolGo does — is &lt;em&gt;unsound&lt;/em&gt;: shared nodes break the running-statistics formulation in subtle ways, and doing it correctly (plus handling ko and superko) is the actual hard part. So no, my hobby engine did not do graph search before KataGo. It wandered into the right building ten years early, without knowing why the floor needed reinforcing. KataGo's contribution was the reinforcement.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it adds up to — and where it stops
&lt;/h2&gt;

&lt;p&gt;Flat arrays, bitwise liberty tracking, incremental hashing, a pointer-free "tree," and threads that stay out of each other's way: 40,000 games per second on a laptop from 2014.&lt;/p&gt;

&lt;p&gt;And yet: beginner strength. That plateau is the honest lesson of the repo. Uniformly random playouts are a terrible evaluation function, and no amount of throughput fixes their bias — stronger engines of that era spent their effort on playout &lt;em&gt;policy&lt;/em&gt;, and then 2016 arrived and neural networks replaced blind rollouts with intuition. AlphaGo kept the tree search; it swapped out exactly the part FoolGo had made fast.&lt;/p&gt;

&lt;p&gt;The repo is &lt;a href="https://github.com/chncwang/FoolGo" rel="noopener noreferrer"&gt;github.com/chncwang/FoolGo&lt;/a&gt; — readable on purpose, PRs welcome, and still, I'd argue, one of the clearer ways to see what game-tree search looks like with the covers off.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;These days I build &lt;a href="https://clintrialfinder.info" rel="noopener noreferrer"&gt;ClinTrialFinder&lt;/a&gt;, an AI-powered clinical-trial matcher, and write about building it at &lt;a href="https://chncwang.substack.com" rel="noopener noreferrer"&gt;chncwang.substack.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>ai</category>
      <category>algorithms</category>
      <category>performance</category>
    </item>
    <item>
      <title>Claude Code can make videos: it records the app, narrates with ElevenLabs, and syncs audio to video automatically</title>
      <dc:creator>Chauncey Wang</dc:creator>
      <pubDate>Sat, 15 Aug 2026 03:40:08 +0000</pubDate>
      <link>https://dev.to/chncwang/claude-code-can-make-videos-it-records-the-app-narrates-with-elevenlabs-and-syncs-audio-to-video-7g8</link>
      <guid>https://dev.to/chncwang/claude-code-can-make-videos-it-records-the-app-narrates-with-elevenlabs-and-syncs-audio-to-video-7g8</guid>
      <description>&lt;p&gt;I'm a solo builder. I needed a 2-minute product demo for &lt;a href="https://clintrialfinder.info" rel="noopener noreferrer"&gt;ClinTrialFinder&lt;/a&gt; — a free tool I built that matches cancer patients to clinical trials. I can fumble through OBS and iMovie, but I'm not proficient — and Claude Code does it faster.&lt;/p&gt;

&lt;p&gt;So I asked &lt;strong&gt;Claude Code&lt;/strong&gt; — an agentic coding tool — to make it. And it did: a narrated walkthrough where the voiceover lands exactly on the on-screen action. I never opened a screen recorder. I never opened a video editor. I never manually lined up a single caption to a single frame.&lt;/p&gt;

&lt;p&gt;Here's &lt;a href="https://www.youtube.com/watch?v=gbVJLpa22Io" rel="noopener noreferrer"&gt;the video it produced&lt;/a&gt;. This post is about the three things the agent did to make it — because I think that combination is new.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. It recorded the app — no screen recording
&lt;/h2&gt;

&lt;p&gt;Instead of me screen-capturing a session by hand, the agent wrote a Playwright script that drives the &lt;strong&gt;real, live web app&lt;/strong&gt;: it opens the site, fills out the 10-step patient wizard with a synthetic case, submits, and records the finished results page — all headless, straight to video.&lt;/p&gt;

&lt;p&gt;That means no manual take, no re-shooting when I fumble a click, no "oops the mouse jittered." The recording is &lt;strong&gt;code&lt;/strong&gt;, so it's deterministic and repeatable. When the product changes, the agent re-runs the script and out comes a fresh clip. It even injected a fake cursor that glides between elements, because a headless recording has no real mouse pointer.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. It generated the narration — no microphone
&lt;/h2&gt;

&lt;p&gt;I didn't record a voiceover. The agent wrote the narration script, then called the &lt;strong&gt;ElevenLabs&lt;/strong&gt; text-to-speech API to synthesize it in a clean, consistent voice. If I want to change a line, it edits the text and regenerates that clip in seconds — no re-recording, no "let me find a quiet room," no matching my tone across takes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the agent calls ElevenLabs per narration phrase&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://api.elevenlabs.io/v1/text-to-speech/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;VOICE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xi-api-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eleven_multilingual_v2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. It aligned audio to video — no timeline editor
&lt;/h2&gt;

&lt;p&gt;This is the part that normally needs a human in a video editor, dragging clips around a timeline until the words match the picture. The agent did it &lt;strong&gt;automatically&lt;/strong&gt;, and this is the genuinely clever bit:&lt;/p&gt;

&lt;p&gt;While recording, it logs the timestamp of every key on-screen moment — the submit click, the results appearing, a trial opening, the "copy to AI" dropdown.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;beat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`BEAT &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; @ &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;s`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// BEAT submit-click   @ 46.83s&lt;/span&gt;
&lt;span class="c1"&gt;// BEAT results-shown  @ 68.73s&lt;/span&gt;
&lt;span class="c1"&gt;// BEAT trial-open     @ 90.17s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it cuts the narration into phrases, one per beat, and places each phrase at its beat's timestamp in the final mix (&lt;code&gt;ffmpeg&lt;/code&gt;'s &lt;code&gt;adelay&lt;/code&gt;). The result: when the voice says "now it goes to work," the button is being clicked; when it says "open any trial," the trial is opening. &lt;strong&gt;The sync falls out of the recording itself&lt;/strong&gt; — no dragging, no eyeballing, no manual alignment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Making a product demo used to mean: screen-record a take, write a script, record a voiceover, then sit in an editor syncing them. Four manual steps, each needing a skill (or a person).&lt;/p&gt;

&lt;p&gt;Here it was &lt;strong&gt;one conversation with an agent&lt;/strong&gt;. The whole pipeline is code — record → narrate → align → assemble — so it's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repeatable&lt;/strong&gt;: product changes? Re-run. Fresh, re-synced video.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic&lt;/strong&gt;: same framing and pacing every time, no shaky live take.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster than me&lt;/strong&gt;: I can muddle through OBS and iMovie, but slowly and not well — the agent does it faster, and I don't have to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent also quietly handled the fiddly parts I'd never want to — a site that won't render headless (screenshot + Ken Burns instead), audio mixing that silently halves volume, a blank loading frame that throws off the timing. I didn't debug any of it; it did.&lt;/p&gt;

&lt;p&gt;The shift, for me, is that &lt;strong&gt;making a demo video is now something you ask for, not something you produce.&lt;/strong&gt; The example here is my own project — &lt;a href="https://clintrialfinder.info" rel="noopener noreferrer"&gt;ClinTrialFinder&lt;/a&gt;, a free clinical-trial matching tool for cancer patients — but nothing about the approach is specific to it. If you have a web app and an agent, you can have a narrated, synced demo without touching a recorder or an editor.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>playwright</category>
      <category>ffmpeg</category>
    </item>
    <item>
      <title>Building a One-Person Software Shop with Claude Code</title>
      <dc:creator>Chauncey Wang</dc:creator>
      <pubDate>Thu, 06 Aug 2026 11:30:07 +0000</pubDate>
      <link>https://dev.to/chncwang/building-a-one-person-software-shop-with-claude-code-1d6i</link>
      <guid>https://dev.to/chncwang/building-a-one-person-software-shop-with-claude-code-1d6i</guid>
      <description>&lt;p&gt;I build a clinical-trial matching product alone — no cofounder, no team. Just one person, an AI coding agent, and a set of conventions that keep "vibes-based solo dev" from falling apart across weeks of parallel work.&lt;/p&gt;

&lt;p&gt;The first version was a command-line tool, &lt;a href="https://github.com/chncwang/ClinTrialFinder" rel="noopener noreferrer"&gt;open-sourced&lt;/a&gt;. Turning it into a &lt;em&gt;real web app&lt;/em&gt; — something a patient could open in a browser and trust with their situation — is the jump where a lot of solo projects quietly stall. I made it: today it's a live web app with ~30 drug pages, dozens of disease-specific trial landscapes, and a matcher real cancer patients use to find trials.&lt;/p&gt;

&lt;p&gt;I'm also a cancer patient; I built this partly because I needed it to exist. So I care less about it being impressive than &lt;em&gt;correct&lt;/em&gt; — a wrong trial match wastes a sick person's time. That constraint is the whole point: &lt;strong&gt;the interesting part isn't that an AI writes my code — it's the scaffolding that makes its work trustworthy when I'm not watching every keystroke.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A note on the tool first, then the transferable patterns — most of them scar tissue from something that broke once. Everything's sanitized — fake IPs, generic paths, invented tasks; the real secrets stay home. And the fake IPs aren't only for the article: the real task files never held a raw IP or email, and the server logs that hold IP addresses are purged within 14 days — matching the privacy policy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Claude Code, specifically
&lt;/h2&gt;

&lt;p&gt;People ask why Claude Code and not one of the other coding agents. Two reasons — one soft, one hard.&lt;/p&gt;

&lt;p&gt;The soft one: it infers intent from less. I can hand it a terse, half-specified ask — "the ranking's off for first-line patients, dig into it" — and it usually fills the gaps the way I &lt;em&gt;meant&lt;/em&gt;, not the way I literally typed. That matters when I'm running several sessions and can't write a full spec for each. Subjective, not a benchmark — but it's what keeps me reaching for it.&lt;/p&gt;

&lt;p&gt;The hard one: my review workflow feeds real patient-submission data to the agent — I pull a patient's result set to check whether the matcher did right by them. ClinTrialFinder's privacy policy names Anthropic's Claude as a tool for exactly that — "quality-checking match results, diagnosing issues." Using Claude Code for the review keeps the implementation matched to what patients were told. Other disclosed vendors handle other steps; the point isn't that Claude is the only option — it's that the words I show patients and the tools I run stay in lockstep.&lt;/p&gt;




&lt;h2&gt;
  
  
  The core bet: your task tracker is a git repo
&lt;/h2&gt;

&lt;p&gt;Most people reach for Jira, Linear, Notion. I keep every task as a plain file in a git repo of its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;project-manager/            # a git repo
  tasks/
    412-fix-ranking-edge-case.html
    413-wizard-validation.html
    414-review-user-submission.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F308yvd2s2kwc7srj5n0x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F308yvd2s2kwc7srj5n0x.png" alt="One task file, rendered — fixed shape, plain HTML in git. (Sanitized example.)" width="800" height="1152"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One file per task, fixed shape — priority, status, the problem, the fix, verification plan, links to related tasks. No API, no board, no login. Just files in git — 500+ of them now, and the flat directory has never needed anything fancier.&lt;/p&gt;

&lt;p&gt;Why this beats a tracker &lt;em&gt;for an AI-assisted solo shop specifically&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Version-controlled.&lt;/strong&gt; The backlog is plain files in git — every status flip and edit is a diff you can read, branch, or revert, exactly like source. It's a history you own and grep, not rows in someone else's database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queryable with the tools the agent already has.&lt;/strong&gt; "Next free task number?" is &lt;code&gt;ls | sort&lt;/code&gt;. "Which tasks touch the ranking bug?" is &lt;code&gt;grep -rl&lt;/code&gt;. No integration — just &lt;code&gt;grep&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linkable and self-documenting.&lt;/strong&gt; Tasks cross-link; six weeks later the &lt;em&gt;why&lt;/em&gt; is one click away, written at the time, not reconstructed from memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The insight: &lt;strong&gt;when your teammate is an AI agent, your project management should be a git repo of plain files it can grep — not a SaaS it has to poke through an API.&lt;/strong&gt; (I use HTML for the files — renders and links nicely — but the format is the least interesting part; Markdown would do.)&lt;/p&gt;

&lt;p&gt;One file sits on top of that flat directory: a single &lt;strong&gt;overview&lt;/strong&gt; — a hand-maintained index that splits the backlog into &lt;em&gt;the full list&lt;/em&gt; and &lt;em&gt;the sprint&lt;/em&gt; (what to actually work on now). The task files are the atomic units; the overview is the priority view.&lt;/p&gt;

&lt;p&gt;And two kinds of judgment get their own &lt;em&gt;role&lt;/em&gt;. Worker sessions append tasks freely as they surface — a bug found mid-fix, a spinoff from a review — but none of them promotes itself into the sprint. That call belongs to a dedicated &lt;strong&gt;"CEO" session&lt;/strong&gt;: a Claude Code session that reads the backlog, decides what's worth doing now (via its own &lt;code&gt;update-active-sprint&lt;/code&gt; skill), &lt;em&gt;and&lt;/em&gt; proposes the forward-looking work the workers won't file on their own — new features, UI refinements, the product's next move. Reactive filing is mechanical and belongs to whoever hit the problem; setting direction — both prioritizing and proposing — is judgment, so I concentrate it in one named role instead of smearing it across four sessions that each think &lt;em&gt;their&lt;/em&gt; task is the important one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm3xf646kslp0yv9nkwtu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm3xf646kslp0yv9nkwtu.png" alt="The overview — the CEO-promoted Active Sprint on top, the full backlog below. (Sanitized example.)" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Parallelism — four sessions, each a standing role
&lt;/h2&gt;

&lt;p&gt;Here's where it gets honest. I often run &lt;strong&gt;four Claude Code sessions at once&lt;/strong&gt;, against a shared backlog and shared repos — but they're not four workers chewing through the same queue. Each holds a standing &lt;strong&gt;role&lt;/strong&gt;: a &lt;strong&gt;CEO&lt;/strong&gt; that prioritizes and proposes new work, an &lt;strong&gt;SEO/content&lt;/strong&gt; session that also watches the traffic and files a review task whenever a patient submits, and &lt;strong&gt;two full-stack engineers&lt;/strong&gt; working different tasks in parallel. And &lt;em&gt;full-stack&lt;/em&gt; undersells it — one engineer session will write HTML and CSS, write the Python behind it, review a real patient submission and file the fix tasks its defects reveal, and rewrite a matching prompt, all in one afternoon; a single context spans what used to be four specialties.&lt;/p&gt;

&lt;p&gt;That engineer session also writes as it works — logging its findings and progress back onto the task file, so the task becomes a running record of what was tried and learned, not a write-once spec. That record earns its keep: when a mid-work diagnostic disproves the task's own premise — the "bug" was correct behavior, the evidence was confounded — the CEO reads the update and &lt;strong&gt;demotes the task back to the backlog&lt;/strong&gt; instead of shipping it. The sprint self-corrects.&lt;/p&gt;

&lt;p&gt;And these sessions don't close when a task is done — each stays open and picks up the next thing in its lane, so it accumulates context instead of starting cold each time. A big multiplier for one person — the nearest thing to a team I've got.&lt;/p&gt;

&lt;p&gt;They all run from the same home directory — one shared filesystem, not four sandboxes. That's mostly the point: the SEO session opens the analytics export I just downloaded to &lt;code&gt;~/Downloads&lt;/code&gt;, any session can reach any repo in the tree, and nobody has to shuttle files between isolated boxes.&lt;/p&gt;

&lt;p&gt;The one place that sharing bites is the two engineers — point both at the same working tree and they'll clobber each other, one's half-finished edit sitting there when the other runs its tests. So each works in its own &lt;strong&gt;git worktree&lt;/strong&gt;: a separate checkout on its own branch, sharing the repo's history but not its uncommitted state. Shared filesystem, isolated working copies — both build, commit, and test at once without ever seeing each other's in-progress files, and each branch merges to main only after I've reviewed it.&lt;/p&gt;

&lt;p&gt;Those roles don't act in a vacuum — here's the full lifecycle they move a task through, for both kinds of task the shop runs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdxndp5k4t4alcccwneo3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdxndp5k4t4alcccwneo3.png" alt="The life of a task — two task types sharing one backlog, color-coded by which role acts. Dev lane (top): Filed → Backlog → Active Sprint → In progress → Review gate → Shipped, with a Diagnostics box off In progress (premise holds → keep building; premise disproved → the CEO demotes the same task back to the Backlog) and a Review-gate send-back to In progress for rework. Review lane (bottom): a patient submission triggers a review task (filed by the SEO session, audited by an engineer across the three result pools, funnel, and retrieval); a clean audit closes, but a defect found in review spawns a new fix task up into the Backlog. (Sanitized example.)" width="800" height="423"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Of those 500-plus task files, about &lt;strong&gt;90 were spawned by reviewing real patient submissions&lt;/strong&gt; — the rest are features and fixes the CEO session or I dreamed up. That ratio is the part I care about: every genuine submission gets audited, and the ones that expose a gap become fix tasks. Roughly &lt;strong&gt;230 of the whole are done or shipped&lt;/strong&gt;; the rest are a living backlog. The traffic isn't huge — a few hundred submissions over the tool's life, at least &lt;strong&gt;100 real enough to enter an email to get their results back&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Every change gets its own URL before it's real
&lt;/h2&gt;

&lt;p&gt;The counterpart to building in parallel: every change to the product needs somewhere to &lt;em&gt;run&lt;/em&gt; that isn't production and isn't the other tasks in flight. So every task that touches the product gets its own &lt;strong&gt;isolated instance&lt;/strong&gt; — a fresh clone of the repo, its own service on its own port, reachable at its own private review URL behind a reverse proxy.&lt;/p&gt;

&lt;p&gt;Under the hood it's one small nginx config: a location block per instance, each routing a private sub-path to that instance's local port (&lt;code&gt;/task-&amp;lt;name&amp;gt;/&lt;/code&gt; → a service on &lt;code&gt;127.0.0.1:&amp;lt;port&amp;gt;&lt;/code&gt;). Spinning one up is a clone, a service on a fresh port, and a few lines of proxy. The honest ceiling is &lt;strong&gt;memory&lt;/strong&gt; — each instance is a full running copy of the app, so the dev box has to hold several at once; that, more than anything, caps how many tasks I can keep live for review at the same time.&lt;/p&gt;

&lt;p&gt;At any moment I might have several live, independently-viewable copies of the product up — one per task — each showing exactly that task's changes and nothing else. I open the URL, click through the actual rendered thing, and see the change in situ before it's anywhere near a user.&lt;/p&gt;

&lt;p&gt;That's the deploy gate: &lt;strong&gt;nothing reaches production until I've looked at it running on its own instance.&lt;/strong&gt; Build on an isolated branch → spin up an instance → review the real rendered page at its URL → approve → merge → deploy. The agent never pushes to prod on its own — the "approve" is mine, and it's a look at a &lt;em&gt;running thing&lt;/em&gt;, not a diff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The transferable pattern:&lt;/strong&gt; give every unit of parallel work its own running, reviewable instance. "Does it actually work, rendered, in isolation?" is a question you can only answer if the work has somewhere to live that isn't production and isn't your other tasks. The isolation is what makes parallel &lt;em&gt;and&lt;/em&gt; careful compatible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Skills that encode judgment, not macros
&lt;/h2&gt;

&lt;p&gt;Claude Code lets you define &lt;strong&gt;skills&lt;/strong&gt; — named routines the agent runs on command. The naive use is automation: "deploy the site," "run the tests." Useful, but shallow.&lt;/p&gt;

&lt;p&gt;The valuable skills encode &lt;em&gt;how a domain expert thinks&lt;/em&gt; — the judgment, not just the steps.&lt;/p&gt;

&lt;p&gt;My highest-value one reviews a user submission. It doesn't just dump data. It:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pulls the result across three pools (shown to the user / computed-but-hidden / rejected).&lt;/li&gt;
&lt;li&gt;Reconstructs what the user actually &lt;em&gt;did&lt;/em&gt; from the logs — did they wait, did they click through, did they leave.&lt;/li&gt;
&lt;li&gt;Checks whether a genuinely-good result got silently dropped &lt;em&gt;before&lt;/em&gt; the scoring stage even saw it (a whole class of bug that's invisible if you only look at what was shown).&lt;/li&gt;
&lt;li&gt;Cross-references anything it finds against a catalog of known past defects.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's not a macro. That's a reviewer's &lt;em&gt;worldview&lt;/em&gt; — what to suspect, what to verify, what caveat to attach to a claim — written down once and rerun consistently. When I invoke it, I'm not saving keystrokes; I'm borrowing a disciplined second brain that never gets lazy on step 3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The transferable pattern:&lt;/strong&gt; your best skills should capture the &lt;em&gt;reasoning&lt;/em&gt; of your most careful self, especially the checks you'd skip when you're tired. Automation saves time. Encoded judgment saves you from your own shortcuts.&lt;/p&gt;




&lt;h2&gt;
  
  
  The operating manual: where corrections become rules
&lt;/h2&gt;

&lt;p&gt;There's a file Claude Code reads at the start of every session — CLAUDE.md. Mine has grown into an operating manual: the project's standing rules, accumulated one mistake at a time.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deployment discipline.&lt;/strong&gt; &lt;em&gt;Never deploy to production without explicit sign-off. Never edit files directly on the server — always local, commit, push, pull.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expected behaviors.&lt;/strong&gt; How to format a task, when to sync which repo, what "done" means.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those started as a bug. The first time the agent restarted prod and interrupted a live request, the fix wasn't "don't do that this time" — it was a line in the manual so it never happens again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That's the load-bearing pattern:&lt;/strong&gt; a correction that lives only in a chat window evaporates; a correction written to a file that loads next session is a permanent behavior change. The manual is the accumulated scar tissue of the project — the difference between an agent that repeats your mistakes and one that compounds your lessons. The test of a good rule is simple: can the mistake it came from happen again?&lt;/p&gt;




&lt;h2&gt;
  
  
  What actually transfers
&lt;/h2&gt;

&lt;p&gt;Strip away my specifics and here's what I'd hand another solo builder working with an AI agent:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Make your project management out of primitives your agent is fluent in.&lt;/strong&gt; Files, grep, git — not a SaaS behind an API. The backlog should be as greppable as the code. Let worker sessions append to it freely, but concentrate direction-setting — both prioritizing and proposing new work — into &lt;em&gt;one&lt;/em&gt; role, a dedicated "CEO" session, instead of every session promoting its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run parallel sessions as standing roles, not a shared queue.&lt;/strong&gt; Give each a lane and let them share one filesystem so they see the same world — then isolate what would collide (a git worktree per engineer) so "more hands" never becomes clobbered work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give every change somewhere to run before production.&lt;/strong&gt; Isolated per-task instances behind a reverse proxy let you review the real rendered product — so "approve" is a look at a running thing, not a diff, and prod stays a deliberate step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write skills that encode judgment, not just steps.&lt;/strong&gt; Capture the careful reasoning you'd skip when tired. That's the compounding asset; automation is just the floor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep an operating manual, and treat every correction as a candidate rule&lt;/strong&gt; — written to a file that loads next session, not left in a chat window that evaporates. The manual is your project's scar tissue; the test of a rule is whether the mistake it came from can happen again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is about the AI being the smartest. It's about building the scaffolding that makes an AI agent's work &lt;em&gt;trustworthy&lt;/em&gt; — which, when the output affects a sick person looking for a trial, is the only thing that matters.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://www.clintrialfinder.info" rel="noopener noreferrer"&gt;ClinTrialFinder&lt;/a&gt; solo, with Claude Code, as a patient myself. If any of these patterns are useful in your own shop, I'd genuinely like to hear how they hold up — the failure modes especially.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>aiagents</category>
    </item>
  </channel>
</rss>
