<?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: Seth Wheeler</title>
    <description>The latest articles on DEV Community by Seth Wheeler (@megapixel99).</description>
    <link>https://dev.to/megapixel99</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%2F4078131%2F27de8464-506f-4452-b786-105e5cbb74b7.png</url>
      <title>DEV Community: Seth Wheeler</title>
      <link>https://dev.to/megapixel99</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/megapixel99"/>
    <language>en</language>
    <item>
      <title>Giving an Open-Weight Language Model the Vote in Among Us Meetings</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Fri, 16 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/giving-an-open-weight-language-model-the-vote-in-among-us-meetings-3kce</link>
      <guid>https://dev.to/megapixel99/giving-an-open-weight-language-model-the-vote-in-among-us-meetings-3kce</guid>
      <description>&lt;p&gt;Meetings are the part of Among Us that a language model looks made for. The crew is trying to find the few hidden impostors among them. When a body is found, everyone talks, states where they were, accuses someone and votes, and whoever gets the most votes is thrown off the ship. My simulator's bots normally vote with small recurrent networks trained on thousands of meetings, which read who saw whom and who accused whom and output a suspicion score for each player. I expected that handing the meeting to a real language model, one that could argue, ask questions and catch lies, would make the crew better at finding impostors. Across six setups it never did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The model is gpt-oss:20b, an open-weight model from OpenAI, running locally through &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;. In a meeting every surviving player, crewmate or impostor, is a separate prompt to the same model, and its reply is that player's speech and vote. A crewmate's prompt contains only what that player could know: where it walked, who it saw and when, any kill or vent it witnessed, and the table of where everyone claims to have been. An impostor's prompt also contains its real movements, marked secret, and permission to lie. Movement, tasks and kills between meetings stay with the trained bots; only the meetings change.&lt;/p&gt;

&lt;p&gt;There were three meeting formats. In the first, players speak in rounds and may ask each other structured questions ("where were you 20 seconds ago?"). In the second, each crewmate's prompt also includes the voting network's guesses as an "instinct" (described below). In the third, players hold a free conversation in turns, again with the instinct.&lt;/p&gt;

&lt;p&gt;The comparison is the same bots in the same lobby with the trained networks doing all the voting, crew and impostors alike, over 300 games. So the language model replaces both sides' votes at once, and a worse result for the crew could come from worse crew votes or from better impostor talk; the runs below cannot tell those apart. I measured the baselines after the language-model runs, on the current version of the simulator. Five of the six language-model runs used an earlier version, which I mark. The bots come from different leagues (training runs), and each league trained its own voting network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eight players, two impostors
&lt;/h2&gt;

&lt;p&gt;These used the bots from my second league. The network baseline did not record wrongly ejected crewmates.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;who votes&lt;/th&gt;
&lt;th&gt;games&lt;/th&gt;
&lt;th&gt;crew wins&lt;/th&gt;
&lt;th&gt;impostors ejected per game&lt;/th&gt;
&lt;th&gt;crewmates ejected per game&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the trained networks (current version)&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;52.0%&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;td&gt;not recorded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss, rounds and questions (earlier version)&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;2 (12.5%)&lt;/td&gt;
&lt;td&gt;0.06&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss, rounds and questions plus the instinct (earlier version)&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;8 (50%)&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss, conversation plus the instinct (earlier version)&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;6 (37.5%)&lt;/td&gt;
&lt;td&gt;0.81&lt;/td&gt;
&lt;td&gt;0.69&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On its own the language model did much worse than the networks. The crew ejected an impostor once in sixteen games and an innocent crewmate almost every game, and won 2 games of 16 where the networks' crew wins about half. That gap is well beyond chance (one-sided Fisher exact test, p = 0.0017), with the caveat that the simulator version differed.&lt;/p&gt;

&lt;p&gt;The "instinct" put the voting network's numbers into each crewmate's prompt as a line of text. It opens "Your instinct (a trained model reading everything YOU saw, plus the accusations so far; it picks the right impostor about 70% of the time, so weigh it with the facts):" and then gives each other player's percentage chance of being an impostor. The 70% in that line was my rough estimate when I wrote the prompt, not a measurement. With the instinct and the rounds format, the crew won 8 of 16, indistinguishable from the networks (p = 0.54). If the model mostly voted the way the instinct line pointed, that is the result you would expect; I did not measure how often it did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fifteen players, three impostors
&lt;/h2&gt;

&lt;p&gt;In these lobbies the crew wins more often under both kinds of voting, and the ordering held. All three runs use the conversation format with the instinct.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;bots&lt;/th&gt;
&lt;th&gt;networks voting (300 games, current version)&lt;/th&gt;
&lt;th&gt;gpt-oss, conversation plus the instinct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;league 2&lt;/td&gt;
&lt;td&gt;89.7%&lt;/td&gt;
&lt;td&gt;10 of 12 (83%), earlier version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;league 4&lt;/td&gt;
&lt;td&gt;84.7%&lt;/td&gt;
&lt;td&gt;11 of 20 (55%), earlier version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;league 6&lt;/td&gt;
&lt;td&gt;93.3%&lt;/td&gt;
&lt;td&gt;16 of 20 (80%), current version&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The league 6 row is the only like-for-like comparison: same bots, same simulator version, same settings, run on the same day. Sixteen of 20 against 280 of 300 is a one-sided Fisher exact p of 0.052, and the 95% interval on 16 of 20 is 58% to 92%. On its own, that row is suggestive rather than settled. The league 4 gap is far larger (p = 0.0025), but between that run and its baseline I changed how meetings treat a running sabotage and what impostors know when they shut doors, so I would not lean on it.&lt;/p&gt;

&lt;p&gt;Counting across all six setups: in one the language model was clearly worse than the networks (eight players, no instinct). In the five with the instinct it matched the networks once and fell short four times, by margins that are individually too small or too confounded to be conclusive. I have no setup in which it did better.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;A 15-player game with gpt-oss meetings took 524 seconds of wall-clock time on average in the league 6 run. Nearly all of that was about 4 meetings' worth of turns, at one model call per player per turn. The networks' votes need no model calls at all. That would be a fair price for better votes, and the votes were not better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I think it happens
&lt;/h2&gt;

&lt;p&gt;This part is my reading rather than a measurement. The networks have an unfair advantage that is also the point: they were trained on the outcomes of thousands of meetings in this simulator, so their suspicion scores are calibrated against who actually turned out to be an impostor here. The language model brings general knowledge of how deception sounds. An impostor here does not sound like anything; it names a room, and the only way to catch the lie is to check that claim against who could have seen that room. The prompts contain what each player saw. Nothing in the results suggests the model made that check reliably, and with the network's numbers in the prompt it did no better than the network.&lt;/p&gt;

&lt;p&gt;An earlier benchmark of mine, which &lt;a href="https://sethwheeler.dev/blog/ranking-models-on-spotting-liars/" rel="noopener noreferrer"&gt;ranked language models on spotting liars in Mafia&lt;/a&gt;, found first-day accusations at chance. There, on the first day, nothing about the liars is knowable yet. Here the evidence is in each crewmate's prompt, which makes falling short of a small network harder to excuse.&lt;/p&gt;

&lt;p&gt;The simulator, the meeting prompts and the measurement scripts were written by Claude Code at my direction. The code is not public. Every figure above comes from the result files the runs wrote, and the significance tests are recomputed from those counts.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>measurement</category>
      <category>statistics</category>
      <category>node</category>
    </item>
    <item>
      <title>Rebuilding the Among Us Floor Plan From the Game's Own Collision Data</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Thu, 15 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/rebuilding-the-among-us-floor-plan-from-the-games-own-collision-data-56ng</link>
      <guid>https://dev.to/megapixel99/rebuilding-the-among-us-floor-plan-from-the-games-own-collision-data-56ng</guid>
      <description>&lt;p&gt;I train Among Us bots in a simulator of the game. A crew walks the ship doing tasks and votes out suspects in meetings. Hidden impostors kill, travel through vents (tunnels only impostors can use) and sabotage the ship, including shutting a room's doors for 10 seconds. The bots learn by self-play in leagues, each league a roughly ten-hour training run that starts from the bots the previous league ended with.&lt;/p&gt;

&lt;p&gt;The simulator decides where players can stand with a grid of 126 by 70 tiles. The grid comes from the map file of &lt;a href="https://github.com/arizkhalid/among-us-clone" rel="noopener noreferrer"&gt;an open-source web clone of the game&lt;/a&gt;, MIT-licensed and written by a third party. I used it because it was there and it matched the ship's layout room for room. It is also wrong, in a way I only saw once the replays were drawn on the real game's art: a crewmate jogging along the top of a corridor with its feet on the corridor's back wall. Measured against the game's own collision data, the clone's floor is 1.75 times too big.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the real floor comes from
&lt;/h2&gt;

&lt;p&gt;Among Us is a Unity game, and the 2018 Android build ships its levels as Unity asset files inside the APK (the Android app package). The ship is a single prefab (a saved Unity object tree) called ShipStatus. Alongside the artwork, it carries the colliders the physics engine uses to stop players walking through things. I read them out of the APK with &lt;a href="https://github.com/K0lb3/UnityPy" rel="noopener noreferrer"&gt;UnityPy&lt;/a&gt;, a third-party Python library for Unity asset files, without running the game. There are 79 wall outlines, which are edge colliders along the edge of every room and corridor. There are also 33 solid obstacles: the Cafeteria tables, the MedBay beds, the engines, Navigation's chairs and a few task consoles.&lt;/p&gt;

&lt;p&gt;The player prefab supplies the other number that matters. A player is a 0.7-scaled object with a circular collider of radius 0.2234 at its feet. So the centre of a player's feet can never get closer than 0.156 units (Unity's unit of distance) to a wall or an obstacle. One unit is 2.646 of my simulator's tiles (from aligning a render of the ship with the clone's map), which makes the clearance 0.41 tiles.&lt;/p&gt;

&lt;p&gt;The rebuild draws every outline and obstacle into a grid eight times finer than the simulator's tiles. It flood-fills the inside of the ship from a known floor point, then removes everything within the foot clearance of a wall or obstacle. A tile is walkable if its centre survives. Two details mattered. The outlines had to be drawn three sub-cells thick, because two outlines meeting at a corner can leave a gap finer than one sub-cell; the first version's flood fill escaped through one and filled the outside of the ship. And 56 tiles of floor that were not connected to the rest were dropped. The build would also force each of the 14 vent grilles onto the floor, since the game snaps a player's feet onto a grille to enter it, but in the final build none needed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How wrong the old floor was
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;walkable tiles&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the clone's map&lt;/td&gt;
&lt;td&gt;3,314&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the game's colliders&lt;/td&gt;
&lt;td&gt;1,894&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;on both&lt;/td&gt;
&lt;td&gt;1,867&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;on the clone's map only&lt;/td&gt;
&lt;td&gt;1,447&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;on the game's colliders only&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every room shrank, unevenly. Eight of the 21 rooms and corridors, the worst and best by the ratio of old to new walkable tiles:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;room&lt;/th&gt;
&lt;th&gt;old&lt;/th&gt;
&lt;th&gt;real&lt;/th&gt;
&lt;th&gt;old / real&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;O2&lt;/td&gt;
&lt;td&gt;98&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;3.06&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weapons&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;td&gt;2.31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shields&lt;/td&gt;
&lt;td&gt;133&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;td&gt;2.29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upper Engine&lt;/td&gt;
&lt;td&gt;110&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;2.29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cafeteria&lt;/td&gt;
&lt;td&gt;658&lt;/td&gt;
&lt;td&gt;351&lt;/td&gt;
&lt;td&gt;1.87&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MedBay&lt;/td&gt;
&lt;td&gt;142&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;td&gt;1.35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;central hall&lt;/td&gt;
&lt;td&gt;253&lt;/td&gt;
&lt;td&gt;196&lt;/td&gt;
&lt;td&gt;1.29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;162&lt;/td&gt;
&lt;td&gt;147&lt;/td&gt;
&lt;td&gt;1.10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is what the replays suggested. In a corridor, the clone's map counts the whole drawn height as floor. That includes the band of wall panels along the top, which the game draws as the back wall, so a corridor loses that band plus the foot clearance on each side. Rooms full of furniture lose the furniture; on the clone's map you could walk straight across the Cafeteria tables. Storage, an open room with crates mostly along its edges, barely changed.&lt;/p&gt;

&lt;p&gt;Before switching, I checked that nothing the simulator depends on fell off the new floor. All 14 vents are on walkable tiles. Every task station moved by at most 3 tiles, to the nearest real floor in its own room. Each of the 7 rooms with doors still cannot be walked into from outside while its doors (13 in all) are shut. Impostors can still vent into some of them, as in the game.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much it changed the game
&lt;/h2&gt;

&lt;p&gt;I do not know precisely, which is the honest answer. The bots were trained on the old floor and played on the new one without retraining. I played 40 games on each floor with the bots from my fourth league, 15 players and 3 impostors, using a fixed random seed so the run repeats exactly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;floor&lt;/th&gt;
&lt;th&gt;crew wins&lt;/th&gt;
&lt;th&gt;tasks done&lt;/th&gt;
&lt;th&gt;kills per game&lt;/th&gt;
&lt;th&gt;vents per game&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;clone's map&lt;/td&gt;
&lt;td&gt;36 of 40&lt;/td&gt;
&lt;td&gt;69%&lt;/td&gt;
&lt;td&gt;5.8&lt;/td&gt;
&lt;td&gt;3.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;game's colliders&lt;/td&gt;
&lt;td&gt;34 of 40&lt;/td&gt;
&lt;td&gt;65%&lt;/td&gt;
&lt;td&gt;6.0&lt;/td&gt;
&lt;td&gt;3.6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two games in 40 is within what a different seed could produce. But an earlier unseeded test, also 40 games each, with the same league's bots taken at an earlier checkpoint and an earlier version of the simulator, measured the crew at 78% on the old floor and 68% on the new one. Forty games cannot separate a 2-point effect from a 10-point one, so the effect is somewhere in that range. Either way it is far smaller than a factor of 1.75 in walkable floor suggests. My guess at why is that the bots decide between rooms rather than between tiles. A room's neighbours, its vents and the walking distance between rooms are close to unchanged, so the floor mostly changes where a player stands inside a room rather than where it goes.&lt;/p&gt;

&lt;p&gt;I switched anyway. The simulator is meant to model the real game, and the old floor was visibly wrong in every replay, whatever it did to the win rate. Training since then has used the real floor, and the old one remains available for comparison with older runs. My fifth league, the first to train on the new floor, later collapsed for reasons I could not separate from the floor change, which is &lt;a href="https://sethwheeler.dev/blog/among-us-reactor/" rel="noopener noreferrer"&gt;a separate post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The simulator, the extraction and the rebuild were written by Claude Code at my direction, and the corridor wall-walking was something I spotted in the replays. The code is not public, and the extracted game data and art stay on my machine. The figures above come from the rebuild script's output and a seeded comparison script; anyone with the 2018 APK and UnityPy can read the same colliders.&lt;/p&gt;

&lt;p&gt;What I take from it is that a simulator can be wrong by a factor of 1.75 in a quantity that sounds fundamental and still produce similar outcomes. Outcome metrics are a poor check on whether a simulator is faithful. The check that found this was looking at the picture.&lt;/p&gt;

</description>
      <category>measurement</category>
      <category>reinforcementlearning</category>
      <category>node</category>
    </item>
    <item>
      <title>Asking Trained Among Us Bots Why They Moved</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Wed, 14 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/asking-trained-among-us-bots-why-they-moved-55nf</link>
      <guid>https://dev.to/megapixel99/asking-trained-among-us-bots-why-they-moved-55nf</guid>
      <description>&lt;p&gt;My Among Us bots can explain themselves. Every time a crewmate picks a room to walk to, the replay records why, and the exported videos show it as a thought bubble over the player: "→ Admin: next task: Swipe Card (Admin); avoiding Pink (I suspect them) in Weapons". For the crew, the arithmetic behind those captions is exact to about fourteen decimal places. The captions were still wrong in three different ways, and one of them made a collapse in training look, on video, like normal play.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the explanation can be exact
&lt;/h2&gt;

&lt;p&gt;The bots are trained by self-play, in leagues: each league is a roughly ten-hour training run that starts from the bots the previous league ended with. A crewmate's walking network does not output a choice directly. At each step it is offered the neighbouring rooms, and staying put. Each option is described by the same dozen inputs, such as how much closer it gets to the next task, how many people are visible there, how suspected the most suspected person there is, and how much closer it gets to fixing a running sabotage. The network looks at what the crewmate has seen and outputs one weight per input. Each option's score is the plain weighted sum of its inputs, and the highest score is the most likely pick.&lt;/p&gt;

&lt;p&gt;That structure makes "why A and not B" a question with an exact answer. If option A scores s_A and the runner-up B scores s_B, then:&lt;/p&gt;

&lt;p&gt;s_A − s_B = Σ_i (input_A,i − input_B,i) × weight_i&lt;/p&gt;

&lt;p&gt;So the lead A has over B splits into one part per input, and the parts add back up to the lead with nothing left over. The caption takes the two largest positive parts and turns each into a phrase. There is no approximation step and no separate model of the bot's behaviour, which most explanation methods for neural networks need. (The impostors' walking adds a learned map term that is not a sum over named inputs, so their captions lump that part together as "what it has learned about the map". Everything below about exactness is about the crew.)&lt;/p&gt;

&lt;p&gt;To check that the split really is exact in the code and not just on paper, I wrote a probe. It plays 30 games with a league's saved bots, using a fixed random seed so the run repeats exactly, and for every crew move decision it compares the score gap with the sum of the parts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;crew&lt;/th&gt;
&lt;th&gt;decisions&lt;/th&gt;
&lt;th&gt;largest error in the split, in score units&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;league 3&lt;/td&gt;
&lt;td&gt;80,550&lt;/td&gt;
&lt;td&gt;7.11e-15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;league 4&lt;/td&gt;
&lt;td&gt;40,433&lt;/td&gt;
&lt;td&gt;7.11e-15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;league 6&lt;/td&gt;
&lt;td&gt;34,226&lt;/td&gt;
&lt;td&gt;1.42e-14&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The median gap between the chosen option and the runner-up was between 6.3 and 9.8 in the same units, depending on the league, so these errors are floating-point rounding. The arithmetic was never the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong way one: the sign of the weight
&lt;/h2&gt;

&lt;p&gt;The first captions had one phrase per input, chosen when that input's part was large and positive. For the suspicion input the phrase was "keeping an eye on Red". That reads as "I am going where my suspect is", and it is only true if the weight on suspicion is positive.&lt;/p&gt;

&lt;p&gt;For the league 3 crew it mostly was not. Over 19,085 decisions with a suspected player in view, its weight on suspicion averaged -3.94 (10th percentile -6.73, 90th -0.45), so it was negative in at least nine decisions in ten: a lean away from people it suspected. That lean was usually outweighed by other inputs, such as the next task. Where the chosen room and the runner-up differed in suspicion, the crew picked the more suspicious one 11,041 times and the less suspicious one 3,348 times. But whenever the weight was negative, a positive suspicion part meant the chosen room was &lt;em&gt;less&lt;/em&gt; suspicious than the runner-up, so "keeping an eye on Red" was saying the opposite of what the number meant. The caption now checks the sign, and says "avoiding Red (I suspect them) in Weapons" when the weight is negative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong way two: relative is not a direction
&lt;/h2&gt;

&lt;p&gt;Fixing the sign was not enough. The part for an input is a difference between two options, and a caption reads as an absolute statement about one.&lt;/p&gt;

&lt;p&gt;My fifth league collapsed: its impostors learned to sabotage the reactor every game, and its crew stopped fixing it (&lt;a href="https://sethwheeler.dev/blog/among-us-reactor/" rel="noopener noreferrer"&gt;written up separately&lt;/a&gt;). The replays from that league were full of crewmates "fixing the reactor" while walking the other way. Four replays survive from the end of that run, at games 57,500, 60,000, 62,500 and 65,000; one of them never had a reactor sabotage. In the other three I counted every crew move captioned "fixing the reactor" while a reactor sabotage was running. For each one I compared how many rooms the destination was from the reactor with how many the crewmate's starting room was:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;destination compared with where it stood&lt;/th&gt;
&lt;th&gt;moves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;closer to the reactor&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same distance&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;farther from the reactor&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Forty-five of 61 "fixing the reactor" moves went farther away. The explanation was right every time: once its weight is applied, the chosen room did beat the runner-up on the "closer to the fix" input. That can happen in two ways. Either the weight had turned negative, or the runner-up was even farther from the reactor than the chosen room, and I did not count which. Either way, "better than the runner-up" and "toward the reactor" are different claims, and a person watching the video reads the second. That is how a crew that had stopped fixing sabotage looked, in the replays, like a crew that was fixing it.&lt;/p&gt;

&lt;p&gt;There are four "closer to X" inputs: the next task, a sabotage fix, the lights fix, and the emergency button. Their captions now say "away from the reactor fix" (and so on) whenever the weight is negative. That covers the first way and not the second: a crewmate that picks the less bad of two moves away from the reactor is still captioned "fixing the reactor". The collapse itself was confirmed by measuring the weight directly, not by reading captions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong way three: what the bot saw is not what was there
&lt;/h2&gt;

&lt;p&gt;The impostors explain their door sabotage too, and these captions used to give a headcount: "shut the Cafeteria doors", followed by how many crewmates were inside. The morning I found the collapse, I had changed the simulator so that impostors choose doors using only the crewmates they can see, after noticing they had been choosing with knowledge of every room's true headcount. The decision was now right, and the caption still stated what the impostor saw as a fact about the room. It now says "I can see 5 crewmates in there".&lt;/p&gt;

&lt;p&gt;Counting across the current replays from the sixth league: 39 door captions give a headcount. In 38 of them the number equals what the impostor could see from where it stood; in 13 the room actually held more crewmates than that. In the game at the end of the run, Yellow shuts the Cafeteria doors at tick 240 (a tick is about half a second) with "I can see 5 crewmates in there", and there are 7. The same rewording went into the crew's "nobody in engine hall", now "I can't see anyone in engine hall", and the impostors' "Lime is alone in Admin", now "I can see only Lime in Admin".&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would check in any explanation like this
&lt;/h2&gt;

&lt;p&gt;All three failures were in the last step, turning a number into words, and none of them would show up in a test of the arithmetic. An exact attribution still has three hidden qualifiers: it is relative to a specific alternative, it is signed, and it is conditioned on what the agent observed. A caption that drops any of them makes a different claim from the one the numbers support. The check that caught the second failure was the cheap one. Take the words literally, write down what would be true in the world if they were right (the crewmate gets closer to the reactor), and count how often it was.&lt;/p&gt;

&lt;p&gt;The simulator, the explanation code and the probes were written by Claude Code at my direction. The reactor captions came to light because I was watching the replays, and the bots' explanations did not match where they were walking. The code is not public. The probes are single scripts over saved leagues and replays, and every figure above is their output.&lt;/p&gt;

</description>
      <category>reinforcementlearning</category>
      <category>measurement</category>
      <category>verification</category>
      <category>node</category>
    </item>
    <item>
      <title>Training Among Us Bots: The Crew That Stopped Fixing the Reactor</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Tue, 13 Oct 2026 10:09:48 +0000</pubDate>
      <link>https://dev.to/megapixel99/training-among-us-bots-the-crew-that-stopped-fixing-the-reactor-28lc</link>
      <guid>https://dev.to/megapixel99/training-among-us-bots-the-crew-that-stopped-fixing-the-reactor-28lc</guid>
      <description>&lt;p&gt;I train Among Us bots by self-play: the crew and the impostors are both learners, and they play tens of thousands of games against each other and against saved copies of their earlier selves. I run the training in leagues, each one a roughly ten-hour run that starts from the bots the previous league ended with. The fifth league looked healthy for ten thousand games and then fell over. Between game 10,000 and game 12,500, the impostors' win rate in their own training games went from 50% to 75%, the crew's tasks went from 70% done to 41%, and the impostors almost stopped killing (6.52 kills a game down to 2.00). They had found something better than killing: sabotaging the reactor. The run went on for another 52,500 games and never recovered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The game, briefly
&lt;/h2&gt;

&lt;p&gt;Among Us puts a crew of crewmates and a few hidden impostors on a spaceship. The crew wins by finishing its tasks (each a short errand at a console, done in steps) or by voting every impostor out in meetings. The impostors win by killing until they match the crew in number. They can also travel through vents, shut a room's doors for 10 seconds, and sabotage the ship. Lights and comms sabotages are nuisances; the reactor and O2 are critical. When the reactor is sabotaged, two crewmates must reach it and hold it within 30 seconds, or the reactor melts down and the impostors win (O2 is the same idea with two panels in different rooms). My simulator follows the 2018 version of the game on its first map, the Skeld, with 15 players and 3 impostors.&lt;/p&gt;

&lt;p&gt;A crewmate's walking is decided by a small network. At every step it is offered the rooms next to it, and staying put, and it scores each option as a weighted sum of a dozen inputs. Two of those inputs matter here: "this room is closer to my next task" and, during a sabotage, "this room is closer to the fix". Both are measured in the same unit, rooms along the ship's corridors, so a 1 means one room closer. The weights are not fixed: a recurrent network computes them each step from what the crewmate has seen, and training (PPO, a standard reinforcement learning method) adjusts that network. The option with the highest score is the most likely pick, so the weight on an input is a direct reading of how much the crew cares about it, and two weights on the same unit can be compared.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the crew stopped doing
&lt;/h2&gt;

&lt;p&gt;Sabotaging the reactor should not keep working against a crew that fixes it, so the obvious suspect was the crew. I wrote a probe that loads a league's saved crew and plays 40 games against the simulator's scripted impostors, so every crew faces the same opponents. Whenever a reactor or O2 sabotage is running, it records the crew network's weights on the two inputs above, and whether the crewmate picks the option that actually gets closer to the fix. It also counts how many of those sabotages get fixed. The probe uses a fixed random seed, so it gives the same numbers on every run.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;crew&lt;/th&gt;
&lt;th&gt;weight on "closer to the fix" (10th to 90th percentile)&lt;/th&gt;
&lt;th&gt;weight on "closer to my next task"&lt;/th&gt;
&lt;th&gt;steps toward the fix&lt;/th&gt;
&lt;th&gt;reactor/O2 sabotages fixed&lt;/th&gt;
&lt;th&gt;games lost to a meltdown&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;league 4 (the run before)&lt;/td&gt;
&lt;td&gt;5.79 (4.47 to 7.57)&lt;/td&gt;
&lt;td&gt;1.15&lt;/td&gt;
&lt;td&gt;90% of 494&lt;/td&gt;
&lt;td&gt;15 of 18&lt;/td&gt;
&lt;td&gt;0 of 40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;league 5 (collapsed)&lt;/td&gt;
&lt;td&gt;1.62 (-0.23 to 3.47)&lt;/td&gt;
&lt;td&gt;6.60&lt;/td&gt;
&lt;td&gt;32% of 399&lt;/td&gt;
&lt;td&gt;0 of 7&lt;/td&gt;
&lt;td&gt;3 of 40&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The weights are averages over every crewmate decision taken while a reactor or O2 sabotage was running, and the step counts are those decisions. League 4's crew, during a sabotage, valued getting one room closer to the fix five times as much as getting one room closer to its next task. League 5 started from league 4's crew and turned that around: it valued its next task 4.1 times as much as the fix. A crew that steps toward the fix one time in three does not get two people to the reactor in 30 seconds. League 5's crew met fewer sabotages (7) because its games went differently; of the seven, three melted down, and the other four were still running when the game ended some other way, mostly by the impostors outnumbering the crew.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it unlearned it
&lt;/h2&gt;

&lt;p&gt;The crew's reward is the game result (1 for a win, 0 for a loss), plus 0.3 times the share of the crew's tasks that got done, plus 0.1 for being alive at the end, plus 0.15 for every task step a crewmate completes itself. Fixing a sabotage appears nowhere in that list. It pays only indirectly, by keeping the game going so that the result and the tasks can still pay later.&lt;/p&gt;

&lt;p&gt;That is enough while fixes usually succeed. Once the impostors sabotage the reactor every game and some fixes fail, walking to the reactor starts to look like a bad trade. A crewmate who goes gives up task steps that pay 0.15 each, and if the fix fails anyway, the game is lost whatever it did. Learning then lowers the weight on the fix, fewer crewmates go, more fixes fail, and the weight drops further. The impostors' side of the same loop is simpler. Every failed fix is a win, so they sabotaged more (1.77 reactor sabotages a game at 12,500 games, against 0.28 at 10,000) and killed less, because killing had stopped being necessary.&lt;/p&gt;

&lt;p&gt;League 4 had reached the same edge and come back. Its impostors used the reactor from early on (0.71 sabotages a game at 2,500 games) and peaked at 1.25 at game 10,000, then dropped to 0.04 by game 20,000, so its crew must have kept fixing often enough for the trade to stay worth it. League 5 differed from league 4 in two ways: a new input to the crew's walking network ("this room is closer to where I last saw the player I suspect most"), and a floor plan of the ship rebuilt to match the real game, with narrower rooms. I cannot tell which of them, if either, tipped it over. What I can say is that the reward never pointed at the fix directly. The evaluations show the result. Every 10,000 games each league plays 100 games against a fixed set of opponents, and at every checkpoint after the start, league 5's crew beat the league 4 impostor less often than league 4's own crew did (54% against 78% at game 60,000).&lt;/p&gt;

&lt;h2&gt;
  
  
  Paying for the fix
&lt;/h2&gt;

&lt;p&gt;The sixth league started again from league 4's bots and added the missing term. Every crewmate standing at the fix when a reactor or O2 sabotage is fixed gets 0.3, about two task steps' worth, and lights and comms pay 0.1. I also put league 5's reactor-spamming impostor in the pool of opponents, so the new crew had to learn to beat it. Finally, I logged the crew's fix rate in every 2,500-game training window, so that a repeat would show up early.&lt;/p&gt;

&lt;p&gt;The same probe on league 6's crew after 33,916 games:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;crew&lt;/th&gt;
&lt;th&gt;weight on "closer to the fix" (10th to 90th percentile)&lt;/th&gt;
&lt;th&gt;weight on "closer to my next task"&lt;/th&gt;
&lt;th&gt;steps toward the fix&lt;/th&gt;
&lt;th&gt;reactor/O2 sabotages fixed&lt;/th&gt;
&lt;th&gt;games lost to a meltdown&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;league 6&lt;/td&gt;
&lt;td&gt;22.68 (17.96 to 27.68)&lt;/td&gt;
&lt;td&gt;3.24&lt;/td&gt;
&lt;td&gt;99% of 747&lt;/td&gt;
&lt;td&gt;30 of 34&lt;/td&gt;
&lt;td&gt;0 of 40&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;During a sabotage, league 6's crew values the fix seven times as much as its next task. In training the crew fixed between 85.6% and 93.9% of the reactor and O2 sabotages in every window. The impostors gave the reactor up almost entirely, at 0.02 reactor sabotages a game or fewer in every window. At the final evaluation the crew beat league 5's reactor-spamming impostor in 93 of 100 games.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not show
&lt;/h2&gt;

&lt;p&gt;League 6 changed more than the reward. It restarted from league 4 rather than continuing league 5, and between the two runs I fixed several simulator bugs that bear on sabotage. Meetings had been ending a running sabotage instead of pausing it. Impostors could see how many crewmates were in a room they were about to lock. Doors could be shut during a sabotage, which the 2018 game does not allow. So the fair statement is that the fix reward plus those changes produced a crew that fixes sabotage, not that the reward alone did. The 30 of 34 is also from one seeded probe of 40 games; a different seed gives a different count.&lt;/p&gt;

&lt;p&gt;It also overshot. League 6's impostors never found a new strategy. They won 17% to 25% of their training games and beat their own crew in only 13 of 100 evaluation games. Human players are the other way round: impostors win 57.69% of the time, according to the game's developers in &lt;a href="https://screenrant.com/among-us-impostor-crewmate-win-rate-stats/" rel="noopener noreferrer"&gt;December 2020&lt;/a&gt;. Getting the bots there by training, rather than by handicapping either side, is the next run.&lt;/p&gt;

&lt;p&gt;The simulator, the training leagues and the probe were written by Claude Code at my direction, and several of the bugs above were ones I found by watching replays. The code is not public. The probe is a single script that loads a saved league and prints the table rows above, and every other figure here comes from the leagues' own training logs.&lt;/p&gt;

&lt;p&gt;The general lesson is about the reward rather than the game. A behaviour that only pays off through a delayed outcome is the first thing to go when an opponent starts punishing it, because the learner sees the cost every time and the benefit only sometimes. If there is an action your agents must keep doing under pressure, it needs a term of its own.&lt;/p&gt;

</description>
      <category>reinforcementlearning</category>
      <category>training</category>
      <category>node</category>
      <category>measurement</category>
    </item>
    <item>
      <title>Giving Evolved Monopoly Players a Memory of Past Trades</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Mon, 12 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/giving-evolved-monopoly-players-a-memory-of-past-trades-5a11</link>
      <guid>https://dev.to/megapixel99/giving-evolved-monopoly-players-a-memory-of-past-trades-5a11</guid>
      <description>&lt;p&gt;My theory was that a Monopoly player who remembers trades should beat one who does not. If a player tried to sell me the brown properties for $60 two turns ago, I can offer $45 now and expect a yes. I evolved two populations of players with identical training budgets, one with a memory of trades and mortgages and one without. The memory players won 56.1% of the games where two of each sat at the table. That looked like the answer until a third population, which barely used its one new input, beat the no-memory players by the same margin in the same two-against-two games.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The players are neural networks evolved with NEAT (NeuroEvolution of Augmenting Topologies). NEAT keeps a population of networks, and each generation the better ones breed and mutate, changing weights and adding connections and nodes. It groups similar networks into species so that new structure is not wiped out before it is tuned; these populations used a version of that grouping adjusted for large starting networks, described in the previous post in this series.&lt;/p&gt;

&lt;p&gt;The game is a simplified four-player Monopoly written for the experiment. It has houses and hotels with the real limit of 32 and 12, mortgages, and all 32 Chance and Community Chest cards. Players can offer to buy or sell a property for cash at 0.5 to 2.5 times its printed price, or swap one property for another. There are no doubles and no auctions, and a bankrupt player's properties go back to the bank. A game still going after 200 rounds goes to the richest player; in the final generation of training, 84% of the memory population's games and 93% of the other's ended in bankruptcy instead.&lt;/p&gt;

&lt;p&gt;Each network sees 44 inputs describing the board and the decision in front of it. It answers every question the game asks (buy this? build? accept this offer? offer how much?) with a yes or no and an amount.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;With memory:&lt;/strong&gt; 13 of the inputs summarise history. They include how often this opponent has accepted my offers, whether they rejected an offer for this property and at what price, whether they tried to sell it in the last 20 rounds and for how much, and recent mortgages. Two more inputs read back two memory slots, which the network writes with two extra outputs at every decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Without memory:&lt;/strong&gt; the identical network with those 15 inputs frozen at "nothing happened" and the slots never written.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both populations had 100 networks and trained for 30 generations by self-play, meaning the networks' opponents were other networks rather than scripted players. Each network played 200 games per generation, and its score was the fraction it won. Most games were against its own population; the rest included members of a hall of fame, the best network from each earlier generation, so that the population kept beating old strategies and not just its current neighbours. Both populations started from the same random seed, so the first generation was identical.&lt;/p&gt;

&lt;p&gt;The champion of each population is its best-scoring network in the final generation. Each champion then played 2,000 games in each of several line-ups: against three copies of a scripted player, against three copies of the other population's champion, and two against two. One scripted player buys and builds everything it can. The "sensible" one buys and builds only while it keeps $300 in cash, and proposes a trade only to complete a color group of its own. With four players, 25% is an even share of wins, and a 2,000-game figure carries a standard error of about one percentage point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first result
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;line-up&lt;/th&gt;
&lt;th&gt;champion's share of wins&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;with memory, against 3 "sensible" players&lt;/td&gt;
&lt;td&gt;27.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;without memory, against 3 "sensible" players&lt;/td&gt;
&lt;td&gt;29.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;with memory, against 3 players that buy everything&lt;/td&gt;
&lt;td&gt;25.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;without memory, against 3 players that buy everything&lt;/td&gt;
&lt;td&gt;24.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 with memory against 3 without&lt;/td&gt;
&lt;td&gt;33.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 without memory against 3 with&lt;/td&gt;
&lt;td&gt;21.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 with memory against 2 without, games won by the memory pair&lt;/td&gt;
&lt;td&gt;56.1% (50% is even)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;with memory, all 15 memory inputs blanked, against 3 "sensible" players&lt;/td&gt;
&lt;td&gt;20.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Against the scripted players memory made no difference. I expected that: they rarely trade and never try to sell, so there is little about them to remember. Head to head, the memory player won a third of its games and the memory pair won 56.1%. Blanking the memory champion's memory inputs dropped it from 27.8% to 20.8% against the scripted players, so it depended on them. A network fed inputs it never saw in training can fail for reasons unrelated to memory, though.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lowball offers
&lt;/h2&gt;

&lt;p&gt;I checked the motivating example directly. Four copies of each champion played 1,000 games at one table, and I split their offers to buy by whether the owner had tried to sell that property in the last 20 rounds. The price is the average offer as a multiple of the printed price.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;champion&lt;/th&gt;
&lt;th&gt;offers after the owner tried to sell&lt;/th&gt;
&lt;th&gt;all other offers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;with memory&lt;/td&gt;
&lt;td&gt;49 offers at 0.52 times the price, 40 accepted (82%)&lt;/td&gt;
&lt;td&gt;69,155 at 0.51 times, 8,483 accepted (12%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;without memory&lt;/td&gt;
&lt;td&gt;74 offers at 1.33 times the price, 0 accepted&lt;/td&gt;
&lt;td&gt;49,965 at 1.05 times, 1,073 accepted (2%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The no-memory player cannot see sale attempts, so its split only reflects which properties tend to be put up for sale. The memory player did not offer less after a sale attempt. It already offered close to the 0.5 floor on everything, so the floor hides any price effect. What changed was acceptance: 82% against 12%. That fits the theory, but it rests on 49 offers. It is also partly a statement about the sellers, who were copies of the same champion and had recently tried to sell.&lt;/p&gt;

&lt;p&gt;Over training, both populations learned to refuse nearly every offer to buy their property. The acceptance rate fell from 44.7% in the first generation to 5.0% in the thirtieth with memory, and from 34.3% to 5.3% without.&lt;/p&gt;

&lt;h2&gt;
  
  
  A memory that predicts
&lt;/h2&gt;

&lt;p&gt;Hand-picked memory features are a guess about what matters. The next test was whether a model reading the raw history could predict an opponent's answer better. I recorded every trade offer from 1,000 games between the two champions and split them by game: 700 games for training the predictors, 300 held back for testing them on games they had never seen. The training games held 137,561 offers for the first seed and 149,307 for the second, with 4.0% and 3.5% accepted. Each predictor used only information the player making the offer could have seen, and was trained and tested on samples balanced to half accepted and half rejected.&lt;/p&gt;

&lt;p&gt;The four predictors differ only in what they are given. The first three are ordinary feed-forward networks. The fourth is an LSTM, a recurrent network that reads events one at a time and carries a state between them. The score is AUC: the chance that the predictor ranks a random accepted offer above a random rejected one, so 0.5 is guessing and 1 is perfect.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;predictor&lt;/th&gt;
&lt;th&gt;test AUC, seed 1&lt;/th&gt;
&lt;th&gt;test AUC, seed 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the offer alone, no history&lt;/td&gt;
&lt;td&gt;0.873&lt;/td&gt;
&lt;td&gt;0.875&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the offer plus the opponent's last 20 events, as 300 separate inputs&lt;/td&gt;
&lt;td&gt;0.963&lt;/td&gt;
&lt;td&gt;0.964&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the offer plus 12 of the hand-made memory features&lt;/td&gt;
&lt;td&gt;0.965&lt;/td&gt;
&lt;td&gt;0.961&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the LSTM reading the last 20 events in order, then the offer&lt;/td&gt;
&lt;td&gt;0.981&lt;/td&gt;
&lt;td&gt;0.982&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;History matters a lot: without it the predictor managed 0.87, and with it every model was above 0.96. The flattened events and the hand-made features came out level, and the LSTM beat both on both seeds, so reading the events as a sequence added something that having them all at once did not. On the balanced test sets the LSTM was right 93.6% of the time on both seeds. (The hand-made set is 12 features rather than 13 because the prediction script left out one of them, the proposer's own recent mortgages.)&lt;/p&gt;

&lt;p&gt;This table is a rerun. A review of this post found that in the first version of the prediction script, two of the 15 numbers describing each event, "is this the same property?" and "how long ago was it?", sat after a code comment on the same line and were always 0. The first run gave the LSTM 0.978 and 0.979 and the flattened events 0.953 and 0.959, which had made the flattened control look worse than the hand-made features. With the fix it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The population that barely used its input
&lt;/h2&gt;

&lt;p&gt;Next I trained a third population, C, with the same budget and seed as the first two. Its hand-made memory inputs were frozen and its memory slots switched off, as in the no-memory population. One input, "how often this player accepts my offers", was replaced by the LSTM's predicted probability that the current offer would be accepted. That input was live only when the network was deciding whether to propose a trade.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;line-up&lt;/th&gt;
&lt;th&gt;C&lt;/th&gt;
&lt;th&gt;memory population, for reference&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;against 3 "sensible" players&lt;/td&gt;
&lt;td&gt;25.0%&lt;/td&gt;
&lt;td&gt;27.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 against 3 of the no-memory population&lt;/td&gt;
&lt;td&gt;28.2%&lt;/td&gt;
&lt;td&gt;33.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 against 2 of the no-memory population, pair's share&lt;/td&gt;
&lt;td&gt;55.8%&lt;/td&gt;
&lt;td&gt;56.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 C against 2 of the memory population, C pair's share&lt;/td&gt;
&lt;td&gt;51.4%&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In two-against-two games C beat the no-memory population by the same margin as the memory population, and it held even with the memory pair. One against three it did less well, 28.2% against 33.0%. Then I froze its LSTM input at 0.5 to see how much it depended on it. Against the scripted players it went from 25.0% to 24.9%, and one live C against three frozen copies won 25.9%. C was barely using its one new input.&lt;/p&gt;

&lt;p&gt;There are at least two reasons it might have ignored a good predictor. The network chooses its offer price in the same step that reads the prediction, so the prediction was always for an offer at the printed price, not the one about to be made. And C used the LSTM from the first, buggy run: in play it received the two event features that had been 0 in its training, so its predictions during C's games were not the ones measured in the table. I have not retrained C.&lt;/p&gt;

&lt;p&gt;That changes what the first result means. In practice C is one more population with next to no memory. It differs from the no-memory population in one input it barely uses and in the path its evolution happened to take. Yet in the two-against-two games it beat the no-memory champion 55.8% to the memory champion's 56.1%. One reading is that the no-memory run simply produced a weaker champion than the other two runs did. With one training run per setup, I cannot separate that from a genuine benefit of memory. The 2,000-game line-ups measure these particular champions precisely. They say nothing about how much champions vary from one training run to the next, and that variation is what needed measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would settle it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Several seeds per population.&lt;/strong&gt; Three or more independent training runs for each setup, compared by the spread of their head-to-head results rather than one champion against one champion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the predictor about the price being offered.&lt;/strong&gt; Giving the network the predicted acceptance at several prices would make the prediction usable for the decision that matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrain C with the fixed predictor, and retrain the predictor on C's own games.&lt;/strong&gt; The LSTM learned from the first two champions, whose habits C's opponents did not share.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code is a learning project and is not public. I wrote the NEAT implementation. Claude Code wrote the Monopoly simulator, the training and comparison scripts and the prediction experiment, and the LSTM code came from another Claude Code session. Every figure here comes from those scripts' saved output.&lt;/p&gt;

&lt;p&gt;A predictor that was right 93.6% of the time did not make a better player, and a two-against-two margin that looked like the effect of memory showed up in a population with next to none. The experiment's direct evidence for the theory is 49 lowball offers, and I would want many more before believing it.&lt;/p&gt;

</description>
      <category>neuroevolution</category>
      <category>node</category>
      <category>measurement</category>
      <category>statistics</category>
    </item>
    <item>
      <title>Why NEAT Stopped Forming Species When the Starting Genome Grew</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Sun, 11 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/why-neat-stopped-forming-species-when-the-starting-genome-grew-56ji</link>
      <guid>https://dev.to/megapixel99/why-neat-stopped-forming-species-when-the-starting-genome-grew-56ji</guid>
      <description>&lt;p&gt;NEAT, short for NeuroEvolution of Augmenting Topologies, evolves a population of neural networks, adding connections and nodes as it goes. A newly added node usually makes a network worse until its weights are tuned, so NEAT sorts the population into species. A network with a new node then competes with its relatives rather than with the whole population, which gives the new structure time to pay off. I had it forming species on XOR and assumed the same code would do the same on a larger problem. On a Monopoly player with 180 starting connections it formed exactly one species for ten generations straight. Nothing about the run looked wrong. It ran, mutated and bred as usual, and the species count simply stayed at 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  How NEAT decides two networks are different
&lt;/h2&gt;

&lt;p&gt;A genome is the list of connections that describes one network, and each connection in it is a gene. Every gene carries an innovation number recording when that connection first appeared anywhere in the population, which lets two genomes be lined up gene by gene. Every genome starts as a copy of the same minimal network, with each input and a bias wired straight to each output. For XOR that is 2 inputs plus a bias to 1 output, 3 genes. For my Monopoly player it is 44 inputs plus a bias to 4 outputs, 180 genes, and every member of the population shares all of them. The &lt;a href="https://nn.cs.utexas.edu/downloads/papers/stanley.ec02.pdf" rel="noopener noreferrer"&gt;original paper&lt;/a&gt; measures the difference between two genomes as a compatibility distance:&lt;/p&gt;

&lt;p&gt;δ = c1·E/N + c2·D/N + c3·W̄&lt;/p&gt;

&lt;p&gt;E counts excess genes and D counts disjoint genes. If one genome has innovations 1 to 5 and the other has 1 to 3 and 7, then 4 and 5 are disjoint (gaps in the middle) and 7 is excess (past the end of the other genome). W̄ is the average weight difference over the genes both share, and N is the gene count of the larger genome. Each generation, a network joins the first species whose representative, a random member of that species from the previous generation, is closer than a threshold; if none is, it founds a new species. Because it is compared with a representative rather than with every other member, a species can hold pairs that are further apart than the threshold. I used the paper's XOR coefficients of 1, 1 and 0.4 and a threshold of 3. The paper also allows N to be 1 when both genomes have fewer than 20 genes, and my implementation does that.&lt;/p&gt;

&lt;p&gt;The division by N is the part that matters here. It makes two large genomes that differ by a few genes count as similar. That makes sense when every gene in a genome was added by evolution, as it nearly is on XOR. It makes much less sense when most of the genes were in the starting genome and every member of the population has them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;I ran the project's NEAT for ten generations on two problems, twice each, from one script with a single fixed random seed. Then I sampled 800 random pairs from each final population and took the median distance. The last column lists the species count after each of the ten generations.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;problem&lt;/th&gt;
&lt;th&gt;starting genes&lt;/th&gt;
&lt;th&gt;N counts&lt;/th&gt;
&lt;th&gt;median distance&lt;/th&gt;
&lt;th&gt;species per generation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;XOR&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;all genes&lt;/td&gt;
&lt;td&gt;7.203&lt;/td&gt;
&lt;td&gt;1 1 3 4 6 7 8 8 10 13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XOR&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;genes added since the start&lt;/td&gt;
&lt;td&gt;8.266&lt;/td&gt;
&lt;td&gt;1 1 1 4 4 5 7 11 13 14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monopoly&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;td&gt;all genes&lt;/td&gt;
&lt;td&gt;0.368&lt;/td&gt;
&lt;td&gt;1 1 1 1 1 1 1 1 1 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monopoly&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;td&gt;genes added since the start&lt;/td&gt;
&lt;td&gt;6.379&lt;/td&gt;
&lt;td&gt;1 1 1 3 7 6 6 6 6 6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The XOR population had 150 networks and was scored on the four XOR cases. The Monopoly population had 100 networks, each with 44 inputs and 4 outputs. Each was scored on eight games against three scripted players that buy everything they land on.&lt;/p&gt;

&lt;p&gt;After ten generations the average Monopoly genome had 181.6 genes, only 1.6 more than it started with. Two genomes typically differed by a handful of genes out of about 180. Even five differing genes, divided by 180, add under 0.03 to the distance, so the median of 0.368 is almost entirely the weight term. For the weight term alone to reach 3, the shared weights would have to differ by 7.5 on average, and they never did. The 90th percentile distance was 0.403, so almost no pair came anywhere near 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change
&lt;/h2&gt;

&lt;p&gt;The question was whether distance should account for the starting genome's size, and the change I made was small. N now counts the genes added since the start rather than all of them:&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;let&lt;/span&gt; &lt;span class="nx"&gt;added&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;baseGenes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;added&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;added&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;baseGenes&lt;/code&gt; is the starting genome's gene count. The NEAT object records it when it is created and passes it to every distance call during speciation, and with a default of 0 the function behaves exactly as before. The Monopoly genomes had added about three genes on average, far short of 20, so N fell to 1 and each added gene counted fully. The median distance went from 0.368 to 6.379, and the population held six species from the sixth generation on.&lt;/p&gt;

&lt;p&gt;On XOR the change does nothing, because the genomes have fewer than 20 genes and N is 1 either way. A test in the project checks that on 100 random pairs of small genomes. The two XOR rows still differ, 7.203 against 8.266 and 13 species against 14, because the seed was set once for the whole script, so the second run drew different random numbers from the first. That difference is what ordinary run-to-run variation looks like. The Monopoly gap is 17 times the distance and five extra species, which variation of that size does not explain.&lt;/p&gt;

&lt;p&gt;A unit test shows the same effect on a smaller network without a full run. It builds a 60-network population from a 54-gene starting genome, grows half of it, and speciates: 31 species with the change and 1 without.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does and does not show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It shows that the species mechanism was switched off, not that turning it back on helps.&lt;/strong&gt; I have not compared Monopoly play from a population with 6 species against one with 1. The paper argues that speciation protects new structure long enough to tune it. Whether that matters here is a separate experiment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The species count can run away the other way.&lt;/strong&gt; With N at 1, every added gene adds a full point to the distance, so a population that keeps adding structure keeps splitting. XOR reached 14 species out of 150 networks by generation 10. Spread across dozens of species, 150 networks leave each one too small to evolve much. Some NEAT implementations adjust the threshold as they go to hold the species count near a target; mine does not yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One seed per configuration.&lt;/strong&gt; The Monopoly result is large enough that I do not expect another seed to reverse it, but I have not run one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code is a learning project and is not public. I wrote the NEAT implementation; Claude Code wrote the tests I built it against and the script that produced the table above. The fix is two lines. What I would check first in any other NEAT port is whether its distance formula assumes a small starting genome. This failure produces a run that looks like it is working, with the one counter nobody watches stuck on 1.&lt;/p&gt;

</description>
      <category>neuroevolution</category>
      <category>node</category>
      <category>measurement</category>
      <category>training</category>
    </item>
    <item>
      <title>Hill Climbing, Backpropagation and NEAT on the Same XOR Problem</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Sat, 10 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/hill-climbing-backpropagation-and-neat-on-the-same-xor-problem-1ol1</link>
      <guid>https://dev.to/megapixel99/hill-climbing-backpropagation-and-neat-on-the-same-xor-problem-1ol1</guid>
      <description>&lt;p&gt;I expected backpropagation to learn XOR with the least work of the three methods I had built. It computes the direction every weight should move instead of guessing, so it seemed obvious. On a small network with 2 inputs, 3 hidden neurons and 1 output (a 2-3-1 network) it needed 9,268 to 11,124 network runs at learning rate 0.5. A hill climber that nudges every weight at random, and keeps the nudge only if the error drops, needed 1,512 to 1,648. Raising the learning rate to 2 brought backpropagation down to 3,028 to 3,372, still about twice the hill climber's work. The order only flipped once the problem grew to a full adder with two outputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;XOR as a learning problem is four examples: the inputs 00, 01, 10 and 11 should produce 0, 1, 1 and 0. It is the classic first test because no single neuron can learn it, so the network needs a hidden layer. Each neuron multiplies its inputs by weights, adds a bias, and squashes the sum through a sigmoid, an S-shaped curve that flattens out near 0 and 1. Learning means finding weights and biases that make the outputs match.&lt;/p&gt;

&lt;p&gt;All three learners live in one Node.js project, which is a learning exercise and is not public. I wrote the network, the hill climber, the backpropagation and the NEAT implementation myself. Claude Code wrote the test suite I implemented against and the comparison script whose output is quoted below, so the numbers come from a harness I did not write but can rerun.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hill climbing:&lt;/strong&gt; every round, add a random amount between -0.5 and +0.5 to every weight and bias. Run the four XOR cases, and restore the previous weights if the total error did not go down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backpropagation:&lt;/strong&gt; calculus tells each weight which way to move to reduce the error, and the learning rate sets how far it moves. The weights are updated after every example, on the same 2-3-1 network. One pass through the four examples is an epoch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NEAT:&lt;/strong&gt; &lt;a href="https://nn.cs.utexas.edu/downloads/papers/stanley.ec02.pdf" rel="noopener noreferrer"&gt;NeuroEvolution of Augmenting Topologies&lt;/a&gt;. A population of 150 networks, each described by a genome listing its connections, starts with no hidden nodes. Each generation, the better networks breed and mutate, adding weights, connections and nodes. Networks are grouped into species so that one with a freshly added node, which usually makes it worse at first, only competes with similar networks while its new weights get tuned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every method stops at the same target: total squared error below 0.04 over the four cases. (The two-output problem later on uses 0.08.) Work is counted in one unit that means the same thing for all three. A &lt;strong&gt;network run&lt;/strong&gt; is one forward pass on one input. The hill climber spends one evaluation, four runs, per round. Backpropagation spends an evaluation plus one training pass per example, eight runs, per epoch. NEAT spends four runs per genome per generation.&lt;/p&gt;

&lt;p&gt;Each configuration ran ten independent learners, and each figure is the median over the ones that reached the target. I ran each configuration three times, so a range is the spread of three medians.&lt;/p&gt;

&lt;h2&gt;
  
  
  XOR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;method&lt;/th&gt;
&lt;th&gt;median network runs, three repeats&lt;/th&gt;
&lt;th&gt;learners that reached the target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;hill climbing, batch A&lt;/td&gt;
&lt;td&gt;1,512 to 1,648&lt;/td&gt;
&lt;td&gt;9 or 10 of 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;backpropagation, learning rate 0.5, batch A&lt;/td&gt;
&lt;td&gt;9,268 to 11,124&lt;/td&gt;
&lt;td&gt;10 of 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEAT, batch A&lt;/td&gt;
&lt;td&gt;64,800 to 67,800&lt;/td&gt;
&lt;td&gt;10 of 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hill climbing, batch B&lt;/td&gt;
&lt;td&gt;1,340 to 1,544&lt;/td&gt;
&lt;td&gt;10 of 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;backpropagation, learning rate 2, batch B&lt;/td&gt;
&lt;td&gt;3,028 to 3,372&lt;/td&gt;
&lt;td&gt;9 or 10 of 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEAT, batch B&lt;/td&gt;
&lt;td&gt;65,400 to 69,000&lt;/td&gt;
&lt;td&gt;10 of 10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each batch is three repeats of all three methods, and the only setting that differs between them is backpropagation's learning rate. The hill climber and NEAT rows in the two batches are the same method measured twice, which shows how much the figures move on their own.&lt;/p&gt;

&lt;p&gt;The explanation I have is that the network is tiny. The 2-3-1 network has 13 parameters: three hidden neurons with two weights and a bias each, plus an output neuron with three weights and a bias. In 13 dimensions a random step has a fair chance of pointing downhill. A learner that tries a direction and throws it away when it is wrong gets there in surprisingly few evaluations.&lt;/p&gt;

&lt;p&gt;Backpropagation never takes a step in the wrong direction; its steps were just small. At learning rate 0.5 it crawled across the flat regions of the sigmoids near 0 and 1, and took 1,158 to 1,390 epochs. Quadrupling the rate cut that to 378 to 421 epochs. The method was fine, and my setting of it was the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full adder
&lt;/h2&gt;

&lt;p&gt;A full adder adds three bits and outputs a sum and a carry. That is eight cases and two outputs instead of four and one. Both fixed-shape learners used a 3-4-2 network, which has 26 parameters, and backpropagation kept learning rate 2.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;method&lt;/th&gt;
&lt;th&gt;median network runs, three repeats&lt;/th&gt;
&lt;th&gt;learners that reached the target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;hill climbing&lt;/td&gt;
&lt;td&gt;10,064 to 12,120&lt;/td&gt;
&lt;td&gt;9 or 10 of 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;backpropagation&lt;/td&gt;
&lt;td&gt;3,640 to 3,928&lt;/td&gt;
&lt;td&gt;10 of 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEAT&lt;/td&gt;
&lt;td&gt;205,200 to 218,400&lt;/td&gt;
&lt;td&gt;8 or 10 of 10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The order reversed. Backpropagation now needs about a third of the hill climber's work. My explanation is the same one run backwards: a random step in 26 dimensions is far less likely to help than one in 13, while the gradient points downhill however many weights there are. I have not isolated that, though, since the full adder also doubled the cases and the outputs, and I tried only one learning rate on it. The dimension argument is the standard reason large networks are trained with gradients, and it is also why XOR is a poor place to see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What NEAT spends its extra work on
&lt;/h2&gt;

&lt;p&gt;NEAT needed 64,800 to 69,000 network runs on XOR, 39 to 51 times the hill climber. It took 108 to 115 generations, and every generation evaluates all 150 networks. What that buys is the one thing the other two cannot do: it chooses the network's shape.&lt;/p&gt;

&lt;p&gt;The other two learners needed me to decide on three hidden neurons, or four for the full adder, before they started. The example NEAT solutions from the six XOR repeats had between 4 and 7 hidden nodes and 18 to 21 connections. On the full adder they ranged from 4 hidden nodes and 23 connections to 12 hidden nodes and 36. It did not choose a smaller shape than mine; its solutions were bigger. The cost is not worth paying when a person can choose a shape that works in advance. The rest of this series uses NEAT on Monopoly, where I had no idea what shape a good player's network should have.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the counts leave out
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A network run is not a unit of time.&lt;/strong&gt; Backpropagation's backward pass is real work that the count ignores, and so is the hill climber's copying of weights. Wall-clock time is too noisy at this size to replace it: backpropagation at learning rate 2 took 4.4 to 18.3 ms for the same configuration across three runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medians exclude learners that failed.&lt;/strong&gt; A run that got stuck and never reached the target counts only in the "reached the target" column, which is why that column is there. With an even number of successes, the script reports the upper of the two middle values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three repeats is a small sample.&lt;/strong&gt; The ranges are the spread I saw, not a confidence interval.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The part I expect to generalise is not which method won. A method's reputation comes from the problems it is famous for, and on a small enough problem the naive method can win honestly. I only found out by counting the same unit of work for all three, at more than one setting of the knob the losing method depends on.&lt;/p&gt;

</description>
      <category>neuroevolution</category>
      <category>node</category>
      <category>measurement</category>
      <category>optimizers</category>
    </item>
    <item>
      <title>A Published Exponent That Was a Digit Count</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Fri, 09 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/a-published-exponent-that-was-a-digit-count-56co</link>
      <guid>https://dev.to/megapixel99/a-published-exponent-that-was-a-digit-count-56co</guid>
      <description>&lt;p&gt;In a private research repo of mine there are twenty-one small programs, each with a frozen &lt;strong&gt;oracle&lt;/strong&gt;: a reference implementation plus a set of &lt;strong&gt;probes&lt;/strong&gt;, which are inputs paired with the answer or the refusal the program is required to produce. A generator reads those oracles and writes a contract document, so somebody reimplementing one of these programs can read what it must accept, what it must reject, and the largest magnitude anybody probes it with. That last figure exists so a reader knows where the edges are.&lt;/p&gt;

&lt;p&gt;It published the &lt;strong&gt;number of digits&lt;/strong&gt;, not the order of magnitude. A probe of exactly &lt;code&gt;10**12&lt;/code&gt; came out as &lt;code&gt;~10^13&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three of the twenty-one operations publish that figure. &lt;strong&gt;All three were wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The repo is private, so there is nothing to link to. The figures come out of the generated documents themselves, and out of commands I re-ran while writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three, and the dangerous one
&lt;/h2&gt;

&lt;p&gt;A count of changed rows cannot show this defect, because the whole defect is a number being one off. So here is every figure that moved, against the literal in the probe it came from:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;operation&lt;/th&gt;
&lt;th&gt;the literal probed&lt;/th&gt;
&lt;th&gt;published before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;th&gt;what the probe expects&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;number_names&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10**12&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~10^13&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;~10^12&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a &lt;strong&gt;refusal&lt;/strong&gt;: a ceiling to reject at&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;binary_convert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1000003&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~10^7&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;&amp;gt;10^6&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;an &lt;strong&gt;answer&lt;/strong&gt;: a floor to clear&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;luhn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;6011000000000004&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~10^16&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;&amp;gt;10^15&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;an answer, and it is a card number rather than a magnitude&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row is the one that matters, and it is worth being precise about why. That probe requires the program to &lt;strong&gt;refuse&lt;/strong&gt; the value, so the published figure is a ceiling: reject at or above this. Publishing &lt;code&gt;~10^13&lt;/code&gt; told a reader to reject an order of magnitude too high, so a program built faithfully to the contract &lt;strong&gt;accepts `10&lt;/strong&gt;12` and fails the very check the figure exists to protect.**&lt;/p&gt;

&lt;p&gt;It misreported in the direction of the defect it exists to prevent. A figure wrong toward safety is a nuisance; this one was wrong toward the failure.&lt;/p&gt;

&lt;p&gt;The fix also separates two things the old format ran together. When the probe value &lt;em&gt;is&lt;/em&gt; an exact power of ten the contract now says &lt;code&gt;~10^12&lt;/code&gt;; when it merely sits above one it says &lt;code&gt;&amp;gt;10^6&lt;/code&gt;. &lt;code&gt;6011000000000004&lt;/code&gt; is a credit card number, and calling it &lt;code&gt;~10^16&lt;/code&gt; invited a reader to think a round magnitude had been chosen deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test was watching the wrong property
&lt;/h2&gt;

&lt;p&gt;There was a check over this figure. It requires every published magnitude to start with &lt;code&gt;~10^&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is a test of the &lt;strong&gt;format&lt;/strong&gt;, not of the value. &lt;code&gt;~10^13&lt;/code&gt; passes it as readily as &lt;code&gt;~10^12&lt;/code&gt;, so it could not see this defect at all, and it was green while the figure was wrong.&lt;/p&gt;

&lt;p&gt;Worse, it made the cheap fix invisible. Subtracting one and leaving the format alone would have kept it green, and would have kept the question closed. The repair was only visible as a repair because it also changed the format to &lt;code&gt;&amp;gt;10^6&lt;/code&gt; for the two floors, which &lt;strong&gt;broke&lt;/strong&gt; the format check and forced the question into the open.&lt;/p&gt;

&lt;p&gt;So this round edits a test that exists to stop exactly that kind of edit, which needs a justification. The justification is that the widening was written down in advance by whoever wrote the test, and they left the instruction in the codebase: expect this check to fail beside the new wording, that is the regular expression reading the new text rather than a leaked value, so widen it in the same change. They were right that it would fail and wrong about how, having expected a polarity word where the round shipped a symbol. Worth noting that the instruction is not a comment beside the assertion it governs; it is the failure message of a &lt;em&gt;different&lt;/em&gt; check sixteen lines further down, which is a fragile place to leave something load-bearing, and it is the only reason the edit is defensible rather than convenient.&lt;/p&gt;

&lt;p&gt;Then a check was written that can actually see the defect. It re-derives the expected string from each operation's own literal and compares it against what is published. &lt;strong&gt;Planting&lt;/strong&gt; the digit-count rule back in the generator, which means reintroducing the defect on purpose to confirm the check notices, turns it &lt;strong&gt;red&lt;/strong&gt;, naming &lt;code&gt;binary_convert ('~10^7', '&amp;gt;10^6')&lt;/code&gt;. Restoring turns it green.&lt;/p&gt;

&lt;p&gt;That new check also carries a denominator, and the reason is a previous version of exactly this control. A check over an empty set passes, so a control has to assert that something was compared. The earlier one asserted only that the published list was non-empty, and stayed green while two of its three entries were being silently discarded before the comparison. The shipped version requires all three, which is the difference between "it ran" and "it ran on everything".&lt;/p&gt;

&lt;h2&gt;
  
  
  The two findings underneath
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The generator could not run at all.&lt;/strong&gt; This is a document that is supposed to be regenerated from its sources, and regenerating it was the first thing the round tried. A document whose generator has quietly stopped working has become a hand-maintained file without anybody deciding that, and every claim in it about being derived is now a claim about history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule lived in two files, so the first fix reached neither artifact a reader is handed.&lt;/strong&gt; Two separate pieces of code computed the same published figure, and repairing one left both documents unchanged. That is the failure mode where the fix is real, the diff is correct, the commit message is accurate, and nothing a reader can see has moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What generalises
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A test that pins a format does not pin a value, and the two are easy to confuse when the format encodes the value.&lt;/strong&gt; &lt;code&gt;~10^N&lt;/code&gt; looks like it is about the number. It is about the string. Anything asserting on a rendered figure is worth reading twice with one question: would this be green if the number were wrong?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The direction of an error matters more than its size.&lt;/strong&gt; One off is nothing until you ask which way. This figure is consumed as a bound, and being one order high on a ceiling turns a protective constraint into permission.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cheap fix that keeps every test green is the worst outcome available.&lt;/strong&gt; Subtracting one would have been correct, quiet, and would have left a format check standing that cannot see the class of defect it appears to guard. The repair that breaks a test is the repair that gets the test fixed.&lt;/p&gt;

</description>
      <category>measurement</category>
      <category>verification</category>
      <category>python</category>
    </item>
    <item>
      <title>The Table Named After the Programming Language</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Thu, 08 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/the-table-named-after-the-programming-language-3enf</link>
      <guid>https://dev.to/megapixel99/the-table-named-after-the-programming-language-3enf</guid>
      <description>&lt;p&gt;&lt;code&gt;appgen&lt;/code&gt; is a tool of mine that builds a small working application from one sentence, with no language model in the generating path. Before it can build anything it has to find the &lt;strong&gt;entity&lt;/strong&gt;: the noun the app is about, which becomes its table name, its form fields and its URL paths. Given &lt;em&gt;"Build a recipe manager with search"&lt;/em&gt; the answer should be &lt;code&gt;recipe&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Given &lt;em&gt;"Build your own Blockchain in JavaScript"&lt;/em&gt; the answer was &lt;code&gt;javascript&lt;/code&gt;, and the tool built a table of &lt;code&gt;javascripts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It was not alone. &lt;em&gt;"Let's implement a cryptocurrency in Kotlin"&lt;/em&gt; produced &lt;code&gt;kotlin&lt;/code&gt;. &lt;em&gt;"Create a Twitter Bot in Python Using Tweepy"&lt;/em&gt; produced &lt;code&gt;tweepy&lt;/code&gt;. &lt;em&gt;"Build a Microblog with Flask"&lt;/em&gt; produced &lt;code&gt;flask&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One more rule matters for everything below, because it decides whether a wrong answer is cosmetic or fatal. The tool's checker rejects any artifact whose entity is &lt;strong&gt;not a word of the request&lt;/strong&gt;. So an entity taken from the wrong part of the sentence still builds; an entity invented from nowhere does not.&lt;/p&gt;

&lt;p&gt;Private repo, so no code link. What is checkable is the provenance of each figure, which I give as I go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the language wins
&lt;/h2&gt;

&lt;p&gt;The rule scans the request for candidate nouns. On an imperative sentence it prefers the rightmost content word, because English noun phrases put the head last: in &lt;em&gt;"a recipe manager"&lt;/em&gt; the head is &lt;code&gt;manager&lt;/code&gt;, and the informative word sits just to its left, so reading rightwards gets you to the right neighbourhood before scanning back.&lt;/p&gt;

&lt;p&gt;A trailing &lt;code&gt;in JavaScript&lt;/code&gt;, &lt;code&gt;using Tweepy&lt;/code&gt; or &lt;code&gt;with Flask&lt;/code&gt; is an adjunct. It says what the thing is built &lt;em&gt;with&lt;/em&gt;, not what it is &lt;em&gt;about&lt;/em&gt;. But it is also, almost always, where the rightmost content word lives. So the part of the sentence that is explicitly not the subject sits exactly where the rule looks for the subject.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix names no languages
&lt;/h2&gt;

&lt;p&gt;Strip the tail, try the head, and keep today's answer if the head names nothing. Fourteen lines, and it shipped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Build your own Blockchain in JavaScript            javascript -&amp;gt; blockchain
Let's implement a cryptocurrency in Kotlin         kotlin     -&amp;gt; cryptocurrency
Create a Twitter Bot in Python Using Tweepy        tweepy     -&amp;gt; bot
Code a 2D Game Engine using Java - Full Course     beginner   -&amp;gt; game
Build a Microblog with Flask                       flask      -&amp;gt; microblog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The thing I like about this one is what is &lt;em&gt;not&lt;/em&gt; in it. There is no list of programming languages. &lt;code&gt;javascript&lt;/code&gt;, &lt;code&gt;kotlin&lt;/code&gt; and &lt;code&gt;tweepy&lt;/code&gt; are not named anywhere in the change, and the rule would work the same way on a framework invented next year. A hand-written list of languages would have scored the same on this corpus and would have started rotting the day it shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  And the title of this post is from the half with no evidence behind it
&lt;/h2&gt;

&lt;p&gt;Here is the part that cuts against everything above.&lt;/p&gt;

&lt;p&gt;The scored corpus is 75 project titles with hand-written gold answers. They come from three third-party lists, taken complete, none written with any knowledge of this tool: 861 linked items, of which 87 have gold answers, of which 75 reach the entity reader at all. On those 75 the fix moved the score from &lt;strong&gt;31 to 37&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Break the six gained rows down by which preposition fired:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;preposition&lt;/th&gt;
&lt;th&gt;rows changed&lt;/th&gt;
&lt;th&gt;rows &lt;strong&gt;gained&lt;/strong&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;in&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;using&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;with&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Every one of the six gains is a &lt;code&gt;with&lt;/code&gt; row.&lt;/strong&gt; The &lt;code&gt;in&lt;/code&gt; branch, which is the one that produced &lt;code&gt;javascripts&lt;/code&gt; and the one this post is named after, changes a single scored row and gains nothing. It changes 57 rows on a larger corpus that has no gold answers, so I can see it doing something and cannot show that the something is right.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;javascripts&lt;/code&gt; is a real defect, really fixed, and "31 to 37" is not evidence that fixing it helped. The reach is carried by &lt;code&gt;in&lt;/code&gt; and &lt;code&gt;using&lt;/code&gt;; the measured accuracy is carried entirely by &lt;code&gt;with&lt;/code&gt;. Those are two different claims and only one of them has gold labels underneath it.&lt;/p&gt;

&lt;p&gt;Both arms in that measurement are the real functions rather than reimplementations. The &lt;em&gt;before&lt;/em&gt; arm is the shipped rule with this change lifted back off; the &lt;em&gt;after&lt;/em&gt; arm is the shipped entry point itself. An earlier round reimplemented an arm, watched it drift from the thing it claimed to measure, and took a whole round to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  One word was worth more than the grammar
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;X Clone&lt;/code&gt; means a clone &lt;strong&gt;of&lt;/strong&gt; X, and the tool was answering &lt;code&gt;clone&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The rule already knows about &lt;strong&gt;generic heads&lt;/strong&gt;: nouns that say nothing about the domain, so that when it finds one it scans leftwards for the real subject. That set already held &lt;code&gt;engine&lt;/code&gt;, &lt;code&gt;box&lt;/code&gt;, &lt;code&gt;book&lt;/code&gt;, &lt;code&gt;collection&lt;/code&gt;, &lt;code&gt;catalog&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt;, &lt;code&gt;platform&lt;/code&gt;, &lt;code&gt;system&lt;/code&gt;, &lt;code&gt;service&lt;/code&gt; and &lt;code&gt;program&lt;/code&gt;, which is why &lt;em&gt;"Trello Platform"&lt;/em&gt; already answered &lt;code&gt;trello&lt;/code&gt;. &lt;code&gt;clone&lt;/code&gt; was in neither that set nor the stop list of words that can never be an answer.&lt;/p&gt;

&lt;p&gt;Adding it is a one-word change, no new code path runs, and it shipped too. On the same 75 rows it moved the score from &lt;strong&gt;37 to 49&lt;/strong&gt;: fourteen rows moved, &lt;strong&gt;twelve gained and none lost&lt;/strong&gt;. With &lt;code&gt;clone&lt;/code&gt; in place the fourteen-line grammar fix is worth &lt;strong&gt;+8&lt;/strong&gt; rather than +6, since both arms move together, so the word is worth about one and a half times the grammar.&lt;/p&gt;

&lt;p&gt;One of the twelve deserves a discount. &lt;em&gt;Google Docs Clone&lt;/em&gt; answers &lt;code&gt;docs&lt;/code&gt; where the gold set wants &lt;code&gt;document&lt;/code&gt; or &lt;code&gt;doc&lt;/code&gt;, and it scores as a gain on a technicality; a reader who thinks both answers are wrong in the same way should read eleven.&lt;/p&gt;

&lt;p&gt;The mechanism was already written down in the tool's own README, one paragraph from the problem the grammar fix solved: &lt;em&gt;on noun phrases the leftmost scan takes a leading modifier&lt;/em&gt;, naming &lt;code&gt;clone&lt;/code&gt; as four rows of it. The fix was sitting in the documentation waiting for me to read my own writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where those twelve rows came from, and why that is a problem
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;27 of the 75 rows contain &lt;code&gt;clone&lt;/code&gt;.&lt;/strong&gt; The corpus is a list of project ideas, and &lt;em&gt;"build a clone of X"&lt;/em&gt; is one of the two or three things such a list is mostly made of.&lt;/p&gt;

&lt;p&gt;And the word was not chosen independently of these rows. The README paragraph that named it was itself written from an error analysis of this corpus, which lists four &lt;em&gt;X Clone Application&lt;/em&gt; titles by name. So &lt;code&gt;clone&lt;/code&gt; is a word selected from the rule's own mistakes on the corpus it is then scored against, which is the setup &lt;a href="https://sethwheeler.dev/blog/fitted-stop-words/" rel="noopener noreferrer"&gt;an earlier post in this series&lt;/a&gt; measured directly: words picked that way gain in sample by construction, and the question is whether they travel.&lt;/p&gt;

&lt;p&gt;Here they do not. The controls say so by being &lt;strong&gt;inert&lt;/strong&gt;, which is the more useful way to read them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On a second corpus of 47 rows, nothing moves: 36 before, 36 after, no row gained or lost. One of those 47 does contain the word, and a different rule fires on it first, so the channel is blocked rather than empty.&lt;/li&gt;
&lt;li&gt;On the 1,440 requests the repository's own generator can produce, nothing moves either: 772 before, 772 after. &lt;strong&gt;Those 1,440 requests contain the word zero times.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is exactly the pattern the earlier post measured with a random-draw control: a real gain on the corpus the word came from, and precisely nothing anywhere else. Two rounds, two methods, same answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it does regress
&lt;/h2&gt;

&lt;p&gt;It regresses, and the round went looking for where. Five hand-written requests a user could plausibly type come out worse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;clone&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tracker&lt;/span&gt;&lt;span class="w"&gt;                          &lt;/span&gt;&lt;span class="err"&gt;clone&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;record&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;clone&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;log&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;with&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;search&lt;/span&gt;&lt;span class="w"&gt;                  &lt;/span&gt;&lt;span class="err"&gt;clone&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;record&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;VM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;clone&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tracker&lt;/span&gt;&lt;span class="w"&gt;                       &lt;/span&gt;&lt;span class="err"&gt;clone&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;record&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;plant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;clone&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;log&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;my&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;nursery&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="err"&gt;clone&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;plant&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;clone&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;inventory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;with&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;creating…&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="err"&gt;clone&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;inventory&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you genuinely want to track clones, &lt;code&gt;clone&lt;/code&gt; is now a generic head and the tool scans past it. Two of those five land on a reasonable alternative. Three land on the tool's fallback noun, &lt;code&gt;record&lt;/code&gt;, which is not a word of the request, so the checker rejects the artifact outright.&lt;/p&gt;

&lt;p&gt;None of these appears in any corpus this tool is scored on. They were written by hand precisely because no corpus contains them, which makes the regression real and unmeasured rather than real and small.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more number, so two posts do not look like they disagree
&lt;/h2&gt;

&lt;p&gt;49 of 75 is the &lt;strong&gt;entity reader&lt;/strong&gt; scored on its own. Elsewhere in this series the same rule appears at 47 of 75, and that is the whole shipped path with a routing layer in front of the reader. Same corpus, different functions, and the two should not be subtracted. The rounds here measure the reader because the reader is what the change touches, and the source write-up is explicit that the delta is +6 either way and that you have to name the harness when quoting one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What generalises
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A rule that scans for the most important word will find whatever is where the important words usually are.&lt;/strong&gt; The language name did not fool a heuristic about languages; it occupied a position. Fixing the position generalises to frameworks that do not exist yet. Fixing the names would have been a list, and a list is a promise to keep updating it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The vivid example and the measured gain were in different halves of the corpus.&lt;/strong&gt; I fixed &lt;code&gt;javascripts&lt;/code&gt;, wrote the post about &lt;code&gt;javascripts&lt;/code&gt;, and the gold labels only ever confirmed the &lt;code&gt;with&lt;/code&gt; rows. The breakdown that shows this is one extra column of the same table, and I did not produce it until the round was almost written up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Say which of your controls could have fired.&lt;/strong&gt; Two controls here report no change. One of them covers a population where the word under test never appears, and the other has a single row holding it, blocked by a different rule. Listed as green checks, those read as two independent confirmations; they are one measurement and two abstentions.&lt;/p&gt;

</description>
      <category>codegeneration</category>
      <category>measurement</category>
      <category>parsing</category>
      <category>python</category>
    </item>
    <item>
      <title>Reading a Reinforcement Learning Taxonomy Against My Own Tools</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/reading-a-reinforcement-learning-taxonomy-against-my-own-tools-3m4p</link>
      <guid>https://dev.to/megapixel99/reading-a-reinforcement-learning-taxonomy-against-my-own-tools-3m4p</guid>
      <description>&lt;p&gt;Somebody handed me a list of reinforcement learning algorithms and asked whether any of them could help my tools. It is a ChatGPT session printed to PDF: roughly ninety algorithm names across fifteen sections, with no descriptions, no citations and no results. I want to be precise about that, not to dismiss it. It is a &lt;strong&gt;menu of mechanism names&lt;/strong&gt;, it was a good thing to be handed, and it is the reason I went and looked at one of my own tools properly. Where a claim below is about how an algorithm behaves, the authority is the third-party documentation I read alongside it, mostly OpenAI's &lt;a href="https://spinningup.openai.com/" rel="noopener noreferrer"&gt;Spinning Up&lt;/a&gt; and a couple of tutorial pages, and never the list.&lt;/p&gt;

&lt;p&gt;The tools are mine and none of them is a &lt;strong&gt;policy&lt;/strong&gt;, which is what reinforcement learning optimises: a rule for choosing an action, tuned by whether past actions turned out well. &lt;code&gt;appgen&lt;/code&gt; turns a sentence into a running application with no language model in the generating path. &lt;code&gt;pycomplete&lt;/code&gt; is a code completer mixing an n-gram model, a buffer cache and a small transformer. &lt;code&gt;growone&lt;/code&gt; is an online learner that adds hidden units as it goes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four rounds later, nothing shipped from any of it.&lt;/strong&gt; The four reasons are different, and that is the interesting part.&lt;/p&gt;

&lt;p&gt;The tools are in a private research repo, so none of them is linkable. The numbers are, in the sense that each one is attributed to where it came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most of the list cannot apply, and that is the first result
&lt;/h2&gt;

&lt;p&gt;Sections two through nine and eleven cover every value-based, policy-gradient, actor-critic, distributional, model-based, offline, multi-agent, hierarchical and large-scale family on the list. All of them need the same input: &lt;strong&gt;a number saying how well that went&lt;/strong&gt;. Policy-gradient methods need to know whether an action beat the average; Q-learning needs a reward plus its own estimate of what the next state is worth. Without that number there is nothing to descend.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;appgen&lt;/code&gt; has not got one, and the reason is not that nobody has built it yet. The obvious candidate is the tool's own verification verdict, &lt;code&gt;ALL CHECKS PASSED&lt;/code&gt;, and it is &lt;strong&gt;not a reward&lt;/strong&gt;, because of what it means: &lt;em&gt;the program matches its own published contract&lt;/em&gt;. A correct implementation of a misread request passes it. An earlier round drove 138 externally-written requests through the tool and got 34 wrong builds; &lt;strong&gt;all 34 printed &lt;code&gt;ALL CHECKS PASSED&lt;/code&gt;.&lt;/strong&gt; Optimising that signal trains the tool toward wrong programs that verify.&lt;/p&gt;

&lt;p&gt;That is a structural fact about the tool rather than a gap in the literature, and it removes nine of the list's fifteen sections in one paragraph. The remaining sections were not all assessed against that requirement, so this is nine of fifteen and not "all but one".&lt;/p&gt;

&lt;h2&gt;
  
  
  The one branch that does apply
&lt;/h2&gt;

&lt;p&gt;Section ten is exploration bonuses: pseudo-counts and Random Network Distillation. Both are &lt;strong&gt;reward-free&lt;/strong&gt;. In reinforcement learning they &lt;em&gt;manufacture&lt;/em&gt; a reward out of novelty, to push an agent toward states it has not seen; here you can keep the novelty estimator and throw the use away. The offline family motivates the same thing from the other side, which is that off the data's support your estimates are untrustworthy, so stay on it.&lt;/p&gt;

&lt;p&gt;Both reduce to one mechanism: &lt;strong&gt;estimate whether a request is inside the distribution the rules were built for.&lt;/strong&gt; What the tool would do with that is refuse earlier and for a better reason. It already refuses most requests it cannot handle, by failing to find a grammar rule that matches; a novelty score would let it decline a request that parses fine and is simply unlike anything the rules were written for, which is the case that currently produces a confidently wrong app.&lt;/p&gt;

&lt;p&gt;The population is 52 requests the tool actually built. 18 of them are &lt;strong&gt;apt&lt;/strong&gt;, meaning the program it produced is the one the request asked for, judged against the request rather than against the tool's own contract; 34 are not. The in-distribution reference is not curated: it is the repository's own corpus generator, so the generator &lt;em&gt;is&lt;/em&gt; the definition of a request the tool was built for, and no judgment call picks the fit set.&lt;/p&gt;

&lt;p&gt;The scoring is &lt;strong&gt;AUC&lt;/strong&gt;: take one apt request and one inapt one at random, and AUC is the probability the score ranks them the right way round. 0.5 is a coin flip and 1.0 is perfect. A permutation test at this sample size puts the floor for a resolvable result at &lt;strong&gt;0.641&lt;/strong&gt;, written down before any arm ran.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;C1_length        AUC 0.717   p=0.0035
C2_coverage      AUC 0.739   p=0.0020
A1_pseudocount   AUC 0.784   p=0.0003
A2_rnd           AUC 0.676   p=0.0194
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All four clear the floor, and the two that are not the imported machinery are the reason nothing shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The control that ate the result
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;C1_length&lt;/code&gt; is token count.&lt;/strong&gt; I put it in as the confound control, and wrote down in advance that it clearing the floor would matter more than the arms clearing it. It clears at 0.717: short requests are apt about 47% of the time, long ones about 11%.&lt;/p&gt;

&lt;p&gt;So the arms have to survive length, and the round strips it two ways, both specified before either was run. &lt;strong&gt;Residual&lt;/strong&gt; regresses the arm on length and scores what is left, which assumes the relationship is linear. &lt;strong&gt;Stratified&lt;/strong&gt; takes the AUC separately inside each half of a median split, which assumes nothing but works on small groups.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;raw&lt;/th&gt;
&lt;th&gt;residual&lt;/th&gt;
&lt;th&gt;stratified&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;C2_coverage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.739&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.682&lt;/strong&gt; (p=0.016)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.720&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;survives both&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;A1_pseudocount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.784&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.567 (p=0.22)&lt;/td&gt;
&lt;td&gt;0.716&lt;/td&gt;
&lt;td&gt;the two controls disagree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;A2_rnd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.676&lt;/td&gt;
&lt;td&gt;0.655 (p=0.033)&lt;/td&gt;
&lt;td&gt;0.644&lt;/td&gt;
&lt;td&gt;survives both, barely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The best raw arm is the only one the controls disagree about. Its 0.784 collapses to a null once the linear part of length is removed, and holds at 0.716 under the assumption-free read. &lt;strong&gt;I do not know which is right&lt;/strong&gt;, and reporting the 0.716 alone would be choosing the favourable control after seeing both.&lt;/p&gt;

&lt;p&gt;Then the part that decided it. The corpus was scraped from two websites, and they are not interchangeable. One contributed 40 requests at a 45% apt rate. The other contributed 12, all of them tutorial titles about reimplementing a technology (&lt;em&gt;Chess Engine In C&lt;/em&gt;, &lt;em&gt;mal - Make a Lisp&lt;/em&gt;, &lt;em&gt;Write your own Operating System&lt;/em&gt;), and &lt;strong&gt;none of the twelve is apt.&lt;/strong&gt; So &lt;strong&gt;knowing which website a request came from scores AUC 0.676&lt;/strong&gt; on this population, identical to the Random Network Distillation arm to three decimals.&lt;/p&gt;

&lt;p&gt;Hold the corpus fixed and read inside the larger source only, with the floor recomputed for that smaller sample, and &lt;strong&gt;the RND arm drops to 0.644 and stops clearing.&lt;/strong&gt; It was reading provenance. The pseudo-count arm survives that read; it is the one that does not survive the length control.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually decided it, which was not any of that
&lt;/h2&gt;

&lt;p&gt;The confound is a reason to distrust an arm. It is not what stopped the tool shipping, and I want to be careful about that, because deciding afterwards is exactly how a round like this goes wrong.&lt;/p&gt;

&lt;p&gt;The pre-registration said: &lt;em&gt;a tool ships only if the separation holds and the operating point is usable.&lt;/em&gt; At a threshold keeping 90% or more of the apt builds, the best arm rejects &lt;strong&gt;26%&lt;/strong&gt; of the inapt ones, against the 40% named in advance. So the second condition failed and nothing ships, and that was settled before the arms ran.&lt;/p&gt;

&lt;p&gt;A round that ran four arms, found p = 0.0003 on one of them, and &lt;em&gt;then&lt;/em&gt; asked what counts as success would have shipped a gate on a 26% rejection rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second tool has a return, and a plateau
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pycomplete&lt;/code&gt; does have a usable number, because you can ask whether its top suggestion was right, so the machinery has the signal it needs. &lt;a href="https://sethwheeler.dev/blog/alpha-routing-plateau/" rel="noopener noreferrer"&gt;That turned into its own round with its own answer&lt;/a&gt;, and a third reason for shipping nothing.&lt;/p&gt;

&lt;p&gt;One framing point is worth carrying here. Offline, over logged positions, every option can be scored at every setting, because the answer is recorded: &lt;strong&gt;the feedback is full, which makes the offline problem ordinary supervised learning and not reinforcement learning at all.&lt;/strong&gt; The bandit framing only bites in deployment, where you see whether the completion you showed was accepted and nothing about the ones you did not. The offline version was measured first because if the offline ceiling is empty the online one cannot be better.&lt;/p&gt;

&lt;h2&gt;
  
  
  The third tool maximises a proxy, which is the one real hit
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;growone&lt;/code&gt; has a decision, a candidate set and a signal. When it adds a hidden unit it trains four candidates from random starting points and installs the one whose activation has the largest &lt;strong&gt;absolute&lt;/strong&gt; covariance with what the model is currently getting wrong.&lt;/p&gt;

&lt;p&gt;That is a &lt;strong&gt;proxy&lt;/strong&gt;. The thing wanted is lower error going forward; the thing maximised is correlation with the current error right now. This is the exact split the literature names between directly optimising what you want and optimising a surrogate, and the list is what sent me to look.&lt;/p&gt;

&lt;p&gt;Measured, the proxy is &lt;strong&gt;directionally right and already takes most of what is there&lt;/strong&gt;. Perfect selection among its four candidates is worth 2.6% of loss, and the shipped rule captures &lt;strong&gt;71.8%&lt;/strong&gt; of that. No change ships, and that is the correct outcome.&lt;/p&gt;

&lt;p&gt;The finding worth keeping is that the alignment is &lt;strong&gt;stratified&lt;/strong&gt;. The measure is &lt;strong&gt;regret&lt;/strong&gt;: how much of the available gap between the best and worst candidate the rule gives away, where 0 is perfect selection and 0.5 is choosing at random. On a smooth nonlinear stream regret is 0.228. On the open-key stream, where feature names arrive mid-run, it is &lt;strong&gt;0.453&lt;/strong&gt;, which is nearly blind. That stream is the one the tool is named for and is its distinguishing claim.&lt;/p&gt;

&lt;p&gt;Two readings of that make opposite predictions. Either a unit installed for a feature that is still arriving looks worthless over a short window and pays later, so regret &lt;strong&gt;falls&lt;/strong&gt; as the window lengthens; or the score simply does not rank candidates on that stream, so regret &lt;strong&gt;stays&lt;/strong&gt;. Running each candidate set forward 600 points once and cutting the same run at three lengths, so the windows share the same installs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;window&lt;/th&gt;
&lt;th&gt;regret on the open-key stream&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;150 points&lt;/td&gt;
&lt;td&gt;0.440&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300 points&lt;/td&gt;
&lt;td&gt;0.401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;600 points&lt;/td&gt;
&lt;td&gt;0.442&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Paired change from four times the window: &lt;strong&gt;+0.002, CI [−0.052, +0.057]&lt;/strong&gt;. The horizon explanation is rejected. The proxy is near-blind there rather than short-sighted.&lt;/p&gt;

&lt;p&gt;(That 0.440 and the 0.453 above are the same measurement on different populations. The window study can only use installs with at least 600 points of stream left after them, which is 149 of the 224 the earlier round scored on that stream, so the two numbers should not be read as a change.)&lt;/p&gt;

&lt;p&gt;And there is almost nothing to be blind about: the spread between the best and worst candidate on that stream is &lt;strong&gt;2.5% to 3.1% of mean loss&lt;/strong&gt; at any window. Ranking them perfectly is worth a rounding error, which is the real reason this closes rather than continues.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I could not check
&lt;/h2&gt;

&lt;p&gt;Seven of the tools the question was asked about were uninitialised submodules in the worktree that round ran in, so they were not examined. That was a fact about my environment rather than a finding about them, and two of them were checked out and audited in a later round, which found nothing across nineteen runners. The rest are still unexamined.&lt;/p&gt;

&lt;h2&gt;
  
  
  What generalises
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A taxonomy is a shopping list, and the first question is which aisle you are allowed in.&lt;/strong&gt; Nine of fifteen sections went away on one property of my tool: there is no reward, and its nearest candidate is a verdict saying &lt;em&gt;this program matches its own contract&lt;/em&gt;, which is exactly what is true of a correct implementation of the wrong request. Working that out cost an afternoon and saved building any of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put the dumb control in before the clever arm, and write down in advance that it matters more.&lt;/strong&gt; Token count at 0.717 is the whole result here. Reporting the pseudo-count arm's 0.784 next to a null, and not next to the length control, would have read as a success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The corpus can be the classifier.&lt;/strong&gt; When an arm ties with the name of the website a row was scraped from, and then falls below the bar as soon as you hold the website fixed, the arm was measuring provenance. That was visible in a two-row table of source against outcome, and I did not look at that table until after the arms had run.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>measurement</category>
      <category>statistics</category>
    </item>
    <item>
      <title>A Measured Headroom That Nothing Could Reach</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Tue, 06 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/a-measured-headroom-that-nothing-could-reach-kf1</link>
      <guid>https://dev.to/megapixel99/a-measured-headroom-that-nothing-could-reach-kf1</guid>
      <description>&lt;p&gt;&lt;code&gt;pycomplete&lt;/code&gt; is a code completer of mine that runs on a laptop CPU. It predicts the next token by mixing three things: an n-gram count model over your repository, a cache of the buffer you are currently editing, and a small transformer. One number, α, decides how much of the mixture is transformer, and it is a constant. The shipped value is 0.45, and a comment in the source says it is a constant on purpose.&lt;/p&gt;

&lt;p&gt;The obvious idea is to stop it being a constant. Some positions in a file surely want more transformer and some want less; pick α per position and win. Three rounds later α is still 0.45, nothing shipped, and the reason is not the one I expected.&lt;/p&gt;

&lt;p&gt;Everything below is scored in &lt;strong&gt;top-1&lt;/strong&gt;: the fraction of positions where the token you were about to type is the single highest-ranked suggestion. And &lt;strong&gt;headroom&lt;/strong&gt; means the gap between what a perfect chooser would score and what the best single fixed choice scores, which is the most any router could ever win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A per-position oracle, allowed to pick the best α for every position after seeing the answer, scores +0.0705 top-1 over the best fixed α, with a paired 95% confidence interval of [+0.0623, +0.0786].&lt;/strong&gt; Three families of features captured 0.0%, −3.1% and −0.9% of that.&lt;/p&gt;

&lt;p&gt;The completer lives in a private research repo, so there is no code to link. Every number below is traced to a results file or a re-run command.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ceiling, and why it is not an artifact
&lt;/h2&gt;

&lt;p&gt;6,000 positions sampled at a fixed stride through 42 held-out files, against a 116-file index of the Python standard library. The split is &lt;strong&gt;by file&lt;/strong&gt;, fixed before any code existed, because positions inside one file share a buffer and an identifier vocabulary, and splitting by position leaks the answer across the boundary in exactly the direction being tested.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;test top-1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;shipped, α = 0.45&lt;/td&gt;
&lt;td&gt;0.4948&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;best fixed α chosen on the training half (also 0.45)&lt;/td&gt;
&lt;td&gt;0.4948&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;per-position oracle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.5653&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An oracle sweeping a grid can only look better as the grid gets denser; +0.0705 could therefore have been an artifact of trying seven values. On a 2,500-position subset, doubling the grid to thirteen values moves the oracle by &lt;strong&gt;+0.0012&lt;/strong&gt;, which is 1.7% of the headroom. The headroom is structure, not resolution.&lt;/p&gt;

&lt;p&gt;One piece of care matters more than it sounds. The mixture is &lt;em&gt;replicated&lt;/em&gt; inside the measuring harness so that every α is scored on identical arm outputs and the transformer's forward pass is paid once instead of seven times. That replication is a second implementation of the thing being measured, which is how harnesses quietly drift from tools; so the probe re-scores a sample of positions through the shipped completer and &lt;strong&gt;refuses to write its output if any disagree&lt;/strong&gt;. The results file exists because 400 of 400 agreed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Family one and two: nothing, and less than nothing
&lt;/h2&gt;

&lt;p&gt;Every policy here is tabular. Sort each position into a cell, work out the best α for each cell on the training half, and use it. With no state transition and no discounting that is exactly a greedy contextual-bandit rule, which is the most legible thing to fail.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;policy&lt;/th&gt;
&lt;th&gt;keyed on&lt;/th&gt;
&lt;th&gt;test&lt;/th&gt;
&lt;th&gt;gain over best fixed&lt;/th&gt;
&lt;th&gt;paired 95% CI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;P-BUFFER&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;how long the buffer is&lt;/td&gt;
&lt;td&gt;0.4948&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.0000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;[−0.0033, +0.0033]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;P-CONF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the three arms' confidence and agreement&lt;/td&gt;
&lt;td&gt;0.4927&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−0.0022&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;[−0.0065, +0.0022]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;oracle&lt;/td&gt;
&lt;td&gt;the answer&lt;/td&gt;
&lt;td&gt;0.5653&lt;/td&gt;
&lt;td&gt;+0.0705&lt;/td&gt;
&lt;td&gt;[+0.0623, +0.0786]&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Buffer length capturing exactly nothing reproduces what the tool's own source comments already claimed, on a different corpus from the one that settled it; confidence does slightly worse than doing nothing.&lt;/p&gt;

&lt;p&gt;A flat result has two very different causes, and the round separated them in advance: a policy that &lt;strong&gt;overfits&lt;/strong&gt; (big training gain, no test gain) is a different animal from one whose features &lt;strong&gt;cannot express anything at all&lt;/strong&gt;. Permute the hit vectors across positions, which destroys any relation to the features while keeping every margin identical, and refit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;train-fit gain from pure noise : mean +0.0054   95th pct +0.0095
P-CONF's real train-fit gain   :      +0.0039   -&amp;gt; p = 0.740
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fitting noise beats fitting the features.&lt;/strong&gt; With eight cells and seven values of α, picking the best-scoring α per cell is upward-biased by construction, and the confidence policy does not even reach that bias. So the finding is &lt;em&gt;these features carry no routing signal&lt;/em&gt;, which is a narrower claim than &lt;em&gt;routing does not work&lt;/em&gt;, and it is the only one that was measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Family three: the optimum really does move, and it is still worth nothing
&lt;/h2&gt;

&lt;p&gt;The first round closed by naming the untried family: syntactic context, which is a different measurement rather than a better learner. The next round built it, importing the mixture, the α grid, the verification contract, the splitting rule and the placebo so that the features would be the substantive change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The corpus is not held fixed, and that matters for reading the two rounds together.&lt;/strong&gt; This one runs 12,000 positions over 72 held-out files rather than 6,000 over 42, and the best fixed α moves from 0.45 to 0.6 with it. So the tables below are internally consistent and are &lt;strong&gt;not&lt;/strong&gt; comparable cell by cell with the tables above. The ship bar is the same in both: a router ships only if it clears &lt;strong&gt;+0.020&lt;/strong&gt; with a confidence interval excluding zero.&lt;/p&gt;

&lt;p&gt;I predicted the payoff would come after a &lt;code&gt;.&lt;/code&gt;, because the next token is an attribute and the repository index holds the project's attribute names, so α should fall there. &lt;strong&gt;Measured, &lt;code&gt;.&lt;/code&gt; wants α = 0.6, which is this round's global best, with a lift of exactly 0.0000.&lt;/strong&gt; The classes that move are &lt;code&gt;,&lt;/code&gt; and &lt;code&gt;=&lt;/code&gt;, which want &lt;strong&gt;less&lt;/strong&gt; transformer at the start of a fresh expression. The direction of the mechanism I argued for was wrong, and I am leaving that on the page.&lt;/p&gt;

&lt;p&gt;The gate held, though, in the sense of the pre-registered criterion: best α per previous-token class spans &lt;strong&gt;0.30&lt;/strong&gt; across nine classes, where buffer length was flat and confidence was below the noise floor. Syntax genuinely moves the optimum, and it is the first family in three to do so.&lt;/p&gt;

&lt;p&gt;Then the question the criterion does not ask: &lt;strong&gt;does moving pay?&lt;/strong&gt; For each class, how much does its own best α beat the global best α on its own rows?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;class&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;best α&lt;/th&gt;
&lt;th&gt;lift over the global best&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;,&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;261&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.0421&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;202&lt;/td&gt;
&lt;td&gt;0.45&lt;/td&gt;
&lt;td&gt;+0.0198&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;=&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;204&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;+0.0098&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;id&lt;/code&gt; (identifier)&lt;/td&gt;
&lt;td&gt;2137&lt;/td&gt;
&lt;td&gt;0.45&lt;/td&gt;
&lt;td&gt;+0.0066&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;kw&lt;/code&gt; (keyword)&lt;/td&gt;
&lt;td&gt;616&lt;/td&gt;
&lt;td&gt;0.45&lt;/td&gt;
&lt;td&gt;+0.0016&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;(&lt;/code&gt; &lt;code&gt;)&lt;/code&gt; &lt;code&gt;.&lt;/code&gt; &lt;code&gt;op&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1639&lt;/td&gt;
&lt;td&gt;0.6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.0000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Size-weighted, the ceiling is &lt;strong&gt;+0.0063&lt;/strong&gt;. Against this round's own test-half oracle of +0.0662 that is &lt;strong&gt;9.5%&lt;/strong&gt;, and it is the &lt;em&gt;most&lt;/em&gt; a per-class router could win, before anyone asks it to generalise. Four of nine classes want the global optimum exactly, and the two classes that actually pull away from it, &lt;code&gt;,&lt;/code&gt; and &lt;code&gt;=&lt;/code&gt;, hold 465 of 5,325 training rows between them.&lt;/p&gt;

&lt;p&gt;The tool's own source said why before any of this existed: the values from 0.45 to 0.6 are a plateau, and two of them differ on eighteen positions. &lt;strong&gt;Picking the best α on a plateau moves for free and is worth nothing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Fitted, it behaves exactly as that predicts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;policy&lt;/th&gt;
&lt;th&gt;test&lt;/th&gt;
&lt;th&gt;gain&lt;/th&gt;
&lt;th&gt;paired 95% CI&lt;/th&gt;
&lt;th&gt;cells&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;best fixed α&lt;/td&gt;
&lt;td&gt;0.4900&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;P-SYN&lt;/strong&gt;, previous-token class by depth&lt;/td&gt;
&lt;td&gt;0.4894&lt;/td&gt;
&lt;td&gt;−0.0006&lt;/td&gt;
&lt;td&gt;[−0.0049, +0.0036]&lt;/td&gt;
&lt;td&gt;22, smallest 22 rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;P-SYN+&lt;/strong&gt;, plus the token before that&lt;/td&gt;
&lt;td&gt;0.4800&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−0.0100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;[−0.0153, −0.0048]&lt;/td&gt;
&lt;td&gt;182, &lt;strong&gt;smallest 1 row&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Finer cells bought variance, not reach; and the placebo gives the round in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;train-fit gain from pure noise : mean +0.0051   95th pct +0.0075
P-SYN's real train-fit gain    :      +0.0073   -&amp;gt; p = 0.065
the per-class ceiling          :      +0.0063
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Syntax carries a little real signal, sitting above the noise mean where the confidence features sat below it. But the ceiling (+0.0063) and the noise floor (+0.0051) &lt;strong&gt;are the same size&lt;/strong&gt;. The entire signal available to a per-class router is no larger than the bias from fitting one. That is a statement about per-class routing on this tool, not about every router anyone could build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same shape, one switch over
&lt;/h2&gt;

&lt;p&gt;There is a second thing in this tool applied unconditionally. A suffix layer re-ranks candidates by how the token being typed ends, which helps when you are halfway through a word, and it is weighted the same everywhere. A per-position oracle over &lt;em&gt;whether to apply it&lt;/em&gt; is worth &lt;strong&gt;+0.0235, CI [+0.0208, +0.0262]&lt;/strong&gt;, which clears the +0.020 bar. A perfect switch would be worth shipping, which is why it was worth trying.&lt;/p&gt;

&lt;p&gt;The best fitted switch, given free choice of threshold and direction over the 5,325 training positions, &lt;strong&gt;returns the identity. It vetoes nothing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The reason is arithmetic. The layer changes the top-ranked candidate on 2,670 positions: &lt;strong&gt;it breaks 282 of them and fixes 1,015.&lt;/strong&gt; So a veto that fires blindly would trade away three and a half fixes for every break it prevents, and one that fires selectively has to find breaks at better than one per fix to come out ahead. Since breaks are only 0.28 of fixes to begin with, a useful veto needs to be about &lt;strong&gt;3.6 times&lt;/strong&gt; as selective as chance. The best ratio reachable by &lt;strong&gt;any&lt;/strong&gt; threshold on &lt;strong&gt;any&lt;/strong&gt; of the four available features is 0.48 breaks per fix, which is &lt;strong&gt;1.7 times&lt;/strong&gt; the base rate. Not enough, by a factor of two, so the optimiser correctly declines to fire at all.&lt;/p&gt;

&lt;p&gt;My stated mechanism was wrong there too, in every feature. I had reasoned that the layer overrides a &lt;em&gt;confident&lt;/em&gt; transformer, so a veto should fire on high confidence. Having the right answer ranked first and being confident are different quantities, and the breaks sit on the &lt;strong&gt;low&lt;/strong&gt;-confidence side for all four features.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this leaves
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The ceiling is the contribution.&lt;/strong&gt; +0.0705 with a confidence interval, stable under grid density, is a number this project did not have, and it bounds every future attempt. The next person does not have to find out whether there is anything there, only whether their features can reach it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;44.6% of positions are wrong at every α&lt;/strong&gt;, which is what caps the oracle in the first round. Routing cannot touch those. Only a better arm can, and that is a different project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is untried is features, not method.&lt;/strong&gt; The tabular policy was never the limitation: it beat nothing because its inputs carried nothing, and when its inputs finally carried something, the objective was flat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The online version is now known to be unpromising rather than unmeasured.&lt;/strong&gt; Measured offline over logged positions, every arm can be scored at every α, so the answer is fully available; a deployed system sees only whether the completion it showed was accepted. With full information these features found nothing, and less information cannot help.&lt;/p&gt;

&lt;h2&gt;
  
  
  What generalises
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Measure the ceiling before you build the router.&lt;/strong&gt; It costs one pass over logged data, it is the same measurement whatever policy you would have built, and it tells you the largest number any of them could return. Mine came back large, which is why I kept going, and three feature families later the interesting discovery is that a large ceiling and a flat objective are perfectly compatible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A best choice that moves is not a best choice that pays.&lt;/strong&gt; I wrote a criterion asking whether the optimum shifts across classes, it held at a span of 0.30, and I nearly reported that as the good news. The lift table is the one that matters and it was one extra column of the same data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Print the bias from fitting noise next to every fitted gain.&lt;/strong&gt; Two of the three families here produced a positive training-set gain. In both cases permuting the labels produced a larger one. That comparison is cheap, it fits in three lines of output, and without it both rounds would have had a number that looked like a small win.&lt;/p&gt;

</description>
      <category>measurement</category>
      <category>llm</category>
      <category>codecompletion</category>
      <category>statistics</category>
    </item>
    <item>
      <title>A Learner That Scored Below Its Own Random Control</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Mon, 05 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/a-learner-that-scored-below-its-own-random-control-49c2</link>
      <guid>https://dev.to/megapixel99/a-learner-that-scored-below-its-own-random-control-49c2</guid>
      <description>&lt;p&gt;&lt;code&gt;appgen&lt;/code&gt; is a tool of mine that turns a sentence into a running application with no language model in the generating path. Something has to read the sentence first, and one field of that reading does more work than the rest: the &lt;strong&gt;entity&lt;/strong&gt;, the noun the generated app calls a row. It becomes a table name, a form field and a URL path, so getting it wrong means the right program gets built about the wrong thing.&lt;/p&gt;

&lt;p&gt;Today that field is read by a hand-written keyword rule. A local language model reads it better, and I am not simply using the model, for two reasons: it costs a 30-billion-parameter forward pass per request against a rule that costs nothing, and &lt;a href="https://sethwheeler.dev/blog/planner-seat/" rel="noopener noreferrer"&gt;when I measured the whole stack end to end the model's plan won no requests the rule's did not&lt;/a&gt;. So the interesting question is whether something cheap and &lt;em&gt;learned&lt;/em&gt; could sit between them. I put a learner there, and it scored &lt;strong&gt;below random units of the same shape&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The learner is &lt;code&gt;growone&lt;/code&gt;, a small online model of mine that starts with no hidden units and adds them as it goes. Its selling point is an open key universe: it is built for features that arrive mid-stream, which is exactly what happens when each token of a request becomes a feature key and the corpus keeps reaching words it has not seen. It produces one number per candidate rather than a sequence, so the entity is never &lt;em&gt;generated&lt;/em&gt;: every token of the request is scored, the highest wins, and a &lt;em&gt;this request names nothing to track&lt;/em&gt; sentinel is carried as one more candidate rather than as a special case. No part-of-speech filter and no stop list, because a hand-written stop list is a rule, and importing a rule into a learner's candidate set is how a learner takes credit for somebody else's work.&lt;/p&gt;

&lt;p&gt;The corpus is 75 project titles with frozen hand-written gold answers, drawn from three third-party lists: &lt;a href="https://github.com/codecrafters-io/build-your-own-x" rel="noopener noreferrer"&gt;build-your-own-x&lt;/a&gt;, &lt;a href="https://github.com/practical-tutorials/project-based-learning" rel="noopener noreferrer"&gt;project-based-learning&lt;/a&gt; and &lt;a href="https://github.com/The-Cool-Coders/Project-Ideas-And-Resources" rel="noopener noreferrer"&gt;Project-Ideas-And-Resources&lt;/a&gt;. The gold set holds 87 rows; twelve of them name a domain the tool already has a table for, so the reader is never consulted on those, and 75 reach it. The code is in a private research repo, so there is no link to that. Every figure comes from a results file or a command I ran, and I say which.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparable arm, and the one that looks respectable
&lt;/h2&gt;

&lt;p&gt;There are two protocols here and only one of them may be set beside the baselines. The &lt;strong&gt;cross-corpus&lt;/strong&gt; arm trains on 47 gold rows from a different corpus, freezes, and evaluates. That is cold on these 75 rows, exactly as the rule and the model are. The &lt;strong&gt;prequential&lt;/strong&gt; arm predicts each row before learning from it, which is &lt;code&gt;growone&lt;/code&gt;'s native protocol and is not comparable, because by the time a late row is scored the net has already seen fifty earlier rows of the same corpus.&lt;/p&gt;

&lt;p&gt;Every learner score below is a mean over seeds, which is why some of them are not integers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;of 75&lt;/th&gt;
&lt;th&gt;what it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the model&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;qwen3-coder:30b, one run, transcribed from an earlier round rather than re-run here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the shipped rule&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;the hand-written incumbent, re-measured on today's tree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prequential&lt;/td&gt;
&lt;td&gt;33.5&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;not&lt;/strong&gt; comparable to the two above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;control&lt;/code&gt;: random units, same shape and schedule&lt;/td&gt;
&lt;td&gt;17.0&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;floor&lt;/code&gt;: growth switched off, so a plain linear model on the same features&lt;/td&gt;
&lt;td&gt;17.0&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;the grown net&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15.5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the comparable arm&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;always answer &lt;code&gt;post&lt;/code&gt;, the most common gold noun&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That 47 deserves a footnote, because this post is partly about stale numbers. The write-up that first scored this rule on these 75 rows published 29; the same scorer returns 47 today, because the rule has been worked on since. Every figure here is the re-measurement.&lt;/p&gt;

&lt;p&gt;The 33.5 is the number that looks respectable and it is the one that should not be set against the rule. The comparable figure is 15.5.&lt;/p&gt;

&lt;p&gt;And 15.5 is the interesting number for a reason that has nothing to do with the rule. &lt;strong&gt;The search is worth −1.5 rows against random units of the same shape and the same schedule.&lt;/strong&gt; That is &lt;code&gt;growone&lt;/code&gt;'s own question, &lt;em&gt;did the hidden units do anything&lt;/em&gt;, asked on a real task rather than on a generator.&lt;/p&gt;

&lt;p&gt;Two seeds each, so be careful with the sign: the grown arm scored 17 and 14, the random-unit control 18 and 16, and the linear floor 17 twice. The round's pre-set band for a real difference on this arm was 2.9 rows, and 1.5 is inside it. So the finding is not that searching for units &lt;em&gt;hurts&lt;/em&gt;; it is that it &lt;strong&gt;buys nothing measurable&lt;/strong&gt;, against a control drawn to have exactly the same shape and schedule. The tool's own README reports the same thing on its home ground, where a linear floor beats the grown net at every size and randomly drawn units match the searched ones.&lt;/p&gt;

&lt;p&gt;The only arm it clears is the constant. I should say what that constant nearly was. The first version answered with the last token of the request, on the reasoning that English noun phrases are head-final. It scored 1 of 75, because these titles end in the framework: &lt;em&gt;…with React Native&lt;/em&gt;. An arm that cannot win makes the comparison against it pass by construction, which tests nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The candidate set is not the excuse
&lt;/h2&gt;

&lt;p&gt;The obvious objection is that picking the highest-scoring token of the request is a bad design: a row whose gold noun is not in the sentence cannot be won by it, however good the learner. That is true and it is worth 11 rows.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;of 75&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ceiling: some candidate is accepted by the grader&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;64&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unwinnable, because no token of the request is the answer&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same ceiling with the rule's own answer added as a candidate&lt;/td&gt;
&lt;td&gt;64, &lt;strong&gt;no change&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rows the rule gets right that are not reachable as a raw token&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The eleven are not hard rows, they are rows where the noun is simply absent: &lt;em&gt;Fitness App&lt;/em&gt; wants &lt;code&gt;workout&lt;/code&gt;, &lt;em&gt;A COVID-19 Tracker&lt;/em&gt; wants &lt;code&gt;case&lt;/code&gt;, &lt;em&gt;Tik Tok Clone - MERN STACK&lt;/em&gt; wants &lt;code&gt;video&lt;/code&gt;. Supplying those is world knowledge, not extraction, and nothing in this round touches it.&lt;/p&gt;

&lt;p&gt;Read against 64 instead of 75, every rate multiplies by the same 1.172 and no ranking moves. What the third line settles is the excuse: &lt;strong&gt;adding the rule's own answer to the candidate set raises the ceiling by zero rows&lt;/strong&gt;, and not one row the rule gets right is unreachable as a raw token. The learner was picking from a set that contained the rule's answer every time it mattered, and picking something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handing it the answer
&lt;/h2&gt;

&lt;p&gt;So I handed it the answer directly. One extra feature key marks whichever candidate the shipped rule chose, and everything else stays. All arms in this section are prequential, so they are comparable to each other and all of them carry the same advantage over the cold baselines: 74 rows of practice the rule and the model never get.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;mean of 75, five seeds&lt;/th&gt;
&lt;th&gt;what it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ceiling&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;oracle over the candidate set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the model&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;transcribed, not re-run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;rule_only&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a net given the rule's pick and nothing else&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the rule&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;the incumbent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hybrid_floor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;41.0&lt;/td&gt;
&lt;td&gt;hybrid features, growth switched off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;hybrid&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40.4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;all the lexical features &lt;strong&gt;plus&lt;/strong&gt; the rule's pick&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;base&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32.6&lt;/td&gt;
&lt;td&gt;the lexical features alone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule's read is worth a real and reliable &lt;strong&gt;+7.8 rows&lt;/strong&gt; to the learner, from 32.6 to 40.4. Every seed gains between six and ten rows and &lt;strong&gt;not one seed loses a row&lt;/strong&gt;, at p between 0.0020 and 0.0312. It is still not enough to reach the rule that supplied it, and that comparison does not resolve: the rule leads the discordant count (the rows where exactly one of the two is right) in all five seeds, and no seed gets below p = 0.096. At n = 75 the ranking is simply unresolved, which is weaker than calling it a loss and is what the numbers support.&lt;/p&gt;

&lt;p&gt;Then there is the row I did not expect. &lt;code&gt;rule_only&lt;/code&gt;, a net handed the rule's pick and nothing else, scores &lt;strong&gt;48.0&lt;/strong&gt;. &lt;code&gt;hybrid&lt;/code&gt;, which is that same key plus thirteen families of lexical features, scores &lt;strong&gt;40.4&lt;/strong&gt;. &lt;strong&gt;Adding features to a working signal costs about eight rows&lt;/strong&gt;, and &lt;code&gt;rule_only&lt;/code&gt; leads the discordant count in every seed.&lt;/p&gt;

&lt;p&gt;The mechanism is not mysterious. The token, suffix and neighbouring-word keys are dense and arrive on every candidate; the rule's pick is a single key arriving on one candidate; and the net has 75 rows in which to learn that the rare key outranks the common ones. It is the same shape as something one layer up in the same stack, where &lt;a href="https://sethwheeler.dev/blog/planner-seat/" rel="noopener noreferrer"&gt;a planner filling a field suppressed the reading the layer below was already doing&lt;/a&gt;. Here it is a token vocabulary drowning the rule's.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rule_only&lt;/code&gt; beating the rule itself, 48 against 47, is &lt;strong&gt;not&lt;/strong&gt; a result. It splits 2–1 discordant on every seed at p = 1.0. It is a pinned arm landing where a pinned arm should, and its value is precisely that it did: a harness that could not reproduce the rule from the rule's own answer would have invalidated every other number on this page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three arms separated by one key will report a margin whether or not the key does anything
&lt;/h2&gt;

&lt;p&gt;So the key was blinded. The rule's accessor is rebound in memory to return nothing, and each arm is required to move the way its name demands.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;with the key blinded&lt;/th&gt;
&lt;th&gt;unblinded&lt;/th&gt;
&lt;th&gt;what it must do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;base&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;33.3&lt;/td&gt;
&lt;td&gt;32.6&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;must not move&lt;/strong&gt;; it never had the key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hybrid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;33.3&lt;/td&gt;
&lt;td&gt;40.4&lt;/td&gt;
&lt;td&gt;must collapse onto &lt;code&gt;base&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rule_only&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;21.0&lt;/td&gt;
&lt;td&gt;48.0&lt;/td&gt;
&lt;td&gt;must collapse to no signal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three did. The load-bearing one is &lt;code&gt;base&lt;/code&gt;: a check that every arm responds to shows only that the harness is live, and the arm that must &lt;em&gt;not&lt;/em&gt; respond is what scopes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Growth earns nothing, and the totals were order-dependent
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;mean&lt;/th&gt;
&lt;th&gt;per seed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;hybrid&lt;/code&gt;, growing&lt;/td&gt;
&lt;td&gt;40.4&lt;/td&gt;
&lt;td&gt;41, 41, 39, 39, 42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;hybrid_floor&lt;/code&gt;, growth switched off&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;41, 41, 41, 41, 41&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The floor is higher and it is identical on every seed. On this task, growth buys seed variance and nothing else.&lt;/p&gt;

&lt;p&gt;A follow-up round found something else in those totals. The corpus arrives in source order, and the three lists are fully contiguous: 4 rows from one, then 38, then 33. So a prequential learner trains on one &lt;strong&gt;register&lt;/strong&gt;, meaning one house style of writing project titles, and is then tested inside it. Shuffle the order and hold the seed count fixed, and the learner drops 3.7 rows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;rule&lt;/th&gt;
&lt;th&gt;learner&lt;/th&gt;
&lt;th&gt;hybrid&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;shipped order, 5 seeds&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;32.6&lt;/td&gt;
&lt;td&gt;40.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shipped order, 20 seeds&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;32.9&lt;/td&gt;
&lt;td&gt;40.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;shuffled, 20 seeds&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29.2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;37.9&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The middle row is why the bottom one means anything. Seed count moves 0.3 rows; the order moves 3.7. Comparing five unshuffled seeds against twenty shuffled ones would have confounded the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Another label does help, and it never changes the ordering
&lt;/h2&gt;

&lt;p&gt;The prequential protocol scores row &lt;em&gt;i&lt;/em&gt; with a net that has seen rows 0 to &lt;em&gt;i&lt;/em&gt;−1, so accuracy by position &lt;strong&gt;is&lt;/strong&gt; the label curve. Over twenty shuffles, by thirds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;third&lt;/th&gt;
&lt;th&gt;rule&lt;/th&gt;
&lt;th&gt;hybrid&lt;/th&gt;
&lt;th&gt;learner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;first&lt;/td&gt;
&lt;td&gt;0.648&lt;/td&gt;
&lt;td&gt;0.384&lt;/td&gt;
&lt;td&gt;0.302&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;middle&lt;/td&gt;
&lt;td&gt;0.606&lt;/td&gt;
&lt;td&gt;0.544&lt;/td&gt;
&lt;td&gt;0.404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;last&lt;/td&gt;
&lt;td&gt;0.626&lt;/td&gt;
&lt;td&gt;0.586&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.462&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule is the control here, not a baseline. It does not learn, so its per-third rate is row difficulty alone, and it is flat to slightly down. Every row of the learner's rise is therefore learning rather than an easier tail, and it comes to &lt;strong&gt;+4.55 rows&lt;/strong&gt; over that control, rising at every step.&lt;/p&gt;

&lt;p&gt;That is a real learning signal, and it changes nothing about the ordering: rule, then hybrid, then learner, in every third, with the gap in the last third still about four rows in twenty-five. The stream runs out of labels before anything crosses.&lt;/p&gt;

&lt;p&gt;A straight line through &lt;em&gt;gained 0.16 accuracy over roughly fifty labels, still 0.164 short&lt;/em&gt; says another fifty would close it. &lt;strong&gt;That line is not evidence and I am not drawing it.&lt;/strong&gt; Learning curves flatten, the reachable maximum for this design is 64 of 75 rather than 75, and nothing here measures the shape past label 75. What is established is narrower and still useful: the curve had not flattened when the labels ran out, so &lt;em&gt;more labels would not help&lt;/em&gt; is refuted, and the label budget is the live lever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What generalises
&lt;/h2&gt;

&lt;p&gt;Three things, in the order I would want to be told them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A learner's own control is the comparison that matters, and it is the one that gets left out.&lt;/strong&gt; Against the incumbent rule this arm loses by 31.5 rows and the honest summary is "not close". Against random units of the same shape and schedule it is level, and that is the number saying the &lt;em&gt;search&lt;/em&gt; bought nothing, which is a claim about the method rather than about the task.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adding a feature to a working signal can subtract.&lt;/strong&gt; I would have said more features is weakly monotone, and it cost eight rows here, for the ordinary reason that dense keys outvote a rare one when there are only 75 rows to sort it out in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A pooled rate hides whether a learner is failing or merely starving.&lt;/strong&gt; The same 32.6 comes out of a method that does not work and a method that has not finished; only the per-position curve separates them, and here it says the second one, while also saying the ordering never moved.&lt;/p&gt;

</description>
      <category>measurement</category>
      <category>llm</category>
      <category>statistics</category>
      <category>codegeneration</category>
    </item>
  </channel>
</rss>
