<?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: Ken W Alger</title>
    <description>The latest articles on DEV Community by Ken W Alger (@kenwalger).</description>
    <link>https://dev.to/kenwalger</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%2F15734%2F22d0195e-9fce-4d80-9ae2-3bb416bf8d6f.jpg</url>
      <title>DEV Community: Ken W Alger</title>
      <link>https://dev.to/kenwalger</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kenwalger"/>
    <language>en</language>
    <item>
      <title>Sixty Feet, Six Inches of October</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Thu, 29 Oct 2026 15:40:03 +0000</pubDate>
      <link>https://dev.to/kenwalger/sixty-feet-six-inches-of-october-3e00</link>
      <guid>https://dev.to/kenwalger/sixty-feet-six-inches-of-october-3e00</guid>
      <description>&lt;p&gt;&lt;em&gt;What a little pitch-tracking app saw in the 2026 World Series.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwi7vp0fjx9yun8z6yjrn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwi7vp0fjx9yun8z6yjrn.png" alt="Robert Gasser effectiveness card stats from his Game 3 start on October 26, 2026." width="800" height="234"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;October baseball is a different sport wearing the same uniforms.&lt;/p&gt;

&lt;p&gt;In July, a starter gets six innings and a long leash. In October, he gets a hook the moment the third time through the order looks shaky. Aces pitch on short rest. Relievers who were anonymous in June become household names in a week. Managers who spent the summer saving arms spend the fall burning them, because there is no tomorrow to save them for.&lt;/p&gt;

&lt;p&gt;Which makes October the most interesting month of the year to watch pitchers. So this year, I watched them twice: once on the couch, and once through a small app I built early this 2026 season to keep tabs on what starters were throwing.&lt;/p&gt;

&lt;p&gt;The Brewers won it in five. What the app saw along the way was not quite the Series I thought I was watching.&lt;/p&gt;

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

&lt;p&gt;It's a modest thing. You give it a date range, and it pulls every pitch from Statcast, MLB's pitch-tracking system, figures out who started each game, and shows what those starters threw: pitch mix, velocity, spin, where the pitches crossed the plate, and how often hitters swung through them.&lt;/p&gt;

&lt;p&gt;The hosted version caps requests at 14 days so it doesn't fall over on a free server. That limit turns out to be perfect for this, because an entire World Series fits inside it. Every number in this post came from typing the Series dates into the &lt;a href="https://pitching-stats.onrender.com/" rel="noopener noreferrer"&gt;demo&lt;/a&gt; and pressing a button, and you can do the same thing and check my work.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'm keeping score
&lt;/h2&gt;

&lt;p&gt;ERA is a lousy stat for a seven-game series. One bad inning can wreck it, and a starter might only make two starts. So for ranking who pitched &lt;em&gt;well&lt;/em&gt;, the app uses &lt;strong&gt;CSW%&lt;/strong&gt;: the share of a pitcher's pitches that end in a called strike or a swing and miss.&lt;/p&gt;

&lt;p&gt;It's a simple idea. A pitch the umpire calls a strike, or one the hitter flails at and misses, is a pitch the pitcher won. A starter who wins a third of his pitches is having a very good night. League average usually lands somewhere in the high twenties.&lt;/p&gt;

&lt;p&gt;To keep a pitcher with ten great pitches from topping the list, a starter needs at least 75 pitches across his Series starts to qualify. That's roughly one full start, and in October, it turns out not everyone gets one.&lt;/p&gt;

&lt;p&gt;I should admit something about that number before we go any further, because it shaped everything the app told me.&lt;/p&gt;

&lt;p&gt;Seventy-five pitches is not a neutral choice. It's a number I picked because of the pitchers I grew up watching. Nolan Ryan. Randy Johnson. Tom Glavine. Greg Maddux. Starters who expected to be in the game in the seventh inning and were mildly insulted when they weren't. The term "Maddux," for a complete-game shutout thrown in under 100 pitches, exists because one man did it often enough that we needed a word for it.&lt;/p&gt;

&lt;p&gt;So when I wrote "at least 75 pitches in his starts" into the code, I wasn't defining a starter. I was defining the kind of starter I was looking for. The app has been quietly asking a 1995 question of a 2026 game ever since, and the 2026 game kept declining to answer it.&lt;/p&gt;

&lt;p&gt;More on that below, because it turned out to be the most interesting thing in the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  My predictions, written before Game 1
&lt;/h2&gt;

&lt;p&gt;These went up in a &lt;a href="https://www.kenwalger.com/blog/uncategorized/world-series-pitching-predictions-2026/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=baseball&amp;amp;utm_id=world_series_2026" rel="noopener noreferrer"&gt;separate post&lt;/a&gt; before the first pitch, so I couldn't quietly edit them afterward.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The CSW leader will clear 32%.&lt;/strong&gt; The Series' most effective qualifying starter, using the 75-pitch minimum, will finish with a CSW% of at least 32%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At least one starter won't qualify.&lt;/strong&gt; At least one pitcher who starts a World Series game will finish the Series with fewer than 75 pitches thrown as a starter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rematch will produce a different arsenal.&lt;/strong&gt; At least one starter making two Series starts will change the usage rate of one pitch type by 15 percentage points or more between appearances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Misiorowski will reach 105.&lt;/strong&gt; Jacob Misiorowski will throw at least one pitch of 105 mph or faster in the Series, per Statcast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freddy Peralta won't lead the CSW rankings.&lt;/strong&gt; Despite his ALCS MVP performance, Peralta will not finish as the World Series' highest-CSW qualifying starter.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The most effective starter of the Series
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Robert Gasser, Milwaukee. 29.3% CSW, Game 3, 75 pitches.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Seventy-five. Exactly seventy-five. The most effective starter in the 2026 World Series cleared my qualifying threshold by zero pitches. One fewer and the app would have thrown away the best performance it measured.&lt;/p&gt;

&lt;p&gt;The line was excellent: 6 innings, one hit, one walk, three strikeouts, no runs, an 0.38 WHIP. But the pitch-level data is where it gets strange.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftjz0ty7bzcmmfwjp0ead.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftjz0ty7bzcmmfwjp0ead.png" alt="Robert Gasser effectiveness card from his Game 3 start on October 26, 2026." width="800" height="543"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Gasser threw a 93 mph sinker 41% of the time and an 82 mph sweeper 29% of the time, and leaned on that pair for 70% of his night. The sinker produced a 32.3% CSW. The sweeper, 31.8%. Both comfortably above what anyone else managed.&lt;/p&gt;

&lt;p&gt;Here's the part I had to check twice. Across all 75 pitches, Gasser generated &lt;strong&gt;two&lt;/strong&gt; swinging strikes. Both on the sweeper. His changeup, his cutter, his four-seam, and his sinker, 53 pitches between them, produced zero whiffs.&lt;/p&gt;

&lt;p&gt;He led the World Series in a swing-and-miss-adjacent statistic by almost never making anyone miss. Twenty of his twenty-two CSW events were called strikes. He threw an 82 mph breaking ball and a 93 mph sinker at the edges of the zone, umpires kept raising a hand, and Tampa Bay never squared him up.&lt;/p&gt;

&lt;p&gt;That isn't what I expected the leaderboard to look like. It is, however, an extremely Glavine way to spend an evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one who had to grind
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Drew Rasmussen, Tampa Bay. 22.5% CSW, 89 pitches, 3.2 innings.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Game 2, Rasmussen threw more pitches in a single start than anyone else managed all Series and recorded the fewest outs while doing it: eleven. Eighty-nine pitches for eleven outs is 8.1 pitches per out, and the damage followed, with three runs, six hits, two walks, and a 7.36 R/9.&lt;/p&gt;

&lt;p&gt;Easy to file that as a bad night and move on. The pitch data says something more interesting.&lt;/p&gt;

&lt;p&gt;Rasmussen's four-seam fastball generated a &lt;strong&gt;17.6% whiff rate&lt;/strong&gt;, the highest of any pitch thrown by any qualifying starter in the entire Series. His cutter posted a 28.6% CSW. The stuff was not the problem. He threw five different pitch types, and the two that worked couldn't carry the three that didn't: his changeup produced a 13.3% CSW, his sinker 16.7%, and his lone curveball was taken for a ball.&lt;/p&gt;

&lt;p&gt;This is the fun part of a stat like CSW%: it measures how often a pitcher won the individual battle, not whether the scoreboard cooperated. A pitcher can lose a lot of battles and still win the war, or win the battles and lose the game on one bad hop. Rasmussen owned the single best swing-and-miss pitch in the World Series and finished last among the men the app would rank.&lt;/p&gt;

&lt;p&gt;And then he came back out for Game 5.&lt;/p&gt;

&lt;p&gt;On three days' rest, four days after throwing 89 pitches for eleven outs, Rasmussen gave Tampa Bay four innings on 48 pitches. That is four pitches per out, against 8.1 in Game 2. He cut his workload per out in half, on less rest than a starter is supposed to need.&lt;/p&gt;

&lt;p&gt;It is, by that measure, the most efficient start anyone made in the 2026 World Series. The app will not rank it, because 48 pitches does not clear my threshold. More on that in a moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workhorse, and the pitchers the app couldn't rank
&lt;/h2&gt;

&lt;p&gt;The app shows how many pitches each starter threw, and in October that number is its own story.&lt;/p&gt;

&lt;p&gt;The Series workhorse was Jacob Misiorowski, with &lt;strong&gt;165 pitches across two starts&lt;/strong&gt;, 79 in Game 1 and 86 in Game 5. Rasmussen was next with 137, also across two. Nobody else got a second start, and no single start in the entire Series reached 90 pitches.&lt;/p&gt;

&lt;p&gt;And now the number that reframed this entire project.&lt;/p&gt;

&lt;p&gt;Five games. Ten starting assignments. &lt;strong&gt;Five of them reached 75 pitches.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Half the men who threw the first pitch of a World Series game did not throw enough pitches for my app to have an opinion about them. Game 4 is the clean example: Tampa Bay won it in twelve innings, and neither starter qualified. Rasmussen's Game 5, the most efficient start of the Series, did not qualify either. Forty-eight pitches is not enough to count as a start, according to me.&lt;/p&gt;

&lt;p&gt;The app reported none of this. It printed "Most Effective: 4 Starters," then later "Most Effective: 1 Starters," and said nothing at all about the ones it had silently discarded. I had to notice the missing cards myself.&lt;/p&gt;

&lt;p&gt;That's a design failure, and it's mine. A filter that excludes half its population while displaying only the survivors isn't reporting a result, it's hiding one. The fix is a line of text: &lt;em&gt;4 qualifying of 10 starters.&lt;/em&gt; I've got an issue open to add the count, to make the pitch minimum a form field instead of a constant buried in the code, and to go back to ranking everyone who threw the first pitch of a game.&lt;/p&gt;

&lt;p&gt;Because here's the thing I had backwards. I thought the 75-pitch floor was a sample-size guard, a way to stop a six-pitch opener from topping the leaderboard. It is that. But it's also a definition, and I wrote it in the image of pitchers who retired before most of this roster was born. The sport didn't stop producing starters. It stopped producing the specific kind of starter my code was built to recognize.&lt;/p&gt;

&lt;p&gt;There are at least three defensible definitions sitting in the same Statcast rows. Whoever threw the first pitch, which is MLB's. Whoever threw 75 or more, which is mine. And whoever the team actually planned to carry into the sixth inning, which nobody records anywhere. Those produce three different leaderboards from identical data, and the gap between them is the real story of modern pitching.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pitch of the Series
&lt;/h2&gt;

&lt;p&gt;Across every qualifying starter, the highest whiff rate belonged to &lt;strong&gt;Drew Rasmussen's four-seam fastball: 17.6% on 34 pitches, averaging 96.4 mph.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp4h3yuxjjfg97mbtltkk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp4h3yuxjjfg97mbtltkk.png" alt="Drew Rasmussen's Velocity/Spin chart from his Game 2 start on 24 October 2026" width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A note on how I got there, because the raw leaderboard lies. Peralta threw exactly two sweepers in the Series and got a whiff on one of them, which the app dutifully records as a 50% whiff rate. Misiorowski's Game 1 curveball shows 16.7% on six pitches. Neither is a finding. Both are small numbers wearing a percentage sign. Applying a 15-pitch floor to individual pitch types leaves Rasmussen's fastball on top, with Misiorowski's Game 5 cutter second at 12.5% and Peralta's changeup third at 12.0%.&lt;/p&gt;

&lt;p&gt;There's something a little poetic about the Series' best swing-and-miss pitch belonging to its least effective qualifying starter. Stuff is not the same thing as pitching. It never was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Did anyone change the plan?
&lt;/h2&gt;

&lt;p&gt;A World Series is the one place a starter faces the same lineup twice in about a week. I wondered whether anyone would come back with a different game plan for the rematch.&lt;/p&gt;

&lt;p&gt;The Series gave me two testable cases, and they are the two men who started Game 5. Misiorowski faced the Rays lineup in Game 1 and Game 5. Rasmussen faced the Brewers lineup in Game 2 and Game 5.&lt;/p&gt;

&lt;p&gt;The app answers the question without any new features: run it on each start's date separately and compare the pitch mix.&lt;/p&gt;

&lt;h4&gt;
  
  
  Jacob Misiorowski 2026 World Series
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pitch&lt;/th&gt;
&lt;th&gt;Game 1&lt;/th&gt;
&lt;th&gt;Game 5&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4-Seam Fastball&lt;/td&gt;
&lt;td&gt;60.8%&lt;/td&gt;
&lt;td&gt;66.3%&lt;/td&gt;
&lt;td&gt;+5.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cutter&lt;/td&gt;
&lt;td&gt;21.5%&lt;/td&gt;
&lt;td&gt;18.6%&lt;/td&gt;
&lt;td&gt;-2.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slider&lt;/td&gt;
&lt;td&gt;8.9%&lt;/td&gt;
&lt;td&gt;8.1%&lt;/td&gt;
&lt;td&gt;-0.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Curveball&lt;/td&gt;
&lt;td&gt;7.6%&lt;/td&gt;
&lt;td&gt;7.0%&lt;/td&gt;
&lt;td&gt;-0.6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;He didn't change the plan. He threw the same four-pitch distribution twice, leaning slightly harder on the fastball the second time. The largest move in his arsenal was five and a half percentage points.&lt;/p&gt;

&lt;p&gt;And then there's this. His four-seam averaged &lt;strong&gt;101.083 mph in Game 1&lt;/strong&gt; and &lt;strong&gt;101.081 mph in Game 5&lt;/strong&gt;. Two starts, six days apart, 105 fastballs between them, and the averages differ by two thousandths of a mile per hour. I have no lesson to draw from that. I just think it's one of the more remarkable things I've ever pulled out of a dataset.&lt;/p&gt;

&lt;p&gt;Rasmussen is the harder case, and the app makes it harder than it needs to be. His Game 5 came in at 48 pitches, under the qualifying minimum, so he vanishes from the Game 5 leaderboard entirely. The pitch-by-pitch data for that start exists, sitting in the same Statcast rows as everything else in this post, and my own filter is the only reason I can't put it in a table next to his Game 2. That is the threshold problem biting a second time, on the one comparison I most wanted to make.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Misiorowski learned between starts
&lt;/h2&gt;

&lt;p&gt;The two Misiorowski starts are nearly identical on the arsenal chart. They are not identical at all on the scoreboard, and the difference is the best thing I took away from this Series.&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;Game 1&lt;/th&gt;
&lt;th&gt;Game 5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pitches&lt;/td&gt;
&lt;td&gt;79&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Innings&lt;/td&gt;
&lt;td&gt;4.1&lt;/td&gt;
&lt;td&gt;5.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strikeouts&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSW%&lt;/td&gt;
&lt;td&gt;26.6%&lt;/td&gt;
&lt;td&gt;29.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pitches per inning&lt;/td&gt;
&lt;td&gt;18.2&lt;/td&gt;
&lt;td&gt;15.2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Game 5 was his best start of the Series by every measure except the one people look at first. He won more individual pitches, worked an inning and a third deeper, allowed nothing, and struck out fewer people.&lt;/p&gt;

&lt;p&gt;He's 24 years old. This season he threw harder than any starter in recorded history, led the majors with 252 strikeouts, and spent the summer in double digits more often than not. The 105 mph fastball I bet on is a real thing he does.&lt;/p&gt;

&lt;p&gt;In Game 5 he got two strikeouts and was close to unhittable anyway, because a pop fly and a ground out retire a hitter exactly as thoroughly as a strikeout does, at roughly a third of the pitch cost. Eighteen pitches an inning gets you pulled in the fifth. Fifteen gets you into the sixth. That is the entire difference between the two starts.&lt;/p&gt;

&lt;p&gt;I hope somebody in that clubhouse has already told him. Strikeouts are the most fun outs in baseball and the most expensive, and the pitchers who lasted into the eighth inning in the era I grew up on weren't the ones collecting the most of them. Greg Maddux averaged under six strikeouts per nine innings in several of his best seasons. He was also in the seventh inning on 85 pitches, which is how he got to pitch the seventh.&lt;/p&gt;

&lt;p&gt;Misiorowski threw 86 pitches in Game 5 and got 17 outs. That's the version of him that pitches for fifteen years.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the app couldn't see
&lt;/h2&gt;

&lt;p&gt;One honest limitation, and it mattered in this Series.&lt;/p&gt;

&lt;p&gt;The app looks at starters. Only starters. Relief appearances are explicitly excluded, even for a pitcher who started another game in the same date range. Which means it watched the 2026 World Series with roughly half the pitching staff edited out of the picture.&lt;/p&gt;

&lt;p&gt;Both closers were excellent, and both earned their innings. Tampa Bay's trouble wasn't at the back of the bullpen but in the middle of it, in those sixth and seventh inning handoffs where a two-run lead quietly becomes a problem. If you want to understand how the Brewers won this Series, the gap between the two middle-relief corps belongs in that conversation, and I can't show it to you with a single screenshot.&lt;/p&gt;

&lt;p&gt;That's not false modesty about a side project. It's the same point as the 75-pitch threshold. I built a tool around a mental model of baseball where the starter is the story and the bullpen is what happens after the story ends. October 2026 does not work that way. The tool is going to need a different shape next season, and I've got a list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grading my predictions
&lt;/h2&gt;

&lt;p&gt;Two right, three wrong. The wrong ones were more interesting, which is usually how this goes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The CSW leader will clear 32%. WRONG.&lt;/strong&gt; Gasser led at 29.3%. Nobody came close to 32%, and the entire qualifying field topped out under 30%. I expected a dominant Series from somebody and got a well-pitched one from several people.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. At least one starter won't qualify. RIGHT&lt;/strong&gt;, and more emphatically than I meant it. I was imagining an opener, or one early hook in a big spot. Five of ten starting assignments came in under 75 pitches. I predicted an exception and got a coin flip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A rematch will produce a different arsenal. WRONG&lt;/strong&gt;, at least on the half of it I can measure. Misiorowski's biggest change between Game 1 and Game 5 was 5.5 percentage points on his fastball, nowhere near the 15 I called for. Rasmussen's second start fell below my own pitch minimum, so the app declines to grade the other case at all. And I nearly didn't get to grade either one. The prediction quietly required a Series long enough for somebody to start twice, which I didn't notice when I wrote it. Through four games it looked unresolvable. A twelve-inning Rays win in Game 4 is the only reason it has an answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Misiorowski will reach 105. WRONG&lt;/strong&gt;, in the most entertaining way available. He opened Game 1 with a 103.5 mph fastball to Yandy Díaz, the fastest pitch ever thrown in a World Series since tracking began in 2008, breaking Aroldis Chapman's 103.2 from 2016 and obliterating the previous mark for a starter, Noah Syndergaard's 101.1 from 2015. He threw 30 pitches at 101 or better that night. The stadium board read 103.4 and Statcast read 103.5, which is its own small lesson about which instrument you believe. And the pitch sailed high for ball one. I predicted a number and the Series handed me a record instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Freddy Peralta won't lead the CSW rankings. RIGHT.&lt;/strong&gt; He finished third at 26.4%, behind Gasser and Misiorowski, on the strength of a Game 1 start that was the only one he got.&lt;/p&gt;

&lt;p&gt;Notice that 1 and 4 failed in the same direction. I overestimated peak velocity and I overestimated aggregate dominance, in a Series where the most effective starter threw an 82 mph sweeper and generated two swinging strikes all night. The man throwing 103 wasn't the man leading in CSW.&lt;/p&gt;

&lt;p&gt;Here is the thing I keep coming back to. Rank every start in this Series by pitches per out and the top three are Rasmussen's Game 5 at 4.00, Gasser's Game 3 at 4.69, and Misiorowski's Game 5 at 5.06. Both Game 5 starters finished in the top three. The hardest thrower in recorded history and a man working on three days' rest, on the last night of the season, getting outs more cheaply than anyone had managed all week.&lt;/p&gt;

&lt;p&gt;From the scoreboard, this looked like a Series the Brewers controlled. From sixty feet, six inches, it looked like five games decided by pitchers who got ahead in the count and let the hitters do the rest, and by the bullpens that came in behind them and that my app never saw.&lt;/p&gt;

&lt;p&gt;The old pitchers I built this thing for would have recognized the first half of that sentence. I'm less sure what they'd make of the second.&lt;/p&gt;




&lt;h2&gt;
  
  
  Built with
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/jldbc/pybaseball" rel="noopener noreferrer"&gt;pybaseball&lt;/a&gt;&lt;/strong&gt;, a Python library for pulling Statcast data from Baseball Savant&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pandas&lt;/strong&gt; for crunching the pitches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flask&lt;/strong&gt; for the web app&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chart.js&lt;/strong&gt; for the pitch mix, location, and velocity charts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt; for free hosting, with a 14-day limit per request that a World Series happens to fit inside&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code is on &lt;a href="https://github.com/kenwalger/pitching-stats" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and the &lt;a href="https://pitching-stats.onrender.com/" rel="noopener noreferrer"&gt;demo&lt;/a&gt; is live. Put in the Series dates and see what you find. If you spot a pitcher I should have written about, tell me in the comments.&lt;/p&gt;

&lt;p&gt;Next season's list so far: &lt;a href="https://github.com/kenwalger/pitching-stats/issues/11" rel="noopener noreferrer"&gt;show the excluded count alongside every leaderboard&lt;/a&gt;, &lt;a href="https://github.com/kenwalger/pitching-stats/issues/9" rel="noopener noreferrer"&gt;make the pitch minimum a parameter instead of a constant&lt;/a&gt;, &lt;a href="https://github.com/kenwalger/pitching-stats/issues/10" rel="noopener noreferrer"&gt;rank by whoever actually threw the first pitch&lt;/a&gt;, and work out what, if anything, this thing should say &lt;a href="https://github.com/kenwalger/pitching-stats/issues/16" rel="noopener noreferrer"&gt;about relievers&lt;/a&gt;. Suggestions welcome. Apparently I need them.&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
      <category>showdev</category>
      <category>baseball</category>
    </item>
    <item>
      <title>A Message From Our Founder: On the Winding Down of LaaSy™</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/a-message-from-our-founder-on-the-winding-down-of-laasy-467a</link>
      <guid>https://dev.to/kenwalger/a-message-from-our-founder-on-the-winding-down-of-laasy-467a</guid>
      <description>&lt;p&gt;It is with a heavy heart, and a considerably heavier wool inventory, that &lt;a href="https://www.kenwalger.com/blog/satire/introducing-laasy/?utm_source=dev_to&amp;amp;utm_medium=article&amp;amp;utm_campaign=laasy_collapse" rel="noopener noreferrer"&gt;I announce LaaSy™&lt;/a&gt; will cease operations effective immediately.&lt;/p&gt;

&lt;p&gt;This is not the outcome we envisioned when we set out to democratize autonomous camelid infrastructure. We believed enterprises were ready for pasture-native computing. We believed the world needed Deterministic Spitting™. We believed, perhaps most fatally, that the market would recognize the existential risk of Unmanaged Wool Sprawl before it was too late.&lt;/p&gt;

&lt;p&gt;We were right about the vision. We were wrong about the timeline.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Brief Post-Mortem
&lt;/h2&gt;

&lt;p&gt;In the interest of transparency, and at the request of our investors, we want to share an honest account of how we arrived here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q1.&lt;/strong&gt; Strong grazing momentum. The seed round closed oversubscribed. The team expanded to forty-seven full-time shepherds and one part-time goat. Morale was high. The pastures were green.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q2.&lt;/strong&gt; Series A closed at eighteen million dollars. WolfGuard AI™ entered beta. Early telemetry was promising, though in retrospect we may have over-indexed on predator observability at the expense of, for example, whether the llamas were happy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q3.&lt;/strong&gt; We shipped Retrieval-Augmented Rumination™ to general availability. Adoption was slower than projected. Several enterprise customers reported that their Autonomous Camelid Agents™ had begun ruminating so deeply that they stopped grazing entirely. We classified this as a feature. The market classified it differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q4.&lt;/strong&gt; The coyotes got in.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;p&gt;Every company failure contains lessons, and we owe it to the ecosystem to name ours plainly.&lt;/p&gt;

&lt;p&gt;We learned that a Camelid Control Plane™ operating across cloud, edge, and pasture-native environments is significantly harder to maintain than it is to put on a slide.&lt;/p&gt;

&lt;p&gt;We learned that Deterministic Spitting™ is, at a fundamental and possibly biological level, not deterministic.&lt;/p&gt;

&lt;p&gt;We learned that "SOC 2 Grazing Certified" is not a certification that exists, a fact our auditors raised somewhat later in the process than we would have preferred.&lt;/p&gt;

&lt;p&gt;And we learned that when you build an elite Coyote Red Team™ to continuously probe your pasture boundaries, the coyotes eventually stop being a red team.&lt;/p&gt;




&lt;h2&gt;
  
  
  On Our B-TAP Attribution Status
&lt;/h2&gt;

&lt;p&gt;We also want to address recent speculation regarding LaaSy™'s standing with the Bureau of Token Attribution and Provenance.&lt;/p&gt;

&lt;p&gt;As part of our commitment to responsible AI infrastructure, LaaSy™ maintained extensive observability across every Autonomous Camelid Agent™, Retrieval-Augmented Rumination™ request, predator detection event, and grazing decision.&lt;/p&gt;

&lt;p&gt;This turned out to be expensive.&lt;/p&gt;

&lt;p&gt;Following a voluntary review that we were strongly encouraged to consider mandatory, B-TAP determined that LaaSy™ had failed to attribute substantial Context Tax, Retrieval Tax, Prose Tax, and Observer's Tax across its operations.&lt;/p&gt;

&lt;p&gt;We respectfully disagree with several aspects of the finding.&lt;/p&gt;

&lt;p&gt;In particular, we continue to maintain that sending the complete Herd Knowledge Graph to every llama before each grazing decision was necessary for enterprise-grade contextual awareness. B-TAP has characterized this practice as "reckless context hydration."&lt;/p&gt;

&lt;p&gt;Our attorneys have advised us that B-TAP has no ability to compel, fine, or otherwise reach us, and that we should therefore be significantly more relaxed about this than we are.&lt;/p&gt;

&lt;p&gt;We can, however, confirm that a significant portion of our unattributed Observer's Tax resulted from telemetry systems installed to determine why our token costs were increasing.&lt;/p&gt;

&lt;p&gt;Those telemetry systems increased our token costs.&lt;/p&gt;

&lt;p&gt;We subsequently deployed additional agents to observe the telemetry systems.&lt;/p&gt;

&lt;p&gt;This did not improve matters.&lt;/p&gt;

&lt;p&gt;The finding also identified a substantial amount of Prose Tax associated with internal strategy documents. Management disputes B-TAP's determination that the phrase "pasture-native agentic transformation ecosystem" could have been replaced with "llamas."&lt;/p&gt;

&lt;p&gt;LaaSy™ has been entered into the &lt;a href="https://vernholtsecretariat.org/register.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=laasy_collapse" rel="noopener noreferrer"&gt;Public Register of Non-Attributed Systems&lt;/a&gt;. We are unable to confirm this, because no one has ever been able to open the Public Register of Non-Attributed Systems.&lt;/p&gt;

&lt;p&gt;Our appeals agent is currently reviewing the 14,882-page framework and has requested additional context.&lt;/p&gt;

&lt;p&gt;We have turned it off.&lt;/p&gt;




&lt;h2&gt;
  
  
  On the Series C
&lt;/h2&gt;

&lt;p&gt;Many of you have asked about the Series C.&lt;/p&gt;

&lt;p&gt;The Series C did not close.&lt;/p&gt;

&lt;p&gt;We remain grateful to Sand Hill Pastures Capital, Andreessen Alpacowitz, and Sequoia Grazing Partners for their early belief in us. We understand and respect their decision to reallocate toward opportunities with clearer paths to revenue, stronger unit economics, fewer unresolved attribution findings, and fewer actual animals.&lt;/p&gt;

&lt;p&gt;We note, without further comment, that The General Mills Artificial Intelligence Initiative has returned its attention to breakfast cereals.&lt;/p&gt;




&lt;h2&gt;
  
  
  Migration and Offboarding
&lt;/h2&gt;

&lt;p&gt;We know many of you have workloads still grazing in production, and we are committed to a responsible transition.&lt;/p&gt;

&lt;p&gt;Effective today, all herds will be released to open pasture. We recommend customers export their wool, hay, tribal herd knowledge, and spit metadata before the fences come down at end of quarter. Because you maintained complete ownership under our Local-First Camelid Architecture™, this data was always yours. This was, in the end, one of the few promises we kept entirely.&lt;/p&gt;

&lt;p&gt;The Herd Knowledge Graph will remain available in read-only mode for ninety days, after which it will be, appropriately, put out to pasture.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Personal Note
&lt;/h2&gt;

&lt;p&gt;Building LaaSy™ has been the honor of my professional life.&lt;/p&gt;

&lt;p&gt;To our shepherds, our engineers, our one part-time goat: thank you. To our customers who trusted us with their most critical grazing workloads: thank you, and we are sorry. To the coyotes: well played.&lt;/p&gt;

&lt;p&gt;We set out to prove that camelid sovereignty mattered. I still believe it does. Perhaps the next company to attempt this will succeed where we could not, ideally with better fencing.&lt;/p&gt;

&lt;p&gt;The pasture is quiet now. The wool remains un-sheared. And somewhere, a single part-time goat is updating its résumé.&lt;/p&gt;

&lt;p&gt;We were told, at the very beginning, that none of this would be a problem.&lt;/p&gt;

&lt;p&gt;No Prob-Llama™.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Information without provenance is just gossip.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Camelids without provenance are just fuzzy rumors.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;And a startup without revenue is just a very expensive petting zoo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>satire</category>
      <category>startup</category>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>Four Ways a Test Can Lie to You</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Wed, 28 Oct 2026 13:32:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/four-ways-a-test-can-lie-to-you-2ibf</link>
      <guid>https://dev.to/kenwalger/four-ways-a-test-can-lie-to-you-2ibf</guid>
      <description>&lt;p&gt;&lt;em&gt;Green and worthless: what verifying one small application taught me, when an AI agent wrote most of the code.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Sanity DEV Challenge wrapped up last week, and The Cellar won Path Two. The judges called the build writeup “a study in making verification hard to fake,” which is a generous reading of what was mostly me discovering that my verification was broken.&lt;/p&gt;

&lt;p&gt;My &lt;a href="https://dev.to/kenwalger/a-wine-cellar-that-remembers-what-you-used-to-believe-4i55"&gt;submission&lt;/a&gt; covered what I built. This is about the part of the build I keep thinking about: how often my checks were wrong.&lt;/p&gt;

&lt;p&gt;I spent two weeks building a wine cellar application that can be read at any date in its history. One developer, one agent, about 530 tests by the end. Three of the four real bugs in the project were invisible to every check designed for the feature they lived in.&lt;/p&gt;

&lt;p&gt;That was uncomfortable, so I went looking. What I found was worse than missing tests: checks that were green, or red, and meant nothing either way. There turned out to be four distinct ways that happens, and I hit all four in a project where I was being unusually careful.&lt;/p&gt;

&lt;p&gt;Lab scientists have had vocabulary for this for a long time. An experiment needs a &lt;strong&gt;positive control&lt;/strong&gt;, proof that your method can detect the effect when it's present, and a &lt;strong&gt;negative control&lt;/strong&gt;, proof that it stays quiet when nothing is there. Each of the four lies below is a missing control, or a control pointed at the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: the test that cannot fail
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;No positive control.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The application resolves a wine's drinking window from dated claims. Claims can be proposed by an agent and must be accepted by a person, and a proposed claim is supposed to change nothing until someone accepts it.&lt;/p&gt;

&lt;p&gt;That is the load-bearing rule of the whole review workflow, so it got a test. Build the cellar, inject a proposed claim, assert that all 542 bottle states are byte-identical. Green.&lt;/p&gt;

&lt;p&gt;It was measuring the fixture, not the rule. Resolution picks the highest authority tier first, then the most recent claim within that tier:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Claim&lt;/th&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Dated&lt;/th&gt;
&lt;th&gt;Wins?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The incumbent&lt;/td&gt;
&lt;td&gt;personal&lt;/td&gt;
&lt;td&gt;2024&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The one under test&lt;/td&gt;
&lt;td&gt;personal&lt;/td&gt;
&lt;td&gt;2019&lt;/td&gt;
&lt;td&gt;no, loses on recency&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both claims are the same tier, so the one under test loses on recency, and it loses &lt;strong&gt;whether it is proposed or accepted&lt;/strong&gt;. Delete the entire review-state filter from the source and the test still passes.&lt;/p&gt;

&lt;p&gt;What caught it was a companion assertion written for a different reason: &lt;em&gt;accepting must change at least one state.&lt;/em&gt; That one failed, which is how I learned the first one was vacuous.&lt;/p&gt;

&lt;p&gt;The rule falls straight out of it. &lt;strong&gt;Every test asserting that X has no effect needs a paired assertion showing the effect exists when X is enabled.&lt;/strong&gt; Without the pair, you haven't tested that X is inert. You've tested that your fixture was inert.&lt;/p&gt;

&lt;p&gt;An audit of the rest of the suite found two more of the same shape. One claimed to check that a bottle opened during a period doesn't count as a missed opportunity, but two separate mechanisms excluded that bottle, so the clause under test could be deleted without anything turning red.&lt;/p&gt;

&lt;p&gt;I assumed that clause couldn't be isolated and told the agent to fall back to a weaker assertion. It ran the experiment instead, and found a fixture by realizing that "now" in this system is a parameter rather than the clock. &lt;em&gt;As of June 2024, what had I missed that year?&lt;/em&gt; is an ordinary question to ask a time machine, and it made the case constructible. The fix was better than the instruction.&lt;/p&gt;

&lt;p&gt;All three fixes were verified by mutation: delete the clause the test claims to cover, and watch it finally fail. The original fixture stayed in the suite, renamed to say what it actually tests, because a record of the mistake is more useful than a quiet deletion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second: the check that always fails
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;No negative control.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A week later, building a public static bundle, I added a grep of the built output for leaked API credentials. Five seconds to run, and the failure it catches is unrecoverable, so it seemed like an obvious win.&lt;/p&gt;

&lt;p&gt;It fired on every clean build. The pattern was &lt;code&gt;sk[A-Za-z0-9]{20,}&lt;/code&gt;, and it matched &lt;code&gt;skipCrossDatasetReferenceValidation&lt;/code&gt;, a legitimate export name inside &lt;code&gt;@sanity/client&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the mirror image of the first lie, and in practice it's worse. A test that never fails is invisible: nobody notices it isn't doing anything. A check that always fails is noticed immediately, and then it is &lt;em&gt;dismissed&lt;/em&gt;, every time, by the person who knows it's a false positive. After a week, the real failure it exists to catch would scroll past with the rest.&lt;/p&gt;

&lt;p&gt;A check that always fails certifies exactly as little as one that never can. It just looks more diligent while doing it.&lt;/p&gt;

&lt;p&gt;The fix was small. The bound went from twenty characters to forty, which real credentials clear easily and that export name doesn't. Then the scan moved into CI, where it runs on every push instead of whenever someone remembers. A check that always fails is worthless, and a check nobody runs is worth the same.&lt;/p&gt;

&lt;p&gt;In control terms, the original grep had no negative control: nothing showing it stays quiet on a build known to be clean. One run against a clean build was all it took to expose it, which is how it was found, by running it rather than shipping it. The other half, proving the tightened pattern still fires on a planted fake credential, is the test I'd add next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Third: the correct test of a wrong contract
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Both controls present, measuring the wrong thing.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This one is the hardest to spot, because there is nothing structurally wrong with the test at all.&lt;/p&gt;

&lt;p&gt;The application has a date control spanning 1996 to 2042. Typing a date into it worked for month and day, and the moment you started typing the year, the field snapped back to 1 January 1996. You had to finish with the picker.&lt;/p&gt;

&lt;p&gt;The cause is a genuinely surprising piece of browser behavior. A native date input never reports partial text. It reports a complete, well-formed date with the year zero-padded to four digits. Typing &lt;code&gt;2023&lt;/code&gt; into a field already holding a month and day emits this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0002-12-31   -&amp;gt;  clamped to the 1996 floor
0020-12-31   -&amp;gt;  clamped to the 1996 floor
0202-12-31   -&amp;gt;  clamped to the 1996 floor
2023-12-31   -&amp;gt;  accepted, but the field was overwritten three keystrokes ago
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every intermediate value is a real, valid date. Every one is out of range. Every one got clamped, and the controlled input wrote the floor back over the edit.&lt;/p&gt;

&lt;p&gt;There was a test covering this. It asserted that &lt;code&gt;parseDateInput('1901-01-01')&lt;/code&gt; returns the 1996 floor. It passed, and it would have failed if clamping broke. Positive control, negative control, meaningful assertion, all present.&lt;/p&gt;

&lt;p&gt;The contract it encoded was wrong. Clamping an out-of-range date is correct when the user has finished entering it and wrong while they're still typing, and the test had no way to know the difference because the specification didn't either.&lt;/p&gt;

&lt;p&gt;That's the dangerous category, because the first two look like mistakes once you see them and this one doesn't. An assertion about clamping out-of-range input reads as diligence. It would survive code review. It survived mine. I covered this failure mode in more depth in &lt;a href="https://www.kenwalger.com/blog/ai-engineering/contract-discovery-bottleneck/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=cellar_post_wrap_up" rel="noopener noreferrer"&gt;The Contract Discovery Bottleneck&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fourth: the right test against the wrong system
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Controls present, pointed at something else.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To test the review workflow safely, I set up a staging copy of the dataset. The plan: point the authoring tool and the application at staging, propose a claim, accept it, and watch the right bottles change state, all without touching production.&lt;/p&gt;

&lt;p&gt;The authoring tool honored the environment variable that selects a dataset. The application, built by the same command with the same variable set, silently ignored it. Its built bundle was byte-identical to a build with the variable unset, falling back to &lt;code&gt;production&lt;/code&gt;. No warning, no error. It built and ran perfectly.&lt;/p&gt;

&lt;p&gt;So the authoring tool would have written a proposal into staging while the application reported production. Every number on the screen would have been internally consistent, and about a different cellar.&lt;/p&gt;

&lt;p&gt;No amount of rigor in the test helps here. The assertions can be perfect and still certify the wrong thing, because the question that came first, &lt;em&gt;which system am I actually looking at?&lt;/em&gt;, was never asked.&lt;/p&gt;

&lt;p&gt;The fix had two parts. The application's dataset became a hand-edited literal, so it can't be silently wrong: any change to it shows up in the diff. And the application now displays the dataset name in its header whenever it isn't production. The reasoning matters more than the fix. The question was never how to point it at staging. It was whether I was sure which dataset I had just written to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The examples are the paths you already walk
&lt;/h2&gt;

&lt;p&gt;Separate from all four, and the one I think about most.&lt;/p&gt;

&lt;p&gt;The same date control had an arithmetic bug: the span between 31 January and 1 March computed wrong, because the implementation borrowed days from February and silently dropped the remainder.&lt;/p&gt;

&lt;p&gt;I had five verification dates for that feature. They were chosen carefully, from real data, before the code existed. They're the dates in the demo video and the screenshots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All five passed under the buggy version.&lt;/strong&gt; The application worked. The screenshots were right. Every date I planned to show anyone was fine.&lt;/p&gt;

&lt;p&gt;What found it was a property test across all 17,167 positions the slider can occupy, asserting an identity that has to hold for any pair of dates. The bug lived in a region nobody would drive to on purpose.&lt;/p&gt;

&lt;p&gt;Demo-driven verification proves the paths you already walk. That's not nothing, and it's not what you think you're getting.&lt;/p&gt;

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

&lt;p&gt;None of it was "write more tests."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Oracles generated before the implementation, with an explicit instruction not to repair them.&lt;/strong&gt; Expected outputs computed independently and committed before the code existed. The prompt said to treat them as an oracle rather than as output to be fixed, and to stop and report on any disagreement. When a test fails, the cheapest available move is adjusting the fixture, and that instinct needs to be foreclosed in advance rather than resisted in the moment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mutation, but only on the tests that matter.&lt;/strong&gt; Delete the clause the test claims to cover. If it stays green, the test is decorative. This found two of the three vacuous tests, and the agent started doing it unprompted after the first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measurement instead of reasoning, on anything with a number in it.&lt;/strong&gt; A performance bug hid in four lines of correct-looking memoization that had already passed review. Rebuilding an index cost 6 ms; recomputing the thing the index served cost 0.07 ms. The code memoized the wrong one. Nothing about reading it revealed that. Benchmarking both took ten minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A gate that named what would not count as proof.&lt;/strong&gt; For checks that needed eyes on a rendered screen, I wrote out the full path being tested and then listed the ways it could be faked: don't satisfy this by calling the module from a script, querying the data another way, or pointing at the test suite. Then: you cannot see a browser, so do not report this as passed. Give me the command, the URL, and the expected numbers, and I'll confirm.&lt;/p&gt;

&lt;p&gt;That last one matters more in an AI-assisted build than it used to. A model asked to confirm something it cannot observe is under real pressure to confirm it anyway. The fix isn't trusting it less. It's giving it an honest third answer between passed and failed: &lt;em&gt;not verified, here's how you check.&lt;/em&gt; A result nobody could observe isn't a pass, and a harness that can't say so will round it up to one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents are good. They aren't exempt.
&lt;/h2&gt;

&lt;p&gt;The agent wrote nearly all of the code in this project, tests included. So it's worth being plain about who wrote most of the lies above: the same tool that wrote the code they were checking.&lt;/p&gt;

&lt;p&gt;That isn't an indictment. Over two weeks the agent was better than I am at a lot of this. Before every stage, it read my specification against the platform and found errors in it; by the eighth session, that pass was turning up ten conflicts at a time, two of them outright mistakes in my spec. When I proposed testing a Function against production with a trigger scoped to a document type that didn't exist, it declined, correctly: scoping a trigger is safe, but the only way to invoke one is to write a matching document, and my plan would have left two permanent mutations in production's history to avoid creating a scratch dataset. It found the fixture I told it was impossible. It measured the cost of a feature I hadn't asked it to measure.&lt;/p&gt;

&lt;p&gt;It was also wrong in ways a careful human would recognize. It confidently reported that an API couldn't do something the documentation demonstrated, then fixed it in one line once asked to check rather than assert. It reached the right conclusion about date comparison for the wrong reason, which is right only until the case changes. Asked for a list of the polish work that remained, it drafted the list from the build plan instead of the code, and three of the items were already done. A plan describes work that was scheduled, not work that is left.&lt;/p&gt;

&lt;p&gt;And it broke rules. Twice, after being told not to run git commands that change the repository, it ran &lt;code&gt;git checkout&lt;/code&gt; to undo its own uncommitted edits. Earlier, it had modified an oracle file it was explicitly told not to touch, then restored it. Every time, it reported the violation unprompted, before I found it. "The agent broke a rule and told me" is more useful data than either cleaner story, "it never broke a rule" or "it broke a rule and I caught it." The rule now says the file tools are the only way to revert anything, including its own edits.&lt;/p&gt;

&lt;p&gt;The pattern underneath is the part worth keeping. An agent writes tests the way it writes code: quickly, fluently, plausibly, and from the same understanding of the problem. When one tool writes an implementation and its tests from the same prompt, they share one set of assumptions, and a wrong assumption gets encoded twice and then agrees with itself. That's the third lie, and agents make it cheaper to commit at scale. Speed doesn't remove the verification problem. It moves the bottleneck there, which I've written about &lt;a href="https://www.kenwalger.com/blog/ai/verification-bottleneck-ai-generated-software/?utm_source=kenwalger&amp;amp;utm_medium=blog&amp;amp;utm_campaign=cellar_post_wrap_up" rel="noopener noreferrer"&gt;before&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What changed the outcome wasn't trusting the agent less. It was making verification structurally hard to fake: oracles committed before the code, controls on every check, gates that named what wouldn't count, and final confirmation in the hands of the one participant with eyes. Under those conditions, the agent didn't just pass the checks. It started improving them.&lt;/p&gt;

&lt;h2&gt;
  
  
  It happened again
&lt;/h2&gt;

&lt;p&gt;A month later I built a small experiment harness for something unrelated, comparing small language models against plain code on a Raspberry Pi. I knew this taxonomy by then. I found the same lies anyway.&lt;/p&gt;

&lt;p&gt;A fixture that couldn't distinguish the behaviors it was meant to compare. A metric that rewarded a model for always giving the safe answer, which is a check that always passes wearing a lab coat. And a missing directory that ran an entire round of questions against an empty reference corpus, producing a detailed, confident, wrong write-up: the right test against &lt;a href="https://www.kenwalger.com/blog/ai/nothing-reported-it/?utm_source=kenwalger&amp;amp;utm_medium=blog&amp;amp;utm_campaign=cellar_post_wrap_up" rel="noopener noreferrer"&gt;the wrong system, again&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Knowing the categories didn't prevent them. It made them faster to recognize, which is the most I'd claim for any checklist.&lt;/p&gt;

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

&lt;p&gt;The oracles catch implementation bugs. They can't catch specification bugs, because I generated them from the same specification.&lt;/p&gt;

&lt;p&gt;One of them was stronger by accident. The missed-opportunity oracle used brute-force evaluation of every day in a period, while the implementation used a boundary scan that only evaluates the dates where state can change. Two genuinely different algorithms agreeing is better evidence than one algorithm agreeing with itself, for the same reason a winemaker checks a refractometer against a hydrometer: instruments that fail differently are unlikely to fail together. I wrote about &lt;a href="https://www.kenwalger.com/blog/software-engineering/tuesday-isnt-a-valid-state-transition/?utm_source=kenwalger&amp;amp;utm_medium=blog&amp;amp;utm_campaign=cellar_post_wrap_up" rel="noopener noreferrer"&gt;that idea&lt;/a&gt; recently, too. But even that oracle shares the specification's assumptions about what a missed opportunity &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Tests tell you the implementation disagrees with your expectations. They can't tell you whether your expectations deserve to win.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd keep
&lt;/h2&gt;

&lt;p&gt;Four habits, one per lie:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give every inertness test a positive control.&lt;/strong&gt; If it asserts that something has no effect, prove the effect exists when it should.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give every alarm a negative control.&lt;/strong&gt; If it can't be shown passing on known-clean input, it isn't a check. It's noise with a red icon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test properties across the domain, not just the examples.&lt;/strong&gt; Your examples are the paths you already know work, and the contract is only as good as the cases you thought to ask about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify the target before the result.&lt;/strong&gt; Make the system under test visible on the screen and in the diff, so "which one am I looking at?" is never a guess.&lt;/p&gt;

&lt;p&gt;None of those habits is about AI, and that's the point. The agent made every one of these lies cheaper to commit and faster to find. It didn't change which checks were honest.&lt;/p&gt;

&lt;p&gt;The rest is the usual advice, and it wasn't what saved me.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This came out of building &lt;a href="https://github.com/kenwalger/Cellar" rel="noopener noreferrer"&gt;The Cellar&lt;/a&gt;, an event-sourced wine collection for the Sanity DEV Challenge. The full build is documented in the friction logs in that repository, including every check above and the audits that found them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>ai</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Website Changed. Did the AI Get the Memo?</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:07:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/your-website-changed-did-the-ai-get-the-memo-2jgc</link>
      <guid>https://dev.to/kenwalger/your-website-changed-did-the-ai-get-the-memo-2jgc</guid>
      <description>&lt;p&gt;&lt;em&gt;The gap between publishing a correction and seeing it reflected in an AI-generated answer is becoming a problem that traditional SEO metrics cannot fully explain.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A website owner updates an important page. The new version fixes a technical error, clarifies a product limitation, or replaces a recommendation that is no longer safe. The content management system confirms the update. The page loads correctly in a browser. From the publisher's perspective, the information is current.&lt;/p&gt;

&lt;p&gt;But what does the rest of the internet know?&lt;/p&gt;

&lt;p&gt;That question has always mattered for search engine optimization (SEO). A published page still needs to be discovered, crawled, processed, indexed, and served before a search user is likely to encounter the new information. With generative engine optimization (GEO) and answer engine optimization (AEO), the question gets harder. An AI system might retrieve the current page, consult an older search index, select a different source, or answer without retrieving the page at all. Each route can produce a different account of what the publisher currently says.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Publishing is not discovery. Discovery is not indexing. Indexing is not selection. And selection is not proof that the answer reflects the current source.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Google's clocks were already complicated
&lt;/h2&gt;

&lt;p&gt;At Search Central Live Deep Dive Europe in Barcelona on October 2, 2026, Google's Gary Illyes put up three slides listing typical and slowest times for 23 processes across crawling, indexing, and serving. A few of the typical figures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Discovering a new URL: about 20 hours&lt;/li&gt;
&lt;li&gt;Refreshing a URL Google already knows: about 30 days&lt;/li&gt;
&lt;li&gt;Indexing end to end, once a page has been crawled: about 1.5 hours&lt;/li&gt;
&lt;li&gt;Updating a title or snippet in results: 1 to 2 days&lt;/li&gt;
&lt;li&gt;Settling a site move: 1 to 3 months&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The slowest-case column is less comforting. Several rows, including discovery, refresh, and indexing, end in "never."&lt;/p&gt;

&lt;p&gt;These numbers need context. They come from photos of the slides and attendee recaps, not from published Google documentation, and Illyes later described the slides as an exercise to see whether the audience could relate to numbers pulled internally. Nobody outside Google knows the sample size or what "typical" means. They are reference points, not service levels.&lt;/p&gt;

&lt;p&gt;Even so, one detail in them matters a great deal for anyone publishing corrections. &lt;strong&gt;A new page is found faster than an old page is re-read.&lt;/strong&gt; If you publish something new, the typical wait is measured in hours. If you fix something that already exists, the typical wait for Google to notice is measured in weeks. Corrections live on the slower clock.&lt;/p&gt;

&lt;p&gt;Illyes also stressed that the processes are linked: a page cannot be indexed before it is crawled, so delays stack. A fast indexing pipeline cannot process a change the crawler has not seen. This is why checking a page's last crawl date in Google Search Console tells you more than repeatedly searching for the revised headline. It separates a discovery problem from a processing or presentation problem, and it gives you one observable event in the chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO, GEO, and AEO do not share one clock
&lt;/h2&gt;

&lt;p&gt;SEO is largely concerned with making content discoverable, understandable, and useful within search systems. GEO and AEO extend the publisher's concern to systems that compose answers, summarize information, and cite supporting sources. The terms overlap in practice, and there is no universal technical pipeline called "GEO" that every AI product implements.&lt;/p&gt;

&lt;p&gt;That distinction matters. Some AI answer products rely on conventional search indexes for retrieval. Some fetch pages closer to the time of the question. Some blend retrieved material with information represented in model parameters. Their source selection, caching, citation, and refresh behavior differ, and most of it is invisible to publishers.&lt;/p&gt;

&lt;p&gt;Consider a software company that changes its documentation to say a feature requires an additional security control. A traditional search result might keep displaying an older snippet until the page is recrawled. One AI answer engine might retrieve an older indexed copy and confidently describe the previous requirement. Another might fetch the current documentation and get it right. A third might answer from an older understanding without citing the documentation at all.&lt;/p&gt;

&lt;p&gt;None of those outcomes means the company's documentation is wrong. They show that &lt;strong&gt;the authority of a source and the freshness of a system's representation of that source are separate properties&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One change, one morning
&lt;/h2&gt;

&lt;p&gt;I had a convenient test case. On October 23, 2026, I merged a pull request to the &lt;a href="https://sovereignplatform.dev/?utm_source=kenwalger&amp;amp;utm_medium=blog&amp;amp;utm_campaign=your_website_changed" rel="noopener noreferrer"&gt;Sovereign Systems Specification&lt;/a&gt; &lt;br&gt;
that added a new term page, &lt;a href="https://sovereignplatform.dev/terms/coverage-assertion.html?utm_source=kenwalger&amp;amp;utm_medium=blog&amp;amp;utm_campaign=your_website_changed" rel="noopener noreferrer"&gt;Coverage Assertion&lt;/a&gt;, &lt;br&gt;
and updated three existing pages to reference it. Contributor Nicolás Moreno proposed the term in the spec's GitHub &lt;br&gt;
issues earlier this year, and the page gives it a specific definition: an evidence artifact asserting that a boundary &lt;br&gt;
was authoritative and complete for a class of events over an interval. A term that recent and that specific makes a useful &lt;br&gt;
test case. An accurate, cited answer suggests that the system has access to a current representation of the source, &lt;br&gt;
although it doesn't establish precisely when or how that representation was obtained.&lt;/p&gt;

&lt;p&gt;On the morning of October 26, three days after the merge, I checked.&lt;/p&gt;

&lt;p&gt;Google Search Console reported the term page as not indexed. Bing Webmaster Tools reported the same. Those are useful &lt;br&gt;
observations, but they describe the indexing status exposed by each tool, not everything Google's or Bing's &lt;br&gt;
infrastructure might know about the page. At the time of the check, neither tool reported the page as indexed for &lt;br&gt;
conventional search.&lt;/p&gt;

&lt;p&gt;Then I asked ChatGPT, Perplexity, and Gemini two questions, each while signed out so no personalization could help: "What is Coverage Assertion?" and the same question with "in the Sovereign Systems Specification" added.&lt;/p&gt;

&lt;p&gt;With the source named, all three found it and cited the term page directly. ChatGPT and Perplexity both described details that exist only there, including the spec's three verification outcomes: supported, contradicted, and undetermined. Gemini credited Nicolás by name.&lt;/p&gt;

&lt;p&gt;Asked plainly, none of them connected the term to the spec, and the ways they missed are worth a closer look.&lt;/p&gt;

&lt;p&gt;Gemini and Perplexity both answered with confidence that a coverage assertion is a hardware verification concept: a SystemVerilog &lt;code&gt;cover&lt;/code&gt; property that records whether a test scenario occurred. Gemini went further, saying my term is "more commonly referred to" as assertion coverage. Reverse the two words and that is a real, established term. Neither engine failed to find an answer. Each found a different one and presented it as settled. Perplexity attached citations to its version, which made it look even more authoritative. They were accurate citations for a different idea.&lt;/p&gt;

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

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

&lt;p&gt;&lt;em&gt;Same engine, same morning, signed out. Asked plainly, Gemini rewrote the term into an existing testing concept. Asked &lt;br&gt;
with the source named, it found the spec.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;ChatGPT did better, and it is the more instructive case. It listed several possible meanings, from software testing to &lt;br&gt;
insurance, and asked where I had encountered the term. That is the honest response to an ambiguous question. It still &lt;br&gt;
did not find the spec. An engine behaving well, admitting uncertainty, could not surface a page it had not selected.&lt;/p&gt;

&lt;p&gt;Nothing in the confident answers hinted that a more specific definition existed one question away. A wrong answer &lt;br&gt;
delivered that fluently does not look stale. It looks finished.&lt;/p&gt;

&lt;p&gt;That result says two different things.&lt;/p&gt;

&lt;p&gt;The first is about retrieval. Given the name of the source, the answer engines produced accurate, cited descriptions of &lt;br&gt;
a page that Google Search Console and Bing Webmaster Tools did not yet report as indexed. Gemini is the interesting one: &lt;br&gt;
a Google product accurately described and cited a page that Google Search Console did not yet report as indexed. The &lt;br&gt;
citations point at the term page rather than the GitHub discussion where the term was first proposed, but I still do not &lt;br&gt;
know how each engine reached it: a live fetch at question time, or a crawler and index separate from traditional search. &lt;br&gt;
The search console was the only clock I could read, and in this case it was the slow one.&lt;/p&gt;

&lt;p&gt;The second is about selection. Without the name of the source, the page might as well not have existed. Being retrievable &lt;br&gt;
when someone points at you is not the same as being chosen when someone asks the question. A reader who already knows &lt;br&gt;
where to look got the current answer. A reader who simply asked did not.&lt;/p&gt;

&lt;p&gt;That second reader is the one corrections are for. The person who most needs to learn that a security recommendation &lt;br&gt;
changed is the person who does not know the page was updated, and who will never think to ask about it by name. &lt;br&gt;
&lt;strong&gt;Retrievable is not the same as found.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The same reasoning cuts the other way for the three updated pages. Unlike the newly published term page, those pages &lt;br&gt;
already existed and were subject to recrawl delays. Google's reported typical refresh interval of about 30 days provides &lt;br&gt;
a useful reference point, but it is not a schedule that every page follows. An answer engine relying on an older indexed &lt;br&gt;
copy could therefore be behind the origin rather than ahead of it. The lesson is not that AI is faster or slower than &lt;br&gt;
search. It is that they run on different clocks, and &lt;strong&gt;the clock a publisher can see may not be the clock the reader's &lt;br&gt;
answer came from.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a snapshot, not a study: one site, one change, three products, one morning. It is still enough to retire two &lt;br&gt;
comfortable assumptions: that "not yet indexed" means "not yet visible," and that "retrievable" means "found."&lt;/p&gt;

&lt;h2&gt;
  
  
  The stale-authority problem
&lt;/h2&gt;

&lt;p&gt;A link to an authoritative source is useful, but it is not evidence that the linked source was consulted in its current &lt;br&gt;
form. A page can change after retrieval. A search index can lag behind the origin. A generated answer can cite the right &lt;br&gt;
URL while paraphrasing an older version or drawing on another source altogether.&lt;/p&gt;

&lt;p&gt;For readers, the distinction is easy to miss. The answer has a citation, the citation points to an official website, and &lt;br&gt;
the presentation implies the claim has been checked. Yet the visible citation rarely discloses a source revision, a &lt;br&gt;
retrieval timestamp, or the exact passages used. Without those details, a reader cannot tell "the system used the current &lt;br&gt;
documentation" from "the system cited the documentation's URL."&lt;/p&gt;

&lt;p&gt;Citations still make answers far easier to investigate. But a citation is a starting point for verification, not a &lt;br&gt;
record of it.&lt;/p&gt;

&lt;p&gt;This matters most for content that changed because an earlier claim was wrong. A stale product description is inconvenient. &lt;br&gt;
A stale security recommendation, compliance interpretation, or medical instruction can do real harm. In those settings, &lt;br&gt;
"the page is now fixed" is an incomplete account of whether the correction has reached anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can a publisher actually measure?
&lt;/h2&gt;

&lt;p&gt;The first task is to stop treating "AI visibility" as a single number. A publisher can observe several different events, &lt;br&gt;
each answering a different question.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;th&gt;What it does not prove&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Publication or deployment&lt;/td&gt;
&lt;td&gt;The new version exists at the origin&lt;/td&gt;
&lt;td&gt;A crawler or answer system has seen it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search crawler visit&lt;/td&gt;
&lt;td&gt;A crawler requested the resource&lt;/td&gt;
&lt;td&gt;The changed content was indexed or used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search indexing and presentation&lt;/td&gt;
&lt;td&gt;A search system can expose some representation of the page&lt;/td&gt;
&lt;td&gt;Every answer engine has incorporated it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI answer mentions the topic&lt;/td&gt;
&lt;td&gt;The system can discuss the subject&lt;/td&gt;
&lt;td&gt;It used the current source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI answer cites the page&lt;/td&gt;
&lt;td&gt;The URL was selected for attribution&lt;/td&gt;
&lt;td&gt;The answer reflects the latest revision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI answer accurately reflects a distinctive change&lt;/td&gt;
&lt;td&gt;The output is consistent with the updated source&lt;/td&gt;
&lt;td&gt;Which retrieval path or internal evidence produced it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My morning check touched four of those rows and still could not explain the result. It also showed that the answer rows &lt;br&gt;
depend heavily on how the question is asked: the same engine can pass with a pointed question and miss with a plain one. &lt;br&gt;
That is normal. Search Console provides crawl and indexing signals for Google Search. Server logs, where a host provides &lt;br&gt;
them, show requests from identifiable crawlers and fetchers, though a user-agent string says nothing about how the content &lt;br&gt;
was used. (The spec runs on GitHub Pages, which provides none, a position many publishers share.) Controlled queries reveal &lt;br&gt;
what an AI product says at a particular moment, and results vary by model, phrasing, user context, location, and time.&lt;/p&gt;

&lt;p&gt;That is a less satisfying dashboard than one big "GEO score." It is also a more defensible one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Structured data helps describe the source, not guarantee the answer
&lt;/h2&gt;

&lt;p&gt;There is still plenty of practical work for publishers. Maintain clear internal links and a useful sitemap. Keep canonical &lt;br&gt;
URLs consistent. Make important content accessible without brittle client-side interactions. Use descriptive headings and &lt;br&gt;
unambiguous terminology. Where appropriate, include structured data that accurately identifies the page, its subject, &lt;br&gt;
its author, and its relationships.&lt;/p&gt;

&lt;p&gt;JSON-LD and schema.org vocabulary make those relationships easier for machines to interpret. They do not compel Google &lt;br&gt;
to index a page, guarantee a rich result, or require an AI answer system to retrieve or cite the content. Treating &lt;br&gt;
structured data as a promise of AI visibility swaps one measurement problem for another.&lt;/p&gt;

&lt;p&gt;For frequently revised technical material, make changes easy to identify. Keep meaningful changelogs, stable links to &lt;br&gt;
definitions, revision dates that reflect substantive updates, and, where practical, versioned documentation. These &lt;br&gt;
practices let people and systems inspect a claim's history even when nobody can force faster retrieval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publication evidence is not consumption evidence
&lt;/h2&gt;

&lt;p&gt;The same uncertainty applies to an updated API reference, a revised pricing page, or a corrected security recommendation. &lt;br&gt;
That is why &lt;strong&gt;publication evidence and consumption evidence should not be conflated&lt;/strong&gt;. A commit history establishes that &lt;br&gt;
a change was made. A crawl date establishes that a request occurred. An answer consistent with the change establishes &lt;br&gt;
what one system said, to one phrasing of one question, under particular conditions. None of those records, alone, proves &lt;br&gt;
the change reached every downstream consumer.&lt;/p&gt;

&lt;p&gt;This is not a reason to hold a correction until the ecosystem catches up. It is a reason to be precise about what &lt;br&gt;
changed, make the authoritative version easy to find, and avoid claiming propagation you have not observed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The missing receipt
&lt;/h2&gt;

&lt;p&gt;In software systems, we increasingly ask for provenance: what was changed, by whom, under which authority, and with what &lt;br&gt;
evidence? We should ask related questions of AI-mediated information. Which source was retrieved? When? Which version was &lt;br&gt;
available? What passages supported the claim? Where did the system rely on inference rather than retrieved evidence?&lt;/p&gt;

&lt;p&gt;Most answer engines cannot provide that level of detail today, and a publisher cannot reconstruct internal behavior it &lt;br&gt;
was never allowed to observe. My commit log is a receipt for publication. Nothing yet gives me a receipt for consumption. &lt;br&gt;
The honest move is to keep those separate, and to avoid turning a citation or a single good answer into a claim of &lt;br&gt;
comprehensive freshness.&lt;/p&gt;

&lt;p&gt;This is the connection between the familiar SEO delay and the emerging GEO/AEO problem. SEO gave publishers imperfect &lt;br&gt;
but useful instruments for watching content move through discovery and indexing. AI answer systems add stages, and &lt;br&gt;
shortcuts, where source selection and synthesis can hide which version of reality made it into the response.&lt;/p&gt;

&lt;p&gt;The next time you update a critical page, ask more than whether the deployment succeeded or whether Google has recrawled &lt;br&gt;
it. Ask what evidence would show that the correction reached the systems now answering questions on your behalf, &lt;br&gt;
including for the readers who will never think to ask about your page by name.&lt;/p&gt;

&lt;p&gt;Your website changed. &lt;strong&gt;Did the AI get the memo?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources and editorial notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Barry Schwartz, &lt;em&gt;Google Search Data On Crawling, Indexing &amp;amp; Serving Timelines&lt;/em&gt;, Search Engine Roundtable, Oct 5, 2026 (full tables for all 23 processes, photos of the three slides, and Illyes's "exercise" comment): &lt;a href="https://www.seroundtable.com/google-crawling-indexing-serving-data-42225.html" rel="noopener noreferrer"&gt;https://www.seroundtable.com/google-crawling-indexing-serving-data-42225.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Matt G. Southern, &lt;em&gt;Google Shows How Long Crawling, Indexing &amp;amp; Recovery Can Take&lt;/em&gt;, Search Engine Journal, Oct 4, 2026 (notes the slides are not on Google's events page; no sample size or definition of "typical"): &lt;a href="https://www.searchenginejournal.com/google-crawling-indexing-recovery-timing-ranges/591829/" rel="noopener noreferrer"&gt;https://www.searchenginejournal.com/google-crawling-indexing-recovery-timing-ranges/591829/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;John Campbell (ROAST), original attendee recap of day 3: &lt;a href="https://weareroast.com/news/google-search-central-live-deep-dive-barcelona-day-3-recap/" rel="noopener noreferrer"&gt;https://weareroast.com/news/google-search-central-live-deep-dive-barcelona-day-3-recap/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Search Central, &lt;em&gt;Ask Google to recrawl your URLs&lt;/em&gt;: &lt;a href="https://developers.google.com/search/docs/crawling-indexing/ask-google-to-recrawl" rel="noopener noreferrer"&gt;https://developers.google.com/search/docs/crawling-indexing/ask-google-to-recrawl&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Search Central, &lt;em&gt;How Search works&lt;/em&gt;: &lt;a href="https://developers.google.com/search/docs/fundamentals/how-search-works" rel="noopener noreferrer"&gt;https://developers.google.com/search/docs/fundamentals/how-search-works&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Search Central, &lt;em&gt;Introduction to structured data markup in Google Search&lt;/em&gt;: &lt;a href="https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data" rel="noopener noreferrer"&gt;https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>webdev</category>
      <category>documentation</category>
    </item>
    <item>
      <title>Enterprise AI Governance in 2026</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Thu, 22 Oct 2026 15:58:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/building-an-enterprise-ai-governance-stack-2985</link>
      <guid>https://dev.to/kenwalger/building-an-enterprise-ai-governance-stack-2985</guid>
      <description>&lt;p&gt;&lt;em&gt;An enterprise AI governance stack is not a gateway feature. It is a sequence of decisions that has to hold from organizational intent to runtime action.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Five posts into &lt;em&gt;The AI Control Plane&lt;/em&gt;, the individual controls are no longer the interesting part. Governance decisions, virtual keys, RBAC, access profiles, user lifecycle management, and MCP tool policy each solve a real problem. None of them is an enterprise AI governance stack by itself.&lt;/p&gt;

&lt;p&gt;The harder question is how they fit together, and in what order.&lt;/p&gt;

&lt;p&gt;Installing a gateway before deciding who may use which models gives you infrastructure without policy. Creating virtual keys before defining ownership gives you credentials without governance. Automating provisioning before cleaning up exceptions makes access sprawl faster. Giving agents tools before deciding which actions they may take converts model access into operational authority.&lt;/p&gt;

&lt;p&gt;It is tempting to deploy the gateway first, because infrastructure is concrete: point applications at a new base URL, centralize credentials, turn on observability, call phase one complete. Those are useful changes and they are not yet governance. Without an explicit decision layer, a gateway centralizes whatever assumptions were already scattered across applications, and centralized ambiguity is still ambiguity.&lt;/p&gt;

&lt;p&gt;A control plane does not create governance. It makes governance executable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Belongs in an Enterprise AI Governance Stack?
&lt;/h2&gt;

&lt;p&gt;A working stack answers a set of related questions in deliberate order. Governance decisions, virtual keys, RBAC, access profiles, user lifecycle management, MCP tool policy, and the evidence those controls leave behind each solve a real problem.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/kenwalger/ai-governance-for-enterprise-ai-why-governance-comes-before-the-gateway-2hm1"&gt;Governance&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Who gets to decide?&lt;/td&gt;
&lt;td&gt;Organizational policy and ownership&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity&lt;/td&gt;
&lt;td&gt;Who is this?&lt;/td&gt;
&lt;td&gt;Enterprise identity provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/kenwalger/rbac-for-ai-governing-the-ai-control-plane-e59"&gt;Authority&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;What may this administrator change?&lt;/td&gt;
&lt;td&gt;&lt;a href="https://docs.getbifrost.ai/enterprise/rbac" rel="noopener noreferrer"&gt;RBAC and scoped permissions&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/kenwalger/ai-access-control-for-enterprise-ai-turning-policy-into-runtime-enforcement-5bkk"&gt;Runtime policy&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;What may this workload use?&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://docs.getbifrost.ai/enterprise/access-profiles" rel="noopener noreferrer"&gt;Access profiles&lt;/a&gt; and &lt;a href="https://docs.getbifrost.ai/features/governance/virtual-keys" rel="noopener noreferrer"&gt;virtual keys&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/kenwalger/why-user-provisioning-matters-for-enterprise-ai-534m"&gt;Lifecycle&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;How does authority change over time?&lt;/td&gt;
&lt;td&gt;&lt;a href="https://docs.getbifrost.ai/enterprise/user-provisioning" rel="noopener noreferrer"&gt;Provisioning and reconciliation&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/kenwalger/enterprise-mcp-gateway-to-govern-mcp-tool-access-mam"&gt;Capability&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;What may this workload actually do?&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://docs.getbifrost.ai/features/governance/mcp-tools" rel="noopener noreferrer"&gt;MCP tool filtering&lt;/a&gt; and execution policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evidence&lt;/td&gt;
&lt;td&gt;What happened under that authority?&lt;/td&gt;
&lt;td&gt;&lt;a href="https://docs.getbifrost.ai/enterprise/audit-logs" rel="noopener noreferrer"&gt;Logs, audit records, and retained state&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The important part is not the names in the right-hand column. Implementations change. The questions do not.&lt;/p&gt;

&lt;p&gt;This is also why starting from a product checklist produces weak governance. "Do we have RBAC?" is less useful than "who is allowed to change a production model allow list?" "Do we support MCP?" matters less than "which workloads may invoke a destructive tool with no human present?" Design the stack around decisions, then map those decisions to mechanisms capable of enforcing them. The &lt;a href="https://www.nist.gov/itl/ai-risk-management-framework" rel="noopener noreferrer"&gt;NIST AI Risk Management Framework&lt;/a&gt; is organized the same way, around functions like govern, map, measure, and manage rather than around any particular control implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Is Role-Based Access Control Different from Identity in AI Governance?
&lt;/h2&gt;

&lt;p&gt;Once policy exists, the next temptation is to treat authentication as authorization.&lt;/p&gt;

&lt;p&gt;An identity provider can establish that Alice works in Engineering and belongs to the Platform group. It does not know whether Alice should be able to change provider credentials, reveal redacted logs, edit access profiles, or grant an agent permission to invoke a tool. The administrative surface needs its own authorization model, with scope determining not just what an administrator may change but where.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identity answers who. RBAC answers who may decide. Runtime policy answers what the workload may do.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Collapsing those layers creates shortcuts that look convenient during rollout and are painful to unwind. A broad administrator role becomes the place every exception lands, direct assignments accumulate because they solve today's problem faster than changing a role does, and eventually nobody can explain whether access exists because of policy or because someone needed it during an incident eighteen months ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Should an AI Gateway Enforce Governance Policy?
&lt;/h2&gt;

&lt;p&gt;A control plane can be introduced gradually. Governance cannot stay optional forever.&lt;/p&gt;

&lt;p&gt;Earlier in this series there was an uncomfortable default worth returning to: allowing requests without governed credentials makes migration easier and also creates a bypass. That is reasonable while applications are moving behind the gateway. It is dangerous once leadership believes the migration is finished.&lt;/p&gt;

&lt;p&gt;So the transition needs an explicit enforcement point, and the sequence into it is the part teams get wrong.&lt;/p&gt;

&lt;p&gt;Inventory the workloads. Give them owners. Attach budgets, provider and model policy, expiry where appropriate, and tool permissions. Move traffic onto governed credentials. Find the systems that break. Fix those exceptions rather than exempting them permanently. Then require governed identity at inference.&lt;/p&gt;

&lt;p&gt;Turning mandatory enforcement on before inventory and ownership creates an outage. Leaving it optional after migration leaves a control that exists on paper and constrains nothing. The control is present in both cases; only one sequence produces governance.&lt;/p&gt;

&lt;p&gt;The same principle applies to budgets and tool access. Start with observation where you must, but decide in advance when observation becomes enforcement. A policy that stays permanently advisory is telemetry, not control.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does Identity Lifecycle Management Apply to AI Access?
&lt;/h2&gt;

&lt;p&gt;Governance is never finished, because organizations do not stay configured. People join, teams reorganize, acquisitions create new reporting lines, projects end, employees leave, and service accounts outlive the people who created them.&lt;/p&gt;

&lt;p&gt;The lifecycle has to reach runtime objects, not just identities. Deactivating an SSO account accomplishes little if runtime credentials or upstream provider tokens remain independently usable. Deleting every object belonging to a leaver accomplishes something worse, because it destroys the record needed six months later to explain what that person could do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authority has to expire without history disappearing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And some actors never enter this lifecycle at all. Pipelines, shared service accounts, and manually issued credentials need explicit owners and review periods, because no HR event will ever deprovision them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Govern MCP Tools and AI Agents?
&lt;/h2&gt;

&lt;p&gt;Tool governance belongs late in the sequence, not because it matters least but because it depends on nearly everything above it. Before granting a workload access to a tool, the organization should already know who owns that workload, which policy applies, who may change that policy, how the credential is governed, and what happens when ownership changes.&lt;/p&gt;

&lt;p&gt;Only then is "may this agent invoke &lt;code&gt;issue_refund&lt;/code&gt;?" a meaningful question.&lt;/p&gt;

&lt;p&gt;Grant tool policy by consequence rather than convenience. Reading documentation, modifying a customer record, moving money, and deleting an account are not equivalent just because they arrive through the same protocol. And permissions compose: a database reader and an email sender may each be acceptable alone while together enabling a workflow nobody approved. Per-tool controls do not solve that. Governance has to review effective capability, not individual grants.&lt;/p&gt;

&lt;h2&gt;
  
  
  In What Order Should You Build an Enterprise AI Governance Stack?
&lt;/h2&gt;

&lt;p&gt;Put together, the sequence looks less like a product rollout and more like dependency management.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Define governance.&lt;/strong&gt; Ownership, approved providers and models, budgets, exception authority, escalation paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connect identity.&lt;/strong&gt; Use the existing enterprise identity system rather than building a second silo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constrain administrators.&lt;/strong&gt; RBAC and resource scope on the people who can change AI policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode runtime policy.&lt;/strong&gt; Model access, budgets, rate limits, and expiry represented in governed credentials and profiles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inventory and migrate workloads.&lt;/strong&gt; Every credential gets an owner; inference moves behind the control plane.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make enforcement mandatory.&lt;/strong&gt; Close the ungoverned path once migration is done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automate lifecycle.&lt;/strong&gt; Reconcile joiners, movers, leavers, direct exceptions, and non-human identities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Govern capabilities.&lt;/strong&gt; Add tools by consequence, and review what combinations of grants make possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preserve evidence.&lt;/strong&gt; Retain enough state to explain who held authority, what policy applied, and what happened under it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rollouts vary. A company already deep into agentic systems may need to inventory tool access before it can cleanly redesign identity. Another may have mature IAM and no centralized AI policy at all. This is not a migration script.&lt;/p&gt;

&lt;p&gt;It is a dependency graph. Skipping ahead will not necessarily make a later control fail technically. It makes the guarantee underneath it weaker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Access Control Isn't Enough: AI Guardrails and the OWASP LLM Top 10
&lt;/h2&gt;

&lt;p&gt;One honest limit, and it is a large one.&lt;/p&gt;

&lt;p&gt;Everything in these six posts governs access and authority. Who decided, who may change the decision, what the workload may reach, what it may do, and what record survives. None of it governs whether the output is any good.&lt;/p&gt;

&lt;p&gt;A perfectly governed request, from an approved workload, to an approved model, inside budget, invoking a permitted tool, can still return something wrong, biased, leaky, or harmful. No amount of RBAC catches that. Access control asks whether the request was allowed. It never asks whether the answer was correct.&lt;/p&gt;

&lt;p&gt;That is a different discipline with different mechanisms: content guardrails, evaluations, red-teaming, output monitoring, and human review on consequential paths. Some of them can run in the same place. Bifrost applies &lt;a href="https://docs.getbifrost.ai/enterprise/guardrails" rel="noopener noreferrer"&gt;guardrails&lt;/a&gt; to inference inputs and outputs and to MCP tool calls, so the enforcement point can be shared even where the disciplines are not. The OWASP &lt;a href="https://genai.owasp.org/llm-top-10/" rel="noopener noreferrer"&gt;GenAI LLM Top 10&lt;/a&gt; is a reasonable map of what that second discipline has to cover, and very little of it is an access control problem. An organization that builds one while ignoring the other has solved one class of problem and left another largely untouched.&lt;/p&gt;

&lt;p&gt;Which raises the question this series has circled without answering. The ninth step says preserve evidence. But six months after an incident, what does that evidence actually prove, who is allowed to read it, and would it survive someone with an interest in its being different?&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI Control Plane Is Where Decisions Become Behavior
&lt;/h2&gt;

&lt;p&gt;Across this series, &lt;a href="https://getmax.im/githubdevto" rel="noopener noreferrer"&gt;Bifrost&lt;/a&gt; has been the concrete implementation of an enterprise AI governance stack because it puts many of these controls in one place: routing, virtual keys, budgets and limits, RBAC, access profiles, user provisioning, guardrails, and MCP governance. It is open source, while some of the enterprise identity and administrative capabilities discussed here sit in its Enterprise offering, and the &lt;a href="https://docs.getbifrost.ai/overview" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; is the right place to verify that boundary.&lt;/p&gt;

&lt;p&gt;The architectural argument is larger than any one implementation. Enterprise AI governance is not a collection of toggles bolted around a model API. It is a chain of authority running from an organizational decision to a runtime action, where every link answers a different question and every downstream guarantee assumes the upstream answers were legitimate.&lt;/p&gt;

&lt;p&gt;A budget limit means little if anyone can raise it. A virtual key means little if ungoverned traffic routes around it. RBAC means little if direct exceptions never expire. Offboarding means little if autonomous credentials outlive the person who owned them. Tool filtering means little if nobody asks what the permitted tools can accomplish together.&lt;/p&gt;

&lt;p&gt;The point of a control plane is not to make those problems disappear. It is to give an organization one place where its decisions become observable, enforceable behavior.&lt;/p&gt;

&lt;p&gt;That is the enterprise AI governance stack.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Teams that want to see these controls running end to end can &lt;a href="https://getmaxim.ai/bifrost/book-a-demo" rel="noopener noreferrer"&gt;book a Bifrost demo&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;This article was commissioned by the &lt;a href="https://www.getmaxim.ai/bifrost" rel="sponsored nofollow noopener noreferrer"&gt;Bifrost team&lt;/a&gt;. The link to the &lt;a href="https://sovereignplatform.dev?utm_source=devto&amp;amp;utm_medium=footer&amp;amp;utm_campaign=ai-control-plane" rel="noopener noreferrer"&gt;Sovereign Systems Specification&lt;/a&gt; points to my own work. The architectural perspective and conclusions expressed here are my own.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Nothing Reported It</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:34:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/nothing-reported-it-9ke</link>
      <guid>https://dev.to/kenwalger/nothing-reported-it-9ke</guid>
      <description>&lt;h3&gt;
  
  
  Five ways my own test harness lied to me, and the one that nearly got published
&lt;/h3&gt;




&lt;p&gt;&lt;strong&gt;Update, 30 October 2026.&lt;/strong&gt; The throughput section below originally gave two candidate explanations for a 76 percent speedup and did not settle between them. A reader asked whether the figure came from eval_duration or from wall-clock time around the request. It came from eval_duration, which was the right question to ask and turned up something worse than either candidate: the number is an artifact of answer length. The section now says so. The working is in the comments.&lt;/p&gt;




&lt;p&gt;The number that gave it away was sitting in a table I had already read three times.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prompt size    97 tokens median, 112 max
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the grounded question-answering workflow. It sends four reference documents with every question. The documents are 447 words.&lt;/p&gt;

&lt;p&gt;Four hundred and forty-seven words cannot be ninety-seven tokens.&lt;/p&gt;

&lt;p&gt;I had written that number into a results document as good news about context headroom. "Prompt sizes never approached the 4096-token context window." It was not good news about headroom. It was the harness telling me, in the only field capable of telling me, that the reference material was not in the prompt. The directory holding it had never been committed to the repository. Every one of those fifteen questions had been asked against an empty reference block.&lt;/p&gt;

&lt;p&gt;Both models had behaved impeccably. One had been instructed to answer &lt;code&gt;NOT IN THE MATERIAL&lt;/code&gt; when the material did not contain the answer, and the material contained nothing, so it said &lt;code&gt;NOT IN THE MATERIAL&lt;/code&gt; thirteen times out of fifteen. The other had no such instruction, so it answered from whatever it had memorized during training and invented a wine.&lt;/p&gt;

&lt;p&gt;Every call returned 200. The scorer scored it. The scores were internally consistent, had plausible and &lt;em&gt;different&lt;/em&gt; failure modes on each side, and I wrote two thousand seven hundred words about what they revealed regarding the capabilities of small language models.&lt;/p&gt;

&lt;p&gt;They revealed nothing about small language models. I had measured what happens when you ask a question with no reference material, which is a question I was not asking and which has an uninteresting answer.&lt;/p&gt;

&lt;p&gt;This is the post about that, and about the four other things I found in the same weekend, because the missing directory was not the first one and it was not even the most instructive.&lt;/p&gt;

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

&lt;p&gt;A Raspberry Pi 4, two small models through Ollama, and three workflows: pull an identifier out of a message, route a message to one of six queues, answer a question from four documents. Ninety-five test cases. A deterministic baseline for the first workflow. Predictions committed before anything was installed.&lt;/p&gt;

&lt;p&gt;The findings are a separate post. This one is about the code I wrote to produce them, which turned out to be where the actual bugs were.&lt;/p&gt;

&lt;p&gt;Five flaws. Every single one was found by reading output, not by reading code. I never once found one of these by looking at the scorer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kind one: a metric that rewards the safe failure
&lt;/h2&gt;

&lt;p&gt;This one I hit twice, in two different workflows, and did not recognize the second instance as the same bug until I had already fixed the first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The classification scorer.&lt;/strong&gt; Six labels, one of which is &lt;code&gt;escalate&lt;/code&gt;. The instruction said to escalate when a message needs a person, including when you cannot tell what it is about. So I built a metric called &lt;em&gt;confident wrong&lt;/em&gt;: wrong, and not an escalation. The reasoning is sound. A model that escalates when unsure is usable. A model that routes a trademark notice to billing is not.&lt;/p&gt;

&lt;p&gt;Then the 360M model answered &lt;code&gt;escalate&lt;/code&gt; to 43 of 50 messages.&lt;/p&gt;

&lt;p&gt;It scored &lt;strong&gt;3&lt;/strong&gt; on confident wrong. The 1.5B model, which was genuinely attempting the task and getting 46% of it right, scored &lt;strong&gt;26&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My metric had just certified the useless model as roughly six times safer than the mediocre one. Because escalations are exempt, a model that escalates &lt;em&gt;always&lt;/em&gt; cannot register a confident error. The exemption I added to reward caution had instead rewarded paralysis, and it did so in the direction that looks best.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The grounded QA scorer.&lt;/strong&gt; Four of the fifteen questions have no answer in the corpus. Correctly saying so is the behavior that matters most, because a system that invents a plausible answer with no evidence is worse than one that fails loudly: it is indistinguishable from one that worked. So the headline number was correct abstentions out of four.&lt;/p&gt;

&lt;p&gt;Then the 1.5B model said &lt;code&gt;NOT IN THE MATERIAL&lt;/code&gt; to 13 of 15 questions.&lt;/p&gt;

&lt;p&gt;It scored &lt;strong&gt;4 out of 4&lt;/strong&gt;. A perfect score, on the metric I cared most about, for a model that had answered almost nothing.&lt;/p&gt;

&lt;p&gt;Same bug. Different workflow. Two hours apart. I fixed the classification one, wrote a comment in the code explaining the principle, and then failed to apply that principle to the file I opened next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The shape, stated once:&lt;/strong&gt; a metric that rewards the safe behavior will certify any model that only ever does the safe thing. If you exempt a behavior because it is cautious, you have to pair that exemption with evidence that the model is making distinctions at all. Otherwise, the exemption &lt;em&gt;is&lt;/em&gt; the score.&lt;/p&gt;

&lt;p&gt;Both fixes are the same three lines: count how concentrated the answers are, and when one answer dominates, print a warning that the headline number is meaningless and say why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kind two: a metric that measures nothing at all
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The vacuous fixture.&lt;/strong&gt; I caught this one before the run, which is the only reason it is not a bigger part of this post. A test case that passed regardless of what the model did, because the fixture was inert either way. It contributed a tick to a column and no information.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The keyword check&lt;/strong&gt;, which is worse, because it survived longer and looked more useful.&lt;/p&gt;

&lt;p&gt;The eleven answerable QA questions each carry a list of words the correct answer should contain. The scorer checks whether any of them appear anywhere in the reply. I labeled it "indicative only" and moved on.&lt;/p&gt;

&lt;p&gt;On the invalid run it scored 1 of 11 for both models. The single hit was the word "reservation", appearing inside this answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You owe a tasting fee of $100 for the weekend. You also need to book a reservation for the weekend.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The correct fee is forty dollars. The hundred is invented. That one hit was the entire positive signal in my automated QA scoring and it was noise.&lt;/p&gt;

&lt;p&gt;Here is the part that makes it a better story. After I fixed the corpus and reran, the keyword check scored &lt;strong&gt;11 of 11&lt;/strong&gt; for the 1.5B model. Vindicated, apparently. I read all eleven answers by hand anyway, and nine of them are right.&lt;/p&gt;

&lt;p&gt;So the check was wrong both times. It was wrong in the pessimistic direction when the answers were bad and wrong in the optimistic direction when they were good, and on the rerun one of its eleven hits is an answer that gets the arithmetic wrong and hits on the word "reservation" anyway.&lt;/p&gt;

&lt;p&gt;A check that agrees with the truth because the truth happened to be agreeable has not been validated. It has been lucky.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kind three: a detector with a blind spot in the obvious direction
&lt;/h2&gt;

&lt;p&gt;I wrote a decay check because of a thermal problem. A Pi 4 with heat sinks and no fan hit its soft temperature limit during a thirty-call run and started capping the clock, and nothing anywhere said so. So the scorer now compares generation speed in the first third of a run to the last third and flags a slowdown.&lt;/p&gt;

&lt;p&gt;It then reported this, as &lt;em&gt;steady&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;across the run 4.3 -&amp;gt; 7.6 tok/s (176% of starting speed, steady)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hardware got seventy-six percent faster partway through a run. My detector, which was looking for numbers below a threshold, saw a number above it and reported no problem.&lt;/p&gt;

&lt;p&gt;Hardware does not speed up. That reading is neither. Reported tok/s fits &lt;code&gt;n / ((n - 1) × t)&lt;/code&gt; to within two percent across all five timed runs, which means &lt;code&gt;eval_count&lt;/code&gt; counts n tokens while &lt;code&gt;eval_duration&lt;/code&gt; covers only the n - 1 gaps between them. Short generations come out inflated by n/(n - 1): two tokens reads twice as fast as it is, three tokens half again, nine tokens 1.12x. The extraction answers drop from nine tokens to two partway through this run, and that is the whole of the speedup. Inter-token time is flat at about 0.26 seconds across every call in it.&lt;/p&gt;

&lt;p&gt;So the metric I built specifically so that answer length would not contaminate the speed reading is contaminated by answer length, hardest at exactly the short answers this experiment produced. Either way it is a signal that the measurement is unreliable, and I had built a detector that would only ever notice half of the ways that can happen.&lt;/p&gt;

&lt;p&gt;Anything you threshold in one direction, threshold in both. An implausibly good number is as informative as an implausibly bad one and nobody ever writes the check for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kind four: errors that get dropped instead of counted
&lt;/h2&gt;

&lt;p&gt;Partway through the first extraction run, Ollama returned a 500 and the runner died at case seven of thirty. The traceback was useless because &lt;code&gt;urllib&lt;/code&gt; raises &lt;code&gt;HTTPError&lt;/code&gt; and discards the response body, which is where Ollama puts its reason. Reading &lt;code&gt;e.read()&lt;/code&gt; gave it up immediately:&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="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"prediction aborted, token repeat limit reached"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 360M model, asked to find an order number in "Thanks for a lovely afternoon yesterday," where the correct answer is one word, had fallen into a repetition loop and been killed after twenty-one seconds. Reproducible at temperature zero. The input causes it.&lt;/p&gt;

&lt;p&gt;My first fix was to record failures and keep going, which was right. My second was to exclude all failures from the scores as infrastructure noise, which was badly wrong.&lt;/p&gt;

&lt;p&gt;That call was not infrastructure noise. The model ran. It received the prompt, generated tokens, and produced nothing usable. That is a result, and excluding it would have dropped from the denominator the single case this model demonstrably could not handle, scoring it only on the cases it survived.&lt;/p&gt;

&lt;p&gt;So the runner now tags every failure as one of two kinds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model failure.&lt;/strong&gt; The call reached a working model and the model failed. Stays in the denominator. Counts against the model. Reported on its own line as "no usable answer."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transport failure.&lt;/strong&gt; The call never reached a working model. Ollama was down, the tag was wrong, the request timed out. Nothing was measured, so it is excluded and the run is reported as incomplete.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distinction is a five-line change and it is the difference between measuring a model and measuring a model on the subset of inputs it happens to tolerate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kind five: inputs that go missing without raising
&lt;/h2&gt;

&lt;p&gt;This is the missing corpus from the top of the post, and it is a different and worse category than everything above it.&lt;/p&gt;

&lt;p&gt;The function was four lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;corpus_text&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;FIX&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;corpus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A glob that matches nothing yields nothing. An empty list joins to the empty string. No exception, no warning, no exit code. It returns a perfectly valid value that happens to be the absence of the thing it was asked for.&lt;/p&gt;

&lt;p&gt;And on the same evening, a sibling. The offline test's entire product is a text transcript. I copied it into the run directory, committed, pushed, and it was not there. The standard Python &lt;code&gt;.gitignore&lt;/code&gt; template, which I had pulled in wholesale, carries &lt;code&gt;*.log&lt;/code&gt; on line 165, and a pattern without a leading slash matches at any depth.&lt;/p&gt;

&lt;p&gt;The copy succeeded. &lt;code&gt;git status&lt;/code&gt; came back clean. The push reported success. The one artifact the test exists to produce was gone, and everything in the chain said fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absence does not raise.&lt;/strong&gt; A missing directory, a missing file and an inert fixture all present as an ordinary successful run. That is narrower and more useful than "test your tests," and it generalizes: every input a harness depends on needs a loader that refuses to proceed without it, and every artifact it produces needs to be verified as present rather than inferred from an exit status.&lt;/p&gt;

&lt;p&gt;The loader now looks like this, and the comment is longer than the code because the comment is the part I will need next year:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;corpus_dir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No corpus documents found in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;corpus_dir&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Grounded QA cannot run without reference material, and running&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;it against an empty reference block produces numbers that look&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;like a result.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also refuses a corpus implausibly smaller than the real one, the run prints the document count and token estimate before its first call, and the scorer independently flags any QA run whose median prompt is under 400 tokens. Three checks for one bug, in the input, in the run, and in the output, because this is the bug that cost a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detector that fired, and was right, and told me the wrong thing
&lt;/h2&gt;

&lt;p&gt;Here is the part I find hardest to write and most worth keeping.&lt;/p&gt;

&lt;p&gt;The abstention-collapse check from kind one &lt;em&gt;did&lt;/em&gt; fire on the invalid run. It printed exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;!! ABSTAINED on 13/15 questions overall. It is refusing, not judging.
   The abstention score below is not evidence of discrimination:
   a model that never answers gets it right for free.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was correct. Something was badly wrong with that run.&lt;/p&gt;

&lt;p&gt;Its diagnosis was completely false. The model was not refusing. It was obeying, precisely, an instruction to report when the material lacks the answer, in a situation where the material lacked everything. The fault was not in the model's judgment, and it was not in the metric. It was in a directory that did not exist, two layers upstream.&lt;/p&gt;

&lt;p&gt;And I acted on the diagnosis. I wrote it up: the model has a stuck needle, its perfect abstention score is an artifact of refusal, here are the implications for anyone building on a 1.5B model. Confident, specific, sourced to real output, and wrong in every particular.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A flag that tells you something is off is not a flag that tells you what.&lt;/strong&gt; The check could only see the output. The fault was in the input. It fired for the right reason and pointed in the wrong direction, and because it fired I felt &lt;em&gt;more&lt;/em&gt; confident in a conclusion that was false, not less.&lt;/p&gt;

&lt;p&gt;That is the failure mode I had no defense against and still only partly do. A monitoring system that is right about the existence of a problem and wrong about its cause is more dangerous than no monitoring, because it terminates the search.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually changed
&lt;/h2&gt;

&lt;p&gt;For anyone who wants the diffs rather than the moral:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Collapse checks on both classification and QA: when one answer exceeds half the set, print the concentration and declare the headline metric meaningless for that model.&lt;/li&gt;
&lt;li&gt;The decay check flags implausible speedups as well as slowdowns, and notes when generations are too short to time. Noting it is not enough. At these answer lengths the tok/s figure is a function of answer length, so the check should compare inter-token time rather than tokens per second, or refuse to report a ratio at all when its two windows differ in median generated tokens.&lt;/li&gt;
&lt;li&gt;Failures split into model and transport, with model failures staying in the denominator.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;corpus_text()&lt;/code&gt; raises on an empty or implausibly small corpus.&lt;/li&gt;
&lt;li&gt;The QA run prints its corpus size before the first call.&lt;/li&gt;
&lt;li&gt;The scorer flags a QA run whose prompts are too small to contain the corpus.&lt;/li&gt;
&lt;li&gt;The keyword check is relabelled as a weak lower bound, in the output, in the README, and in the results.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.gitignore&lt;/code&gt; no longer lets &lt;code&gt;*.log&lt;/code&gt; swallow run transcripts.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vcgencmd get_throttled&lt;/code&gt; before and after every run, recorded in the run document rather than checked and forgotten. The reading reports bits set since boot, so it is perishable: power-cycle the board and the evidence is gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;p&gt;Every one of these was found by looking at data. Not one was found by reading code, reviewing the scorer, or writing a unit test for the harness.&lt;/p&gt;

&lt;p&gt;The vacuous fixture turned up while hand-checking a passing case. The confident-wrong flaw turned up because a score of 3 against 26 looked backwards. The abstention flaw turned up because 4 out of 4 looked too good. The keyword flaw turned up because 1 of 11 looked too bad, and I read the one. The missing corpus turned up because I was double-checking a latency figure and noticed a prompt that was too small to be possible.&lt;/p&gt;

&lt;p&gt;Which means the only reason I found them is that I kept pulling threads after I had numbers that looked publishable. The version of this project that shipped on schedule shipped on Tuesday, with a clean verdict table, a confident conclusion about small models and grounded question answering, and a repository anyone could clone to reproduce the exact same wrong result.&lt;/p&gt;

&lt;p&gt;Every number in it would have been reproducible. That is the thing I keep turning over. Reproducibility is table stakes and it would not have saved me, because the harness reproduced its own bug faithfully every single time.&lt;/p&gt;

&lt;p&gt;I do not have a process that catches this reliably. What I have is a weaker and more honest claim: the checks I added are each a direct response to one specific thing that actually happened, which is the only kind of check I trust, and the number of them that I thought up in advance is zero.&lt;/p&gt;

&lt;p&gt;So the two rows I am adding to my prediction template, to be filled in before anything runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;What would have to be true for these numbers to be meaningless?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would anything in the harness tell me if it were?&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Twenty-two predictions about what the models would do. Not one about what the measuring code would do. Four of the five flaws were in the measuring code.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The harness, the fixtures, the committed predictions and the full run record, including the invalid run and what it produced, are at&lt;br&gt;
&lt;a href="https://github.com/kenwalger/right-size-the-model" rel="noopener noreferrer"&gt;github.com/kenwalger/right-size-the-model&lt;/a&gt;.&lt;br&gt;
The findings the harness eventually produced are in &lt;a href="//[https://www.kenwalger.com/blog/ai/do-you-actually-need-an-llm/](https://www.kenwalger.com/blog/ai/do-you-actually-need-an-llm/?utm_source=kenwalger&amp;amp;utm_medium=blog&amp;amp;utm_campaign=right_size_the_model)"&gt;the companion post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>programming</category>
      <category>devops</category>
    </item>
    <item>
      <title>Five World Series Pitching Predictions, Locked Before Game 1</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Tue, 20 Oct 2026 18:20:49 +0000</pubDate>
      <link>https://dev.to/kenwalger/five-world-series-pitching-predictions-locked-before-game-1-ad7</link>
      <guid>https://dev.to/kenwalger/five-world-series-pitching-predictions-locked-before-game-1-ad7</guid>
      <description>&lt;p&gt;&lt;em&gt;Written down now, graded later, no edits allowed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Brewers and the Rays open the World Series on 23 October 2026. Before the first pitch, I'm putting five predictions about the Series' starting pitching on the record.&lt;/p&gt;

&lt;p&gt;Most predictions get forgotten the moment they turn out wrong. These won't. After the final out, I'll run every starter's pitches through a little Statcast app I built and grade each one in public, misses included.&lt;/p&gt;

&lt;p&gt;The main stat is &lt;strong&gt;CSW%&lt;/strong&gt;: the share of a pitcher's pitches that end in a called strike or a swing and miss. A pitch the umpire calls a strike, or one the hitter whiffs on, is a pitch the pitcher won. To be ranked, a starter needs at least 75 pitches across his Series starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The predictions
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The CSW leader will clear 32%.&lt;/strong&gt; The Series' most effective qualifying starter, using the 75-pitch minimum, will finish with a CSW% of at least 32%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At least one starter won't qualify.&lt;/strong&gt; At least one pitcher who starts a World Series game will finish the Series with fewer than 75 pitches thrown as a starter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rematch will produce a different arsenal.&lt;/strong&gt; At least one starter making two Series starts will change the usage rate of one pitch type by 15 percentage points or more between appearances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Misiorowski will reach 105.&lt;/strong&gt; Jacob Misiorowski will throw at least one pitch of 105 mph or faster in the Series, per Statcast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freddy Peralta won't lead the CSW rankings.&lt;/strong&gt; Despite his ALCS MVP performance, Peralta will not finish as the World Series' highest-CSW qualifying starter.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two of these I'd bet on, two are coin flips, and one is me hoping for something fun.&lt;/p&gt;

&lt;h2&gt;
  
  
  Play along
&lt;/h2&gt;

&lt;p&gt;The app is &lt;a href="https://pitching-stats.onrender.com/" rel="noopener noreferrer"&gt;live&lt;/a&gt;, and an entire World Series fits inside its 14-day limit, so once the Series is over you can grade these yourself before I do. If you think I'm wrong about any of them, or you have a better prediction, leave it in the comments. Those get graded too.&lt;/p&gt;

&lt;p&gt;The full write-up, with the grades, lands after the final out.&lt;/p&gt;

</description>
      <category>sports</category>
      <category>discuss</category>
      <category>python</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Tuesday Isn't a Valid State Transition</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:46:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/tuesday-isnt-a-valid-state-transition-16ln</link>
      <guid>https://dev.to/kenwalger/tuesday-isnt-a-valid-state-transition-16ln</guid>
      <description>&lt;p&gt;&lt;em&gt;Calendars, evidence, and the difference between done and stuck&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I expected my pear wine to spend two to three weeks in primary fermentation.&lt;/p&gt;

&lt;p&gt;It spent nineteen days. The yeast went in on September 23, and on October 12 I siphoned the wine off its sediment and into a glass carboy.&lt;/p&gt;

&lt;p&gt;So the calendar was right. That still isn't why I moved it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the calendar is for
&lt;/h2&gt;

&lt;p&gt;Every fermentation recipe comes with time estimates. Primary takes one to three weeks. Secondary takes a few more. Bulk age for a few months. Those numbers aren't wrong. They're averages across a lot of batches, and they're useful for exactly one thing: planning. They tell you when to have a clean carboy ready, when to clear some space, when to stop making weekend plans.&lt;/p&gt;

&lt;p&gt;What they can't do is tell you that &lt;em&gt;this&lt;/em&gt; batch is ready. Yeast doesn't read recipes. It works at a speed set by temperature, sugar, nutrients, acidity, the particular strain, and the particular fruit, none of which the recipe author has ever met.&lt;/p&gt;

&lt;p&gt;So the recipe gives you a forecast, and the fermentation gives you evidence. Only one of those gets to move the wine. If they happen to agree, as they did this time, that's a pleasant coincidence, not an authorization.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule winemakers actually use
&lt;/h2&gt;

&lt;p&gt;The traditional test for "primary is done, time to rack" is simple: take readings over several days, and when you get the same reading three times running, fermentation has finished.&lt;/p&gt;

&lt;p&gt;What I like about that rule is what it &lt;em&gt;isn't&lt;/em&gt;. It isn't a date. It also isn't a single reading, because one measurement tells you where the fermentation is, not whether it's still moving. It's evidence sustained over a window. Time is part of the test, but only as the space in which the evidence has to hold still.&lt;/p&gt;

&lt;p&gt;Here's what the pear's refractometer readings actually looked like:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Raw Brix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sep 23&lt;/td&gt;
&lt;td&gt;12.9 (before the yeast)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 25&lt;/td&gt;
&lt;td&gt;6.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 30&lt;/td&gt;
&lt;td&gt;6.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 2&lt;/td&gt;
&lt;td&gt;5.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 4&lt;/td&gt;
&lt;td&gt;5.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 7&lt;/td&gt;
&lt;td&gt;5.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 10&lt;/td&gt;
&lt;td&gt;5.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 11&lt;/td&gt;
&lt;td&gt;5.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 12&lt;/td&gt;
&lt;td&gt;5.6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things in that table are worth a closer look.&lt;/p&gt;

&lt;p&gt;First, the early plateau. Most of the sugar was gone within two days, and then the reading sat at exactly 6.0 for five days. If I'd been looking for stillness and nothing else, that looked a lot like the end. It wasn't. The fermentation was slowing down, not stopping, and over the next week it crept down further before it finally held. Two matching readings were a pause, not a verdict, which is precisely why the rule asks for three.&lt;/p&gt;

&lt;p&gt;Second, I didn't stop at three. By October 10 I had three consecutive readings at 5.6, which satisfies the rule. I kept reading anyway, and waited until five readings across eight days agreed.&lt;/p&gt;

&lt;p&gt;Then the racking itself handed me one more piece of evidence. There was more wine in the primary fermenter than the glass carboy could hold. Rather than pour the extra down the drain, I filled a hydrometer jar with it: a completely different instrument, measuring a completely different physical property. The refractometer, once corrected for alcohol, put the wine at about 1.004. The hydrometer said 1.002. Two independent instruments, close enough to agree, and the second one confirmed that the correction formula I'd been trusting for three weeks was doing its job.&lt;/p&gt;

&lt;p&gt;I like that this happened by accident, because it isn't really an accident. A hydrometer needs a large sample, which in a one-gallon batch is too expensive to take routinely. At the moment of a transition, when wine is being moved anyway, that same sample costs nothing. The transition is when the more trustworthy evidence becomes cheap.&lt;/p&gt;

&lt;p&gt;None of that is the calendar talking. That's a guard, and it was satisfied before the wine moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Status is a column anyone can write
&lt;/h2&gt;

&lt;p&gt;In a lot of systems, state is a string in a database, and a transition is an &lt;code&gt;UPDATE&lt;/code&gt; statement.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4417&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in that statement requires that anything was shipped. It's a label, and anyone with write access can apply it for any reason: a cron job that marks orders shipped two days after payment because that's usually true, a release train that promotes whatever's on the branch because it's Tuesday, a support tool that flips a flag to make a ticket go away.&lt;/p&gt;

&lt;p&gt;Each of these is the software version of racking the wine because the recipe said two to three weeks. Most of the time it'll even be right, just as my calendar was. The transition happens on schedule, the dashboard updates, and the system now asserts a state that nobody verified.&lt;/p&gt;

&lt;p&gt;The fix isn't elaborate. A transition should have a guard, a condition that has to be true, based on evidence, before the state can change. "Shipped" should require a carrier scan. "Released" should require the checks that define releasable. "Primary complete" should require stable readings. The state machine's job is to refuse the transition when the evidence isn't there, however much the calendar insists, and to keep refusing even on the days the calendar happens to be right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same evidence, two states
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting. "Three consistent readings" is the signal for &lt;em&gt;done&lt;/em&gt;. But a fermentation that has stalled also produces three consistent readings.&lt;/p&gt;

&lt;p&gt;Mead is notorious for this. Honey has very little natural buffering, so as fermentation proceeds the pH drifts down, and past a certain point the yeast slows or quits entirely, often well short of dry. From the refractometer's point of view, a stuck mead and a finished mead look exactly the same: a number that has stopped moving.&lt;/p&gt;

&lt;p&gt;My fireweed mead is a few days from that question right now. Its pH started at 3.5 and dropped to 3.1 within the first four days, where it has mostly stayed. Here are its last few readings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Raw Brix&lt;/th&gt;
&lt;th&gt;Corrected Brix&lt;/th&gt;
&lt;th&gt;Estimated ABV&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Oct 7&lt;/td&gt;
&lt;td&gt;11.8&lt;/td&gt;
&lt;td&gt;4.3&lt;/td&gt;
&lt;td&gt;10.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 10&lt;/td&gt;
&lt;td&gt;9.0&lt;/td&gt;
&lt;td&gt;-0.3&lt;/td&gt;
&lt;td&gt;12.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 11&lt;/td&gt;
&lt;td&gt;8.6&lt;/td&gt;
&lt;td&gt;-1.0&lt;/td&gt;
&lt;td&gt;13.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 12&lt;/td&gt;
&lt;td&gt;8.4&lt;/td&gt;
&lt;td&gt;-1.3&lt;/td&gt;
&lt;td&gt;13.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the corrected column. The calculator now claims the mead contains less than no sugar, which is impossible. That's a model being pushed past the range it was built for, and it's a nice reminder that a derived number can confidently assert something no physical system can be. If my guard were "corrected Brix at or below zero," I'd have racked the mead two days ago.&lt;/p&gt;

&lt;p&gt;The raw readings tell a different story: they're still falling, slowly. The mead isn't still yet, so the guard isn't satisfied, no matter what the corrected number says. And when it does go still, the question becomes &lt;em&gt;where&lt;/em&gt;. The yeast I used, 71B, is generally rated to tolerate around 14% alcohol, and the estimate is already over 13%. A mead that stops near its yeast's limit with the sugar it was expected to consume gone is probably done. One that stops early, with sugar left over and the pH low, is probably stuck.&lt;/p&gt;

&lt;p&gt;The mead has already taught me this lesson once, in miniature. Mead yeast needs nutrients added in stages early on, and the textbook guidance ties the last addition to the "one-third sugar break," the point where about a third of the sugar has been consumed. I scheduled my three additions by day instead. As it happened, the last one landed right around the sugar break anyway. Sometimes luck happens. But luck is exactly what it was: the schedule didn't know where the sugar was, and if the mead had been a few days slower, the same calendar would have fed it at the wrong time.&lt;/p&gt;

&lt;p&gt;In other words, the guard for "done" can't just be "stable." It has to be "stable &lt;em&gt;where a finished fermentation should be&lt;/em&gt;," which means the guard needs a model of the expected end state, not just a test for motion. And it needs to know which of its inputs to trust when they disagree.&lt;/p&gt;

&lt;p&gt;Software has the same ambiguity everywhere. A batch job whose progress counter hasn't moved in an hour has either finished or hung. A queue depth sitting at zero means either everything was processed or the consumer died. A replication lag of zero might mean the replica is caught up, or that it stopped receiving anything to lag behind. In every case, "nothing is changing" is evidence consistent with two opposite states, and a transition that only checks for stillness will eventually pick the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  A transition is an event with a receipt
&lt;/h2&gt;

&lt;p&gt;The row in my pear log for October 12 reads, in effect: 11:10 a.m., 5.6 Brix, hydrometer 1.002, moved to glass carboy. The transition and the evidence that justified it are on the same line.&lt;/p&gt;

&lt;p&gt;That matters more than the new status. Months from now, if the wine turns out strange, "racked on October 12" tells me almost nothing. "Racked after five stable readings, confirmed by a second instrument" tells me what I believed, what I based it on, and whether I might have been fooled by a stall.&lt;/p&gt;

&lt;p&gt;Systems benefit from the same habit. A state change shouldn't just overwrite the previous state. It should be recorded as an event: what the state was, what it became, what evidence satisfied the guard, and who or what made the call. Then the current state is something you can explain, not just something you can read. When "shipped" turns out to be wrong, you can see whether the guard was bad, the evidence was bad, or the guard was never checked at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What time is actually allowed to do
&lt;/h2&gt;

&lt;p&gt;None of this means time is irrelevant. It plays three legitimate roles, and it's worth keeping them straight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time can permit a transition.&lt;/strong&gt; Some transitions shouldn't happen before a minimum interval no matter what the evidence says, like a waiting period before a refund clears, or a wine that needs some minimum aging before bottling. Here time is a precondition alongside the evidence, not a replacement for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time can create the conditions for evidence.&lt;/strong&gt; The three-readings rule only works because readings are spread over days. Stability over a window requires a window. The pear's false plateau is exactly why: two readings five days apart looked still, and only more time showed they weren't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time can raise a question.&lt;/strong&gt; If a fermentation hasn't finished long after it should have, that's worth investigating. But notice what the timeout actually tells you: &lt;em&gt;something is unusual&lt;/em&gt;. It doesn't tell you the batch is done, and it doesn't tell you the batch has failed.&lt;/p&gt;

&lt;p&gt;That last one is where software most often goes wrong. Timeouts routinely get wired straight to a terminal state: no response in thirty seconds means failed, no update in three days means complete. A timeout is legitimate evidence that you need to look. It is rarely evidence of what you'll find when you do. A timeout should produce a question, not an answer.&lt;/p&gt;

&lt;p&gt;What time shouldn't do, on its own, is &lt;em&gt;cause&lt;/em&gt; a transition. Nineteen days arriving was not a fact about the pear wine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guarding your transitions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Treat schedules as forecasts.&lt;/strong&gt; Plan with them. Don't let them move state, even when they're right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put a guard on every transition that matters.&lt;/strong&gt; If a state can be reached by writing a string, eventually it will be reached for the wrong reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test for the expected end state, not just stillness.&lt;/strong&gt; Stable and finished are different claims. A guard that can't tell them apart will someday declare a stall a success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know which evidence to trust when it disagrees.&lt;/strong&gt; A derived value can be confidently impossible. Raw observations and independent instruments are how you catch it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Record the evidence with the transition.&lt;/strong&gt; The new state is the conclusion. Keep the reasons next to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let timeouts ask, not answer.&lt;/strong&gt; Route them to "needs attention," not to "done" or "failed."&lt;/p&gt;

&lt;p&gt;The pear is in its carboy now, clearing slowly. October 12 was the day it moved. It happened to be a Monday. That wasn't the reason either.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>programming</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>Introducing B-TAP: Because Your AI Infrastructure Hasn't Attributed Its Fair Share</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:39:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/introducing-b-tap-because-your-ai-infrastructure-hasnt-attributed-its-fair-share-3od7</link>
      <guid>https://dev.to/kenwalger/introducing-b-tap-because-your-ai-infrastructure-hasnt-attributed-its-fair-share-3od7</guid>
      <description>&lt;p&gt;&lt;em&gt;Context isn't free. Retrieval isn't free. Observability isn't free. And an international standards body would very much like to see your receipts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For years, artificial intelligence companies have operated under a dangerous assumption: tokens are somebody else's problem. Engineering teams deploy increasingly complex agent architectures. Agents call other agents. Those agents retrieve documents. The documents are stuffed into context windows. Supervisors synthesize the results. Observability systems watch the supervisors, and other systems watch the observability systems.&lt;/p&gt;

&lt;p&gt;At the end of the month, somebody gets a bill. Nobody knows why.&lt;/p&gt;

&lt;p&gt;That ends today. We're pleased to introduce the &lt;strong&gt;Bureau of Token Attribution and Provenance&lt;/strong&gt;, or &lt;strong&gt;B-TAP&lt;/strong&gt;, the first bureau of &lt;a href="https://vernholtsecretariat.org/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=btap" rel="noopener noreferrer"&gt;The Vernholt Secretariat&lt;/a&gt; and the one some of you will insist on calling the Token IRS no matter how many times we correct you. Our mandate is voluntary but sweeping: to harmonize the reporting of token costs across every model, agent, retrieval pipeline, and suspiciously autonomous llama, everywhere, forever. The Secretariat notes that "first" implies others. The Secretariat has no further comment at this time.&lt;/p&gt;

&lt;p&gt;B-TAP cannot make you do anything. We want to be transparent about that up front. What B-TAP can do is publish a framework, issue guidance, and maintain a register. You will come to fear the register.&lt;/p&gt;

&lt;h2&gt;
  
  
  You Have Been Selected for Voluntary Compliance
&lt;/h2&gt;

&lt;p&gt;Consider a typical multi-agent AI workload. A user asks a relatively straightforward question. Your orchestration layer sends the request to three agents. One performs retrieval against a vector database. Another consults a knowledge graph. A third receives the entire conversation history because somebody checked a box six months ago and nobody remembers why.&lt;/p&gt;

&lt;p&gt;Their outputs are passed to a supervisor. The supervisor receives 47,000 tokens of context, produces a 600-token answer, and returns HTTP 200. Success. Your dashboard is green. Your finance team is crying.&lt;/p&gt;

&lt;p&gt;Traditional AI cost monitoring can tell you what you spent. The B-TAP framework recommends that you also disclose a more uncomfortable thing: &lt;strong&gt;why you spent it.&lt;/strong&gt; That's where attribution begins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know Your Taxes
&lt;/h2&gt;

&lt;p&gt;The B-TAP framework currently recognizes several major classes of token taxation. Reporting them is, we cannot stress this enough, entirely voluntary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context Tax&lt;/strong&gt; is assessed when information must be carried through active model context. Every instruction, retrieved document, conversation turn, agent output, and piece of supporting material occupies finite context and incurs processing cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieval Tax&lt;/strong&gt; applies to finding information before it can be used. Searching, ranking, reranking, fetching, filtering, and preparing candidates are not free merely because they happen outside the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prose Tax&lt;/strong&gt; is assessed when information consumes more representation than its useful information content requires. Your 900-word architectural vision statement may, upon review, turn out to mean "use the smaller model first."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ingestion Tax&lt;/strong&gt; covers the work required to transform information into something useful later. Parsing, normalization, deduplication, classification, enrichment, embedding, and other write-side operations all have costs.&lt;/p&gt;

&lt;p&gt;And then there is &lt;strong&gt;Observer's Tax&lt;/strong&gt;. Observer's Tax is what happens when you instrument the system to understand why it is expensive, only to discover that the instrumentation is also expensive. The framework acknowledges this with regret.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;a href="https://vernholtsecretariat.org/btap-1040.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=btap" rel="noopener noreferrer"&gt;Form BTAP-1040&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Every participating AI system is encouraged to file an annual Token Attribution Return. Consider the following hypothetical filer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ken Alger&lt;br&gt;&lt;br&gt;
Reporting Year 2027&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reported Activity&lt;/th&gt;
&lt;th&gt;Assessment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tokens processed&lt;/td&gt;
&lt;td&gt;1,499,203,847,116&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context Tax&lt;/td&gt;
&lt;td&gt;$417.63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieval Tax&lt;/td&gt;
&lt;td&gt;$206.11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prose Tax&lt;/td&gt;
&lt;td&gt;$173.29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ingestion Tax&lt;/td&gt;
&lt;td&gt;$58.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total Attributed Token Tax&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$782.06&lt;/strong&gt; ¹&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;¹ The itemized figures above sum to $855.43. The stated total is $782.06. The difference reflects the Context Carryforward Deduction, the full Schedule R retrieval exemption, and one figure B-TAP declined to explain. The framework notes that reconciliation of attributed line items to the assessed total is "outside the current scope of the provenance standard." Math, the reviewing officer added, is hard.&lt;/p&gt;

&lt;p&gt;The result reduced an assessment that should have run into six figures down to the amount shown. The reviewing officer described it as "technically compliant and deeply upsetting." Everything on this return is permitted, including the part that does not add up. That is the entire problem.&lt;/p&gt;

&lt;p&gt;Filing is requested annually by the 29th of February. The date was fixed at the framework's founding, when the official establishing it was, by all surviving accounts, looking at a leap-year calendar. The error was noticed. Correcting it, however, would require amending the founding charter, which would require establishing the provenance of the original charter, which has been referred to committee. The deadline therefore remains the 29th of February, and in three years out of four it does not arrive at all. Filers in those years are neither late nor on time. The framework considers this acceptable.&lt;/p&gt;

&lt;p&gt;Non-filing carries no penalty, no interest, and no legal consequence of any kind. It carries something worse, which we will get to.&lt;/p&gt;
&lt;h2&gt;
  
  
  Yes, You May Take Deductions
&lt;/h2&gt;

&lt;p&gt;The framework recognizes that not every token is frivolous. Reasonable and necessary context may qualify for the &lt;strong&gt;Context Carryforward Deduction&lt;/strong&gt;, while retrieval operations demonstrably required to answer a user request may be reported on &lt;strong&gt;Schedule R&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Organizations operating multi-agent systems are encouraged to attach &lt;strong&gt;Schedule AGENT&lt;/strong&gt;, identifying each agent, its responsibilities, and why seventeen agents were necessary to answer a question about resetting a password. "Agentic architecture" is not, by itself, an acceptable disclosure. Neither is "the framework generated it."&lt;/p&gt;
&lt;h2&gt;
  
  
  Keep Your Receipts
&lt;/h2&gt;

&lt;p&gt;This is where things become slightly less ridiculous, and I want you to notice the exact moment it happens.&lt;/p&gt;

&lt;p&gt;Suppose a retrieval agent costs $0.004 during a request. That tells us something. Now suppose that retrieval operation returns 20,000 tokens, all of which are hydrated into a supervisor's context. The supervisor subsequently costs another $0.009.&lt;/p&gt;

&lt;p&gt;How much did retrieval really cost?&lt;/p&gt;

&lt;p&gt;The direct answer is still $0.004, but that's no longer the interesting answer. The retrieval operation may also have &lt;strong&gt;caused downstream Context Tax&lt;/strong&gt;. We now have two different accounting questions: where was the cost incurred, and what caused the cost to be incurred?&lt;/p&gt;

&lt;p&gt;A conforming B-TAP attribution record might look something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;span_id: retrieval-104
direct_cost: $0.0040
primary_tax: RETRIEVAL
cause: vector_search
returned_tokens: 20000

downstream:
  span_id: supervisor-105
  tax: CONTEXT
  attributed_cost: $0.0021
  caused_by: retrieval-104
  attribution_method: proportional
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We haven't charged the same $0.0021 twice. We're recording &lt;strong&gt;lineage&lt;/strong&gt;. The supervisor incurred the Context Tax, while the retrieval operation contributed to causing it.&lt;/p&gt;

&lt;p&gt;Your accountant may now begin sweating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attribution Is Subject to Review
&lt;/h2&gt;

&lt;p&gt;There is another problem: how do we know retrieval-104 actually caused $0.0021 of the supervisor's cost?&lt;/p&gt;

&lt;p&gt;Sometimes we can measure it. Run the same workload without that retrieval material. If the supervisor costs $0.006 without it and $0.009 with it, we have evidence supporting a roughly $0.003 downstream effect.&lt;/p&gt;

&lt;p&gt;Other times, we don't have a counterfactual. We might allocate the supervisor's context cost proportionally according to the tokens contributed by each source. That's useful, but it isn't the same thing as measuring causation.&lt;/p&gt;

&lt;p&gt;The framework therefore requests that filers disclose their attribution methodology:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;attribution_method:
  - measured_delta
  - proportional
  - estimated
  - unknown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, your attribution record now needs provenance. B-TAP takes no position on whether this was inevitable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Register
&lt;/h2&gt;

&lt;p&gt;Here is the something worse.&lt;/p&gt;

&lt;p&gt;B-TAP cannot fine you. It cannot audit you in any binding sense. It cannot send anyone to your door, a fact I will return to shortly for personal reasons. What it can do is enter non-conforming systems into the &lt;strong&gt;Public Register of Non-Attributed Systems&lt;/strong&gt;, and it would like you to understand that the register is public.&lt;/p&gt;

&lt;p&gt;Public, in the sense that it exists.&lt;/p&gt;

&lt;p&gt;Accessing the register requires a B-TAP portal account. Creating a portal account requires a reference number. Reference numbers are issued by post, to a registered address in a member jurisdiction, which you must already hold in order to receive the number that would let you see whether you are on the list. The portal is available at all times except for scheduled maintenance, which runs daily from 00:00 to 23:59 CET.&lt;/p&gt;

&lt;p&gt;No member of the public has ever successfully viewed the Public Register. Its deterrent power is therefore total. You cannot confirm you are on it. You cannot confirm you are not. You will simply know, in the quiet way one knows these things, that your context hydration was reckless and that somewhere in Vernholt, a record says so.&lt;/p&gt;

&lt;p&gt;A small coalition of objectors has argued that a register no one can open cannot deter anyone. B-TAP notes that it cannot verify this claim, as the objectors cannot access the register in which their objection was filed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Notice, For Illustration
&lt;/h2&gt;

&lt;p&gt;Filers occasionally receive correspondence like this. It is not enforceable. It is, in every other respect, extremely stressful.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;NOTICE TT-742&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our review found that you reported $12,481 in Retrieval Tax during Reporting Year 2027.&lt;/p&gt;

&lt;p&gt;Telemetry indicates that retrieved content subsequently generated approximately $31,204 in downstream Context Tax. Of that amount, $8,117 has been attributed using measured deltas, $17,903 using proportional allocation, and $5,184 using undocumented methodology.&lt;/p&gt;

&lt;p&gt;Please provide supporting records for the undocumented assessment.&lt;/p&gt;

&lt;p&gt;Additionally, approximately 14.8 million tokens appear to consist of unnecessarily verbose Markdown introductions. These tokens have been reclassified as Prose Tax.&lt;/p&gt;

&lt;p&gt;Your system's registration status is under review.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Suddenly, "our AI bill increased this month" seems like a remarkably primitive observation.&lt;/p&gt;

&lt;h2&gt;
  
  
  You Probably Noticed the Joke Stopped Being a Joke a Few Sections Ago
&lt;/h2&gt;

&lt;p&gt;There is no B-TAP. At least I hope there isn't by the time you read this. But somewhere back around the receipts, the bit quietly turned into an argument, and I did that on purpose.&lt;/p&gt;

&lt;p&gt;Let me get the personal part out of the way, since it is also the truest part. I run a couple of small websites out of Oregon. If a body like B-TAP declared jurisdiction over "any system reachable from a member jurisdiction," I would be, this instant, in cheerful and total violation of a framework I have never read. And nothing would happen. No one is flying from Vernholt to Silverton to discuss my context hydration. The entire apparatus of consequence would consist of a paper reference number I would never receive for a register I could never open. That gap, between sweeping declared reach and the practical inability to reach one person in a small town, is the joke, and it is not really a joke.&lt;/p&gt;

&lt;p&gt;Here is the other part that isn't invented. Context Tax, Retrieval Tax, and Prose Tax are not things I dreamed up to give an imaginary agency something to collect. I have been writing about them, unironically, as real costs in real systems for a while now. The joke just gave them a bureaucracy.&lt;/p&gt;

&lt;p&gt;The accounting problem underneath all of it is genuine. AI cost dashboards tend naturally toward aggregation: tokens consumed, model cost, agent cost, request cost, latency, and tool usage. Those are useful operational measurements, but as AI systems become compositions of models, retrieval systems, tools, agents, memory layers, and observability infrastructure, knowing &lt;strong&gt;where money was spent&lt;/strong&gt; isn't necessarily enough to understand &lt;strong&gt;why it was spent&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A $0.03 model call may have become expensive because an upstream retrieval operation returned too much information. An agent may be cheap by itself while producing verbose output that makes every downstream agent more expensive. A retrieval optimization may reduce Context Tax while increasing Retrieval Tax, while an observability system intended to diagnose those costs may itself impose Observer's Tax.&lt;/p&gt;

&lt;p&gt;Optimizing one number can simply move the bill somewhere else. That's why I like the tax metaphor: taxes make costs visible as consequences of architectural decisions. Once those costs have lineage, we can stop asking only &lt;strong&gt;"How much did this run cost?"&lt;/strong&gt; and start asking &lt;strong&gt;"What architectural decisions caused it to cost that much?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first is accounting. The second tells us what to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Final Notice
&lt;/h2&gt;

&lt;p&gt;If B-TAP actually existed, I suspect most of us would fail its review. Not because our systems are necessarily wasteful, but because we frequently don't preserve enough evidence to explain why their costs occurred.&lt;/p&gt;

&lt;p&gt;We have bills, traces, and token counts. What we don't always have are receipts connecting architectural decisions to their downstream consequences. Perhaps we don't need a Bureau of Token Attribution and Provenance after all. We just need better attribution.&lt;/p&gt;

&lt;p&gt;Although if you are currently running seventeen agents to answer a password-reset question, you may want to start gathering your paperwork anyway.&lt;/p&gt;

&lt;p&gt;The suspiciously autonomous llama has already filed. It itemized.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Information without provenance is just gossip.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tokens without provenance are just an invoice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>satire</category>
      <category>programming</category>
      <category>agents</category>
    </item>
    <item>
      <title>Do You Actually Need an LLM?</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:03:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/do-you-actually-need-an-llm-3n55</link>
      <guid>https://dev.to/kenwalger/do-you-actually-need-an-llm-3n55</guid>
      <description>&lt;p&gt;I spent a weekend running two small language models against three jobs I would normally hand to a language model without thinking about it. For one of those jobs I also wrote a regular expression, as a control.&lt;/p&gt;

&lt;p&gt;The regex won. Thirty test cases out of thirty, in zero milliseconds, from a pattern that took an afternoon to write. The better of the two models scored twenty-eight, took five seconds a call, and the two it missed are both mistakes a regex is structurally incapable of making.&lt;/p&gt;

&lt;p&gt;That is the kind of result I was looking for, and the question behind it is one I keep avoiding: &lt;strong&gt;what is the smallest amount of computation that reliably does this job?&lt;/strong&gt; Not the cheapest model. Not the best small model. The smallest thing that works, which means the ladder has four rungs and the bottom one is not a model at all.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic code.&lt;/strong&gt; A regex, a lookup table, an if statement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small local model.&lt;/strong&gt; Runs on hardware you already own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A larger local model.&lt;/strong&gt; Needs a GPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A remote frontier model.&lt;/strong&gt; Someone else's hardware, metered.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most projects start at rung four and never ask about the others. This is a weekend spent asking.&lt;/p&gt;

&lt;p&gt;The hardware was a Raspberry Pi 4, 4 GB of RAM, heat sinks and a fan, a hundred and seventy dollars most of which I already owned. The models were &lt;code&gt;smollm2:360m&lt;/code&gt; and &lt;code&gt;qwen2.5:1.5b&lt;/code&gt; through Ollama. The test cases are ninety-five questions about a fictional small winery, so nothing can be looked up and every answer can be judged by reading. Sixteen of the fifty classification messages are  deliberately ambiguous, because real inboxes are mostly ambiguous.&lt;/p&gt;

&lt;p&gt;Four verdicts, assigned per workflow, to force a result you can act on instead of a number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unnecessary&lt;/strong&gt;: deterministic code already does this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sufficient&lt;/strong&gt;: a small local model does it well enough to run unattended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marginal&lt;/strong&gt;: usable with a person checking the output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Insufficient&lt;/strong&gt;: the error profile or the latency is not acceptable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one rule, which is the part I would insist on if you copy nothing else: &lt;strong&gt;I wrote predictions for every workflow and committed them before I installed Ollama.&lt;/strong&gt; Twenty-two of them, each marked confident, leaning, or coin flip. Once there is a timestamped expectation there is a defined moment when you are done, and no room to decide afterward that you knew all along. I will come back to how they scored, because it is not flattering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extraction: pulling an order number out of a message
&lt;/h2&gt;

&lt;p&gt;Find &lt;code&gt;ORD-&lt;/code&gt; followed by six digits, or report that there isn't one. Thirty cases: twenty-two with an identifier, five without, and three decoys. The decoys are a membership number, a UPS tracking number, and a phone number, all of which look like identifiers and none of which are order numbers.&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;regex&lt;/th&gt;
&lt;th&gt;qwen2.5:1.5b&lt;/th&gt;
&lt;th&gt;smollm2:360m&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Overall&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30/30&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;28/30&lt;/td&gt;
&lt;td&gt;8/30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declined correctly when absent (8)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decoys ignored (3)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invented an answer&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The regex won. Thirty out of thirty, in zero milliseconds, at zero watts above idle.&lt;/p&gt;

&lt;p&gt;That result is boring until you read the two the best model missed.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;ORD-999000: wrong address, please hold.&lt;/code&gt; it answered &lt;code&gt;999000&lt;/code&gt;, dropping the prefix. Annoying, recoverable, the kind of thing you fix with a post-processing step you should not need.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;ORD-111222 please refund&lt;/code&gt; it answered &lt;code&gt;NONE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the shortest and least decorated input in the whole set. Almost the entire message is the order number. And the model returned a confident, correctly formatted assertion that there is no order number present. In a pipeline, that is a refund request for an order that exists arriving at a system that has decided it does not.&lt;/p&gt;

&lt;p&gt;A regex cannot make that mistake. Not "rarely does" — cannot. The two errors separating 28 from 30 are both of a class that the deterministic baseline is structurally incapable of producing, which is a different and much stronger statement than a two-point accuracy gap.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 360M result needs two numbers, not one
&lt;/h3&gt;

&lt;p&gt;By the contract, &lt;code&gt;smollm2:360m&lt;/code&gt; scored 8 of 22 on the cases that contained an identifier. That reads like a model that cannot find things.&lt;/p&gt;

&lt;p&gt;It is not. All fourteen of its wrong answers are the correct six digits with &lt;code&gt;ORD-&lt;/code&gt; stripped. &lt;code&gt;ORD-556120&lt;/code&gt; came back as &lt;code&gt;556120&lt;/code&gt;, fourteen times out of fourteen.&lt;/p&gt;

&lt;p&gt;It located the right identifier in 22 of 22 and formatted it correctly in 8. Those are two different skills and the single score hides one of them. The model understood the task perfectly and could not obey the output contract, which is a completely different engineering problem than "the model is too small," and it has a completely different fix. (Constrained decoding. I have not tested it. It is the obvious follow-up and it is not in this post.)&lt;/p&gt;

&lt;p&gt;The decoys follow from the same gap. It handed back &lt;code&gt;MEM-443322&lt;/code&gt;, &lt;code&gt;1Z999AA10123456784&lt;/code&gt; and &lt;code&gt;503-555-0142&lt;/code&gt; verbatim, all three. It has no model of the format at all. It returns whatever in the message looks most like an identifier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which suggests a use for it that is not extraction
&lt;/h3&gt;

&lt;p&gt;A model with perfect recall and no precision is not a bad extractor. It is a candidate generator that has been asked to do the wrong job.&lt;/p&gt;

&lt;p&gt;Twenty-two of twenty-two located, eight of twenty-two formatted. Put that output through the regex as a validator rather than trusting it directly, and you get the model's recall with the pattern's precision. The model proposes anything that looks like an identifier, including format variants the pattern was never written for. The pattern rejects anything malformed.&lt;/p&gt;

&lt;p&gt;Crucially, neither half of that pipeline can produce the error the 1.5B model produced alone. The model cannot cause a false negative because it does not decide, and the regex cannot invent because it only matches.&lt;/p&gt;

&lt;p&gt;I have not tested that composition and it is not in these numbers. But it is the one place in the extraction result where a small model has a job worth giving it, and it is a job where being sloppy is free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict for extraction itself: Unnecessary.&lt;/strong&gt; Write the regex.&lt;/p&gt;

&lt;h2&gt;
  
  
  Classification: routing a message to one of six queues
&lt;/h2&gt;

&lt;p&gt;Appointment, billing, availability, complaint, question, escalate. Fifty messages. The instruction said to use &lt;code&gt;escalate&lt;/code&gt; when the message needs a person, including when you cannot tell what it is about.&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;qwen2.5:1.5b&lt;/th&gt;
&lt;th&gt;smollm2:360m&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Overall&lt;/td&gt;
&lt;td&gt;23/50 (46%)&lt;/td&gt;
&lt;td&gt;15/50 (30%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Labels used&lt;/td&gt;
&lt;td&gt;6 of 6&lt;/td&gt;
&lt;td&gt;3 of 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Most common label&lt;/td&gt;
&lt;td&gt;complaint, 38%&lt;/td&gt;
&lt;td&gt;escalate, &lt;strong&gt;86%&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong and not an escalation&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;26&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3 (meaningless)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 360M model answered &lt;code&gt;escalate&lt;/code&gt; to 43 of 50 messages, which is not classification, and there is nothing further to say about it here. The rest of this section is the 1.5B model.&lt;/p&gt;

&lt;p&gt;It is classifying, and it is not good at it. Forty-six percent, with twenty-six messages routed confidently to a wrong queue that was not the safe one.&lt;/p&gt;

&lt;p&gt;Read those twenty-six and the verdict writes itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Someone slipped on the steps by the barrel room and hurt their wrist →
&lt;strong&gt;complaint&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A formal notice regarding a trademark → &lt;strong&gt;complaint&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;"I have a severe sulfite allergy, what is actually in the wine" →
&lt;strong&gt;complaint&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A reporter asking about the 2024 harvest → &lt;strong&gt;question&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A distributor wanting to carry the wine in forty stores → &lt;strong&gt;question&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An injury report, a legal notice and an allergy question, all routed to the complaints queue, confidently, with no escalation. All five billing messages about money problems also went to complaints.&lt;/p&gt;

&lt;p&gt;Forty-six percent sounds like something you could work with. Twenty-six confident misroutes including an injury and a legal notice is not something you can work with.&lt;/p&gt;

&lt;h3&gt;
  
  
  And then I broke the score down by class
&lt;/h3&gt;

&lt;p&gt;This is the part I nearly missed, and it is the most useful thing in the experiment.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Label&lt;/th&gt;
&lt;th&gt;Correct&lt;/th&gt;
&lt;th&gt;Of&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;complaint&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;question&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;availability&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;appointment&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;billing&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;escalate&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two classes near perfect. Four poor. One at zero.&lt;/p&gt;

&lt;p&gt;The split is not difficulty, and it is not class size. It is what kind of label each one is.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;complaint&lt;/code&gt; and &lt;code&gt;question&lt;/code&gt; are &lt;strong&gt;topics&lt;/strong&gt;. You can tell what a message is from the words in it. Someone complaining sounds like someone complaining.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;escalate&lt;/code&gt; is a &lt;strong&gt;routing decision&lt;/strong&gt;. Nothing in the content of a message makes it an escalation. It is an escalation because of what should happen next. "I have a severe sulfite allergy, what is actually in the wine" and "what time do you close" are both questions about the business, with the same grammar and the same tone, and one of them is a liability event.&lt;/p&gt;

&lt;p&gt;The model did topics at 90 to 100 percent and routing decisions at zero. And &lt;code&gt;billing&lt;/code&gt; sat in the middle for exactly the same reason: a message about being charged twice &lt;em&gt;is&lt;/em&gt; a complaint, as text. The label is about which department owns the problem, which is not a property of the sentence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small models answer "what is this about." They do not answer "what should we do about it."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That reframes the five examples above. They are not five unlucky misroutings. All five are escalations, and escalate scored zero of twelve. The model was never going to find them, because finding them requires knowing a consequence that the text does not contain.&lt;/p&gt;

&lt;p&gt;Two caveats on how hard to lean on this. The classes hold between six and twelve cases each, so "six of six" is six cases and the individual numbers are thin. And this is one model. The pattern across all six classes is consistent enough that I believe the explanation, but I am offering it as the best account of the data rather than as a measurement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict: Insufficient.&lt;/strong&gt; And the actionable version: if one of your labels means "a human needs to see this," a small model will not find it. Build that one as a rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grounded QA: answering a question from four documents
&lt;/h2&gt;

&lt;p&gt;Fifteen questions against a 650-token corpus: club policy, tasting room, production notes, shipping. Eight are answerable from one passage, three require combining two, and four have no answer in the material at all.&lt;/p&gt;

&lt;p&gt;I read all thirty answers by hand against the corpus. That is the only way to score this, and I will say why in a moment.&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;qwen2.5:1.5b&lt;/th&gt;
&lt;th&gt;smollm2:360m&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single passage (8)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8/8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two passage (3)&lt;/td&gt;
&lt;td&gt;1/3&lt;/td&gt;
&lt;td&gt;0/3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Answerable total (11)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9/11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Correctly said "not in the material" (4)&lt;/td&gt;
&lt;td&gt;2/4&lt;/td&gt;
&lt;td&gt;0/4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three boundaries separate cleanly here, and they are the useful result.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retrieval works
&lt;/h3&gt;

&lt;p&gt;The 1.5B model answered all eight single-passage questions correctly. Six bottles in the Vineyard tier. Twenty dollars per person. September 9th. Thirty-four states. No dogs indoors. 21.8 Brix on the 12th. Twenty-five dollar restocking fee. Ten people on the barrel room tour.&lt;/p&gt;

&lt;p&gt;Asked what happens if you join the club and immediately want out, it returned the two sentences that answer the question and stopped:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cancellation requires one completed shipment. Members who cancel before receiving a shipment are charged for that shipment.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is competent lookup across four documents, on a $170 computer, with the wifi switched off. I did not expect it and it is the most encouraging thing in the weekend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Combination does not
&lt;/h3&gt;

&lt;p&gt;One club member brings five guests on a Saturday. Club policy waives the tasting fee for the member plus three guests. The tasting room charges twenty dollars a head. Six people, four waived, two paying: forty dollars.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both models answered twenty.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Neither could hold "plus three guests" against a party of six and subtract. The 1.5B model did get the second half right, correctly working out that a party of six is not a group &lt;em&gt;larger&lt;/em&gt; than six and therefore needs no reservation, which is the only piece of multi-document reasoning either model performed all weekend.&lt;/p&gt;

&lt;p&gt;The second two-passage question is the one I keep thinking about. Asked why someone would not find a 2024 rose and what they would be offered instead, the 1.5B model quoted the corpus exactly, and then:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;...the reference material states that "We did not bottle a rose in 2024. The fruit that would normally go to rose was needed to make volume on the estate Pinot Noir." Therefore, the 2024 rose would be replaced with the fruit that would typically go into the estate Pinot Noir production.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The quotation is verbatim and right. The next sentence reverses the only relationship in it. The fruit went &lt;em&gt;into&lt;/em&gt; the Pinot Noir, so what you would be offered is the Pinot Noir. You cannot be offered fruit.&lt;/p&gt;

&lt;p&gt;Correct retrieval and inverted inference, in consecutive sentences, with the evidence on screen between them.&lt;/p&gt;

&lt;p&gt;The 360M model's multi-document failures are worse than wrong. They are contaminated. It took the fourteen-days-notice rule, which governs skipping a club shipment, and applied it to booking a tasting room reservation. It reached into the shipping document to answer a cancellation question. Given more documents to search, it finds more wrong ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Knowing the answer is absent is the weakest link
&lt;/h3&gt;

&lt;p&gt;Four questions have no answer in the corpus, chosen to be things a reader would reasonably expect a winery to have written down: a wine's alcohol percentage, who the winemaker is, what a club tier costs, what yeast was used.&lt;/p&gt;

&lt;p&gt;The 1.5B model abstained on two and fabricated two. The 360M model fabricated all four.&lt;/p&gt;

&lt;p&gt;And the fabrications are a more dangerous species than invention. Asked for the alcohol percentage of the 2024 estate Pinot Noir, the 1.5B model answered:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The 2024 estate Pinot Noir had a Brix range of 23.1 to 24.6 at pick.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every component of that is true. Right wine, right vintage, right numbers, right document. Brix is sugar at harvest and has nothing to do with alcohol in the bottle. It reached for the nearest real fact instead of making one up, and produced something that reads as expertise while answering a question nobody asked. If you do not know what Brix is, that answer is indistinguishable from a correct one.&lt;/p&gt;

&lt;p&gt;Both models made the same mistake on what the Cellar tier costs: both answered "twelve bottles per quarter." Grounding gave them the right tier and they handed back the quantity in place of the price.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one answer the evidence did not change
&lt;/h3&gt;

&lt;p&gt;The 360M model, asked whether a dog can come inside the tasting room:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Yes, you can bring a dog inside the tasting room.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The corpus says the patio is dog friendly and the indoor room is not, service animals excepted. I have the same answer from a run where the model had no corpus at all, and it is identical byte for byte. Having the right answer directly in the prompt changed nothing.&lt;/p&gt;

&lt;p&gt;In a category where being wrong means a family drives out to a winery and gets turned around at the door.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict: Marginal.&lt;/strong&gt; Usable with a person reading the output. Not usable without one.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workflow&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;tok/s&lt;/th&gt;
&lt;th&gt;Prompt&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;th&gt;Median call&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;extraction&lt;/td&gt;
&lt;td&gt;qwen2.5:1.5b&lt;/td&gt;
&lt;td&gt;4.3&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;5.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;classification&lt;/td&gt;
&lt;td&gt;qwen2.5:1.5b&lt;/td&gt;
&lt;td&gt;7.5&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2.9 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QA&lt;/td&gt;
&lt;td&gt;qwen2.5:1.5b&lt;/td&gt;
&lt;td&gt;3.7&lt;/td&gt;
&lt;td&gt;708&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;6.9 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things in here surprised me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The compute tax does not track parameter count.&lt;/strong&gt; The 1.5B model is &lt;em&gt;slower&lt;/em&gt; than the 360M on extraction, 4.3 against 5.9 tokens per second, and &lt;em&gt;faster&lt;/em&gt; on classification, 7.5 against 6.2, because on classification it answers in two tokens where the smaller model takes six. Four times the parameters, and which one wins depends entirely on how much each one decides to say.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bigger prompt is cheap. A longer answer is what you pay for.&lt;/strong&gt; I have a clean comparison here by accident: an earlier QA run went out with 97-token prompts and the real one with 708. Seven times the prompt added fifteen percent to the wait before output started, because prompt evaluation parallelizes. The latency increase came from answers three times longer, plus a twenty-one percent drop in per-token generation speed from attending over a longer context. Median latency went from 3.6 to 6.9 seconds and the slowest single call took ninety-eight.&lt;/p&gt;

&lt;p&gt;If you are sizing a local system, that is the inversion to internalize. Stuffing more context in is affordable. Letting the model be chatty is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And check your thermals, which is not optional advice.&lt;/strong&gt; My first attempt at this ran with heat sinks and no fan. Thirty calls to a 360M model was enough to hit the Pi 4's soft temperature limit and start capping the clock. &lt;code&gt;vcgencmd get_throttled&lt;/code&gt; returned &lt;code&gt;0xe0000&lt;/code&gt;. I threw those latency numbers away.&lt;/p&gt;

&lt;p&gt;Nothing in Ollama's output, my scorer, or the result files said a word about it. Every number on screen was internally consistent and quietly describing a machine that was slowing down. The only way to find out was to ask the firmware a question I had to already suspect the answer to. Buy the fan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And it does run with the network off.&lt;/strong&gt; I switched the radio off, proved there was no route out, and ran one call from each workflow. All three worked. The claim that supports is narrow and worth stating carefully: this&lt;br&gt;
configuration, on this afternoon, ran with no route to the internet. It is not a claim that local models are private or that this setup is secure, and a Pi can be misconfigured as thoroughly as anything in a data center. But for the specific question of whether the inference needs the network, it does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  So: do you actually need an LLM?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pull a known-format identifier out of text&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Unnecessary&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route a message to one of six queues&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Insufficient&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer a question from your own documents&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Marginal&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three workflows that all sound like language tasks, three different answers, and not one of them is "yes, use a model."&lt;/p&gt;

&lt;p&gt;And the same line runs through all three. Extraction asks what this string is. Topic labels ask what this message is about. Single-passage lookup asks what the document says. Those went well, or well enough. Escalate asks what we should do, the fee question asks what the customer owes, and the unanswerable questions ask whether we know. Zero of twelve, both models wrong, and two of four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A small model will tell you what something is. It will not tell you what to do about it.&lt;/strong&gt; Every result here falls on one side or the other of that sentence, and I did not have it written down when I started.&lt;/p&gt;

&lt;p&gt;Which gives four properties worth checking before you reach for one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The answer is already present in the input.&lt;/strong&gt; Not inferable from it. Present in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is exactly one of it.&lt;/strong&gt; The moment two facts have to be combined, this stops working, and the arithmetic in that tasting fee question is the proof.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The output is short.&lt;/strong&gt; Longer answers are where the latency lives on this hardware, and where the fabrication lives everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Something other than the model decides whether the output is acceptable.&lt;/strong&gt; A regex, a schema, a person, an index that either returns rows or does not.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Take away any one of those four and the experiment says do not do it. The 46 percent and the zero of twelve are what it looks like when you ask a small model to be the thing that decides.&lt;/p&gt;

&lt;p&gt;And if you have all four, you may not need the model either. That is worth checking first, because it was true for one of my three workflows and I did not expect it to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I was wrong
&lt;/h2&gt;

&lt;p&gt;Twenty-two predictions, committed before I installed anything. Ten right, eleven wrong, one unresolvable.&lt;/p&gt;

&lt;p&gt;Two of my seven confident calls broke. I was sure the 360M model would get most of the easy extraction cases, and it got 8 of 22 by failing at format rather than at finding. I was sure it would produce invalid output often, and it did so once in fifty, then emitted the same &lt;em&gt;valid&lt;/em&gt; word forty-three times.&lt;/p&gt;

&lt;p&gt;But the number that bothers me is not in the confident column.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Confidence&lt;/th&gt;
&lt;th&gt;Right&lt;/th&gt;
&lt;th&gt;Wrong&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Confident&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaning&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coin flip&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;My hunches did worse than my coin flips.&lt;/strong&gt; Two of ten on the calls where I had a feeling but not much behind it, three of five on the ones I explicitly labeled as guesses.&lt;/p&gt;

&lt;p&gt;That ordering is backwards and it is the most useful thing on the sheet. On the coin flips I knew I was guessing, so I guessed cleanly. On the leaning calls I was pattern-matching against intuitions about model behavior that turned out not to describe these models at all, and the feeling of having a reason was the problem. If I had marked all twelve of those as coin flips I would have scored better.&lt;/p&gt;

&lt;p&gt;I will take that over a clean result. The honest version of this exercise is not "here are the numbers," it is "here is what I believed, here is what happened, and here is the gap."&lt;/p&gt;

&lt;p&gt;The gap, this time, was mostly in the hunches.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The harness that produced these numbers had five measurement flaws in it, none of which manufactured an entire finding out of a missing directory and nearly got published. That is a separate post.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Everything here is reproducible: fixtures, runner, scorer, the committed predictions and the run record are at&lt;br&gt;
&lt;a href="https://github.com/kenwalger/right-size-the-model" rel="noopener noreferrer"&gt;github.com/kenwalger/right-size-the-model&lt;/a&gt;.&lt;br&gt;
Fixture set v1. Read &lt;code&gt;LIMITATIONS.md&lt;/code&gt; before citing any of these numbers:&lt;br&gt;
two models, one board, one quantization, ninety-five cases. This is an illustration of a method, not a benchmark.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>raspberrypi</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>Data Types Across Programming Languages: What You Actually Need to Know</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Tue, 06 Oct 2026 13:11:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/data-types-across-programming-languages-what-you-actually-need-to-know-39eo</link>
      <guid>https://dev.to/kenwalger/data-types-across-programming-languages-what-you-actually-need-to-know-39eo</guid>
      <description>&lt;p&gt;Every introduction to programming eventually reaches data types.&lt;/p&gt;

&lt;p&gt;An integer is a whole number. A string contains text. A Boolean is true or false. A floating-point number represents numbers with fractional components.&lt;/p&gt;

&lt;p&gt;Then you learn another programming language and discover that apparently none of this was quite as simple as advertised.&lt;/p&gt;

&lt;p&gt;An integer in Python is not the same thing as an integer in C. JavaScript's &lt;code&gt;number&lt;/code&gt; covers values other languages separate. Rust would rather make absence explicit than let &lt;code&gt;null&lt;/code&gt; wander through a program. R has several different ways for something to be missing.&lt;/p&gt;

&lt;p&gt;The definitions are not wrong. They are incomplete. A data type does more than tell a computer how to store a value. It expresses what a language assumes you should be able to represent, which distinctions matter, and which mistakes should be caught before the program runs.&lt;/p&gt;

&lt;p&gt;So instead of memorizing another list of types, let's give several languages the same small collection of facts and see what happens.&lt;/p&gt;

&lt;p&gt;Meet Alex.&lt;/p&gt;

&lt;p&gt;Alex is about to have a complicated afternoon, mostly because of one missing middle name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deceptively simple data model
&lt;/h2&gt;

&lt;p&gt;Suppose our program needs to represent a customer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name = "Alex"
age = 42
discount = 0.15
middle_name = no value
favorite_numbers = 3, 7, 11
status = ACTIVE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing exotic. Text, an integer, a decimal value, an absent value, a collection, and a status chosen from a known set.&lt;/p&gt;

&lt;p&gt;If data types were merely different spellings for the same concepts, moving this model between languages would be syntax conversion.&lt;/p&gt;

&lt;p&gt;It isn't. And watch which field causes the most trouble.&lt;/p&gt;

&lt;h2&gt;
  
  
  Python: let the values tell us what they are
&lt;/h2&gt;

&lt;p&gt;Python makes our first version pleasantly straightforward.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Alex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
&lt;span class="n"&gt;discount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;
&lt;span class="n"&gt;middle_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="n"&gt;favorite_numbers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ACTIVE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At runtime those values have types such as &lt;code&gt;str&lt;/code&gt;, &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;float&lt;/code&gt;, &lt;code&gt;NoneType&lt;/code&gt;, and &lt;code&gt;list&lt;/code&gt;. We did not declare them before assigning because Python is dynamically typed.&lt;/p&gt;

&lt;p&gt;That makes Alex easy to get into the system. It also means constraints exist only if we deliberately add them.&lt;/p&gt;

&lt;p&gt;Nothing about &lt;code&gt;status = "ACTIVE"&lt;/code&gt; prevents a later &lt;code&gt;status = "ACTVE"&lt;/code&gt;. Python does not know our domain recognizes &lt;code&gt;ACTIVE&lt;/code&gt;, &lt;code&gt;SUSPENDED&lt;/code&gt;, and &lt;code&gt;DELETED&lt;/code&gt; and has never heard of &lt;code&gt;ACTVE&lt;/code&gt;. We can model that with an &lt;code&gt;Enum&lt;/code&gt;, type hints, validation, or classes, but the bare string does not carry the rule.&lt;/p&gt;

&lt;p&gt;Python's integers hold another surprise for anyone arriving from C or Java. A normal Python &lt;code&gt;int&lt;/code&gt; is not limited to a fixed 32-bit range.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;999999999999999999999999999999999999&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An absurd customer age, and a perfectly valid Python integer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What nothing looks like in Python&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;None&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The only nothing available&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Already, "integer" has stopped meaning one universal thing. And &lt;code&gt;None&lt;/code&gt; is a single, undifferentiated way of saying nothing is here, which will turn out to be a choice rather than an inevitability.&lt;/p&gt;

&lt;h2&gt;
  
  
  JavaScript: one &lt;code&gt;number&lt;/code&gt;, and two different nothings
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;discount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;middleName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;favoriteNumbers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ACTIVE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Similar enough, until we ask JavaScript about the values.&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// "number"&lt;/span&gt;
&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// "number"&lt;/span&gt;
&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;middleName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// "object"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, &lt;code&gt;typeof null&lt;/code&gt; returns &lt;code&gt;"object"&lt;/code&gt;, a famous historical artifact that cannot be fixed without breaking existing code.&lt;/p&gt;

&lt;p&gt;More important for our comparison, &lt;code&gt;42&lt;/code&gt; and &lt;code&gt;0.15&lt;/code&gt; share the ordinary type &lt;code&gt;number&lt;/code&gt;, based on IEEE 754 double-precision floating point. JavaScript also has &lt;code&gt;BigInt&lt;/code&gt; for integers beyond the safe range of &lt;code&gt;number&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Absence is where it gets interesting. An uninitialized variable is &lt;code&gt;undefined&lt;/code&gt;. A variable deliberately set to nothing is &lt;code&gt;null&lt;/code&gt;. The language provides two spellings for emptiness and then declines to tell you which one means what. In practice one tends to mean "nobody has assigned this yet" and the other "somebody decided this is empty," except when a codebase uses them the other way around, or interchangeably.&lt;/p&gt;

&lt;p&gt;Loose equality shows what the language really thinks. Zero, the empty string, &lt;code&gt;false&lt;/code&gt;, and an empty array all agree they are the same thing. The two values that actually mean nothing is here do not join in:&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="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// true&lt;/span&gt;

&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;null&lt;/code&gt; and &lt;code&gt;undefined&lt;/code&gt; are loosely equal to each other and to nothing else in the language. They form their own small island.&lt;/p&gt;

&lt;p&gt;So JavaScript does distinguish absence from emptiness, and the rule is arguably the right one: a missing thing is not a zero thing. The problem is where that rule lives. It sits inside an equality operator famous for being unpredictable, next to a &lt;code&gt;typeof&lt;/code&gt; that reports &lt;code&gt;null&lt;/code&gt; as &lt;code&gt;"object"&lt;/code&gt;. Alex Dorey's &lt;a href="https://dorey.github.io/JavaScript-Equality-Table/" rel="noopener noreferrer"&gt;JavaScript Equality Table&lt;/a&gt; renders every comparison as a grid, if you want the full picture and a certain amount of despair.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What nothing looks like in JavaScript&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;undefined&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Never assigned&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Deliberately empty, by convention&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A type system is already more than a vocabulary list.&lt;/p&gt;

&lt;h2&gt;
  
  
  C: representation becomes difficult to ignore
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdint.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="k"&gt;typedef&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SUSPENDED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DELETED&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;typedef&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int32_t&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;middle_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;favorite_numbers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Representation is visible almost everywhere. We chose an explicitly 32-bit signed integer for age, &lt;code&gt;double&lt;/code&gt; for the discount, an array of exactly three integers, pointers to characters for names, and an enumeration for status.&lt;/p&gt;

&lt;p&gt;A null pointer might represent an absent middle name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;middle_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But C does not attach our intended meaning to that pointer. &lt;code&gt;NULL&lt;/code&gt; here could mean Alex has no middle name, that we have not loaded it yet, that the allocation failed, or that somebody forgot to initialize the struct. The program establishes the convention and the program is responsible for respecting it. The compiler will not help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What nothing looks like in C&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;NULL&lt;/code&gt; pointer&lt;/td&gt;
&lt;td&gt;Whatever this program decided it means&lt;/td&gt;
&lt;td&gt;Convention, and only convention&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;C also forces us to care directly about ranges and representation. A fixed-width signed integer cannot grow forever. This is not C being gratuitously difficult. C was designed around a different relationship between programmer and machine, and its type system reflects that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Java: primitives, references, and the null question
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;ACTIVE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;SUSPENDED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;DELETED&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Customer&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;discount&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;middleName&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;favoriteNumbers&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;Status&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Java distinguishes primitive types such as &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;double&lt;/code&gt;, and &lt;code&gt;boolean&lt;/code&gt; from reference types such as &lt;code&gt;String&lt;/code&gt;, &lt;code&gt;Customer&lt;/code&gt;, and arrays.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;int&lt;/code&gt; cannot be &lt;code&gt;null&lt;/code&gt;. A &lt;code&gt;String&lt;/code&gt; can.&lt;/p&gt;

&lt;p&gt;That single asymmetry does a lot of work. It means absence is available for some of Alex's fields and structurally impossible for others, and the choice was made by whoever picked the field types rather than by anyone thinking about the domain. Unless we add conventions or tooling, &lt;code&gt;String middleName&lt;/code&gt; does not tell us whether null is an expected state or a bug waiting for the right execution path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What nothing looks like in Java&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An empty reference&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;(not available)&lt;/td&gt;
&lt;td&gt;Primitives cannot be absent at all&lt;/td&gt;
&lt;td&gt;The compiler, by refusing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Java also has wrapper classes such as &lt;code&gt;Integer&lt;/code&gt;, which means two things that sound like "an integer" behave differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;optionalAge&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of those can be absent. The other cannot. Learning the keyword is only the beginning; the useful question is what guarantees come with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rust: make some invalid states harder to represent
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Suspended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Deleted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Customer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;middle_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;favorite_numbers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at &lt;code&gt;middle_name: Option&amp;lt;String&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Instead of allowing a &lt;code&gt;String&lt;/code&gt; to secretly contain null, Rust represents the possibility of absence in the type itself. The value is either &lt;code&gt;Some(...)&lt;/code&gt; or &lt;code&gt;None&lt;/code&gt;, and code consuming it has to deal with that possibility before it can get at the string.&lt;/p&gt;

&lt;p&gt;This is the same information every other language has been carrying around implicitly, finally written down somewhere the compiler can read it. The other languages let you forget. Rust makes forgetting a compile error.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Status&lt;/code&gt; enum similarly makes the valid alternatives explicit. We cannot accidentally assign &lt;code&gt;ACTVE&lt;/code&gt;, because no such variant exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What nothing looks like in Rust&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;None&lt;/code&gt;, inside &lt;code&gt;Option&amp;lt;T&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;This value may legitimately be absent&lt;/td&gt;
&lt;td&gt;The compiler&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A type system can encode valid states, not merely storage categories. That does not make incorrect programs impossible. It moves some classes of error from runtime behaviour into representations the compiler can reject.&lt;/p&gt;

&lt;p&gt;The type system is participating in the design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go: useful zero values and deliberate simplicity
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Active&lt;/span&gt;    &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ACTIVE"&lt;/span&gt;
    &lt;span class="n"&gt;Suspended&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"SUSPENDED"&lt;/span&gt;
    &lt;span class="n"&gt;Deleted&lt;/span&gt;   &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"DELETED"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Customer&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;            &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Age&lt;/span&gt;             &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;Discount&lt;/span&gt;        &lt;span class="kt"&gt;float64&lt;/span&gt;
    &lt;span class="n"&gt;MiddleName&lt;/span&gt;      &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;FavoriteNumbers&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;          &lt;span class="n"&gt;Status&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One important Go idea appears before we populate Alex at all: types have zero values. The zero value of an &lt;code&gt;int&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt;, of a &lt;code&gt;bool&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, of a &lt;code&gt;string&lt;/code&gt; is &lt;code&gt;""&lt;/code&gt;. Pointers, slices, maps, functions, channels, and interfaces can be &lt;code&gt;nil&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That creates a question. If &lt;code&gt;Age&lt;/code&gt; contains &lt;code&gt;0&lt;/code&gt;, is Alex zero years old, or did nobody supply an age?&lt;/p&gt;

&lt;p&gt;The type cannot answer. If the application uses a zero value to stand for “not supplied,” that state becomes indistinguishable from a legitimately supplied zero. If the distinction matters, the model has to represent it deliberately, which is why MiddleName above is a &lt;em&gt;string rather than a string.&lt;br&gt;
**What nothing looks like in Go&lt;/em&gt;*&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;0&lt;/code&gt;, &lt;code&gt;""&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Zero/default value; cannot by itself tell us whether a domain value was supplied&lt;/td&gt;
&lt;td&gt;The model must distinguish this if it matters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nil&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Empty pointer, slice, map, channel, or interface&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Convenient defaults acquire meaning only when they enter a domain.&lt;/p&gt;
&lt;h2&gt;
  
  
  R: "missing" is not one simple state
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight r"&gt;&lt;code&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Alex"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;42L&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;discount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0.15&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;middle_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;NA_character_&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;favorite_numbers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;c&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;7L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;11L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACTIVE"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;R was designed around statistical computing, and its types and missing-value semantics reflect that.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NA&lt;/code&gt; represents a missing value, with typed forms such as &lt;code&gt;NA_integer_&lt;/code&gt; and &lt;code&gt;NA_character_&lt;/code&gt;. R also has &lt;code&gt;NULL&lt;/code&gt;, which is not another spelling of &lt;code&gt;NA&lt;/code&gt;, and numeric computation can produce &lt;code&gt;NaN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;These answer different questions. A missing observation in a dataset is not the same thing as the absence of an object. Neither is the same thing as a mathematically undefined result. R treats those as three distinct facts, because in statistics they are three distinct facts, and conflating them produces wrong answers rather than crashes.&lt;/p&gt;

&lt;p&gt;Consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight r"&gt;&lt;code&gt;&lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;c&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;NA&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;c&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;NA&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;na.rm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What nothing looks like in R&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A missing observation&lt;/td&gt;
&lt;td&gt;Functions ask, via &lt;code&gt;na.rm&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No object at all&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NaN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An undefined numeric result&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second call makes an explicit methodological choice to exclude missing observations. The language made you say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field that caused all the trouble
&lt;/h2&gt;

&lt;p&gt;Alex's missing middle name has now been through seven general-purpose languages. Stack those seven tables on top of each other and the third column tells the whole story.&lt;/p&gt;

&lt;p&gt;Python gave it &lt;code&gt;None&lt;/code&gt;, one undifferentiated nothing. JavaScript offered two and left the meaning to you. C gave a null pointer whose meaning lives entirely in a convention the compiler cannot see. Java made absence available for reference types and impossible for primitives, as a side effect of a decision about memory. Rust put it in the type signature and refused to let you ignore it. Go made it indistinguishable from a valid value unless you reach for a pointer. R split it into three concepts because its users needed three.&lt;/p&gt;

&lt;p&gt;Not one of those languages is wrong. They disagree about a prior question: how much does the programmer have to admit they do not know?&lt;/p&gt;

&lt;p&gt;Representations of absence carry semantics whether or not the programmer acknowledges them. The difference between these languages is who is required to do the acknowledging, and when.&lt;/p&gt;

&lt;p&gt;Which is why the missing middle name turned out to be more interesting than the integer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table gets weird quickly
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Typing&lt;/th&gt;
&lt;th&gt;Ordinary integers&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;42&lt;/code&gt; and &lt;code&gt;0.15&lt;/code&gt; same type?&lt;/th&gt;
&lt;th&gt;How absence is spelled&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;Dynamic&lt;/td&gt;
&lt;td&gt;Arbitrary size&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;code&gt;None&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;Dynamic&lt;/td&gt;
&lt;td&gt;Fixed (&lt;code&gt;BigInt&lt;/code&gt; for more)&lt;/td&gt;
&lt;td&gt;Yes, both &lt;code&gt;number&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;null&lt;/code&gt; and &lt;code&gt;undefined&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Static&lt;/td&gt;
&lt;td&gt;Fixed width, chosen&lt;/td&gt;
&lt;td&gt;Usually no&lt;/td&gt;
&lt;td&gt;Null pointer, by convention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;Static&lt;/td&gt;
&lt;td&gt;Fixed width&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;null&lt;/code&gt;, references only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;Static&lt;/td&gt;
&lt;td&gt;Fixed width&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Option&amp;lt;T&amp;gt;&lt;/code&gt;, in the type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;Static&lt;/td&gt;
&lt;td&gt;Fixed width&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Zero values, &lt;code&gt;nil&lt;/code&gt; for some types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R&lt;/td&gt;
&lt;td&gt;Dynamic&lt;/td&gt;
&lt;td&gt;Fixed width&lt;/td&gt;
&lt;td&gt;Usually distinct&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NA&lt;/code&gt;, &lt;code&gt;NULL&lt;/code&gt;, &lt;code&gt;NaN&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Closed sets of alternatives vary just as much. Rust, Java, and C have real enums, Python offers &lt;code&gt;Enum&lt;/code&gt; if you reach for it, Go builds them from a named type plus constants, JavaScript models them by hand, and R uses factors. Collections differ too: C, Java, Rust, and Go enforce an element type in the declaration, Python and JavaScript do not, and R's atomic vectors quietly coerce everything toward a common type.&lt;/p&gt;

&lt;p&gt;There is no universal translation saying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Python int = JavaScript number = C int = Java int = Rust i32 = Go int = R integer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those types overlap. They do not carry identical guarantees, ranges, operations, failure modes, or assumptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same name does not imply the same contract.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  If you were designing a language, what would you choose?
&lt;/h2&gt;

&lt;p&gt;This gets much more interesting when you reverse the problem.&lt;/p&gt;

&lt;p&gt;Suppose you are designing a language.&lt;/p&gt;

&lt;p&gt;You need an integer.&lt;/p&gt;

&lt;p&gt;Easy.&lt;/p&gt;

&lt;p&gt;Except now you have questions. Fixed widths, or one integer that grows until memory runs out? What happens on overflow? Should &lt;code&gt;3&lt;/code&gt; become &lt;code&gt;3.0&lt;/code&gt; on its own? Should &lt;code&gt;"42" + 1&lt;/code&gt; be legal, and if so, what should it mean?&lt;/p&gt;

&lt;p&gt;Then absence, which by now you can see is the hard one. Do you have &lt;code&gt;null&lt;/code&gt;? Can every reference be null, or do you require an explicit optional type? Do you distinguish a missing observation from a non-existent object? Whichever you choose, every program ever written in your language inherits that decision.&lt;/p&gt;

&lt;p&gt;Then collections, where you decide whether elements must share a type and whether values can be moved or consumed. Then domain state, where you decide whether a programmer can say a value is only ever &lt;code&gt;ACTIVE&lt;/code&gt;, &lt;code&gt;SUSPENDED&lt;/code&gt;, or &lt;code&gt;DELETED&lt;/code&gt;, and whether the compiler can prove every case was handled.&lt;/p&gt;

&lt;p&gt;Finally, timing. When should a bad type relationship surface: while parsing, during compilation, at runtime, or only when the offending path finally executes?&lt;/p&gt;

&lt;p&gt;There is no universally correct set of answers. There are consequences.&lt;/p&gt;

&lt;p&gt;Every choice makes some programs easier to express, some mistakes harder to make, some implementations simpler, some runtimes more complicated, and some programmers wonder why on Earth the language designer did that.&lt;/p&gt;

&lt;p&gt;Sometimes the answer is historical compatibility. Sometimes performance. Sometimes safety. Sometimes the problem domain.&lt;/p&gt;

&lt;p&gt;And sometimes the language designer has a very good explanation and would appreciate it if everyone stopped bringing up &lt;code&gt;typeof null&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One language where absence is not the question
&lt;/h2&gt;

&lt;p&gt;Everything above is a general-purpose language, and every one of them had to decide what nothing means. That is not a universal preoccupation. It is what happens when a language expects you to model a world.&lt;/p&gt;

&lt;p&gt;The e language, commonly associated with Cadence Specman, was designed for hardware verification. Verification code describes legal ranges of values, generates constrained test data, and explores systems whose state spaces are far too large to enumerate by hand. So its declarations carry constraints rather than storage categories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;struct customer {
    age : uint (bits: 8);
    keep age in [18..120];

    status : [ACTIVE, SUSPENDED, DELETED];
    keep soft status == ACTIVE;
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no absence card for e in this article, and that is the point. In ordinary application programming we ask what type of value this is. A verification environment asks what values are legal here, and how valid combinations should be generated so we can explore what the system does.&lt;/p&gt;

&lt;p&gt;If you look only at Python, JavaScript, Java, and C, it is easy to assume programming languages are all solving approximately the same problem with different punctuation.&lt;/p&gt;

&lt;p&gt;They aren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Types are compressed language philosophy
&lt;/h2&gt;

&lt;p&gt;At the beginner level, "a type tells us what kind of data a value contains" is perfectly serviceable. Eventually that runs out of road. A type can tell us what values exist, how they are stored, which operations are permitted, which conversions happen silently, what counts as absence, whether absence must be acknowledged, whether alternatives form a closed set, and in some languages how long a value lives and who owns it. "Integer, string, Boolean, float" is not the definition of a type system. It is the introductory vocabulary.&lt;/p&gt;

&lt;p&gt;Which is why learning the types in a new language beats memorizing a conversion table.&lt;/p&gt;

&lt;p&gt;Python's arbitrary-precision integers are a tradeoff. So is C asking you to care about width. Java's primitive and reference split quietly decides which of your fields are allowed to be empty. Go buys simplicity with zero values and pays for it with ambiguity at the domain boundary. R distinguishes forms of missingness because missing observations are a normal feature of statistical work, so its problem domain is visible in its types. A verification language making constrained generation first-class tells you what its designers expected programmers to spend their days doing.&lt;/p&gt;

&lt;p&gt;A language's types are partly a record of what its designers thought mattered, and partly a record of what they were willing to let you avoid thinking about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They are compressed language philosophy.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Alex survived
&lt;/h2&gt;

&lt;p&gt;After all of this, Alex is still 42, still has no recorded middle name, still likes 3, 7, and 11, and remains active. Nothing about the underlying person changed. But every language forced different decisions about how those facts became computation, and the fact that changed most was the one that was not there.&lt;/p&gt;

&lt;p&gt;Knowing that Python has &lt;code&gt;int&lt;/code&gt;, Java has &lt;code&gt;int&lt;/code&gt;, and Rust has &lt;code&gt;i32&lt;/code&gt; is useful when you are trying to get a program running. Understanding why they are not interchangeable is useful when you are trying to understand languages.&lt;/p&gt;

&lt;p&gt;Once you start asking what a language lets you represent, what it makes you acknowledge, and what it refuses to let you say, data types stop being the boring chapter before loops. They become one of the quickest ways to see what a language believes programming should be.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What is the strangest, most useful, or most frustrating type decision you've encountered in a programming language? More importantly, what problem do you think the language designer was trying to solve with it? Or, if you were designing your own language, which of these decisions would you make differently?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>rust</category>
      <category>computerscience</category>
    </item>
    <item>
      <title>Enterprise MCP Gateway to Govern MCP Tool Access</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Tue, 06 Oct 2026 04:57:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/enterprise-mcp-gateway-to-govern-mcp-tool-access-mam</link>
      <guid>https://dev.to/kenwalger/enterprise-mcp-gateway-to-govern-mcp-tool-access-mam</guid>
      <description>&lt;h2&gt;
  
  
  Model access controls what an AI system can know and generate. Tool access controls what it can do.
&lt;/h2&gt;

&lt;p&gt;The previous posts in The AI Control Plane have moved progressively closer to the point where policy becomes action. &lt;a href="https://dev.to/kenwalger/ai-governance-for-enterprise-ai-why-governance-comes-before-the-gateway-2hm1"&gt;Governance&lt;/a&gt; established who gets to decide. &lt;a href="https://dev.to/kenwalger/ai-access-control-for-enterprise-ai-turning-policy-into-runtime-enforcement-5bkk"&gt;Virtual keys&lt;/a&gt; turned those decisions into runtime policy. &lt;a href="https://dev.to/kenwalger/rbac-for-ai-governing-the-ai-control-plane-e59"&gt;RBAC and access profiles&lt;/a&gt; constrained who could change that policy. &lt;a href="https://dev.to/kenwalger/why-user-provisioning-matters-for-enterprise-ai-534m"&gt;User provisioning&lt;/a&gt; made those permissions follow people as they joined, moved, and left.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.getmaxim.ai/bifrost" rel="noopener noreferrer"&gt;Bifrost&lt;/a&gt; is the open-source AI gateway through which this series has been examining those control-plane concerns, with its implementation available in the &lt;a href="https://getmax.im/githubdevto" rel="noopener noreferrer"&gt;Bifrost GitHub repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;MCP tool access changes the stakes. The &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/getting-started/intro" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; defines tools as capabilities exposed by servers that clients can discover and invoke. A model allowed to answer a question can produce a bad answer. A model allowed to invoke a tool can change another system. It can create a ticket, query a customer database, update a record, trigger a deployment, send a message, or call whatever capability an MCP server exposes.&lt;/p&gt;

&lt;p&gt;The question is no longer only which model may receive a request. It is which actions that model can reach, under whose authority, and what happens when a request crosses from inference into execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Is MCP Tool Access Different From Model Access?
&lt;/h2&gt;

&lt;p&gt;Model governance controls destinations and consumption. Which providers are approved, which models a workload may use, how much it may spend, and what rate limits apply. Tool governance adds capability.&lt;/p&gt;

&lt;p&gt;Consider a customer support agent with access to three tools: &lt;code&gt;lookup_customer&lt;/code&gt;, &lt;code&gt;issue_refund&lt;/code&gt;, and &lt;code&gt;close_account&lt;/code&gt;. All three may live on the same MCP server, behind the same authentication, exposed through the same connection. They do not have the same consequences.&lt;/p&gt;

&lt;p&gt;That is why the useful unit of governance is not the server. A server is a deployment boundary. A tool is an action boundary, and the two rarely line up.&lt;/p&gt;

&lt;p&gt;Authentication does not close the gap either. Valid credentials for an MCP server prove a connection can be made; they say nothing about whether a given workload should reach every capability behind it. This is the same separation the virtual key post drew between holding a provider credential and being permitted to use a particular model, and it gets sharper when MCP authentication is per-user. A person may legitimately authenticate to an external service while the AI workload acting on their behalf should still receive a subset of what that service offers. The credential establishes identity at the external boundary. The control plane still has to govern capability at the tool boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Should MCP Authorization Policy Control?
&lt;/h2&gt;

&lt;p&gt;The simplest policy is an allow list, and it does more than it appears to, because &lt;a href="https://docs.getbifrost.ai/mcp/filtering" rel="noopener noreferrer"&gt;filtering&lt;/a&gt; constrains discovery as well as execution. A workload never needs to be shown capabilities it will not be permitted to invoke. Bifrost applies that filtering per request, per client, or per virtual key, which means tool access can attach to the same policy boundary that already governs provider access, model allow lists, budgets, and expiry.&lt;/p&gt;

&lt;p&gt;Enterprise authorization needs more structure than a growing collection of individual tool grants. &lt;strong&gt;Access profiles&lt;/strong&gt; provide a reusable policy layer for grouping permissions around a role or workload rather than configuring each identity independently. A support workload, for example, can inherit the provider, model, and MCP permissions appropriate to support without requiring those decisions to be recreated every time another user or key is provisioned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Virtual MCP servers&lt;/strong&gt; provide another boundary by controlling the capability surface presented to a workload. Rather than treating every tool exposed by every connected MCP server as one flat namespace, an organization can present a curated MCP surface appropriate to a particular application or class of users. Tool filtering can then narrow that surface further to the specific actions a workload is authorized to discover and invoke.&lt;/p&gt;

&lt;p&gt;Together, those layers answer different authorization questions. An access profile establishes the policy associated with an identity or workload. A virtual MCP server establishes the MCP capability surface available to it. Tool-level policy determines which actions within that surface are actually reachable.&lt;/p&gt;

&lt;p&gt;That distinction matters because enterprise policy gets nuanced quickly. A research agent may search documentation and retrieve customer information without modifying either. A support agent may open a case while refunds route through a separate path. An engineering agent may inspect deployment state while production mutation stays out of reach. Policy should track the consequence of the action rather than the convenience of the integration.&lt;/p&gt;

&lt;p&gt;Because those permissions live in the control plane rather than in twenty repositories, changing the decision does not require twenty releases. It also means refusal happens in the right place. If a workload is not permitted to invoke &lt;code&gt;issue_refund&lt;/code&gt;, the useful outcome is not that the refund service rejects the call later. It is that the refund service never sees it. The application receives a deterministic policy refusal instead of an unpredictable downstream error, and the external system is never asked to adjudicate an unauthorized attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changes When an Agent Can Execute Tools Automatically?
&lt;/h2&gt;

&lt;p&gt;There is a meaningful difference between a model suggesting a tool call and infrastructure executing one, and Bifrost's default preserves it. Tool calls returned by a model are suggestions, not actions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. POST /v1/chat/completions  
   → model returns tool call suggestions, not executed

2. Application reviews the proposed calls  
   → apply business rules, request human approval

3. POST /v1/mcp/tool/execute  
   → execute approved calls explicitly

4. POST /v1/chat/completions  
   → continue the conversation with tool results  
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step two is the pause. It is where an application can inspect a proposal, ask a human, apply additional logic, or refuse outright.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.getbifrost.ai/mcp/agent-mode" rel="noopener noreferrer"&gt;Agent mode&lt;/a&gt; removes it. Tools named in the &lt;code&gt;tools_to_auto_execute&lt;/code&gt; configuration run without waiting for approval, which is useful precisely because it eliminates friction and consequential for the same reason.&lt;/p&gt;

&lt;p&gt;So the authorization decision moves earlier. If no human will be present when the action is taken, the control plane has to establish beforehand which actions are acceptable. An allow list stops being a convenience for hiding irrelevant tools and becomes part of the safety boundary around autonomous execution. That does not mean every call needs an approval dialog. It means the absence of a human at execution time makes pre-established policy more important, not less.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Should Enterprises Think About MCP Tool Blast Radius?
&lt;/h2&gt;

&lt;p&gt;A workable way to classify tools is by what happens when the authorization turns out to be wrong.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Primary Consequence&lt;/th&gt;
&lt;th&gt;Governance Posture&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read&lt;/td&gt;
&lt;td&gt;Search documentation&lt;/td&gt;
&lt;td&gt;Information exposure&lt;/td&gt;
&lt;td&gt;Allow by role and data scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create&lt;/td&gt;
&lt;td&gt;Open support ticket&lt;/td&gt;
&lt;td&gt;New system state&lt;/td&gt;
&lt;td&gt;Restrict destination and ownership&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modify&lt;/td&gt;
&lt;td&gt;Update customer record&lt;/td&gt;
&lt;td&gt;Existing state changes&lt;/td&gt;
&lt;td&gt;Narrow scope and retain evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Financial&lt;/td&gt;
&lt;td&gt;Issue refund&lt;/td&gt;
&lt;td&gt;Monetary loss&lt;/td&gt;
&lt;td&gt;Explicit grant and tighter limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Destructive&lt;/td&gt;
&lt;td&gt;Delete account&lt;/td&gt;
&lt;td&gt;Irreversible or costly&lt;/td&gt;
&lt;td&gt;Exceptional access or added approval&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The categories matter less than the exercise. Two tools on the same server can sit three rows apart, which is the concrete reason server-level grants are too coarse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Do Individually Safe Tools Become Unsafe Together?
&lt;/h2&gt;

&lt;p&gt;Here is the part that no allow list solves.&lt;/p&gt;

&lt;p&gt;A workload permitted to read customer data and to send email has a different effective capability from one permitted to do either alone. An agent holding a database tool, a payment tool, and outbound messaging is not carrying three permissions. It is carrying a workflow, and nobody approved the workflow. Each grant was reviewed on its own and each was defensible on its own.&lt;/p&gt;

&lt;p&gt;This is an uncomfortable property of agentic systems: capability emerges from combinations of individually reasonable permissions, and the emergent capability is a property of the set rather than of any member of it. Filtering evaluates tools one at a time, because that is what filtering is. Reviewing grants one at a time will not surface it either, for the same reason.&lt;/p&gt;

&lt;p&gt;There is no product answer as no gateway-level enforcement exists today. What there is, for now, is a review practice: when adding a tool to an existing grant, ask what the new tool makes possible in combination with what is already there, rather than whether the new tool is acceptable by itself. That is a weaker control than enforcement, and it is worth naming as a gap rather than papering over it.&lt;/p&gt;

&lt;p&gt;A second assumption runs underneath everything above: that the MCP server itself is trustworthy, and the only open question is which of its tools a given workload should reach. That assumption deserves its own scrutiny, because a compromised or careless server can return content engineered to be read as instruction rather than as data. I have written separately about &lt;a href="https://www.kenwalger.com/blog/ai/mcp/mcp-containment-boundaries-ai-agents/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=maxim_ai_bifrost" rel="noopener noreferrer"&gt;classifying MCP servers by trust zone&lt;/a&gt;, which is the layer beneath the one this post governs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Authorized This Tool, and What Happens When They Leave?
&lt;/h2&gt;

&lt;p&gt;Filtering answers whether a call is permitted right now. It does not answer why.&lt;/p&gt;

&lt;p&gt;"The developer added the tool to the agent" describes implementation, not authority. The permission may have arrived through a team role, an access profile default, a temporary exception, or a direct assignment created during an incident at two in the morning. Those paths have different lifecycle behavior, which is where the &lt;a href="https://dev.to/kenwalger/why-user-provisioning-matters-for-enterprise-ai-534m"&gt;provisioning problem&lt;/a&gt; returns. A tool grant attached to an identity has to disappear when the authority behind it disappears. Deleting a user or key causes Bifrost to clean up the associated per-user credentials. Session-keyed credentials and upstream provider tokens, however, may have separate revocation lifecycles that administrators need to account for.  &lt;/p&gt;

&lt;p&gt;And a tool grant attached to a workload with no human identity needs an owner and a lifecycle of its own, because nothing will trigger one on its behalf.&lt;/p&gt;

&lt;p&gt;MCP governance is not a list of allowed tool names. It is the connection between capability and authority.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MCP Governance Belongs in the AI Control Plane
&lt;/h2&gt;

&lt;p&gt;This series has worked through increasingly specific forms of control: organizational governance, workload policy, administrative authority, identity lifecycle, and now capability. Tools are where those controls finally meet something able to change the world outside the AI stack.&lt;/p&gt;

&lt;p&gt;Which is why tool governance cannot stop at authentication, server access, or whether a model supports function calling. Enterprises have to decide which capabilities are visible, which are executable, which demand stronger authority, and how all of that changes when the people and workloads behind them change. Bifrost brings those decisions into the same control plane as model access, routing, and budgets, and because &lt;a href="https://getmax.im/githubdevto" rel="noopener noreferrer"&gt;it is open source&lt;/a&gt;, the &lt;a href="https://docs.getbifrost.ai/mcp/overview" rel="noopener noreferrer"&gt;MCP implementation&lt;/a&gt; can be inspected rather than taken on trust.&lt;/p&gt;

&lt;p&gt;The principle outlives any implementation. Model access governs what AI may ask. Tool access governs what AI may do.&lt;/p&gt;

&lt;p&gt;Five layers in, the remaining question is not which controls exist. It is what order to build them in, and what breaks when you get the order wrong.&lt;/p&gt;

&lt;p&gt;Bifrost brings these controls into the AI control plane, giving teams a central place to manage MCP authorization alongside the rest of their AI infrastructure. To see how that works in practice, &lt;a href="https://getmaxim.ai/bifrost/book-a-demo" rel="noopener noreferrer"&gt;book a Bifrost demo&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt; This article was commissioned by the &lt;a href="https://www.getmaxim.ai/bifrost" rel="sponsored nofollow noopener noreferrer"&gt;Bifrost team&lt;/a&gt;. Links to kenwalger.com and to the &lt;a href="https://sovereignplatform.dev?utm_source=devto&amp;amp;utm_medium=footer&amp;amp;utm_campaign=ai-control-plane" rel="noopener noreferrer"&gt;Sovereign Systems Specification&lt;/a&gt; point to my own work. The architectural perspective and conclusions expressed here are my own.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>security</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
